// Unit tests for the deterministic quote-span segmenter. Run: npm run test:unit import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const spans = createRequire(import.meta.url)('../static/js/audiobook-spans.js'); const filler = Array.from({ length: 6 }, (_, i) => `»Zeile ${i} ist ein Satz.« sagte er.`).join(' '); const kinds = (out) => out.map((s) => (s.kind === 'dialogue' ? 'D' : 'N')).join(''); const strip = (t) => t.replace(/[»«]/g, '').replace(/\s+/g, ' ').trim(); test('detects German inward guillemets and ignores text without a quote style', () => { assert.equal(spans.detectStyle(filler).name, 'guillemets-inward'); assert.equal(spans.detectStyle('Kein wörtliche Rede hier. '.repeat(20)), null); assert.equal(spans.segmentByQuotes('Nur Erzählung.'), null); }); test('splits narration and dialogue and keeps every word', () => { const text = `${filler} Der Zwerg schneuzte sich. »Na los!« Er ging.`; const out = spans.segmentByQuotes(text); assert.equal(strip(out.map((s) => s.text).join(' ')), strip(text)); assert.match(kinds(out), /DN$/); assert.equal(out.find((s) => s.text === 'Na los!').kind, 'dialogue'); assert.equal(out.find((s) => s.text.includes('Der Zwerg')).kind, 'narration'); }); test('a closing mark without its opener still yields the speech before it', () => { const text = `${filler} Misira griff nach seiner Hand. Ich liebe dich und will ohne dich nicht leben.« Sie stockte.`; const out = spans.segmentByQuotes(text); const d = out.find((s) => s.text.startsWith('Ich liebe dich')); assert.equal(d.kind, 'dialogue'); assert.equal(out.find((s) => s.text.includes('Misira')).kind, 'narration'); }); test('a speech continues across paragraphs that are not re-opened', () => { const text = `${filler} Er begann: »Erster Absatz der Rede ist hier.\n\nZweiter Absatz ohne Anführungszeichen.\n\nDritter Absatz endet.« Dann schwieg er.`; const out = spans.segmentByQuotes(text); const d = out.find((s) => s.text.includes('Zweiter Absatz')); assert.equal(d.kind, 'dialogue'); assert.ok(d.text.includes('Dritter Absatz endet.')); assert.equal(out.find((s) => s.text.includes('Dann schwieg')).kind, 'narration'); }); test('a quoted term inside a sentence is not speech', () => { const text = `${filler} Himgi war überzeugt, daß es der »gewundene Weg der Krieger« sei, von dem die Schriften sprachen.`; const out = spans.segmentByQuotes(text); assert.ok(!out.some((s) => s.kind === 'dialogue' && s.text.includes('gewundene'))); }); test('windows keep spans whole, stay near the target size and split on paragraph gaps', () => { const para = `${filler}\n\n`; const text = para.repeat(12); const out = spans.segmentByQuotes(text); const wins = spans.buildWindows(out, text, { targetChars: 300, hardChars: 500 }); assert.equal(wins.flatMap((w) => w.spans).length, out.length); assert.ok(wins.length > 3); assert.ok(wins.every((w) => w.spans.reduce((a, s) => a + s.text.length, 0) <= 500 + 200)); assert.ok(wins.every((w) => w.hasDialogue === w.spans.some((s) => s.kind === 'dialogue'))); }); test('very long spans are abbreviated for the LLM but kept whole in text', () => { const long = 'Wort '.repeat(600); const text = `${filler} ${long}\n\n»Kurz.« sagte er.`; const out = spans.segmentByQuotes(text); const wins = spans.buildWindows(out, text); const sp = wins.flatMap((w) => w.spans).find((s) => s.text.length > 2000); assert.ok(sp.llmText.length < 1000 && sp.llmText.includes('[...]')); assert.equal(sp.text.length > 2000, true); });