"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
89 lines
4.9 KiB
JavaScript
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;/);
|
|
});
|