/**
* 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/)
})
})