pediatric-ai-scribe-v3/test/slide-spec.test.js
Daniel 012346528c
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 51s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m35s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: generation stopped working whenever the slide reviewer was switched off
savedFigureIds was declared inside the review branch, so with no reviewer
configured — the default, and what everyone is running — it was undefined by the
time the INSERT stringified it. JSON.stringify(undefined) is not a string, the
column is NOT NULL, and every generation failed with "Generation failed". `var`
is function-scoped, so nothing complained until the database did.

This is the second bug of exactly this shape in this file, so the test asserts
position rather than presence: the value must be declared before both the review
and the insert read it.

Found by the logging added in the same change, which is the other half of this
commit. Every modification now says what it did:

  [my-resources] refine id=29 path=deck outcome=applied 13→14 slides changed=yes
  [my-resources] refine id=37 path=markdown outcome=applied 2635→3018 chars changed=yes

CHANGED=no is warn-level and deliberately shouty, because that is the failure
worth catching: the response says success either way, the row updates, and the
download is identical — which is exactly how the deck bug went unnoticed. A
refusal logs its reason. ped_ai_resource_refine_total{path,outcome} counts the
same thing over time, so "did that modification do anything" is answerable
without watching logs live.

Verified across every path rather than the one that was broken: a deck
presentation modified and exported to both pptx and docx carries the change; a
legacy presentation with no stored deck still takes the markdown path and
carries it; an article generates, modifies and exports; and a presentation
generates with the reviewer off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 00:57:30 +02:00

186 lines
9.3 KiB
JavaScript

