diff --git a/specs/012-code-review/code-review-report.md b/specs/012-code-review/code-review-report.md index d52adb9a..5a548a49 100644 --- a/specs/012-code-review/code-review-report.md +++ b/specs/012-code-review/code-review-report.md @@ -149,3 +149,27 @@ Files 400–340 lines that are *structurally healthy* (no split required): `qd-s 7. **Extract reusable Lit components**: `` / ``, `qd-student-table`, `qd-spinner`, `qd-lockout-banner`; adopt `qd-modal` base in `qd-pin-create`. Each step should keep tests green per the Definition of Done (typecheck, lint, unit/integration, format, build, <40KB bundle). Refactors must preserve behavior — TDD: characterize current behavior with tests before moving logic. + +--- + +## 7. Resolution (post-refactor — final state) + +All findings above have been implemented behavior-preservingly (the only sanctioned behavior change is the quiz instructor-overlay `innerHTML` → `textContent` XSS fix). Final line counts for the former hot-spots: + +| File | Before | After | Outcome | +|------|-------:|------:|---------| +| `src/components/qd-login.ts` | 983 | 395 | Presentational view over `AuthService`; styles → `qd-login.styles.ts` | +| `src/enhancers/quiz-table.ts` | 807 | 399 | Split → `quiz-table-columns`, `quiz-input-factory`, `quiz-answer-persistence`, `quiz-instructor-overlay` | +| `src/services/storage/indexeddb.ts` | 759 | 308 | Thin coordinator → `idb-connection`, `idb-helpers`, `idb-codec`, `backup-repository`, `audit-log-repository` | +| `src/enhancers/analysis-table.ts` | 637 | 301 | Split → `analysis-display`, `analysis-persistence`, `analysis-instructor-overlay` | +| `src/components/qd-migration-dialog.ts` | 519 | 344 | Styles extracted; uses shared `instructor-auth` + `qd-spinner` | +| `src/init/bootstrap.ts` | 490 | 321 | CSS literal → `global-styles.ts`; parameterized table enhancement | +| `src/services/session.ts` | 443 | 269 | Cache math → `session-cache.ts` | +| `src/init/event-coordinator.ts` | 339 | 216 | Reduced to a pure event router | + +**Success criteria**: +- **SC-002**: `find src -name '*.ts' | xargs wc -l | awk '$1>400'` returns only the frozen `src/types/contracts.ts` (411). ✅ +- **SC-003**: no enhancer overlay renders student data via `innerHTML`; quiz/analysis overlays are backed by the auto-escaped `` / `` Lit components. ✅ +- **SC-005**: full Definition of Done green (831 unit + 84 integration tests); IIFE bundle 37.6 KB gzip (≤40 KB limit, +0.9 KB vs the 36.7 KB baseline from the four new reusable Lit components). ✅ + +Reusable components added: ``, ``, ``, ``. New shared helpers: `session-state.ts`, `persist-and-notify.ts`, `auth-service.ts`, `instructor-auth.ts`, `pin-reset-service.ts`, `page-id.ts`. diff --git a/specs/012-code-review/tasks.md b/specs/012-code-review/tasks.md index 0eedc10e..4bb61a2c 100644 --- a/specs/012-code-review/tasks.md +++ b/specs/012-code-review/tasks.md @@ -79,16 +79,16 @@ Behavior-preserving, test-gated, one slice at a time. The **only** sanctioned be - [X] T012 [P] [US2] Characterization test `tests/integration/quiz-instructor-overlay.test.ts` asserting current rendered student-answer overlay content and structure (baseline before XSS fix) - [X] T013 [P] [US2] Add XSS regression test in `tests/integration/quiz-instructor-overlay.test.ts`: a student answer/name containing ` & "quotes" \'apostrophes\''; const entries: CellEntry[] = [ { @@ -324,11 +332,13 @@ describe('Analysis Table Instructor Display Integration (T046)', () => { ]; const display = createStudentEntriesDisplay(entries); + document.body.appendChild(display); + await display.updateComplete; - // Should escape HTML properly (textContent shows actual text) - expect(display.textContent).toContain('', + success: false, + formattedTimestamp: '00:00', + cssClass: 'qd-incorrect', + }, + ]; + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('img')).toBeNull(); + expect(el.shadowRoot?.querySelector('script')).toBeNull(); + expect(el.shadowRoot?.querySelector('.qd-student-answer-text')?.textContent).toBe( + '', + ); + }); +}); + +describe('qd-student-entries', () => { + let el: QdStudentEntries; + + beforeEach(() => { + el = document.createElement('qd-student-entries'); + document.body.appendChild(el); + }); + afterEach(() => el.remove()); + + it('renders the empty-state placeholder when there are no entries', async () => { + el.entries = []; + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.qd-no-entries')?.textContent).toContain( + '(No entries yet)', + ); + }); + + it('escapes student-controlled content', async () => { + const entries: CellEntry[] = [ + { + serviceId: 'RN1234', + name: 'Bob', + content: '', + timestamp: '2024-11-19T14:23:00Z', + }, + ]; + el.entries = entries; + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('script')).toBeNull(); + expect(el.shadowRoot?.querySelector('.qd-entry-content')?.textContent).toBe( + '', + ); + }); +}); diff --git a/tests/unit/enhancers/analysis-table.test.ts b/tests/unit/enhancers/analysis-table.test.ts index 0e911600..0e031d29 100644 --- a/tests/unit/enhancers/analysis-table.test.ts +++ b/tests/unit/enhancers/analysis-table.test.ts @@ -5,11 +5,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { StudentRecord } from '../../../src/types/contracts.js'; -import { - groupEntriesByCell, - sortByTimestamp, - createStudentEntriesDisplay, -} from '../../../src/enhancers/analysis-table.js'; +import { groupEntriesByCell, sortByTimestamp } from '../../../src/services/analysis-display.js'; +import { createStudentEntriesDisplay } from '../../../src/enhancers/analysis-instructor-overlay.js'; describe('Analysis Table Enhancer - Instructor View (FR-012, FR-013)', () => { let table: HTMLTableElement; @@ -249,7 +246,7 @@ describe('Analysis Table Enhancer - Instructor View (FR-012, FR-013)', () => { }); describe('FR-012: Display Student Entries', () => { - it('should create display element for cell with student entries', () => { + it('should create display element for cell with student entries', async () => { const entries = [ { serviceId: 'RN2344', @@ -266,36 +263,37 @@ describe('Analysis Table Enhancer - Instructor View (FR-012, FR-013)', () => { ]; const displayElement = createStudentEntriesDisplay(entries); + document.body.appendChild(displayElement); + await displayElement.updateComplete; - // Should have container - expect(displayElement).toBeTruthy(); - expect(displayElement.className).toContain('qd-student-entries'); + expect(displayElement.tagName.toLowerCase()).toBe('qd-student-entries'); - // Should have all entries - const entryElements = displayElement.querySelectorAll('.qd-entry'); + // Entries render (newest first) inside the component's shadow root + const entryElements = displayElement.shadowRoot?.querySelectorAll('.qd-entry'); expect(entryElements).toHaveLength(2); - // First entry should be Bob (newest) - const firstEntry = entryElements[0]; + const firstEntry = entryElements?.[0]; expect(firstEntry).toBeTruthy(); if (firstEntry) { expect(firstEntry.textContent).toContain('Bob Student'); expect(firstEntry.textContent).toContain('5678'); // Last 4 of serviceId expect(firstEntry.textContent).toContain('Bob answer'); - // Should have timestamp in 24-hour format expect(firstEntry.textContent).toContain('15:30'); } + displayElement.remove(); }); }); describe('FR-013: Empty Cell Placeholder', () => { - it('should create placeholder for cell with no entries', () => { + it('should create placeholder for cell with no entries', async () => { const displayElement = createStudentEntriesDisplay([]); + document.body.appendChild(displayElement); + await displayElement.updateComplete; - expect(displayElement).toBeTruthy(); - expect(displayElement.textContent).toContain('(No entries yet)'); - expect(displayElement.className).toContain('qd-no-entries'); + expect(displayElement.shadowRoot?.textContent).toContain('(No entries yet)'); + expect(displayElement.shadowRoot?.querySelector('.qd-no-entries')).not.toBeNull(); + displayElement.remove(); }); }); }); diff --git a/tests/unit/idb-helpers.test.ts b/tests/unit/idb-helpers.test.ts new file mode 100644 index 00000000..982f4cba --- /dev/null +++ b/tests/unit/idb-helpers.test.ts @@ -0,0 +1,75 @@ +/** + * Unit tests for the IndexedDB request/transaction helpers (T027). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promisifyRequest, runTransaction } from '../../src/services/storage/idb-helpers.js'; +import { StorageError } from '../../src/services/storage/adapter-utils.js'; + +const DB_NAME = 'idb-helpers-test'; +const STORE = 'kv'; + +function openTestDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('open failed')); + }); +} + +describe('idb-helpers', () => { + let db: IDBDatabase; + + beforeEach(async () => { + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); + db = await openTestDb(); + }); + + afterEach(() => { + db.close(); + }); + + describe('runTransaction', () => { + it('resolves with the request result on success', async () => { + await runTransaction(db, STORE, 'readwrite', (s) => s.put('hello', 'k1'), 'put'); + const value = await runTransaction( + db, + STORE, + 'readonly', + (s) => s.get('k1') as IDBRequest, + 'get', + ); + expect(value).toBe('hello'); + }); + + it('rejects with a StorageError when the request fails', async () => { + // add() to an existing key fails with a ConstraintError + await runTransaction(db, STORE, 'readwrite', (s) => s.add('a', 'dup'), 'add'); + await expect( + runTransaction(db, STORE, 'readwrite', (s) => s.add('b', 'dup'), 'add'), + ).rejects.toBeInstanceOf(StorageError); + }); + + it('rejects with a StorageError when the store does not exist', async () => { + await expect( + runTransaction(db, 'missing-store', 'readonly', (s) => s.get('x'), 'get'), + ).rejects.toBeInstanceOf(StorageError); + }); + }); + + describe('promisifyRequest', () => { + it('resolves with the request result', async () => { + const tx = db.transaction(STORE, 'readwrite'); + const result = await promisifyRequest(tx.objectStore(STORE).put('v', 'k'), 'put'); + expect(result).toBe('k'); + }); + }); +}); diff --git a/tests/unit/qd-login.test.ts b/tests/unit/qd-login.test.ts index fefafca6..baa95778 100644 --- a/tests/unit/qd-login.test.ts +++ b/tests/unit/qd-login.test.ts @@ -65,15 +65,14 @@ describe('QdLogin Component', () => { expect(serviceIdInput.placeholder).toContain('Service ID'); }); - it('should render Login and Instructor buttons', () => { + it('should render the Login button and the instructor-login child', () => { const buttons = element.shadowRoot?.querySelectorAll('button'); - expect(buttons?.length).toBeGreaterThanOrEqual(2); - const loginBtn = Array.from(buttons!).find((b) => b.textContent?.includes('Login')); - const instructorBtn = Array.from(buttons!).find((b) => b.textContent?.includes('Instructor')); - expect(loginBtn).toBeDefined(); - expect(instructorBtn).toBeDefined(); + + // The Instructor button now lives inside the child. + const instructorLogin = element.shadowRoot?.querySelector('qd-instructor-login'); + expect(instructorLogin).not.toBeNull(); }); it('should allow custom title property', async () => { diff --git a/tests/unit/qd-student-table.test.ts b/tests/unit/qd-student-table.test.ts new file mode 100644 index 00000000..9d1b801a --- /dev/null +++ b/tests/unit/qd-student-table.test.ts @@ -0,0 +1,110 @@ +/** + * Component tests for (T045). + * + * Covers the search filter and the per-row `select` event, plus auto-escaped + * rendering of student-supplied names. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import '../../src/components/qd-student-table.js'; +import type { QdStudentTable } from '../../src/components/qd-student-table.js'; +import type { StudentRecord } from '../../src/types/contracts.js'; + +function makeStudent(serviceId: string, name: string): StudentRecord { + return { + schema: 2, + docId: '', + release: '06-2026', + serviceId, + name, + attempted: 0, + correct: 0, + updated: new Date().toISOString(), + pages: {}, + }; +} + +async function mount(students: StudentRecord[]): Promise { + const el = document.createElement('qd-student-table'); + el.students = students; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +function setSearch(el: QdStudentTable, value: string): void { + const input = el.shadowRoot?.querySelector('input.search-input'); + if (!input) throw new Error('search input not found'); + input.value = value; + input.dispatchEvent(new Event('input')); +} + +describe('qd-student-table', () => { + let el: QdStudentTable; + + beforeEach(async () => { + el = await mount([ + makeStudent('30011111', 'Alice Smith'), + makeStudent('30022222', 'Bob Jones'), + ]); + }); + + afterEach(() => { + el.remove(); + }); + + it('renders one row per student', () => { + const rows = el.shadowRoot?.querySelectorAll('tbody tr'); + expect(rows?.length).toBe(2); + }); + + it('filters by name (case-insensitive)', async () => { + setSearch(el, 'alice'); + await el.updateComplete; + const rows = el.shadowRoot?.querySelectorAll('tbody tr'); + expect(rows?.length).toBe(1); + expect(el.shadowRoot?.textContent).toContain('Alice Smith'); + }); + + it('filters by service ID', async () => { + setSearch(el, '30022222'); + await el.updateComplete; + const rows = el.shadowRoot?.querySelectorAll('tbody tr'); + expect(rows?.length).toBe(1); + expect(el.shadowRoot?.textContent).toContain('Bob Jones'); + }); + + it('shows an empty message when nothing matches', async () => { + setSearch(el, 'zzz'); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.empty-message')?.textContent).toContain('No matching'); + }); + + it('emits a select event with the row student', () => { + let received: StudentRecord | null = null; + el.addEventListener('select', (e) => { + received = (e as CustomEvent).detail; + }); + + const button = el.shadowRoot?.querySelector('tbody tr button.action-btn'); + button?.click(); + + expect(received).not.toBeNull(); + expect(received!.serviceId).toBe('30011111'); + }); + + it('uses the configured action label', async () => { + el.actionLabel = 'Reset'; + await el.updateComplete; + const button = el.shadowRoot?.querySelector('tbody tr button.action-btn'); + expect(button?.textContent?.trim()).toBe('Reset'); + }); + + it('renders student-supplied names as inert text (auto-escaped)', async () => { + el.students = [makeStudent('30033333', '')]; + await el.updateComplete; + // The raw string should be present as text, with no injected element. + expect(el.shadowRoot?.querySelector('tbody img')).toBeNull(); + expect(el.shadowRoot?.textContent).toContain(''); + }); +}); diff --git a/tests/unit/services/session.test.ts b/tests/unit/services/session.test.ts index 322f8452..1c8673ff 100644 --- a/tests/unit/services/session.test.ts +++ b/tests/unit/services/session.test.ts @@ -7,11 +7,8 @@ import { SessionService, getSessionService, resetSessionService, - buildCacheFromRecord, - buildPageCache, - updateCacheWithAnswer, } from '../../../src/services/session.js'; -import type { StudentRecord, SessionCache, PageData } from '../../../src/types/contracts.js'; +import type { SessionCache } from '../../../src/types/contracts.js'; import { STORAGE_KEYS, SESSION_TIMEOUT_MS } from '../../../src/types/contracts.js'; describe('Session Service', () => { @@ -407,240 +404,3 @@ describe('Session Service', () => { }); }); }); - -describe('Cache Building Utilities', () => { - describe('buildCacheFromRecord()', () => { - it('should build empty cache for record with no pages', () => { - const record: StudentRecord = { - schema: 1, - docId: 'doc-test', - release: '11-2024', - serviceId: 'RN2344', - name: 'Alice', - attempted: 0, - correct: 0, - updated: '2024-11-16T10:00:00Z', - pages: {}, - }; - - const cache = buildCacheFromRecord(record); - - expect(cache.totals.answered).toBe(0); - expect(cache.totals.correct).toBe(0); - expect(Object.keys(cache.pages)).toHaveLength(0); - }); - - it('should build cache with correct totals for single page', () => { - const record: StudentRecord = { - schema: 1, - docId: 'doc-test', - release: '11-2024', - serviceId: 'RN2344', - name: 'Alice', - attempted: 3, - correct: 2, - updated: '2024-11-16T10:00:00Z', - pages: { - 'page-1': { - state: 'complete', - answers: [ - { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' }, - { answer: 'b', success: false, timestamp: '2024-11-16T10:01:00Z' }, - { answer: 'c', success: true, timestamp: '2024-11-16T10:02:00Z' }, - ], - lastAttempted: '2024-11-16T10:02:00Z', - }, - }, - }; - - const cache = buildCacheFromRecord(record); - - expect(cache.totals.answered).toBe(3); - expect(cache.totals.correct).toBe(2); - expect(cache.pages['page-1']).toBeDefined(); - expect(cache.pages['page-1']?.answered).toBe(3); - expect(cache.pages['page-1']?.correct).toBe(2); - }); - - it('should build cache with correct totals for multiple pages', () => { - const record: StudentRecord = { - schema: 1, - docId: 'doc-test', - release: '11-2024', - serviceId: 'RN2344', - name: 'Alice', - attempted: 5, - correct: 4, - updated: '2024-11-16T10:00:00Z', - pages: { - 'page-1': { - state: 'complete', - answers: [ - { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' }, - { answer: 'b', success: true, timestamp: '2024-11-16T10:01:00Z' }, - ], - lastAttempted: '2024-11-16T10:01:00Z', - }, - 'page-2': { - state: 'incomplete', - answers: [ - { answer: 'c', success: true, timestamp: '2024-11-16T10:05:00Z' }, - { answer: 'd', success: false, timestamp: '2024-11-16T10:06:00Z' }, - { answer: 'e', success: true, timestamp: '2024-11-16T10:07:00Z' }, - ], - lastAttempted: '2024-11-16T10:07:00Z', - }, - }, - }; - - const cache = buildCacheFromRecord(record); - - expect(cache.totals.answered).toBe(5); - expect(cache.totals.correct).toBe(4); - expect(Object.keys(cache.pages)).toHaveLength(2); - }); - }); - - describe('buildPageCache()', () => { - it('should build page cache with correct counts', () => { - const pageData: PageData = { - state: 'complete', - answers: [ - { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' }, - { answer: 'b', success: false, timestamp: '2024-11-16T10:01:00Z' }, - { answer: 'c', success: true, timestamp: '2024-11-16T10:02:00Z' }, - ], - lastAttempted: '2024-11-16T10:02:00Z', - }; - - const cache = buildPageCache('page-1', pageData); - - expect(cache.answered).toBe(3); - expect(cache.correct).toBe(2); - expect(cache.state).toBe('complete'); - expect(cache.last).toBe('2024-11-16T10:02:00Z'); - }); - - it('should handle empty answers', () => { - const pageData: PageData = { - state: 'unstarted', - answers: [], - }; - - const cache = buildPageCache('page-1', pageData); - - expect(cache.answered).toBe(0); - expect(cache.correct).toBe(0); - expect(cache.state).toBe('unstarted'); - }); - }); - - describe('updateCacheWithAnswer()', () => { - it('should increment answered count', () => { - const cache: SessionCache = { - totals: { total: 5, answered: 5, correct: 3 }, - pages: { - 'page-1': { - state: 'incomplete', - total: 5, - answered: 2, - correct: 1, - }, - }, - }; - - const updated = updateCacheWithAnswer(cache, 'page-1', true, 'complete'); - - expect(updated.totals.answered).toBe(6); - expect(updated.pages['page-1']?.answered).toBe(3); - }); - - it('should increment correct count for correct answer', () => { - const cache: SessionCache = { - totals: { total: 5, answered: 5, correct: 3 }, - pages: { - 'page-1': { - state: 'incomplete', - total: 5, - answered: 2, - correct: 1, - }, - }, - }; - - const updated = updateCacheWithAnswer(cache, 'page-1', true, 'complete'); - - expect(updated.totals.correct).toBe(4); - expect(updated.pages['page-1']?.correct).toBe(2); - }); - - it('should not increment correct count for incorrect answer', () => { - const cache: SessionCache = { - totals: { total: 5, answered: 5, correct: 3 }, - pages: { - 'page-1': { - state: 'incomplete', - total: 5, - answered: 2, - correct: 1, - }, - }, - }; - - const updated = updateCacheWithAnswer(cache, 'page-1', false, 'incomplete'); - - expect(updated.totals.correct).toBe(3); - expect(updated.pages['page-1']?.correct).toBe(1); - }); - - it('should update state', () => { - const cache: SessionCache = { - totals: { total: 5, answered: 5, correct: 3 }, - pages: { - 'page-1': { - state: 'incomplete', - total: 5, - answered: 2, - correct: 1, - }, - }, - }; - - const updated = updateCacheWithAnswer(cache, 'page-1', true, 'complete'); - - expect(updated.pages['page-1']?.state).toBe('complete'); - }); - - it('should create new page entry if it does not exist', () => { - const cache: SessionCache = { - totals: { total: 0, answered: 0, correct: 0 }, - pages: {}, - }; - - const updated = updateCacheWithAnswer(cache, 'page-new', true, 'incomplete'); - - expect(updated.pages['page-new']).toBeDefined(); - expect(updated.pages['page-new']?.answered).toBe(1); - expect(updated.pages['page-new']?.correct).toBe(1); - }); - - it('should not mutate original cache', () => { - const cache: SessionCache = { - totals: { total: 5, answered: 5, correct: 3 }, - pages: { - 'page-1': { - state: 'incomplete', - total: 5, - answered: 2, - correct: 1, - }, - }, - }; - - const original = JSON.parse(JSON.stringify(cache)) as SessionCache; - updateCacheWithAnswer(cache, 'page-1', true, 'complete'); - - expect(cache).toEqual(original); - }); - }); -}); diff --git a/tests/unit/session-cache.test.ts b/tests/unit/session-cache.test.ts new file mode 100644 index 00000000..3bcf14d6 --- /dev/null +++ b/tests/unit/session-cache.test.ts @@ -0,0 +1,252 @@ +/** + * Characterization tests for session cache math (T025). + * + * Pure cache-building helpers extracted from session.ts into session-cache.ts. + * These capture the existing behavior of buildCacheFromRecord, buildPageCache, + * and updateCacheWithAnswer before/after the extraction. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildCacheFromRecord, + buildPageCache, + updateCacheWithAnswer, +} from '../../src/services/session-cache.js'; +import type { StudentRecord, SessionCache, PageData } from '../../src/types/contracts.js'; + +describe('Cache Building Utilities', () => { + describe('buildCacheFromRecord()', () => { + it('should build empty cache for record with no pages', () => { + const record: StudentRecord = { + schema: 1, + docId: 'doc-test', + release: '11-2024', + serviceId: 'RN2344', + name: 'Alice', + attempted: 0, + correct: 0, + updated: '2024-11-16T10:00:00Z', + pages: {}, + }; + + const cache = buildCacheFromRecord(record); + + expect(cache.totals.answered).toBe(0); + expect(cache.totals.correct).toBe(0); + expect(Object.keys(cache.pages)).toHaveLength(0); + }); + + it('should build cache with correct totals for single page', () => { + const record: StudentRecord = { + schema: 1, + docId: 'doc-test', + release: '11-2024', + serviceId: 'RN2344', + name: 'Alice', + attempted: 3, + correct: 2, + updated: '2024-11-16T10:00:00Z', + pages: { + 'page-1': { + state: 'complete', + answers: [ + { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' }, + { answer: 'b', success: false, timestamp: '2024-11-16T10:01:00Z' }, + { answer: 'c', success: true, timestamp: '2024-11-16T10:02:00Z' }, + ], + lastAttempted: '2024-11-16T10:02:00Z', + }, + }, + }; + + const cache = buildCacheFromRecord(record); + + expect(cache.totals.answered).toBe(3); + expect(cache.totals.correct).toBe(2); + expect(cache.pages['page-1']).toBeDefined(); + expect(cache.pages['page-1']?.answered).toBe(3); + expect(cache.pages['page-1']?.correct).toBe(2); + }); + + it('should build cache with correct totals for multiple pages', () => { + const record: StudentRecord = { + schema: 1, + docId: 'doc-test', + release: '11-2024', + serviceId: 'RN2344', + name: 'Alice', + attempted: 5, + correct: 4, + updated: '2024-11-16T10:00:00Z', + pages: { + 'page-1': { + state: 'complete', + answers: [ + { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' }, + { answer: 'b', success: true, timestamp: '2024-11-16T10:01:00Z' }, + ], + lastAttempted: '2024-11-16T10:01:00Z', + }, + 'page-2': { + state: 'incomplete', + answers: [ + { answer: 'c', success: true, timestamp: '2024-11-16T10:05:00Z' }, + { answer: 'd', success: false, timestamp: '2024-11-16T10:06:00Z' }, + { answer: 'e', success: true, timestamp: '2024-11-16T10:07:00Z' }, + ], + lastAttempted: '2024-11-16T10:07:00Z', + }, + }, + }; + + const cache = buildCacheFromRecord(record); + + expect(cache.totals.answered).toBe(5); + expect(cache.totals.correct).toBe(4); + expect(Object.keys(cache.pages)).toHaveLength(2); + }); + }); + + describe('buildPageCache()', () => { + it('should build page cache with correct counts', () => { + const pageData: PageData = { + state: 'complete', + answers: [ + { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' }, + { answer: 'b', success: false, timestamp: '2024-11-16T10:01:00Z' }, + { answer: 'c', success: true, timestamp: '2024-11-16T10:02:00Z' }, + ], + lastAttempted: '2024-11-16T10:02:00Z', + }; + + const cache = buildPageCache('page-1', pageData); + + expect(cache.answered).toBe(3); + expect(cache.correct).toBe(2); + expect(cache.state).toBe('complete'); + expect(cache.last).toBe('2024-11-16T10:02:00Z'); + }); + + it('should handle empty answers', () => { + const pageData: PageData = { + state: 'unstarted', + answers: [], + }; + + const cache = buildPageCache('page-1', pageData); + + expect(cache.answered).toBe(0); + expect(cache.correct).toBe(0); + expect(cache.state).toBe('unstarted'); + }); + }); + + describe('updateCacheWithAnswer()', () => { + it('should increment answered count', () => { + const cache: SessionCache = { + totals: { total: 5, answered: 5, correct: 3 }, + pages: { + 'page-1': { + state: 'incomplete', + total: 5, + answered: 2, + correct: 1, + }, + }, + }; + + const updated = updateCacheWithAnswer(cache, 'page-1', true, 'complete'); + + expect(updated.totals.answered).toBe(6); + expect(updated.pages['page-1']?.answered).toBe(3); + }); + + it('should increment correct count for correct answer', () => { + const cache: SessionCache = { + totals: { total: 5, answered: 5, correct: 3 }, + pages: { + 'page-1': { + state: 'incomplete', + total: 5, + answered: 2, + correct: 1, + }, + }, + }; + + const updated = updateCacheWithAnswer(cache, 'page-1', true, 'complete'); + + expect(updated.totals.correct).toBe(4); + expect(updated.pages['page-1']?.correct).toBe(2); + }); + + it('should not increment correct count for incorrect answer', () => { + const cache: SessionCache = { + totals: { total: 5, answered: 5, correct: 3 }, + pages: { + 'page-1': { + state: 'incomplete', + total: 5, + answered: 2, + correct: 1, + }, + }, + }; + + const updated = updateCacheWithAnswer(cache, 'page-1', false, 'incomplete'); + + expect(updated.totals.correct).toBe(3); + expect(updated.pages['page-1']?.correct).toBe(1); + }); + + it('should update state', () => { + const cache: SessionCache = { + totals: { total: 5, answered: 5, correct: 3 }, + pages: { + 'page-1': { + state: 'incomplete', + total: 5, + answered: 2, + correct: 1, + }, + }, + }; + + const updated = updateCacheWithAnswer(cache, 'page-1', true, 'complete'); + + expect(updated.pages['page-1']?.state).toBe('complete'); + }); + + it('should create new page entry if it does not exist', () => { + const cache: SessionCache = { + totals: { total: 0, answered: 0, correct: 0 }, + pages: {}, + }; + + const updated = updateCacheWithAnswer(cache, 'page-new', true, 'incomplete'); + + expect(updated.pages['page-new']).toBeDefined(); + expect(updated.pages['page-new']?.answered).toBe(1); + expect(updated.pages['page-new']?.correct).toBe(1); + }); + + it('should not mutate original cache', () => { + const cache: SessionCache = { + totals: { total: 5, answered: 5, correct: 3 }, + pages: { + 'page-1': { + state: 'incomplete', + total: 5, + answered: 2, + correct: 1, + }, + }, + }; + + const original = JSON.parse(JSON.stringify(cache)) as SessionCache; + updateCacheWithAnswer(cache, 'page-1', true, 'complete'); + + expect(cache).toEqual(original); + }); + }); +});