test(livia): Textgewinnung aus PDF und DOCX gegen erzeugte Prüfdateien
Die Extraktion ist selbst gebaut; ein stiller Fehlschlag sähe aus wie ein Dokument ohne Inhalt. Die Prüfdateien entstehen zur Laufzeit, damit im Test sichtbar bleibt, welche Struktur erwartet wird. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30daa77937
commit
314a6a36e3
1 file changed
+167
@@ -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 => `<w:p><w:r><w:t>${a}</w:t></w:r></w:p>`).join('')
|
||||||
|
const xml = `<?xml version="1.0"?><w:document><w:body>${body}</w:body></w:document>`
|
||||||
|
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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user