pediatric-ai-scribe-v3/test/assistant-citation-modal.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

113 lines
5.9 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 read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function ui(t) {
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.markdownit = require('markdown-it');
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const toasts = [];
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }) };
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);
}
appBinding(t, context);
t.after(() => window.close());
return { context, document: window.document, window, toasts };
}
// The production page binds delegated clicks in bindEvents; the vm harness mirrors it.
function appBinding(t, context) {
context.bindEvents();
}
const longExcerpt = 'Synthetic excerpt. ' + 'x'.repeat(15000);
function openModal(app, n = 1) {
const cite = app.document.querySelector('.assistant-cite[data-source-number="' + n + '"]');
assert.ok(cite, 'citation chip exists');
cite.click();
return app.document.querySelector('.assistant-source-modal');
}
test('citation chips open an OWUI-style source modal with title, page and 10k excerpt preview', t => {
const app = ui(t);
const sources = [{ number: 1, title: 'Nelson Textbook of Pediatrics', page: 7, excerpt: longExcerpt }];
app.context.restoreSavedChat({ version: 2, messages: [
{ role: 'user', content: 'Question' },
{ role: 'assistant', content: 'Answer [1].', sources }
], lastAnswer: 'Answer [1].', sources });
const modal = openModal(app);
assert.ok(modal, 'modal opens');
assert.equal(modal.getAttribute('role'), 'dialog');
assert.match(modal.textContent, /Nelson Textbook of Pediatrics/);
assert.match(modal.textContent, /page 7/);
const excerpt = modal.querySelector('[data-assistant-source-excerpt]');
assert.ok(excerpt);
assert.ok(excerpt.textContent.length >= 10000 && excerpt.textContent.length <= 10200, '10k preview shown');
const showAll = modal.querySelector('[data-assistant-source-show-all]');
assert.ok(showAll, 'Show all offered for long excerpts');
showAll.click();
assert.ok(excerpt.textContent.length >= 15000, 'Show all reveals the full excerpt');
assert.equal(showAll.hidden, true, 'toggle hides once expanded');
// Sidebar switching is preserved alongside the modal.
assert.match(app.document.querySelector('#assistant-source-1').textContent, /Nelson Textbook of Pediatrics.*page 7/is);
});
test('source modal closes via button, backdrop and Escape', t => {
const app = ui(t);
const sources = [{ number: 1, title: 'Synthetic source', page: 3, excerpt: 'Short excerpt.' }];
app.context.restoreSavedChat({ version: 2, messages: [
{ role: 'user', content: 'Question' },
{ role: 'assistant', content: 'Answer [1].', sources }
], lastAnswer: 'Answer [1].', sources });
let modal = openModal(app);
assert.equal(modal.querySelector('[data-assistant-source-show-all]'), null, 'no Show all for short excerpts');
modal.querySelector('.modal-close').click();
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
modal = openModal(app);
modal.click(); // backdrop is the modal element itself
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
modal = openModal(app);
app.window.document.dispatchEvent(new app.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
});
test('per-turn source maps select the correct source for reused citation numbers', t => {
const app = ui(t);
const first = [{ number: 1, title: 'First turn reference', page: 11, excerpt: 'First turn context.' }];
const second = [{ number: 1, title: 'Second turn reference', page: 23, excerpt: 'Second turn context.' }];
app.context.restoreSavedChat({ version: 2, messages: [
{ role: 'user', content: 'First question' },
{ role: 'assistant', content: 'First answer. [1]', sources: first },
{ role: 'user', content: 'Second question' },
{ role: 'assistant', content: 'Second answer. [1]', sources: second }
], lastAnswer: 'Second answer. [1]', sources: second });
const citations = app.document.querySelectorAll('#assistant-messages .assistant-cite');
assert.equal(citations.length, 2);
citations[0].click();
assert.match(app.document.querySelector('.assistant-source-modal').textContent, /First turn reference/);
assert.match(app.document.querySelector('.assistant-source-modal').textContent, /page 11/);
app.document.querySelector('.assistant-source-modal .modal-close').click();
citations[1].click();
const modal = app.document.querySelector('.assistant-source-modal');
assert.match(modal.textContent, /Second turn reference/);
assert.match(modal.textContent, /page 23/);
assert.doesNotMatch(modal.textContent, /First turn reference/);
});