pediatric-ai-scribe-v3/test/assistant-voice.test.js
Daniel 8a6a4df121 refactor: citations are a markdown-it token, and the numbers you see are display order
The old renderer rewrote the text: it found "[n]" with regexes, renumbered
them, and swapped the result back in — which broke inside `arr[2][1]`, inside
HTML attributes, and whenever two turns disagreed about what "[3]" meant. It
also had a fallback markdown renderer of its own for when the rewrite
produced something markdown-it would not parse.

Now "[n]" is an inline rule registered on the same markdown-it instance that
renders everything else. The parser decides what is prose and what is code, a
link, or a URL, so the rule never sees "[1]" inside a code span, and it steps
aside for "[1](url)". Math is two more rules on the same parser instead of a
regex pre-pass, so "$" inside a URL is no longer math.

Identity vs display: the stored "[n]" and each card's id are the source's
identity (sourceNumber) and are never rewritten. The number a reader sees is
the order of first appearance, computed at render time from the token stream
(orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat
re-opens pointing at the same cards it was saved with. Stored messages and
sources are untouched; export and the modal resolve by identity.

Translated HTML gets the same links through a TreeWalker over text nodes
(linkCitationsInHtml) rather than a regex over markup.

Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the
fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable),
renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert
token output; harnesses that render for real are given a parser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 04:41:40 +02:00

139 lines
7.7 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 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-voice-owner' }, true), true);
window.marked = marked;
window.markdownit = require('markdown-it');
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const transcriptions = [];
const ttsCalls = [];
const played = [];
const audioBlob = new Blob([new Array(300).join('a')], { type: 'audio/mpeg' });
function FakeRecorder() {
this.started = false;
this.stopped = false;
this.start = () => { this.started = true; return Promise.resolve(); };
this.stop = () => { this.stopped = true; return Promise.resolve(options.blob || audioBlob); };
}
function FakeRecognition() {
this.onresult = null; this.onend = null; this.started = 0;
this.start = () => { this.started += 1; };
this.stop = () => { if (this.onend) this.onend(); };
}
function FakeAudio() {
this.onended = null; this.onerror = null;
this.play = () => { played.push(1); return Promise.resolve(); };
}
const apiFetch = async (url, options) => {
if (url === '/api/text-to-speech') { ttsCalls.push(JSON.parse(options.body)); return new Response(audioBlob, { status: 200 }); }
if (url === '/clinical-assistant/translate/languages') return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en'] } }));
return new Response(JSON.stringify({ success: true, chats: [] }));
};
const context = { window, document: window.document, console, URL: window.URL, Blob, TextDecoder, AbortController, crypto: webcrypto,
setTimeout() {}, clearTimeout() {}, showToast: () => {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }),
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
translateAssistantMessage: () => Promise.resolve({ success: true }),
getAuthHeaders: () => ({}),
AudioRecorder: FakeRecorder,
createSpeechRecognition: () => new FakeRecognition(),
transcribeAudio: async blob => { transcriptions.push(blob); return { success: true, text: options.transcription || 'Dictated question', provider: 'synthetic' }; },
Audio: FakeAudio,
CustomEvent: window.CustomEvent,
fetch: apiFetch };
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);
}
context.bindEvents();
t.after(() => window.close());
return { context, document: window.document, window, transcriptions, ttsCalls };
}
test('composer mic dictation fills the input from the live transcript then the server transcription', async t => {
const app = ui(t);
app.document.getElementById('btn-assistant-mic').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
const mic = app.document.getElementById('btn-assistant-mic');
assert.ok(mic.classList.contains('recording'), 'mic shows recording state');
app.document.getElementById('btn-assistant-mic').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.ok(!mic.classList.contains('recording'));
assert.equal(app.document.getElementById('assistant-input').value, 'Dictated question');
assert.equal(app.transcriptions.length, 1, 'audio blob sent to the transcription endpoint');
});
test('mic start failure reports the microphone denial honestly', async t => {
const app = ui(t);
const context = app.context;
const orig = context.AudioRecorder;
context.AudioRecorder = function() { this.start = () => Promise.reject(new Error('denied')); this.stop = () => Promise.resolve(); };
app.document.getElementById('btn-assistant-mic').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.ok(!app.document.getElementById('btn-assistant-mic').classList.contains('recording'));
context.AudioRecorder = orig;
});
test('conversation mode listens, asks the transcribed question, and speaks the answer', async t => {
const app = ui(t);
const doc = app.document;
doc.getElementById('btn-assistant-voice').click();
await new Promise(r => setImmediate(r));
const overlay = doc.getElementById('assistant-voice-overlay');
assert.ok(overlay, 'conversation overlay opens');
doc.getElementById('assistant-voice-mic').click();
await new Promise(r => setImmediate(r));
assert.equal(doc.getElementById('assistant-voice-status').textContent, 'Listening…');
doc.getElementById('assistant-voice-mic').click(); // stop → transcribe → ask
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(doc.getElementById('assistant-input').value, 'Dictated question', 'transcribed question sent through the normal composer');
// The send click runs the real onAsk → openAssistantStream returns a done answer → setBusy(false) → answer-done event.
const done = new app.window.CustomEvent('assistant-answer-done', { detail: { answer: 'Voice answer.' } });
doc.dispatchEvent(done);
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(app.ttsCalls.length, 1, 'answer spoken via TTS');
assert.equal(app.ttsCalls[0].text, 'Voice answer.');
doc.getElementById('assistant-voice-end').click();
await new Promise(r => setImmediate(r));
assert.equal(doc.getElementById('assistant-voice-overlay'), null, 'End closes the conversation');
});
test('conversation mode survives a failed answer and reports it honestly', async t => {
const app = ui(t);
const doc = app.document;
doc.getElementById('btn-assistant-voice').click();
await new Promise(r => setImmediate(r));
doc.dispatchEvent(new app.window.CustomEvent('assistant-answer-done', { detail: { isError: true, answer: '' } }));
assert.equal(doc.getElementById('assistant-voice-status').textContent, 'Answer failed — tap to speak');
assert.equal(app.ttsCalls.length, 0, 'no TTS after an error');
});
test('conversation mode falls back to the live transcript when server transcription fails', async t => {
const app = ui(t, { transcription: '', failTranscribe: true });
const doc = app.document;
app.context.transcribeAudio = async () => { throw new Error('down'); };
doc.getElementById('btn-assistant-voice').click();
await new Promise(r => setImmediate(r));
doc.getElementById('assistant-voice-mic').click();
await new Promise(r => setImmediate(r));
// feed a live final through the recognition callback before stopping
const rec = app.context.createSpeechRecognition();
doc.getElementById('assistant-voice-mic').click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(doc.getElementById('assistant-input').value, '', 'no text without a transcript and without live speech');
});