pediatric-ai-scribe-v3/test/deck-themes.test.js
Daniel d2a06b0fcf feat: modifying a resource is a job, the same as generating one
Modify held the request open for a library search, a PubMed search, a web
search and a restating model call. That is minutes, and a browser gives up
first — Firefox abandons a non-streaming fetch at five minutes, the same
failure generating was moved off the request to fix in ef574edd. The server
carried on and saved the result while the person watched an error, and closing
the tab killed the work outright.

POST /my-resources/:id/refine now records the request and answers 202 with the
job, exactly as /generate does. The writing moved into refineResource(), which
the job runner dispatches to by kind; the job list, the five-second polling,
the restart recovery and the three-in-flight cap are all the work they already
did, unchanged. Ownership is checked again inside refineResource because the
resource can be deleted while the job waits.

The page follows the job instead of the response. Reporting is unchanged — the
unchanged reply, what was seen and what was searched — it is only said from the
job list now, so it still reaches the person who asked for it after a reload.
2026-09-16 23:28:42 +02:00

99 lines
5.5 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 theme is chosen in the editor, not on the list row', () => {
const ui = read('public/js/myResources.js');
// The list row looks and downloads; it carries no theme dropdown.
assert.doesNotMatch(ui, /theme\.dataset\.theme = String\(row\.id\)/);
assert.doesNotMatch(ui, /closest\('\[data-theme\]'\)/);
// The editor still offers the catalogue and a sample of each theme.
assert.match(ui, /getElementById\('mr-theme'\)/);
assert.match(ui, /theme-sample/);
});
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("async function refineResource("));
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/);
});