// 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("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/); });