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');
  });
});