import { strToU8, zipSync } from 'fflate' import { describe, expect, it } from 'vitest' import { buildChunkContextPrefix, chunkDocumentAdvanced, parseDocument } from './document-parser' function createPdfFixture(text: string): Buffer { const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET` const objects = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', `<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream` ] let content = '%PDF-1.4\n' const offsets = [0] for (const [index, object] of objects.entries()) { offsets.push(Buffer.byteLength(content)) content += `${index + 1} 0 obj\n${object}\nendobj\n` } const xrefOffset = Buffer.byteLength(content) content += `xref\n0 ${objects.length + 1}\n` content += '0000000000 65535 f \n' content += offsets .slice(1) .map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`) .join('') content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` content += `startxref\n${xrefOffset}\n%%EOF\n` return Buffer.from(content) } describe('document parser', () => { it('parses text and creates overlapping bounded chunks', async () => { const parsed = await parseDocument( 'notes.md', Buffer.from(`# GoodBuddy\n\n${'知识内容。'.repeat(500)}`) ) const chunks = chunkDocumentAdvanced(parsed, { version: 1, mode: 'fixed', targetCharacters: 500, overlapCharacters: 50, parentCharacters: 4_800, childCharacters: 900, contextualIndexingEnabled: false }) expect(parsed.title).toBe('notes') expect(chunks.length).toBeGreaterThan(1) expect(chunks.every((chunk) => chunk.content.length <= 501)).toBe(true) expect(chunks[0]?.locator).toBe('全文') }) it('removes scripts when parsing HTML', async () => { const parsed = await parseDocument( 'page.html', Buffer.from( '

安全标题

网页正文

