Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/app/__test__/mocks/localeMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// File used for jest moduleNameMapper - empty locale messages for tests
module.exports = {};
12 changes: 12 additions & 0 deletions client/app/__test__/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,15 @@ jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
unstable_usePrompt: jest.fn(),
}));

// Replace I18nProvider with a synchronous stub so tests using test-utils
// don't stall on async locale loading.
jest.mock('lib/components/wrappers/I18nProvider', () => {
const { IntlProvider } = require('react-intl');
const SyncI18nProvider = ({ children }) => (
<IntlProvider defaultLocale="en" locale="en" messages={{}}>
{children}
</IntlProvider>
);
return { __esModule: true, default: SyncI18nProvider };
});
8 changes: 7 additions & 1 deletion client/app/api/course/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GradebookData } from 'types/course/gradebook';
import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook';

import { APIResponse } from 'api/types';

Expand All @@ -12,4 +12,10 @@ export default class GradebookAPI extends BaseCourseAPI {
index(): APIResponse<GradebookData> {
return this.client.get(this.#urlPrefix);
}

updateWeights(
payload: UpdateWeightsPayload,
): APIResponse<UpdateWeightsPayload> {
return this.client.patch(`${this.#urlPrefix}/weights`, payload);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { fireEvent, render, screen, waitFor } from 'test-utils';

import * as operations from '../operations';
import ConfigureWeightsDialog from '../components/ConfigureWeightsDialog';

jest.spyOn(operations, 'updateGradebookWeights').mockReturnValue(async () => {});

const categories = [{ id: 1, title: 'Missions' }];
const tabs = [
{ id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 },
{ id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 },
];

const setup = (overrides = {}) =>
render(
<ConfigureWeightsDialog
open
onClose={jest.fn()}
categories={categories}
tabs={tabs}
{...overrides}
/>,
);

describe('<ConfigureWeightsDialog />', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders one input per tab grouped by category', () => {
setup();
expect(screen.getByText('Missions')).toBeInTheDocument();
expect(screen.getByLabelText('Assignments')).toHaveValue(50);
expect(screen.getByLabelText('Optional')).toHaveValue(50);
});

it('shows Total: 100% with no warning when sum = 100', () => {
setup();
expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument();
expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument();
});

it('shows warning when sum != 100', () => {
setup();
fireEvent.change(screen.getByLabelText('Optional'), { target: { value: '30' } });
expect(screen.getByText(/Total:\s*80%/)).toBeInTheDocument();
expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument();
});

it('shows inline error for >100', () => {
setup();
fireEvent.change(screen.getByLabelText('Assignments'), { target: { value: '101' } });
expect(screen.getByText(/must be at most 100/i)).toBeInTheDocument();
});

it('shows inline error for negative', () => {
setup();
fireEvent.change(screen.getByLabelText('Optional'), { target: { value: '-1' } });
expect(screen.getByText(/must be at least 0/i)).toBeInTheDocument();
});

it('Save dispatches updateGradebookWeights with current values', async () => {
setup();
fireEvent.change(screen.getByLabelText('Optional'), { target: { value: '40' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(operations.updateGradebookWeights).toHaveBeenCalledWith([
{ tabId: 10, weight: 50 },
{ tabId: 11, weight: 40 },
]);
});
});

it('Cancel does not dispatch', () => {
setup();
fireEvent.change(screen.getByLabelText('Optional'), { target: { value: '40' } });
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(operations.updateGradebookWeights).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ const populatedStateWithGamification = {
},
};

const populatedStateWithWeightedView = {
gradebook: {
...populatedState.gradebook,
weightedViewEnabled: true,
canManageWeights: false,
},
};

beforeEach(() => {
jest.clearAllMocks();
mockFetchGradebook.mockReturnValue((): Promise<void> => Promise.resolve());
Expand Down Expand Up @@ -143,4 +151,26 @@ describe('GradebookIndex', () => {
),
).toBeInTheDocument();
});

it('does not render view toggle when weightedViewEnabled is false', async () => {
render(<GradebookIndex />, { state: populatedState });
// Wait for loading to finish
await screen.findByRole('button', { name: /export/i });
expect(screen.queryByText(/by weight/i)).not.toBeInTheDocument();
});

it('renders view toggle when weightedViewEnabled is true', async () => {
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
expect(await screen.findByText(/all assessments/i)).toBeInTheDocument();
expect(await screen.findByText(/by weight/i)).toBeInTheDocument();
});

it('switches to By weight view on toggle click', async () => {
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
const byWeightButton = await screen.findByText(/by weight/i);
fireEvent.click(byWeightButton);
expect(
await screen.findByTestId('gradebook-weighted-table'),
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { fireEvent } from '@testing-library/react';
import { render, screen, within } from 'test-utils';

import type {
AssessmentData,
CategoryData,
StudentData,
SubmissionData,
TabData,
} from '../types';
import GradebookWeightedTable from '../components/GradebookWeightedTable';

// ---------------------------------------------------------------------------
// Minimal shared fixtures (used where a single tab/category is enough)
// ---------------------------------------------------------------------------
const makeCategory = (id: number, title: string): CategoryData => ({
id,
title,
});

const makeTab = (
id: number,
title: string,
categoryId: number,
gradebookWeight = 50,
): TabData => ({ id, title, categoryId, gradebookWeight });

const makeAssessment = (
id: number,
title: string,
tabId: number,
maxGrade: number,
): AssessmentData => ({ id, title, tabId, maxGrade });

const makeStudent = (id: number, name: string): StudentData => ({
id,
name,
email: `${name.toLowerCase()}@example.com`,
level: 1,
totalXp: 0,
});

const makeSub = (
studentId: number,
assessmentId: number,
grade: number | null,
): SubmissionData => ({ studentId, assessmentId, grade });

// Default render helper
interface RenderWeightedOptions {
categories?: CategoryData[];
tabs?: TabData[];
assessments?: AssessmentData[];
students?: StudentData[];
submissions?: SubmissionData[];
canManageWeights?: boolean;
courseTitle?: string;
}

const renderWeighted = (opts: RenderWeightedOptions = {}): ReturnType<typeof render> => {
const cats = opts.categories ?? [makeCategory(1, 'Cat A')];
const tabs = opts.tabs ?? [makeTab(10, 'Tab 1', 1, 100)];
const assessments = opts.assessments ?? [makeAssessment(100, 'Quiz 1', 10, 150)];
const students = opts.students ?? [makeStudent(1, 'Alice')];
const submissions = opts.submissions ?? [];
return render(
<GradebookWeightedTable
assessments={assessments}
canManageWeights={opts.canManageWeights ?? true}
categories={cats}
courseTitle={opts.courseTitle ?? 'Test Course'}
students={students}
submissions={submissions}
tabs={tabs}
/>,
);
};

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GradebookWeightedTable', () => {
// 1. Row 1: category cells with colSpan
it('renders category cells in row 1 with colSpan equal to number of tabs in that category', () => {
const cats = [makeCategory(1, 'Cat A'), makeCategory(2, 'Cat B')];
const tabs = [
makeTab(10, 'Tab 1', 1, 50),
makeTab(11, 'Tab 2', 1, 50),
makeTab(20, 'Tab 3', 2, 0),
];
const assessments = [
makeAssessment(100, 'Q1', 10, 10),
makeAssessment(101, 'Q2', 11, 10),
makeAssessment(102, 'Q3', 20, 10),
];
renderWeighted({ categories: cats, tabs, assessments });
const thead = document.querySelector('thead')!;
const rows = thead.querySelectorAll('tr');
const row1Cells = rows[0].querySelectorAll('th');
// We expect a cell for "Cat A" (colSpan=2) and a cell for "Cat B" (colSpan=1)
const catACell = Array.from(row1Cells).find(
(c) => c.textContent === 'Cat A',
);
const catBCell = Array.from(row1Cells).find(
(c) => c.textContent === 'Cat B',
);
expect(catACell).toBeTruthy();
expect(catBCell).toBeTruthy();
expect(catACell!.getAttribute('colspan') ?? catACell!.colSpan.toString()).toBe('2');
expect(catBCell!.getAttribute('colspan') ?? catBCell!.colSpan.toString()).toBe('1');
});

// 2. Row 2: tab title cells
it('renders tab title cells in row 2', () => {
renderWeighted({
tabs: [makeTab(10, 'Homework', 1, 60), makeTab(11, 'Exams', 1, 40)],
});
const thead = document.querySelector('thead')!;
const row2 = thead.querySelectorAll('tr')[1];
expect(within(row2 as HTMLElement).getByText('Homework')).toBeInTheDocument();
expect(within(row2 as HTMLElement).getByText('Exams')).toBeInTheDocument();
});

// 3. Weight subheader shows "X% of grade" per tab
it('shows weight subheader "X% of grade" for each tab in row 3', () => {
renderWeighted({
tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 70)],
});
expect(screen.getByText('30% of grade')).toBeInTheDocument();
expect(screen.getByText('70% of grade')).toBeInTheDocument();
});

// 4a. Total column shows "100% total" when sum = 100
it('shows "100% total" in total column header when weights sum to 100', () => {
renderWeighted({
tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 40)],
});
expect(screen.getByText('100% total')).toBeInTheDocument();
});

// 4b. Total column shows warning text when sum ≠ 100
it('shows a warning when weight sum ≠ 100 in total header', () => {
renderWeighted({
tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)],
});
// The sum is 60, so there should be a warning indicated in the header
expect(screen.getByText(/60%/)).toBeInTheDocument();
expect(screen.getByText(/does not sum to 100/i)).toBeInTheDocument();
});

// 5. Student subtotal shows computed percentage "86.67%"
it('renders student subtotal as computed percentage', () => {
// grade=130, maxGrade=150 → 130/150 = 0.86666... → 86.67%
renderWeighted({
tabs: [makeTab(10, 'Tab 1', 1, 100)],
assessments: [makeAssessment(100, 'Q1', 10, 150)],
students: [makeStudent(1, 'Alice')],
submissions: [makeSub(1, 100, 130)],
});
// Both subtotal and total show 86.67% (single tab with weight=100)
expect(screen.getAllByText('86.67%').length).toBeGreaterThanOrEqual(1);
});

// 6. Tab with no assessments → subtotal "—"
it('shows "—" for a tab with no assessments', () => {
renderWeighted({
tabs: [makeTab(10, 'Empty Tab', 1, 100)],
assessments: [], // no assessments in this tab
students: [makeStudent(1, 'Alice')],
submissions: [],
});
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
});

// 7. Student with no graded submissions in weighted tab → subtotal "—"
it('shows "—" when student has no graded submissions in a tab', () => {
renderWeighted({
tabs: [makeTab(10, 'Tab 1', 1, 100)],
assessments: [makeAssessment(100, 'Q1', 10, 10)],
students: [makeStudent(1, 'Alice')],
submissions: [], // no submissions at all
});
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
});

// 8. Treat Ungraded as 0 toggle changes numbers
it('changes subtotal values when "Treat Ungraded as 0" is toggled', () => {
renderWeighted({
tabs: [makeTab(10, 'Tab 1', 1, 100)],
assessments: [
makeAssessment(100, 'Q1', 10, 50),
makeAssessment(101, 'Q2', 10, 50),
],
students: [makeStudent(1, 'Alice')],
// Alice graded on Q1=40, Q2 ungraded
submissions: [makeSub(1, 100, 40)],
});

// Without toggle: only Q1 is graded → 40/50 = 80.00%
expect(screen.getAllByText('80.00%').length).toBeGreaterThanOrEqual(1);

// Toggle on "Treat Ungraded as 0"
const toggle = screen.getByRole('checkbox', { name: /treat ungraded as 0/i });
fireEvent.click(toggle);

// With toggle: 40/(50+50) = 40.00%
expect(screen.getAllByText('40.00%').length).toBeGreaterThanOrEqual(1);
});

// 9. All weights zero → empty-state banner visible
it('shows empty-state banner when all weights are 0', () => {
renderWeighted({
tabs: [makeTab(10, 'Tab 1', 1, 0), makeTab(11, 'Tab 2', 1, 0)],
});
expect(
screen.getByText(/no weights configured/i),
).toBeInTheDocument();
});

// 10. canManageWeights === false → no "Configure Weights" button
it('does not show Configure Weights button when canManageWeights is false', () => {
renderWeighted({ canManageWeights: false });
expect(
screen.queryByRole('button', { name: /configure weights/i }),
).not.toBeInTheDocument();
});

// 11. canManageWeights === true → "Configure Weights" button present
it('shows Configure Weights button when canManageWeights is true', () => {
renderWeighted({ canManageWeights: true });
expect(
screen.getByRole('button', { name: /configure weights/i }),
).toBeInTheDocument();
});
});
Loading