pediatric-ai-scribe-v3/test/my-resources.test.js
Daniel 055a86bbb1
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: My Resources says what it is, offers its sources in one place, and Modify gets them too
The screen had been trimmed to controls with no explanation of what it was for;
"Private to you" said who could see it, not what it did. It now opens with a
sentence that says what you get and how you get it out, and the header reads
"Only you can see these".

The four choices — clinical library, PubMed, web, illustration — are one "Draw
on" group instead of four separate rows, with the library ticked by default.
Each option hides itself when an administrator has not enabled it, so nothing
appears that a person could tick and then be refused.

Modify offers exactly the same choices. It had none, so "add what the 2024 trial
showed" was answered from the model's memory rather than by looking anything up.
Generate and Modify now go through one gatherSources(), so they cannot drift
into offering different things or searching them differently.

Writing "include a diagram of the airway" in the instructions now switches the
illustration option on and says why, rather than the request being dropped in
silence. Switching it off by hand sticks — the hint then reminds instead of
fighting — and when no image model is configured it says so rather than
pretending. Both the generate and modify boxes behave this way.

Two things found by testing this rather than assuming it.

PubMed ANDs every mapped term, so one unrecognised word takes the query to zero.
"febrile seizures" returns six results and "febrile seizures in under-fives"
returns none; "the anatomy of croup: subglottic narrowing and the steeple sign"
returned none until it was narrowed to "anatomy croup", which returns six. A
query that finds nothing is now retried against progressively shorter versions
of itself, longest first, and the response says which query actually worked so
the screen cannot report one that found nothing. Those extra calls tripped
NCBI's three-a-second limit and produced a 429, so retries are spaced and the
first attempt waits for nothing. Separately, the searches run on the topic while
the library retrieval also gets the instruction: retrieval is semantic and
benefits from the context, but a keyword engine handed a whole sentence returns
nothing.

And when a search was asked for and came back empty, the prompt now says not to
invent a citation, a PMID or a URL to fill the gap. Without that the model
supplies them from memory, and a fabricated PMID looks exactly like a real one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 18:54:17 +02:00

278 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 persons 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 persons 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\)/);
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, and reuses the assistants image tool', () => {
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.
assert.match(route, /if \(wantsImages\) tools = tools\.concat\(imageTool\.tools\);/);
assert.doesNotMatch(route, /tools\.concat\((?:webSearch|pubmedSearch)\.tools\)/);
// The same dispatcher the assistant uses, so an image made here is owned,
// queued and rendered identically to one made there.
assert.match(route, /imageTool\.dispatch\(ai, \{/);
assert.match(route, /workflow: '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 imageTool\.dispatch\(ai, \{/);
// dispatch expects { request, history }; a bare topic string made the bound
// request undefined and lost the topic entirely.
assert.match(route, /imageContext: generatedImages\.imageContext\(topic, \[\]\)/);
// 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, /call generate_image ONCE before writing/);
assert.match(route, /is about the written resource; the 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 below ask for illustration/);
assert.match(route, /image description from what they asked for/);
// And it says what it cannot do, rather than quietly drawing one of three.
assert.match(route, /Exactly one image is produced per/);
// The instructions really do come after this paragraph in the prompt, so
// "below" is accurate rather than a guess.
const prompt = route.slice(route.indexOf('function buildPrompt'));
assert.ok(prompt.indexOf('illustration +') < prompt.indexOf('Additional instructions'),
'illustration guidance precedes the instructions it refers to');
// 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, /include a diagram of the airway/);
assert.match(html, /Add one illustration/, 'and the count is stated where the choice is made');
// 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('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('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;/);
// And it applies to the PDF path too, which renders from the pptx.
assert.match(exporter, /if \(office === 'pptx'\) bytes = await fitSlideText\(bytes\);/);
// 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\./);
});