Three generations in a row fell back to plain slides for the same reason, visible once a failed reply was kept whole: the model wrote "rows":[[…]]} and then ,"notes":"…"} — the slide closed before its notes, which is not JSON. On parse failure the one premature brace is removed, bounded to the schema's slide keys; a brace that legitimately closes an inner object is left alone. Both test generations now come back as designed decks. Modify used to hand the model's new deck back without its theme; the look is the author's, so it is carried over, and the column agrees. Failed deck replies are kept under data/logs/deck-failures for the next time "the reply was not a deck" needs reading rather than guessing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
98 lines
5.4 KiB
JavaScript
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' && 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;/);
|
|
});
|
|
|
|
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/);
|
|
});
|