' ) ) expect(parsed.content).toContain('安全标题') expect(parsed.content).toContain('网页正文') expect(parsed.content).not.toContain('恶意脚本') }) it('extracts text from DOCX, XLSX and PPTX archives', async () => { const fixtures = [ { name: 'sample.docx', path: 'word/document.xml', xml: '文档正文' }, { name: 'sample.xlsx', path: 'xl/sharedStrings.xml', xml: '表格内容' }, { name: 'sample.pptx', path: 'ppt/slides/slide1.xml', xml: '幻灯片内容' } ] for (const fixture of fixtures) { const archive = zipSync({ [fixture.path]: strToU8(fixture.xml) }) const parsed = await parseDocument( fixture.name, Buffer.from(archive) ) expect(parsed.content).toContain( fixture.name.endsWith('.docx') ? '文档正文' : fixture.name.endsWith('.xlsx') ? '表格内容' : '幻灯片内容' ) } }) it('extracts page text and locators from PDF files', async () => { const parsed = await parseDocument( 'sample.pdf', createPdfFixture('PDF body text') ) expect(parsed.content).toContain('PDF body text') expect(parsed.sections).toEqual([ { locator: '第 1 页', content: 'PDF body text', pageNumber: 1, blockKind: 'text' } ]) expect(parsed.pageCount).toBe(1) }) it('rejects unsupported or oversized content', async () => { await expect( parseDocument('archive.zip', Buffer.from('not supported')) ).rejects.toThrow('不支持') await expect( parseDocument('large.txt', Buffer.alloc(20 * 1024 * 1024 + 1)) ).rejects.toThrow('20MB') const expandedArchive = zipSync({ 'word/document.xml': new Uint8Array(11 * 1024 * 1024) }) await expect( parseDocument('expanded.docx', Buffer.from(expandedArchive)) ).rejects.toThrow('损坏') await expect( parseDocument('invalid.txt', Buffer.from([0xc3, 0x28])) ).rejects.toThrow('UTF-8') }) it('keeps extracted sections consistent with the document character limit', async () => { const parsed = await parseDocument( 'large.txt', Buffer.from('x'.repeat(5_000_100)) ) expect(parsed.content).toHaveLength(5_000_000) expect(parsed.sections).toEqual([ { locator: '全文', content: parsed.content } ]) expect(parsed.warnings).toEqual([ '文档提取文本超过 5,000,000 字符,已截断' ]) }) it('rejects chunk output that exceeds the database limit', () => { expect(() => chunkDocumentAdvanced( { title: 'Too many chunks', sourceFormat: '.txt', content: '', sections: Array.from({ length: 10_001 }, (_, index) => ({ locator: `section-${index}`, content: 'content' })), warnings: [] }, { version: 1, mode: 'fixed', targetCharacters: 400, overlapCharacters: 0, parentCharacters: 1_600, childCharacters: 300, contextualIndexingEnabled: false } ) ).toThrow('超过 10,000 个分区') }) it('preserves headings and creates recall-only children with parent context', () => { const chunks = chunkDocumentAdvanced( { title: 'Guide', sourceFormat: '.md', content: '# 安装\n' + '安装步骤和配置说明。'.repeat(250), sections: [ { locator: '全文', content: '# 安装\n' + '安装步骤和配置说明。'.repeat(250) } ], warnings: [] }, { version: 1, mode: 'parent-child', targetCharacters: 1_600, overlapCharacters: 100, parentCharacters: 1_600, childCharacters: 400, contextualIndexingEnabled: false } ) const parents = chunks.filter((chunk) => chunk.role === 'parent') const children = chunks.filter((chunk) => chunk.role === 'child') expect(parents.length).toBeGreaterThan(0) expect(children.length).toBeGreaterThan(parents.length) expect(children.every((chunk) => chunk.heading === '安装')).toBe(true) expect( children.every((chunk) => parents.some((parent) => parent.position === chunk.parentPosition) ) ).toBe(true) }) it('tracks nested Markdown heading paths and resets deeper levels', () => { const chunks = chunkDocumentAdvanced( { title: 'Guide', sourceFormat: '.md', content: '# A\none\n## B\ntwo\n### C\nthree\n## D\nfour\n# E\nfive', sections: [ { locator: '全文', content: '# A\none\n## B\ntwo\n### C\nthree\n## D\nfour\n# E\nfive' } ], warnings: [] }, { version: 1, mode: 'structure', targetCharacters: 500, overlapCharacters: 0, parentCharacters: 1_000, childCharacters: 300, contextualIndexingEnabled: false } ) expect(chunks.map((chunk) => chunk.headingPath)).toEqual([ ['A'], ['A', 'B'], ['A', 'B', 'C'], ['A', 'D'], ['E'] ]) expect(chunks.map((chunk) => chunk.heading)).toEqual([ 'A', 'B', 'C', 'D', 'E' ]) expect(chunks[1]?.content).toBe('## B\ntwo') }) it('propagates page and table metadata without crossing section boundaries', () => { const chunks = chunkDocumentAdvanced( { title: 'Workbook', sourceFormat: '.xlsx', content: '第一页\n\n表格行', sections: [ { locator: '第 1 页', content: '第一页', pageNumber: 1, blockKind: 'text' }, { locator: '工作表 1', content: '表格行'.repeat(200), blockKind: 'table' } ], warnings: [] }, { version: 1, mode: 'parent-child', targetCharacters: 300, overlapCharacters: 20, parentCharacters: 300, childCharacters: 100, contextualIndexingEnabled: false } ) expect( chunks .filter((chunk) => chunk.locator === '第 1 页') .every( (chunk) => chunk.pageNumber === 1 && chunk.blockKind === 'text' ) ).toBe(true) expect( chunks .filter((chunk) => chunk.locator === '工作表 1') .every( (chunk) => chunk.pageNumber === undefined && chunk.blockKind === 'table' ) ).toBe(true) expect( chunks.every((chunk) => chunk.locator === '第 1 页' ? chunk.content.includes('第一页') : !chunk.content.includes('第一页') ) ).toBe(true) }) it('builds deterministic bounded context without changing citation content', () => { const chunk = { position: 0, locator: ' 第 2 页 \n 附录 ', content: '## API\n原始引用内容', headingPath: [' 指南 ', 'API'], pageNumber: 2, blockKind: 'table' as const } const originalContent = chunk.content const first = buildChunkContextPrefix(' GoodBuddy \n 手册 ', chunk) const second = buildChunkContextPrefix(' GoodBuddy \n 手册 ', chunk) expect(first).toBe(second) expect(first).toBe( '[context title="GoodBuddy 手册" heading="指南 > API" page="2" locator="第 2 页 附录" block="table"]\n' ) expect(first.length).toBeLessThanOrEqual(512) expect(chunk.content).toBe(originalContent) expect(first).not.toContain(originalContent) }) })