// 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, 1400), /UPDATE user_resources SET deck = \?::jsonb/); assert.doesNotMatch(handler.slice(0, 1400), /callAI/, 'no model call: nothing can reword a slide'); assert.match(handler.slice(0, 1400), /AND user_id = \?/, 'scoped to the owner'); assert.match(handler.slice(0, 1400), /has no slide layout/, 'flat markdown has no theme'); }); test('the preview is rendered by the renderer, not mocked up', () => { // A hand-drawn swatch drifts the moment a palette or a layout changes. const route = read('src/routes/myResources.js'); const preview = route.slice(route.indexOf("router.get('/my-resources/theme-preview/:id'")); assert.match(preview.slice(0, 1800), /documentExport\.renderDeck\(previewDeck\(theme\)/); assert.match(preview.slice(0, 1800), /deckReview\.slideImages/); assert.match(preview.slice(0, 1800), /deckSchema\.themeId\(req\.params\.id\)/, 'the id is validated, not used as a path'); assert.match(route, /THEME_PREVIEW_DIR/, 'cached: it costs a render, a Gotenberg trip and a rasterise'); }); 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;/); });