A pathway or an algorithm had no slide of its own and came out as bullets. "flow" is a slide of two to eight steps, each a short phrase with an optional note; the exporter lays it out — across for up to four, down for more — as rounded boxes joined by arrows in the shape vocabulary the renderer already draws, so the model is never asked for coordinates and Python gains one small builder. Word gets the steps as a numbered list. The theme sample shows one, so every template demonstrates it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
149 lines
8 KiB
JavaScript
149 lines
8 KiB
JavaScript
// 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', 'flow'];
|
|
|
|
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/);
|
|
});
|
|
|
|
test('a flowchart is steps in the schema, boxes and arrows on the slide, a numbered list in Word', () => {
|
|
const deckSchema = require('../src/utils/deckSchema');
|
|
const docSpec = require('../src/utils/docSpec');
|
|
const deck = deckSchema.normalise({ title: 't', slides: [
|
|
{ type: 'flow', heading: 'Croup', steps: [{ text: 'Assess', note: 'stridor at rest?' }, { text: 'Dexamethasone' }, { text: 'Reassess' }, { text: 'Admit or home' }] },
|
|
{ type: 'flow', heading: 'Long', steps: [1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => 'Step ' + i) },
|
|
{ type: 'flow', heading: 'One', steps: ['only one'] }
|
|
] }, []);
|
|
assert.deepEqual(deck.slides.map(s => [s.type, (s.steps || s.bullets).length]), [['flow', 4], ['flow', 8], ['bullets', 1]],
|
|
'eight steps at most; a single step is a bullet, not a flow');
|
|
// Across for four: four boxes, three arrows, all inside the slide.
|
|
const across = deckSchema.flowShapes(deck.slides[0]);
|
|
assert.deepEqual(across.map(s => s.kind), ['roundRect', 'arrow', 'roundRect', 'arrow', 'roundRect', 'arrow', 'roundRect']);
|
|
assert.ok(across.every(s => s.x >= 0 && s.y >= 0 && s.x + s.w <= 100 && s.y + s.h <= 100));
|
|
assert.equal(across[0].runs[1].text, 'stridor at rest?', 'the note is the second line of its box');
|
|
// Down for eight: arrows point down, nothing runs off the bottom.
|
|
const down = deckSchema.flowShapes(deck.slides[1]);
|
|
assert.equal(down.filter(s => s.kind === 'arrowDown').length, 7);
|
|
assert.ok(Math.max(...down.map(s => s.y + s.h)) <= 100);
|
|
// Word gets the steps as a numbered list, in order.
|
|
const doc = docSpec.build({ deck: { title: 't', slides: [deck.slides[0]] }, markdown: '' });
|
|
const list = doc.blocks.find(b => b.type === 'bullets');
|
|
assert.equal(list.items[0].text, '1. Assess — stridor at rest?');
|
|
assert.equal(list.items[3].text, '4. Admit or home');
|
|
// The renderer never sees "flow": it is handed the shapes.
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'src/utils/documentExport.js'), 'utf8');
|
|
assert.match(src, /if \(copy\.type === 'flow'\) \{\s*copy\.shapes = deckSchema\.flowShapes\(copy\);\s*copy\.type = 'custom';/);
|
|
assert.match(deckSchema.instructions(6, 0), /"type":"flow"/, 'the model is told it exists');
|
|
});
|