const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const MarkdownIt = require('markdown-it');
let modulePromise;
// The module is real ESM with no window; it finds its parser on globalThis,
// which is what window is in the browser.
globalThis.markdownit = require('markdown-it');
async function loadCitationModule() {
if (!modulePromise) {
modulePromise = fs.readFile(path.join(__dirname, '..', 'public', 'js', 'assistant', 'citations.js'), 'utf8')
.then((source) => import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source)));
}
return modulePromise;
}
const sources = [
{ title: 'Nelson Textbook of Pediatrics' },
{ title: 'Pediatric Asthma Guideline' },
{ resource: 'Emergency Medicine Review' }
];
test('renders citation clusters as links to matching source cards', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources);
assert.match(html, /href="#assistant-source-1"/);
assert.match(html, /href="#assistant-source-2"/);
assert.match(html, /title="Source 1: Nelson Textbook of Pediatrics"/);
assert.match(html, /data-source-number="1"/);
assert.match(html, /data-source-number="2"/);
assert.match(html, /]*>\d+<\/a> ]*>\d+<\/a>/);
});
test('renders escaped citation tokens as source links, not display math', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const katex = { renderToString: function () { throw new Error('citation sent to KaTeX'); } };
const html = renderAssistantMarkdown('Acquired hypothyroidism is uncommon. \\[1, 2\\].', sources, { katex });
assert.match(html, /]*>\d+<\/a> ]*>\d+<\/a>/);
assert.doesNotMatch(html, /katex-display/);
});
test('can render citation labels as numbers for PDF export', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources, { citationLabel: 'number' });
assert.match(html, /]*>1<\/a> ]*>2<\/a>/);
});
test('strips [src]/[source] placeholder tokens instead of inventing numbers', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Give fluids [src] and review in 24h [source].', []);
assert.doesNotMatch(html, /\[src\]|\[source\]/i);
assert.match(html, /Give fluids/);
const kept = renderAssistantMarkdown('Give fluids [1] and review.', [{ number: 1, title: 'S', page: 2 }]);
assert.match(kept, /data-source-number="1"/, 'real citation numbers still render');
});
test('leaves unknown citation clusters untouched rather than guessing', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Dose statement [4].', sources);
assert.match(html, /\[4\]/);
assert.doesNotMatch(html, /assistant-source-4/);
});
test('does not merge separate citations across separate claims', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Use oxygen [1]. Give bronchodilator [2].', sources);
assert.match(html, /oxygen ]*data-source-number="1"[^>]*>\d+<\/a>/);
assert.match(html, /bronchodilator ]*data-source-number="2"[^>]*>\d+<\/a>/);
assert.doesNotMatch(html, /data-source-number="1"[^>]*>\d+<\/a>]*data-source-number="2"/);
});
test('adjacent citations render as one run of chips, numbered by first appearance', async () => {
// They used to be sorted into ascending source order. Nothing is sorted now:
// the chip shows the display number, which is first-appearance order, so a
// reader sees 1 2 3 4 in either case — and the text underneath is untouched,
// which is what keeps a saved answer's markers meaning what they meant.
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3].', [
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
]);
assert.match(html, /data-source-number="1"[^>]*>1<\/a> ]*data-source-number="4"[^>]*>2<\/a> ]*data-source-number="2"[^>]*>3<\/a> ]*data-source-number="3"[^>]*>4<\/a>/);
assert.doesNotMatch(html, /\]]*>\d+<\/a> ]*data-source-number="2"[^>]*>\d+<\/a> ]*data-source-number="3"[^>]*>\d+<\/a> ]*data-source-number="4"[^>]*>\d+<\/a>/);
assert.doesNotMatch(html, /\] {
// "[1][4][2][3" at the very end is a bracket the model ran out of room for.
// Repaired before parsing, so the rule sees a complete cluster; numbered by
// first appearance like any other.
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3', [
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
]);
assert.match(html, /data-source-number="1"[^>]*>1<\/a> ]*data-source-number="4"[^>]*>2<\/a> ]*data-source-number="2"[^>]*>3<\/a> ]*data-source-number="3"[^>]*>4<\/a>/);
assert.doesNotMatch(html, /\[3(?!\])/, 'the unterminated bracket is gone');
});
test('does not normalize adjacent citations if any source number is unknown', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Unsupported claim [1][9].', sources);
assert.match(html, /\[9\]/);
assert.doesNotMatch(html, /assistant-source-9/);
});
test('escapes source titles inside citation link titles', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Unsafe title [1].', [{ title: 'Bad "Title"