The author presses "Copy a share link" and sends it however they like. Whoever follows it (app.pedshub.com/#share=<token>) is signed in first if need be — the token survives the trip through the SSO — then shown what it is and who from, and adds it with one press. Only the token's hash is stored; a link lasts 30 days and can be withdrawn; accepting twice is harmless; the owner following their own link changes nothing. Sharing by email is gone: nobody is looked up by address. "Everyone signed in" stays as a switch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
404 lines
25 KiB
JavaScript
404 lines
25 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('anyone signed in can generate here — no moderator gate', () => {
|
||
const route = read('src/routes/myResources.js');
|
||
// This existed alongside Learning Hub, which was moderator-owned, so that not
|
||
// being a moderator did not mean not being able to generate anything at all.
|
||
// Learning Hub is retired and this is the only generator left; the absence of
|
||
// a role gate still matters, and is still worth pinning.
|
||
assert.doesNotMatch(route, /moderatorMiddleware/);
|
||
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
|
||
// A share link is resolved by its token — the token is the credential —
|
||
// and what it reveals is the title and who shared it, never the content.
|
||
if (/token_hash = \?/.test(s)) continue;
|
||
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, and the only two left now Learning Hub is retired. The
|
||
// barrier this used to describe — keeping a private illustration out of
|
||
// published content — is now absolute: there is no published content, and an
|
||
// image is readable by its owner alone.
|
||
assert.match(read('src/utils/generatedImages.js'), /const workflows = \['clinical_assistant', 'my_resources'\];/);
|
||
assert.match(read('migrations/1780800000000_retire-learning-hub.js'), /'clinical_assistant'::text, 'my_resources'::text/);
|
||
// 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/);
|
||
// A key per figure, or the second is returned as a replay of the first — and
|
||
// keyed on the figure rather than on its index, or two generations from the
|
||
// same form collide with each other. See image-key-collision.test.js.
|
||
assert.match(lib, /'res:' \+ images\.requestKey\(opts\.body\) \+ ':' \+ drawing/);
|
||
assert.match(lib, /createHash\('sha256'\)\s*\n?\s*\.update\(JSON\.stringify\(input\)\)/);
|
||
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, theme 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\./);
|
||
});
|
||
|
||
test('Details is material to cover, quoted after the topic and kept apart from instructions', () => {
|
||
// Someone with a long list of topics has somewhere to put it that is not
|
||
// the one-line topic box and not the instructions. It reaches the prompt
|
||
// as quoted material, in both the deck and the markdown shapes.
|
||
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/myResources.js'), 'utf8');
|
||
assert.match(route, /var details = String\(req\.body\.details \|\| ''\)\.slice\(0, 12000\)\.trim\(\);/);
|
||
assert.equal((route.match(/details: details, corpusContext/g) || []).length, 2, 'generation and the markdown fallback both carry it');
|
||
assert.equal((route.match(/opts\.topic \+ '\\n' \+ detailsBlock\(opts\)/g) || []).length, 2, 'both prompt shapes quote it after the topic');
|
||
assert.match(route, /DETAILS FROM THE AUTHOR \(what this must cover, in their words\)/);
|
||
const html = fs.readFileSync(path.join(__dirname, '..', 'public/components/my-resources.html'), 'utf8');
|
||
assert.match(html, /<details class="admin-row" id="mr-details-wrap"/, 'collapsed by default');
|
||
assert.match(html, /<textarea id="mr-details"[^>]*maxlength="12000"/);
|
||
const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/myResources.js'), 'utf8');
|
||
assert.match(js, /details: \(document\.getElementById\('mr-details'\) \|\| \{\}\)\.value \|\| ''/);
|
||
});
|