import { describe, test, expect } from 'bun:test'; import { chunkText } from '../../src/core/chunkers/recursive.ts'; describe('Recursive Text Chunker', () => { test('returns empty array for empty input', () => { expect(chunkText('')).toEqual([]); expect(chunkText(' ')).toEqual([]); }); test('returns single chunk for short text', () => { const text = 'Hello world. This is a short text.'; const chunks = chunkText(text); expect(chunks).toHaveLength(1); expect(chunks[0].text).toBe(text.trim()); expect(chunks[0].index).toBe(0); }); test('splits at paragraph boundaries', () => { const paragraph = 'word '.repeat(200).trim(); const text = paragraph + '\n\n' + paragraph; const chunks = chunkText(text, { chunkSize: 250 }); expect(chunks.length).toBeGreaterThanOrEqual(2); }); test('respects chunk size target', () => { const text = 'word '.repeat(1000).trim(); const chunks = chunkText(text, { chunkSize: 100 }); for (const chunk of chunks) { const wordCount = chunk.text.split(/\s+/).length; // Allow up to 1.5x target due to greedy merge expect(wordCount).toBeLessThanOrEqual(150); } }); test('applies overlap between chunks', () => { const text = 'word '.repeat(1000).trim(); const chunks = chunkText(text, { chunkSize: 100, chunkOverlap: 20 }); expect(chunks.length).toBeGreaterThan(1); // Second chunk should start with words from end of first chunk // (overlap means shared content between adjacent chunks) expect(chunks[1].text.length).toBeGreaterThan(0); }); test('splits at sentence boundaries', () => { const sentences = Array.from({ length: 50 }, (_, i) => `This is sentence number ${i} with some content about topic ${i}.` ).join(' '); const chunks = chunkText(sentences, { chunkSize: 50 }); expect(chunks.length).toBeGreaterThan(1); // Each chunk should end near a sentence boundary for (const chunk of chunks.slice(0, -1)) { // Allow for overlap text, but the core content should have sentence endings expect(chunk.text).toMatch(/[.!?]/); } }); test('assigns sequential indices', () => { const text = 'word '.repeat(1000).trim(); const chunks = chunkText(text, { chunkSize: 100 }); for (let i = 0; i < chunks.length; i++) { expect(chunks[i].index).toBe(i); } }); test('handles single word input', () => { const chunks = chunkText('hello'); expect(chunks).toHaveLength(1); expect(chunks[0].text).toBe('hello'); }); test('handles unicode text', () => { const text = 'Bonjour le monde. ' + 'Ceci est un texte en francais. '.repeat(100); const chunks = chunkText(text, { chunkSize: 50 }); expect(chunks.length).toBeGreaterThan(1); expect(chunks[0].text).toContain('Bonjour'); }); test('splits at single newline (line-level) when paragraphs are absent', () => { // Lines without double newlines should still split at single newlines const lines = Array(100).fill('This is a single line of text.').join('\n'); const chunks = chunkText(lines, { chunkSize: 20 }); expect(chunks.length).toBeGreaterThan(1); }); test('handles text with only whitespace delimiters (word-level split)', () => { // No sentences, no newlines, just words const words = Array(200).fill('word').join(' '); const chunks = chunkText(words, { chunkSize: 50 }); expect(chunks.length).toBeGreaterThan(1); for (const chunk of chunks) { expect(chunk.text.trim().length).toBeGreaterThan(0); } }); test('handles clause-level delimiters (semicolons, colons, commas)', () => { // Text with clauses but no sentence endings const text = Array(100).fill('clause one; clause two: clause three, clause four').join(' '); const chunks = chunkText(text, { chunkSize: 30 }); expect(chunks.length).toBeGreaterThan(1); }); test('preserves content across chunks (lossless)', () => { const original = 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.'; const chunks = chunkText(original, { chunkSize: 5, chunkOverlap: 0 }); // With no overlap, all text should appear in chunks const reconstructed = chunks.map(c => c.text).join(' '); expect(reconstructed).toContain('First paragraph'); expect(reconstructed).toContain('Second paragraph'); expect(reconstructed).toContain('Third paragraph'); }); test('default options produce reasonable chunks', () => { // Large text with defaults (300 words, 50 overlap) const text = Array(500).fill('This is a test sentence with several words.').join(' '); const chunks = chunkText(text); expect(chunks.length).toBeGreaterThan(1); for (const chunk of chunks) { const wordCount = chunk.text.split(/\s+/).length; // Should be roughly 300 words, with 1.5x tolerance expect(wordCount).toBeLessThanOrEqual(500); } }); test('handles mixed delimiter hierarchy', () => { const text = [ 'Paragraph one has sentences. And more sentences! Really?', '', 'Paragraph two; with clauses: and more, clauses here.', '', 'Paragraph three.\nWith line breaks.\nAnd more lines.', ].join('\n'); const chunks = chunkText(text, { chunkSize: 10 }); expect(chunks.length).toBeGreaterThan(1); }); }); describe('CJK chunking (v0.32.7)', () => { test('MARKDOWN_CHUNKER_VERSION is 3', async () => { // v0.40.3.0: bumped 2→3 to signal the post-upgrade reembed sweep that // contextual retrieval wrapping is now applied at embed time. Chunk // boundaries themselves are unchanged; the bump forces re-embed for // pages where chunker_version < 3. const mod = await import('../../src/core/chunkers/recursive.ts'); expect(mod.MARKDOWN_CHUNKER_VERSION).toBe(3); }); test('long pure-Chinese paragraph splits into multiple chunks', () => { // Pre-fix: 1000 Chinese chars counts as 1 word, never splits. // Post-fix: density >= 30% → char-count → splits at chunkSize. const text = '品牌圣经测试用例'.repeat(200); // 1600 CJK chars, no whitespace const chunks = chunkText(text, { chunkSize: 100, chunkOverlap: 10 }); expect(chunks.length).toBeGreaterThan(1); }); test('Japanese with 。 sentence terminator splits at CJK delimiter', () => { // Each sentence is small (10 chars). chunkSize 5 → must split. // With CJK delimiter `。` in L2, the splitter finds sentence boundaries. const text = '今日は晴れです。明日は雨です。明後日は曇りです。'.repeat(20); const chunks = chunkText(text, { chunkSize: 50, chunkOverlap: 5 }); expect(chunks.length).toBeGreaterThan(1); // Verify chunks generally end near a sentence boundary const someEndAtPunct = chunks.some(c => /[。!?]/.test(c.text.slice(-3))); expect(someEndAtPunct).toBe(true); }); test('Korean Hangul + spaces splits cleanly', () => { // Mixed CJK density but with spaces — should still split. const text = '한글 테스트 입니다 짧은 문장 여러개 '.repeat(50); const chunks = chunkText(text, { chunkSize: 30 }); expect(chunks.length).toBeGreaterThan(1); }); test('mixed CJK + English still splits', () => { const para = 'This is English text. 这是中文文本。 More English here. '; const text = para.repeat(30); const chunks = chunkText(text, { chunkSize: 20 }); expect(chunks.length).toBeGreaterThan(1); }); test('maxChars hard cap fires on whitespace-less CJK at chunkSize boundary', () => { // 20K char pure-Chinese blob with no whitespace; chunkSize 10K (huge) // is overridden by maxChars=6000 cap. const text = '测试'.repeat(10000); // 20K chars const chunks = chunkText(text, { chunkSize: 100000, chunkOverlap: 0, maxChars: 6000 }); expect(chunks.length).toBeGreaterThan(1); for (const c of chunks) { expect(c.text.length).toBeLessThanOrEqual(6000); } }); test('maxChars sliding window preserves overlap for continuity', () => { const text = 'A'.repeat(15000); // 15K of one char, no delimiters const chunks = chunkText(text, { chunkSize: 100000, chunkOverlap: 0, maxChars: 6000 }); expect(chunks.length).toBeGreaterThanOrEqual(3); // Successive chunks should overlap by ~500 chars at the cap boundary expect(chunks[0].text.length).toBeLessThanOrEqual(6000); expect(chunks[1].text.length).toBeLessThanOrEqual(6000); }); test('maxChars applies on single-short-chunk path too', () => { // A short doc (under chunkSize words) but with one huge whitespace-less // line that exceeds maxChars. The single-chunk fast path must still cap. const text = 'a'.repeat(8000); // 1 "word" of 8000 chars const chunks = chunkText(text, { chunkSize: 300, maxChars: 6000 }); expect(chunks.length).toBeGreaterThanOrEqual(2); for (const c of chunks) { expect(c.text.length).toBeLessThanOrEqual(6000); } }); test('REGRESSION: pure English doc unchanged', () => { const para = 'The quick brown fox jumps over the lazy dog. '.repeat(50); const chunks = chunkText(para, { chunkSize: 50 }); expect(chunks.length).toBeGreaterThan(0); // Should have been chunked by word boundaries (English-dominant doc). expect(chunks.every(c => /^[\x20-\x7e\s]+$/.test(c.text))).toBe(true); }); });