pediatric-ai-scribe-v3/test/deck-themes.test.js
Daniel e92b72d406
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s
feat: preview a resource, or a theme's sample deck, as pages — without downloading
A PowerPoint or Word file cannot be shown in a browser and a phone has
nowhere to open one. Preview renders the resource the way its download
would be built, turns it into one PNG per page (Gotenberg to PDF, pdftoppm
to pages), and shows the pages in an overlay that scrolls and pinch-zooms
like anything else. Rendered once per version — updated_at and theme are in
the key — and served from disk afterwards. The theme picker's sample deck
has the same Preview beside its download.

Every presentation now shows the theme picker in the library, since
markdown slides take a theme too.

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

98 lines
5.4 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' && themeCatalogue\.length > 1/);
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;/);
});
test('a modification keeps the deck\'s theme', () => {
// The model returns a new deck; the look was never its to choose. The
// column keeps the theme regardless, but the deck field must agree with it.
const route = read('src/routes/myResources.js');
const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'"));
assert.match(refine, /revisedDeck\.theme = existingDeck\.theme \|\| existing\.theme \|\| revisedDeck\.theme/);
assert.match(refine.slice(0, 2500), /SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources/);
});