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
This commit is contained in:
Daniel 2026-09-14 15:14:32 +02:00
parent aac02ec0df
commit 381483dcf3
2 changed files with 32 additions and 2 deletions

View file

@ -97,6 +97,20 @@ function text(value, max) {
.slice(0, max || 400).trim(); .slice(0, max || 400).trim();
} }
var OPTION_LETTER = /^\(?([A-Ha-h])[.):]\s+/;
function unletter(option) {
return option.replace(OPTION_LETTER, '');
}
function answerWithOption(answer, options) {
var letter = /^\(?([A-Ha-h])[.):]?$/.exec(answer.trim());
if (!letter) return answer;
var at = 'ABCDEFGH'.indexOf(letter[1].toUpperCase());
if (at < 0 || !options[at]) return letter[1].toUpperCase();
return letter[1].toUpperCase() + ' — ' + options[at];
}
function bullets(list) { function bullets(list) {
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
return list.slice(0, 14).map(function (item) { return list.slice(0, 14).map(function (item) {
@ -167,9 +181,13 @@ function normalise(raw, gaps) {
if (!out.text) return; if (!out.text) return;
} else if (type === 'question') { } else if (type === 'question') {
out.question = text(slide.question || slide.text, 400); out.question = text(slide.question || slide.text, 400);
// The renderer letters the options itself, so a letter the model wrote
// into the text ("A. Start IVIG") would be drawn twice.
out.options = (Array.isArray(slide.options) ? slide.options : []).slice(0, 6) out.options = (Array.isArray(slide.options) ? slide.options : []).slice(0, 6)
.map(function (o) { return text(typeof o === 'string' ? o : (o && o.text), 160); }).filter(Boolean); .map(function (o) { return unletter(text(typeof o === 'string' ? o : (o && o.text), 160)); }).filter(Boolean);
out.answer = text(slide.answer, 300); // An answer given as a bare letter is shown with the option it names: a
// card reading "A" alone made the room look back at the previous slide.
out.answer = answerWithOption(text(slide.answer, 300), out.options);
out.explanation = text(slide.explanation, 500); out.explanation = text(slide.explanation, 500);
if (!out.question) return; if (!out.question) return;
} else if (type === 'title') { } else if (type === 'title') {

View file

@ -68,3 +68,15 @@ test('the picker is told what a format is for, never the recipe itself', () => {
assert.deepEqual(Object.keys(entry).sort(), ['audience', 'description', 'id', 'length', 'name']); 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');
});