pediatric-ai-scribe-v3/test/deck-themes.test.js
Daniel 0163d40811
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s
fix: every presentation takes a theme, not only the ones the model designed
"Change template" answered "no slide layout" for 28 of the 41 presentations
in production: the theme lived only inside the deck JSON, and a presentation
whose deck reply failed twice and fell back to markdown slides had nowhere
to keep one. The theme is a column now, written at generation and by the
picker, and the markdown slide builder carries it to the same renderer field
a designed deck uses. A deck's own theme field is kept in step. Articles are
the only thing refused — they have no slides.

A deck reply that fails to parse is logged with its first 240 characters, so
the next "the reply was not a deck" can be read rather than guessed at.

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

89 lines
4.9 KiB
JavaScript

// A deck's look is a theme, not a colour the model writes into a field. The
// slide vocabulary is structural — bullets, compare, table, callout — and none
// of it carries a colour, so this is the whole of a deck's styling.
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 deckSchema = require('../src/utils/deckSchema');
const catalogue = JSON.parse(read('assets/deck-themes.json')).themes;
test('every theme is complete, and every colour is a real colour', () => {
assert.ok(catalogue.length >= 4, 'a picker of one is not a choice');
for (const theme of catalogue) {
for (const key of ['id', 'name', 'accent', 'ink', 'muted', 'rule', 'paper', 'font']) {
assert.ok(theme[key], theme.id + ' is missing ' + key);
}
for (const key of ['accent', 'ink', 'muted', 'rule', 'paper']) {
assert.match(theme[key], /^[0-9A-Fa-f]{6}$/, theme.id + '.' + key + ' is not six hex digits');
}
}
const ids = catalogue.map(t => t.id);
assert.equal(ids.length, new Set(ids).size, 'theme ids must be unique');
});
test('the app and the renderer read one catalogue, so they cannot disagree', () => {
const py = read('scripts/render_pptx.py');
assert.match(py, /assets", "deck-themes\.json/);
const js = read('src/utils/deckSchema.js');
assert.match(js, /deck-themes\.json/);
assert.deepEqual(deckSchema.themes().map(t => t.id), catalogue.map(t => t.id));
});
test('an unknown theme is dropped rather than stored', () => {
// A stored deck must never claim a theme the renderer will silently ignore.
const slides = [{ type: 'bullets', heading: 'x', bullets: ['a'] }];
assert.equal(deckSchema.normalise({ theme: 'teaching-amber', slides }).theme, 'teaching-amber');
assert.equal(deckSchema.normalise({ theme: 'not-a-theme', slides }).theme, '');
assert.equal(deckSchema.normalise({ theme: '../../etc/passwd', slides }).theme, '');
assert.equal(deckSchema.normalise({ slides }).theme, '');
});
test('the renderer rebinds its palette instead of hardcoding one', () => {
// Every builder reads these names, which is what makes a theme a rebinding
// rather than a change to ten slide builders.
const py = read('scripts/render_pptx.py');
assert.match(py, /def apply_theme\(theme_id\)/);
assert.match(py, /global INK, MUTED, ACCENT, RULE, PAPER, FONT/);
assert.match(py, /apply_theme\(spec\.get\("theme"\)\)/);
// An unusable theme must not cost the deck its render.
assert.match(py, /a deck rendering in the wrong colours beats a deck not rendering/);
});
test('re-skinning is a column write, never a regeneration', () => {
const route = read('src/routes/myResources.js');
const handler = route.slice(route.indexOf("router.put('/my-resources/:id/theme'"));
assert.match(handler.slice(0, 1600), /UPDATE user_resources SET theme = \?, deck = COALESCE\(\?::jsonb, deck\)/);
assert.doesNotMatch(handler.slice(0, 1600), /callAI/, 'no model call: nothing can reword a slide');
assert.match(handler.slice(0, 1600), /AND user_id = \?/, 'scoped to the owner');
// Markdown slides take a theme too — the renderer applies it to both — so
// the only thing refused is an article, which has no slides at all.
assert.match(handler.slice(0, 1600), /An article has no slides/, 'articles have no theme');
});
test('the theme is shown by a deck the renderer built, not a mocked-up swatch', () => {
// A hand-drawn swatch drifts the moment a palette or a layout changes. This
// used to be a server-rendered PNG of one slide; it is now a downloadable
// sample deck of every layout — same principle, no Gotenberg.
// See test/deck-theme-sample.test.js.
const route = read('src/routes/myResources.js');
const sample = route.slice(route.indexOf("router.get('/my-resources/theme-sample/:id'"));
assert.match(sample.slice(0, 1800), /documentExport\.renderDeck\(deck, \[deckSample\.FIGURE\]/);
assert.match(sample.slice(0, 1800), /deckSchema\.themeId\(req\.params\.id\)/, 'the id is validated, not used as a path');
// Scoped to this route: the deck reviewer legitimately rasterises the deck
// it is about to judge. Showing a theme does not.
const body = sample.slice(0, sample.indexOf('\n});'));
assert.doesNotMatch(body, /slideImages|GOTENBERG|pdftoppm/, 'no rasterising to show a theme');
assert.doesNotMatch(route, /THEME_PREVIEW_DIR|theme-preview/, 'the cached-PNG preview is gone');
});
test('the library offers a theme only where there is a deck to re-skin', () => {
const ui = read('public/js/myResources.js');
assert.match(ui, /row\.kind !== 'article' && row\.has_deck !== false/);
assert.match(ui, /data-theme|dataset\.theme/);
// A failed change puts the control back rather than showing a theme the
// resource does not have.
assert.match(ui, /if \(previous\) select\.value = previous;/);
});