import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { enoughReads, MIN_READS, readsToGo } from '../dashboard/helpers'; import { addLocalReads, availableModalities, caseNoun, forgetLearner, guardAttemptResult, learnerLabel, loadLocalReads, loadModality, loadRemembered, looksLikeCode, markTestDone, needsTutorial, nextTest, parseLocalReads, parseRemembered, readPath, readsFor, remember, sameLearner, sampleSessionCreate, saveModality, testAvailable, toSessionCreate, weakSpotsNote, weakSpotsReady, type Remembered, type StartForm, } from './sessionOptions'; const form = (over: Partial = {}): StartForm => ({ name: '', practice: 'mixed', finding: 'nodule', count: 10, ...over }); const fresh = { remembered: null, reads: null }; const maya: Remembered = { learnerId: 'lrn_1', name: 'Maya', tests: [] }; describe('start form → SessionCreate', () => { it('a mixed set is adaptive practice with the chosen length, and level is always "other"', () => { expect(toSessionCreate(form({ name: ' Maya ', count: 5 }), fresh)).toEqual({ display_name: 'Maya', participant_code: null, level: 'other', mode: 'practice', settings: { case_count: 5, selection: 'adaptive' }, }); }); it('a blank name stays anonymous', () => { const b = toSessionCreate(form(), fresh); expect(b.display_name).toBe('Anonymous'); expect(b.participant_code).toBeNull(); expect(b.settings).toEqual({ case_count: 10, selection: 'adaptive' }); }); it('a personal code is sent as the participant code', () => { expect(toSessionCreate(form({ name: 'MK-2041' }), fresh)).toMatchObject({ display_name: 'MK-2041', participant_code: 'MK-2041' }); expect(looksLikeCode('Maya')).toBe(false); }); it('one finding type is a drill on that label', () => { expect(toSessionCreate(form({ practice: 'finding', finding: 'pneumothorax', count: 20 }), fresh)).toMatchObject({ mode: 'drill', settings: { case_count: 20, selection: 'adaptive', label: 'pneumothorax' }, }); }); it('weak spots needs 5 reads; before that it falls back to a mixed set', () => { expect(toSessionCreate(form({ practice: 'weak' }), { remembered: maya, reads: 4 }).settings).toMatchObject({ selection: 'adaptive' }); const b = toSessionCreate(form({ name: 'Maya', practice: 'weak' }), { remembered: maya, reads: 5 }); expect(b.mode).toBe('practice'); expect(b.settings).toEqual({ learner_id: 'lrn_1', case_count: 10, selection: 'weak_areas' }); }); it('the test set has no length or selection: A first, B once A is done, then A again', () => { const a = toSessionCreate(form({ practice: 'test', count: 5 }), fresh); expect(a.mode).toBe('assess_A'); expect(a.settings).toEqual({}); expect(toSessionCreate(form({ name: 'Maya', practice: 'test' }), { remembered: { ...maya, tests: ['A'] }, reads: 0 }).mode).toBe('assess_B'); expect(toSessionCreate(form({ name: 'Maya', practice: 'test' }), { remembered: { ...maya, tests: ['A', 'B'] }, reads: 0 }).mode).toBe('assess_A'); expect(nextTest(['A', 'B'])).toEqual({ mode: 'assess_A', set: 'A', repeat: true }); // Someone else's finished test does not move a new name on to B. expect(toSessionCreate(form({ name: 'Sam', practice: 'test' }), { remembered: { ...maya, tests: ['A'] }, reads: 0 }).mode).toBe('assess_A'); }); it('the remembered learner is resumed only while the name still matches', () => { expect(toSessionCreate(form({ name: 'maya' }), { remembered: maya, reads: 9 }).settings).toMatchObject({ learner_id: 'lrn_1' }); expect(toSessionCreate(form({ name: 'Sam' }), { remembered: maya, reads: 9 }).settings).not.toHaveProperty('learner_id'); const anon: Remembered = { learnerId: 'lrn_2', name: '', tests: [] }; expect(sameLearner(anon, ' ')).toBe(true); expect(toSessionCreate(form(), { remembered: anon, reads: 0 }).settings).toMatchObject({ learner_id: 'lrn_2' }); }); it('projector mode rides along in settings', () => { expect(toSessionCreate(form(), { ...fresh, projector: true }).settings).toMatchObject({ projector: true }); }); it('the sample set uses the reserved name and nothing else; a learner typing that name does not', () => { expect(sampleSessionCreate()).toEqual({ display_name: 'Demo', level: 'other', participant_code: null, mode: 'practice', settings: {} }); expect(toSessionCreate(form({ name: 'demo' }), fresh).display_name).toBe('demo (reader)'); expect(learnerLabel('Demo')).toBe('Sample set'); expect(learnerLabel('Anonymous')).toBe('Anonymous reader'); expect(learnerLabel('Maya')).toBe('Maya'); }); }); describe('fewer than 5 reads', () => { it('gates the headline numbers and the weak-spots option at the same threshold', () => { expect(MIN_READS).toBe(5); for (const n of [null, undefined, Number.NaN, 0, 4]) { expect(enoughReads(n)).toBe(false); expect(weakSpotsReady(n)).toBe(false); } for (const n of [5, 6, 200]) { expect(enoughReads(n)).toBe(true); expect(weakSpotsReady(n)).toBe(true); } }); it('says how many films are left', () => { expect(readsToGo(0)).toBe(5); expect(readsToGo(3)).toBe(2); expect(readsToGo(5)).toBe(0); expect(readsToGo(null)).toBe(5); expect(weakSpotsNote(2)).toBe('Opens after 5 films, so there is something to go on. You have read 2; 3 to go.'); expect(weakSpotsNote(8)).toBe('Films like the ones you have missed most.'); }); }); describe('guards', () => { it('reads a remembered learner and rejects junk', () => { expect(parseRemembered('{"learnerId":"x","name":"Maya","tests":["A","C"]}')).toEqual({ learnerId: 'x', name: 'Maya', tests: ['A'] }); expect(parseRemembered('{"name":"Maya"}')).toBeNull(); expect(parseRemembered('not json')).toBeNull(); expect(parseRemembered(null)).toBeNull(); }); it('takes a stored result with or without the film and the submitted marks', () => { const base = { score: 70, success: true, outcomes: [], reveal: { findings: [] }, facts_card: { headline: 'h', lines: ['a', 3] } }; const bare = guardAttemptResult(base); expect(bare.film).toBeNull(); expect(bare.marks).toBeNull(); expect(bare.result.reveal.marks).toEqual([]); expect(bare.result.facts_card.lines).toEqual(['a']); const full = guardAttemptResult({ ...base, case: { case_id: 'cxd_1', image_url: '/api/cases/cxd_1/image', width: 1024, height: 1024 }, submitted: { marks: [{ mark_id: 'M1', x: 10, y: 20, label: 'nodule', confidence: 4 }, { x: 'bad' }], declared_normal: false }, }); expect(full.film).toEqual({ case_id: 'cxd_1', image_url: '/api/cases/cxd_1/image', width: 1024, height: 1024, volume: null }); expect(full.marks).toEqual([{ mark_id: 'M1', x: 10, y: 20, label: 'nodule', confidence: 4 }]); expect(full.declaredNormal).toBe(false); expect(full.modality).toBeNull(); expect(full.provenance).toBeNull(); expect(() => guardAttemptResult({ recorded: true })).toThrow(); }); it('a CT read carries its scan type, the reveal provenance and the case volume block through (round 4)', () => { const prov = { dataset: 'Medical Segmentation Decathlon, Task07 Pancreas', segmented_by: 'an abdominal radiologist (single reader)' }; const vol = { shape: [16, 64, 64], spacing: [3, 1.5, 1.5], window: { wc: 50, ww: 400 }, data_url: '/api/cases/vol_001/volume' }; const r = guardAttemptResult({ score: 80, success: true, outcomes: [], facts_card: { headline: 'h', lines: [] }, reveal: { findings: [], modality: 'ct', provenance: prov, maskvol_url: '/api/attempts/a/maskvol' }, case: { case_id: 'vol_001', image_url: '/api/cases/vol_001/image', width: 64, height: 64, modality: 'ct', volume: vol }, }); expect(r.modality).toBe('ct'); expect(r.provenance).toEqual(prov); expect(r.film?.volume).toEqual(vol); expect(guardAttemptResult({ score: 1, success: true, outcomes: [], reveal: { findings: [] }, facts_card: { headline: '', lines: [] }, modality: 'mr' }).modality).toBe('mr'); }); }); describe('scan type (round 4)', () => { const store = new Map(); const fake = { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), removeItem: (k: string) => void store.delete(k) }; beforeEach(() => { vi.stubGlobal('localStorage', fake); }); afterEach(() => { store.clear(); vi.unstubAllGlobals(); }); it('a CT set says so and names the region; an X-ray body is unchanged', () => { const ct = toSessionCreate(form({ modality: 'ct', count: 5 }), fresh); expect(ct.settings).toEqual({ case_count: 5, selection: 'adaptive', modality: 'ct', body_region: 'abdomen' }); const mr = toSessionCreate(form({ modality: 'mr', practice: 'finding', finding: 'brain_tumour' }), fresh); expect(mr.mode).toBe('drill'); expect(mr.settings).toMatchObject({ modality: 'mr', body_region: 'brain', label: 'brain_tumour' }); expect(toSessionCreate(form({ modality: 'cxr' }), fresh).settings).toEqual({ case_count: 10, selection: 'adaptive' }); }); it('the test sets are chest films only', () => { expect(testAvailable('cxr')).toBe(true); expect(testAvailable(undefined)).toBe(true); expect(testAvailable('ct')).toBe(false); }); it('films become studies off the X-ray', () => { expect(caseNoun('cxr', 2)).toBe('films'); expect(caseNoun('ct')).toBe('study'); expect(caseNoun('mr', 3)).toBe('studies'); expect(weakSpotsNote(2, 'ct')).toBe('Opens after 5 studies of this scan type, so there is something to go on. You have read 2; 3 to go.'); expect(weakSpotsNote(5, 'mr')).toBe('Studies like the ones you have missed most.'); expect(weakSpotsNote(2)).toBe('Opens after 5 films, so there is something to go on. You have read 2; 3 to go.'); }); it('the chosen scan type is remembered on this device, and an unknown value is the chest film', () => { expect(loadModality()).toBe('cxr'); saveModality('mr'); expect(loadModality()).toBe('mr'); localStorage.setItem('bs_modality', 'pet'); expect(loadModality()).toBe('cxr'); }); it('which scan types the library holds comes from health', () => { expect(availableModalities(undefined)).toEqual(['cxr']); expect(availableModalities({ cases_by_modality: { cxr: 500, ct: 12, mr: 0 } })).toEqual(['cxr', 'ct']); expect(availableModalities({ cases_by_modality: null, modalities: ['mr', 'cxr'] })).toEqual(['cxr', 'mr']); }); it('reads per scan type: the server count when it sends one, all reads for X-ray on an older server, the local estimate for CT / MR', () => { expect(readsFor({ n_attempts: 9, n_by_modality: { cxr: 7, ct: 2 } }, 'ct', {})).toEqual({ n: 2, estimated: false }); expect(readsFor({ n_attempts: 9, n_by_modality: { cxr: 7 } }, 'mr', { mr: 4 })).toEqual({ n: 0, estimated: false }); expect(readsFor({ n_attempts: 9 }, 'cxr', { ct: 4 })).toEqual({ n: 9, estimated: false }); expect(readsFor({ n_attempts: 9 }, 'ct', { ct: 4 })).toEqual({ n: 4, estimated: true }); addLocalReads('ct', 5); addLocalReads('ct', 5); expect(loadLocalReads()).toEqual({ ct: 10 }); expect(parseLocalReads('{"ct": 3, "pet": 2, "mr": -1}')).toEqual({ ct: 3 }); expect(parseLocalReads('nope')).toEqual({}); }); }); describe('this device remembers the learner', () => { const store = new Map(); const fake = { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), removeItem: (k: string) => void store.delete(k) }; afterEach(() => { store.clear(); vi.unstubAllGlobals(); }); it('keeps the reader and the tests they finished; a different reader starts clean', () => { vi.stubGlobal('localStorage', fake); expect(loadRemembered()).toBeNull(); remember('lrn_1', ' Maya '); expect(loadRemembered()).toEqual({ learnerId: 'lrn_1', name: 'Maya', tests: [] }); markTestDone('lrn_1', 'assess_A'); markTestDone('lrn_1', 'assess_A'); markTestDone('someone_else', 'assess_B'); markTestDone('lrn_1', 'practice'); expect(loadRemembered()?.tests).toEqual(['A']); expect(remember('lrn_1', 'Maya').tests).toEqual(['A']); expect(remember('lrn_2', 'Sam').tests).toEqual([]); forgetLearner(); expect(loadRemembered()).toBeNull(); }); it('asks for the tutorial on the first session only, and never when storage is blocked', () => { expect(needsTutorial()).toBe(false); // no localStorage at all: do not nag vi.stubGlobal('localStorage', fake); expect(readPath()).toBe('/read?tutorial=1'); store.set('bs_tutorial_done', '1'); expect(readPath()).toBe('/read'); }); });