pediatric-ai-scribe-v3/test/assistant-autosave.test.js
Daniel 500fe2c12a fix: image Done state, chat titles that keep whole words, and readable extension cards
The Create image popup showed "Generating image…" forever even after the job
finished. The status poll called fetchAssistantImageJob, which was never
imported, so every tick threw ReferenceError — and the catch treated that like a
transient network failure and rescheduled, permanently. The import is added, a
test now asserts that every api.js function the assistant calls is actually
imported, and the poll distinguishes a programming error (surface it) from a
transient one (retry, but not forever).

Chat titles were hard-cut at 60 characters mid-word, so "Rickets Radiographic
Fea" was all the Create image picker could ever show. The server already allows
160, so titles now keep whole words up to that, and each view decides its own
visible length from the width it actually has rather than inheriting one cut made
at save time. A single very long token still falls back to a hard cut.

Extension cards led with the number at 20px with word-break:break-all, so
"5616/3764/5619" wrapped as "5616/3764/56 19" — unreadable, and unsafe to dial
from. The name leads now, since that is what the eye hunts for in a list of
fifty; the number follows in tabular figures and may only break between groups,
never inside a run of digits. Cards share a minimum height so a grid reads as
rows rather than a ragged mosaic.

The collapsed rail's brand kept its expanded margin-right:auto, which pushed the
stethoscope off the axis the two buttons sat on. Every child of the collapsed
head is now the same centred fixed-size box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
2026-09-10 06:44:22 +02:00

