// @vitest-environment node /** * Die Kanonform der Suche, gemessen am echten Katalog. * * Diese Tests arbeiten bewusst **nicht** mit ausgedachten Beispielen. Eine * Faltung, die an zwanzig handverlesenen Wörtern richtig liegt, kann am * einundzwanzigsten alles verlieren – und niemand merkt es, weil die Suche * weiter antwortet, nur mit weniger. Gemessen wird deshalb über alle * Wortformen des amtlichen Katalogs. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { Katalog } from '../src/shared/katalog'; import { aufUrsprung, falten, faltenMitZuordnung, istWortanfang } from '../src/shared/suchtext'; const wurzel = fileURLToPath(new URL('..', import.meta.url)); const KATALOG = JSON.parse( readFileSync(join(wurzel, '..', 'content', 'katalog', 'katalog.json'), 'utf8'), ) as Katalog; /** Jeder Textblock des Katalogs: Frage, Antwortoptionen, Musterantwort. */ function bloecke(): string[] { const aus: string[] = []; for (const frage of KATALOG.fragen) { aus.push(frage.frage.text); for (const option of frage.optionen ?? []) { aus.push(option.inhalt.text); } if (frage.musterantwort) { aus.push(frage.musterantwort.text); } } return aus; } /** Alle im Katalog vorkommenden Wortformen, ungefaltet. */ function wortformen(): string[] { const menge = new Set(); for (const block of bloecke()) { for (const wort of block.split(/[^\p{L}\p{N}]+/u)) { if (wort.length > 0) { menge.add(wort); } } } return [...menge]; } const BLOECKE = bloecke(); const WORTFORMEN = wortformen(); describe('Kanonform – Grundzusagen', () => { it('führt alle Schreibweisen von „Büchse“ zusammen', () => { const ziel = falten('Büchse'); expect(falten('Buechse')).toBe(ziel); expect(falten('buchse')).toBe(ziel); expect(falten('BÜCHSE')).toBe(ziel); }); it('führt alle Schreibweisen von „Schießstätte“ zusammen', () => { const ziel = falten('Schießstätte'); expect(falten('Schiessstätte')).toBe(ziel); expect(falten('Schiesstätte')).toBe(ziel); expect(falten('schiesstaette')).toBe(ziel); }); it('faltet ein zerlegt gespeichertes ä wie ein zusammengesetztes', () => { /* NFD: „a“ plus kombinierendes Trema. Beide Wege müssen bei „a“ enden, sonst hinge das Suchergebnis daran, wie der Text gespeichert wurde. */ expect(falten('ähnlich')).toBe(falten('ähnlich')); }); it('ist idempotent – über alle Wortformen des Katalogs', () => { const brueche = WORTFORMEN.filter((wort) => falten(falten(wort)) !== falten(wort)); expect(brueche, `Nicht idempotent: ${brueche.slice(0, 10).join(', ')}`).toEqual([]); }); it('ist idempotent – auch über die ungeteilten Textblöcke', () => { /* Diese Zusicherung sieht wie eine Wiederholung der vorigen aus und ist keine. Die Wortformen entstehen durch Zerlegen an Nicht-Buchstaben – Bindestriche, Punkte und Klammern verschwinden dabei, und genau dort saß der Fehler: Die Regel für den Bindestrich sah auf das Ursprungszeichen, die Kleinschreibung geschah aber vorher. `falten("Wadcutter-Geschoss")` behielt den Strich, ein zweiter Durchlauf entfernte ihn. Über Wortformen war das unsichtbar. */ const brueche = BLOECKE.filter((t) => falten(falten(t)) !== falten(t)); expect(brueche.length, `Nicht idempotent: ${brueche.slice(0, 3).join(' | ')}`).toBe(0); }); it('deckt mit den Wortformen einen nennenswerten Bestand ab', () => { /* Eine Zusicherung über den Test selbst: Bräche das Einlesen, liefen die Messungen oben über eine leere Liste und meldeten grün. */ expect(WORTFORMEN.length).toBeGreaterThan(3000); expect(BLOECKE.length).toBeGreaterThan(2000); }); }); describe('Kanonform – Ziffern bleiben unangetastet', () => { /* Der Laufkollaps gilt nur für Buchstaben. Die naheliegende Fassung /(.)\1+/u fräße auch Ziffern – in einem Korpus aus Joule-, Millimeter- und Kaliberangaben wäre das ein stiller Falschtrefferautomat. */ it('hält 10, 100 und 1000 auseinander', () => { expect(falten('10')).toBe('10'); expect(falten('100')).toBe('100'); expect(falten('1000')).toBe('1000'); }); it('hält 2 und 22, 26,5 und 265 auseinander', () => { expect(falten('2')).not.toBe(falten('22')); expect(falten('26,5')).not.toBe(falten('265')); }); it('lässt Maßangaben unverändert erkennbar', () => { expect(falten('7,5 Joule')).toBe('7,5 joule'); expect(falten('Kaliber 12/70')).toBe('kaliber 12/70'); }); }); describe('Kanonform – was sie kostet', () => { /* Die Faltung wirft Unterschiede weg, und einige davon sind echte. Diese Liste ist ein FESTWERT, keine Obergrenze: Sie steht hier, damit eine spätere „Verbesserung“ auffällt – in beide Richtungen. Wer eine Gruppe auflöst, muss sie streichen; wer eine neue erzeugt, muss sie eintragen und dabei erklären, warum der Verlust hinnehmbar ist. */ it('erzeugt genau die bekannten Kollisionsgruppen', () => { const nachForm = new Map>(); for (const wort of WORTFORMEN) { const schluessel = falten(wort); const gruppe = nachForm.get(schluessel) ?? new Set(); gruppe.add(wort.toLowerCase()); nachForm.set(schluessel, gruppe); } const gruppen = [...nachForm.values()] .filter((g) => g.size > 1) .map((g) => [...g].sort().join('/')) .sort(); expect(gruppen).toEqual([ 'das/dass', 'den/denn', 'erhalt/erhält', 'fahrt/fährt', 'fallen/fällen', /* Kein Verlust, sondern ein Gewinn: Der Katalog schreibt an fünf Stellen „Geschossfang“ und an einer „Geschoßfang“. Die ß-Faltung führt beide zusammen – wer das eine sucht, findet auch das andere. */ 'geschossfang/geschoßfang', 'hohe/höhe', 'i/ii', 'lange/länge', 'langer/länger', 'm/mm', /* Ebenso: die amtliche Schreibung schwankt zwischen „Mitgliedstaat“ und „Mitgliedsstaat“. */ 'mitgliedsstaat/mitgliedstaat', 'mitgliedsstaates/mitgliedstaates', 'munitionserwerbschein/munitionserwerbsschein', 'musste/müsste', 'nahe/nähe', 'neu/neue', 'starke/stärke', 'wen/wenn', 'zahlen/zählen', ]); }); }); describe('Kanonform – Bindestriche zwischen Buchstaben', () => { /* Drei Stellen im amtlichen Katalog tragen einen Strich mitten im Wort, und keine davon lässt sich beheben: „er-klärt“ und „orange-farbenen“ stehen so im Original, „lever-action“ ist richtig so. Am amtlichen Wortlaut wird nicht gearbeitet – also liest die Suche darüber hinweg. Und wer „Double-Action-Revolver“ sucht, tippt den Strich mal mit und mal ohne. */ it('findet „erklärt“ auch dort, wo „er-klärt“ steht', () => { expect(falten('er-klärt')).toBe(falten('erklärt')); expect(falten('orange-farbenen')).toBe(falten('orangefarbenen')); expect(falten('lever-action')).toBe(falten('leveraction')); }); it('gilt unabhängig von der Schreibung – sonst wäre die Faltung nicht idempotent', () => { /* Genau hier saß der Fehler: „Wadcutter-Geschoss“ hat einen Großbuchstaben hinter dem Strich, die gefaltete Form nicht mehr. Sähe die Regel auf die Schreibung, lieferten beide Durchläufe Verschiedenes. */ expect(falten('Wadcutter-Geschoss')).toBe(falten('wadcuttergeschoss')); expect(falten('Physikalisch-Technische')).toBe(falten('physikalischtechnische')); expect(falten('Double-Action-Revolver')).toBe(falten('doubleactionrevolver')); }); it('lässt den Strich neben einer Ziffer stehen', () => { // Damit Fragennummern und technische Bezeichnungen erkennbar bleiben. expect(falten('CO2-Waffen')).toContain('-'); expect(falten('II-45')).toContain('-'); expect(falten('I.2-150')).toContain('-'); }); it('lässt den Ergänzungsstrich vor einem Leerzeichen stehen', () => { // „Hieb- und Stoßwaffen“ – der Strich vertritt hier ein ganzes Wort. expect(falten('Hieb- und Stoßwaffen')).toContain('- '); }); it('greift auch im echten Bestand', () => { const gefaltet = BLOECKE.map((t) => falten(t)).join('\n'); for (const wort of ['erklart', 'orangefarbenen', 'leveraction', 'wadcutergeschos']) { expect(gefaltet, `„${wort}“ nicht auffindbar`).toContain(wort); } }); }); describe('Positionszuordnung', () => { it('stimmt mit der schnellen Faltung überein – über alle Textblöcke', () => { /* Zwei Umsetzungen derselben Regel liefen unweigerlich auseinander, und der Schaden wäre still: richtig gefunden, falsch markiert. */ const abweichungen = BLOECKE.filter((t) => faltenMitZuordnung(t).gefaltet !== falten(t)); expect(abweichungen.length).toBe(0); }); it('trifft die Fundstelle im Ursprungstext – über alle Fragen', () => { /* Die eigentliche Zusicherung: Wird eine Fundstelle im gefalteten Text auf den Ursprung zurückgerechnet, muss der herausgeschnittene Originalabschnitt dasselbe gefaltete Wort ergeben. Gemessen wird über alle 575 Fragen mit einer Batterie von Suchwörtern – ausdrücklich auch an Fragen mit Umlauten, denn dort ändert die Faltung die Längen. */ const woerter = [ 'schiessen', 'buechse', 'waffe', 'erwerben', 'gross', 'fuehren', 'zulaessig', 'schusswaffe', 'munition', 'aufbewahrung', 'notwehr', 'geschoss', 'patrone', 'sportschuetze', 'jaeger', 'behoerde', 'gefaehrlich', 'draussen', 'massnahme', 'ueberlassen', ].map((w) => falten(w)); let geprueft = 0; const fehler: string[] = []; for (const frage of KATALOG.fragen) { const roh = frage.frage.text; const faltung = faltenMitZuordnung(roh); for (const wort of woerter) { const stelle = faltung.gefaltet.indexOf(wort); if (stelle === -1) { continue; } const spanne = aufUrsprung(faltung, stelle, stelle + wort.length); if (spanne === null) { fehler.push(`${frage.id}: keine Spanne für „${wort}“`); continue; } const ausschnitt = roh.slice(spanne.von, spanne.bis); geprueft++; if (!falten(ausschnitt).includes(wort)) { fehler.push(`${frage.id}: „${wort}“ → „${ausschnitt}“ (${falten(ausschnitt)})`); } } } expect(fehler, fehler.slice(0, 5).join('\n')).toEqual([]); // Ohne Mindestzahl bewiese ein leeres Ergebnis nichts. expect(geprueft).toBeGreaterThan(200); }); it('verweigert die Auskunft bei unplausiblen Spannen', () => { const f = faltenMitZuordnung('Waffe'); expect(aufUrsprung(f, 0, 0)).toBeNull(); expect(aufUrsprung(f, -1, 2)).toBeNull(); expect(aufUrsprung(f, 0, 99)).toBeNull(); }); }); describe('Wortanfang', () => { it('erkennt den Anfang am Zeichenrand und nach Trennzeichen', () => { const t = falten('Sport und Transport, § 3'); expect(istWortanfang(t, 0)).toBe(true); expect(istWortanfang(t, t.indexOf('transport'))).toBe(true); // „port“ in „transport“ ist kein Wortanfang – das trennt die Rangklassen. expect(istWortanfang(t, t.indexOf('transport') + 5)).toBe(false); }); });