feat: a theme is shown by a sample deck you download, not a picture
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
8602c7bd14
commit
5f738fbe30
9 changed files with 295 additions and 149 deletions
BIN
assets/sample-figure.png
Normal file
BIN
assets/sample-figure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
|
|
@ -49,8 +49,9 @@
|
|||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<select id="mr-theme" class="admin-control" style="max-width:320px;"></select>
|
||||
<p id="mr-theme-hint" style="margin:0;font-size:12px;color:var(--g500);"></p>
|
||||
<!-- A slide drawn by the renderer itself, so the preview cannot promise
|
||||
a look the download does not deliver. Hidden if it cannot render. -->
|
||||
<!-- A sample deck in this theme: every layout, placeholder text. The
|
||||
file is the preview — opened in PowerPoint it cannot promise a look
|
||||
the real download does not deliver. -->
|
||||
<div id="mr-theme-preview" hidden style="margin-top:4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
123
src/utils/deckSample.js
Normal file
123
src/utils/deckSample.js
Normal file
|
|
@ -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 };
|
||||
119
test/deck-theme-sample.test.js
Normal file
119
test/deck-theme-sample.test.js
Normal file
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' }; },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in a new issue