170 lines
8.5 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const { webcrypto } = require('node:crypto');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function fakeTimers() {
var nextId = 1;
var pending = new Map();
return {
pending: pending,
setTimeout(fn, ms) { var id = nextId++; pending.set(id, { fn, ms }); return id; },
clearTimeout(id) { pending.delete(id); },
count() { return pending.size; },
delay() { return pending.size ? Array.from(pending.values()).map(e => e.ms).join(',') : ''; },
async flush() { while (pending.size) { var entries = Array.from(pending.values()); pending.clear(); for (var e of entries) await e.fn(); } }
};
}
function ui(t, options = {}) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const timers = fakeTimers();
const toasts = [];
const saves = [];
var nextSaveId = 1;
const chats = [];
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, crypto: webcrypto,
setTimeout: timers.setTimeout.bind(timers), clearTimeout: timers.clearTimeout.bind(timers),
showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchAssistantStatus: async () => ({ success: true }),
fetchAssistantExamples: async () => ({ success: true, examples: [] }),
fetchSavedAssistantChats: async () => ({ success: true, chats }),
fetchSavedAssistantChat: async id => ({ success: true, chat: chats.find(c => c.id === id) || { id, title: 'Stored', payload: { version: 2, messages: [], sources: [], lastAnswer: '' } } }),
deleteSavedAssistantChat: async () => ({ success: true }),
saveAssistantChat: async body => {
saves.push(JSON.parse(JSON.stringify(body)));
if (options.saveError) throw new Error(options.saveError);
if (body.id) {
var existing = chats.find(c => c.id === body.id);
if (existing) { existing.payload = JSON.parse(JSON.stringify({ version: 2, messages: body.messages, sources: body.sources, lastAnswer: body.lastAnswer })); return { success: true, id: body.id }; }
}
var id = nextSaveId++;
chats.push({ id, title: body.title || 'Derived', payload: { version: 2, messages: body.messages, sources: body.sources, lastAnswer: body.lastAnswer } });
return { success: true, id };
},
openAssistantStream: async () => new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete answer.', sources: [] }) + '\n\n') };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
t.after(() => window.close());
return { context, document: window.document, window, timers, toasts, saves };
}
async function ask(app, text) {
app.document.getElementById('assistant-input').value = text;
await app.context.onAsk();
}
test('autosave debounces completed turns into one save with a word-boundary derived title, then updates in place', async t => {
const app = ui(t);
const c = app.context;
const question = 'Synthetic pediatric asthma management question that is deliberately longer than sixty characters to verify title derivation';
await ask(app, question);
await ask(app, 'Follow-up question about monitoring');
assert.equal(app.timers.count(), 1, 'two turns debounce into one pending save');
assert.equal(app.timers.delay(), '800');
await app.timers.flush();
assert.equal(app.saves.length, 1);
// A hard 60-character slice cut titles mid-word. The server allows 160, so the
// title keeps whole words up to that and each view decides its own visible
// length from the width it actually has.
assert.equal(app.saves[0].title, question, 'the whole question fits within the server limit');
assert.doesNotMatch(app.saves[0].title, /\S…$/, 'and nothing is cut mid-word');
assert.equal(app.saves[0].id, undefined, 'first autosave creates the chat');
assert.equal(app.saves[0].generatedImage, undefined);
assert.deepEqual(app.saves[0].messages.map(m => [m.role, m.content]), [
['user', question], ['assistant', 'Complete answer.'], ['user', 'Follow-up question about monitoring'], ['assistant', 'Complete answer.']
], 'raw transcript stored intact');
assert.equal(c.currentChatId, 1);
await ask(app, 'Third question');
await app.timers.flush();
assert.equal(app.saves.length, 2);
assert.equal(app.saves[1].id, 1, 'follow-up autosave updates the same chat');
assert.equal(app.saves[1].title, undefined, 'updates never overwrite the stored title');
assert.equal(app.saves[1].messages.at(-1).content, 'Complete answer.');
});
test('autosave failure surfaces exactly once and retries only on the next change', async t => {
const app = ui(t, { saveError: 'Saved chat exceeds the 8 MiB storage limit. Nothing was saved or truncated.' });
const c = app.context;
await ask(app, 'A question that produces a too-large saved chat');
await app.timers.flush();
assert.equal(app.saves.length, 1);
assert.equal(app.toasts.length, 1);
assert.match(app.toasts[0][0], /8 MiB/);
assert.equal(app.timers.count(), 0, 'no automatic retry loop');
await app.timers.flush();
assert.equal(app.saves.length, 1, 'flushing an empty timer queue does not resend');
c.appendMessage('user', 'Another change');
c.scheduleAutosave();
await app.timers.flush();
assert.equal(app.saves.length, 2, 'the next change retries');
assert.equal(app.toasts.length, 2, 'the retry failure surfaces once more');
assert.equal(c.messages.length, 3, 'failures never touch the live chat');
});
test('loading a saved chat binds autosave updates to that chat id', async t => {
const app = ui(t);
const c = app.context;
c.chats = undefined;
await c.loadSavedChat(7); // fetchSavedAssistantChat stub returns a stored payload for id 7
assert.equal(c.currentChatId, 7);
await ask(app, 'Continuing the loaded chat');
await app.timers.flush();
assert.equal(app.saves.length, 1);
assert.equal(app.saves[0].id, 7);
});
test('new chat clears the autosave identity and pending timer; autosave derives the title and keeps updating one chat', async t => {
const app = ui(t);
const c = app.context;
assert.equal(app.document.getElementById('btn-assistant-save'), null, 'manual Save control is gone — chats autosave');
assert.equal(app.document.getElementById('assistant-save-panel'), null);
c.currentChatId = 5;
c.appendMessage('user', 'Question');
c.scheduleAutosave();
assert.equal(app.timers.count(), 1);
c.performClearConversation();
assert.equal(c.currentChatId, null);
assert.equal(app.timers.count(), 0, 'pending autosave cancelled');
c.appendMessage('user', 'Fresh question');
c.appendMessage('assistant', 'Fresh answer.', []);
c.lastAnswer = 'Fresh answer.';
c.scheduleAutosave();
await app.timers.flush();
assert.equal(app.saves.length, 1);
assert.equal(app.saves[0].title, 'Fresh question', 'title derives from the first user message');
assert.equal(app.saves[0].id, undefined);
assert.equal(c.currentChatId, 1);
c.scheduleAutosave();
await app.timers.flush();
assert.equal(app.saves[1].id, 1, 'later autosaves update the same chat');
assert.equal(app.saves[1].title, undefined, 'updates keep the existing title (only the first save sets it)');
});
test('oversized conversations are sent whole and never client-truncated', async t => {
const app = ui(t);
const c = app.context;
const big = 'X'.repeat(2000);
c.appendMessage('user', 'Long question');
c.appendMessage('assistant', big, []);
c.lastAnswer = big;
c.scheduleAutosave();
await app.timers.flush();
assert.equal(app.saves[0].messages[1].content, big, 'content sent exactly');
assert.equal(c.messages[1].content, big);
});