Spaces:
Running
Running
File size: 12,714 Bytes
469c3b7 d6f5fcf 469c3b7 d6f5fcf 469c3b7 d6f5fcf 469c3b7 d6f5fcf 469c3b7 d6f5fcf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | 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> = {}): 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<string, string>();
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<string, string>();
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');
});
});
|