From 5f738fbe306abc419f271319f5697663a0cfa050 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 22:24:05 +0200 Subject: [PATCH] feat: a theme is shown by a sample deck you download, not a picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A picture of one slide answers a narrower question than the one a theme picker is asked. What a person wants to know is what a deck will look like in this theme — all of it, at the size it will be shown, with the fonts substituted the way they will be. One rendered slide showed one layout, in content someone then read instead of looking at. So: a sample deck per theme. Every layout the renderer can draw — title, bullets with a sub-point, both two-column forms, table, callout, figure, full-slide figure, section divider, a custom slide with shapes, an arrow and a chart, and a references slide — with filler text throughout. Download it, open it, see the theme. The text is deliberately meaningless. Clinical content in a specimen invites you to read it, and then you are judging the teaching rather than the type; that is what the old croup slide got wrong. No engine. The previous preview needed a Gotenberg round trip and a pdftoppm to produce a PNG, cached on disk because of what it cost, and could fail in ways a missing picture cannot explain. python-pptx builds the file in ~330ms and PowerPoint draws it. The link is a plain anchor, so it is there whether or not anything on the server is well. The figure placeholder rides the same path a generated figure does — attachFigures downgrades a figure slide with no picture to bullets and an image slide to a section, so without it the sample would silently stop showing those two layouts. Verified: all five themes build, 11 slides, the chart is a real chart and the placeholder embeds as real media. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- assets/sample-figure.png | Bin 0 -> 5570 bytes public/components/my-resources.html | 5 +- public/js/myResources.js | 46 ++++------- src/routes/myResources.js | 70 +++++----------- src/utils/deckSample.js | 123 ++++++++++++++++++++++++++++ test/deck-theme-sample.test.js | 119 +++++++++++++++++++++++++++ test/deck-themes.test.js | 20 +++-- test/my-resources-refine.test.js | 1 + test/theme-preview-visible.test.js | 60 -------------- 9 files changed, 295 insertions(+), 149 deletions(-) create mode 100644 assets/sample-figure.png create mode 100644 src/utils/deckSample.js create mode 100644 test/deck-theme-sample.test.js delete mode 100644 test/theme-preview-visible.test.js diff --git a/assets/sample-figure.png b/assets/sample-figure.png new file mode 100644 index 0000000000000000000000000000000000000000..b84d87ded8324b442b398d5f4c1bab0518b28e27 GIT binary patch literal 5570 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 literal 0 HcmV?d00001 diff --git a/public/components/my-resources.html b/public/components/my-resources.html index 088f4826..3715b541 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -49,8 +49,9 @@

