pediatric-ai-scribe-v3/test/image-library.test.js
Daniel 03621752e8
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: image fallback chains for every workflow, and a library worth looking at
**Fallbacks.** One image model meant a refusal, a rate limit or a model the
gateway had since dropped ended as a missing picture. Every workflow now tries
its model, then each fallback in order, stopping at the first that produces an
image. Primary plus two, capped: each hop is a paid request, and a chain long
enough to need a cap is long enough to surprise someone.

My Resources previously had no fallback at all — only the Clinical Assistant
did, and only one. That is backwards: a missing figure is most visible in a
deck, where it leaves a hole in a slide.

The retry rule is now a classifier that says *why*, rather than a boolean.
Transient faults, a 404 for a model the gateway does not have, and a content
refusal all move to the next model — a refusal because policy is a vendor
decision, not a fact about the request. 401/403 stop immediately (one gateway,
one set of credentials, the next model fails identically), as do 413 and any
other 4xx, which are malformed everywhere. Refusals are recognised from the
message: no provider sends a machine-readable reason and the status varies.

Each hop re-leases the job, so a chain cannot outlive its claim and let a second
worker repeat the same paid work, and the row records the model actually being
paid for so a picture made by the third model is not attributed to the first.

The old singular `fallback_image_model` is still read, so an existing
configuration keeps working without anyone re-entering it.

**Library.** Documents/Images tabs in My Resources, with a real grid: fixed
aspect tiles so the rows line up whatever shape the pictures are, a source badge
on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails
land, and a lightbox that closes on Escape or the backdrop and restores focus.
Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete
behind :hover would put it out of reach on touch and keyboard.

Downloads go through privateImageBlob rather than a bare `<a download href>`: a
mobile client's session is a bearer token an anchor cannot send, and these
assets are served no-store on purpose.

The gallery lives in My Resources only. Assistant images appear in it, which was
the point; the assistant page does not grow a gallery of its own, and a test
asserts no assistant module lists the endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 14:53:04 +02:00

143 lines
7.7 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.

