diff --git a/src/lib/__tests__/documentText.test.ts b/src/lib/__tests__/documentText.test.ts new file mode 100644 index 0000000..e7e03d2 --- /dev/null +++ b/src/lib/__tests__/documentText.test.ts @@ -0,0 +1,167 @@ +/** + * Textgewinnung aus abgelegten Dokumenten (Runde 10, §2.6). + * + * Diese Tests sind wichtiger als die meisten anderen in diesem Projekt: Die + * Extraktion ist selbst gebaut, ohne Bibliothek. Ein selbst gebauter Parser + * scheitert nicht laut, sondern liefert stillschweigend eine leere Zeichenkette — + * und der Recherchelauf würde dann behaupten, das Dokument enthalte nichts. + * + * Die Prüfdateien entstehen hier zur Laufzeit, statt als Binärdateien im Repo + * zu liegen: So ist im Test sichtbar, welche Struktur erwartet wird. + */ + +import { describe, expect, it } from 'vitest' +import { extractDocumentText, kindOf } from '../documentText' +import { ResearchDocumentKind } from '../../domain/researchDocument' + +// ── Prüfdateien bauen ───────────────────────────────────────────────────────── + +/** + * Ein ZIP-Archiv mit einem unkomprimiert abgelegten Eintrag. + * + * Methode 0 («stored») genügt: Der Leser entpackt nur, wenn die Methode es + * verlangt, und der Weg über das zentrale Verzeichnis ist in beiden Fällen + * derselbe. Die Prüfsumme bleibt null — sie wird beim Lesen nicht geprüft. + */ +function baueZip(pfad: string, inhalt: string): Uint8Array { + const enc = new TextEncoder() + const name = enc.encode(pfad) + const daten = enc.encode(inhalt) + + const lokal = new Uint8Array(30 + name.length + daten.length) + const lv = new DataView(lokal.buffer) + lv.setUint32(0, 0x04034b50, true) // Signatur + lv.setUint16(4, 20, true) // Version + lv.setUint16(8, 0, true) // Methode: stored + lv.setUint32(18, daten.length, true) + lv.setUint32(22, daten.length, true) + lv.setUint16(26, name.length, true) + lokal.set(name, 30) + lokal.set(daten, 30 + name.length) + + const zentral = new Uint8Array(46 + name.length) + const zv = new DataView(zentral.buffer) + zv.setUint32(0, 0x02014b50, true) + zv.setUint16(10, 0, true) // Methode: stored + zv.setUint32(20, daten.length, true) + zv.setUint32(24, daten.length, true) + zv.setUint16(28, name.length, true) + zv.setUint32(42, 0, true) // Offset der lokalen Kopfzeile + zentral.set(name, 46) + + const eocd = new Uint8Array(22) + const ev = new DataView(eocd.buffer) + ev.setUint32(0, 0x06054b50, true) + ev.setUint16(8, 1, true) // Einträge auf dieser Platte + ev.setUint16(10, 1, true) // Einträge gesamt + ev.setUint32(12, zentral.length, true) + ev.setUint32(16, lokal.length, true) + + const alles = new Uint8Array(lokal.length + zentral.length + eocd.length) + alles.set(lokal, 0) + alles.set(zentral, lokal.length) + alles.set(eocd, lokal.length + zentral.length) + return alles +} + +function docxMit(absaetze: string[]): File { + const body = absaetze.map(a => `${a}`).join('') + const xml = `${body}` + return new File([baueZip('word/document.xml', xml) as BlobPart], 'bericht.docx') +} + +/** Ein PDF mit einem ungepackten Inhaltsstrom — der einfachste gültige Fall. */ +function pdfMit(zeilen: string[]): File { + const strom = zeilen.map(z => `BT /F1 12 Tf 72 700 Td (${z}) Tj ET`).join('\n') + const inhalt = [ + '%PDF-1.4', + '1 0 obj << /Type /Catalog >> endobj', + `2 0 obj << /Length ${strom.length} >>`, + 'stream', + strom, + 'endstream', + 'endobj', + '%%EOF', + ].join('\n') + return new File([inhalt], 'bericht.pdf', { type: 'application/pdf' }) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('kindOf', () => { + it('erkennt die beiden unterstützten Endungen, unabhängig von der Schreibweise', () => { + expect(kindOf('Bericht.PDF')).toBe(ResearchDocumentKind.PDF) + expect(kindOf('bericht.docx')).toBe(ResearchDocumentKind.DOCX) + }) + + it('lehnt alles andere ab — auch das alte Word-Format', () => { + expect(kindOf('bericht.doc')).toBeNull() + expect(kindOf('tabelle.xlsx')).toBeNull() + expect(kindOf('notiz.txt')).toBeNull() + }) +}) + +describe('DOCX', () => { + it('gewinnt den Text der Absätze in ihrer Reihenfolge', async () => { + const datei = docxMit([ + 'Die Muster AG eröffnet im dritten Quartal einen zweiten Standort im Raum Zürich.', + 'Dafür werden rund 1200 Quadratmeter Produktionsfläche gesucht, mit Anschluss an den öffentlichen Verkehr.', + 'Der bestehende Standort in Wetzikon bleibt bestehen und wird nicht verkleinert.', + ]) + + const { text, note } = await extractDocumentText(datei, ResearchDocumentKind.DOCX) + + expect(note).toBeUndefined() + expect(text).toContain('Muster AG') + expect(text).toContain('1200 Quadratmeter') + expect(text.indexOf('Muster AG')).toBeLessThan(text.indexOf('Wetzikon')) + }) + + it('löst die üblichen XML-Entitäten auf', async () => { + const lang = 'Die Firma Meier & Co. sucht Fläche. '.repeat(12) + const { text } = await extractDocumentText(docxMit([lang]), ResearchDocumentKind.DOCX) + + expect(text).toContain('Meier & Co.') + expect(text).not.toContain('&') + }) + + it('meldet ein Dokument mit zu wenig Text, statt es stumm zu übergehen', async () => { + const { note } = await extractDocumentText(docxMit(['Kurz.']), ResearchDocumentKind.DOCX) + + expect(note).toBeTruthy() + }) + + it('meldet eine Datei, die gar kein Word-Dokument ist', async () => { + const kaputt = new File(['das ist kein zip'], 'bericht.docx') + const { note } = await extractDocumentText(kaputt, ResearchDocumentKind.DOCX) + + expect(note).toBeTruthy() + }) +}) + +describe('PDF', () => { + it('gewinnt den Text aus ungepackten Inhaltsströmen', async () => { + const datei = pdfMit([ + 'Die Beispiel SA verlagert ihre Produktion von Vallorbe nach Enney.', + 'Investiert wird in eine neue Werkstatt von 1600 Quadratmetern.', + 'Die Produktion laeuft seit Anfang September.', + 'Ein weiterer Ausbau ist fuer das kommende Jahr angekuendigt.', + ]) + + const { text, note } = await extractDocumentText(datei, ResearchDocumentKind.PDF) + + expect(note).toBeUndefined() + expect(text).toContain('Beispiel SA') + expect(text).toContain('1600 Quadratmetern') + }) + + it('meldet ein PDF ohne Textebene, statt es als leer auszugeben', async () => { + // Genau der Fall eines eingescannten Dokuments: Struktur da, Text nicht. + const scan = new File(['%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF'], 'scan.pdf') + + const { text, note } = await extractDocumentText(scan, ResearchDocumentKind.PDF) + + expect(text.length).toBeLessThan(200) + expect(note).toMatch(/Scan|Text/) + }) +})