pediatric-ai-scribe-v3/test/deck-formats.test.js
Daniel 381483dcf3 fix: a question slide's options are lettered once, and a bare-letter answer names its option
DeepSeek flash wrote 'A. Start IVIG' into option text the renderer already
letters, so the slide read 'A  A. Start IVIG'; and an answer of just 'A' sat
alone on the answer card, sending the room back a slide to see what A was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
2026-09-14 15:14:32 +02:00

82 lines
4.7 KiB
JavaScript

// A deck has a shape as well as a look: a journal club runs PICO → methods →
// appraisal → bottom line, a board review runs in case → question → answer
// cycles. The format is a recipe the model follows and may repeat, never a
// slide type of its own.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
const deckFormats = require('../src/utils/deckFormats');
const deckSchema = require('../src/utils/deckSchema');
const catalogue = JSON.parse(read('assets/deck-formats.json')).formats;
const LAYOUTS = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'question', 'figure', 'image', 'flow', 'custom'];
test('every format is complete and speaks only the slide vocabulary', () => {
assert.ok(catalogue.length >= 5, 'a picker of a few is not a choice');
for (const format of catalogue) {
for (const key of ['id', 'name', 'audience', 'description', 'sequence']) {
assert.ok(format[key], format.id + ' is missing ' + key);
}
assert.ok(format.sequence.length >= 3, format.id + ' has too few parts');
for (const part of format.sequence) {
assert.ok(part.part && part.purpose, format.id + ' has a part without a name or purpose');
for (const layout of part.layouts || []) {
assert.ok(LAYOUTS.includes(layout), format.id + ' names a layout the renderer cannot draw: ' + layout);
}
}
assert.ok(format.sequence.some(p => p.repeat), format.id + ' has no part the model may reuse');
}
const ids = catalogue.map(f => f.id);
assert.equal(ids.length, new Set(ids).size, 'format ids must be unique');
});
test('the brief lists the parts in order, says which repeat, and never invents a slide type', () => {
const format = catalogue[0];
const brief = deckFormats.instructions(format.id);
assert.match(brief, new RegExp('FORMAT: this is a ' + format.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
format.sequence.forEach((part, i) => {
assert.ok(brief.indexOf((i + 1) + '. ' + part.part) !== -1, 'part ' + part.part + ' is listed in order');
});
assert.match(brief, /\(repeatable\)/);
assert.match(brief, /may be used more than once/);
assert.match(brief, /Every slide is still one of the types above/);
assert.equal(deckFormats.instructions('not-a-format'), '');
assert.equal(deckFormats.instructions(''), '');
});
test('an unknown format is dropped rather than stored, and a known one survives normalisation', () => {
const slides = [{ type: 'bullets', heading: 'x', bullets: ['a'] }];
assert.equal(deckSchema.normalise({ format: catalogue[0].id, slides }).format, catalogue[0].id);
assert.equal(deckSchema.normalise({ format: 'not-a-format', slides }).format, '');
assert.equal(deckSchema.normalise({ slides }).format, '');
});
test('the format reaches the model after the layouts, on generation and on revision', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /deckSchema\.instructions\(opts\.slideCount, opts\.figureCount\) \+[\s\S]{0,400}deckFormats\.instructions\(opts\.format\)/);
assert.match(route, /deckFormats\.instructions\(existingDeck\.format\)/);
assert.match(route, /revisedDeck\.format = existingDeck\.format/);
assert.match(route, /formats: deckFormats\.catalogue\(\)/);
const ui = read('public/js/myResources.js');
assert.match(ui, /format: \(document\.getElementById\('mr-format'\) \|\| \{\}\)\.value/);
});
test('the picker is told what a format is for, never the recipe itself', () => {
for (const entry of deckFormats.catalogue()) {
assert.deepEqual(Object.keys(entry).sort(), ['audience', 'description', 'id', 'length', 'name']);
}
});
test('a question slide never letters its options twice, and a bare-letter answer names its option', () => {
const deck = deckSchema.normalise({ slides: [{ type: 'question', heading: 'Case 1', question: 'Next step?',
options: ['A. Start IVIG plus aspirin', 'B) Antibiotics', '(C) Observe', 'Give NSAIDs'], answer: 'A', explanation: 'x' }] });
const q = deck.slides[0];
assert.deepEqual(q.options, ['Start IVIG plus aspirin', 'Antibiotics', 'Observe', 'Give NSAIDs']);
assert.equal(q.answer, 'A — Start IVIG plus aspirin');
const prose = deckSchema.normalise({ slides: [{ type: 'question', question: 'q', options: ['x', 'y'], answer: 'Start IVIG' }] });
assert.equal(prose.slides[0].answer, 'Start IVIG', 'a written-out answer is left alone');
const stray = deckSchema.normalise({ slides: [{ type: 'question', question: 'q', options: ['x'], answer: 'D.' }] });
assert.equal(stray.slides[0].answer, 'D', 'a letter with no such option is shown as the letter');
});