pediatric-ai-scribe-v3/test/generated-image-cache.test.js
Daniel 2096057ebd perf: cache generated images for the session and decode thumbnails, not originals
Generated assets are served `private, no-store`, which is right for a clinical
app on a shared workstation — but it meant every gallery render re-downloaded
every image. Measured: 26 images averaging 279kB, so 7.2MB fetched to draw 56px
tiles, on every open of the Create image popup.

Two changes, both reusable anywhere in the app:

- A session cache holding decoded blobs in MEMORY ONLY, so nothing is written to
  disk and the no-store posture is unchanged. Entries are keyed by owner ticket
  as well as asset, and cleared when the account boundary moves, so one account
  can never read another's bytes out of memory. Concurrent tiles asking for the
  same asset share one request rather than racing.
- Any img carrying data-image-thumb gets a downscaled copy instead of the
  original, so a 56px tile no longer decodes a 300kB image. The gallery asks for
  256px and the in-chat preview for 640px; opening the full view still gets the
  original. Browsers without OffscreenCanvas, and any decode failure, fall back
  to the full image rather than showing nothing.

This does not reduce the first fetch. Serving genuinely smaller bytes needs
server-side resizing, which needs an image library this project does not carry —
worth a deliberate decision rather than adding a native dependency in passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 05:20:10 +02:00

43 lines
2.4 KiB
JavaScript

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');
// Generated assets are served `private, no-store`, which is correct for a
// clinical app on a shared workstation. The cost was that every gallery render
// re-downloaded every image — 26 images at ~279kB to draw 56px tiles.
test('assets are still never written to disk', () => {
const route = read('src/routes/generatedImages.js');
assert.match(route, /Cache-Control', 'private, no-store'/,
'the no-store posture is unchanged; caching is in memory for the session only');
});
test('the cache is scoped to the account that fetched it', () => {
const js = read('public/js/generatedImages.js');
// A cache keyed only by URL would let the next account read the previous
// one's bytes out of memory.
assert.match(js, /function cacheKey\(src, ticket\) \{ return String\(ticket && ticket\.ticket\)/,
'entries are keyed by owner ticket as well as asset');
assert.match(js, /clearImageCache\(\); \/\/ cached bytes must not outlive the account/,
'and dropped when the account boundary moves');
assert.match(js, /assertImageOwner\(ticket\)/, 'ownership is asserted on the way in');
});
test('concurrent tiles share one request instead of racing', () => {
const js = read('public/js/generatedImages.js');
assert.match(js, /if \(inflight\.has\(key\)\) return inflight\.get\(key\);/);
assert.match(js, /\.finally\(\(\) => inflight\.delete\(key\)\)/, 'and the slot is released either way');
});
test('a tile decodes a thumbnail, not a full image', () => {
const js = read('public/js/generatedImages.js');
assert.match(js, /const edge = Number\(img\.getAttribute\('data-image-thumb'\)\) \|\| 0;/);
assert.match(js, /edge \? await cachedThumbnail\(src, edge, ticket\) : await cachedImageBlob\(src, ticket\)/);
// A browser without OffscreenCanvas still shows the image rather than nothing.
assert.match(js, /if \(typeof createImageBitmap !== 'function' \|\| typeof OffscreenCanvas !== 'function'\) return blob;/);
assert.match(js, /catch \(_\) \{\s*\n\s*return blob;/, 'and a decode failure falls back to the original');
const assistant = read('public/js/clinicalAssistant.js');
assert.match(assistant, /data-image-thumb="256"/, 'the 56px gallery asks for a small copy');
});