diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index 1c07b29cbc..06e12297e8 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -1,3 +1,4 @@ +import { useLocation } from 'react-router-dom'; import { fireEvent, render, screen, waitFor, within } from 'test-utils'; import toast from 'lib/hooks/toast'; @@ -5,6 +6,13 @@ import toast from 'lib/hooks/toast'; import fetchGradebook from '../operations'; import GradebookIndex from '../pages/GradebookIndex'; +// TestApp mounts a MemoryRouter, whose location lives in memory and never +// touches window.location. This spy surfaces the router's current search +// string into the DOM so tests can assert on URL changes. +const LocationSearch = (): JSX.Element => ( +
{useLocation().search}
+); + jest.mock('../../container/CourseLoader', () => ({ useCourseContext: (): { courseTitle: string; id: number } => ({ courseTitle: 'Test Course', @@ -24,6 +32,8 @@ jest.mock('../operations', () => ({ const mockFetchGradebook = fetchGradebook as jest.Mock; +const WEIGHTED_TABLE_TESTID = 'gradebook-weighted-table'; + const emptyState = { gradebook: { categories: [], @@ -99,6 +109,16 @@ const populatedState = { }, }; +const studentsNoAssessmentsState = { + gradebook: { + ...populatedState.gradebook, + categories: [], + tabs: [], + assessments: [], + submissions: [], + }, +}; + const populatedStateWithGamification = { gradebook: { ...populatedState.gradebook, @@ -139,6 +159,55 @@ const populatedStateManagerWeightedOn = { }, }; +const populatedStateExternalInRange = { + gradebook: { + ...populatedState.gradebook, + assessments: [ + { + id: 200, + title: 'External Midterm', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + floorAtZero: true, + }, + ], + submissions: [ + { studentId: 1, assessmentId: 200, submissionId: 200, grade: 90 }, // within [0,100] + ], + }, +}; + +const populatedStateWithOutOfRangeGrade = { + gradebook: { + ...populatedState.gradebook, + assessments: [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { + id: 200, + title: 'External Midterm', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + floorAtZero: true, + }, + ], + submissions: [ + { studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 }, + { studentId: 1, assessmentId: 200, submissionId: 200, grade: 110 }, // above max, capped + ], + }, +}; + +const populatedStateWithOutOfRangeGradeWeighted = { + gradebook: { + ...populatedStateWithOutOfRangeGrade.gradebook, + weightedViewEnabled: true, + }, +}; + beforeEach(() => { jest.clearAllMocks(); mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); @@ -162,18 +231,44 @@ describe('GradebookIndex', () => { expect(await screen.findByText('Gradebook')).toBeInTheDocument(); }); - it('shows empty students message when there are no students', async () => { - render(, { state: noStudentsState }); + it('shows the grade-link hint in the all-assessments view', async () => { + render(, { state: populatedState }); expect( - await screen.findByText('No students enrolled yet'), + await screen.findByText( + /Click any grade to open that submission and adjust the marks/i, + ), ).toBeInTheDocument(); }); - it('shows empty students message when both assessments and students are absent', async () => { + it('hides the grade-link hint in the weighted-total view', async () => { + render(, { state: populatedStateWithWeightedView }); + const byWeightButton = await screen.findByText(/weighted total/i); + fireEvent.click(byWeightButton); + await screen.findByTestId(WEIGHTED_TABLE_TESTID); + expect( + screen.queryByText( + /Click any grade to open that submission and adjust the marks/i, + ), + ).not.toBeInTheDocument(); + }); + + it('shows empty students message and renders no gradebook table when there are no students', async () => { render(, { state: emptyState }); expect( await screen.findByText('No students enrolled yet'), ).toBeInTheDocument(); + expect(screen.queryByTestId(WEIGHTED_TABLE_TESTID)).not.toBeInTheDocument(); + }); + + it('renders the gradebook table when there are students but no assessments', async () => { + render(, { state: studentsNoAssessmentsState }); + expect( + await screen.findByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect( + screen.queryByText('No students enrolled yet'), + ).not.toBeInTheDocument(); }); it('shows error toast when fetch fails', async () => { @@ -196,7 +291,7 @@ describe('GradebookIndex', () => { ).toBeInTheDocument(); }); - it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => { + it('shows grade-and-gamification hint in column picker after enabling a gamification column with no grade columns selected', async () => { render(, { state: populatedStateWithGamification }); fireEvent.click( await screen.findByRole('button', { name: /select columns/i }), @@ -224,12 +319,38 @@ describe('GradebookIndex', () => { expect(await screen.findByText(/weighted total/i)).toBeInTheDocument(); }); - it('switches to Weighted Total view on toggle click', async () => { - render(, { state: populatedStateWithWeightedView }); + it('switches to Weighted total view on toggle click and reflects it in the URL', async () => { + render( + <> + + + , + { state: populatedStateWithWeightedView }, + ); const byWeightButton = await screen.findByText(/weighted total/i); fireEvent.click(byWeightButton); expect( - await screen.findByTestId('gradebook-weighted-table'), + await screen.findByTestId(WEIGHTED_TABLE_TESTID), + ).toBeInTheDocument(); + expect(screen.getByTestId('location-search')).toHaveTextContent( + 'view=weighted', + ); + + fireEvent.click(await screen.findByText(/all assessments/i)); + await waitFor(() => + expect(screen.getByTestId('location-search')).not.toHaveTextContent( + 'view=weighted', + ), + ); + }); + + it('starts in Weighted total view when the URL requests it', async () => { + render(, { + state: populatedStateWithWeightedView, + at: ['/?view=weighted'], + }); + expect( + await screen.findByTestId(WEIGHTED_TABLE_TESTID), ).toBeInTheDocument(); }); @@ -239,7 +360,7 @@ describe('GradebookIndex', () => { }); const byWeightButton = await screen.findByText(/weighted total/i); fireEvent.click(byWeightButton); - await screen.findByTestId('gradebook-weighted-table'); + await screen.findByTestId(WEIGHTED_TABLE_TESTID); fireEvent.click( await screen.findByRole('button', { name: /select columns/i }), ); @@ -248,6 +369,75 @@ describe('GradebookIndex', () => { expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); }); + it('shows the manage button and not the old import/add buttons', async () => { + render(, { state: populatedStateManagerWeightedOff }); + expect( + await screen.findByRole('button', { + name: 'Manage external assessments', + }), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Import external assessments' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add external assessment' }), + ).not.toBeInTheDocument(); + }); + + it('shows the manage button in the weighted-total view for managers', async () => { + render(, { state: populatedStateManagerWeightedOn }); + const byWeightButton = await screen.findByText(/weighted total/i); + fireEvent.click(byWeightButton); + await screen.findByTestId(WEIGHTED_TABLE_TESTID); + expect( + screen.getByRole('button', { name: 'Manage external assessments' }), + ).toBeVisible(); + }); + + it('does not show the manage button to staff who cannot manage weights', async () => { + render(, { state: populatedState }); + await screen.findByRole('button', { name: /export/i }); // wait for load + expect( + screen.queryByRole('button', { name: 'Manage external assessments' }), + ).not.toBeInTheDocument(); + }); + + describe('out-of-range banner', () => { + it('shows the banner when there are out-of-range grades', async () => { + render(, { state: populatedStateWithOutOfRangeGrade }); + expect( + await screen.findByText(/outside their range/i), + ).toBeInTheDocument(); + }); + + it('shows the weighted-total wording when the weighted view is enabled', async () => { + render(, { + state: populatedStateWithOutOfRangeGradeWeighted, + }); + expect( + await screen.findByText( + /being capped or floored in the weighted total/i, + ), + ).toBeInTheDocument(); + }); + + it('does not show the banner when all grades are in range', async () => { + render(, { state: populatedStateExternalInRange }); + await screen.findByRole('button', { name: /export/i }); // wait for load + expect( + screen.queryByText(/outside their range/i), + ).not.toBeInTheDocument(); + }); + + it('does not show the banner when there are no students', async () => { + render(, { state: noStudentsState }); + await screen.findByText('No students enrolled yet'); + expect( + screen.queryByText(/outside their range/i), + ).not.toBeInTheDocument(); + }); + }); + describe('weighted-view discoverability hint', () => { it('shows the hint to managers when the weighted view is off', async () => { render(, { state: populatedStateManagerWeightedOff }); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx index b70451c63b..bc2be8c4ca 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -2,7 +2,12 @@ import userEvent from '@testing-library/user-event'; import { store as appStore } from 'store'; import { render, screen, waitFor, within } from 'test-utils'; -import GradebookTable from '../components/GradebookTable'; +import CourseAPI from 'api/course'; +import toast from 'lib/hooks/toast'; + +import GradebookTable, { + EXTERNAL_ASSESSMENT_BACKGROUND, +} from '../components/GradebookTable'; import type { AssessmentData, CategoryData, @@ -11,6 +16,13 @@ import type { TabData, } from '../types'; +jest.mock('api/course'); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn(), success: jest.fn() }, +})); + const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; const assessments: AssessmentData[] = [ @@ -82,10 +94,12 @@ const userState = { interface RenderOptions { gamificationEnabled?: boolean; + weightedViewEnabled?: boolean; } const renderTable = ({ gamificationEnabled = true, + weightedViewEnabled = false, }: RenderOptions = {}): void => { render( , { state: userState }, ); @@ -145,6 +160,7 @@ describe('GradebookTable', () => { students={students} submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -229,6 +245,33 @@ describe('GradebookTable', () => { expect(await screen.findByText('Max Marks')).toBeInTheDocument(); }); + it('leaves the Max Marks cell blank under non-assessment columns', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, email: true, 'asn-100': true }), + ); + renderTable(); + await screen.findByText('Max Marks'); + // The Max Marks (second header) row has a "/10" under Quiz 1 and the + // "Max Marks" label under name, but the email column's cell is empty. + // The Max Marks row lives in the TableHead, so its cells are + // (role columnheader), not /cell. + const maxMarksCell = screen.getByText('Max Marks').closest('th')!; + const maxMarksRow = maxMarksCell.closest('tr')!; + const cells = within(maxMarksRow as HTMLElement).getAllByRole( + 'columnheader', + ); + // checkbox spacer | name("Max Marks") | email("") | asn-100("/10") + expect(cells[2]).toHaveTextContent(''); + }); + + it('hides the Max Marks header row when all assessment columns are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable(); + await screen.findByText('Alice'); + expect(screen.queryByText('Max Marks')).not.toBeInTheDocument(); + }); + it('renders row selection checkboxes', async () => { renderTableWithAssessmentVisible(); await screen.findByText('Alice'); @@ -414,6 +457,36 @@ describe('GradebookTable', () => { }); }); + describe('no-data-columns hint', () => { + it('warns that export is student-info-only, mentioning gamification, when gamification is on', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ 'asn-100': false, level: false, totalXp: false }), + ); + renderTable({ gamificationEnabled: true }); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /select columns/i })); + expect( + await screen.findByText(/no grade or gamification columns selected/i), + ).toBeInTheDocument(); + }); + + it('warns that export is student-info-only, no gamification mention, when gamification is off', async () => { + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /select columns/i })); + expect( + await screen.findByText(/no grade columns selected/i), + ).toBeInTheDocument(); + expect( + screen.queryByText(/gamification columns selected/i), + ).not.toBeInTheDocument(); + }); + }); + describe('external ID column', () => { const studentsWithExtId: StudentData[] = [ { @@ -447,6 +520,7 @@ describe('GradebookTable', () => { students={studs} submissions={[]} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -520,6 +594,7 @@ describe('GradebookTable', () => { students={[students[1], students[0]]} submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -578,6 +653,7 @@ describe('GradebookTable', () => { students={[students[1], students[0]]} // Bob first in raw order submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -636,6 +712,7 @@ describe('GradebookTable', () => { students={[students[1], students[0]]} submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -685,6 +762,7 @@ describe('GradebookTable', () => { students={studs} submissions={subs} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -708,9 +786,7 @@ describe('GradebookTable', () => { const user = userEvent.setup(); renderGrades(mixedStudents, mixedSubmissions); await screen.findByText('Alice'); - // Grade columns sort desc-first, so click twice to reach ascending. - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // Ascending: Bob(3), Alice(8), then the two missing rows last. await waitFor(() => expectInOrder(['Bob', 'Alice'])); expectInOrder(['Alice', 'Carol']); @@ -721,7 +797,7 @@ describe('GradebookTable', () => { const user = userEvent.setup(); renderGrades(mixedStudents, mixedSubmissions); await screen.findByText('Alice'); - // Grade columns sort desc-first, so a single click yields descending. + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc // Descending: Alice(8), Bob(3), then the two missing rows still last. await waitFor(() => expectInOrder(['Alice', 'Bob'])); @@ -739,15 +815,550 @@ describe('GradebookTable', () => { ], ); await screen.findByText('Alice'); - // Grade columns sort desc-first, so click twice to reach ascending. - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // Numeric ascending: 9 (Alice) before 10 (Bob). Lexical would reverse this. await waitFor(() => expectInOrder(['Alice', 'Bob'])); }); }); }); + describe('assessment grade cell rendering', () => { + const renderGradeCells = (subs: SubmissionData[]): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + }; + + it('renders "—" when the student has no submission for an assessment', async () => { + renderGradeCells([]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).getByText('—'), + ).toBeInTheDocument(); + }); + + it('renders an empty cell (not "—") for a submission with a null grade', async () => { + // Alice: submission with null grade → empty; Bob: no submission → '—' + renderGradeCells([ + { submissionId: 1, studentId: 1, assessmentId: 100, grade: null }, + ]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).queryByText('—'), + ).not.toBeInTheDocument(); + const bobRow = screen.getByText('Bob').closest('tr')!; + expect(within(bobRow as HTMLElement).getByText('—')).toBeInTheDocument(); + }); + + it('renders the grade as a link to the submission when submissionId is present', async () => { + renderGradeCells([ + { submissionId: 42, studentId: 1, assessmentId: 100, grade: 7 }, + ]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).getByRole('link', { name: '7' }), + ).toBeInTheDocument(); + }); + + it('renders the grade as plain text (no link) when there is no submissionId', async () => { + renderGradeCells([{ studentId: 1, assessmentId: 100, grade: 7 }]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).getByText('7'), + ).toBeInTheDocument(); + expect( + within(aliceRow as HTMLElement).queryByRole('link', { name: '7' }), + ).not.toBeInTheDocument(); + }); + }); + + describe('external assessment columns', () => { + const externalAssessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ]; + const externalTabs: TabData[] = [ + { id: 10, title: 'Tab 1', categoryId: 1 }, + { id: 200, title: 'Midterm', categoryId: 2 }, + ]; + const externalCategories: CategoryData[] = [ + { id: 1, title: 'Cat A' }, + { id: 2, title: 'External Assessments' }, + ]; + + const renderWithExternal = (): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn--5': true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + }; + + it('renders the External badge in the external column header', async () => { + renderWithExternal(); + expect(await screen.findByText('External')).toBeVisible(); + }); + + it('tints the external assessment body cells with the external background', async () => { + renderWithExternal(); + const gradeCell = (await screen.findByText('30')).closest('td'); + expect(gradeCell).toHaveStyle({ + backgroundColor: EXTERNAL_ASSESSMENT_BACKGROUND, + }); + }); + + it('keeps the external column header the neutral grey (not the blue tint)', async () => { + renderWithExternal(); + const headerCell = (await screen.findByText('Midterm')).closest('th'); + // grey[100] — the same opaque neutral every other header cell uses, so the + // header reads as a header rather than a coloured band. + expect(headerCell).toHaveStyle({ backgroundColor: 'rgb(245, 245, 245)' }); + expect(headerCell).not.toHaveStyle({ + backgroundColor: EXTERNAL_ASSESSMENT_BACKGROUND, + }); + }); + + it('edits an external grade inline and persists optimistically', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: -5, grade: 45 }, + }); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => + expect(CourseAPI.gradebook.setExternalGrade).toHaveBeenCalledWith(5, { + studentId: 1, + grade: 45, + }), + ); + expect(await screen.findByText('45')).toBeVisible(); + }); + + it('rolls back the cell and keeps the old value when the API rejects', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockRejectedValue( + new Error('boom'), + ); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => expect(screen.getByText('30')).toBeVisible()); + }); + + it('names the student and assessment in the failure toast', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockRejectedValue( + new Error('boom'), + ); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('Midterm'), + ), + ); + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('Alice'), + ); + }); + + it('confirms a successful edit with a persistent toast showing the student, assessment, and old → new grade', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: -5, grade: 45 }, + }); + (toast.success as jest.Mock).mockClear(); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + const [message, options] = (toast.success as jest.Mock).mock.calls[0]; + expect(message).toEqual(expect.stringContaining('Midterm')); + expect(message).toEqual(expect.stringContaining('Alice')); + expect(message).toEqual(expect.stringContaining('30')); + expect(message).toEqual(expect.stringContaining('45')); + // Persistent (no auto-dismiss). The toast is the only in-session record of + // the overwritten value and exists to catch a row/column misclick, so it + // must wait for the user's attention rather than expire on a timer. + expect(options).toEqual(expect.objectContaining({ autoClose: false })); + }); + + it('does not toast a confirmation when the grade is unchanged', async () => { + (toast.success as jest.Mock).mockClear(); + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockClear(); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + screen.getByRole('textbox', { name: /midterm grade for alice/i }); + // Commit without changing the value. + await userEvent.tab(); + expect(CourseAPI.gradebook.setExternalGrade).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('keeps regular assessment cells read-only (no input on click)', async () => { + renderWithExternal(); + // Alice has no Quiz 1 submission → '—' is rendered (not an ExternalGradeCell). + // Clicking the '—' in the Quiz 1 column must NOT produce a textbox. + await screen.findByText('Midterm'); // wait for render + const dashCells = screen.getAllByText('—'); + // The first '—' in Alice's row is the Quiz 1 cell (no submission). + await userEvent.click(dashCells[0]); + expect( + screen.queryByRole('textbox', { name: /quiz 1/i }), + ).not.toBeInTheDocument(); + }); + + it('renders the external chip without a manage menu', async () => { + renderWithExternal(); + expect(await screen.findByText('External')).toBeVisible(); + expect( + screen.queryByRole('button', { name: /manage/i }), + ).not.toBeInTheDocument(); + }); + + it('shows an in-flight spinner while the grade save is pending', async () => { + let resolveSave: () => void = () => {}; + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolveSave = (): void => + resolve({ data: { studentId: 1, assessmentId: -5, grade: 45 } }); + }), + ); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + // Pending: spinner visible, optimistic value already shown. + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + resolveSave(); + // Resolved: spinner gone, value remains. + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), + ); + expect(screen.getByText('45')).toBeInTheDocument(); + }); + + it('caps an external grade at three integer digits (999.99 ceiling)', async () => { + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + // The fourth integer digit is dropped at entry — the DB column maxes at 999.99. + await userEvent.type(input, '1000'); + expect(input).toHaveValue('100'); + }); + + it('accepts up to two decimal places and rejects a third', async () => { + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + // A third decimal digit is rejected at entry — DB column is numeric(_, 2). + await userEvent.type(input, '12.345'); + expect(input).toHaveValue('12.34'); + }); + + it('does not call the API when the cell is committed without a change', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockClear(); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + // Blur without editing → commit() sees an unchanged value and returns early. + await userEvent.tab(); + await waitFor(() => expect(screen.getByText('30')).toBeVisible()); + expect(CourseAPI.gradebook.setExternalGrade).not.toHaveBeenCalled(); + }); + + const renderCapped = (grade: number): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn--5': true }), + ); + render( + , + { state: userState }, + ); + }; + + it('flags a grade above the maximum on a capped assessment', async () => { + renderCapped(60); + expect( + await screen.findByLabelText(/exceeds the maximum/i), + ).toBeInTheDocument(); + }); + + it('does not flag a grade within the maximum', async () => { + renderCapped(40); + await screen.findByText('40'); + expect( + screen.queryByLabelText(/exceeds the maximum/i), + ).not.toBeInTheDocument(); + }); + }); + + describe('ExternalGradeCell — negatives, buttons, tooltip copy', () => { + // External assessments use negative IDs on the frontend; the operation + // negates them before calling the API (so -(-901) = 901 is sent to the server). + const EXT_ASN_ID = -901; + const extAssessments: AssessmentData[] = [ + { + id: EXT_ASN_ID, + title: 'Midterms', + tabId: 300, + maxGrade: 100, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + ]; + const extTabs: TabData[] = [{ id: 300, title: 'Midterms', categoryId: 3 }]; + const extCategories: CategoryData[] = [ + { id: 3, title: 'External Assessments' }, + ]; + const aliceStudent: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: null, + level: 1, + totalXp: 0, + levelContribution: null, + }, + ]; + + beforeEach(() => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockClear(); + }); + + const renderForExternal = ({ + grade = null, + weightedViewEnabled = false, + }: { + grade?: number | null; + weightedViewEnabled?: boolean; + } = {}): ReturnType => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, [`asn-${EXT_ASN_ID}`]: true }), + ); + const subs = + grade !== null + ? [{ studentId: 1, assessmentId: EXT_ASN_ID, grade }] + : []; + return render( + , + { state: userState }, + ); + }; + + it('accepts a negative grade on entry and commits it', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: EXT_ASN_ID, grade: -5 }, + }); + renderForExternal({ grade: null }); + const cell = await screen.findByText('—'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterms grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '-5'); + await userEvent.keyboard('{Enter}'); + await waitFor(() => + expect(CourseAPI.gradebook.setExternalGrade).toHaveBeenCalledWith( + -EXT_ASN_ID, + { studentId: 1, grade: -5 }, + ), + ); + }); + + it('shows the below-zero icon when floored and grade < 0', async () => { + renderForExternal({ grade: -5, weightedViewEnabled: true }); + await screen.findByText('-5'); + expect(screen.getByLabelText(/below 0/i)).toBeInTheDocument(); + }); + + it('does not show the below-zero icon when the grade is non-negative', async () => { + renderForExternal({ grade: 5, weightedViewEnabled: true }); + await screen.findByText('5'); + expect(screen.queryByLabelText(/below 0/i)).not.toBeInTheDocument(); + }); + + it('revert button discards the edit without calling the API', async () => { + renderForExternal({ grade: 10 }); + const cell = await screen.findByText('10'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterms grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '20'); + // userEvent.pointer is used instead of userEvent.click so that the + // mousedown fires (and its preventDefault keeps input focus) before the + // click, mirroring real-browser behaviour and preventing the onBlur=commit + // from firing before the cancel handler runs. + const revertBtn = screen.getByRole('button', { name: /revert/i }); + await userEvent.pointer({ target: revertBtn, keys: '[MouseLeft]' }); + expect(CourseAPI.gradebook.setExternalGrade).not.toHaveBeenCalled(); + }); + + it('accept button commits the edit via the API', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: EXT_ASN_ID, grade: 20 }, + }); + renderForExternal({ grade: 10 }); + const cell = await screen.findByText('10'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterms grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '20'); + const acceptBtn = screen.getByRole('button', { name: /accept/i }); + await userEvent.click(acceptBtn); + await waitFor(() => + expect(CourseAPI.gradebook.setExternalGrade).toHaveBeenCalledWith( + -EXT_ASN_ID, + { studentId: 1, grade: 20 }, + ), + ); + }); + + it('over-max tooltip mentions the weighted total only when weighted view is on', async () => { + // Weighted: tooltip should mention "weighted total" + renderForExternal({ grade: 150, weightedViewEnabled: true }); + expect( + await screen.findByLabelText( + /contribution to the weighted total is capped/i, + ), + ).toBeInTheDocument(); + }); + + it('over-max tooltip does not mention weighted total when weighted view is off', async () => { + // Non-weighted: tooltip should just say "exceeds the maximum" without weighted mention + renderForExternal({ grade: 150, weightedViewEnabled: false }); + expect( + await screen.findByLabelText(/exceeds the maximum of 100/i), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(/weighted total/i), + ).not.toBeInTheDocument(); + }); + + it('below-zero tooltip mentions flooring in the weighted total when weighted view is on', async () => { + renderForExternal({ grade: -5, weightedViewEnabled: true }); + expect( + await screen.findByLabelText(/floored to 0 in the weighted total/i), + ).toBeInTheDocument(); + }); + + it('below-zero tooltip does not mention the weighted total when weighted view is off', async () => { + renderForExternal({ grade: -5, weightedViewEnabled: false }); + expect( + await screen.findByLabelText(/this grade is below 0\./i), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(/weighted total/i), + ).not.toBeInTheDocument(); + }); + }); + describe('cross-page selection', () => { it('export label reflects selection count across pages', async () => { const user = userEvent.setup(); @@ -770,6 +1381,7 @@ describe('GradebookTable', () => { students={makeStudents(101)} submissions={[]} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -797,4 +1409,36 @@ describe('GradebookTable', () => { ).toBeInTheDocument(); }); }); + + describe('external assessments', () => { + it('shows external assessment columns by default but keeps native ones hidden', async () => { + render( + , + { state: userState }, + ); + expect(await screen.findByText('Olympiad')).toBeInTheDocument(); + expect(screen.queryByText('Quiz 1')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/OutOfRangeAlert.test.tsx b/client/app/bundles/course/gradebook/__tests__/OutOfRangeAlert.test.tsx new file mode 100644 index 0000000000..3cfdec7c06 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/OutOfRangeAlert.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from 'test-utils'; + +import OutOfRangeAlert from '../components/OutOfRangeAlert'; + +describe('OutOfRangeAlert', () => { + it('renders nothing when there are no out-of-range grades', async () => { + render( + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('summarises counts, assessment names, and prompts review before export', async () => { + render( + , + ); + + expect( + await screen.findByText( + /3 grades in the external assessments External Quiz and Final Exam/i, + ), + ).toBeInTheDocument(); + + expect(screen.getByText(/review before exporting/i)).toBeInTheDocument(); + }); + + it('joins three or more assessment names with commas and a final "and"', async () => { + render( + , + ); + + expect( + await screen.findByText(/Quiz A, Quiz B and Quiz C/i), + ).toBeInTheDocument(); + }); + + it('uses singular grade/assessment wording and a bare name for one grade', async () => { + render( + , + ); + + expect( + await screen.findByText( + /1 grade in the external assessment External Quiz is outside their range/i, + ), + ).toBeInTheDocument(); + }); + + it('names the weighted-total capping/flooring when the weighted view is on', async () => { + render( + , + ); + + expect( + await screen.findByText(/being capped or floored in the weighted total/i), + ).toBeInTheDocument(); + }); + + it('omits the weighted-total clause when the weighted view is off', async () => { + render( + , + ); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect( + screen.queryByText(/being capped or floored in the weighted total/i), + ).not.toBeInTheDocument(); + }); + + it('does not mention the weighted total when weighting is off', async () => { + render( + , + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).not.toMatch(/weighted total/i); + expect(alert.textContent).not.toMatch(/capped or floored/i); + }); + + it('mentions the weighted total when weighting is on', async () => { + render( + , + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/weighted total/i); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx index 1ee1086468..f27ca38a5f 100644 --- a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx @@ -98,6 +98,7 @@ interface RenderWeightedOptions { gamificationEnabled?: boolean; courseMaxLevel?: number; levelContribution?: typeof defaultLevelContribution; + toolbarAction?: JSX.Element; } const renderWeighted = ( @@ -123,6 +124,7 @@ const renderWeighted = ( students={students} submissions={submissions} tabs={tabs} + toolbarAction={opts.toolbarAction} />, { state: userState }, ); @@ -199,6 +201,19 @@ describe('WeightedGradebookTable', () => { expect(screen.getByText('/70')).toBeInTheDocument(); }); + // 3b. Weight subheader switches to "{weight}% of grade" in percent mode + it('shows "{weight}% of grade" subheader for each non-excluded tab in percent mode', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 40)], + }); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + const thead = document.querySelector('thead')!; + const row3 = thead.querySelectorAll('tr')[2] as HTMLElement; + expect(within(row3).getByText('60% of grade')).toBeInTheDocument(); + expect(within(row3).getByText('40% of grade')).toBeInTheDocument(); + }); + // 4a. Total column shows "/100" when sum = 100 (points default) it('shows "/100" in total column header when weights sum to 100', () => { renderWeighted({ @@ -226,12 +241,14 @@ describe('WeightedGradebookTable', () => { expect(screen.queryByText(/99\.999/)).not.toBeInTheDocument(); }); - // 4b. Total column shows just "/N" on one line when sum ≠ 100 - it('shows "/N" when weight sum ≠ 100 in total header', () => { + // 4b. Total column shows just "/N" on one line when sum ≠ 100 — the explanatory + // sentence is no longer an inline second line (it lives in the tooltip instead). + it('shows "/N" with no inline warning line when weight sum ≠ 100 in total header', () => { renderWeighted({ tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], }); expect(screen.getByText('/60')).toBeInTheDocument(); + expect(screen.queryByText(/does not sum to 100/i)).not.toBeInTheDocument(); }); // 4c. Hovering the warning-coloured total reveals the full message via tooltip @@ -247,19 +264,19 @@ describe('WeightedGradebookTable', () => { ); }); - // 4b. Total column shows just "/N" on one line when sum ≠ 100 - it('shows "N% total" when sum ≠ 100 in total header', async () => { + // 4d. Percent mode, weights ≠ 100 → total header shows "{weight}% total" warning + it('total column header shows "{weight}% total" warning in percent mode when sum ≠ 100', async () => { const user = userEvent.setup(); renderWeighted({ - tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 20)], }); await user.click(screen.getByRole('radio', { name: /percentage/i })); const thead = document.querySelector('thead')!; const row3 = thead.querySelectorAll('tr')[2] as HTMLElement; - expect(within(row3).getByText('60% total')).toBeInTheDocument(); + expect(within(row3).getByText('80% total')).toBeInTheDocument(); }); - // 4d. Percent mode, weights = 100 → total header shows exact "100% total" + // 4e. Percent mode, weights = 100 → total header shows exact "100% total" it('total column header shows "100% total" in percent mode when sum = 100', async () => { const user = userEvent.setup(); renderWeighted({ @@ -353,7 +370,7 @@ describe('WeightedGradebookTable', () => { expect(screen.getAllByText('100.00').length).toBeGreaterThanOrEqual(2); }); - // 6a. Tab with no assessments → cell shows "—" + // 6. Tab with no assessments → cell shows "—" it('shows "—" for a tab with no assessments', () => { renderWeighted({ tabs: [makeTab(10, 'Empty Tab', 1, 100)], @@ -453,6 +470,20 @@ describe('WeightedGradebookTable', () => { expect(screen.getByText(/set your own/i)).toBeInTheDocument(); }); + // 10e. Showing default weights suppresses the projected-total policy hint + it('does not show the projected-total policy banner when default weights are in effect', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0), makeTab(11, 'Tab 2', 1, 0)], + assessments: [ + makeAssessment(100, 'Quiz 1', 10, 150), + makeAssessment(101, 'Quiz 2', 11, 100), + ], + }); + expect( + screen.queryByText(/projected totals count ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + }); + // 10a. Default split feeds the totals: two tabs → 50/100 each, summing to 100. it('applies an equal split (sums to 100) when no weights are configured', () => { renderWeighted({ @@ -487,6 +518,18 @@ describe('WeightedGradebookTable', () => { expect(screen.getByText(/no weights configured/i)).toBeInTheDocument(); }); + // 10f. Degenerate + canManageWeights=false → no-access copy + it('shows "No tab weights have been configured yet" when canManageWeights is false and no assessments', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0)], + assessments: [], + canManageWeights: false, + }); + expect( + screen.getByText(/no tab weights have been configured yet/i), + ).toBeInTheDocument(); + }); + // 10c. At least one non-zero weight → banner absent it('does not show empty-state banner when at least one tab has a non-zero weight', () => { renderWeighted({ @@ -559,16 +602,15 @@ describe('WeightedGradebookTable', () => { expect(screen.getByText('Alice')).toBeInTheDocument(); }); - // 14c. Typing an email filters student rows when Email column is visible + // 14c. Typing an email filters student rows (email column is searchable) it('filters student rows when typing an email in the search bar', async () => { const user = userEvent.setup(); - + // Search only matches visible columns ("search what you see"), and the + // Email column is hidden by default — surface it so the email is searchable. localStorage.setItem(WEIGHTED_STORAGE_KEY, JSON.stringify({ email: true })); - renderWeighted({ students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], }); - expect(screen.getByText('Alice')).toBeInTheDocument(); expect(screen.getByText('Bob')).toBeInTheDocument(); expect(screen.getByText(ALICE_EMAIL)).toBeInTheDocument(); @@ -578,7 +620,6 @@ describe('WeightedGradebookTable', () => { await waitFor(() => expect(screen.queryByText('Bob')).not.toBeInTheDocument(), ); - expect(screen.getByText('Alice')).toBeInTheDocument(); }); @@ -600,6 +641,39 @@ describe('WeightedGradebookTable', () => { expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(3); }); + describe('toolbar action slot', () => { + it('renders a provided toolbar action', () => { + renderWeighted({ + toolbarAction: , + }); + expect( + screen.getByRole('button', { name: 'Manage external' }), + ).toBeInTheDocument(); + }); + + it('places the toolbar action before the Select Columns button', () => { + renderWeighted({ + toolbarAction: , + }); + const action = screen.getByText('Manage external'); + const selectColumns = screen.getByRole('button', { + name: /select columns/i, + }); + expect( + // eslint-disable-next-line no-bitwise + action.compareDocumentPosition(selectColumns) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('renders no extra action when toolbarAction is omitted', () => { + renderWeighted(); + expect( + screen.queryByRole('button', { name: 'Manage external' }), + ).not.toBeInTheDocument(); + }); + }); + describe('column picker', () => { it('shows Select Columns and Export buttons', () => { renderWeighted(); @@ -681,19 +755,6 @@ describe('WeightedGradebookTable', () => { ); }); - it('lists Email in the picker dialog (no gamification columns)', async () => { - const user = userEvent.setup(); - renderWeighted(); - await user.click(screen.getByRole('button', { name: /select columns/i })); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('Email')).toBeInTheDocument(); - expect( - within(dialog).queryByText('Gamification'), - ).not.toBeInTheDocument(); - expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); - expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); - }); - it('puts the select-all checkbox in an indeterminate state on a partial selection', async () => { const user = userEvent.setup(); renderWeighted({ @@ -706,6 +767,19 @@ describe('WeightedGradebookTable', () => { expect(checkboxes[0]).toHaveAttribute('data-indeterminate', 'true'), ); }); + + it('lists Email in the picker dialog (no gamification columns)', async () => { + const user = userEvent.setup(); + renderWeighted(); + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Email')).toBeInTheDocument(); + expect( + within(dialog).queryByText('Gamification'), + ).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); + }); }); describe('truncation tooltips', () => { @@ -1039,6 +1113,27 @@ describe('WeightedGradebookTable', () => { ).toBeInTheDocument(); }); + it('rounds a fractional effective weightage to 2dp in the breakdown', async () => { + const user = userEvent.setup(); + // Tab weight 100 split equally across 3 assessments → 33.333…% → "33.33% of grade" + renderWeighted({ + tabs: [makeTab(10, 'Missions', 1, 100)], + assessments: [ + makeAssessment(1, 'M1', 10, 100), + makeAssessment(2, 'M2', 10, 100), + makeAssessment(3, 'M3', 10, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 1, 50)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + expect( + within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( + /33\.33% of grade/, + ), + ).toBeInTheDocument(); + }); + it('shows effective weightage as "% of grade" regardless of the points/percentage lens', async () => { const user = userEvent.setup(); renderWeighted(expandable); @@ -1134,30 +1229,9 @@ describe('WeightedGradebookTable', () => { ), ).toBeInTheDocument(); }); - - it('rounds a fractional effective weightage to 2dp in the breakdown', async () => { - const user = userEvent.setup(); - // Tab weight 100 split equally across 3 assessments → 33.333…% → "33.33% of grade" - renderWeighted({ - tabs: [makeTab(10, 'Missions', 1, 100)], - assessments: [ - makeAssessment(1, 'M1', 10, 100), - makeAssessment(2, 'M2', 10, 100), - makeAssessment(3, 'M3', 10, 100), - ], - students: [makeStudent(1, 'Alice')], - submissions: [makeSub(1, 1, 50)], - }); - await user.click(screen.getByRole('button', { name: /expand Alice/i })); - expect( - within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( - /33\.33% of grade/, - ), - ).toBeInTheDocument(); - }); }); - describe('display mode toggle — values', () => { + describe('display mode toggle - values', () => { // weight 100, one assessment max 100, grade 80 → subtotal 0.8 // points cell = 80 ; percent cell = 80% const singleTab = { @@ -1198,6 +1272,18 @@ describe('WeightedGradebookTable', () => { expect(screen.getAllByText('40%').length).toBeGreaterThanOrEqual(1), ); }); + }); + + describe('display mode toggle - control', () => { + it('renders Points and Percentage toggle buttons with Points pressed by default', () => { + renderWeighted(); + const points = screen.getByRole('radio', { name: /points/i }); + const percent = screen.getByRole('radio', { name: /percentage/i }); + expect(points).toBeInTheDocument(); + expect(percent).toBeInTheDocument(); + expect(points).toHaveAttribute('aria-checked', 'true'); + expect(percent).toHaveAttribute('aria-checked', 'false'); + }); it('shows the Points and Percentage explanatory tooltips on hover', async () => { renderWeighted(); @@ -1216,18 +1302,6 @@ describe('WeightedGradebookTable', () => { }); }); - describe('display mode toggle — control', () => { - it('renders Points and Percentage toggle buttons with Points pressed by default', () => { - renderWeighted(); - const points = screen.getByRole('radio', { name: /points/i }); - const percent = screen.getByRole('radio', { name: /percentage/i }); - expect(points).toBeInTheDocument(); - expect(percent).toBeInTheDocument(); - expect(points).toHaveAttribute('aria-checked', 'true'); - expect(percent).toHaveAttribute('aria-checked', 'false'); - }); - }); - describe('identity columns rendering', () => { it('hides Email and External ID by default', () => { renderWeighted(); @@ -1280,6 +1354,7 @@ describe('WeightedGradebookTable', () => { within(thead as HTMLElement).getByText(EXTERNAL_ID), ).toBeInTheDocument(); expect(screen.getByText('EXT-001')).toBeInTheDocument(); + // Bob has no external ID → his External ID cell renders empty, not "null". const bobRow = screen.getByText('Bob').closest('tr')!; expect(within(bobRow).queryByText('null')).not.toBeInTheDocument(); @@ -1686,6 +1761,124 @@ describe('WeightedGradebookTable', () => { ).not.toBeInTheDocument(); }); }); + + describe('external assessment regression', () => { + it('includes an external assessment in its tab subtotal and the projected total', () => { + // Regular tab (weight 50): Quiz id=100, max=10, Alice grade=8 + // subtotal = 8/10 = 0.8; cell = 0.8 × 50 = 40 + // External tab (weight 50): Midterm id=-5, max=50, Alice grade=25 + // subtotal = 25/50 = 0.5; cell = 0.5 × 50 = 25 + // Total = 40 + 25 = 65 + renderWeighted({ + categories: [ + makeCategory(1, 'Cat A'), + makeCategory(2, 'External Assessments'), + ], + tabs: [makeTab(10, 'Tab 1', 1, 50), makeTab(200, 'Midterm', 2, 50)], + assessments: [ + makeAssessment(100, 'Quiz 1', 10, 10), + { + id: -5, + title: 'Midterm', + tabId: 200, + maxGrade: 50, + external: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 8), makeSub(1, -5, 25)], + }); + + // External Assessments category and its tab must appear in the header + expect(screen.getByText('External Assessments')).toBeInTheDocument(); + expect(screen.getByText('Midterm')).toBeInTheDocument(); + + // External tab cell = 25 (integer); regular tab cell = 40; total = 65 + const cells = screen.getAllByRole('cell'); + const cellTexts = cells.map((c) => c.textContent?.trim()); + expect(cellTexts).toContain('40'); + expect(cellTexts).toContain('25'); + expect(cellTexts).toContain('65'); + }); + }); + + describe('clamped grade indicator in breakdown', () => { + it('marks a capped grade in the breakdown', async () => { + const user = userEvent.setup(); + // Render with an external assessment: maxGrade 100, capAtMaximum true, + // and a student grade of 150. Expand the student's breakdown row. + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + { + id: -5, + title: 'External Test', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, -5, 150)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // Raw grade shown in the breakdown row subtitle (e.g., "150/100") + expect(await screen.findByText(/150\/100/)).toBeInTheDocument(); + // Capped indicator visible with aria-label + expect(screen.getByLabelText('Capped at 100')).toBeInTheDocument(); + }); + + it('marks a floored grade in the breakdown', async () => { + const user = userEvent.setup(); + // Render with an external assessment: maxGrade 100, floorAtZero true, + // and a student grade of -10. Expand the student's breakdown row. + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + { + id: -6, + title: 'External Test', + tabId: 10, + maxGrade: 100, + external: true, + floorAtZero: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, -6, -10)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // Raw grade shown in the breakdown row subtitle (e.g., "-10/100") + expect(await screen.findByText(/-10\/100/)).toBeInTheDocument(); + // Floored indicator visible with aria-label + expect(screen.getByLabelText('Floored to 0')).toBeInTheDocument(); + }); + + it('shows the "Counts as" effective-value tooltip on the clamped indicator', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + { + id: -5, + title: 'External Test', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, -5, 150)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + await userEvent.hover(await screen.findByLabelText('Capped at 100')); + await waitFor(() => + expect(screen.getByText('Counts as 100')).toBeInTheDocument(), + ); + }); + }); }); describe('level contribution columns', () => { @@ -2138,3 +2331,91 @@ describe('level contribution columns', () => { expect(cells[2]).toHaveTextContent('0'); }); }); + +describe('external assessment clamp warnings', () => { + const makeExternal = ( + id: number, + title: string, + tabId: number, + maxGrade: number, + ): AssessmentData => ({ + id, + title, + tabId, + maxGrade, + external: true, + floorAtZero: true, + capAtMaximum: true, + }); + + const externalSetup = ( + grade: number, + ): Parameters[0] => ({ + categories: [makeCategory(1, 'External Assessments')], + tabs: [makeTab(10, 'Midterm', 1, 100)], + assessments: [makeExternal(100, 'Midterm', 10, 25)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, grade)], + }); + + it('flags the external tab subheader when a grade is capped', () => { + renderWeighted(externalSetup(28)); // 28 > max 25 + expect(screen.getByLabelText(/exceed the maximum/i)).toBeInTheDocument(); + }); + + it('flags the external tab subheader when a grade is floored', () => { + renderWeighted(externalSetup(-3)); + expect(screen.getByLabelText(/below 0/i)).toBeInTheDocument(); + }); + + it('does not flag the subheader when every grade is in range', () => { + renderWeighted(externalSetup(20)); + expect( + screen.queryByLabelText( + /exceed the maximum|below 0|outside the valid range/i, + ), + ).not.toBeInTheDocument(); + }); + + it('shows a warning icon on the offending student cell', () => { + renderWeighted(externalSetup(28)); + const row = screen.getByText('Alice').closest('tr')!; + expect(within(row).getByTestId('WarningAmberIcon')).toBeInTheDocument(); + }); + + it('shows the cap tooltip with raw and effective values on hover', async () => { + renderWeighted(externalSetup(28)); + const row = screen.getByText('Alice').closest('tr')!; + await userEvent.hover(within(row).getByTestId('WarningAmberIcon')); + expect( + await screen.findByText('Set to 25 because the grade was 28.00.'), + ).toBeInTheDocument(); + }); + + it('shows no cell warning when the grade is in range', () => { + renderWeighted(externalSetup(20)); + const row = screen.getByText('Alice').closest('tr')!; + expect( + within(row).queryByTestId('WarningAmberIcon'), + ).not.toBeInTheDocument(); + }); + + it('does not clamp or warn when the bound toggle is off', () => { + renderWeighted({ + categories: [makeCategory(1, 'External Assessments')], + tabs: [makeTab(10, 'Midterm', 1, 100)], + assessments: [ + { ...makeExternal(100, 'Midterm', 10, 25), capAtMaximum: false }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 28)], + }); + expect( + screen.queryByLabelText(/exceed the maximum/i), + ).not.toBeInTheDocument(); + const row = screen.getByText('Alice').closest('tr')!; + expect( + within(row).queryByTestId('WarningAmberIcon'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/outOfRange.test.ts b/client/app/bundles/course/gradebook/__tests__/outOfRange.test.ts new file mode 100644 index 0000000000..a0db01c59f --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/outOfRange.test.ts @@ -0,0 +1,137 @@ +import type { AssessmentData, SubmissionData } from 'types/course/gradebook'; + +import { externalClamp, outOfRangeSummary } from '../outOfRange'; + +const MIDTERMS = 'Midterms'; + +const ext = (over: Partial): AssessmentData => ({ + id: 1, + title: MIDTERMS, + tabId: -1, + maxGrade: 100, + external: true, + floorAtZero: true, + capAtMaximum: true, + ...over, +}); +const sub = (assessmentId: number, grade: number | null): SubmissionData => ({ + studentId: 1, + assessmentId, + grade, +}); + +describe('outOfRangeSummary', () => { + it('counts grades above max only when capped, below 0 only when floored', () => { + const assessments = [ + ext({ id: 1, capAtMaximum: true, floorAtZero: true }), + ext({ id: 2, capAtMaximum: false, floorAtZero: false, maxGrade: 50 }), + ]; + const submissions = [ + sub(1, 105), // counted (capped) + sub(1, -3), // counted (floored) + sub(2, 999), // NOT counted (cap off) + sub(2, -9), // NOT counted (floor off) + ]; + expect(outOfRangeSummary(assessments, submissions)).toEqual({ + gradeCount: 2, + assessmentNames: [MIDTERMS], + }); + }); + + it('counts each offending grade but de-dups assessment names by id', () => { + const assessments = [ + ext({ id: 1, title: 'Quiz A', maxGrade: 100 }), + ext({ id: 2, title: 'Quiz B', maxGrade: 100 }), + ]; + const submissions = [ + sub(1, 105), // over + sub(1, -1), // under (same assessment) + sub(2, 200), // over (different assessment) + ]; + expect(outOfRangeSummary(assessments, submissions)).toEqual({ + gradeCount: 3, + assessmentNames: ['Quiz A', 'Quiz B'], + }); + }); + + it('treats grade exactly at maxGrade as in range when capped', () => { + expect( + outOfRangeSummary([ext({ id: 1, maxGrade: 100 })], [sub(1, 100)]), + ).toEqual({ gradeCount: 0, assessmentNames: [] }); + }); + + it('treats grade exactly at 0 as in range when floored', () => { + expect( + outOfRangeSummary([ext({ id: 1, floorAtZero: true })], [sub(1, 0)]), + ).toEqual({ gradeCount: 0, assessmentNames: [] }); + }); + + it('ignores null grades and in-range grades', () => { + expect( + outOfRangeSummary([ext({ id: 1 })], [sub(1, null), sub(1, 50)]), + ).toEqual({ gradeCount: 0, assessmentNames: [] }); + }); + + it('ignores native (non-external) assessments', () => { + const native = ext({ id: 9, external: false }); + expect(outOfRangeSummary([native], [sub(9, 999)])).toEqual({ + gradeCount: 0, + assessmentNames: [], + }); + }); + + it('ignores submissions with no matching assessment', () => { + expect(outOfRangeSummary([ext({ id: 1 })], [sub(404, 999)])).toEqual({ + gradeCount: 0, + assessmentNames: [], + }); + }); +}); + +describe('externalClamp', () => { + it('records above/below per student and per assessment, respecting toggles', () => { + const assessments = [ + ext({ id: 1, capAtMaximum: true, floorAtZero: true, maxGrade: 100 }), + ext({ id: 2, capAtMaximum: false, floorAtZero: false, maxGrade: 50 }), + ]; + const submissions = [ + { studentId: 1, assessmentId: 1, grade: 105 }, // capped -> above + { studentId: 2, assessmentId: 1, grade: -3 }, // floored -> below + { studentId: 3, assessmentId: 2, grade: 999 }, // cap off -> ignored + ]; + const { byStudent, byAssessment } = externalClamp(assessments, submissions); + expect(byStudent.get('1:1')).toEqual({ + bound: 'above', + raw: 105, + max: 100, + }); + expect(byStudent.get('2:1')).toEqual({ bound: 'below', raw: -3, max: 100 }); + expect(byStudent.has('3:2')).toBe(false); + expect(byAssessment.get(1)).toEqual({ above: true, below: true }); + expect(byAssessment.has(2)).toBe(false); + }); + + it('treats a grade exactly at the bound as in range', () => { + const { byStudent } = externalClamp( + [ext({ id: 1, maxGrade: 100 })], + [ + { studentId: 1, assessmentId: 1, grade: 100 }, + { studentId: 2, assessmentId: 1, grade: 0 }, + ], + ); + expect(byStudent.size).toBe(0); + }); + + it('ignores null grades, in-range grades, and non-external assessments', () => { + const { byStudent, byAssessment } = externalClamp( + [ext({ id: 1, maxGrade: 100 }), ext({ id: 9, external: false })], + [ + { studentId: 1, assessmentId: 1, grade: null }, + { studentId: 1, assessmentId: 1, grade: 50 }, + { studentId: 2, assessmentId: 9, grade: 999 }, + ], + ); + expect(byStudent.size).toBe(0); + expect(byAssessment.size).toBe(0); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx index dc430d3a8d..205f148dc1 100644 --- a/client/app/bundles/course/gradebook/components/GradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -8,9 +8,14 @@ import { useState, } from 'react'; import { defineMessages } from 'react-intl'; +import Check from '@mui/icons-material/Check'; +import Close from '@mui/icons-material/Close'; +import InfoOutlined from '@mui/icons-material/InfoOutlined'; import { Checkbox, Chip, + CircularProgress, + IconButton, Paper, type SxProps, Table, @@ -83,6 +88,20 @@ const getColWidth = (id: string): number => const isLeftAligned = (id: string): boolean => id === 'name' || id === 'email' || id === 'externalId'; +// Resolves a column's stable key. TanStack columns identify by `id`, falling back +// to `of` for plain accessor columns; this single helper keeps that rule in one +// place instead of repeating the `??` across every column loop. +const colKey = (c: ColumnTemplate): string => + c.id ?? (c.of as string); + +// The two grey separator weights used across the table. `gridLine` is the hairline +// cell grid; `seamLine` is the heavier 1px edge that bounds the frozen header/column +// block (a full pixel survives sticky-scroll compositing where 0.5px can drop out). +const gridLine = (theme: Theme): string => + `0.5px solid ${theme.palette.grey[200]}`; +const seamLine = (theme: Theme): string => + `1px solid ${theme.palette.grey[200]}`; + const translations = defineMessages({ searchStudents: { id: 'course.gradebook.GradebookIndex.searchStudents', @@ -132,12 +151,39 @@ const translations = defineMessages({ }, gradeSaveError: { id: 'course.gradebook.GradebookTable.gradeSaveError', - defaultMessage: 'Could not save the grade. Please try again.', + defaultMessage: + 'Could not save the {title} grade for {name}. Please try again.', }, gradeSaved: { id: 'course.gradebook.GradebookTable.gradeSaved', defaultMessage: 'Grade saved. {title} · {name}: {oldGrade} → {newGrade}', }, + gradeExceedsMax: { + id: 'course.gradebook.GradebookTable.gradeExceedsMax', + defaultMessage: 'This grade exceeds the maximum of {max}.', + }, + gradeExceedsMaxWeighted: { + id: 'course.gradebook.GradebookTable.gradeExceedsMaxWeighted', + defaultMessage: + 'This grade exceeds the maximum of {max}; its contribution to the weighted total is capped at {max}.', + }, + gradeBelowZero: { + id: 'course.gradebook.GradebookTable.gradeBelowZero', + defaultMessage: 'This grade is below 0.', + }, + gradeBelowZeroWeighted: { + id: 'course.gradebook.GradebookTable.gradeBelowZeroWeighted', + defaultMessage: + 'This grade is below 0; it is floored to 0 in the weighted total.', + }, + acceptEdit: { + id: 'course.gradebook.GradebookTable.acceptEdit', + defaultMessage: 'Accept', + }, + revertEdit: { + id: 'course.gradebook.GradebookTable.revertEdit', + defaultMessage: 'Revert', + }, }); const HeaderLabel = forwardRef< @@ -207,16 +253,24 @@ HeaderLabel.displayName = 'HeaderLabel'; const ExternalGradeCell = ({ assessmentId, + capAtMaximum, + floorAtZero, + maxGrade, studentId, studentName, title, value, + weightedViewEnabled, }: { assessmentId: number; + capAtMaximum: boolean; + floorAtZero: boolean; + maxGrade: number; studentId: number; studentName: string; title: string; value: number | null | undefined; + weightedViewEnabled: boolean; }): JSX.Element => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -225,6 +279,7 @@ const ExternalGradeCell = ({ const [localValue, setLocalValue] = useState( value, ); + const [saving, setSaving] = useState(false); const commit = async (): Promise => { setEditing(false); @@ -234,6 +289,7 @@ const ExternalGradeCell = ({ if (next === (localValue ?? null)) return; const prev = localValue; setLocalValue(next); + setSaving(true); try { await dispatch(setExternalGrade(assessmentId, studentId, next)); // Persistent confirmation: external grades flow to exported finals, and the @@ -252,31 +308,89 @@ const ExternalGradeCell = ({ ); } catch { setLocalValue(prev); - toast.error(t(translations.gradeSaveError)); + toast.error(t(translations.gradeSaveError, { name: studentName, title })); + } finally { + setSaving(false); } }; + const cancel = (): void => setEditing(false); + + const exceedsMax = + capAtMaximum && localValue != null && localValue > maxGrade; + const belowZero = floorAtZero && localValue != null && localValue < 0; + + const exceedsMsg = weightedViewEnabled + ? translations.gradeExceedsMaxWeighted + : translations.gradeExceedsMax; + const belowMsg = weightedViewEnabled + ? translations.gradeBelowZeroWeighted + : translations.gradeBelowZero; + if (editing) { return ( - setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commit(); - if (e.key === 'Escape') setEditing(false); + + > + { + const next = e.target.value; + if (next === '' || /^-?\d{0,3}(\.\d{0,2})?$/.test(next)) + setText(next); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') cancel(); + }} + size="small" + sx={{ + width: 72, + '& .MuiInputBase-root': { + width: 72, + }, + }} + value={text} + variant="standard" + /> + {/* onMouseDown preventDefault keeps focus so the input's onBlur=commit + does not fire before onClick — critical for Revert (a blur-commit + would save the very edit we are discarding). */} + e.preventDefault()} + size="small" + sx={{ flexShrink: 0 }} + > + + + e.preventDefault()} + size="small" + sx={{ flexShrink: 0 }} + > + + + ); } @@ -287,10 +401,37 @@ const ExternalGradeCell = ({ setEditing(true); }} role="button" - style={{ cursor: 'pointer', display: 'inline-block', minWidth: 24 }} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + width: '100%', + height: '100%', + }} tabIndex={0} > - {localValue == null ? '—' : localValue} + {exceedsMax && ( + + + + )} + {belowZero && ( + + + + )} + {saving && } + {localValue == null ? '—' : localValue} ); }; @@ -315,6 +456,7 @@ interface GradebookTableProps { courseTitle: string; courseId: number; gamificationEnabled: boolean; + weightedViewEnabled: boolean; /** Optional action rendered in the toolbar, left of the column picker. */ toolbarAction?: JSX.Element; } @@ -328,6 +470,7 @@ const GradebookTable = ({ courseTitle, courseId, gamificationEnabled, + weightedViewEnabled, toolbarAction, }: GradebookTableProps): JSX.Element => { const { t } = useTranslation(); @@ -442,6 +585,7 @@ const GradebookTable = ({ sortable: true, sortProps: { undefinedPriority: 'last', + descFirst: false, sort: (a, b) => { const aGrade = a.grades[asn.id]; const bGrade = b.grades[asn.id]; @@ -454,10 +598,14 @@ const GradebookTable = ({ return ( ); } @@ -474,11 +622,17 @@ const GradebookTable = ({ return grade; }, csvDownloadable: true, - defaultVisible: false, + defaultVisible: asn.external ?? false, }); }); return cols; - }, [assessments, gamificationEnabled, hasExternalIds, t]); + }, [ + assessments, + gamificationEnabled, + hasExternalIds, + t, + weightedViewEnabled, + ]); const assessmentMaxGrades = useMemo( () => new Map(assessments.map((a) => [a.id, a.maxGrade])), @@ -583,9 +737,7 @@ const GradebookTable = ({ const visibility = toolbar?.getColumnVisibility?.() ?? {}; const isColVisible = (id: string): boolean => visibility[id] ?? true; - const visibleCols = columns.filter((c) => - isColVisible(c.id ?? (c.of as string)), - ); + const visibleCols = columns.filter((c) => isColVisible(colKey(c))); const sortByColId = new Map( (header?.headers ?? []).map( @@ -627,10 +779,7 @@ const GradebookTable = ({ const totalWidth = useMemo( () => CHECKBOX_WIDTH + - visibleCols.reduce((sum, c) => { - const id = c.id ?? (c.of as string); - return sum + getColWidth(id); - }, 0), + visibleCols.reduce((sum, c) => sum + getColWidth(colKey(c)), 0), [visibleCols], ); @@ -639,10 +788,7 @@ const GradebookTable = ({ const toggleAllRows = (): void => body.toggleAllFiltered?.(); const hasVisibleAssessments = useMemo( - () => - visibleCols.some( - (c) => parseAssessmentColumnId(c.id ?? (c.of as string)) !== null, - ), + () => visibleCols.some((c) => parseAssessmentColumnId(colKey(c)) !== null), [visibleCols], ); @@ -664,7 +810,7 @@ const GradebookTable = ({ () => new Map( visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); return [id, (f: boolean): void => onSingleLine(id, f)]; }), ), @@ -699,14 +845,14 @@ const GradebookTable = ({ border: 0, // Draws the cell grid without relying on collapsed borders. - borderBottom: `0.5px solid ${theme.palette.grey[200]}`, + borderBottom: gridLine(theme), }, })} > {visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); return ; })} @@ -732,7 +878,7 @@ const GradebookTable = ({ // compositing where 0.5px can drop and let the body show // through the row1/row2 seam. '&&': { - borderBottom: `1px solid ${theme.palette.grey[200]}`, + borderBottom: seamLine(theme), }, })} > @@ -744,13 +890,36 @@ const GradebookTable = ({ /> {visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); const label = typeof c.title === 'string' ? c.title : id; const isLeft = isLeftAligned(id); const fits = headerFits[id] ?? false; const sort = sortByColId.get(id); const isExternalCol = externalAssessmentColumnIds.has(id); - const labelNode = ( + // Wrap a header label in the clickable sort control (or leave + // it bare when the column isn't sortable). `extraSx` lets the + // external variant flip the sort arrow to the label's left. + const withSort = ( + inner: JSX.Element, + extraSx?: Record, + ): JSX.Element => + sort ? ( + + {inner} + + ) : ( + inner + ); + const sortedLabel = withSort( - + , ); - const sortedLabel = sort ? ( - - {labelNode} - - ) : ( - labelNode + const externalSortedLabel = withSort( + + + {label} + + , + { + display: 'inline-flex', + flexDirection: 'row-reverse', + justifyContent: 'flex-start', + }, ); return ( {isExternalCol ? ( - {sortedLabel} + {externalSortedLabel} ) : ( @@ -824,8 +1013,8 @@ const GradebookTable = ({ // body never shows through on scroll. `& .MuiTableCell-root` // (0,2,0) outranks the table's `& th` rule (0,1,1). '& .MuiTableCell-root': { - borderTop: `1px solid ${theme.palette.grey[200]}`, - borderBottom: `1px solid ${theme.palette.grey[200]}`, + borderTop: seamLine(theme), + borderBottom: seamLine(theme), }, })} > @@ -843,7 +1032,7 @@ const GradebookTable = ({ }} /> {visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); const asnId = parseAssessmentColumnId(id); let cellNode: React.ReactNode = ''; if (id === 'name') cellNode = t(translations.maxMarks); @@ -863,8 +1052,8 @@ const GradebookTable = ({ zIndex: 3, // Continue the frozen region's right edge. '&&': { - borderTop: `1px solid ${theme.palette.grey[200]}`, - borderRight: `1px solid ${theme.palette.grey[200]}`, + borderTop: seamLine(theme), + borderRight: seamLine(theme), }, }), })} @@ -903,10 +1092,7 @@ const GradebookTable = ({ // layer and always paints. Row 0's top edge is already // the header cell's (higher z-index) bottom border. borderBottom: 'none', - borderTop: - idx === 0 - ? undefined - : `0.5px solid ${theme.palette.grey[200]}`, + borderTop: idx === 0 ? undefined : gridLine(theme), })} > cell.column.id !== 'rowSelector') .map((cell) => { + const isExternalCol = externalAssessmentColumnIds.has( + cell.column.id, + ); // Sticky cover for the frozen `name` column, mirroring // the checkbox cell above. Declared as a directly-typed // const so the callback is contextually typed (a ternary @@ -932,22 +1121,17 @@ const GradebookTable = ({ // the separator as the lower row's `borderTop`, not a // covered `borderBottom`. borderBottom: 'none', - borderTop: - idx === 0 - ? undefined - : `0.5px solid ${theme.palette.grey[200]}`, + borderTop: idx === 0 ? undefined : gridLine(theme), // Continue the frozen region's right edge down the data // rows. `&&` (0,2,0) beats the table's `& td` border // rule (0,1,1). '&&': { - borderRight: `1px solid ${theme.palette.grey[200]}`, + borderRight: seamLine(theme), }, }); let cellSx: SxProps | undefined; if (cell.column.id === 'name') cellSx = nameCellSx; - else if ( - externalAssessmentColumnIds.has(cell.column.id) - ) + else if (isExternalCol) // bgcolor is the faint external-column tint. cellSx = { bgcolor: EXTERNAL_ASSESSMENT_BACKGROUND, diff --git a/client/app/bundles/course/gradebook/components/OutOfRangeAlert.tsx b/client/app/bundles/course/gradebook/components/OutOfRangeAlert.tsx new file mode 100644 index 0000000000..1b598f6727 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/OutOfRangeAlert.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + warning: { + id: 'course.gradebook.OutOfRangeAlert.warning', + defaultMessage: + '{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range. Review before exporting.', + }, + warningWeighted: { + id: 'course.gradebook.OutOfRangeAlert.warningWeighted', + defaultMessage: + '{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range and {gradeCount, plural, one {is} other {are}} being capped or floored in the weighted total. Review before exporting.', + }, +}); + +interface Props { + gradeCount: number; + assessmentNames: string[]; + weightedViewEnabled: boolean; +} + +const formatAssessmentNames = (names: string[]): string => { + if (names.length === 0) return ''; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +}; + +const OutOfRangeAlert: FC = ({ + gradeCount, + assessmentNames, + weightedViewEnabled, +}) => { + const { t } = useTranslation(); + if (gradeCount === 0) return null; + if (weightedViewEnabled) { + return ( + + {t(translations.warningWeighted, { + gradeCount, + assessmentCount: assessmentNames.length, + assessmentNames: formatAssessmentNames(assessmentNames), + })} + + ); + } + return ( + + {t(translations.warning, { + gradeCount, + assessmentCount: assessmentNames.length, + assessmentNames: formatAssessmentNames(assessmentNames), + })} + + ); +}; + +export default OutOfRangeAlert; diff --git a/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx index 8a35678992..3911d27268 100644 --- a/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx @@ -56,13 +56,14 @@ import { computeStudentBreakdown, computeWeightedRows, customTabImbalanced, - gradeRatio, + effectiveGrade, LEVEL_TAB_ID, levelOffenders, resolveTabWeights, usingDefaultWeights, } from '../computeWeighted'; import { parseFormula } from '../levelFormula'; +import { externalClamp } from '../outOfRange'; import ConfigureWeightsPrompt from './ConfigureWeightsPrompt'; import ProjectedTotalHint, { @@ -70,6 +71,8 @@ import ProjectedTotalHint, { } from './ProjectedTotalHint'; import WeightedGradebookColumnTree from './WeightedGradebookColumnTree'; +const INLINE_FLEX = 'inline-flex'; + const translations = defineMessages({ configureWeights: { id: 'course.gradebook.WeightedGradebookTable.configureWeights', @@ -184,6 +187,18 @@ const translations = defineMessages({ id: 'course.gradebook.WeightedGradebookTable.weightedTotal', defaultMessage: 'Weighted Total', }, + gradeCapped: { + id: 'course.gradebook.WeightedGradebookTable.gradeCapped', + defaultMessage: 'Capped at {value}', + }, + gradeFloored: { + id: 'course.gradebook.WeightedGradebookTable.gradeFloored', + defaultMessage: 'Floored to {value}', + }, + gradeCountsAs: { + id: 'course.gradebook.WeightedGradebookTable.gradeCountsAs', + defaultMessage: 'Counts as {value}', + }, levelHeader: { id: 'course.gradebook.WeightedGradebookTable.levelHeader', defaultMessage: 'Level', @@ -229,6 +244,29 @@ const translations = defineMessages({ defaultMessage: 'This tab\'s assessment weights don\'t add up to its tab weight. Its total may be understated - open "Configure Weights" to fix.', }, + externalOverRangeAboveOnly: { + id: 'course.gradebook.WeightedGradebookTable.externalOverRangeAboveOnly', + defaultMessage: + 'Some grades exceed the maximum and are capped in the weighted total.', + }, + externalOverRangeBelowOnly: { + id: 'course.gradebook.WeightedGradebookTable.externalOverRangeBelowOnly', + defaultMessage: + 'Some grades are below 0 and are floored to 0 in the weighted total.', + }, + externalOverRangeBoth: { + id: 'course.gradebook.WeightedGradebookTable.externalOverRangeBoth', + defaultMessage: + 'Some grades are outside the valid range (below 0 or above the maximum) and are floored and capped accordingly.', + }, + externalCellCapped: { + id: 'course.gradebook.WeightedGradebookTable.externalCellCapped', + defaultMessage: 'Set to {max} because the grade was {raw}.', + }, + externalCellFloored: { + id: 'course.gradebook.WeightedGradebookTable.externalCellFloored', + defaultMessage: 'Set to 0 because the grade was {raw}.', + }, }); type DisplayMode = 'points' | 'percent'; @@ -311,6 +349,10 @@ const WeightedGradebookTable = ({ () => usingDefaultWeights(tabs, assessments), [tabs, assessments], ); + const assessmentsById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); const tabDisplayValue = ( sub: number | null, @@ -416,6 +458,27 @@ const WeightedGradebookTable = ({ if (bound === 'below') return translations.levelCellCappedBelow; return translations.levelCellCappedAbove; }; + const externalClampState = useMemo( + () => externalClamp(assessments, submissions), + [assessments, submissions], + ); + // Each external assessment is its own single-assessment tab; map tab -> its + // bounded assessment id so a tab column can resolve its clamp state. + const externalAssessmentByTab = useMemo(() => { + const map = new Map(); + assessments.forEach((a) => { + if (a.external) map.set(a.tabId, a.id); + }); + return map; + }, [assessments]); + const getExternalOverRangeTranslation = (flags: { + above: boolean; + below: boolean; + }): MessageDescriptor => { + if (flags.above && flags.below) return translations.externalOverRangeBoth; + if (flags.above) return translations.externalOverRangeAboveOnly; + return translations.externalOverRangeBelowOnly; + }; const tabTotalWeight = resolvedTabs.reduce( (acc, tab) => acc + (allExcludedTabIds.has(tab.id) ? 0 : tab.gradebookWeight ?? 0), @@ -1086,7 +1149,7 @@ const WeightedGradebookTable = ({ > )} - {resolvedTabs.map((tab, i) => ( - - {customTabImbalanced(tab, assessments) ? ( - { + const externalAid = externalAssessmentByTab.get(tab.id); + const clampFlags = + externalAid != null + ? externalClampState.byAssessment.get(externalAid) + : undefined; + const balancedSubheader = customTabImbalanced( + tab, + assessments, + ) ? ( + + - + + ) : ( + tabSubheaderLabel(tab) + ); + return ( + + {clampFlags ? ( + - {tabSubheaderLabel(tab)} - - - ) : ( - tabSubheaderLabel(tab) - )} - - ))} + + {tabSubheaderLabel(tab)} + + + + ) : ( + balancedSubheader + )} + + ); + })} {totalWeight === 100 ? ( totalWeightHeaderLabel @@ -1318,7 +1415,7 @@ const WeightedGradebookTable = ({ {info ? ( { const weight = resolvedTabs[i].gradebookWeight ?? 0; + const valueText = fmtDisplay( + tabDisplayValue(subtotal, weight), + columnPrecisions.tabs[i], + ); + const externalAid = externalAssessmentByTab.get( + resolvedTabs[i].id, + ); + const clampInfo = + externalAid != null + ? externalClampState.byStudent.get( + `${studentId}:${externalAid}`, + ) + : undefined; return ( - {fmtDisplay( - tabDisplayValue(subtotal, weight), - columnPrecisions.tabs[i], + {clampInfo ? ( + + + + + {valueText} + + ) : ( + valueText )} ); @@ -1401,6 +1537,28 @@ const WeightedGradebookTable = ({ } else if (isDropped) { statusText = t(translations.dropped); } + // A bounded (external) assessment whose raw grade + // fell outside [0, maxGrade] counts as its clamped + // value — surface which bound bit so the instructor + // sees the grade that actually contributes. + const assessmentData = assessmentsById.get( + a.assessmentId, + ); + const eff = + a.grade != null && assessmentData != null + ? effectiveGrade(a.grade, assessmentData) + : null; + const wasCapped = + eff != null && + a.grade != null && + a.grade > a.maxGrade && + eff !== a.grade; + const wasFloored = + eff != null && + a.grade != null && + a.grade < 0 && + eff !== a.grade; + const clamped = wasCapped || wasFloored; return ( {`${gradeText} · ${statusText}`} + {clamped && ( + + + + )} {/* One empty cell per visible identity column so diff --git a/client/app/bundles/course/gradebook/outOfRange.ts b/client/app/bundles/course/gradebook/outOfRange.ts new file mode 100644 index 0000000000..a99d7e8ed4 --- /dev/null +++ b/client/app/bundles/course/gradebook/outOfRange.ts @@ -0,0 +1,80 @@ +import type { AssessmentData, SubmissionData } from 'types/course/gradebook'; + +import { effectiveGrade } from './computeWeighted'; + +export interface OutOfRangeSummary { + gradeCount: number; + assessmentNames: string[]; +} + +// Counts external grades that an ACTIVE bound is silently adjusting in the +// weighted total: above max only when capped, below 0 only when floored. A +// disabled toggle clamps nothing, so those grades are not flagged. Read-only — +// never mutates anything (mirrors effectiveGrade's read-time contract). +export const outOfRangeSummary = ( + assessments: AssessmentData[], + submissions: Pick[], +): OutOfRangeSummary => { + const byId = new Map(); + assessments.forEach((a) => { + if (a.external) byId.set(a.id, a); + }); + + let gradeCount = 0; + const offending = new Map(); + submissions.forEach((s) => { + if (s.grade == null) return; + const a = byId.get(s.assessmentId); + if (!a) return; + const over = (a.capAtMaximum ?? false) && s.grade > a.maxGrade; + const under = (a.floorAtZero ?? false) && s.grade < 0; + if (over || under) { + gradeCount += 1; + offending.set(a.id, a.title); + } + }); + + return { gradeCount, assessmentNames: Array.from(offending.values()) }; +}; + +export interface ExternalClampInfo { + bound: 'above' | 'below'; // above = capped down to max; below = floored up to 0 + raw: number; + max: number; +} + +export interface ExternalClamp { + byStudent: Map; + byAssessment: Map; +} + +export const externalClamp = ( + assessments: AssessmentData[], + submissions: Pick[], +): ExternalClamp => { + const byId = new Map(); + assessments.forEach((a) => { + if (a.external) byId.set(a.id, a); + }); + + const byStudent = new Map(); + const byAssessment = new Map(); + submissions.forEach((s) => { + if (s.grade == null) return; + const a = byId.get(s.assessmentId); + if (!a) return; + if (effectiveGrade(s.grade, a) === s.grade) return; // in range or bound off + const bound: 'above' | 'below' = s.grade > a.maxGrade ? 'above' : 'below'; + byStudent.set(`${s.studentId}:${s.assessmentId}`, { + bound, + raw: s.grade, + max: a.maxGrade, + }); + const flags = byAssessment.get(a.id) ?? { above: false, below: false }; + if (bound === 'above') flags.above = true; + else flags.below = true; + byAssessment.set(a.id, flags); + }); + + return { byStudent, byAssessment }; +}; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx index 0d08b3a302..8de817ff9b 100644 --- a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState, useTransition } from 'react'; +import { FC, useEffect, useMemo, useState, useTransition } from 'react'; import { defineMessages } from 'react-intl'; import { useParams, useSearchParams } from 'react-router-dom'; import { PeopleAlt } from '@mui/icons-material'; @@ -14,9 +14,11 @@ import { useCourseContext } from '../../../container/CourseLoader'; import GradebookTable from '../../components/GradebookTable'; import GradeLinkHint from '../../components/GradeLinkHint'; import ManageExternalAssessmentsButton from '../../components/manage/ManageExternalAssessmentsButton'; +import OutOfRangeAlert from '../../components/OutOfRangeAlert'; import WeightedGradebookTable from '../../components/WeightedGradebookTable'; import WeightedViewHint from '../../components/WeightedViewHint'; import fetchGradebook from '../../operations'; +import { outOfRangeSummary } from '../../outOfRange'; import { getAssessments, getCanManageWeights, @@ -81,6 +83,11 @@ const GradebookIndex: FC = () => { const courseMaxLevel = useAppSelector(getCourseMaxLevel); const levelContribution = useAppSelector(getLevelContribution); + const rangeSummary = useMemo( + () => outOfRangeSummary(assessments, submissions), + [assessments, submissions], + ); + useEffect(() => { dispatch(fetchGradebook()) .finally(() => setIsLoading(false)) @@ -137,6 +144,7 @@ const GradebookIndex: FC = () => { toolbarAction={ canManageWeights ? : undefined } + weightedViewEnabled={weightedViewEnabled} /> ); } @@ -165,6 +173,13 @@ const GradebookIndex: FC = () => { {!isLoading && students.length > 0 && !(weightedViewEnabled && viewMode === 'weighted') && } + {!isLoading && students.length > 0 && ( + + )}
{isPending && (
diff --git a/client/locales/en.json b/client/locales/en.json index a66f6ec601..d1fd9fc890 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9494,6 +9494,21 @@ "course.gradebook.WeightedGradebookTable.levelCellDivByZero": { "defaultMessage": "Set to 0 because the formula divides by zero for this level." }, + "course.gradebook.WeightedGradebookTable.externalOverRangeAboveOnly": { + "defaultMessage": "Some grades exceed the maximum and are capped in the weighted total." + }, + "course.gradebook.WeightedGradebookTable.externalOverRangeBelowOnly": { + "defaultMessage": "Some grades are below 0 and are floored to 0 in the weighted total." + }, + "course.gradebook.WeightedGradebookTable.externalOverRangeBoth": { + "defaultMessage": "Some grades are outside the valid range (below 0 or above the maximum) and are floored and capped accordingly." + }, + "course.gradebook.WeightedGradebookTable.externalCellCapped": { + "defaultMessage": "Set to {max} because the grade was {raw}." + }, + "course.gradebook.WeightedGradebookTable.externalCellFloored": { + "defaultMessage": "Set to 0 because the grade was {raw}." + }, "course.gradebook.ConfigureWeightsPrompt.descriptionDrop": { "defaultMessage": "In Equal mode, optionally drop each student's N lowest-scoring assessments before averaging." }, @@ -9672,7 +9687,7 @@ "defaultMessage": "{title} grade for {name}" }, "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "Could not save the grade. Please try again." + "defaultMessage": "Could not save the {title} grade for {name}. Please try again." }, "course.gradebook.GradebookTable.gradeSaved": { "defaultMessage": "Grade saved. {title} · {name}: {oldGrade} → {newGrade}" @@ -9808,5 +9823,38 @@ }, "course.gradebook.ProjectedTotalHint.policy": { "defaultMessage": "Totals count ungraded assessments as 0." + }, + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "Accept" + }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "Revert" + }, + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "This grade exceeds the maximum of {max}." + }, + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "This grade exceeds the maximum of {max}; its contribution to the weighted total is capped at {max}." + }, + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "This grade is below 0." + }, + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "This grade is below 0; it is floored to 0 in the weighted total." + }, + "course.gradebook.WeightedGradebookTable.gradeCapped": { + "defaultMessage": "Capped at {value}" + }, + "course.gradebook.WeightedGradebookTable.gradeCountsAs": { + "defaultMessage": "Counts as {value}" + }, + "course.gradebook.WeightedGradebookTable.gradeFloored": { + "defaultMessage": "Floored to {value}" + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range. Review before exporting." + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range and {gradeCount, plural, one {is} other {are}} being capped or floored in the weighted total. Review before exporting." } } diff --git a/client/locales/ko.json b/client/locales/ko.json index ad2e910451..78350b14fa 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9647,6 +9647,21 @@ "course.gradebook.WeightedGradebookTable.levelBreakdownDetail": { "defaultMessage": "레벨 {level}" }, + "course.gradebook.WeightedGradebookTable.externalOverRangeAboveOnly": { + "defaultMessage": "일부 성적이 최댓값을 초과하여 가중 총점에서 최댓값으로 제한됩니다." + }, + "course.gradebook.WeightedGradebookTable.externalOverRangeBelowOnly": { + "defaultMessage": "일부 성적이 0보다 낮아 가중 총점에서 0으로 처리됩니다." + }, + "course.gradebook.WeightedGradebookTable.externalOverRangeBoth": { + "defaultMessage": "일부 성적이 유효 범위(0 미만 또는 최댓값 초과)를 벗어나 그에 따라 0으로 처리되거나 최댓값으로 제한됩니다." + }, + "course.gradebook.WeightedGradebookTable.externalCellCapped": { + "defaultMessage": "성적이 {raw}였기 때문에 {max}로 설정되었습니다." + }, + "course.gradebook.WeightedGradebookTable.externalCellFloored": { + "defaultMessage": "성적이 {raw}였기 때문에 0으로 설정되었습니다." + }, "course.gradebook.TotalHint.policy": { "defaultMessage": "총점은 채점되지 않은 평가를 0점으로 계산합니다." }, @@ -9663,7 +9678,7 @@ "defaultMessage": "{name}의 {title} 성적" }, "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "성적을 저장할 수 없습니다. 다시 시도해 주세요." + "defaultMessage": "{name}의 {title} 성적을 저장할 수 없습니다. 다시 시도해 주세요." }, "course.gradebook.GradebookTable.gradeSaved": { "defaultMessage": "성적이 저장되었습니다. {title} · {name}: {oldGrade} → {newGrade}" @@ -9704,12 +9719,6 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "외부 평가 삭제" }, - "course.gradebook.GradeLinkHint.hint": { - "defaultMessage": "각 성적은 학생 제출물의 점수 합계입니다. 성적을 클릭하면 해당 제출물을 열고 점수를 조정할 수 있습니다." - }, - "course.gradebook.GradebookIndex.noStudentsHint": { - "defaultMessage": "학생이 강좌에 참여하면 여기에 성적이 표시됩니다." - }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "표시 모드" }, @@ -9805,5 +9814,38 @@ }, "course.gradebook.ManageExternalPanel.weight": { "defaultMessage": "가중치" + }, + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "적용" + }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "되돌리기" + }, + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "이 성적은 최대 점수 {max}을(를) 초과합니다." + }, + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "이 성적은 최대 점수 {max}을(를) 초과합니다. 가중 총점에 대한 기여도는 {max}(으)로 제한됩니다." + }, + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "이 성적은 0보다 작습니다." + }, + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "이 성적은 0보다 작습니다. 가중 총점에서는 0으로 처리됩니다." + }, + "course.gradebook.WeightedGradebookTable.gradeCapped": { + "defaultMessage": "{value}(으)로 상한 처리" + }, + "course.gradebook.WeightedGradebookTable.gradeCountsAs": { + "defaultMessage": "{value}(으)로 계산" + }, + "course.gradebook.WeightedGradebookTable.gradeFloored": { + "defaultMessage": "{value}(으)로 하한 처리" + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "외부 평가 {assessmentNames}에서 {gradeCount, plural, other {성적 #개}}이(가) 범위를 벗어났습니다. 내보내기 전에 검토하세요." + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "외부 평가 {assessmentNames}에서 {gradeCount, plural, other {성적 #개}}이(가) 범위를 벗어나 가중 총점에서 제한되거나 하한 처리됩니다. 내보내기 전에 검토하세요." } } diff --git a/client/locales/zh.json b/client/locales/zh.json index 69841e9524..f3b0bac591 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9641,6 +9641,21 @@ "course.gradebook.WeightedGradebookTable.levelBreakdownDetail": { "defaultMessage": "等级 {level}" }, + "course.gradebook.WeightedGradebookTable.externalOverRangeAboveOnly": { + "defaultMessage": "部分成绩超过最高值,并在加权总分中被限制为最高值。" + }, + "course.gradebook.WeightedGradebookTable.externalOverRangeBelowOnly": { + "defaultMessage": "部分成绩低于 0,并在加权总分中按 0 计算。" + }, + "course.gradebook.WeightedGradebookTable.externalOverRangeBoth": { + "defaultMessage": "部分成绩超出有效范围(低于 0 或高于最高值),并会相应地按 0 计算或限制为最高值。" + }, + "course.gradebook.WeightedGradebookTable.externalCellCapped": { + "defaultMessage": "由于成绩为 {raw},已设置为 {max}。" + }, + "course.gradebook.WeightedGradebookTable.externalCellFloored": { + "defaultMessage": "由于成绩为 {raw},已设置为 0。" + }, "course.gradebook.TotalHint.policy": { "defaultMessage": "总成绩将未评分的评估按 0 分计算。" }, @@ -9657,7 +9672,7 @@ "defaultMessage": "{name} 的 {title} 成绩" }, "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "无法保存成绩。请重试。" + "defaultMessage": "无法保存 {name} 的 {title} 成绩。请重试。" }, "course.gradebook.GradebookTable.gradeSaved": { "defaultMessage": "成绩已保存。{title} · {name}:{oldGrade} → {newGrade}" @@ -9698,12 +9713,6 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "删除外部评估" }, - "course.gradebook.GradeLinkHint.hint": { - "defaultMessage": "每个成绩都是学生提交内容中各项分数的总和。点击任一成绩即可打开该提交内容并调整分数。" - }, - "course.gradebook.GradebookIndex.noStudentsHint": { - "defaultMessage": "学生加入课程后,成绩将显示在这里。" - }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "显示模式" }, @@ -9799,5 +9808,38 @@ }, "course.gradebook.ManageExternalPanel.weight": { "defaultMessage": "权重" + }, + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "接受" + }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "还原" + }, + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "此成绩超过了最高分 {max}。" + }, + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "此成绩超过了最高分 {max};其对加权总分的贡献被限制为 {max}。" + }, + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "此成绩低于 0。" + }, + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "此成绩低于 0;在加权总分中按 0 计算。" + }, + "course.gradebook.WeightedGradebookTable.gradeCapped": { + "defaultMessage": "上限为 {value}" + }, + "course.gradebook.WeightedGradebookTable.gradeCountsAs": { + "defaultMessage": "计为 {value}" + }, + "course.gradebook.WeightedGradebookTable.gradeFloored": { + "defaultMessage": "下限为 {value}" + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "外部评估 {assessmentNames} 中有 {gradeCount, plural, other {# 个成绩}}超出范围。导出前请检查。" + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "外部评估 {assessmentNames} 中有 {gradeCount, plural, other {# 个成绩}}超出范围,将在加权总分中被限制或下调。导出前请检查。" } }