- +
diff --git a/public/js/myResources.js b/public/js/myResources.js index 904bf234..ff1689f1 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -210,41 +210,25 @@ // when the tab was reopened, looking like output for a topic nobody had // typed. It cleared on a full page refresh and only then, which is why it // read as a leak. - // A rendered slide in the chosen palette. The renderer draws it, so it cannot - // promise a look the export does not deliver. + // A sample deck to download: every layout, filler text, this theme. // - // The first view of a theme costs a pptx render, a Gotenberg round trip and a - // pdftoppm — a second or so — and after that it is served from a disk cache. - // So it says it is working, rather than leaving an empty gap that reads as a - // feature that is not there. + // Not a picture of one slide, and not a picture of their deck. The question a + // theme picker answers is what the thing will look like, and the file itself + // answers it better than any screenshot — opened in PowerPoint, at the size it + // will be shown, with the fonts actually substituted the way they will be. // - // A failure says so too. Hiding the box on error, which is what this did, - // makes a broken preview and an absent one look identical, from both sides of - // the screen: nothing to see, and nothing to report. - function showThemePreview() { + // An ordinary link, so it is there whether or not anything renders. + function showThemeSample() { var select = document.getElementById('mr-theme'); var box = document.getElementById('mr-theme-preview'); if (!select || !select.value || !box) return; - var url = '/api/my-resources/theme-preview/' + encodeURIComponent(select.value); - - var note = box.querySelector('p') || document.createElement('p'); - note.style.cssText = 'margin:0;font-size:12px;color:var(--g500);'; - note.textContent = 'Drawing a sample slide...'; - if (!note.parentNode) box.appendChild(note); - - var img = box.querySelector('img') || document.createElement('img'); - img.alt = 'A slide rendered in the ' + ((select.options[select.selectedIndex] || {}).text || 'chosen') + ' theme'; - img.style.cssText = 'max-width:min(100%,340px);border:1px solid var(--g200);border-radius:8px;display:none;'; - img.onload = function () { img.style.display = 'block'; note.hidden = true; }; - img.onerror = function () { - img.style.display = 'none'; - note.hidden = false; - note.textContent = 'No preview just now — the look itself is unaffected.'; - }; - // Assigned last: in a cache hit, onload can fire before the handlers below - // the assignment would have been attached. - img.src = url; - if (!img.parentNode) box.appendChild(img); + var name = (select.options[select.selectedIndex] || {}).text || 'this theme'; + var link = box.querySelector('a') || document.createElement('a'); + link.href = '/api/my-resources/theme-sample/' + encodeURIComponent(select.value); + link.textContent = 'Download a sample deck in ' + name; + link.title = 'Every slide layout, with placeholder text, as a PowerPoint file'; + link.style.cssText = 'font-size:12px;color:var(--blue);text-decoration:none;'; + if (!link.parentNode) box.appendChild(link); box.hidden = false; } @@ -255,7 +239,7 @@ if (!select || !hint) return; var chosen = themes.filter(function (t) { return t.id === select.value; })[0]; hint.textContent = chosen ? (chosen.description || '') : ''; - showThemePreview(); + showThemeSample(); } function clearResults() { diff --git a/src/routes/myResources.js b/src/routes/myResources.js index cca5b1f2..7e3a9ac9 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -24,6 +24,7 @@ var { callAI } = require('../utils/ai'); var learningRetrieval = require('../utils/learningRetrieval'); var resourceImages = require('../utils/resourceImages'); var deckSchema = require('../utils/deckSchema'); +var deckSample = require('../utils/deckSample'); var deckBuild = require('../utils/deckBuild'); var deckReview = require('../utils/deckReview'); var nextcloudFiles = require('../utils/nextcloudFiles'); @@ -542,66 +543,37 @@ router.post('/my-resources/:id/to-nextcloud', async function (req, res) { }); // ── Theme previews ────────────────────────────────────────── -// One representative slide per theme, rendered by the renderer itself and -// cached. Drawn rather than mocked up: a hand-made swatch drifts the moment a -// palette or a layout changes, and the point of a preview is to be true. +// A sample deck per theme: every layout the renderer can draw, filler text +// throughout, downloaded and opened in PowerPoint. // -// Cached on disk because it costs a pptx render, a Gotenberg round trip and a -// pdftoppm — far too much to repeat per page view, and the output only changes -// when the catalogue or the renderer does. -var THEME_PREVIEW_DIR = require('path').join(require('os').tmpdir(), 'deck-theme-previews'); - -function previewDeck(theme) { - return { - theme: theme.id, - slides: [{ - type: 'compare', - heading: theme.name, - columns: [ - { label: 'MILD', bullets: [{ text: 'Barking cough', level: 0 }, { text: 'No stridor at rest', level: 0 }] }, - { label: 'SEVERE', bullets: [{ text: 'Stridor at rest', level: 0 }, { text: 'Marked recession', level: 0 }] } - ] - }] - }; -} - -router.get('/my-resources/theme-preview/:id', async function (req, res) { - var fsp = require('fs/promises'); - var pathMod = require('path'); +// This replaced a server-rendered PNG of one slide. That needed a Gotenberg +// round trip and a pdftoppm to show a single layout, could fail in ways a +// picture cannot explain, and answered a narrower question than the one being +// asked — what does a deck look like in this theme, not what does this one +// slide look like. python-pptx alone builds the file, and PowerPoint draws it. +router.get('/my-resources/theme-sample/:id', async function (req, res) { try { var id = deckSchema.themeId(req.params.id); if (!id) return res.status(404).json({ error: 'No such theme' }); - - var cached = pathMod.join(THEME_PREVIEW_DIR, id + '.png'); - try { - var hit = await fsp.readFile(cached); - res.setHeader('Content-Type', 'image/png'); - res.setHeader('Cache-Control', 'private, max-age=86400'); - return res.send(hit); - } catch (e) { /* not rendered yet */ } - var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0]; - var pptx = await documentExport.renderDeck(previewDeck(theme), [], []); - var images = await deckReview.slideImages(pptx, documentExport.GOTENBERG, - documentExport.FORMATS.pptx.mime); - if (!images.length) return res.status(503).json({ error: 'Preview rendering unavailable' }); - var png = Buffer.from(images[0].dataBase64, 'base64'); - // Best effort: a preview that cannot be cached is still a preview. - try { - await fsp.mkdir(THEME_PREVIEW_DIR, { recursive: true }); - await fsp.writeFile(cached, png); - } catch (e) {} - res.setHeader('Content-Type', 'image/png'); + var deck = deckSample.build(theme); + deck.theme = id; + // The placeholder rides the same path a generated figure does, so the + // sample exercises the real figure layouts rather than a special case. + var pptx = await documentExport.renderDeck(deck, [deckSample.FIGURE], [deckSample.FIGURE_JOB]); + + res.setHeader('Content-Type', documentExport.FORMATS.pptx.mime); + res.setHeader('Content-Disposition', + 'attachment; filename="' + id + '-sample.pptx"'); res.setHeader('Cache-Control', 'private, max-age=86400'); - res.send(png); + res.send(pptx); } catch (err) { - logger.warn('[my-resources] theme preview', { theme: req.params.id, error: err.message }); - res.status(503).json({ error: 'Preview rendering unavailable' }); + logger.warn('[my-resources] theme sample', { theme: req.params.id, error: err.message }); + res.status(503).json({ error: 'Could not build the sample deck.' }); } }); -// ── The owner's library ───────────────────────────────────── router.get('/my-resources', async function (req, res) { try { var rows = await db.all( diff --git a/src/utils/deckSample.js b/src/utils/deckSample.js new file mode 100644 index 00000000..4566d082 --- /dev/null +++ b/src/utils/deckSample.js @@ -0,0 +1,123 @@ +// ============================================================ +// DECK SAMPLE +// ============================================================ +// A specimen deck: every layout the renderer can draw, in one theme, with +// filler text. +// +// This is what a theme picker owes you. A picture of one slide shows one +// layout, and a picture of *your* deck answers a different question — whether +// the writing is any good — when the question here is only what the thing will +// look like. So: one file, every layout, download it and look. +// +// The text is deliberately meaningless. Clinical content in a specimen invites +// you to read it, and then you are judging the teaching rather than the type. +// +// Nothing renders here but python-pptx. No Gotenberg, no pdftoppm, no image +// model — a theme is colours, type and rules, and the file itself shows those +// better than a screenshot of it could. + +var pathMod = require('path'); + +// Stands in for an illustration. Neutral in every palette, and plainly a +// placeholder rather than art, so nothing in the sample looks like content. +var FIGURE = pathMod.join(__dirname, '..', '..', 'assets', 'sample-figure.png'); +var FIGURE_JOB = 'sample-figure'; + +function bullets(items) { + return items.map(function (item) { + return Array.isArray(item) ? { text: item[0], level: item[1] } : { text: item, level: 0 }; + }); +} + +/** + * The specimen for one theme. + * + * Ordered the way a real deck runs — title, content, a divider, more content — + * so the sample also shows how the layouts sit next to each other, which is + * where a palette usually goes wrong. + */ +function build(theme) { + var name = (theme && theme.name) || 'Sample'; + return { + title: name, + subtitle: 'A sample deck — every slide layout, in this theme', + date: 'Placeholder text throughout', + slides: [ + { type: 'title', heading: name, + subtitle: 'A sample deck — every slide layout, in this theme' }, + + { type: 'bullets', heading: 'Bullets, with a sub-point', + bullets: bullets([ + 'A first point, about the length one usually runs to', + 'A second point that carries a little more detail with it', + ['An indented sub-point beneath it', 1], + ['A second sub-point, to show the spacing', 1], + 'A closing point to fill the slide out' + ]), + notes: 'Speaker notes look like this. They do not appear on the slide.' }, + + { type: 'compare', heading: 'Two labelled columns', + columns: [ + { label: 'LEFT', bullets: bullets(['First item', 'Second item', 'Third item']) }, + { label: 'RIGHT', bullets: bullets(['First item', 'Second item', 'Third item']) } + ] }, + + { type: 'two', heading: 'Two plain columns', + left: bullets(['One', 'Two', 'Three']), + right: bullets(['Four', 'Five', 'Six']) }, + + { type: 'table', heading: 'A table', + header: ['Column', 'Second', 'Third'], + rows: [ + ['First row', 'Value', 'Value'], + ['Second row', 'Value', 'Value'], + ['Third row', 'Value', 'Value'], + ['Fourth row', 'Value', 'Value'] + ] }, + + { type: 'callout', heading: 'A callout', + text: 'One sentence on a tinted card, for something worth stopping on.' }, + + { type: 'figure', heading: 'A figure beside its text', + image_job: FIGURE_JOB, + bullets: bullets([ + 'Text takes the left, the picture the right', + 'Three or four points sit comfortably here', + 'The grey panel stands in for an illustration' + ]) }, + + { type: 'section', heading: 'A section divider' }, + + { type: 'image', heading: 'A full-slide figure', + image_job: FIGURE_JOB, + caption: 'A caption sits under the picture.' }, + + { type: 'custom', heading: 'A custom slide: shapes, an arrow, a chart', + shapes: [ + { kind: 'roundRect', x: 5, y: 30, w: 20, h: 14, fill: 'EFF6FF', line: '2563EB', + runs: [{ text: 'First', bold: true, align: 'center' }] }, + { kind: 'arrow', x: 26, y: 34, w: 7, h: 6, fill: '94A3B8' }, + { kind: 'roundRect', x: 34, y: 30, w: 20, h: 14, fill: 'EFF6FF', line: '2563EB', + runs: [{ text: 'Second', bold: true, align: 'center' }] }, + { kind: 'line', x: 5, y: 50, w: 49, h: 0, line: 'E5E7EB' }, + { kind: 'text', x: 5, y: 53, w: 49, h: 20, + runs: [ + { text: 'Free text under a rule', bold: true }, + { text: 'A bulleted run inside a text shape', bullet: true }, + { text: 'And a second one', bullet: true } + ] }, + { kind: 'chart', chart: 'column', x: 58, y: 28, w: 37, h: 45, + categories: ['One', 'Two', 'Three'], + series: [{ name: 'Series', values: [8, 14, 11] }] } + ] }, + + { type: 'bullets', heading: 'References', + bullets: bullets([ + 'A reference, set the way the last slide of a real deck sets them', + 'A second reference, longer, to show how a wrapped line falls' + ]) } + ] + }; +} + +module.exports = { build: build, FIGURE: FIGURE, FIGURE_JOB: FIGURE_JOB }; diff --git a/test/deck-theme-sample.test.js b/test/deck-theme-sample.test.js new file mode 100644 index 00000000..aa7b20e4 --- /dev/null +++ b/test/deck-theme-sample.test.js @@ -0,0 +1,119 @@ +// A theme picker owes you a look at the theme. A server-rendered PNG of one +// slide answered a narrower question than the one being asked, needed a +// Gotenberg round trip and a pdftoppm to do it, and could fail in ways a +// missing picture cannot explain. A sample deck answers it properly: every +// layout, filler text, opened in PowerPoint at the size it will be shown. +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 deckSample = require('../src/utils/deckSample'); +const deckSchema = require('../src/utils/deckSchema'); + +// The vocabulary the renderer can draw, from the schema itself, so a layout +// added later fails this until the sample shows it too. +const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table', + 'callout', 'figure', 'image', 'custom']; + +test('the sample shows every layout the renderer can draw', () => { + const deck = deckSample.build({ id: 'clinical-blue', name: 'Clinical Blue' }); + const present = new Set(deck.slides.map(s => s.type)); + for (const type of SCHEMA_TYPES) { + assert.ok(present.has(type), 'the sample never shows a "' + type + '" slide'); + } +}); + +test('that list is the schema\'s own, not a copy that can drift', () => { + const schema = read('src/utils/deckSchema.js'); + const declared = schema.match(/var VALID = \[([^\]]*)\]/)[1] + .split(',').map(s => s.trim().replace(/'/g, '')).filter(Boolean); + assert.deepEqual(declared.slice().sort(), SCHEMA_TYPES.slice().sort(), + 'deckSchema.VALID changed — the sample must show the new layout too'); +}); + +test('the custom slide exercises the shape vocabulary, not just one box', () => { + const deck = deckSample.build({ id: 'slate', name: 'Slate' }); + const custom = deck.slides.find(s => s.type === 'custom'); + const kinds = new Set(custom.shapes.map(s => s.kind)); + for (const kind of ['roundRect', 'arrow', 'line', 'text', 'chart']) { + assert.ok(kinds.has(kind), 'no ' + kind + ' shape in the custom sample'); + } +}); + +test('the figure layouts carry a placeholder, or they render as plain text', () => { + // attachFigures downgrades a figure slide with no picture to bullets and an + // image slide to a section, so without this the sample would silently stop + // showing those two layouts at all. + const deck = deckSample.build({ id: 'ward-teal', name: 'Ward Teal' }); + for (const type of ['figure', 'image']) { + const slide = deck.slides.find(s => s.type === type); + assert.equal(slide.image_job, deckSample.FIGURE_JOB, type + ' has no figure attached'); + } + assert.ok(fs.existsSync(deckSample.FIGURE), 'the placeholder image is missing from the repo'); +}); + +test('the sample carries no clinical content', () => { + // Real content invites you to read it, and then you are judging the teaching + // rather than the type — which is what the previous sample slide got wrong. + const text = JSON.stringify(deckSample.build({ id: 'slate', name: 'Slate' })).toLowerCase(); + for (const word of ['stridor', 'croup', 'cough', 'dose', 'mg/kg', 'patient', 'fever']) { + assert.ok(!text.includes(word), 'the sample mentions "' + word + '"'); + } +}); + +test('every theme builds, and the deck is stamped with the one asked for', () => { + for (const theme of deckSchema.themes()) { + const deck = deckSample.build(theme); + assert.ok(deck.slides.length >= SCHEMA_TYPES.length); + assert.equal(deck.title, theme.name); + } +}); + +// ---- how it is served and offered ------------------------------------------ + +test('the route renders with python-pptx alone — no Gotenberg, no pdftoppm', () => { + const route = read('src/routes/myResources.js'); + const fn = route.slice(route.indexOf("router.get('/my-resources/theme-sample/:id'")); + const body = fn.slice(0, fn.indexOf('\n});')); + assert.match(body, /documentExport\.renderDeck\(deck, \[deckSample\.FIGURE\], \[deckSample\.FIGURE_JOB\]\)/); + assert.doesNotMatch(body, /slideImages|GOTENBERG|pdftoppm/); + assert.doesNotMatch(route, /theme-preview/, 'the rendered-PNG preview should be gone'); +}); + +test('it downloads as a named file rather than rendering in the tab', () => { + const route = read('src/routes/myResources.js'); + const fn = route.slice(route.indexOf("router.get('/my-resources/theme-sample/:id'")); + assert.match(fn.slice(0, 1400), /attachment; filename="' \+ id \+ '-sample\.pptx"/); + assert.match(fn.slice(0, 1400), /documentExport\.FORMATS\.pptx\.mime/); +}); + +test('an unknown theme is refused rather than rendered as a default', () => { + const route = read('src/routes/myResources.js'); + const fn = route.slice(route.indexOf("router.get('/my-resources/theme-sample/:id'")); + assert.match(fn.slice(0, 600), /if \(!id\) return res\.status\(404\)/); +}); + +test('the route is registered before the :id catch-all', () => { + const route = read('src/routes/myResources.js'); + assert.ok(route.indexOf("'/my-resources/theme-sample/:id'") < route.indexOf("router.get('/my-resources/:id'")); +}); + +test('the link is a plain anchor, present whatever the server does', () => { + // The previous preview hid itself when it failed, so a broken preview and an + // absent one looked the same. A link cannot fail to appear. + const ui = read('public/js/myResources.js'); + const fn = ui.slice(ui.indexOf('function showThemeSample'), ui.indexOf('function describeTheme')); + assert.match(fn, /createElement\('a'\)/); + assert.match(fn, /link\.href = '\/api\/my-resources\/theme-sample\/'/); + assert.match(fn, /box\.hidden = false/); + assert.doesNotMatch(fn, /onerror/); + assert.doesNotMatch(ui, /showThemePreview/); +}); + +test('the link names the theme it will download', () => { + const ui = read('public/js/myResources.js'); + const fn = ui.slice(ui.indexOf('function showThemeSample'), ui.indexOf('function describeTheme')); + assert.match(fn, /'Download a sample deck in ' \+ name/); +}); diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js index 3e998c04..cbb8830a 100644 --- a/test/deck-themes.test.js +++ b/test/deck-themes.test.js @@ -61,14 +61,20 @@ test('re-skinning is a column write, never a regeneration', () => { 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. +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 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'); + 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', () => { diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js index 713dde32..ef4dcb51 100644 --- a/test/my-resources-refine.test.js +++ b/test/my-resources-refine.test.js @@ -69,6 +69,7 @@ function router(t, overrides = {}) { }, '../utils/deckBuild': deckBuild, '../utils/deckSchema': require('../src/utils/deckSchema'), + '../utils/deckSample': require('../src/utils/deckSample'), '../utils/deckReview': { slideImages: async () => (overrides.slideImages || []), review: async (deck) => { reviewCalls.push(deck); return { deck: deck, reviewed: true, reason: 'ok' }; }, diff --git a/test/theme-preview-visible.test.js b/test/theme-preview-visible.test.js deleted file mode 100644 index aa6d7997..00000000 --- a/test/theme-preview-visible.test.js +++ /dev/null @@ -1,60 +0,0 @@ -// The theme preview box was hidden on error and hidden until an image loaded, -// so a preview that failed and a preview that did not exist looked exactly the -// same — an empty gap. That cost a diagnosis: the endpoint was working the -// whole time and there was no way to tell from the page. -// -// The first view of a theme is a pptx render, a Gotenberg round trip and a -// pdftoppm, so it is a second or so before anything appears. Silence for that -// second reads as a missing feature. -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 ui = read('public/js/myResources.js'); -const fn = ui.slice(ui.indexOf('function showThemePreview'), ui.indexOf('function describeTheme')); - -test('a failed preview says so instead of vanishing', () => { - assert.doesNotMatch(fn, /onerror = function \(\) \{ box\.hidden = true; \}/); - assert.match(fn, /img\.onerror = function \(\)[\s\S]{0,200}note\.textContent = 'No preview just now/); -}); - -test('it says it is working while the render happens', () => { - assert.match(fn, /note\.textContent = 'Drawing a sample slide\.\.\.'/); - assert.match(fn, /box\.hidden = false/); -}); - -test('the note is replaced by the image, not left stacked above it', () => { - assert.match(fn, /img\.onload = function \(\) \{ img\.style\.display = 'block'; note\.hidden = true; \}/); -}); - -test('src is assigned after the handlers, so a cache hit is not missed', () => { - // A cached preview can complete before a handler attached after the - // assignment would exist, leaving the box stuck on "Drawing...". - const onload = fn.indexOf('img.onload'); - const onerror = fn.indexOf('img.onerror'); - const src = fn.indexOf('img.src ='); - assert.ok(onload > -1 && onerror > -1 && src > -1); - assert.ok(src > onload && src > onerror, 'img.src must be set after both handlers'); -}); - -test('no request for a theme that has not been chosen', () => { - assert.match(fn, /if \(!select \|\| !select\.value \|\| !box\) return;/); -}); - -test('the preview is requested once per change, not twice', () => { - // describeTheme ends by calling it; a second call beside it fired an - // identical request on every load. - assert.equal((ui.match(/\n\s*showThemePreview\(\);/g) || []).length, 1); -}); - -test('the route it calls exists and is the one that renders', () => { - const route = read('src/routes/myResources.js'); - assert.match(fn, /'\/api\/my-resources\/theme-preview\/' \+ encodeURIComponent\(select\.value\)/); - assert.match(route, /router\.get\('\/my-resources\/theme-preview\/:id'/); - // It must be registered before the '/my-resources/:id' catch-all, or that - // would answer instead. - assert.ok(route.indexOf("'/my-resources/theme-preview/:id'") < route.indexOf("router.get('/my-resources/:id'"), - 'theme-preview must be registered before the :id route'); -});