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
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,97 @@
import { fireEvent, render, screen, waitFor } from 'test-utils';

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

jest
.spyOn(operations, 'updateGradebookWeights')
.mockReturnValue(() => Promise.resolve());

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', async () => {
setup();
expect(await screen.findByText('Missions')).toBeInTheDocument();
expect(screen.getByLabelText('Assignments')).toHaveValue(50);
expect(screen.getByLabelText('Optional')).toHaveValue(50);
});

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

it('shows warning when sum != 100', async () => {
setup();
await screen.findByText('Missions');
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', async () => {
setup();
await screen.findByText('Missions');
fireEvent.change(screen.getByLabelText('Assignments'), {
target: { value: '101' },
});
expect(screen.getByText(/must be at most 100/i)).toBeInTheDocument();
});

it('shows inline error for negative', async () => {
setup();
await screen.findByText('Missions');
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();
await screen.findByText('Missions');
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', async () => {
setup();
await screen.findByText('Missions');
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 @@ -32,6 +32,8 @@ const emptyState = {
students: [],
submissions: [],
gamificationEnabled: false,
weightedViewEnabled: false,
canManageWeights: false,
},
};

Expand All @@ -43,6 +45,8 @@ const noStudentsState = {
students: [],
submissions: [],
gamificationEnabled: false,
weightedViewEnabled: false,
canManageWeights: false,
},
};

Expand All @@ -62,6 +66,8 @@ const populatedState = {
],
submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }],
gamificationEnabled: false,
weightedViewEnabled: false,
canManageWeights: false,
},
};

Expand Down Expand Up @@ -143,4 +149,44 @@ describe('GradebookIndex', () => {
),
).toBeInTheDocument();
});

it('does not render view toggle when weightedViewEnabled is false', async () => {
render(<GradebookIndex />, { state: populatedState });
await screen.findByText('Gradebook');
expect(
screen.queryByRole('button', { name: /by weight/i }),
).not.toBeInTheDocument();
});

it('renders view toggle when weightedViewEnabled is true', async () => {
render(<GradebookIndex />, {
state: {
gradebook: {
...populatedState.gradebook,
weightedViewEnabled: true,
canManageWeights: false,
},
},
});
expect(
await screen.findByRole('button', { name: /all assessments/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /by weight/i }),
).toBeInTheDocument();
});

it('switches to By weight view on toggle click', async () => {
render(<GradebookIndex />, {
state: {
gradebook: {
...populatedState.gradebook,
weightedViewEnabled: true,
canManageWeights: false,
},
},
});
fireEvent.click(await screen.findByRole('button', { name: /by weight/i }));
expect(screen.getByTestId('gradebook-weighted-table')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import userEvent from '@testing-library/user-event';
import { render, screen, within } from 'test-utils';

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

// Suppress MUI dialog rendering noise in jsdom
jest.mock('../components/ConfigureWeightsDialog', () => ({
__esModule: true,
default: () => null,
}));

const categories: CategoryData[] = [{ id: 1, title: 'Missions' }];
const tabs: TabData[] = [
{ id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 60 },
{ id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 40 },
];
const assessments: AssessmentData[] = [
{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 100 },
{ id: 101, title: 'Quiz 2', tabId: 11, maxGrade: 50 },
];
const students: StudentData[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', level: 3, totalXp: 150 },
];
const submissions: SubmissionData[] = [
{ studentId: 1, assessmentId: 100, grade: 80 },
{ studentId: 1, assessmentId: 101, grade: 40 },
];

const defaultProps = {
categories,
tabs,
assessments,
students,
submissions,
canManageWeights: true,
};

const renderTable = (props = {}) =>
render(<GradebookWeightedTable {...defaultProps} {...props} />);

describe('<GradebookWeightedTable />', () => {
it('renders category header in row 1', async () => {
renderTable();
expect(await screen.findByText('Missions')).toBeInTheDocument();
});

it('renders tab titles in row 2', async () => {
renderTable();
await screen.findByText('Missions');
expect(screen.getByText('Assignments')).toBeInTheDocument();
expect(screen.getByText('Optional')).toBeInTheDocument();
});

it('renders "X% of grade" subheaders in row 3', async () => {
renderTable();
await screen.findByText('Missions');
expect(screen.getByText('60% of grade')).toBeInTheDocument();
expect(screen.getByText('40% of grade')).toBeInTheDocument();
});

it('shows "100% total" in Total subheader when weights sum to 100', async () => {
renderTable();
await screen.findByText('Missions');
expect(screen.getByText('100% total')).toBeInTheDocument();
});

it('shows warning text in Total subheader when weights do not sum to 100', async () => {
const tabsUnbalanced: TabData[] = [
{ id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 60 },
{ id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 30 },
];
renderTable({ tabs: tabsUnbalanced });
await screen.findByText('Missions');
// subheader should show 90% total with a warning indicator
expect(screen.getByText(/90%\s*total/)).toBeInTheDocument();
});

it('computes and displays tab subtotals for a student', async () => {
renderTable();
await screen.findByText('Alice');
// tab 10: 80/100 = 80.00%, tab 11: 40/50 = 80.00% — both show the same value
const cells = screen.getAllByText('80.00%');
expect(cells.length).toBeGreaterThanOrEqual(2);
});

it('computes and displays weighted total for a student', async () => {
renderTable();
await screen.findByText('Alice');
// total = (60 * 0.8 + 40 * 0.8) / 100 = 0.8 = 80.00%
const allCells = screen.getAllByText('80.00%');
expect(allCells.length).toBeGreaterThanOrEqual(2);
});

it('shows — for student with no graded submissions in a tab', async () => {
renderTable({ submissions: [] });
await screen.findByText('Alice');
// No submissions → all dashes
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
});

it('recomputes when Treat Ungraded as 0 is toggled', async () => {
const user = userEvent.setup();
renderTable({
submissions: [{ studentId: 1, assessmentId: 100, grade: 80 }],
});
await screen.findByText('Alice');
// Before toggle: tab 11 ungraded → dash
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
// Toggle on
const toggle = screen.getByRole('checkbox', { name: /treat ungraded as 0/i });
await user.click(toggle);
// After toggle: tab 11 = 0/50 = 0.00%
expect(screen.getByText('0.00%')).toBeInTheDocument();
});

it('shows empty state banner when all weights are 0', async () => {
const zeroTabs: TabData[] = [
{ id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 0 },
];
renderTable({ tabs: zeroTabs });
await screen.findByText(/no tab weights configured/i);
});

it('shows Configure Weights button when canManageWeights = true', async () => {
renderTable({ canManageWeights: true });
await screen.findByText('Missions');
expect(
screen.getByRole('button', { name: /configure weights/i }),
).toBeInTheDocument();
});

it('hides Configure Weights button when canManageWeights = false', async () => {
renderTable({ canManageWeights: false });
await screen.findByText('Missions');
expect(
screen.queryByRole('button', { name: /configure weights/i }),
).not.toBeInTheDocument();
});
});
Loading