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