// ============================================================
// SLIDE SPEC
// ============================================================
// The half of deck rendering that decides what each slide is. Pandoc had no
// such step — every slide became bullets on a reference layout — so this is
// where most of the difference in a generated deck now comes from.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const slideSpec = require('../src/utils/slideSpec');
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
const DECK = [
'% Croup in Children', '% Teaching Resource', '% 2026', '',
'# What Croup Is', '', '- A viral illness', '- Peaks at 12-18 months', '',
'# Severity', '',
'| Feature | Mild | Severe |', '|---|---|---|',
'| Stridor | Absent | Present |', '| Retractions | None | Marked |', '',
'# Long list', '',
'- one', '- two', '- three', '- four', '- five', '- six', '- seven', '- eight', '',
'# References', '', '- Nelson, p. 2606'
].join('\n');
test('a title block becomes a title slide, not a bullet', () => {
const spec = slideSpec.build(DECK, {});
assert.equal(spec.slides[0].type, 'title');
assert.equal(spec.slides[0].heading, 'Croup in Children');
assert.equal(spec.slides[0].subtitle, 'Teaching Resource');
assert.equal(spec.slides[0].date, '2026');
});
test('each slide gets the layout its content needs', () => {
const byHeading = {};
slideSpec.build(DECK, {}).slides.forEach(s => { if (s.heading) byHeading[s.heading] = s; });
assert.equal(byHeading['What Croup Is'].type, 'bullets');
// A pipe table is a table, not eight lines of text with pipes in them.
assert.equal(byHeading['Severity'].type, 'table');
assert.deepEqual(byHeading['Severity'].header, ['Feature', 'Mild', 'Severe']);
assert.equal(byHeading['Severity'].rows.length, 2, 'the alignment rule is not a row');
// A long list is unreadable at any legible size in one column.
assert.equal(byHeading['Long list'].type, 'two');
assert.equal(byHeading['Long list'].left.length + byHeading['Long list'].right.length, 8);
});
test('figures are spread through the deck, and References stays last', () => {
const spec = slideSpec.build(DECK, { images: ['/tmp/a.png', '/tmp/b.png'] });
const types = spec.slides.map(s => s.type);
assert.equal(types.filter(t => t === 'image').length, 2);
// Appending them would end the deck with unexplained pictures.
assert.notEqual(types[types.length - 1], 'image');
assert.equal(spec.slides[spec.slides.length - 1].heading, 'References');
// And never before the first content slide.
assert.ok(types.indexOf('image') > 1);
});
test('a slide with no list still becomes a slide', () => {
const spec = slideSpec.build('# Just a heading\n', {});
assert.equal(spec.slides[0].type, 'section');
assert.equal(spec.slides[0].heading, 'Just a heading');
});
test('the renderer sizes text to fit rather than trusting autofit', () => {
const py = read('scripts/render_pptx.py');
// LibreOffice ignores <a:normAutofit/> when converting to PDF, which is how
// slides were being cut off mid-sentence.
assert.match(py, /def _fit_size\(/);
assert.match(py, /BULLET_SIZES = /);
// 16:9. Pandoc's reference doc is 4:3.
assert.match(py, /SLIDE_W = Emu\(12192000\)/);
// An image keeps its own aspect ratio; the old pptxgenjs path stretched every
// one of them to the target box.
assert.match(py, /ratio = img\.width \/ float\(img\.height\)/);
assert.match(py, /width = int\(height \* ratio\)/);
// Wrapped lines hang under the text.
assert.match(py, /pPr\.set\("indent", str\(-indent\)\)/);
});
test('the deck renderer replaces pandoc, and pandoc still catches it if it falls', () => {
const exporter = read('src/utils/documentExport.js');
assert.match(exporter, /async function buildDeck\(markdown, workdir, images, options\)/);
// One spawn helper, now that the document renderer uses it too.
assert.match(exporter, /runRenderer\(DECK_RENDERER, out, spec, workdir\)/);
assert.match(exporter, /spawn\('python3', \[script, out\]/);
// A plainer deck beats a failed download.
assert.match(exporter, /deck renderer failed, falling back to pandoc/);
assert.match(exporter, /runPandoc\(\['doc\.md', '--reference-doc=' \+ REFERENCE_DECK/);
// Word is still pandoc's, where its output is good.
assert.match(exporter, /runPandoc\(\['doc\.md', '-o', 'doc\.docx'\]/);
// And the runtime actually has it.
assert.match(read('Dockerfile'), /python-pptx==1\.0\.2/);
});
test('a resource remembers its figures, so an export can include them', () => {
const route = read('src/routes/myResources.js');
// They were queued and shown on screen, but nothing tied them to the
// resource, so an exported deck could never contain them.
assert.match(read('migrations/1780500000000_resource-images.js'), /image_ids JSONB/);
assert.match(route, /var savedFigureIds = \(ai\.imageJobs \|\| \[\]\)\.map/);
// "Add two more diagrams" means more, not instead.
assert.match(route, /image_ids = image_ids \|\| \?::jsonb/);
assert.match(route, /async function collectFigures\(ids, user, dir\)/);
// A figure that cannot be fetched is left out rather than failing a download.
assert.match(route, /figure unavailable for export/);
assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format,\s*\n?\s*\{ images: figures, deck: row\.deck/);
});
// ── Deck mode ───────────────────────────────────────────────
// The model designs the deck instead of writing markdown for a parser to guess
// at. Measured on a live generation: the model chose section, bullets, table,
// compare, figure, callout and image layouts for one topic — none of which
// markdown can ask for.
const deckSchema = require('../src/utils/deckSchema');
const deckBuild = require('../src/utils/deckBuild');
test('a model reply is read as a deck even when it is wrapped', () => {
// Fences and a covering sentence are common enough that refusing them would
// be pedantry.
assert.ok(deckBuild.extractJson('```json\n{"title":"A","slides":[]}\n```'));
assert.ok(deckBuild.extractJson('Here you go: {"title":"A","slides":[]} hope that helps'));
// A brace inside a string is not the end of the object.
assert.equal(deckBuild.extractJson('{"title":"a } b","slides":[]}').title, 'a } b');
assert.equal(deckBuild.extractJson('no json at all'), null);
// Nothing usable means fall back to markdown, not save an apology.
assert.equal(deckBuild.parse('{"slides":[]}'), null);
});
test('a malformed slide costs that slide, not the deck', () => {
const deck = deckSchema.normalise({
title: 'T',
slides: [
{ type: 'bullets', heading: 'Fine', bullets: ['one', { text: 'two', level: 1 }] },
{ type: 'nonsense', heading: 'Unknown type', bullets: ['kept'] },
{ type: 'compare', heading: 'Only one column', columns: [{ label: 'A', bullets: ['x'] }] },
{ type: 'table', heading: 'No rows', header: ['a'], rows: [] },
{ type: 'callout', heading: 'Empty', text: '' },
],
});
assert.equal(deck.slides[0].bullets.length, 2);
assert.equal(deck.slides[0].bullets[1].level, 1);
assert.equal(deck.slides[1].type, 'bullets', 'an unknown type degrades rather than throwing');
assert.equal(deck.slides[2].type, 'bullets', 'a comparison needs two columns to be one');
assert.equal(deck.slides[3].type, 'bullets', 'an empty table is not a table');
assert.equal(deck.slides.length, 4, 'a callout with nothing to say is dropped');
});
test('the deck still serialises to markdown, because Word and text edits need it', () => {
const md = deckSchema.toMarkdown(deckSchema.normalise({
title: 'Croup', subtitle: 'Teaching', date: '2026',
slides: [
{ type: 'compare', heading: 'Versus', columns: [
{ label: 'CROUP', bullets: ['barking cough'] },
{ label: 'EPIGLOTTITIS', bullets: ['drooling'] }] },
{ type: 'table', heading: 'Features', header: ['Feature', 'Croup'], rows: [['Onset', 'Days']] },
{ type: 'callout', heading: 'Red flag', text: 'Do not examine the throat.' },
],
}));
assert.match(md, /^% Croup/m);
assert.match(md, /^## CROUP$/m);
assert.match(md, /^\| Feature \| Croup \|$/m);
assert.match(md, /\*\*Do not examine the throat\.\*\*/);
});
test('a figure that never arrives leaves a slide of text, not an empty frame', () => {
const exporter = read('src/utils/documentExport.js');
assert.match(exporter, /function attachFigures\(deck, files, figureIds\)/);
assert.match(exporter, /if \(copy\.image_job && byJob\[copy\.image_job\]\) copy\.image = byJob\[copy\.image_job\];/);
assert.match(exporter, /copy\.type = copy\.type === 'image' \? 'section' : 'bullets';/);
// And a deck the model designed is rendered as designed; only older resources
// fall back to inferring a layout from their markdown.
assert.match(exporter, /options\.deck\s*\n?\s*\? attachFigures/);
const build = read('src/utils/deckBuild.js');
assert.match(build, /if \(!wanted\.length \|\| !opts\.imageModel\)/, 'no image model configured is not an error');
assert.match(build, /a figure could not be queued/);
});
test('the renderer can draw what the schema offers', () => {
const py = read('scripts/render_pptx.py');
for (const type of deckSchema.VALID) {
assert.match(py, new RegExp('"' + type + '": slide_'), type + ' has a builder');
}
});