diff --git a/assets/sample-figure.png b/assets/sample-figure.png new file mode 100644 index 00000000..b84d87de Binary files /dev/null and b/assets/sample-figure.png differ 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'); -});