From b5f8e682ecba3442d7e73a6fe1cfa10b7d69aa4f Mon Sep 17 00:00:00 2001 From: Wouter Meetsma Date: Tue, 30 Jun 2026 20:52:01 +0200 Subject: [PATCH 1/6] test: extend page coverage for Phase 13.1, focused on RubricBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dedicated test suites for the lowest-coverage pages (per the wiki Roadmap's Phase 13.1 "extend coverage" item), prioritized by uncovered statement count rather than raw percentage so each file moves the global number the most. - Five previously 0%-coverage pages: DocsPage, EssayBuilderPage, EssayListPage, ModerationQueuePage, StudentLearningPathPage - Six of the largest-gap pages: RubricBuilder (13%→64%, the deepest investment — form view CRUD, designer/WYSIWYG grid, standards/CEFR linking, version history, sync dialog, export menu), ExportPage, StatisticsPage, ComparativeGrading, GradeStudent, SettingsPage Global statement coverage moves from 58.65% to ~67%. All tests use stable module-level mock references for useApp() — fresh array/object literals per call were found to defeat memo/effect dependency arrays in components that sync derived state (StatisticsPage), causing infinite render loops in jsdom. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/ComparativeGrading.test.tsx | 144 ++++ src/pages/__tests__/DocsPage.test.tsx | 55 ++ src/pages/__tests__/EssayBuilderPage.test.tsx | 147 ++++ src/pages/__tests__/EssayListPage.test.tsx | 129 ++++ src/pages/__tests__/ExportPage.test.tsx | 282 +++++++ src/pages/__tests__/GradeStudent.test.tsx | 202 ++++++ .../__tests__/ModerationQueuePage.test.tsx | 133 ++++ src/pages/__tests__/RubricBuilder.test.tsx | 685 ++++++++++++++++++ src/pages/__tests__/SettingsPage.test.tsx | 152 ++++ src/pages/__tests__/StatisticsPage.test.tsx | 214 ++++++ .../StudentLearningPathPage.test.tsx | 67 ++ 11 files changed, 2210 insertions(+) create mode 100644 src/pages/__tests__/ComparativeGrading.test.tsx create mode 100644 src/pages/__tests__/DocsPage.test.tsx create mode 100644 src/pages/__tests__/EssayBuilderPage.test.tsx create mode 100644 src/pages/__tests__/EssayListPage.test.tsx create mode 100644 src/pages/__tests__/ExportPage.test.tsx create mode 100644 src/pages/__tests__/GradeStudent.test.tsx create mode 100644 src/pages/__tests__/ModerationQueuePage.test.tsx create mode 100644 src/pages/__tests__/RubricBuilder.test.tsx create mode 100644 src/pages/__tests__/SettingsPage.test.tsx create mode 100644 src/pages/__tests__/StatisticsPage.test.tsx create mode 100644 src/pages/__tests__/StudentLearningPathPage.test.tsx diff --git a/src/pages/__tests__/ComparativeGrading.test.tsx b/src/pages/__tests__/ComparativeGrading.test.tsx new file mode 100644 index 0000000..4556570 --- /dev/null +++ b/src/pages/__tests__/ComparativeGrading.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { DEFAULT_FORMAT } from '../../types'; +import type { Class, Rubric, Student, AppSettings } from '../../types'; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: '', + criteria: [ + { + id: 'c1', + title: 'Criterion 1', + description: '', + weight: 100, + levels: [ + { id: 'l1', label: 'Excellent', minPoints: 90, maxPoints: 100, description: '', subItems: [] }, + { id: 'l2', label: 'Good', minPoints: 70, maxPoints: 89, description: '', subItems: [] }, + ], + }, + ], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 100, + scoringMode: 'weighted-percentage', +}; + +const mockClassA: Class = { id: 'c1', name: 'Class A', rubricIds: ['r1'] }; +const mockStudentA: Student = { id: 's1', name: 'Alice', classId: 'c1' }; +const mockStudentB: Student = { id: 's2', name: 'Bob', classId: 'c1' }; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockSaveStudentRubric = vi.fn(); +const mockNavigate = vi.fn(); + +// Stable references — a useApp() mock that builds new array literals on every call +// defeats this page's useMemo/useEffect deps and causes infinite render loops. +const mockRubricsArr = [mockRubric]; +const mockStudentsArr = [mockStudentA, mockStudentB]; +const mockClassesArr = [mockClassA]; +const mockStudentRubricsArr: never[] = []; +const mockAttachmentsArr: never[] = []; + +const mockAppValue = { + rubrics: mockRubricsArr, + students: mockStudentsArr, + classes: mockClassesArr, + studentRubrics: mockStudentRubricsArr, + attachments: mockAttachmentsArr, + saveStudentRubric: mockSaveStudentRubric, + gradeScales: [], + settings: mockSettings, +}; + +vi.mock('../../context/AppContext', () => ({ + useApp: () => mockAppValue, +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: string | Record) => { + if (typeof opts === 'string') return opts; + if (opts && typeof opts === 'object') return `${key}:${JSON.stringify(opts)}`; + return key; + }, + i18n: { language: 'en' }, + }), +})); + +let ComparativeGradingComp: React.ComponentType; + +function renderAt(path: string) { + const router = createMemoryRouter( + [{ path: '/grade-comparative/:classId/:rubricId', element: }], + { initialEntries: [path] } + ); + return render(); +} + +describe('ComparativeGrading', () => { + beforeEach(async () => { + mockSaveStudentRubric.mockClear(); + mockNavigate.mockClear(); + vi.spyOn(Math, 'random').mockReturnValue(0); + const mod = await import('../ComparativeGrading'); + ComparativeGradingComp = mod.default; + }); + + it('shows the class picker when no class is chosen, then the student picker', () => { + renderAt('/grade-comparative/all/r1'); + expect(screen.getByText('comparativeGrading.select_class_title')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Class A' })); + expect(screen.getByText('comparativeGrading.select_student_title')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('starts a random session from the class picker', () => { + renderAt('/grade-comparative/all/r1'); + fireEvent.click(screen.getByRole('button', { name: 'Class A' })); + fireEvent.click(screen.getByText('comparativeGrading.action_start_random')); + expect(mockNavigate).toHaveBeenCalledWith('/grade-comparative/c1/r1', { replace: true }); + }); + + it('renders the grading session with two students and compares a criterion', () => { + renderAt('/grade-comparative/c1/r1'); + expect(screen.getAllByText('Alice').length).toBeGreaterThan(0); + expect(screen.getAllByText('Bob').length).toBeGreaterThan(0); + fireEvent.click(screen.getByText('comparativeGrading.action_equal')); + fireEvent.click(screen.getByText(/comparativeGrading.action_save_next/)); + expect(mockSaveStudentRubric).toHaveBeenCalledTimes(2); + }); + + it('renders the combined-classes session scope', () => { + renderAt('/grade-comparative/__combined__/r1'); + expect(screen.getAllByText('Alice').length).toBeGreaterThan(0); + expect(screen.getAllByText('Bob').length).toBeGreaterThan(0); + }); + + it('shows the rubric-not-found state for a missing rubricId param', () => { + const router = createMemoryRouter([{ path: '/grade-comparative/:classId', element: }], { + initialEntries: ['/grade-comparative/c1'], + }); + render(); + expect(screen.getByText('comparativeGrading.rubric_not_found')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/DocsPage.test.tsx b/src/pages/__tests__/DocsPage.test.tsx new file mode 100644 index 0000000..d1564f0 --- /dev/null +++ b/src/pages/__tests__/DocsPage.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { renderWithRouter } from '../../test-utils/renderWithProviders'; +import { DEFAULT_FORMAT } from '../../types'; +import type { AppSettings } from '../../types'; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +vi.mock('../../context/AppContext', () => ({ + useApp: () => ({ + settings: mockSettings, + updateSettings: vi.fn(), + classes: [], + students: [], + studentRubrics: [], + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})); + +describe('DocsPage', () => { + it('renders the getting-started tab by default', async () => { + const { default: DocsPage } = await import('../DocsPage'); + renderWithRouter(); + expect(screen.getAllByText('navigation.docs').length).toBeGreaterThan(0); + expect(screen.getAllByText('docs.tab_getting_started').length).toBeGreaterThan(0); + }); + + it.each([ + 'docs.tab_route_map', + 'docs.tab_rubrics', + 'docs.tab_grading', + 'docs.tab_cefr', + 'docs.tab_essays', + 'docs.tab_analytics', + 'docs.tab_data', + ])('switches to the %s tab without crashing', async (tabKey) => { + const { default: DocsPage } = await import('../DocsPage'); + renderWithRouter(); + fireEvent.click(screen.getByText(tabKey)); + expect(screen.getAllByText(tabKey).length).toBeGreaterThan(0); + }); +}); diff --git a/src/pages/__tests__/EssayBuilderPage.test.tsx b/src/pages/__tests__/EssayBuilderPage.test.tsx new file mode 100644 index 0000000..3b4d358 --- /dev/null +++ b/src/pages/__tests__/EssayBuilderPage.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithRouter } from '../../test-utils/renderWithProviders'; +import { DEFAULT_FORMAT } from '../../types'; +import type { AppSettings, Class, EssayAssignment, EssaySubmission, Rubric, Student } from '../../types'; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockClasses: Class[] = [{ id: 'c1', name: 'Class A' }]; +const mockStudents: Student[] = [ + { id: 's1', name: 'Alice', classId: 'c1' }, + { id: 's2', name: 'Bob', classId: 'c1' }, +]; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: '', + criteria: [], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 0, + scoringMode: 'weighted-percentage', +}; + +const mockAssignment: EssayAssignment = { + rubricId: 'r1', + studentId: 's1', + teacherKey: 'tk1', + title: 'My Essay', + readOnlyAfterSubmit: true, + createdAt: '2024-01-01T00:00:00Z', +}; + +const mockSubmission: EssaySubmission = { + id: 'sub1', + assignmentRubricId: 'r1', + assignmentStudentId: 's1', + teacherKey: 'tk1', + contentHtml: '

hi

', + wordCount: 1, + submittedAt: '2024-01-02T00:00:00Z', +}; + +const mockNavigate = vi.fn(); +const mockShowToast = vi.fn(); +const mockUpdateEssayGroup = vi.fn(); +const mockAddEssayAssignments = vi.fn(); +const mockAddEssaySubmission = vi.fn(); + +let routeParams: Record = { teacherKey: 'tk1' }; +let appOverrides: Record = {}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate, useParams: () => routeParams }; +}); + +vi.mock('../../hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +vi.mock('../../context/AppContext', () => ({ + useApp: () => ({ + essayAssignments: [mockAssignment], + essaySubmissions: [mockSubmission], + rubrics: [mockRubric], + classes: mockClasses, + students: mockStudents, + addEssayAssignments: mockAddEssayAssignments, + updateEssayGroup: mockUpdateEssayGroup, + addEssaySubmission: mockAddEssaySubmission, + settings: mockSettings, + studentRubrics: [], + ...appOverrides, + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key), + i18n: { language: 'en' }, + }), +})); + +describe('EssayBuilderPage', () => { + beforeEach(() => { + routeParams = { teacherKey: 'tk1' }; + appOverrides = {}; + mockNavigate.mockClear(); + mockShowToast.mockClear(); + mockUpdateEssayGroup.mockClear(); + mockAddEssayAssignments.mockClear(); + }); + + it('shows the not-found state for an unknown teacherKey', async () => { + routeParams = { teacherKey: 'missing' }; + const { default: EssayBuilderPage } = await import('../EssayBuilderPage'); + renderWithRouter(); + expect(screen.getByText('essays.no_essays')).toBeInTheDocument(); + }); + + it('renders the new-essay form when no teacherKey is present', async () => { + routeParams = {}; + const { default: EssayBuilderPage } = await import('../EssayBuilderPage'); + renderWithRouter(); + expect(screen.getByText('essays.builder_title_new')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('essays.title_label')).toHaveValue(''); + }); + + it('renders the edit form pre-filled and saves', async () => { + const { default: EssayBuilderPage } = await import('../EssayBuilderPage'); + renderWithRouter(); + expect(screen.getByText('essays.builder_title_edit')).toBeInTheDocument(); + expect(screen.getByDisplayValue('My Essay')).toBeInTheDocument(); + fireEvent.click(screen.getByText('essays.save')); + expect(mockUpdateEssayGroup).toHaveBeenCalledWith('tk1', expect.objectContaining({ title: 'My Essay' })); + expect(mockShowToast).toHaveBeenCalled(); + }); + + it('opens the assign-to-class modal and assigns new students', async () => { + const { default: EssayBuilderPage } = await import('../EssayBuilderPage'); + renderWithRouter(); + fireEvent.click(screen.getByText('essays.assign_to_students')); + fireEvent.click(screen.getByRole('button', { name: 'Class A' })); + // The assignment modal opens for the first un-assigned class student (Alice). + expect(screen.getByText('essay_assignment.modal_title:{"name":"Alice"}')).toBeInTheDocument(); + }); + + it('opens the import-submission modal', async () => { + const { default: EssayBuilderPage } = await import('../EssayBuilderPage'); + renderWithRouter(); + fireEvent.click(screen.getByText('essays.import_submission_code')); + expect(screen.getByText('essays.import_code_label')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/EssayListPage.test.tsx b/src/pages/__tests__/EssayListPage.test.tsx new file mode 100644 index 0000000..95ae8cf --- /dev/null +++ b/src/pages/__tests__/EssayListPage.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithRouter } from '../../test-utils/renderWithProviders'; +import { DEFAULT_FORMAT } from '../../types'; +import type { AppSettings, Class, EssayAssignment, EssaySubmission, Rubric, Student } from '../../types'; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockClasses: Class[] = [{ id: 'c1', name: 'Class A' }]; +const mockStudents: Student[] = [{ id: 's1', name: 'Alice', classId: 'c1' }]; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: '', + criteria: [], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 0, + scoringMode: 'weighted-percentage', +}; + +const mockAssignment: EssayAssignment = { + rubricId: 'r1', + studentId: 's1', + teacherKey: 'tk1', + title: 'My Essay', + readOnlyAfterSubmit: true, + createdAt: '2024-01-01T00:00:00Z', +}; + +const mockSubmission: EssaySubmission = { + id: 'sub1', + assignmentRubricId: 'r1', + assignmentStudentId: 's1', + teacherKey: 'tk1', + contentHtml: '

hi

', + wordCount: 1, + submittedAt: '2024-01-02T00:00:00Z', +}; + +const mockNavigate = vi.fn(); +const mockDeleteEssayGroup = vi.fn(); +const mockUpdateEssayGroup = vi.fn(); + +let appOverrides: Record = {}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../../context/AppContext', () => ({ + useApp: () => ({ + essayAssignments: [mockAssignment], + essaySubmissions: [mockSubmission], + rubrics: [mockRubric], + deleteEssayGroup: mockDeleteEssayGroup, + updateEssayGroup: mockUpdateEssayGroup, + students: mockStudents, + classes: mockClasses, + settings: mockSettings, + studentRubrics: [], + ...appOverrides, + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key), + i18n: { language: 'en' }, + }), +})); + +describe('EssayListPage', () => { + beforeEach(() => { + appOverrides = {}; + mockNavigate.mockClear(); + mockDeleteEssayGroup.mockClear(); + }); + + it('shows the empty state when there are no essays', async () => { + appOverrides = { essayAssignments: [] }; + const { default: EssayListPage } = await import('../EssayListPage'); + renderWithRouter(); + expect(screen.getByText('essays.no_essays')).toBeInTheDocument(); + }); + + it('lists an essay group and navigates to edit it', async () => { + const { default: EssayListPage } = await import('../EssayListPage'); + renderWithRouter(); + expect(screen.getByText('My Essay')).toBeInTheDocument(); + fireEvent.click(screen.getByText('essays.action_monitor')); + }); + + it('navigates to the new essay builder', async () => { + const { default: EssayListPage } = await import('../EssayListPage'); + renderWithRouter(); + fireEvent.click(screen.getByText('essays.new_essay')); + expect(mockNavigate).toHaveBeenCalledWith('/essays/new'); + }); + + it('deletes an essay group after confirming', async () => { + const { default: EssayListPage } = await import('../EssayListPage'); + renderWithRouter(); + const deleteBtn = screen.getByTitle('tests.action_delete'); + await act(async () => { + fireEvent.click(deleteBtn); + }); + const confirmBtn = screen.getAllByRole('button').find((b) => b.className?.match(/btn-danger/i)); + if (confirmBtn) { + await act(async () => { + fireEvent.click(confirmBtn); + }); + } + expect(mockDeleteEssayGroup).toHaveBeenCalledWith('tk1'); + }); +}); diff --git a/src/pages/__tests__/ExportPage.test.tsx b/src/pages/__tests__/ExportPage.test.tsx new file mode 100644 index 0000000..3a77dc6 --- /dev/null +++ b/src/pages/__tests__/ExportPage.test.tsx @@ -0,0 +1,282 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { DEFAULT_FORMAT } from '../../types'; +import type { Class, GradeScale, Rubric, Student, StudentRubric, AppSettings, EssayAssignment, EssaySubmission } from '../../types'; + +const mockGradeScale: GradeScale = { + id: 'gs1', + name: 'Letter', + type: 'letter', + ranges: [{ min: 0, max: 100, label: 'A', color: '#22c55e' }], +}; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: '', + criteria: [ + { + id: 'c1', + title: 'Criterion 1', + description: '', + weight: 100, + levels: [{ id: 'l1', label: 'Excellent', minPoints: 90, maxPoints: 100, description: '', subItems: [] }], + }, + ], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 100, + scoringMode: 'weighted-percentage', +}; + +const mockClass: Class = { id: 'c1', name: 'Class A' }; +const mockStudent: Student = { id: 's1', name: 'Alice', classId: 'c1' }; + +const mockSr: StudentRubric = { + id: 'sr1', + rubricId: 'r1', + studentId: 's1', + entries: [{ criterionId: 'c1', levelId: 'l1', checkedSubItems: [], comment: '' }], + overallComment: '', + isPeerReview: false, + gradedAt: '2024-01-01T00:00:00Z', +}; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockEssayAssignment: EssayAssignment = { + rubricId: 'r1', + studentId: 's1', + teacherKey: 'tk1', + title: 'My Essay', + readOnlyAfterSubmit: true, + createdAt: '2024-01-01T00:00:00Z', + expiresAt: '2024-02-01T00:00:00Z', +}; + +const mockEssaySubmission: EssaySubmission = { + id: 'sub1', + assignmentRubricId: 'r1', + assignmentStudentId: 's1', + teacherKey: 'tk1', + contentHtml: '

hi

', + wordCount: 1, + submittedAt: '2024-01-02T00:00:00Z', +}; + +const mockShowToast = vi.fn(); + +let appOverrides: Record = {}; + +vi.mock('../../context/AppContext', () => ({ + useApp: () => ({ + rubrics: [mockRubric], + students: [mockStudent], + classes: [mockClass], + studentRubrics: [mockSr], + gradeScales: [mockGradeScale], + settings: mockSettings, + exportTemplates: [], + updateSettings: vi.fn(), + saveStudentRubric: vi.fn(), + selfAssessments: [], + analysisResults: [], + tests: [], + studentTests: [], + essayAssignments: [mockEssayAssignment], + essaySubmissions: [mockEssaySubmission], + ...appOverrides, + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: string | Record) => { + if (typeof opts === 'string') return opts; + if (opts && typeof opts === 'object') return `${key}:${JSON.stringify(opts)}`; + return key; + }, + i18n: { language: 'en' }, + }), +})); + +vi.mock('../../hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +vi.mock('../../services/database/AuditLogger', () => ({ logAuditEvent: vi.fn() })); + +const mockExportSinglePdf = vi.fn().mockResolvedValue(undefined); +const mockExportBatchPdf = vi.fn().mockResolvedValue(undefined); +vi.mock('../../utils/pdfExport', () => ({ + exportSinglePdf: (...args: unknown[]) => mockExportSinglePdf(...args), + exportBatchPdf: (...args: unknown[]) => mockExportBatchPdf(...args), +})); + +const mockExportBatchDocx = vi.fn().mockResolvedValue(undefined); +const mockExportRubricToDocx = vi.fn().mockResolvedValue(undefined); +vi.mock('../../utils/docxExport', () => ({ + exportBatchDocx: (...args: unknown[]) => mockExportBatchDocx(...args), + exportRubricToDocx: (...args: unknown[]) => mockExportRubricToDocx(...args), +})); + +vi.mock('../../utils/docxTemplateExport', () => ({ + exportRubricWithTemplate: vi.fn().mockResolvedValue(undefined), +})); + +const mockExportEssaysBatch = vi.fn().mockResolvedValue(undefined); +const mockExportEssayWithRubric = vi.fn().mockResolvedValue(undefined); +vi.mock('../../utils/essayExport', () => ({ + exportEssaysBatch: (...args: unknown[]) => mockExportEssaysBatch(...args), + exportEssayWithRubric: (...args: unknown[]) => mockExportEssayWithRubric(...args), +})); + +const mockBuildIcs = vi.fn((..._args: unknown[]) => 'BEGIN:VCALENDAR'); +vi.mock('../../utils/icsExport', () => ({ buildIcs: (...args: unknown[]) => mockBuildIcs(...args) })); + +const mockExportPeriodReportsBatch = vi.fn().mockResolvedValue(undefined); +const mockExportReportCard = vi.fn().mockResolvedValue(undefined); +const mockExportReportCardsBatch = vi.fn().mockResolvedValue(undefined); +vi.mock('../../utils/periodReportExport', () => ({ + exportPeriodReportsBatch: (...args: unknown[]) => mockExportPeriodReportsBatch(...args), + exportReportCard: (...args: unknown[]) => mockExportReportCard(...args), + exportReportCardsBatch: (...args: unknown[]) => mockExportReportCardsBatch(...args), +})); + +vi.mock('../../utils/reportCardAggregator', () => ({ + buildReportCardData: vi.fn().mockResolvedValue({}), +})); + +let ExportPageComp: React.ComponentType; + +function renderPage() { + const router = createMemoryRouter([{ path: '/export', element: }], { + initialEntries: ['/export'], + }); + return render(); +} + +describe('ExportPage', () => { + beforeEach(async () => { + appOverrides = {}; + mockShowToast.mockClear(); + vi.clearAllMocks(); + global.URL.createObjectURL = vi.fn(() => 'blob:fake'); + global.URL.revokeObjectURL = vi.fn(); + HTMLAnchorElement.prototype.click = vi.fn(); + const mod = await import('../ExportPage'); + ExportPageComp = mod.default; + }); + + it('renders the rubric export section header', () => { + renderPage(); + expect(screen.getByText('exportPage.rubric_section_title')).toBeInTheDocument(); + }); + + it('exports the rubric as Word via the template/default button', async () => { + renderPage(); + await act(async () => { + fireEvent.click(screen.getByText('exportPage.export_word_default')); + }); + expect(mockExportRubricToDocx).toHaveBeenCalled(); + }); + + it('selects a student and exports a CSV', async () => { + renderPage(); + // Expand the rubric-students section. + fireEvent.click(screen.getByText('exportPage.rubric_students_section_title')); + fireEvent.click(screen.getAllByText('Alice').find((el) => el.tagName === 'TD')!); + await act(async () => { + fireEvent.click(screen.getByText(/exportPage.csv_export_count/)); + }); + expect(global.URL.createObjectURL).toHaveBeenCalled(); + }); + + it('selects all students and exports a batch PDF', async () => { + renderPage(); + fireEvent.click(screen.getByText('exportPage.rubric_students_section_title')); + fireEvent.click(screen.getByText('exportPage.select_all')); + await act(async () => { + fireEvent.click(screen.getByText(/exportPage.print_to_pdf/)); + }); + expect(mockExportBatchPdf).toHaveBeenCalled(); + }); + + it('exports a batch DOCX for selected students', async () => { + renderPage(); + fireEvent.click(screen.getByText('exportPage.rubric_students_section_title')); + fireEvent.click(screen.getByText('exportPage.select_all')); + await act(async () => { + fireEvent.click(screen.getByText(/exportPage.batch_docx_export/)); + }); + expect(mockExportBatchDocx).toHaveBeenCalled(); + }); + + it('exports a single student PDF from their row', async () => { + renderPage(); + fireEvent.click(screen.getByText('exportPage.rubric_students_section_title')); + await act(async () => { + fireEvent.click(screen.getByText('PDF')); + }); + expect(mockExportSinglePdf).toHaveBeenCalled(); + }); + + it('exports essay deadlines as ICS', async () => { + renderPage(); + fireEvent.click(screen.getByText('exportPage.essays_title')); + await act(async () => { + fireEvent.click(screen.getByText('exportPage.ics_export_button')); + }); + expect(mockBuildIcs).toHaveBeenCalled(); + }); + + it('exports submitted essays for an assignment', async () => { + renderPage(); + fireEvent.click(screen.getByText('exportPage.essays_title')); + fireEvent.change(screen.getByDisplayValue('exportPage.essays_select_assignment_placeholder'), { + target: { value: 'tk1' }, + }); + const selectAllMatches = screen.getAllByText(/exportPage.select_all|exportPage.deselect_all/); + fireEvent.click(selectAllMatches[selectAllMatches.length - 1]); + await act(async () => { + fireEvent.click(screen.getByText('exportPage.essays_export_button')); + }); + expect(mockExportEssaysBatch).toHaveBeenCalled(); + }); + + it('generates a period report for a selected class and students', async () => { + renderPage(); + fireEvent.click(screen.getByText('exportPage.period_report_title')); + fireEvent.change(screen.getByDisplayValue('exportPage.period_select_class'), { target: { value: 'c1' } }); + fireEvent.click(screen.getAllByText('Alice').find((el) => el.tagName === 'BUTTON')!); + await act(async () => { + fireEvent.click(screen.getByText(/exportPage.period_generate_btn/)); + }); + expect(mockExportPeriodReportsBatch).toHaveBeenCalled(); + }); + + it('generates a report card batch, reusing the period class/student picker', async () => { + renderPage(); + // Report Card reuses the Period Report's class + student selection state. + fireEvent.click(screen.getByText('exportPage.period_report_title')); + fireEvent.change(screen.getByDisplayValue('exportPage.period_select_class'), { target: { value: 'c1' } }); + fireEvent.click(screen.getAllByText('Alice').find((el) => el.tagName === 'BUTTON')!); + fireEvent.click(screen.getByText('reportCard.title')); + await act(async () => { + fireEvent.click(screen.getByText(/reportCard.generate_batch_btn/)); + }); + expect(mockExportReportCardsBatch).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/__tests__/GradeStudent.test.tsx b/src/pages/__tests__/GradeStudent.test.tsx new file mode 100644 index 0000000..a60ae48 --- /dev/null +++ b/src/pages/__tests__/GradeStudent.test.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { DEFAULT_FORMAT } from '../../types'; +import type { AppSettings, Class, GradeScale, Rubric, Student } from '../../types'; + +const mockGradeScale: GradeScale = { + id: 'gs1', + name: 'Letter', + type: 'letter', + ranges: [{ min: 0, max: 100, label: 'A', color: '#22c55e' }], +}; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: '', + criteria: [ + { + id: 'c1', + title: 'Criterion 1', + description: '', + weight: 100, + levels: [ + { id: 'l1', label: 'Excellent', minPoints: 90, maxPoints: 100, description: '', subItems: [] }, + { id: 'l2', label: 'Good', minPoints: 70, maxPoints: 89, description: '', subItems: [] }, + ], + }, + ], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 100, + scoringMode: 'weighted-percentage', +}; + +const mockClass: Class = { id: 'c1', name: 'Class A' }; +const mockStudent: Student = { id: 's1', name: 'Alice', classId: 'c1' }; +const mockStudentBob: Student = { id: 's2', name: 'Bob', classId: 'c1' }; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockSaveStudentRubric = vi.fn(); +const mockUpdateSettings = vi.fn(); + +// Stable references — see ComparativeGrading/StatisticsPage tests for why this matters: +// fresh array/object literals on every useApp() call defeat memo/effect deps and can +// cause infinite render loops. +const mockRubricsArr = [mockRubric]; +const mockStudentsArr = [mockStudent, mockStudentBob]; +const mockClassesArr = [mockClass]; +const mockStudentRubricsArr: never[] = []; +const mockAttachmentsArr: never[] = []; +const mockAnalysisResultsArr: never[] = []; +const mockGradeScalesArr = [mockGradeScale]; +const mockEssayTemplatesArr: never[] = []; + +const mockAppValue = { + rubrics: mockRubricsArr, + students: mockStudentsArr, + classes: mockClassesArr, + studentRubrics: mockStudentRubricsArr, + attachments: mockAttachmentsArr, + analysisResults: mockAnalysisResultsArr, + gradeScales: mockGradeScalesArr, + settings: mockSettings, + saveStudentRubric: mockSaveStudentRubric, + updateSettings: mockUpdateSettings, + saveAnalysisResult: vi.fn(), + addCommentBankItem: vi.fn(), + addAttachment: vi.fn(), + saveEssayAssignment: vi.fn(), + essayTemplates: mockEssayTemplatesArr, + saveEssayTemplate: vi.fn(), + fetchEssaySubmissionsForStudent: vi.fn().mockResolvedValue([]), + deleteEssaySubmission: vi.fn(), + getEssaySignedUrl: vi.fn(), + fetchSchoolMembers: vi.fn().mockResolvedValue([]), + commentBank: [], +}; + +vi.mock('../../context/AppContext', () => ({ + useApp: () => mockAppValue, +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../../hooks/useDbStatus', () => ({ + useDbStatus: () => ({ isConnected: false }), +})); + +vi.mock('../../components/Editor/TiptapEditor', () => ({ + default: ({ content, onChange }: { content: string; onChange: (html: string) => void }) => + React.createElement('textarea', { + 'data-testid': 'tiptap-mock', + value: content, + onChange: (e: React.ChangeEvent) => onChange(e.target.value), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: string | Record) => { + if (typeof opts === 'string') return opts; + if (opts && typeof opts === 'object') return `${key}:${JSON.stringify(opts)}`; + return key; + }, + i18n: { language: 'en' }, + }), +})); + +let GradeStudentComp: React.ComponentType; + +function renderPage() { + const router = createMemoryRouter( + [{ path: '/rubrics/:rubricId/grade/:studentId', element: }], + { initialEntries: ['/rubrics/r1/grade/s1'] } + ); + return render(); +} + +describe('GradeStudent', () => { + beforeEach(async () => { + mockSaveStudentRubric.mockClear(); + mockUpdateSettings.mockClear(); + mockNavigate.mockClear(); + const mod = await import('../GradeStudent'); + GradeStudentComp = mod.default; + }); + + it('renders the rubric and student name', () => { + renderPage(); + expect(screen.getAllByText(/Alice/).length).toBeGreaterThan(0); + expect(screen.getByText('Criterion 1')).toBeInTheDocument(); + }); + + it('selects a level and saves', () => { + renderPage(); + fireEvent.click(screen.getByText('Excellent')); + fireEvent.click(screen.getAllByText('gradeStudent.action_save')[0]); + expect(mockSaveStudentRubric).toHaveBeenCalledWith( + expect.objectContaining({ + entries: expect.arrayContaining([ + expect.objectContaining({ criterionId: 'c1', levelId: 'l1' }), + ]), + }) + ); + }); + + it('toggles feedback-only and anchor checkboxes', () => { + renderPage(); + const checkboxes = screen.getAllByRole('checkbox'); + const feedbackOnlyBox = checkboxes.find((cb) => cb.nextSibling?.textContent?.match(/feedback_only/)); + // Fall back: toggle the first checkbox (feedback-only) if text match fails. + const target = (feedbackOnlyBox ?? checkboxes[0]) as HTMLInputElement; + fireEvent.click(target); + expect(target.checked).toBe(true); + }); + + it('edits the overall comment', () => { + renderPage(); + const editors = screen.getAllByTestId('tiptap-mock'); + const overallCommentEditor = editors[editors.length - 1]; + fireEvent.change(overallCommentEditor, { target: { value: 'Great work overall' } }); + expect(overallCommentEditor).toHaveValue('Great work overall'); + }); + + it('marks the student as not handed in and navigates to the next student', () => { + renderPage(); + fireEvent.click(screen.getByLabelText('gradeStudent.action_not_handed_in')); + expect(mockSaveStudentRubric).toHaveBeenCalledWith(expect.objectContaining({ notHandedIn: true })); + expect(mockNavigate).toHaveBeenCalledWith('/rubrics/r1/grade/s2'); + }); + + it('saves and advances to the next student', () => { + renderPage(); + fireEvent.click(screen.getByText('Excellent')); + fireEvent.click(screen.getByTitle('Next: Bob')); + expect(mockSaveStudentRubric).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/rubrics/r1/grade/s2'); + }); + + it('opens the keyboard shortcuts panel via the "?" key', () => { + renderPage(); + fireEvent.keyDown(window, { key: '?' }); + expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/ModerationQueuePage.test.tsx b/src/pages/__tests__/ModerationQueuePage.test.tsx new file mode 100644 index 0000000..ea3d1ea --- /dev/null +++ b/src/pages/__tests__/ModerationQueuePage.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithRouter } from '../../test-utils/renderWithProviders'; +import { DEFAULT_FORMAT } from '../../types'; +import type { AppSettings, Rubric, Student, StudentRubric } from '../../types'; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockStudents: Student[] = [{ id: 's1', name: 'Alice', classId: 'c1' }]; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: '', + criteria: [ + { + id: 'crit1', + title: 'Argument', + description: '', + weight: 100, + levels: [ + { id: 'lvl1', label: 'Poor', minPoints: 1, maxPoints: 1, description: '', subItems: [] }, + { id: 'lvl2', label: 'Great', minPoints: 4, maxPoints: 4, description: '', subItems: [] }, + ], + }, + ], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 4, + scoringMode: 'weighted-percentage', +}; + +const baseline: StudentRubric = { + id: 'sr-baseline', + rubricId: 'r1', + studentId: 's1', + entries: [{ criterionId: 'crit1', levelId: 'lvl1', checkedSubItems: [], comment: '' }], + overallComment: '', + gradedAt: '2024-01-02T00:00:00Z', + isPeerReview: false, +}; + +const secondMarker: StudentRubric = { + id: 'sr-second', + rubricId: 'r1', + studentId: 's1', + entries: [{ criterionId: 'crit1', levelId: 'lvl2', checkedSubItems: [], comment: '' }], + overallComment: '', + gradedAt: '2024-01-03T00:00:00Z', + isPeerReview: true, + gradedBy: 'colleague-1', +}; + +const mockSaveStudentRubric = vi.fn(); +const mockDeletePeerReview = vi.fn(); +const mockFetchSchoolMembers = vi.fn().mockResolvedValue([]); + +let appOverrides: Record = {}; + +vi.mock('../../context/AppContext', () => ({ + useApp: () => ({ + rubrics: [mockRubric], + studentRubrics: [baseline], + peerReviews: [secondMarker], + students: mockStudents, + settings: mockSettings, + saveStudentRubric: mockSaveStudentRubric, + deletePeerReview: mockDeletePeerReview, + fetchSchoolMembers: mockFetchSchoolMembers, + ...appOverrides, + }), +})); + +vi.mock('../../hooks/useDbStatus', () => ({ + useDbStatus: () => ({ isConnected: false }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key), + i18n: { language: 'en' }, + }), +})); + +describe('ModerationQueuePage', () => { + beforeEach(() => { + appOverrides = {}; + mockSaveStudentRubric.mockClear(); + mockDeletePeerReview.mockClear(); + }); + + it('shows the empty state when no second-marker entries are outstanding', async () => { + appOverrides = { peerReviews: [] }; + const { default: ModerationQueuePage } = await import('../ModerationQueuePage'); + renderWithRouter(); + expect(screen.getByText('coGrading.moderation_empty')).toBeInTheDocument(); + }); + + it('lists a queue item needing moderation and resolves via keep-baseline', async () => { + const { default: ModerationQueuePage } = await import('../ModerationQueuePage'); + renderWithRouter(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + fireEvent.click(screen.getByText('coGrading.action_keep_baseline')); + expect(mockDeletePeerReview).toHaveBeenCalledWith('sr-second'); + }); + + it('reconciles a queue item', async () => { + const { default: ModerationQueuePage } = await import('../ModerationQueuePage'); + renderWithRouter(); + fireEvent.click(screen.getByText('coGrading.action_reconcile')); + expect(mockSaveStudentRubric).toHaveBeenCalled(); + expect(mockDeletePeerReview).toHaveBeenCalledWith('sr-second'); + }); + + it('updates the threshold input', async () => { + const { default: ModerationQueuePage } = await import('../ModerationQueuePage'); + renderWithRouter(); + const input = screen.getByLabelText('coGrading.threshold_label'); + fireEvent.change(input, { target: { value: '5' } }); + expect(input).toHaveValue(5); + }); +}); diff --git a/src/pages/__tests__/RubricBuilder.test.tsx b/src/pages/__tests__/RubricBuilder.test.tsx new file mode 100644 index 0000000..75e3449 --- /dev/null +++ b/src/pages/__tests__/RubricBuilder.test.tsx @@ -0,0 +1,685 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { DEFAULT_FORMAT } from '../../types'; +import type { GradeScale, Rubric, RubricCriterion, StudentRubric, AppSettings } from '../../types'; + +const mockGradeScale: GradeScale = { + id: 'gs1', + name: 'Letter', + type: 'letter', + ranges: [{ min: 0, max: 100, label: 'A', color: '#22c55e' }], +}; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: 'A test rubric', + criteria: [ + { + id: 'c1', + title: 'Criterion 1', + description: '', + weight: 100, + levels: [ + { id: 'l1', label: 'Excellent', minPoints: 90, maxPoints: 100, description: 'Great', subItems: [] }, + { id: 'l2', label: 'Good', minPoints: 70, maxPoints: 89, description: 'OK', subItems: [] }, + ], + }, + ], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 100, + scoringMode: 'weighted-percentage', +}; + +const mockSr: StudentRubric = { + id: 'sr1', + rubricId: 'r1', + studentId: 's1', + entries: [{ criterionId: 'c1', levelId: 'l1', checkedSubItems: [], comment: '' }], + overallComment: '', + isPeerReview: false, + gradedAt: '2024-01-01T00:00:00Z', +}; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockRubricWithLinks: Rubric = { + ...mockRubric, + criteria: [ + { + ...mockRubric.criteria[0], + linkedStandards: [ + { + guid: 'std1', + description: 'A linked standard', + statementNotation: 'CCSS.1', + standardSetTitle: 'CCSS', + jurisdictionTitle: 'US', + }, + ], + cefrDescriptors: [ + { descriptorId: 'd0', level: 'A2', skill: 'reading', descriptionEn: 'desc', descriptionNl: 'desc' }, + ], + frameworkDescriptors: [ + { + descriptorId: 'f0', + framework: 'ib', + categoryId: 'cat0', + categoryLabelEn: 'Category', + categoryLabelNl: 'Categorie', + categoryColor: '#fff', + descriptionEn: 'desc', + descriptionNl: 'desc', + }, + ], + }, + ], +}; + +const mockRubricWithVersions: Rubric = { + ...mockRubric, + versions: [ + { + savedAt: '2024-01-05T00:00:00Z', + label: 'v1', + snapshot: { ...mockRubric, name: 'Essay Rubric (old)' }, + }, + ], +}; + +const mockAddRubric = vi.fn(() => ({ ...mockRubric, id: 'new-r' })); +const mockUpdateRubric = vi.fn(); +const mockNavigate = vi.fn(); +const mockShowToast = vi.fn(); +const mockExportPdf = vi.fn(); +const mockExportDocx = vi.fn(); +const mockSyncRubricSnapshot = vi.fn(); +const mockSaveRubricVersion = vi.fn(); +const mockRestoreRubricVersion = vi.fn(); + +let appOverrides: Record = {}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../../context/AppContext', () => ({ + useApp: () => ({ + rubrics: [mockRubric], + studentRubrics: [mockSr], + peerReviews: [], + addRubric: mockAddRubric, + updateRubric: mockUpdateRubric, + syncRubricSnapshot: mockSyncRubricSnapshot, + saveRubricVersion: mockSaveRubricVersion, + restoreRubricVersion: mockRestoreRubricVersion, + gradeScales: [mockGradeScale], + settings: mockSettings, + addVocabularyItem: vi.fn(), + updateVocabularyItem: vi.fn(), + deleteVocabularyItem: vi.fn(), + deleteVocabularyItems: vi.fn(), + classes: [], + students: [], + ...appOverrides, + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: string | Record) => { + if (typeof opts === 'string') return opts; + return key; + }, + i18n: { language: 'en' }, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => React.createElement('span', null, i18nKey), +})); + +vi.mock('../../hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +vi.mock('@hello-pangea/dnd', () => ({ + DragDropContext: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + Droppable: ({ children }: { children: (provided: unknown) => React.ReactNode }) => + children({ innerRef: vi.fn(), droppableProps: {}, placeholder: null }), + Draggable: ({ children }: { children: (provided: unknown) => React.ReactNode }) => + children({ innerRef: vi.fn(), draggableProps: {}, dragHandleProps: {} }), +})); + +vi.mock('../../utils/pdfExport', () => ({ exportRubricGridPdf: mockExportPdf })); +vi.mock('../../utils/docxExport', () => ({ exportRubricToDocx: mockExportDocx })); +vi.mock('../../services/database/AuditLogger', () => ({ logAuditEvent: vi.fn() })); +const mockLoadCriterionClipboard = vi.fn((): RubricCriterion | null => null); +vi.mock('../../store/storage', () => ({ + saveCriterionClipboard: vi.fn(), + loadCriterionClipboard: () => mockLoadCriterionClipboard(), + loadUserTemplates: vi.fn(() => []), + saveUserTemplates: vi.fn(), +})); + +vi.mock('../../components/CEFR/CefrPickerModal', () => ({ + default: ({ + onAdd, + onRemove, + onAddFramework, + onRemoveFramework, + onClose, + }: { + onAdd: (d: { descriptorId: string; level: string; skill: string }) => void; + onRemove: (id: string) => void; + onAddFramework: (d: { descriptorId: string; framework: string; categoryId: string }) => void; + onRemoveFramework: (id: string) => void; + onClose: () => void; + }) => + React.createElement( + 'div', + { 'data-testid': 'cefr-picker-modal' }, + React.createElement( + 'button', + { onClick: () => onAdd({ descriptorId: 'd1', level: 'B1', skill: 'reading' }) }, + 'Add Descriptor' + ), + React.createElement('button', { onClick: () => onRemove('d0') }, 'Remove Descriptor'), + React.createElement( + 'button', + { onClick: () => onAddFramework({ descriptorId: 'f1', framework: 'ib', categoryId: 'cat1' }) }, + 'Add Framework Descriptor' + ), + React.createElement('button', { onClick: () => onRemoveFramework('f0') }, 'Remove Framework Descriptor'), + React.createElement('button', { onClick: onClose }, 'Close CEFR Picker') + ), +})); + +vi.mock('../../components/Standards/StandardsPickerModal', () => ({ + default: ({ + onSelect, + onClose, + }: { + onSelect: (std: { + guid: string; + description: string; + standardSetTitle: string; + jurisdictionTitle: string; + }) => void; + onClose: () => void; + }) => + React.createElement( + 'div', + { 'data-testid': 'standards-picker-modal' }, + React.createElement( + 'button', + { + onClick: () => + onSelect({ + guid: 'std-new', + description: 'New standard', + standardSetTitle: 'CCSS', + jurisdictionTitle: 'US', + }), + }, + 'Select Standard' + ), + React.createElement('button', { onClick: onClose }, 'Close Standards Picker') + ), +})); + +vi.mock('../../components/Modals/RubricVersionDiffModal', () => ({ + default: ({ onClose }: { onClose: () => void }) => + React.createElement( + 'div', + { 'data-testid': 'version-diff-modal' }, + React.createElement('button', { onClick: onClose }, 'Close Diff') + ), +})); + +let RubricBuilderLazy: React.ComponentType; + +describe('RubricBuilder', () => { + beforeEach(async () => { + appOverrides = {}; + mockNavigate.mockClear(); + mockAddRubric.mockClear(); + mockUpdateRubric.mockClear(); + mockShowToast.mockClear(); + mockExportPdf.mockClear(); + mockExportDocx.mockClear(); + mockSyncRubricSnapshot.mockClear(); + mockSaveRubricVersion.mockClear(); + mockRestoreRubricVersion.mockClear(); + mockLoadCriterionClipboard.mockReset().mockReturnValue(null); + const mod = await import('../RubricBuilder'); + RubricBuilderLazy = mod.default; + }); + + function renderNew() { + const router = createMemoryRouter([{ path: '/rubrics/new', element: }], { + initialEntries: ['/rubrics/new'], + }); + return render(); + } + + function renderEdit() { + const router = createMemoryRouter([{ path: '/rubrics/:id', element: }], { + initialEntries: ['/rubrics/r1'], + }); + return render(); + } + + function renderEditWithVersions() { + appOverrides = { rubrics: [mockRubricWithVersions] }; + return renderEdit(); + } + + function renderEditWithLinks() { + appOverrides = { rubrics: [mockRubricWithLinks] }; + return renderEdit(); + } + + it('renders the new-rubric form with one default criterion', () => { + renderNew(); + expect(screen.getByText('rubricBuilder.new_rubric')).toBeInTheDocument(); + expect(screen.getAllByDisplayValue(/Excellent|New Criterion/).length).toBeGreaterThan(0); + }); + + it('shows a validation error and refuses to save when the name is blank', () => { + renderNew(); + fireEvent.click(screen.getByText('rubricBuilder.action_save')); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(mockAddRubric).not.toHaveBeenCalled(); + }); + + it('creates a new rubric and navigates to it', () => { + renderNew(); + fireEvent.change(screen.getByPlaceholderText('rubricBuilder.placeholder_name'), { + target: { value: 'Brand New Rubric' }, + }); + fireEvent.click(screen.getByText('rubricBuilder.action_save')); + expect(mockAddRubric).toHaveBeenCalledWith(expect.objectContaining({ name: 'Brand New Rubric' })); + expect(mockNavigate).toHaveBeenCalledWith('/rubrics/new-r', { replace: true }); + }); + + it('loads an existing rubric pre-filled and saves an update', () => { + renderEdit(); + expect(screen.getByText('rubricBuilder.edit_rubric')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Essay Rubric')).toBeInTheDocument(); + fireEvent.click(screen.getByText('rubricBuilder.action_save')); + expect(mockUpdateRubric).toHaveBeenCalledWith(expect.objectContaining({ id: 'r1', name: 'Essay Rubric' })); + }); + + it('adds and deletes a criterion', () => { + renderNew(); + const initialTitleInputs = screen.getAllByPlaceholderText('rubricBuilder.placeholder_criterion_name'); + expect(initialTitleInputs).toHaveLength(1); + fireEvent.click(screen.getByText('rubricBuilder.action_add_first_criterion')); + expect(screen.getAllByPlaceholderText('rubricBuilder.placeholder_criterion_name')).toHaveLength(2); + const deleteButtons = screen.getAllByLabelText('rubricBuilder.action_delete_criterion'); + fireEvent.click(deleteButtons[0]); + expect(screen.getAllByPlaceholderText('rubricBuilder.placeholder_criterion_name')).toHaveLength(1); + }); + + it('duplicates a criterion', () => { + renderEdit(); + const before = screen.getAllByPlaceholderText('rubricBuilder.placeholder_criterion_name').length; + fireEvent.click(screen.getByLabelText('rubricBuilder.action_duplicate_criterion')); + expect(screen.getAllByPlaceholderText('rubricBuilder.placeholder_criterion_name')).toHaveLength(before + 1); + }); + + it('edits criterion title and weight', () => { + renderEdit(); + const titleInput = screen.getByPlaceholderText('rubricBuilder.placeholder_criterion_name'); + fireEvent.change(titleInput, { target: { value: 'Updated Title' } }); + expect(titleInput).toHaveValue('Updated Title'); + }); + + it('switches scoring mode to total-points and reveals the max-points field', () => { + renderNew(); + const radios = screen.getAllByRole('radio'); + const totalPointsRadio = radios.find((r) => (r as HTMLInputElement).value === 'total-points')!; + fireEvent.click(totalPointsRadio); + expect(screen.getByPlaceholderText('e.g. 100')).toBeInTheDocument(); + }); + + it('sets a CEFR target level, revealing the threshold control', () => { + renderNew(); + fireEvent.change(screen.getByLabelText('cefr.target_level_label'), { target: { value: 'B1' } }); + expect(screen.getByText('cefr.achieve_threshold_label')).toBeInTheDocument(); + }); + + it('toggles the designer view', () => { + renderNew(); + fireEvent.click(screen.getByText('rubricBuilder.action_designer_view')); + fireEvent.click(screen.getByText('rubricBuilder.action_form_view')); + expect(screen.getByText('rubricBuilder.new_rubric')).toBeInTheDocument(); + }); + + it('toggles the format panel and preview panel', () => { + renderNew(); + fireEvent.click(screen.getByText('FORMAT')); + fireEvent.click(screen.getByText('rubricBuilder.action_preview')); + // No crash; both panels are optional renders gated by local state. + expect(screen.getByText('rubricBuilder.new_rubric')).toBeInTheDocument(); + }); + + it('opens the export menu and exports as PDF', async () => { + renderNew(); + fireEvent.click(screen.getByText('rubricBuilder.action_export')); + fireEvent.click(screen.getByText('rubricBuilder.action_export_pdf')); + expect(mockExportPdf).toHaveBeenCalled(); + }); + + it('opens the export menu and exports as DOCX', async () => { + renderNew(); + fireEvent.click(screen.getByText('rubricBuilder.action_export')); + fireEvent.click(screen.getByText('rubricBuilder.action_export_docx')); + expect(mockExportDocx).toHaveBeenCalled(); + }); + + it('shows the version history button only when editing an existing rubric', () => { + renderNew(); + expect(screen.queryByText('rubricBuilder.version_history')).not.toBeInTheDocument(); + }); + + it('opens version history when editing', () => { + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.version_history')); + // Toggling shouldn't crash; the panel content depends on rubric.versions which is unset here. + expect(screen.getByText('rubricBuilder.edit_rubric')).toBeInTheDocument(); + }); + + it('expands and collapses vocabulary section', () => { + renderNew(); + fireEvent.click(screen.getByText('Vocabulary & Grammar List')); + fireEvent.click(screen.getByText('Vocabulary & Grammar List')); + expect(screen.getByText('rubricBuilder.new_rubric')).toBeInTheDocument(); + }); + + // ── Level / sub-item CRUD (form view) ─────────────────────────────────────── + + it('adds and deletes a level', () => { + renderEdit(); + const before = screen.getAllByPlaceholderText('rubricBuilder.placeholder_level_name').length; + fireEvent.click(screen.getByText('rubricBuilder.action_add_level')); + expect(screen.getAllByPlaceholderText('rubricBuilder.placeholder_level_name')).toHaveLength(before + 1); + fireEvent.click(screen.getAllByLabelText('rubricBuilder.action_delete_level')[0]); + expect(screen.getAllByPlaceholderText('rubricBuilder.placeholder_level_name')).toHaveLength(before); + }); + + it('expands sub-items and adds one', () => { + renderEdit(); + fireEvent.click(screen.getAllByText(/rubricBuilder.label_sub_items/)[0]); + fireEvent.click(screen.getByText('rubricBuilder.action_add_sub_item')); + expect(screen.getAllByText(/rubricBuilder.label_sub_items.*\(1\)/).length).toBeGreaterThan(0); + }); + + // ── Standards linking ──────────────────────────────────────────────────────── + + it('shows the no-API-key standards modal when no standardsApiKey is configured', () => { + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.action_link_standard')); + expect(screen.getByText('rubricBuilder.standards_modal_title')).toBeInTheDocument(); + }); + + // ── CEFR / framework descriptor linking ────────────────────────────────────── + + it('opens the CEFR picker and adds a descriptor', () => { + renderEdit(); + fireEvent.click(screen.getByText(/framework.action_link_descriptor/)); + expect(screen.getByTestId('cefr-picker-modal')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Add Descriptor')); + fireEvent.click(screen.getByText('Close CEFR Picker')); + expect(screen.queryByTestId('cefr-picker-modal')).not.toBeInTheDocument(); + }); + + // ── Version history ────────────────────────────────────────────────────────── + + it('saves a new version from the version history panel', () => { + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.version_history')); + expect(screen.getByText('rubricBuilder.no_versions_yet')).toBeInTheDocument(); + fireEvent.click(screen.getByText('rubricBuilder.save_version')); + expect(mockSaveRubricVersion).toHaveBeenCalledWith('r1', undefined); + }); + + it('lists existing versions and opens the diff modal', () => { + renderEditWithVersions(); + fireEvent.click(screen.getByText('rubricBuilder.version_history')); + fireEvent.click(screen.getByText('rubricBuilder.compare_version')); + expect(screen.getByTestId('version-diff-modal')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Close Diff')); + expect(screen.queryByTestId('version-diff-modal')).not.toBeInTheDocument(); + }); + + it('restores a version after confirming', () => { + renderEditWithVersions(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const reloadSpy = vi.fn(); + Object.defineProperty(window, 'location', { value: { reload: reloadSpy }, writable: true }); + fireEvent.click(screen.getByText('rubricBuilder.version_history')); + fireEvent.click(screen.getByText('rubricBuilder.restore_version')); + expect(mockRestoreRubricVersion).toHaveBeenCalledWith('r1', 0); + confirmSpy.mockRestore(); + }); + + // ── Sync dialog (saving an edited rubric with graded submissions) ──────────── + + it('offers to sync the rubric snapshot after saving an edit with existing student grades', () => { + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.action_save')); + expect(screen.getByText('rubricBuilder.sync_dialog_title')).toBeInTheDocument(); + fireEvent.click(screen.getByText('rubricBuilder.sync_dialog_confirm')); + expect(mockSyncRubricSnapshot).toHaveBeenCalledWith('r1', expect.objectContaining({ id: 'r1' })); + }); + + // ── Save-as-template, print, JSON export ────────────────────────────────────── + + it('saves the rubric as a template from the export menu', () => { + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.action_export')); + fireEvent.click(screen.getByText('rubricBuilder.action_save_as_template')); + expect(mockShowToast).toHaveBeenCalled(); + }); + + it('exports the rubric as JSON', () => { + renderEdit(); + const createObjectURL = vi.fn(() => 'blob:fake'); + global.URL.createObjectURL = createObjectURL; + global.URL.revokeObjectURL = vi.fn(); + HTMLAnchorElement.prototype.click = vi.fn(); + fireEvent.click(screen.getByText('rubricBuilder.action_export')); + fireEvent.click(screen.getByText('rubricBuilder.action_download_json')); + expect(createObjectURL).toHaveBeenCalled(); + }); + + it('prints the rubric', () => { + renderEdit(); + const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {}); + fireEvent.click(screen.getByText('rubricBuilder.action_export')); + fireEvent.click(screen.getByText('rubricBuilder.action_print')); + expect(printSpy).toHaveBeenCalled(); + printSpy.mockRestore(); + }); + + // ── Standards picker (with API key configured) ─────────────────────────────── + + it('links and unlinks a standard via the real picker when an API key is set', () => { + appOverrides = { + rubrics: [mockRubricWithLinks], + settings: { ...mockSettings, standardsApiKey: 'key123' }, + }; + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.action_link_standard')); + expect(screen.getByTestId('standards-picker-modal')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Select Standard')); + expect(screen.getAllByText('New standard').length).toBeGreaterThan(0); + + fireEvent.click(screen.getAllByLabelText('rubricBuilder.action_unlink_standard')[0]); + expect(screen.queryByText('A linked standard')).not.toBeInTheDocument(); + }); + + // ── CEFR / framework descriptor add+remove round trip ──────────────────────── + + it('adds and removes CEFR and framework descriptors via the mocked picker', () => { + renderEditWithLinks(); + fireEvent.click(screen.getByText(/framework.action_link_descriptor/)); + fireEvent.click(screen.getByText('Add Descriptor')); + fireEvent.click(screen.getByText('Remove Descriptor')); + fireEvent.click(screen.getByText('Add Framework Descriptor')); + fireEvent.click(screen.getByText('Remove Framework Descriptor')); + fireEvent.click(screen.getByText('Close CEFR Picker')); + // No crash through the full add/remove round trip for both descriptor kinds. + expect(screen.getByText('rubricBuilder.edit_rubric')).toBeInTheDocument(); + }); + + // ── Clipboard copy/paste a criterion ────────────────────────────────────────── + + it('copies a criterion to the clipboard', () => { + renderEdit(); + fireEvent.click(screen.getByLabelText('rubricBuilder.action_copy_criterion')); + expect(screen.getByText('rubricBuilder.edit_rubric')).toBeInTheDocument(); + }); + + it('pastes a criterion from the clipboard', () => { + mockLoadCriterionClipboard.mockReturnValue({ + id: 'clip1', + title: 'Clipboard Criterion', + description: '', + weight: 50, + levels: [{ id: 'cl1', label: 'Lvl', minPoints: 0, maxPoints: 1, description: '', subItems: [] }], + }); + renderEdit(); + const before = screen.getAllByPlaceholderText('rubricBuilder.placeholder_criterion_name').length; + fireEvent.click(screen.getByText('rubricBuilder.action_paste_criterion')); + expect(screen.getAllByPlaceholderText('rubricBuilder.placeholder_criterion_name')).toHaveLength(before + 1); + }); + + // ── Single-point scoring mode ───────────────────────────────────────────────── + + it('shows the single-point descriptor textarea in single-point mode', () => { + renderNew(); + const radios = screen.getAllByRole('radio'); + const singlePointRadio = radios.find((r) => (r as HTMLInputElement).value === 'single-point')!; + fireEvent.click(singlePointRadio); + const textarea = screen.getByPlaceholderText('rubricBuilder.single_point_descriptor_placeholder'); + fireEvent.change(textarea, { target: { value: 'Meets the standard' } }); + expect(textarea).toHaveValue('Meets the standard'); + }); + + // ── Speaking dimensions insertion (CEFR speaking skill) ─────────────────────── + + it('shows the insert-speaking-dimensions action when the CEFR skill is speaking', () => { + renderNew(); + fireEvent.change(screen.getByLabelText('cefr.skill_label'), { target: { value: 'speaking_production' } }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + fireEvent.click(screen.getByText('rubricBuilder.insert_speaking_dims')); + expect(confirmSpy).toHaveBeenCalled(); + confirmSpy.mockRestore(); + }); + + // ── Designer (WYSIWYG grid) view ────────────────────────────────────────────── + + describe('designer view', () => { + // The form view stays mounted (display:none) while the designer view is active, + // so queries must be scoped to the designer's own + // to avoid matching the hidden form-view's identically-labeled controls. + function renderDesigner() { + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.action_designer_view')); + return within(document.querySelector('table.rubric-grid') as HTMLElement); + } + + it('edits the rubric name from the grid', () => { + renderDesigner(); + const nameInput = screen.getAllByPlaceholderText('rubricBuilder.placeholder_name')[1]; + fireEvent.change(nameInput, { target: { value: 'Designer Name' } }); + expect(nameInput).toHaveValue('Designer Name'); + }); + + it('adds a row and a column via the grid controls', () => { + const grid = renderDesigner(); + const rowsBefore = grid.getAllByLabelText('rubricBuilder.action_delete_criterion').length; + fireEvent.click(grid.getByText('rubricBuilder.action_add_row')); + expect(grid.getAllByLabelText('rubricBuilder.action_delete_criterion')).toHaveLength(rowsBefore + 1); + + fireEvent.click(screen.getByText('rubricBuilder.action_add_column_level')); + // No direct assertion target for header count without deeper DOM knowledge; + // a successful click with no crash already exercises addCriterionLevel. + expect(screen.getByText('rubricBuilder.action_add_column_level')).toBeInTheDocument(); + }); + + it('duplicates and deletes a criterion row', () => { + const grid = renderDesigner(); + const before = grid.getAllByLabelText('rubricBuilder.action_delete_criterion').length; + fireEvent.click(grid.getAllByLabelText('rubricBuilder.action_duplicate_criterion')[0]); + expect(grid.getAllByLabelText('rubricBuilder.action_delete_criterion')).toHaveLength(before + 1); + fireEvent.click(grid.getAllByLabelText('rubricBuilder.action_delete_criterion')[0]); + expect(grid.getAllByLabelText('rubricBuilder.action_delete_criterion')).toHaveLength(before); + }); + + it('smart-allocates points and balances weights', () => { + // These toolbar buttons sit above the
, outside the `grid` scope. + renderDesigner(); + fireEvent.click(screen.getByText('rubricBuilder.action_smart_allocate')); + fireEvent.click(screen.getByText('rubricBuilder.action_balance_weights')); + expect(screen.getByText('rubricBuilder.action_balance_weights')).toBeInTheDocument(); + }); + + it('moves a level header left and right', () => { + const grid = renderDesigner(); + const rightBtns = grid.getAllByLabelText('rubricBuilder.action_move_level_right'); + fireEvent.click(rightBtns[0]); + const leftBtns = grid.getAllByLabelText('rubricBuilder.action_move_level_left'); + fireEvent.click(leftBtns[leftBtns.length - 1]); + expect(grid.getAllByLabelText('rubricBuilder.action_move_level_right').length).toBeGreaterThan(0); + }); + + it('edits a level header label, syncing across criteria', () => { + const grid = renderDesigner(); + const headerInput = grid.getAllByPlaceholderText('rubricBuilder.placeholder_level_name')[0]; + fireEvent.change(headerInput, { target: { value: 'Renamed Level' } }); + expect(headerInput).toHaveValue('Renamed Level'); + }); + + it('edits a criterion description inline via click-to-edit', () => { + const grid = renderDesigner(); + fireEvent.click(grid.getByText('rubricBuilder.placeholder_click_to_edit')); + const descInput = grid.getByPlaceholderText('rubricBuilder.placeholder_criterion_description'); + fireEvent.change(descInput, { target: { value: 'New description' } }); + fireEvent.blur(descInput); + expect(grid.getByText('New description')).toBeInTheDocument(); + }); + + it('shows linked standard badges on a criterion row', () => { + appOverrides = { rubrics: [mockRubricWithLinks] }; + renderEdit(); + fireEvent.click(screen.getByText('rubricBuilder.action_designer_view')); + const gridEl = document.querySelector('table.rubric-grid') as HTMLElement; + // Shown by statementNotation, not description, when showStdDesc is off (the default). + expect(gridEl.textContent).toContain('CCSS.1'); + }); + + it('inserts a rubric-bank item', () => { + const grid = renderDesigner(); + const before = grid.getAllByLabelText('rubricBuilder.action_delete_criterion').length; + const bankSelect = grid.getByDisplayValue('rubricBuilder.action_insert_from_bank'); + fireEvent.change(bankSelect, { target: { value: 'Grammar & Spelling' } }); + expect(grid.getAllByLabelText('rubricBuilder.action_delete_criterion').length).toBeGreaterThan(before); + }); + }); +}); diff --git a/src/pages/__tests__/SettingsPage.test.tsx b/src/pages/__tests__/SettingsPage.test.tsx new file mode 100644 index 0000000..39096a7 --- /dev/null +++ b/src/pages/__tests__/SettingsPage.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { DEFAULT_FORMAT } from '../../types'; +import type { AppSettings, GradeScale } from '../../types'; + +const mockGradeScale: GradeScale = { + id: 'gs1', + name: 'Letter', + type: 'letter', + ranges: [{ min: 0, max: 100, label: 'A', color: '#22c55e' }], +}; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, + userRole: 'admin', +}; + +const mockUpdateSettings = vi.fn(); +const mockAddGradeScale = vi.fn(() => ({ ...mockGradeScale, id: 'gs2' })); +const mockShowToast = vi.fn(); + +// Stable references — see other page tests in this directory for why fresh array/object +// literals on every useApp() call can cause infinite render loops via memo/effect deps. +const mockGradeScalesArr = [mockGradeScale]; +const mockCommentBankArr: never[] = []; +const mockExportTemplatesArr: never[] = []; +const mockRubricsArr: never[] = []; +const mockStudentsArr: never[] = []; +const mockClassesArr: never[] = []; +const mockStudentRubricsArr: never[] = []; + +const mockAppValue = { + settings: mockSettings, + updateSettings: mockUpdateSettings, + gradeScales: mockGradeScalesArr, + addGradeScale: mockAddGradeScale, + updateGradeScale: vi.fn(), + deleteGradeScale: vi.fn(), + commentBank: mockCommentBankArr, + exportTemplates: mockExportTemplatesArr, + addExportTemplate: vi.fn(), + deleteExportTemplate: vi.fn(), + rubrics: mockRubricsArr, + students: mockStudentsArr, + classes: mockClassesArr, + studentRubrics: mockStudentRubricsArr, + importBackup: vi.fn(), +}; + +vi.mock('../../context/AppContext', () => ({ + useApp: () => mockAppValue, +})); + +vi.mock('../../hooks/useDbStatus', () => ({ + useDbStatus: () => ({ isConnected: false }), +})); + +vi.mock('../../hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +vi.mock('../../components/Comments/CommentBankModal', () => ({ + default: ({ onClose }: { onClose: () => void }) => + React.createElement( + 'div', + { 'data-testid': 'comment-bank-modal' }, + React.createElement('button', { onClick: onClose }, 'Close Modal') + ), +})); + +vi.mock('../../components/Rubric/TemplateUploadModal', () => ({ + default: ({ onClose }: { onClose: () => void }) => + React.createElement( + 'div', + { 'data-testid': 'template-upload-modal' }, + React.createElement('button', { onClick: onClose }, 'Close Upload') + ), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: string | Record) => { + if (typeof opts === 'string') return opts; + if (opts && typeof opts === 'object') return `${key}:${JSON.stringify(opts)}`; + return key; + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => React.createElement('span', null, i18nKey), +})); + +let SettingsPageComp: React.ComponentType; + +function renderPage() { + const router = createMemoryRouter([{ path: '/settings', element: }], { + initialEntries: ['/settings'], + }); + return render(); +} + +describe('SettingsPage', () => { + beforeEach(async () => { + mockUpdateSettings.mockClear(); + mockAddGradeScale.mockClear(); + mockShowToast.mockClear(); + const mod = await import('../SettingsPage'); + SettingsPageComp = mod.default; + }); + + it('renders the General tab by default', () => { + renderPage(); + expect(screen.getByText('settings.title')).toBeInTheDocument(); + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('changes the language', () => { + renderPage(); + const langSelect = screen.getByLabelText('settings.language_selection'); + fireEvent.change(langSelect, { target: { value: 'nl' } }); + expect(mockUpdateSettings).toHaveBeenCalledWith({ language: 'nl' }); + }); + + it('switches to the Teaching tab and adds a grade scale', () => { + renderPage(); + fireEvent.click(screen.getByText('Teaching')); + expect(screen.getByText('settings.grade_scales')).toBeInTheDocument(); + fireEvent.click(screen.getByText('settings.action_new_scale')); + expect(mockAddGradeScale).toHaveBeenCalled(); + }); + + it('opens and closes the comment bank modal from the Teaching tab', () => { + renderPage(); + fireEvent.click(screen.getByText('Teaching')); + fireEvent.click(screen.getByText('settings.action_manage_comments')); + expect(screen.getByTestId('comment-bank-modal')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Close Modal')); + expect(screen.queryByTestId('comment-bank-modal')).not.toBeInTheDocument(); + }); + + it('switches to the Administration tab (admin role)', () => { + renderPage(); + fireEvent.click(screen.getByText('Administration')); + // Smoke check: the tab switch itself exercises the admin-only render branch. + expect(screen.getByRole('button', { name: 'Administration' })).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/src/pages/__tests__/StatisticsPage.test.tsx b/src/pages/__tests__/StatisticsPage.test.tsx new file mode 100644 index 0000000..6ad25ed --- /dev/null +++ b/src/pages/__tests__/StatisticsPage.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { DEFAULT_FORMAT } from '../../types'; +import type { Class, GradeScale, Rubric, Student, StudentRubric, AppSettings } from '../../types'; + +const mockGradeScale: GradeScale = { + id: 'gs1', + name: 'Letter', + type: 'letter', + ranges: [ + { min: 90, max: 100, label: 'A', color: '#22c55e' }, + { min: 0, max: 89, label: 'B', color: '#84cc16' }, + ], +}; + +const mockRubric: Rubric = { + id: 'r1', + name: 'Essay Rubric', + subject: 'English', + description: '', + criteria: [ + { + id: 'c1', + title: 'Criterion 1', + description: '', + weight: 100, + levels: [ + { id: 'l1', label: 'Excellent', minPoints: 90, maxPoints: 100, description: '', subItems: [] }, + { id: 'l2', label: 'Good', minPoints: 70, maxPoints: 89, description: '', subItems: [] }, + ], + }, + ], + gradeScaleId: 'gs1', + format: DEFAULT_FORMAT, + attachmentIds: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + totalMaxPoints: 100, + scoringMode: 'weighted-percentage', +}; + +const mockClassA: Class = { id: 'c1', name: 'Class A' }; +const mockClassB: Class = { id: 'c2', name: 'Class B' }; +const mockStudentA: Student = { id: 's1', name: 'Alice', classId: 'c1' }; +const mockStudentB: Student = { id: 's2', name: 'Bob', classId: 'c2' }; + +const mockSrA: StudentRubric = { + id: 'sr1', + rubricId: 'r1', + studentId: 's1', + entries: [{ criterionId: 'c1', levelId: 'l1', checkedSubItems: [], comment: '' }], + overallComment: '', + isPeerReview: false, + gradedAt: '2024-01-01T00:00:00Z', +}; + +const mockSrB: StudentRubric = { + id: 'sr2', + rubricId: 'r1', + studentId: 's2', + entries: [{ criterionId: 'c1', levelId: 'l2', checkedSubItems: [], comment: '' }], + overallComment: '', + isPeerReview: false, + gradedAt: '2024-01-02T00:00:00Z', +}; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockUpdateSettings = vi.fn(); + +// Stable references: StatisticsPage has effects/memos keyed on `classes` etc. — a mock +// that builds new array literals on every useApp() call defeats those memo deps and +// causes an infinite render loop (each render sees a "new" classes array). +const mockRubricsArr = [mockRubric]; +const mockStudentsArr = [mockStudentA, mockStudentB]; +const mockClassesArr = [mockClassA, mockClassB]; +const mockStudentRubricsArr = [mockSrA, mockSrB]; +const mockGradeScalesArr = [mockGradeScale]; +const emptyArr: never[] = []; + +const mockAppValue = { + rubrics: mockRubricsArr, + students: mockStudentsArr, + classes: mockClassesArr, + studentRubrics: mockStudentRubricsArr, + gradeScales: mockGradeScalesArr, + settings: mockSettings, + updateSettings: mockUpdateSettings, + tests: emptyArr, + studentTests: emptyArr, +}; + +vi.mock('../../context/AppContext', () => ({ + useApp: () => mockAppValue, +})); + +vi.mock('recharts', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + ResponsiveContainer: ({ children }: { children: React.ReactElement<{ width?: number; height?: number }> }) => + React.cloneElement(children, { width: 600, height: 400 }), + }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: string | Record) => { + if (typeof opts === 'string') return opts; + if (opts && typeof opts === 'object') return `${key}:${JSON.stringify(opts)}`; + return key; + }, + i18n: { language: 'en' }, + }), +})); + +let StatisticsPageComp: React.ComponentType; + +function renderPage() { + const router = createMemoryRouter([{ path: '/statistics', element: }], { + initialEntries: ['/statistics'], + }); + return render(); +} + +async function waitForCharts() { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); +} + +describe('StatisticsPage', () => { + beforeEach(async () => { + mockUpdateSettings.mockClear(); + const mod = await import('../StatisticsPage'); + StatisticsPageComp = mod.default; + }); + + it('renders before charts become ready (skeleton)', async () => { + renderPage(); + expect(screen.getByText('statistics.title')).toBeInTheDocument(); + }); + + it('renders the rubric view after charts become ready', async () => { + renderPage(); + await waitForCharts(); + expect(screen.getByText('statistics.title')).toBeInTheDocument(); + expect(screen.getAllByText('Essay Rubric').length).toBeGreaterThan(0); + }); + + it('switches to the student view and selects a student', async () => { + renderPage(); + await waitForCharts(); + fireEvent.click(screen.getByText('statistics.view_by_student')); + await waitForCharts(); + const studentSelect = screen.getByDisplayValue('statistics.select_student_placeholder'); + fireEvent.change(studentSelect, { target: { value: 's1' } }); + expect(studentSelect).toHaveValue('s1'); + }); + + it('switches to compare mode, selects two classes, and shows results', async () => { + renderPage(); + await waitForCharts(); + fireEvent.click(screen.getByText('statistics.view_compare')); + await waitForCharts(); + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + fireEvent.click(checkboxes[1]); + await waitForCharts(); + expect(screen.queryByText('statistics.compare.prompt')).not.toBeInTheDocument(); + }); + + it('expands the insights panel in compare mode', async () => { + renderPage(); + await waitForCharts(); + fireEvent.click(screen.getByText('statistics.view_compare')); + await waitForCharts(); + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + fireEvent.click(checkboxes[1]); + await waitForCharts(); + const insightsToggle = screen.queryByText(/statistics.insights.title/); + if (insightsToggle) { + fireEvent.click(insightsToggle); + } + expect(screen.getByText('statistics.title')).toBeInTheDocument(); + }); + + it('changes the active class filter, syncing back to settings', async () => { + renderPage(); + await waitForCharts(); + const classSelect = screen.getByDisplayValue('statistics.all_classes'); + fireEvent.change(classSelect, { target: { value: 'c1' } }); + expect(mockUpdateSettings).toHaveBeenCalledWith({ activeClassId: 'c1' }); + }); + + it('toggles the criterion chart type and the exclude-not-handed-in filter', async () => { + renderPage(); + await waitForCharts(); + fireEvent.click(screen.getByText('statistics.chart_radar')); + fireEvent.click(screen.getByText('statistics.excl_nhi')); + // Settings is a static mock here (real toggling is verified via the updateSettings call), + // so assert the intent rather than the post-toggle label. + expect(mockUpdateSettings).toHaveBeenCalledWith({ statisticsExcludeNotHandedIn: true }); + }); +}); diff --git a/src/pages/__tests__/StudentLearningPathPage.test.tsx b/src/pages/__tests__/StudentLearningPathPage.test.tsx new file mode 100644 index 0000000..73d6166 --- /dev/null +++ b/src/pages/__tests__/StudentLearningPathPage.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithRouter } from '../../test-utils/renderWithProviders'; +import { DEFAULT_FORMAT } from '../../types'; +import type { AppSettings, Class, Student } from '../../types'; + +const mockSettings: AppSettings = { + defaultGradeScaleId: 'gs1', + theme: 'dark', + language: 'en', + accentColor: '#3b82f6', + defaultFormat: DEFAULT_FORMAT, +}; + +const mockClasses: Class[] = [{ id: 'c1', name: 'Class A' }]; +const mockStudents: Student[] = [{ id: 's1', name: 'Alice', classId: 'c1' }]; + +const mockNavigate = vi.fn(); +let routeParams: Record = { id: 's1' }; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate, useParams: () => routeParams }; +}); + +vi.mock('../../context/AppContext', () => ({ + useApp: () => ({ + students: mockStudents, + classes: mockClasses, + rubrics: [], + studentRubrics: [], + selfAssessments: [], + analysisResults: [], + settings: mockSettings, + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key), + i18n: { language: 'en' }, + }), +})); + +describe('StudentLearningPathPage', () => { + beforeEach(() => { + routeParams = { id: 's1' }; + mockNavigate.mockClear(); + }); + + it('shows the not-found state for an unknown student id', async () => { + routeParams = { id: 'missing' }; + const { default: StudentLearningPathPage } = await import('../StudentLearningPathPage'); + renderWithRouter(); + expect(screen.getByText('learningPath.student_not_found')).toBeInTheDocument(); + }); + + it('renders the path view for a known student with no data, showing empty states', async () => { + const { default: StudentLearningPathPage } = await import('../StudentLearningPathPage'); + renderWithRouter(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getAllByText('Class A').length).toBeGreaterThan(0); + expect(screen.getByText('learningPath.recommendations_empty')).toBeInTheDocument(); + expect(screen.getByText('learningPath.interventions_empty')).toBeInTheDocument(); + }); +}); From 20168abae42cf24a0d2b7c189048fa258757623b Mon Sep 17 00:00:00 2001 From: Wouter Meetsma Date: Tue, 30 Jun 2026 21:00:10 +0200 Subject: [PATCH 2/6] style: fix Prettier formatting on 3 new test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial commit used a mismatched/uncached local Prettier build (worktree node_modules was incomplete — npm ls prettier showed empty). Running npm ci installs the project-pinned version; format:check now passes for all files. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/__tests__/ComparativeGrading.test.tsx | 9 ++++++--- src/pages/__tests__/ExportPage.test.tsx | 11 ++++++++++- src/pages/__tests__/GradeStudent.test.tsx | 4 +--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/pages/__tests__/ComparativeGrading.test.tsx b/src/pages/__tests__/ComparativeGrading.test.tsx index 4556570..6964ffd 100644 --- a/src/pages/__tests__/ComparativeGrading.test.tsx +++ b/src/pages/__tests__/ComparativeGrading.test.tsx @@ -135,9 +135,12 @@ describe('ComparativeGrading', () => { }); it('shows the rubric-not-found state for a missing rubricId param', () => { - const router = createMemoryRouter([{ path: '/grade-comparative/:classId', element: }], { - initialEntries: ['/grade-comparative/c1'], - }); + const router = createMemoryRouter( + [{ path: '/grade-comparative/:classId', element: }], + { + initialEntries: ['/grade-comparative/c1'], + } + ); render(); expect(screen.getByText('comparativeGrading.rubric_not_found')).toBeInTheDocument(); }); diff --git a/src/pages/__tests__/ExportPage.test.tsx b/src/pages/__tests__/ExportPage.test.tsx index 3a77dc6..829a8a9 100644 --- a/src/pages/__tests__/ExportPage.test.tsx +++ b/src/pages/__tests__/ExportPage.test.tsx @@ -3,7 +3,16 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { DEFAULT_FORMAT } from '../../types'; -import type { Class, GradeScale, Rubric, Student, StudentRubric, AppSettings, EssayAssignment, EssaySubmission } from '../../types'; +import type { + Class, + GradeScale, + Rubric, + Student, + StudentRubric, + AppSettings, + EssayAssignment, + EssaySubmission, +} from '../../types'; const mockGradeScale: GradeScale = { id: 'gs1', diff --git a/src/pages/__tests__/GradeStudent.test.tsx b/src/pages/__tests__/GradeStudent.test.tsx index a60ae48..621b615 100644 --- a/src/pages/__tests__/GradeStudent.test.tsx +++ b/src/pages/__tests__/GradeStudent.test.tsx @@ -154,9 +154,7 @@ describe('GradeStudent', () => { fireEvent.click(screen.getAllByText('gradeStudent.action_save')[0]); expect(mockSaveStudentRubric).toHaveBeenCalledWith( expect.objectContaining({ - entries: expect.arrayContaining([ - expect.objectContaining({ criterionId: 'c1', levelId: 'l1' }), - ]), + entries: expect.arrayContaining([expect.objectContaining({ criterionId: 'c1', levelId: 'l1' })]), }) ); }); From 76ae074ad0a45dc62c82dd38192d41346f88c4c2 Mon Sep 17 00:00:00 2001 From: Wouter Meetsma Date: Tue, 30 Jun 2026 21:19:33 +0200 Subject: [PATCH 3/6] test: address CodeRabbit review feedback on Phase 13.1 test suite - ComparativeGrading: switch to static import; restore Math.random spy in afterEach - DocsPage: static import; stronger tab-switch assertions (count before vs after) - EssayListPage: assert navigate() after clicking the edit button (Link vs navigate); use translation-key-based confirm button selector; proper async act() wrapping - ExportPage: add pre-assertion before .find()!-click patterns; remove what-not-why comment - GradeStudent: assert both feedback-only AND anchor checkboxes in toggle test - RubricBuilder: expose saveCriterionClipboard mock + assert it; restore window.location after version-restore test and assert reloadSpy was called - SettingsPage: add TemplateUploadModal open/close test; objectContaining on addGradeScale; switch to renderWithRouter helper - StatisticsPage: switch to renderWithRouter; replace optional insights guard with explicit assertion (compare.prompt absent when 2 classes selected) Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/ComparativeGrading.test.tsx | 21 ++++++++++-------- src/pages/__tests__/DocsPage.test.tsx | 12 +++++----- src/pages/__tests__/EssayListPage.test.tsx | 21 ++++++++++-------- src/pages/__tests__/ExportPage.test.tsx | 13 +++++++---- src/pages/__tests__/GradeStudent.test.tsx | 22 ++++++++++++++----- src/pages/__tests__/RubricBuilder.test.tsx | 14 +++++++++--- src/pages/__tests__/SettingsPage.test.tsx | 22 +++++++++++++------ src/pages/__tests__/StatisticsPage.test.tsx | 18 ++++++--------- 8 files changed, 89 insertions(+), 54 deletions(-) diff --git a/src/pages/__tests__/ComparativeGrading.test.tsx b/src/pages/__tests__/ComparativeGrading.test.tsx index 6964ffd..45d2db2 100644 --- a/src/pages/__tests__/ComparativeGrading.test.tsx +++ b/src/pages/__tests__/ComparativeGrading.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import ComparativeGradingDefault from '../ComparativeGrading'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { DEFAULT_FORMAT } from '../../types'; import type { Class, Rubric, Student, AppSettings } from '../../types'; @@ -85,23 +86,25 @@ vi.mock('react-i18next', () => ({ }), })); -let ComparativeGradingComp: React.ComponentType; - function renderAt(path: string) { const router = createMemoryRouter( - [{ path: '/grade-comparative/:classId/:rubricId', element: }], + [{ path: '/grade-comparative/:classId/:rubricId', element: }], { initialEntries: [path] } ); return render(); } describe('ComparativeGrading', () => { - beforeEach(async () => { + let mathRandomSpy: ReturnType; + + beforeEach(() => { mockSaveStudentRubric.mockClear(); mockNavigate.mockClear(); - vi.spyOn(Math, 'random').mockReturnValue(0); - const mod = await import('../ComparativeGrading'); - ComparativeGradingComp = mod.default; + mathRandomSpy = vi.spyOn(Math, 'random').mockReturnValue(0); + }); + + afterEach(() => { + mathRandomSpy.mockRestore(); }); it('shows the class picker when no class is chosen, then the student picker', () => { @@ -136,7 +139,7 @@ describe('ComparativeGrading', () => { it('shows the rubric-not-found state for a missing rubricId param', () => { const router = createMemoryRouter( - [{ path: '/grade-comparative/:classId', element: }], + [{ path: '/grade-comparative/:classId', element: }], { initialEntries: ['/grade-comparative/c1'], } diff --git a/src/pages/__tests__/DocsPage.test.tsx b/src/pages/__tests__/DocsPage.test.tsx index d1564f0..6b949ec 100644 --- a/src/pages/__tests__/DocsPage.test.tsx +++ b/src/pages/__tests__/DocsPage.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { renderWithRouter } from '../../test-utils/renderWithProviders'; import { DEFAULT_FORMAT } from '../../types'; import type { AppSettings } from '../../types'; +import DocsPage from '../DocsPage'; const mockSettings: AppSettings = { defaultGradeScaleId: 'gs1', @@ -31,8 +32,7 @@ vi.mock('react-i18next', () => ({ })); describe('DocsPage', () => { - it('renders the getting-started tab by default', async () => { - const { default: DocsPage } = await import('../DocsPage'); + it('renders the getting-started tab by default', () => { renderWithRouter(); expect(screen.getAllByText('navigation.docs').length).toBeGreaterThan(0); expect(screen.getAllByText('docs.tab_getting_started').length).toBeGreaterThan(0); @@ -46,10 +46,12 @@ describe('DocsPage', () => { 'docs.tab_essays', 'docs.tab_analytics', 'docs.tab_data', - ])('switches to the %s tab without crashing', async (tabKey) => { - const { default: DocsPage } = await import('../DocsPage'); + ])('switches to the %s tab without crashing', (tabKey) => { renderWithRouter(); + // Count before clicking — nav button renders the label regardless of activeTab. + // After clicking, the breadcrumb also shows the label, so count increases. + const before = screen.getAllByText(tabKey).length; fireEvent.click(screen.getByText(tabKey)); - expect(screen.getAllByText(tabKey).length).toBeGreaterThan(0); + expect(screen.getAllByText(tabKey).length).toBeGreaterThan(before); }); }); diff --git a/src/pages/__tests__/EssayListPage.test.tsx b/src/pages/__tests__/EssayListPage.test.tsx index 95ae8cf..145d45d 100644 --- a/src/pages/__tests__/EssayListPage.test.tsx +++ b/src/pages/__tests__/EssayListPage.test.tsx @@ -101,7 +101,9 @@ describe('EssayListPage', () => { const { default: EssayListPage } = await import('../EssayListPage'); renderWithRouter(); expect(screen.getByText('My Essay')).toBeInTheDocument(); - fireEvent.click(screen.getByText('essays.action_monitor')); + // The edit button calls navigate(); the monitor action is a and doesn't. + fireEvent.click(screen.getAllByText('tests.action_edit')[0]); + expect(mockNavigate).toHaveBeenCalledWith('/essays/tk1'); }); it('navigates to the new essay builder', async () => { @@ -114,16 +116,17 @@ describe('EssayListPage', () => { it('deletes an essay group after confirming', async () => { const { default: EssayListPage } = await import('../EssayListPage'); renderWithRouter(); - const deleteBtn = screen.getByTitle('tests.action_delete'); + // handleDelete() awaits confirm() — act flushes the resulting state update. await act(async () => { - fireEvent.click(deleteBtn); + fireEvent.click(screen.getByTitle('tests.action_delete')); + }); + // ConfirmDialog uses t('common.delete') as the confirmLabel (our mock returns the key). + const confirmBtn = screen.getByText('common.delete'); + expect(confirmBtn).toBeInTheDocument(); + // Clicking confirm resolves the useConfirm() Promise; act flushes the continuation. + await act(async () => { + fireEvent.click(confirmBtn); }); - const confirmBtn = screen.getAllByRole('button').find((b) => b.className?.match(/btn-danger/i)); - if (confirmBtn) { - await act(async () => { - fireEvent.click(confirmBtn); - }); - } expect(mockDeleteEssayGroup).toHaveBeenCalledWith('tk1'); }); }); diff --git a/src/pages/__tests__/ExportPage.test.tsx b/src/pages/__tests__/ExportPage.test.tsx index 829a8a9..1e612ab 100644 --- a/src/pages/__tests__/ExportPage.test.tsx +++ b/src/pages/__tests__/ExportPage.test.tsx @@ -204,9 +204,10 @@ describe('ExportPage', () => { it('selects a student and exports a CSV', async () => { renderPage(); - // Expand the rubric-students section. fireEvent.click(screen.getByText('exportPage.rubric_students_section_title')); - fireEvent.click(screen.getAllByText('Alice').find((el) => el.tagName === 'TD')!); + const aliceCell = screen.getAllByText('Alice').find((el) => el.tagName === 'TD'); + expect(aliceCell).toBeInTheDocument(); + fireEvent.click(aliceCell!); await act(async () => { fireEvent.click(screen.getByText(/exportPage.csv_export_count/)); }); @@ -269,7 +270,9 @@ describe('ExportPage', () => { renderPage(); fireEvent.click(screen.getByText('exportPage.period_report_title')); fireEvent.change(screen.getByDisplayValue('exportPage.period_select_class'), { target: { value: 'c1' } }); - fireEvent.click(screen.getAllByText('Alice').find((el) => el.tagName === 'BUTTON')!); + const aliceBtn = screen.getAllByText('Alice').find((el) => el.tagName === 'BUTTON'); + expect(aliceBtn).toBeInTheDocument(); + fireEvent.click(aliceBtn!); await act(async () => { fireEvent.click(screen.getByText(/exportPage.period_generate_btn/)); }); @@ -281,7 +284,9 @@ describe('ExportPage', () => { // Report Card reuses the Period Report's class + student selection state. fireEvent.click(screen.getByText('exportPage.period_report_title')); fireEvent.change(screen.getByDisplayValue('exportPage.period_select_class'), { target: { value: 'c1' } }); - fireEvent.click(screen.getAllByText('Alice').find((el) => el.tagName === 'BUTTON')!); + const aliceBtn2 = screen.getAllByText('Alice').find((el) => el.tagName === 'BUTTON'); + expect(aliceBtn2).toBeInTheDocument(); + fireEvent.click(aliceBtn2!); fireEvent.click(screen.getByText('reportCard.title')); await act(async () => { fireEvent.click(screen.getByText(/reportCard.generate_batch_btn/)); diff --git a/src/pages/__tests__/GradeStudent.test.tsx b/src/pages/__tests__/GradeStudent.test.tsx index 621b615..c3b47e4 100644 --- a/src/pages/__tests__/GradeStudent.test.tsx +++ b/src/pages/__tests__/GradeStudent.test.tsx @@ -161,12 +161,22 @@ describe('GradeStudent', () => { it('toggles feedback-only and anchor checkboxes', () => { renderPage(); - const checkboxes = screen.getAllByRole('checkbox'); - const feedbackOnlyBox = checkboxes.find((cb) => cb.nextSibling?.textContent?.match(/feedback_only/)); - // Fall back: toggle the first checkbox (feedback-only) if text match fails. - const target = (feedbackOnlyBox ?? checkboxes[0]) as HTMLInputElement; - fireEvent.click(target); - expect(target.checked).toBe(true); + // Both are checkbox inputs inside