Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people actually ask for unanswerable: "that slide is too crowded", "the diagram is in the wrong place", "this one looks empty" are facts about the rendered page, not about the JSON. When a vision model is configured, modifying now renders the current deck — with its figures, unlike the review pass, which runs while they are still being drawn — and hands the model one image per slide alongside the JSON. Same pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF, pdftoppm, capped at MAX_SLIDES. The vision model then does the editing, which is a second and separately measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the deck unchanged for "make it better" — the echo reported yesterday — while openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo even without sight. A model the author picks explicitly still wins over both. The result is rendered and reviewed again. Generation-only was the old rule, on the reasoning that refining is a text edit; it is not. The edit is made against how the deck looked before it, so a slide that gains two bullets only overflows once it is rendered again. The reviewer may reposition but is held to the same words, so a verification pass cannot quietly undo what was just asked for. Whether an instruction achieved anything is judged on the model's edit, before the reviewer runs, or a reviewer nudging a slide into two columns would mask an instruction that did nothing. Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a render that fails — each falls through to editing blind, and a test covers each of those paths. Verified against two mutations: keeping the text model when images are attached, and dropping the verification pass, each fail exactly one test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
382 lines
23 KiB
JavaScript
382 lines
23 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const root = path.join(__dirname, '..');
|
||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||
|
||
test('a person’s own resources are a separate pathway from Learning', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
const learning = read('src/routes/learningAI.js');
|
||
|
||
// Learning stays moderator-owned. This exists so that not being a moderator
|
||
// no longer means not being able to generate anything at all.
|
||
assert.match(learning, /router\.use\(moderatorMiddleware\)/, 'Learning is unchanged');
|
||
assert.doesNotMatch(route, /moderatorMiddleware/, 'and this one never mentions it');
|
||
assert.match(route, /router\.use\('\/my-resources', authMiddleware\)/,
|
||
'signed in is the only requirement, and the gate names its own prefix');
|
||
});
|
||
|
||
test('nothing here can return another person’s work', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// Every statement that touches the table filters on the owner. A missing
|
||
// WHERE clause here is the whole risk, so it is asserted rather than assumed.
|
||
const statements = route.match(/'(SELECT|UPDATE|DELETE|INSERT)[^']*(?:' \+\s*\n\s*'[^']*)*'/g) || [];
|
||
const touching = statements.filter(s => /user_resources/.test(s));
|
||
assert.ok(touching.length >= 5, 'expected the table statements to be found');
|
||
for (const s of touching) {
|
||
if (/^'INSERT/.test(s)) continue; // supplies user_id as a value instead
|
||
assert.match(s, /user_id = \?/, 'every read and write is scoped to the owner: ' + s.slice(0, 60));
|
||
}
|
||
assert.match(route, /INSERT INTO user_resources \(user_id,/, 'and an insert records one');
|
||
});
|
||
|
||
test('markdown is the artifact; every format is rendered from it', () => {
|
||
const exporter = read('src/utils/documentExport.js');
|
||
// Refining means editing text, never patching a binary — which is what makes
|
||
// "change slide 4" possible at all.
|
||
assert.match(exporter, /async function render\(markdown, kind, format, options\)/);
|
||
assert.match(exporter, /var office = kind === 'presentation' \? 'pptx' : 'docx';/);
|
||
assert.match(exporter, /--reference-doc=' \+ REFERENCE_DECK/, 'decks keep the house template');
|
||
|
||
// PDF goes through Gotenberg because pandoc ships no PDF engine in this
|
||
// image, and converting the office file preserves the deck's layout.
|
||
assert.match(exporter, /forms\/libreoffice\/convert/);
|
||
assert.match(exporter, /AbortSignal\.timeout\(90000\)/, 'and cannot hang a request');
|
||
|
||
// Temporary directories are always cleaned, including on failure.
|
||
assert.match(exporter, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/);
|
||
});
|
||
|
||
test('a failed PDF says so, because the other two formats still work', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
assert.match(route, /PDF conversion is unavailable right now\. PowerPoint and Word still work\./);
|
||
// Gotenberg is a different stack; PDF is the one export allowed to fail.
|
||
assert.match(read('src/utils/documentExport.js'), /GOTENBERG_URL \|\| 'http:\/\/gotenberg:3000'/);
|
||
assert.match(read('docker-compose.yml'), /danvics_convert/, 'and ped-ai is on its network');
|
||
});
|
||
|
||
test('the generated markdown is told the rules pandoc enforces', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// The same rules the Learning prompt carries, found by rendering decks and
|
||
// looking at them: a table alone on its slide, blank lines around it, no
|
||
// "Slide 3:" prefixes, no deep nesting.
|
||
assert.match(route, /A slide containing a table contains ONLY that table/);
|
||
assert.match(route, /a table needs a blank line/);
|
||
assert.match(route, /the heading is the slide\\'s subject, not "Slide 3:"/);
|
||
// And grounded resources cite only at the end.
|
||
assert.match(route, /Do NOT cite in the body/);
|
||
assert.match(route, /In a presentation that is the final slide, titled References/);
|
||
});
|
||
|
||
test('a library has a ceiling, and generation says when it is reached', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
assert.match(route, /var MAX_PER_USER = 100;/);
|
||
assert.match(route, /You have reached ' \+ MAX_PER_USER \+ ' saved resources/);
|
||
// The count is per owner, so one person filling their library cannot stop
|
||
// anyone else generating.
|
||
assert.match(route, /SELECT COUNT\(\*\)::int AS n FROM user_resources WHERE user_id = \?/);
|
||
});
|
||
|
||
test('the screen is reachable by anyone signed in, and states that it is private', () => {
|
||
const index = read('public/index.html');
|
||
const component = read('public/components/my-resources.html');
|
||
|
||
// A menu item of its own, next to the Learning Hub: related, not the same
|
||
// thing, and sitting together is how someone discovers the difference.
|
||
assert.match(index, /<button class="tab-btn" data-tab="myresources">/);
|
||
assert.match(index, /<section id="myresources-tab" class="tab-content" data-component="my-resources">/);
|
||
// No role gate in the markup: the tab button carries no hidden class, unlike
|
||
// the admin and CMS ones which JavaScript reveals per role.
|
||
const button = index.slice(index.indexOf('data-tab="myresources"') - 40, index.indexOf('data-tab="myresources"') + 40);
|
||
assert.doesNotMatch(button, /hidden/, 'visible to every signed-in user');
|
||
|
||
// Said in the header. It used to be repeated in a paragraph below; the claim
|
||
// is what matters, not that it was made twice.
|
||
assert.match(component, /Only you can see these/);
|
||
});
|
||
|
||
test('a row offers the right formats, and the download carries its auth', () => {
|
||
const js = read('public/js/myResources.js');
|
||
assert.match(js, /formats\.forEach\(function \(format\)/);
|
||
// An <a href> cannot carry the Authorization header, so the file is fetched
|
||
// and saved from a blob instead of linked.
|
||
assert.match(js, /headers: getAuthHeaders\(\)/);
|
||
assert.match(js, /filename="\(\[\^"\]\+\)"/, 'and keeps the name the server chose');
|
||
assert.match(js, /URL\.revokeObjectURL\(url\)/, 'without leaking the object URL');
|
||
|
||
// Titles come from a model; this is where they reach the page.
|
||
assert.match(js, /title\.textContent = row\.title \|\| 'Untitled';/);
|
||
assert.doesNotMatch(js, /innerHTML\s*=\s*[^'"]*row\./, 'never interpolated into innerHTML');
|
||
});
|
||
|
||
test('users pick from the models an admin already approved, and nothing else', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// One allow-list, the one chat already uses. A second would be another thing
|
||
// to keep in step, and would let this reach a model nobody approved.
|
||
assert.match(route, /db\.getSetting\('clinical_assistant\.allowed_models', ''\)/);
|
||
assert.match(route, /db\.getSetting\('clinical_assistant\.chat_model', ''\)/);
|
||
// A stale option in an open browser tab must not cost someone their
|
||
// generation, so an unknown model falls back rather than being refused.
|
||
assert.match(route, /return wanted && models\.allowed\.indexOf\(wanted\) !== -1 \? wanted : \(models\.configured \|\| undefined\);/);
|
||
// Refining goes through the same resolution, not req.body.model directly.
|
||
assert.doesNotMatch(route, /model: req\.body\.model \|\| undefined/);
|
||
|
||
// And the screen only asks when there is a real choice to make.
|
||
const js = read('public/js/myResources.js');
|
||
assert.match(js, /if \(modelRow\) modelRow\.hidden = models\.length < 2;/);
|
||
});
|
||
|
||
test('illustration is opt-in, with its own dispatcher rather than the assistant’s', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// A model handed a drawing tool will find a reason to use it, so the tool is
|
||
// only offered when the author asked for one.
|
||
assert.match(route, /var wantsImages = String\(body\.withImages\) === 'true'/);
|
||
// Opt-in is the checkbox's own default, which is the fact worth pinning —
|
||
// stronger than the sentence that used to explain it.
|
||
assert.match(read('public/components/my-resources.html'),
|
||
/<input type="checkbox" id="mr-with-images">/, 'unchecked by default');
|
||
// Tools are assembled per generation: only what the author asked for.
|
||
// Illustration is the only thing left that is genuinely a tool: it needs the
|
||
// model to decide there should be a picture and to compose the prompt for it.
|
||
// Search does not — see web-search.test.js for why both searches were taken
|
||
// away from the model and run by the route instead.
|
||
// In deck mode the slides name their own figures, so there is nothing for a
|
||
// tool to decide; an article still gets the tool, having no structure to hang
|
||
// a figure on.
|
||
assert.match(route, /if \(wantsImages && !deckMode\) tools = tools\.concat\(resourceImages\.tools\);/);
|
||
assert.doesNotMatch(route, /tools\.concat\((?:webSearch|pubmedSearch)\.tools\)/);
|
||
// Its own dispatcher. The assistant's permits one image per request, which is
|
||
// right for a chat reply and wrong for a deck, and three features depend on
|
||
// that rule — so this is a separate path rather than a relaxed shared one.
|
||
assert.match(route, /resourceImages\.dispatch\(ai, \{/);
|
||
assert.doesNotMatch(route, /imageTool/, 'the shared single-image dispatcher is not used here');
|
||
assert.match(read('src/utils/imageTool.js'), /Only one image tool invocation is permitted per request/,
|
||
'and its limit is left exactly as it was');
|
||
assert.match(read('src/utils/resourceImages.js'), /'my_resources'/, 'but attributed to this feature');
|
||
|
||
// The dispatch call itself. It was lost once in a refactor: the tool was
|
||
// still offered, the model still called it, and the call was silently
|
||
// dropped, so no job was ever enqueued and imageJobs was always empty.
|
||
assert.match(route, /ai = await resourceImages\.dispatch\(ai, \{/);
|
||
// dispatch expects { request, history }; a bare topic string made the bound
|
||
// request undefined and lost the topic entirely.
|
||
assert.match(read('src/utils/resourceImages.js'), /images\.imageContext\(opts\.subject, \[\]\)/);
|
||
|
||
// A model handed a tool schema and then told to "Output ONLY Pandoc markdown"
|
||
// obeys the sentence, not the schema — measured: zero tool calls until the
|
||
// prompt said the tool existed and that calling it was not a violation.
|
||
assert.match(route, /is about the written resource; a tool call is not a violation of it/);
|
||
assert.match(route, /wantsImages: Boolean\(wantsImages && imageModel\)/);
|
||
|
||
// Its own workflow, not a reuse of learning_hub: generated_image_links only
|
||
// accepts learning_hub assets, and that is exactly the barrier keeping a
|
||
// private illustration out of published content.
|
||
assert.match(read('src/utils/generatedImages.js'), /const workflows = \['clinical_assistant', 'learning_hub', 'my_resources'\];/);
|
||
assert.match(read('migrations/1780400000000_my-resources-images.js'), /CHECK \(workflow IN \('clinical_assistant', 'learning_hub', 'my_resources'\)\)/);
|
||
// Status polling is owner-scoped and workflow-scoped, so it can only report
|
||
// on an image the caller made here.
|
||
assert.match(route, /service\(\)\.get\(req\.params\.id, req\.user\.id, 'my_resources'\)/);
|
||
assert.match(read('public/js/generatedImages.js'), /my_resources: '\/api\/my-resources\/image\/jobs\/'/);
|
||
// And it renders where the person is looking, rather than pointing them at an
|
||
// image history this feature does not have.
|
||
assert.match(read('public/js/myResources.js'), /showIllustrations\(data\.imageJobs \|\| \[\]\)/);
|
||
assert.match(read('public/components/my-resources.html'), /id="mr-images"/);
|
||
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
|
||
|
||
// The row is hidden entirely when no image model is configured.
|
||
assert.match(read('public/js/myResources.js'), /\['mr-images-row', 'images'\]/);
|
||
});
|
||
|
||
test('the author can ask for the illustration, not only leave it to the model', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// Without this the decision is the model's alone, and someone who wants a
|
||
// figure of something particular has no way to say so — the instructions
|
||
// steer the prose and nothing else.
|
||
assert.match(route, /If the author\\'s additional instructions above name what a figure should show/);
|
||
assert.match(route, /compose the image description from/);
|
||
|
||
// How many, when the author says. "use 3 images" is as clear an instruction
|
||
// as any other and used to be capped at one figure regardless.
|
||
const lib = read('src/utils/resourceImages.js');
|
||
assert.match(lib, /function requestedCount\(text\)/);
|
||
assert.match(lib, /var MAX_IMAGES = 6;/, 'bounded, because each figure is a paid request');
|
||
assert.match(lib, /jobs\.length < MAX_IMAGES/);
|
||
assert.match(lib, /'res:' \+ images\.requestKey\(opts\.body\) \+ ':' \+ i/,
|
||
'a key per figure, or the second is returned as a replay of the first');
|
||
assert.match(route, /resourceImages\.guidance\(opts\.refinement\)/, 'generate');
|
||
assert.match(route, /resourceImages\.guidance\(instructions\)/, 'and modify');
|
||
// A figure that cannot be queued is said out loud; fewer pictures than asked
|
||
// for with no explanation reads as the model ignoring the request.
|
||
assert.match(lib, /failures\.push/);
|
||
assert.match(read('public/js/myResources.js'), /function reportImageFailures/);
|
||
// The paragraph now comes last, so it says "above" — checked, because a
|
||
// prompt that points the model at the wrong end of itself is worse than one
|
||
// that says nothing.
|
||
assert.match(route, /additional instructions above name what a figure should show/);
|
||
assert.doesNotMatch(route, /instructions below/);
|
||
// Said once on screen too: the label points at Instructions, and the
|
||
// Instructions placeholder shows what asking for one looks like.
|
||
const html = read('public/components/my-resources.html');
|
||
assert.match(html, /use 3 diagrams/, 'and the placeholder shows that asking for several works');
|
||
assert.match(html, /Add illustrations — say how many in Instructions/);
|
||
|
||
// Saying it in the instructions is as clear as ticking the box, so the box
|
||
// follows rather than the request being dropped in silence.
|
||
const js = read('public/js/myResources.js');
|
||
assert.match(js, /function looksLikeImageRequest/);
|
||
assert.match(js, /Illustration switched on, because your instructions ask for a figure/);
|
||
assert.match(js, /no image model is configured, so none can be made/);
|
||
assert.match(js, /if \(!check\.checked\) overruled = true;/, 'and switching it off by hand sticks');
|
||
assert.match(js, /wireImageIntent\('mr-refinement', 'mr-with-images', 'mr-image-hint'\)/);
|
||
assert.match(js, /wireImageIntent\('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint'\)/);
|
||
});
|
||
|
||
test('a named number of figures survives a prompt full of library excerpts', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
const lib = read('src/utils/resourceImages.js');
|
||
|
||
// Measured, and deterministic on this model: with the library off, "use 3
|
||
// diagrams" produced three tool calls; with thirty excerpts in the prompt it
|
||
// produced none and a longer deck instead. The excerpts are not wrong to
|
||
// dominate — the request simply has to survive them.
|
||
assert.match(route, /if \(tools\.length && resourceImages\.requestedCount\(refinement\)\) callOptions\.toolChoice = 'required';/);
|
||
assert.match(route, /if \(tools\.length && resourceImages\.requestedCount\(instructions\)\) callOptions\.toolChoice = 'required';/);
|
||
// With no number named the choice stays the model's.
|
||
assert.doesNotMatch(route, /toolChoice = 'required';\s*\n\s*var ai = await callAI\(messages, Object/);
|
||
|
||
// Placement matters as much as wording: the illustration paragraph goes after
|
||
// the output rules and the author's instructions, because read before them it
|
||
// lost to a long "Output ONLY Pandoc markdown" block.
|
||
const tail = route.slice(route.indexOf("return 'You are writing teaching material"));
|
||
assert.ok(tail.indexOf('+ illustration;') > tail.indexOf('Additional instructions'),
|
||
'illustration guidance is the last thing in the prompt');
|
||
|
||
// A model that has just made three tool calls tends to sign off rather than
|
||
// write. Measured: "I'll create the presentation and the three teaching
|
||
// diagrams." — 61 characters, saved as the resource, because only a
|
||
// completely empty body counted as missing.
|
||
assert.match(lib, /function looksLikeResource\(content\)/);
|
||
assert.match(lib, /if \(!looksLikeResource\(ai && ai\.content\)\)/);
|
||
assert.match(lib, /\^%\/m\.test\(text\) \|\| \/\^#\{1,2\}/, 'a title block or a heading, not mere length');
|
||
// And if the continuation is no better, keep whichever actually reads like one.
|
||
assert.match(lib, /completed = ai;/);
|
||
});
|
||
|
||
test('the library is bounded, searchable, and drives the modify picker', () => {
|
||
const html = read('public/components/my-resources.html');
|
||
// Unbounded, a long library pushes everything else off the page.
|
||
assert.match(html, /id="mr-list"[^>]*max-height:360px;overflow-y:auto;/);
|
||
assert.match(html, /id="mr-search"/);
|
||
|
||
const js = read('public/js/myResources.js');
|
||
// Filtering is local — the rows are already in hand, so it costs no request.
|
||
assert.match(js, /search\.addEventListener\('input', renderLibrary\)/);
|
||
assert.match(js, /var rows = library\.filter/);
|
||
assert.match(js, /String\(row\.title \|\| ''\) \+ ' ' \+ String\(row\.topic \|\| ''\)/, 'title and topic both searched');
|
||
// "Nothing yet" and "nothing matches" are different situations.
|
||
assert.match(js, /library\.length\s*\n?\s*\? 'Nothing matches/);
|
||
});
|
||
|
||
test('a figure asked for while modifying belongs to a slide', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// How a figure is asked for depends on what is being edited. A deck places
|
||
// figures by declaring them on a slide; markdown has nowhere to put one, so
|
||
// it uses the tool. Offering the tool while editing a deck queued a figure
|
||
// that no slide referenced — generated, paid for, recorded against the
|
||
// resource, and absent from the export. Measured: 1 recorded, 0 on a slide.
|
||
assert.match(route, /var tools = sources\.wantsImages && !existingDeck \? resourceImages\.tools : \[\];/);
|
||
assert.match(route, /if \(sources\.wantsImages && !existingDeck\) \{\s*\n\s*ai = await resourceImages\.dispatch/);
|
||
// The deck path draws what the revised deck asked for, as generating does.
|
||
assert.match(route, /if \(sources\.wantsImages\) \{\s*\n\s*var drawn = await deckBuild\.drawFigures\(revisedDeck/);
|
||
// And a slide that already has a figure keeps it.
|
||
assert.match(route, /keep the "image_job" value of any slide that already has one/);
|
||
});
|
||
|
||
test('the figure ids are computed before anything reads them', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// They were declared inside the slide-review branch, so with no reviewer
|
||
// configured — the default — the value was undefined by the time the INSERT
|
||
// stringified it, and every generation failed on a NOT NULL constraint. `var`
|
||
// is function-scoped, so nothing complained until the database did. This is
|
||
// the second bug of exactly this shape in this file.
|
||
const body = route.slice(route.indexOf("router.post('/my-resources/generate'"));
|
||
const declared = body.indexOf('var savedFigureIds =');
|
||
const reviewUses = body.indexOf('renderDeck(deck, [], savedFigureIds)');
|
||
const insertUses = body.indexOf('JSON.stringify(savedFigureIds)');
|
||
assert.ok(declared > -1 && declared < reviewUses, 'declared before the review reads it');
|
||
assert.ok(declared < insertUses, 'and before the insert reads it');
|
||
// Declared at the top level of the handler, not inside a branch.
|
||
assert.doesNotMatch(body.slice(0, insertUses), /if \(reviewModel\) \{\s*\n\s*var savedFigureIds/);
|
||
});
|
||
|
||
test('modifying a presentation edits the deck, not only its markdown', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// Export renders from the stored deck. Refine edited the markdown beside it
|
||
// and left the deck alone, so a modification reported success, updated the
|
||
// library row, and produced a byte-identical download. Measured: markdown
|
||
// gained the new slide, the deck did not, and the exported pptx did not.
|
||
assert.match(route, /SELECT id, kind, topic, markdown, deck, image_ids FROM user_resources/);
|
||
assert.match(route, /var revisedDeck = existingDeck \? deckBuild\.parse\(ai && ai\.content\) : null;/);
|
||
assert.match(route, /deck = COALESCE\(\?::jsonb, deck\)/, 'and the deck is written back');
|
||
|
||
// A reply that is not a deck must not be saved as markdown: that would drop
|
||
// every layout the deck held while looking like it worked.
|
||
assert.match(route, /outcome: 'refused',\s*\n\s*detail: 'the model did not return a usable deck'/);
|
||
assert.match(route, /That change could not be applied\. Try wording it differently\./);
|
||
|
||
// An article has no deck and keeps the markdown path.
|
||
assert.match(route, /: 'Revise the following Pandoc markdown according to the instruction\. '/);
|
||
// The reply restates the whole resource, so it needs room for one.
|
||
assert.match(route, /temperature: 0\.2, maxTokens: 16000/);
|
||
});
|
||
|
||
test('modify revises something already generated, in place', () => {
|
||
const js = read('public/js/myResources.js');
|
||
// The endpoint existed with no way to reach it: the markdown is what is
|
||
// stored precisely so that "redo slide 4" is a text edit.
|
||
assert.match(js, /\/refine'/);
|
||
assert.match(js, /instructions: instructions/);
|
||
// The picker is the library, so it cannot drift from it, and a selection
|
||
// survives the refresh that follows a generation.
|
||
assert.match(js, /function syncModifyTargets\(\)/);
|
||
assert.match(js, /var previous = select\.value;/);
|
||
assert.match(js, /if \(previous && library\.some/);
|
||
// Refusals are local rather than a wasted round trip.
|
||
assert.match(js, /if \(!instructions\) return say\('Say what to change\.', 'bad'\);/);
|
||
// And the screen says the old version is gone, because it is.
|
||
assert.match(read('public/components/my-resources.html'), /The previous version is replaced/);
|
||
});
|
||
|
||
test('a slide shrinks its text rather than spilling off the bottom', () => {
|
||
// pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with
|
||
// no autofit even though the slide master has one. Rendered and counted: a
|
||
// slide with eight bullets showed three and cut the third mid-sentence, and
|
||
// the remaining five were not on the slide at all.
|
||
const exporter = read('src/utils/documentExport.js');
|
||
assert.match(exporter, /async function fitSlideText\(bytes\)/);
|
||
assert.match(exporter, /<a:bodyPr><a:normAutofit\/><\/a:bodyPr>/);
|
||
// No fontScale: the renderer works out the reduction, so a slide that already
|
||
// fits is left alone. A fixed scale would shrink every slide regardless.
|
||
assert.doesNotMatch(exporter, /normAutofit fontScale/);
|
||
// Running it on a deck that already has autofit must not double-inject.
|
||
assert.match(exporter, /if \(xml\.indexOf\('normAutofit'\) !== -1\) continue;/);
|
||
// It is now only needed on the pandoc fallback: the Python renderer sizes
|
||
// text to fit before writing the file, so its decks never need patching.
|
||
assert.match(exporter, /await fsp\.writeFile\(out, await fitSlideText\(await fsp\.readFile\(out\)\)\);/);
|
||
// A deck that renders imperfectly beats no deck at all.
|
||
assert.match(exporter, /could not apply slide autofit/);
|
||
assert.ok(JSON.parse(read('package.json')).dependencies.jszip, 'jszip is declared, not borrowed');
|
||
});
|
||
|
||
test('an article is never offered as slides', () => {
|
||
const js = read('public/js/myResources.js');
|
||
const route = read('src/routes/myResources.js');
|
||
// A deck of paragraphs is not a presentation. Word and PDF are fine for
|
||
// either; PowerPoint only makes sense for something written as slides.
|
||
assert.match(js, /row\.kind === 'article' \? \['docx', 'pdf'\] : \['pptx', 'docx', 'pdf'\]/);
|
||
// The route is the boundary that matters, not the button.
|
||
assert.match(route, /if \(row\.kind === 'article' && format === 'pptx'\)/);
|
||
assert.match(route, /An article has no slides\. Download it as Word or PDF\./);
|
||
});
|