pediatric-ai-scribe-v3/test/assistant-citations.test.js
Daniel 6e779e61ff
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 6s
fix: citation chips that sit together read in ascending order
[1] early in an answer and then "[2][1]" later showed "2 1". A run of
adjacent chips is now sorted by what it displays — a core rule after inline
parsing — so it reads "1 2". Text between two clusters keeps them apart,
every chip still points at its own source, and the numbering itself is
unchanged: only the order within a run moves.

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

386 lines
24 KiB
JavaScript

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, /<a class="assistant-cite"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*>\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, /<a class="assistant-cite"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*>\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, /<a class="assistant-cite"[^>]*>1<\/a> <a class="assistant-cite"[^>]*>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 <a class="assistant-cite"[^>]*data-source-number="1"[^>]*>\d+<\/a>/);
assert.match(html, /bronchodilator <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>\d+<\/a>/);
assert.doesNotMatch(html, /data-source-number="1"[^>]*>\d+<\/a><a class="assistant-cite"[^>]*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> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>2<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>3<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>4<\/a>/);
assert.doesNotMatch(html, /\]<span|\]\[/);
return;
assert.match(html, /data-source-number="1"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>\d+<\/a>/);
assert.doesNotMatch(html, /\]<span|\]\[/);
});
test('repairs a clearly adjacent trailing citation missing its closing bracket', async () => {
// "[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> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>2<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>3<\/a> <a class="assistant-cite"[^>]*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" <script>' }]);
assert.match(html, /title="Source 1: Bad &quot;Title&quot; &lt;script&gt;"/);
assert.doesNotMatch(html, /<script>/);
});
test('does not strip sentences that mention available sources', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Pneumonia can cause parapneumonic effusion [1]. However, the available sources do not specifically identify adenovirus as a typical cause [2]. In summary, evidence is insufficient [3].', sources);
assert.match(html, /available sources do not specifically identify adenovirus/);
assert.match(html, /In summary, evidence is insufficient/);
});
test('normalizes inline markdown headings before rendering', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Intro ### Management\n- oxygen', sources);
assert.match(html, /<h3>Management<\/h3>/);
assert.match(html, /<li>oxygen<\/li>/);
});
test('normalizes old saved assistant answer formatting', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Comparison of Bronchiolitis and Asthma Management in Infants ### Bronchiolitis Management - Mainstay: Supportive care [1, 2]. - Medications: bronchodilators are not routine [2]. ### Key Differences | Aspect | Bronchiolitis | Asthma | |---|---|---| | Therapy | Supportive | SABA | --- References: [1] duplicate', sources);
assert.match(html, /<h3>Bronchiolitis Management<\/h3>/);
assert.match(html, /<li>Mainstay: Supportive care/);
assert.match(html, /Key Differences/);
});
test('does not turn citation-delimited prose into a list', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Admission is indicated for: Apnoea [1, 2]- **Persistent oxygen saturation <90%** [1, 2].', sources);
assert.match(html, /<\/a>- <strong>Persistent oxygen saturation &lt;90%<\/strong>/);
assert.doesNotMatch(html, /<ul>/);
});
test('joins a citation-only paragraph back to its claim', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Acquired hypothyroidism is uncommon.\n\n[1, 2]\n\n.\n\n- Next point', sources);
assert.match(html, /uncommon\. <a class="assistant-cite"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*>\d+<\/a>\./);
assert.doesNotMatch(html, /<p>\s*<a class="assistant-cite"/);
});
test('keeps citations inline before clinical terms', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Assess for lethargy [1] and dehydration [2].', sources);
assert.match(html, /lethargy <a class="assistant-cite"[^>]*>\d+<\/a> and dehydration <a class="assistant-cite"[^>]*>\d+<\/a>/);
assert.doesNotMatch(html, /<\/a><br>\s*and dehydration/);
});
test('keeps citation-delimited clinical prose inline', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('CT is indicated for: [1, 2, 3]- Focal neurologic deficits [1].', sources);
assert.match(html, /<\/a>- Focal neurologic deficits/);
assert.doesNotMatch(html, /<br>|<ul>/);
});
test('preserves code block contents without creating citation links inside code', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('```js\nconst ref = "[1]";\n```', sources);
assert.match(html, /<pre><code>const ref = &quot;\[1\]&quot;/);
assert.doesNotMatch(html, /assistant-source-1/);
});
test('does not repair source tables inside code blocks', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('```md\n| Feature | Source(s) |\n|---|---|\n| Example | 1, 2 |\n```', sources);
assert.match(html, /\| Example \| 1, 2 \|/);
assert.doesNotMatch(html, /assistant-source-1/);
});
test('does not repair model-split markdown tables', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Summary Table\n| Indication for Use | Dose (IV) | Max Dose\n\n| Infusion Time | Monitoring/Precautions |\n|-----------------------------------------|----------------------------|----------|--------------|---------------------------------------|\n| Severe exacerbation unresponsive to SABA/anticholinergic | 25-75 mg/kg | 2 g\n| 20 min | BP, reflexes, respiratory status[1, 2] |', sources);
assert.doesNotMatch(html, /Max Dose Infusion Time/);
assert.match(html, /Infusion Time/);
assert.match(html, /assistant-cite/);
});
test('breaks inline numbered clinical sections onto new lines', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Monitor with serial abdominal exams [1]. 2. Imaging\nImmediate abdominal X-ray for obstruction [1, 2]. 3. Laboratory Studies\nAssess electrolytes if ill-appearing [3]. 4. Surgical Consultation\nUrgent pediatric surgical consultation [1].', sources);
assert.match(html, /2\. Imaging/);
assert.match(html, /3\. Laboratory Studies/);
assert.match(html, /4\. Surgical Consultation/);
assert.doesNotMatch(html, /exams[^<]*2\. Imaging/);
assert.doesNotMatch(html, /obstruction[^<]*3\. Laboratory Studies/);
});
test('strips orphan trailing markdown emphasis markers', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Summary Table\n\nAge Group\tEmpiric Antibiotics\n<1 month\tAmpicillin + cefotaxime\n**', sources);
assert.match(html, /Summary Table/);
assert.doesNotMatch(html, /\*\*/);
});
test('strips orphan trailing markdown marker after tab-separated text', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = 'Summary Table\nStep\tRecommendation\nBlood culture\tObtain before antibiotics\nHospitalization\tYes, for all febrile neonates <=28 days old\n**';
const html = renderAssistantMarkdown(input, sources, { markdownIt });
assert.doesNotMatch(html, /<table>/);
assert.match(html, /Hospitalization/);
assert.doesNotMatch(html, /\*\*/);
});
test('keeps summary paragraph outside tab-separated text', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = 'Summary Table\nStep/Indication\tRecommendation/Threshold\tCitation\nInitial assessment\tABCs and focused exam\t[1]\nActivated charcoal window\tWithin 1 hour\t[2]\nIn summary: rapidly assess ABCs and consider activated charcoal when appropriate [1, 2].';
const html = renderAssistantMarkdown(input, sources, { markdownIt });
assert.doesNotMatch(html, /<table>/);
assert.match(html, /In summary:/);
assert.doesNotMatch(html, /<td>In summary:/);
});
test('strips trailing marker after notes followed by tab-separated text', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = 'Additional Notes\nListeria coverage: Ampicillin should be added for infants <3 months [1].\nVancomycin: Added for resistant pneumococcus [2].\nAge Group\tEmpiric Antibiotics\n<1 month\tAmpicillin + Cefotaxime\n1-23 months\tVancomycin + Cefotaxime or Ceftriaxone\n>=24 months\tVancomycin + Cefotaxime or Ceftriaxone\n**';
const html = renderAssistantMarkdown(input, sources, { markdownIt });
assert.match(html, /Additional Notes/);
assert.doesNotMatch(html, /<table>/);
assert.doesNotMatch(html, /\*\*/);
});
test('links citation-only cells in markdown pipe tables', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = '| Step | Purpose | Source(s) |\n|---|---|---|\n| Surgical consult | Urgent assessment | [1, 2] |\n| X-ray | Assess obstruction | [3] |';
const html = renderAssistantMarkdown(input, sources, { markdownIt });
assert.match(html, /<table>/);
assert.match(html, /data-source-number="1"/);
assert.match(html, /data-source-number="2"/);
assert.match(html, /data-source-number="3"/);
assert.doesNotMatch(html, /<td>\[1, 2\]<\/td>/);
});
test('repairs bare source numbers in markdown table source columns', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = '| Feature | Possible Surgical Cause(s) | Source(s) |\n|---|---|---|\n| Bilious vomiting | Malrotation with volvulus | 1, 2, 3 |\n| Projectile vomiting | Pyloric stenosis | 2 |';
const html = renderAssistantMarkdown(input, sources, { markdownIt });
assert.match(html, /<table>/);
assert.match(html, /data-source-number="1"/);
assert.match(html, /data-source-number="2"/);
assert.match(html, /data-source-number="3"/);
assert.doesNotMatch(html, /<td>1, 2, 3<\/td>/);
});
test('uses markdown-it stack for GFM-style tables when provided', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const html = renderAssistantMarkdown('| Age | Antibiotics |\n|---|---|\n| <1 month | Ampicillin + cefotaxime [1] |', sources, { markdownIt });
assert.match(html, /<table>/);
assert.match(html, /<td>&lt;1 month<\/td>/);
assert.match(html, /assistant-cite/);
});
test('renders pipe summary tables and removes trailing emphasis junk', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = 'Summary Table\n\n| Situation | Imaging Recommended | Timing/Notes |\n|---|---|---|\n| First febrile UTI | Renal/bladder ultrasound | After recovery |\n| Routine use of DMSA scan | Not recommended | |\n**';
const html = renderAssistantMarkdown(input, sources, { markdownIt });
assert.match(html, /<table>/);
assert.match(html, /<th>Situation<\/th>/);
assert.match(html, /<td>Routine use of DMSA scan<\/td>/);
assert.doesNotMatch(html, /\*\*/);
});
test('does not repair malformed tab-separated clinical summary rows into a table', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = 'Summary Table\nStep\tDetails\tSource\nRed Flags\tShock, peritonitis, distension\t3 4 7\nInitial Imaging\tAbdominal X-ray if perforation suspected, then ultrasound\n2 3 7\t\tDiagnostic Gold Std\nAbdominal ultrasound target sign\t2 3 7 Nonoperative Reduction\tAir or contrast enema if stable\nSurgical Indications\tShock, peritonitis, perforation, failed reduction\n1 3 7\t\tRecurrence Rate\n~10% after nonsurgical reduction\t5 6 7\tIf you need details on recurrence management, please specify.';
const html = renderAssistantMarkdown(input, sources, { markdownIt, citationLabel: 'number' });
assert.doesNotMatch(html, /<table>/);
assert.match(html, /Initial Imaging/);
assert.match(html, /If you need details/);
});
test('a streaming table renders block by block, not as a raw text dump', async () => {
// Previously a long or pipe-heavy answer gave up on markdown entirely and
// went into an unstyled <pre> — the dark flash while a table streamed. Now
// the finished blocks are markdown and only the unfinished tail is text.
// Behaviour lives in test/assistant-streaming-blocks.test.js; this guards
// against the old bail-out returning.
const source = await fs.readFile(path.join(__dirname, '..', 'public', 'js', 'clinicalAssistant.js'), 'utf8');
assert.doesNotMatch(source, /STREAM_MARKDOWN_LIMIT/);
assert.doesNotMatch(source, /pipeRows >= 8/);
assert.match(source, /function settledMarkdownLength\(text, from\)/);
assert.match(source, /renderStreamingInto\(bubble, partial, streamSources\)/);
});
test('mermaid source survives the sanitiser and round-trips', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const flowchart = 'graph TD; A[Start]-->B[Give O2];';
const html = renderAssistantMarkdown('```mermaid\n' + flowchart + '\n```', []);
const attr = html.match(/data-mermaid="([^"]*)"/);
assert.ok(attr, 'the placeholder must carry the diagram source');
// Encoded, because DOMPurify strips an attribute whose value contains "-->"
// and every flowchart has one, which left diagrams stuck on their placeholder.
assert.ok(!attr[1].includes('-->'), 'the stored value must not contain a raw arrow');
assert.equal(decodeURIComponent(attr[1].replace(/&amp;/g, '&')).trim(), flowchart);
});
// ── Admin sources display ──────────────────────────────────────────────────
test('the prompt is identical whether or not sources are displayed', () => {
const { buildSystemPrompt } = require('../src/utils/clinicalAnswer');
// Branching the prompt on a display setting would change how the model
// reasons and cites, so the same question could get a different answer
// depending on what the admin chose to show. That is the bias this prevents.
const base = buildSystemPrompt('BEHAVIOR');
assert.equal(buildSystemPrompt('BEHAVIOR', { citations: false }), base, 'options cannot alter it');
assert.equal(buildSystemPrompt('BEHAVIOR', { showSources: false }), base);
assert.equal(buildSystemPrompt('BEHAVIOR', {}), base);
assert.match(base, /Cite factual claims immediately with numbered citations/,
'citations are always requested, so the stored answer keeps them');
});
test('hiding sources is display-only and reversible', () => {
const { stripCitationMarkers } = require('../src/utils/clinicalAnswer');
assert.equal(stripCitationMarkers('Amoxicillin 90 mg/kg/day [1][2]. Reassess in 48 h [3].'),
'Amoxicillin 90 mg/kg/day. Reassess in 48 h.');
assert.equal(stripCitationMarkers('Give fluids [1, 3] and rest [2].'), 'Give fluids and rest.');
assert.equal(stripCitationMarkers('No citations here.'), 'No citations here.');
// The escaped form and the placeholder go too; the answer stays grounded either way.
assert.equal(stripCitationMarkers('Uncommon \\[1, 2\\]. Review [src] weekly [1][4].'), 'Uncommon. Review weekly.');
const fs = require('node:fs');
const path = require('node:path');
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8');
// Stripping happens on the way OUT, so the answer is generated and stored with
// citations intact and turning the setting back on restores them.
assert.match(route, /answer: prepared\.showSources \? answer : stripCitationMarkers\(answer\)/);
// The same sanitised list either way: showSources gates only what leaves the
// route, never what was retrieved. Both answer paths compute it, so match the
// guarantee rather than one spelling of it.
assert.match(route, /sources: prepared\.showSources \? (sanitizeSourcesForClient\(prepared\.sources\)|fallbackSources|safeSources) : \[\]/);
assert.match(route, /var fallbackSources = sanitizeSourcesForClient\(prepared\.sources\);/);
assert.match(route, /content: buildSystemPrompt\(behavior\)/, 'the prompt takes no display argument');
});
test('the sources toggle is a boolean the server enforces, with the legacy key honoured', () => {
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const admin = fs.readFileSync(path.join(root, 'src/routes/adminConfig.js'), 'utf8');
assert.match(admin, /clinical_assistant\.show_sources' && !\['true', 'false'\]/);
const route = fs.readFileSync(path.join(root, 'src/routes/clinicalAssistant.js'), 'utf8');
assert.match(route, /clinical_assistant\.citations_enabled', 'true'\)/, 'the old key still applies');
assert.match(fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'),
/id="assistant-show-sources"/);
});
test('a markdown link or footnote whose text is a bare number stays a link, not a citation', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
// The citation rule runs before markdown-it's link rule, so it has to step
// aside for "[1](url)" and "[1]: url" or every numbered link would become a chip.
const html = renderAssistantMarkdown('See [1](https://example.org/guide) and [2].', sources);
assert.match(html, /<a[^>]+href="https:\/\/example\.org\/guide"[^>]*>1<\/a>/);
assert.equal((html.match(/assistant-cite/g) || []).length, 1);
assert.match(html, /data-source-number="2"/);
});
test('chips that sit together read in ascending order, and still point at their own sources', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const four = [1, 2, 3, 4].map(n => ({ number: n, title: 'S' + n }));
// [1] appears first; the model then writes [2][1] — which showed "2 1".
const html = renderAssistantMarkdown('First claim [1]. Second claim [2][1]. Third [3, 2].', four);
const chips = [...html.matchAll(/data-source-number="(\d+)"[^>]*data-display-number="(\d+)"/g)].map(m => m[1] + '→' + m[2]);
assert.deepEqual(chips, ['1→1', '1→1', '2→2', '2→2', '3→3'], 'each run ascends by display; identity is untouched');
// Clusters separated by text are not merged into one run.
const apart = renderAssistantMarkdown('A [2] then [1].', four);
const order = [...apart.matchAll(/data-display-number="(\d+)"/g)].map(m => m[1]);
assert.deepEqual(order, ['1', '2'], 'first appearance still numbers them; nothing is reordered across text');
});