// The image library: every picture this account generated, across all three
// features, with thumbnails rather than originals and a delete that removes the
// bytes and not only the row.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
test('the listing is the owners own finished images, newest first', () => {
const route = read('src/routes/generatedImages.js');
assert.match(route, /router\.get\('\/generated-images'/);
// Scoped by owner in the statement, not filtered afterwards.
assert.match(route, /owner_id=\$1 AND stage='done'/);
assert.match(route, /ORDER BY created_at DESC/);
// Unfinished jobs have nothing to show; listing one puts a broken frame in a
// gallery.
assert.match(route, /Only finished jobs/);
});
test('paging is keyset, not OFFSET', () => {
// A gallery that grows while you scroll repeats or skips a row under OFFSET.
const route = read('src/routes/generatedImages.js');
assert.match(route, /created_at < \$/);
assert.doesNotMatch(route, /\bOFFSET\s+\$?\d/i, 'no SQL OFFSET; the word in a comment is fine');
assert.match(route, /nextBefore/);
});
test('a prompt that cannot be decrypted does not cost the gallery its picture', () => {
const route = read('src/routes/generatedImages.js');
assert.match(route, /try \{ prompt = String\(encryption\.decryptString/);
assert.match(route, /catch \(e\) \{ prompt = ''; \}/);
});
test('deleting removes the bytes before the row, and refuses if it cannot', () => {
// A row without its object is a broken image in a gallery; an object without
// its row is only wasted space. Only one of those is allowed to happen.
const lib = read('src/utils/generatedImages.js');
assert.match(lib, /async function discard\(id, owner\)/);
const discard = lib.slice(lib.indexOf('async function discard'));
const storageFirst = discard.indexOf('getStorage().remove(id)');
const rowSecond = discard.indexOf('DELETE FROM generated_image_jobs');
assert.ok(storageFirst > -1 && rowSecond > storageFirst, 'storage must be removed first');
assert.match(discard.slice(0, 800), /throw failure\(503, 'Image storage is unavailable; nothing was deleted'\)/);
});
test('deleting removes the previews too, not just the original', () => {
// Both derived widths live under their own prefix. Missing them leaves paid-for
// bytes in the bucket that are still readable.
const storage = read('src/utils/generatedImageStorage.js');
assert.match(storage, /async remove\(id\)/);
assert.match(storage, /\['assets\/' \+ id\]\.concat\(THUMB_WIDTHS\.map/);
});
test('the thumbnail widths have one definition', () => {
// Two copies drift: a width written but never deleted is paid for once and
// then left in the bucket forever.
const storage = read('src/utils/generatedImageStorage.js');
const lib = read('src/utils/generatedImages.js');
assert.match(storage, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\)/);
assert.match(lib, /const \{ THUMB_WIDTHS \} = storageUtil/);
assert.doesNotMatch(lib, /THUMB_WIDTHS = Object\.freeze/, 'not defined twice');
assert.deepEqual(require('../src/utils/generatedImages').THUMB_WIDTHS, [256, 640]);
});
test('a borrowed id deletes nothing rather than someone elses picture', () => {
const route = read('src/routes/generatedImages.js');
assert.match(route, /SELECT id FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/);
const lib = read('src/utils/generatedImages.js');
assert.match(lib, /DELETE FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/);
});
test('the grid asks for the stored preview, not the original', () => {
// Thirty tiles at full size is thirty full-size downloads.
const ui = read('public/js/myResources.js');
assert.match(ui, /setAttribute\('data-image-thumb', '256'\)/);
assert.match(ui, /hydrateImage\(img, image\.imageUrl\)/,
'fetched through the authenticated loader, never a bare src');
});
test('the caption is a model-written prompt and never reaches the page as HTML', () => {
const ui = read('public/js/myResources.js');
const tile = ui.slice(ui.indexOf('function imageTile'), ui.indexOf('function openImage'));
assert.match(tile, /prompt\.textContent = image\.prompt/);
assert.doesNotMatch(tile, /innerHTML/);
});
test('the images tab loads on first open, not on page load', () => {
// Most visits never open it, and it costs a query plus a thumbnail per tile.
const ui = read('public/js/myResources.js');
assert.match(ui, /if \(!docs && !imagesLoaded\) loadImages\(true\)/);
});
test('the library is in My Resources and never in the Clinical Assistant', () => {
// Assistant images appear here — that was the point — but the assistant page
// does not grow a gallery of its own.
const ui = require('fs').readFileSync(require('path').join(__dirname, '..', 'public/js/myResources.js'), 'utf8');
assert.match(ui, /function loadImages/);
const assistantFiles = require('fs').readdirSync(require('path').join(__dirname, '..', 'public/js/assistant'));
for (const file of assistantFiles) {
const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'public/js/assistant', file), 'utf8');
assert.doesNotMatch(src, /\/api\/generated-images\?/, file + ' must not list the library');
}
const component = require('fs').readFileSync(require('path').join(__dirname, '..', 'public/components/my-resources.html'), 'utf8');
assert.match(component, /id="mr-images-panel"/);
});
test('downloading goes through the authenticated blob, not a bare link', () => {
// A mobile client's session is a bearer token an <a> cannot send, and the
// asset is served no-store on purpose.
const ui = require('fs').readFileSync(require('path').join(__dirname, '..', 'public/js/myResources.js'), 'utf8');
const save = ui.slice(ui.indexOf('function saveImage'), ui.indexOf('function deleteImage'));
assert.match(save, /m\.privateImageBlob\(image\.imageUrl\)/);
assert.match(save, /URL\.revokeObjectURL/, 'the object URL is released');
assert.doesNotMatch(save, /href = image\.downloadUrl/);
});
test('the tile actions stay reachable without a pointer', () => {
// Hiding delete behind :hover puts it out of reach on touch and keyboard.
const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public/css/styles.css'), 'utf8');
const block = css.slice(css.indexOf('.img-tile-actions'));
assert.match(block.slice(0, 400), /@media \(hover:hover\)/, 'only hidden where hover exists');
assert.match(css, /\.img-tile:focus-within \.img-tile-actions/);
assert.match(css, /prefers-reduced-motion/);
});
test('the picture itself is a button, so opening it needs no invented key handling', () => {
const ui = require('fs').readFileSync(require('path').join(__dirname, '..', 'public/js/myResources.js'), 'utf8');
const tile = ui.slice(ui.indexOf('function imageTile'), ui.indexOf('function openImage'));
assert.match(tile, /frame\.type = 'button'/);
assert.match(tile, /loading = 'lazy'/, 'a grid of thumbnails should not all fetch at once');
});
test('the lightbox closes on Escape and on the backdrop, and restores focus', () => {
const ui = require('fs').readFileSync(require('path').join(__dirname, '..', 'public/js/myResources.js'), 'utf8');
const box = ui.slice(ui.indexOf('function openImage'), ui.indexOf('function saveImage'));
assert.match(box, /aria-modal/);
assert.match(box, /e\.key === 'Escape'/);
assert.match(box, /if \(e\.target === overlay\) dismiss\(\)/, 'a click on the picture must not close it');
assert.match(box, /lastFocus\.focus\(\)/);
assert.match(box, /removeEventListener\('keydown', onKey\)/, 'no listener left behind');
});