// @vitest-environment node /** * Die Volltextsuche, gemessen am echten amtlichen Katalog. * * ## Warum hier keine Attrappe steht * * Eine Suche gegen drei ausgedachte Fragen zu prüfen misst die Schleife, nicht * die Suche. Was in einem Lernmittel schadet, ist die Suche, die **weiter * antwortet, nur mit weniger** – und das zeigt sich ausschließlich am echten * Bestand. Deshalb laufen diese Tests über alle 575 Fragen. * * ## Die gefährlichste Fehlerart und das Netz dagegen * * Ein Lernender, der zu „Notwehr“ 29 Fragen bekommt statt der richtigen Zahl, * hält das Thema für abgedeckt. Dagegen stehen hier drei Zusicherungen, die * einander nicht ersetzen: * * 1. Die **Regressionstabelle** pinnt gemessene Zahlen. Sie bricht, sobald * sich die Treffermenge irgendwo verschiebt. * 2. Der **Selbstfindungstest** verlangt, dass jede der 575 Fragen sich mit * ihrem eigenen längsten Wort wiederfindet. Kein handverlesenes Wörterset, * an dem man versehentlich am Fehler vorbeiprüft – und tatsächlich hat er * schon einen gefunden: Die Kanonform war nicht idempotent, 24 Fragen * fanden sich selbst nicht mehr. * 3. Die **Rangfolge** wird geprüft, nicht nur die Menge. Eine Suche, die * alles findet und nichts ordnet, ist bei 372 Treffern unbrauchbar. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { BEGRIFFSBRUECKE } from '../src/shared/begriffsbruecke'; import { bereiche, brueckeFinden, schuldigeWoerter, suchen, suchindexBauen, suchwoerter, zerlegung, type Rangklasse, } from '../src/shared/fragensuche'; import type { Katalog } from '../src/shared/katalog'; import { falten } 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; const INDEX = suchindexBauen(KATALOG); /** Trefferzahl zu einer Eingabe. */ function zahl(eingabe: string, bereich: string | null = null): number { return suchen(INDEX, eingabe, bereich).treffer.length; } describe('Suchindex', () => { it('deckt den ganzen Katalog ab', () => { /* Zusicherung über den Test selbst: Bräche das Einlesen, liefen alle Messungen unten über einen leeren Index und meldeten grün. */ expect(INDEX.eintraege.length).toBe(575); expect(KATALOG.fragen.length).toBe(575); }); }); describe('Regressionstabelle – gemessen, nicht geschätzt', () => { /* Diese Zahlen sind Messwerte gegen `content/katalog/katalog.json`. Ändert sich eine, ist entweder der Katalog ein anderer geworden (dann gehört die Zahl nachgeführt) oder die Suche findet anders (dann gehört das erklärt). Beides soll auffallen, und zwar hier. */ const TABELLE: readonly (readonly [string, number])[] = [ ['schiessen', 52], ['schießen', 52], ['Schußwaffe', 145], ['schusswaffe', 145], ['besitzkarte', 52], ['fuehren', 38], ['führen', 38], ['buechsenmacher', 6], ['geschossfang', 5], ['aufbewahr', 26], ['notwehr', 29], ['WBK', 29], ['waffe', 372], ['erwerben', 23], ['reizstoff', 11], ['sport', 76], ['stand', 141], ['messer', 20], ['notwehr angriff', 15], ['7,5 Joule', 9], ['kaliber 12/70', 3], ['tresor', 3], ]; it.each(TABELLE)('„%s“ findet %i Fragen', (eingabe, erwartet) => { expect(zahl(eingabe)).toBe(erwartet); }); it('findet „erwerben“ auch in Frage 2.29', () => { /* Dort stand bis zur Korrektur der Datenpipeline „er-werben“ – eine doppelte Silbentrennung des Originals, die nur zur Hälfte aufgelöst war. Die Suche hat den Fehler ans Licht gebracht. */ const treffer = suchen(INDEX, 'erwerben').treffer.map((t) => t.frageId); expect(treffer).toContain('I.2-29'); }); }); describe('Schreibweisen fallen zusammen', () => { const PAARE: readonly (readonly [string, string])[] = [ ['schiessen', 'schießen'], ['Schußwaffe', 'schusswaffe'], ['fuehren', 'führen'], ['buechse', 'Büchse'], ['gross', 'groß'], ['Schiessstaette', 'Schießstätte'], ]; it.each(PAARE)('„%s“ und „%s“ liefern dieselben Fragen', (a, b) => { const eins = suchen(INDEX, a).treffer.map((t) => t.frageId); const zwei = suchen(INDEX, b).treffer.map((t) => t.frageId); expect(eins).toEqual(zwei); expect(eins.length).toBeGreaterThan(0); }); }); describe('Teilwortsuche – der Grund, warum es keine Wortanfangssuche ist', () => { it('findet „besitzkarte“ ausschließlich im Wortinneren', () => { /* Das ist das Kernargument in einer Zusicherung: „Waffenbesitzkarte“ verschluckt das gesuchte Wort. Eine Suche, die nur Wortanfänge kennt, lieferte hier NULL Treffer – bei 52 einschlägigen Fragen. */ const ergebnis = suchen(INDEX, 'besitzkarte'); expect(ergebnis.treffer.length).toBe(52); expect(ergebnis.treffer.filter((t) => t.klasse === 'frage-anfang')).toEqual([]); }); it('findet zusammengesetzte Wörter auch am Wortanfang', () => { expect(zahl('waffenbesitzkarte')).toBeGreaterThan(0); }); }); describe('Rangfolge', () => { it('stellt Fragetexttreffer vor Antworttreffer vor Musterantworttreffer', () => { const ordnung: readonly Rangklasse[] = ['frage-anfang', 'frage-innen', 'antwort', 'muster']; for (const eingabe of ['waffe', 'schiessen', 'notwehr', 'sport', 'stand']) { const klassen = suchen(INDEX, eingabe).treffer.map((t) => ordnung.indexOf(t.klasse)); const sortiert = [...klassen].sort((a, b) => a - b); expect(klassen, `Rangfolge verletzt bei „${eingabe}“`).toEqual(sortiert); } }); it('hält innerhalb einer Klasse die Katalogreihenfolge', () => { /* Die Reihenfolge, die der Prüfling aus der amtlichen Vorlage kennt. */ const stelle = new Map(KATALOG.fragen.map((f, i) => [f.id, i])); const treffer = suchen(INDEX, 'waffe').treffer; for (const klasse of ['frage-anfang', 'frage-innen', 'antwort', 'muster'] as const) { const stellen = treffer .filter((t) => t.klasse === klasse) .map((t) => stelle.get(t.frageId) ?? -1); expect([...stellen].sort((a, b) => a - b)).toEqual(stellen); } }); it('benennt bei Antworttreffern die betroffenen Möglichkeiten', () => { const treffer = suchen(INDEX, 'geschossfang').treffer; expect(treffer.length).toBe(5); for (const t of treffer) { expect(t.klasse).toBe('antwort'); expect(t.optionen.length).toBeGreaterThan(0); // Amtliche Labels, keine Indizes. expect(t.optionen.every((l) => /^[a-h]$/u.test(l))).toBe(true); } }); }); describe('Bereiche', () => { it('nennt die Fragenzahl je Bereich', () => { const gezaehlt = Object.fromEntries(bereiche(KATALOG).map((b) => [String(b.id), b.anzahl])); expect(gezaehlt).toEqual({ null: 575, I: 345, 'I.1': 90, 'I.2': 154, 'I.3': 35, 'I.4': 23, 'I.5': 43, II: 92, III: 49, IV: 89, }); }); it('kürzt die Trefferliste nachweisbar', () => { expect(zahl('waffe', 'I.5')).toBeLessThan(zahl('waffe')); expect(zahl('waffe', 'I.5')).toBeGreaterThan(0); }); it('zeigt bei leerem Feld den ganzen Bereich – der Fragen-Browser', () => { const ergebnis = suchen(INDEX, '', 'I.5'); expect(ergebnis.stoebern).toBe(true); expect(ergebnis.treffer.length).toBe(43); expect(suchen(INDEX, '', 'I').treffer.length).toBe(345); expect(suchen(INDEX, '', null).treffer.length).toBe(575); }); }); describe('Mehrere Wörter', () => { it('verlangt jedes Wort, nicht irgendeines', () => { const beide = zahl('notwehr angriff'); expect(beide).toBe(15); expect(beide).toBeLessThan(zahl('notwehr')); expect(beide).toBeLessThan(zahl('angriff')); }); it('lässt die Wörter über die Felder verteilt zu', () => { /* Ein Wort im Fragetext, das andere in einer Antwortmöglichkeit – das ist der Regelfall bei einer Frage mit sieben Optionen. */ const treffer = suchen(INDEX, 'notwehr angriff').treffer; expect(treffer.some((t) => t.klasse === 'antwort')).toBe(true); }); }); describe('Eingaben, an denen eine Abfragesprache zerbräche', () => { /* Genau diese Zeichen tippt der Nutzer hier zwangsläufig. Bei FTS5 werfen sie harte Fehler („fts5: syntax error“), hier darf nichts davon passieren: Ein leeres Ergebnis ist eine Antwort, eine Ausnahme ist keine. */ const EINGABEN = [ '-', '(', '"', '^', '*', 'AND', 'a OR', 'waffe AND', 'Waffe:', 'x"y', '§ 3', '§3', 'Abs. 1', 'II-45', '2.150', '7,65', 'Soft-Air-Waffen', ' ', '()[]{}', ]; it.each(EINGABEN)('„%s“ wirft nicht', (eingabe) => { expect(() => suchen(INDEX, eingabe)).not.toThrow(); }); }); describe('Kurze und leere Eingaben', () => { it('meldet ein einzelnes Zeichen als zu kurz', () => { const ergebnis = suchen(INDEX, 'a'); expect(ergebnis.zuKurz).toBe(true); expect(ergebnis.treffer).toEqual([]); }); it('sucht ab zwei Zeichen', () => { expect(suchen(INDEX, 'ab').zuKurz).toBe(false); // Kürzel wie „P1“ müssen gehen – drei Zeichen wären eine Trigram-Eigenheit. expect(suchen(INDEX, 'P1').zuKurz).toBe(false); }); it('behandelt das leere Feld als Stöbern, nicht als Fehler', () => { const ergebnis = suchen(INDEX, ''); expect(ergebnis.stoebern).toBe(true); expect(ergebnis.zuKurz).toBe(false); expect(ergebnis.treffer.length).toBe(575); }); }); describe('Leerzustand – Wege statt Sackgassen', () => { it('benennt das schuldige Wort einer mehrteiligen Anfrage', () => { /* Der Katalog sagt nicht „verloren“, sondern „abhanden gekommen“. */ const woerter = suchwoerter('waffe verloren'); expect(schuldigeWoerter(INDEX, woerter)).toEqual([suchwoerter('verloren')[0]]); expect(zahl('waffe verloren')).toBe(0); }); it('meldet nichts, wenn jedes Wort irgendwo vorkommt', () => { expect(schuldigeWoerter(INDEX, suchwoerter('notwehr angriff'))).toEqual([]); }); it('bietet für ein zusammengesetztes Wort ohne Treffer eine Zerlegung an', () => { /* „Reizstoffwaffe“ – der Gesetzesbegriff aus § 42a WaffG – steht im Katalog kein einziges Mal, weil dort „Schreckschuss-, Reizstoff- und Signalwaffen“ geschrieben ist. */ expect(zahl('Reizstoffwaffe')).toBe(0); expect(zerlegung(INDEX, 'Reizstoffwaffe')).toEqual({ links: 'Reizstoff', rechts: 'waffe', anzahl: 11, }); }); it('gibt die Schreibweise des Suchenden zurück, nicht die Kanonform', () => { // „Reizstoff“, nicht „reizstof“ – die Faltung ist ein inneres Werkzeug. const z = zerlegung(INDEX, 'Hiebwaffen'); expect(z).toEqual({ links: 'Hieb', rechts: 'waffen', anzahl: 3 }); }); it.each([ ['Federdruckwaffen', 6], ['Angriffswaffen', 5], ])('zerlegt „%s“ zu %i Fragen', (wort, anzahl) => { expect(zerlegung(INDEX, wort)?.anzahl).toBe(anzahl); }); it('verspricht nichts, wo die Teile nicht gemeinsam vorkommen', () => { // Ehrlich: „Munition“ und „Aufbewahrung“ stehen in keiner gemeinsamen Frage. expect(zerlegung(INDEX, 'Munitionsaufbewahrung')).toBeNull(); }); }); describe('Der Test, der stilles Verlieren verhindert', () => { it('findet jede der 575 Fragen mit ihrem eigenen längsten Wort', () => { /* Diese Zusicherung ersetzt kein Urteil über die Trefferqualität – sie verhindert das Schlimmste: eine Suche, die weiter antwortet, nur mit weniger. Sie bricht, sobald Faltung, Indexbau, Feldzuordnung oder Bereichsfilter etwas verlieren, und sie ist nicht auszutricksen, weil die Wörter aus dem Bestand selbst kommen. */ const ausfaelle: string[] = []; for (const frage of KATALOG.fragen) { const laengstes = [...suchwoerter(frage.frage.text)] .filter((w) => w.length >= 6) .sort((a, b) => b.length - a.length)[0]; if (laengstes === undefined) { continue; } const treffer = suchen(INDEX, laengstes).treffer; if (!treffer.some((t) => t.frageId === frage.id)) { ausfaelle.push(`${frage.id} findet sich nicht mit „${laengstes}“`); } } expect(ausfaelle, ausfaelle.slice(0, 5).join('\n')).toEqual([]); }); it('findet jede Frage auch über ihre Antwortmöglichkeiten', () => { const ausfaelle: string[] = []; for (const frage of KATALOG.fragen) { const option = frage.optionen?.[0]; if (option === undefined) { continue; } const laengstes = [...suchwoerter(option.inhalt.text)] .filter((w) => w.length >= 6) .sort((a, b) => b.length - a.length)[0]; if (laengstes === undefined) { continue; } if (!suchen(INDEX, laengstes).treffer.some((t) => t.frageId === frage.id)) { ausfaelle.push(`${frage.id} über Antwort ${option.label}: „${laengstes}“`); } } expect(ausfaelle, ausfaelle.slice(0, 5).join('\n')).toEqual([]); }); it('stolpert nicht über die zwei leeren Antwortmöglichkeiten', () => { /* Bei zwei Optionen ist das Bild die Antwort, der Text ist leer. Ein Index, der stumpf darüberläuft, erzeugte leere Einträge. */ const leere = KATALOG.fragen.flatMap((f) => (f.optionen ?? []).filter((o) => o.inhalt.text.trim().length === 0).map(() => f.id), ); expect(leere.length).toBe(2); expect(() => suchen(INDEX, 'waffe')).not.toThrow(); }); }); describe('Begriffsbrücke – vom Wort des Prüflings zum Wort des Katalogs', () => { it('hält jedes Versprechen: jedes Katalogwort findet auch etwas', () => { /* Eine Brücke, die auf ein Wort zeigt, das der Katalog nicht kennt, ist schlimmer als keine – sie schickt den Suchenden ein zweites Mal ins Leere, diesmal mit einer Zusage. */ for (const eintrag of BEGRIFFSBRUECKE) { for (const wort of eintrag.katalog) { expect(zahl(wort), `„${wort}“ findet nichts`).toBeGreaterThan(0); } } }); it('verspricht genau das, was ein Klick auch liefert', () => { /* DER Test dieses Blocks, und er hat gefehlt. Vorher nannte das Angebot die Vereinigungsmenge über alle Katalogwörter – „Tresor“ versprach 27 Fragen – und setzte beim Klick nur das erste Wort ein, das drei liefert. Der Suchende bekam ein Neuntel und hielt das Thema für erschöpfend behandelt. Die Vereinigung ist über das Suchfeld auch gar nicht herstellbar, weil mehrere Wörter UND-verknüpft sind. */ for (const eintrag of BEGRIFFSBRUECKE) { for (const gesucht of eintrag.gesucht) { for (const vorschlag of brueckeFinden(INDEX, gesucht)?.vorschlaege ?? []) { expect( zahl(vorschlag.wort), `„${gesucht}“ → „${vorschlag.wort}“ verspricht ${String(vorschlag.anzahl)}`, ).toBe(vorschlag.anzahl); } } } }); it('zählt im gewählten Bereich, nicht katalogweit', () => { /* Sonst verspräche das Angebot 27 Fragen und führte in Kapitel II auf „Keine Frage gefunden.“ – eine Zusage, die die Einschränkung nicht kennt. */ for (const vorschlag of brueckeFinden(INDEX, 'tresor', 'I.4')?.vorschlaege ?? []) { expect(zahl(vorschlag.wort, 'I.4')).toBe(vorschlag.anzahl); } // In Kapitel II gibt es zu keinem der Wörter etwas – dann kein Angebot. expect(brueckeFinden(INDEX, 'tresor', 'II')).toBeNull(); }); it('bietet nur Wörter an, die im Bereich tatsächlich etwas finden', () => { for (const bereich of [null, 'I', 'I.4', 'IV']) { for (const vorschlag of brueckeFinden(INDEX, 'tresor', bereich)?.vorschlaege ?? []) { expect(vorschlag.anzahl).toBeGreaterThan(0); } } }); it('bietet nicht an, wonach ohnehin schon gesucht wird', () => { // „Reizstoff“ ist selbst das Katalogwort – ein Angebot wäre eine Schleife. expect(brueckeFinden(INDEX, 'Reizstoff')).toBeNull(); }); it.each([ ['Tresor', ['Sicherheitsbehältnis', 'Widerstandsgrad', 'Aufbewahrung']], ['Pfefferspray', ['Reizstoff']], ])('„%s“ verweist auf %j', (eingabe, woerter) => { expect(brueckeFinden(INDEX, eingabe)?.vorschlaege.map((v) => v.wort)).toEqual(woerter); }); it('springt nicht auf gewöhnliche Wörter an', () => { /* Der Fehler, den die Messung gefunden hat: „ölen“ faltet zu „olen“, und das steckt in „Pistolen“, „wollen“, „sollen“. Als Teilzeichenkette geprüft, bekäme jeder, der nach Pistolen sucht, ein Angebot zur Instandhaltung. Deshalb wird hier Wort für Wort verglichen. */ for (const wort of ['Pistolen', 'wollen', 'sollen', 'Waffe', 'Munition', 'Holster']) { expect(brueckeFinden(INDEX, wort), `Fehlauslöser bei „${wort}“`).toBeNull(); } }); it('greift auch, wenn das Stichwort in einer längeren Eingabe steht', () => { expect(brueckeFinden(INDEX, 'waffenschrank kaufen')).not.toBeNull(); expect(brueckeFinden(INDEX, 'Diebstahl melden')).not.toBeNull(); }); it('führt kein Stichwort doppelt', () => { const alle = BEGRIFFSBRUECKE.flatMap((e) => e.gesucht.map((w) => falten(w))); expect(new Set(alle).size).toBe(alle.length); }); }); describe('Zerlegungsangebot: Satzzeichen am Rand', () => { /* Befund der Prüfrunde zu 0.27.2. `suchwoerter` schneidet Satzzeichen an den Rändern ab — ausdrücklich auch die typografischen Anführungszeichen, „die diese Anwendung in ihren eigenen Hinweistexten setzt“. Das Zerlegungsangebot bekam den Text dagegen ungefiltert. Folge: „Hiebwaffen.“ (mit Punkt) versprach eine andere Zahl, als der eigene Klick einlöste, und bei „Reizstoffwaffe?“ verschwand das Angebot ganz — die einzige Hilfe aus der Sackgasse. Der Grundsatz derselben Datei: Ein Angebot verspricht nur das, was sein eigener Klick einlöst. */ it('zerlegt ein Wort mit Satzzeichen wie ohne', () => { const index = suchindexBauen(KATALOG); for (const [roh, sauber] of [ ['Hiebwaffen.', 'Hiebwaffen'], ['Hiebwaffen?', 'Hiebwaffen'], ['„Hiebwaffen“', 'Hiebwaffen'], ['(Hiebwaffen)', 'Hiebwaffen'], ]) { const mitZeichen = zerlegung(index, roh ?? ''); const ohneZeichen = zerlegung(index, sauber ?? ''); expect(mitZeichen?.anzahl ?? null, `„${roh ?? ''}“`).toBe(ohneZeichen?.anzahl ?? null); } }); });