waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests fragensuche.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Die Volltextsuche, gemessen am echten amtlichen Katalog. |
| 4 | * |
| 5 | * ## Warum hier keine Attrappe steht |
| 6 | * |
| 7 | * Eine Suche gegen drei ausgedachte Fragen zu prüfen misst die Schleife, nicht |
| 8 | * die Suche. Was in einem Lernmittel schadet, ist die Suche, die **weiter |
| 9 | * antwortet, nur mit weniger** – und das zeigt sich ausschließlich am echten |
| 10 | * Bestand. Deshalb laufen diese Tests über alle 575 Fragen. |
| 11 | * |
| 12 | * ## Die gefährlichste Fehlerart und das Netz dagegen |
| 13 | * |
| 14 | * Ein Lernender, der zu „Notwehr“ 29 Fragen bekommt statt der richtigen Zahl, |
| 15 | * hält das Thema für abgedeckt. Dagegen stehen hier drei Zusicherungen, die |
| 16 | * einander nicht ersetzen: |
| 17 | * |
| 18 | * 1. Die **Regressionstabelle** pinnt gemessene Zahlen. Sie bricht, sobald |
| 19 | * sich die Treffermenge irgendwo verschiebt. |
| 20 | * 2. Der **Selbstfindungstest** verlangt, dass jede der 575 Fragen sich mit |
| 21 | * ihrem eigenen längsten Wort wiederfindet. Kein handverlesenes Wörterset, |
| 22 | * an dem man versehentlich am Fehler vorbeiprüft – und tatsächlich hat er |
| 23 | * schon einen gefunden: Die Kanonform war nicht idempotent, 24 Fragen |
| 24 | * fanden sich selbst nicht mehr. |
| 25 | * 3. Die **Rangfolge** wird geprüft, nicht nur die Menge. Eine Suche, die |
| 26 | * alles findet und nichts ordnet, ist bei 372 Treffern unbrauchbar. |
| 27 | */ |
| 28 | |
| 29 | import { readFileSync } from 'node:fs'; |
| 30 | import { join } from 'node:path'; |
| 31 | import { fileURLToPath } from 'node:url'; |
| 32 | |
| 33 | import { describe, expect, it } from 'vitest'; |
| 34 | |
| 35 | import { BEGRIFFSBRUECKE } from '../src/shared/begriffsbruecke'; |
| 36 | import { |
| 37 | bereiche, |
| 38 | brueckeFinden, |
| 39 | schuldigeWoerter, |
| 40 | suchen, |
| 41 | suchindexBauen, |
| 42 | suchwoerter, |
| 43 | zerlegung, |
| 44 | type Rangklasse, |
| 45 | } from '../src/shared/fragensuche'; |
| 46 | import type { Katalog } from '../src/shared/katalog'; |
| 47 | import { falten } from '../src/shared/suchtext'; |
| 48 | |
| 49 | const wurzel = fileURLToPath(new URL('..', import.meta.url)); |
| 50 | |
| 51 | const KATALOG = JSON.parse( |
| 52 | readFileSync(join(wurzel, '..', 'content', 'katalog', 'katalog.json'), 'utf8'), |
| 53 | ) as Katalog; |
| 54 | |
| 55 | const INDEX = suchindexBauen(KATALOG); |
| 56 | |
| 57 | /** Trefferzahl zu einer Eingabe. */ |
| 58 | function zahl(eingabe: string, bereich: string | null = null): number { |
| 59 | return suchen(INDEX, eingabe, bereich).treffer.length; |
| 60 | } |
| 61 | |
| 62 | describe('Suchindex', () => { |
| 63 | it('deckt den ganzen Katalog ab', () => { |
| 64 | /* Zusicherung über den Test selbst: Bräche das Einlesen, liefen alle |
| 65 | Messungen unten über einen leeren Index und meldeten grün. */ |
| 66 | expect(INDEX.eintraege.length).toBe(575); |
| 67 | expect(KATALOG.fragen.length).toBe(575); |
| 68 | }); |
| 69 | }); |
| 70 | |
| 71 | describe('Regressionstabelle – gemessen, nicht geschätzt', () => { |
| 72 | /* |
| 73 | Diese Zahlen sind Messwerte gegen `content/katalog/katalog.json`. Ändert |
| 74 | sich eine, ist entweder der Katalog ein anderer geworden (dann gehört die |
| 75 | Zahl nachgeführt) oder die Suche findet anders (dann gehört das erklärt). |
| 76 | Beides soll auffallen, und zwar hier. |
| 77 | */ |
| 78 | const TABELLE: readonly (readonly [string, number])[] = [ |
| 79 | ['schiessen', 52], |
| 80 | ['schießen', 52], |
| 81 | ['Schußwaffe', 145], |
| 82 | ['schusswaffe', 145], |
| 83 | ['besitzkarte', 52], |
| 84 | ['fuehren', 38], |
| 85 | ['führen', 38], |
| 86 | ['buechsenmacher', 6], |
| 87 | ['geschossfang', 5], |
| 88 | ['aufbewahr', 26], |
| 89 | ['notwehr', 29], |
| 90 | ['WBK', 29], |
| 91 | ['waffe', 372], |
| 92 | ['erwerben', 23], |
| 93 | ['reizstoff', 11], |
| 94 | ['sport', 76], |
| 95 | ['stand', 141], |
| 96 | ['messer', 20], |
| 97 | ['notwehr angriff', 15], |
| 98 | ['7,5 Joule', 9], |
| 99 | ['kaliber 12/70', 3], |
| 100 | ['tresor', 3], |
| 101 | ]; |
| 102 | |
| 103 | it.each(TABELLE)('„%s“ findet %i Fragen', (eingabe, erwartet) => { |
| 104 | expect(zahl(eingabe)).toBe(erwartet); |
| 105 | }); |
| 106 | |
| 107 | it('findet „erwerben“ auch in Frage 2.29', () => { |
| 108 | /* Dort stand bis zur Korrektur der Datenpipeline „er-werben“ – eine |
| 109 | doppelte Silbentrennung des Originals, die nur zur Hälfte aufgelöst |
| 110 | war. Die Suche hat den Fehler ans Licht gebracht. */ |
| 111 | const treffer = suchen(INDEX, 'erwerben').treffer.map((t) => t.frageId); |
| 112 | expect(treffer).toContain('I.2-29'); |
| 113 | }); |
| 114 | }); |
| 115 | |
| 116 | describe('Schreibweisen fallen zusammen', () => { |
| 117 | const PAARE: readonly (readonly [string, string])[] = [ |
| 118 | ['schiessen', 'schießen'], |
| 119 | ['Schußwaffe', 'schusswaffe'], |
| 120 | ['fuehren', 'führen'], |
| 121 | ['buechse', 'Büchse'], |
| 122 | ['gross', 'groß'], |
| 123 | ['Schiessstaette', 'Schießstätte'], |
| 124 | ]; |
| 125 | |
| 126 | it.each(PAARE)('„%s“ und „%s“ liefern dieselben Fragen', (a, b) => { |
| 127 | const eins = suchen(INDEX, a).treffer.map((t) => t.frageId); |
| 128 | const zwei = suchen(INDEX, b).treffer.map((t) => t.frageId); |
| 129 | expect(eins).toEqual(zwei); |
| 130 | expect(eins.length).toBeGreaterThan(0); |
| 131 | }); |
| 132 | }); |
| 133 | |
| 134 | describe('Teilwortsuche – der Grund, warum es keine Wortanfangssuche ist', () => { |
| 135 | it('findet „besitzkarte“ ausschließlich im Wortinneren', () => { |
| 136 | /* |
| 137 | Das ist das Kernargument in einer Zusicherung: „Waffenbesitzkarte“ |
| 138 | verschluckt das gesuchte Wort. Eine Suche, die nur Wortanfänge kennt, |
| 139 | lieferte hier NULL Treffer – bei 52 einschlägigen Fragen. |
| 140 | */ |
| 141 | const ergebnis = suchen(INDEX, 'besitzkarte'); |
| 142 | expect(ergebnis.treffer.length).toBe(52); |
| 143 | expect(ergebnis.treffer.filter((t) => t.klasse === 'frage-anfang')).toEqual([]); |
| 144 | }); |
| 145 | |
| 146 | it('findet zusammengesetzte Wörter auch am Wortanfang', () => { |
| 147 | expect(zahl('waffenbesitzkarte')).toBeGreaterThan(0); |
| 148 | }); |
| 149 | }); |
| 150 | |
| 151 | describe('Rangfolge', () => { |
| 152 | it('stellt Fragetexttreffer vor Antworttreffer vor Musterantworttreffer', () => { |
| 153 | const ordnung: readonly Rangklasse[] = ['frage-anfang', 'frage-innen', 'antwort', 'muster']; |
| 154 | for (const eingabe of ['waffe', 'schiessen', 'notwehr', 'sport', 'stand']) { |
| 155 | const klassen = suchen(INDEX, eingabe).treffer.map((t) => ordnung.indexOf(t.klasse)); |
| 156 | const sortiert = [...klassen].sort((a, b) => a - b); |
| 157 | expect(klassen, `Rangfolge verletzt bei „${eingabe}“`).toEqual(sortiert); |
| 158 | } |
| 159 | }); |
| 160 | |
| 161 | it('hält innerhalb einer Klasse die Katalogreihenfolge', () => { |
| 162 | /* Die Reihenfolge, die der Prüfling aus der amtlichen Vorlage kennt. */ |
| 163 | const stelle = new Map(KATALOG.fragen.map((f, i) => [f.id, i])); |
| 164 | const treffer = suchen(INDEX, 'waffe').treffer; |
| 165 | for (const klasse of ['frage-anfang', 'frage-innen', 'antwort', 'muster'] as const) { |
| 166 | const stellen = treffer |
| 167 | .filter((t) => t.klasse === klasse) |
| 168 | .map((t) => stelle.get(t.frageId) ?? -1); |
| 169 | expect([...stellen].sort((a, b) => a - b)).toEqual(stellen); |
| 170 | } |
| 171 | }); |
| 172 | |
| 173 | it('benennt bei Antworttreffern die betroffenen Möglichkeiten', () => { |
| 174 | const treffer = suchen(INDEX, 'geschossfang').treffer; |
| 175 | expect(treffer.length).toBe(5); |
| 176 | for (const t of treffer) { |
| 177 | expect(t.klasse).toBe('antwort'); |
| 178 | expect(t.optionen.length).toBeGreaterThan(0); |
| 179 | // Amtliche Labels, keine Indizes. |
| 180 | expect(t.optionen.every((l) => /^[a-h]$/u.test(l))).toBe(true); |
| 181 | } |
| 182 | }); |
| 183 | }); |
| 184 | |
| 185 | describe('Bereiche', () => { |
| 186 | it('nennt die Fragenzahl je Bereich', () => { |
| 187 | const gezaehlt = Object.fromEntries(bereiche(KATALOG).map((b) => [String(b.id), b.anzahl])); |
| 188 | expect(gezaehlt).toEqual({ |
| 189 | null: 575, |
| 190 | I: 345, |
| 191 | 'I.1': 90, |
| 192 | 'I.2': 154, |
| 193 | 'I.3': 35, |
| 194 | 'I.4': 23, |
| 195 | 'I.5': 43, |
| 196 | II: 92, |
| 197 | III: 49, |
| 198 | IV: 89, |
| 199 | }); |
| 200 | }); |
| 201 | |
| 202 | it('kürzt die Trefferliste nachweisbar', () => { |
| 203 | expect(zahl('waffe', 'I.5')).toBeLessThan(zahl('waffe')); |
| 204 | expect(zahl('waffe', 'I.5')).toBeGreaterThan(0); |
| 205 | }); |
| 206 | |
| 207 | it('zeigt bei leerem Feld den ganzen Bereich – der Fragen-Browser', () => { |
| 208 | const ergebnis = suchen(INDEX, '', 'I.5'); |
| 209 | expect(ergebnis.stoebern).toBe(true); |
| 210 | expect(ergebnis.treffer.length).toBe(43); |
| 211 | expect(suchen(INDEX, '', 'I').treffer.length).toBe(345); |
| 212 | expect(suchen(INDEX, '', null).treffer.length).toBe(575); |
| 213 | }); |
| 214 | }); |
| 215 | |
| 216 | describe('Mehrere Wörter', () => { |
| 217 | it('verlangt jedes Wort, nicht irgendeines', () => { |
| 218 | const beide = zahl('notwehr angriff'); |
| 219 | expect(beide).toBe(15); |
| 220 | expect(beide).toBeLessThan(zahl('notwehr')); |
| 221 | expect(beide).toBeLessThan(zahl('angriff')); |
| 222 | }); |
| 223 | |
| 224 | it('lässt die Wörter über die Felder verteilt zu', () => { |
| 225 | /* Ein Wort im Fragetext, das andere in einer Antwortmöglichkeit – das |
| 226 | ist der Regelfall bei einer Frage mit sieben Optionen. */ |
| 227 | const treffer = suchen(INDEX, 'notwehr angriff').treffer; |
| 228 | expect(treffer.some((t) => t.klasse === 'antwort')).toBe(true); |
| 229 | }); |
| 230 | }); |
| 231 | |
| 232 | describe('Eingaben, an denen eine Abfragesprache zerbräche', () => { |
| 233 | /* |
| 234 | Genau diese Zeichen tippt der Nutzer hier zwangsläufig. Bei FTS5 werfen |
| 235 | sie harte Fehler („fts5: syntax error“), hier darf nichts davon passieren: |
| 236 | Ein leeres Ergebnis ist eine Antwort, eine Ausnahme ist keine. |
| 237 | */ |
| 238 | const EINGABEN = [ |
| 239 | '-', |
| 240 | '(', |
| 241 | '"', |
| 242 | '^', |
| 243 | '*', |
| 244 | 'AND', |
| 245 | 'a OR', |
| 246 | 'waffe AND', |
| 247 | 'Waffe:', |
| 248 | 'x"y', |
| 249 | '§ 3', |
| 250 | '§3', |
| 251 | 'Abs. 1', |
| 252 | 'II-45', |
| 253 | '2.150', |
| 254 | '7,65', |
| 255 | 'Soft-Air-Waffen', |
| 256 | ' ', |
| 257 | '()[]{}', |
| 258 | ]; |
| 259 | |
| 260 | it.each(EINGABEN)('„%s“ wirft nicht', (eingabe) => { |
| 261 | expect(() => suchen(INDEX, eingabe)).not.toThrow(); |
| 262 | }); |
| 263 | }); |
| 264 | |
| 265 | describe('Kurze und leere Eingaben', () => { |
| 266 | it('meldet ein einzelnes Zeichen als zu kurz', () => { |
| 267 | const ergebnis = suchen(INDEX, 'a'); |
| 268 | expect(ergebnis.zuKurz).toBe(true); |
| 269 | expect(ergebnis.treffer).toEqual([]); |
| 270 | }); |
| 271 | |
| 272 | it('sucht ab zwei Zeichen', () => { |
| 273 | expect(suchen(INDEX, 'ab').zuKurz).toBe(false); |
| 274 | // Kürzel wie „P1“ müssen gehen – drei Zeichen wären eine Trigram-Eigenheit. |
| 275 | expect(suchen(INDEX, 'P1').zuKurz).toBe(false); |
| 276 | }); |
| 277 | |
| 278 | it('behandelt das leere Feld als Stöbern, nicht als Fehler', () => { |
| 279 | const ergebnis = suchen(INDEX, ''); |
| 280 | expect(ergebnis.stoebern).toBe(true); |
| 281 | expect(ergebnis.zuKurz).toBe(false); |
| 282 | expect(ergebnis.treffer.length).toBe(575); |
| 283 | }); |
| 284 | }); |
| 285 | |
| 286 | describe('Leerzustand – Wege statt Sackgassen', () => { |
| 287 | it('benennt das schuldige Wort einer mehrteiligen Anfrage', () => { |
| 288 | /* Der Katalog sagt nicht „verloren“, sondern „abhanden gekommen“. */ |
| 289 | const woerter = suchwoerter('waffe verloren'); |
| 290 | expect(schuldigeWoerter(INDEX, woerter)).toEqual([suchwoerter('verloren')[0]]); |
| 291 | expect(zahl('waffe verloren')).toBe(0); |
| 292 | }); |
| 293 | |
| 294 | it('meldet nichts, wenn jedes Wort irgendwo vorkommt', () => { |
| 295 | expect(schuldigeWoerter(INDEX, suchwoerter('notwehr angriff'))).toEqual([]); |
| 296 | }); |
| 297 | |
| 298 | it('bietet für ein zusammengesetztes Wort ohne Treffer eine Zerlegung an', () => { |
| 299 | /* |
| 300 | „Reizstoffwaffe“ – der Gesetzesbegriff aus § 42a WaffG – steht im |
| 301 | Katalog kein einziges Mal, weil dort „Schreckschuss-, Reizstoff- und |
| 302 | Signalwaffen“ geschrieben ist. |
| 303 | */ |
| 304 | expect(zahl('Reizstoffwaffe')).toBe(0); |
| 305 | expect(zerlegung(INDEX, 'Reizstoffwaffe')).toEqual({ |
| 306 | links: 'Reizstoff', |
| 307 | rechts: 'waffe', |
| 308 | anzahl: 11, |
| 309 | }); |
| 310 | }); |
| 311 | |
| 312 | it('gibt die Schreibweise des Suchenden zurück, nicht die Kanonform', () => { |
| 313 | // „Reizstoff“, nicht „reizstof“ – die Faltung ist ein inneres Werkzeug. |
| 314 | const z = zerlegung(INDEX, 'Hiebwaffen'); |
| 315 | expect(z).toEqual({ links: 'Hieb', rechts: 'waffen', anzahl: 3 }); |
| 316 | }); |
| 317 | |
| 318 | it.each([ |
| 319 | ['Federdruckwaffen', 6], |
| 320 | ['Angriffswaffen', 5], |
| 321 | ])('zerlegt „%s“ zu %i Fragen', (wort, anzahl) => { |
| 322 | expect(zerlegung(INDEX, wort)?.anzahl).toBe(anzahl); |
| 323 | }); |
| 324 | |
| 325 | it('verspricht nichts, wo die Teile nicht gemeinsam vorkommen', () => { |
| 326 | // Ehrlich: „Munition“ und „Aufbewahrung“ stehen in keiner gemeinsamen Frage. |
| 327 | expect(zerlegung(INDEX, 'Munitionsaufbewahrung')).toBeNull(); |
| 328 | }); |
| 329 | }); |
| 330 | |
| 331 | describe('Der Test, der stilles Verlieren verhindert', () => { |
| 332 | it('findet jede der 575 Fragen mit ihrem eigenen längsten Wort', () => { |
| 333 | /* |
| 334 | Diese Zusicherung ersetzt kein Urteil über die Trefferqualität – sie |
| 335 | verhindert das Schlimmste: eine Suche, die weiter antwortet, nur mit |
| 336 | weniger. Sie bricht, sobald Faltung, Indexbau, Feldzuordnung oder |
| 337 | Bereichsfilter etwas verlieren, und sie ist nicht auszutricksen, weil |
| 338 | die Wörter aus dem Bestand selbst kommen. |
| 339 | */ |
| 340 | const ausfaelle: string[] = []; |
| 341 | for (const frage of KATALOG.fragen) { |
| 342 | const laengstes = [...suchwoerter(frage.frage.text)] |
| 343 | .filter((w) => w.length >= 6) |
| 344 | .sort((a, b) => b.length - a.length)[0]; |
| 345 | if (laengstes === undefined) { |
| 346 | continue; |
| 347 | } |
| 348 | const treffer = suchen(INDEX, laengstes).treffer; |
| 349 | if (!treffer.some((t) => t.frageId === frage.id)) { |
| 350 | ausfaelle.push(`${frage.id} findet sich nicht mit „${laengstes}“`); |
| 351 | } |
| 352 | } |
| 353 | expect(ausfaelle, ausfaelle.slice(0, 5).join('\n')).toEqual([]); |
| 354 | }); |
| 355 | |
| 356 | it('findet jede Frage auch über ihre Antwortmöglichkeiten', () => { |
| 357 | const ausfaelle: string[] = []; |
| 358 | for (const frage of KATALOG.fragen) { |
| 359 | const option = frage.optionen?.[0]; |
| 360 | if (option === undefined) { |
| 361 | continue; |
| 362 | } |
| 363 | const laengstes = [...suchwoerter(option.inhalt.text)] |
| 364 | .filter((w) => w.length >= 6) |
| 365 | .sort((a, b) => b.length - a.length)[0]; |
| 366 | if (laengstes === undefined) { |
| 367 | continue; |
| 368 | } |
| 369 | if (!suchen(INDEX, laengstes).treffer.some((t) => t.frageId === frage.id)) { |
| 370 | ausfaelle.push(`${frage.id} über Antwort ${option.label}: „${laengstes}“`); |
| 371 | } |
| 372 | } |
| 373 | expect(ausfaelle, ausfaelle.slice(0, 5).join('\n')).toEqual([]); |
| 374 | }); |
| 375 | |
| 376 | it('stolpert nicht über die zwei leeren Antwortmöglichkeiten', () => { |
| 377 | /* Bei zwei Optionen ist das Bild die Antwort, der Text ist leer. Ein |
| 378 | Index, der stumpf darüberläuft, erzeugte leere Einträge. */ |
| 379 | const leere = KATALOG.fragen.flatMap((f) => |
| 380 | (f.optionen ?? []).filter((o) => o.inhalt.text.trim().length === 0).map(() => f.id), |
| 381 | ); |
| 382 | expect(leere.length).toBe(2); |
| 383 | expect(() => suchen(INDEX, 'waffe')).not.toThrow(); |
| 384 | }); |
| 385 | }); |
| 386 | |
| 387 | describe('Begriffsbrücke – vom Wort des Prüflings zum Wort des Katalogs', () => { |
| 388 | it('hält jedes Versprechen: jedes Katalogwort findet auch etwas', () => { |
| 389 | /* Eine Brücke, die auf ein Wort zeigt, das der Katalog nicht kennt, ist |
| 390 | schlimmer als keine – sie schickt den Suchenden ein zweites Mal ins |
| 391 | Leere, diesmal mit einer Zusage. */ |
| 392 | for (const eintrag of BEGRIFFSBRUECKE) { |
| 393 | for (const wort of eintrag.katalog) { |
| 394 | expect(zahl(wort), `„${wort}“ findet nichts`).toBeGreaterThan(0); |
| 395 | } |
| 396 | } |
| 397 | }); |
| 398 | |
| 399 | it('verspricht genau das, was ein Klick auch liefert', () => { |
| 400 | /* |
| 401 | DER Test dieses Blocks, und er hat gefehlt. Vorher nannte das Angebot |
| 402 | die Vereinigungsmenge über alle Katalogwörter – „Tresor“ versprach 27 |
| 403 | Fragen – und setzte beim Klick nur das erste Wort ein, das drei liefert. |
| 404 | Der Suchende bekam ein Neuntel und hielt das Thema für erschöpfend |
| 405 | behandelt. Die Vereinigung ist über das Suchfeld auch gar nicht |
| 406 | herstellbar, weil mehrere Wörter UND-verknüpft sind. |
| 407 | */ |
| 408 | for (const eintrag of BEGRIFFSBRUECKE) { |
| 409 | for (const gesucht of eintrag.gesucht) { |
| 410 | for (const vorschlag of brueckeFinden(INDEX, gesucht)?.vorschlaege ?? []) { |
| 411 | expect( |
| 412 | zahl(vorschlag.wort), |
| 413 | `„${gesucht}“ → „${vorschlag.wort}“ verspricht ${String(vorschlag.anzahl)}`, |
| 414 | ).toBe(vorschlag.anzahl); |
| 415 | } |
| 416 | } |
| 417 | } |
| 418 | }); |
| 419 | |
| 420 | it('zählt im gewählten Bereich, nicht katalogweit', () => { |
| 421 | /* Sonst verspräche das Angebot 27 Fragen und führte in Kapitel II auf |
| 422 | „Keine Frage gefunden.“ – eine Zusage, die die Einschränkung nicht |
| 423 | kennt. */ |
| 424 | for (const vorschlag of brueckeFinden(INDEX, 'tresor', 'I.4')?.vorschlaege ?? []) { |
| 425 | expect(zahl(vorschlag.wort, 'I.4')).toBe(vorschlag.anzahl); |
| 426 | } |
| 427 | // In Kapitel II gibt es zu keinem der Wörter etwas – dann kein Angebot. |
| 428 | expect(brueckeFinden(INDEX, 'tresor', 'II')).toBeNull(); |
| 429 | }); |
| 430 | |
| 431 | it('bietet nur Wörter an, die im Bereich tatsächlich etwas finden', () => { |
| 432 | for (const bereich of [null, 'I', 'I.4', 'IV']) { |
| 433 | for (const vorschlag of brueckeFinden(INDEX, 'tresor', bereich)?.vorschlaege ?? []) { |
| 434 | expect(vorschlag.anzahl).toBeGreaterThan(0); |
| 435 | } |
| 436 | } |
| 437 | }); |
| 438 | |
| 439 | it('bietet nicht an, wonach ohnehin schon gesucht wird', () => { |
| 440 | // „Reizstoff“ ist selbst das Katalogwort – ein Angebot wäre eine Schleife. |
| 441 | expect(brueckeFinden(INDEX, 'Reizstoff')).toBeNull(); |
| 442 | }); |
| 443 | |
| 444 | it.each([ |
| 445 | ['Tresor', ['Sicherheitsbehältnis', 'Widerstandsgrad', 'Aufbewahrung']], |
| 446 | ['Pfefferspray', ['Reizstoff']], |
| 447 | ])('„%s“ verweist auf %j', (eingabe, woerter) => { |
| 448 | expect(brueckeFinden(INDEX, eingabe)?.vorschlaege.map((v) => v.wort)).toEqual(woerter); |
| 449 | }); |
| 450 | |
| 451 | it('springt nicht auf gewöhnliche Wörter an', () => { |
| 452 | /* |
| 453 | Der Fehler, den die Messung gefunden hat: „ölen“ faltet zu „olen“, und |
| 454 | das steckt in „Pistolen“, „wollen“, „sollen“. Als Teilzeichenkette |
| 455 | geprüft, bekäme jeder, der nach Pistolen sucht, ein Angebot zur |
| 456 | Instandhaltung. Deshalb wird hier Wort für Wort verglichen. |
| 457 | */ |
| 458 | for (const wort of ['Pistolen', 'wollen', 'sollen', 'Waffe', 'Munition', 'Holster']) { |
| 459 | expect(brueckeFinden(INDEX, wort), `Fehlauslöser bei „${wort}“`).toBeNull(); |
| 460 | } |
| 461 | }); |
| 462 | |
| 463 | it('greift auch, wenn das Stichwort in einer längeren Eingabe steht', () => { |
| 464 | expect(brueckeFinden(INDEX, 'waffenschrank kaufen')).not.toBeNull(); |
| 465 | expect(brueckeFinden(INDEX, 'Diebstahl melden')).not.toBeNull(); |
| 466 | }); |
| 467 | |
| 468 | it('führt kein Stichwort doppelt', () => { |
| 469 | const alle = BEGRIFFSBRUECKE.flatMap((e) => e.gesucht.map((w) => falten(w))); |
| 470 | expect(new Set(alle).size).toBe(alle.length); |
| 471 | }); |
| 472 | }); |
| 473 | |
| 474 | describe('Zerlegungsangebot: Satzzeichen am Rand', () => { |
| 475 | /* |
| 476 | Befund der Prüfrunde zu 0.27.2. `suchwoerter` schneidet Satzzeichen an den |
| 477 | Rändern ab — ausdrücklich auch die typografischen Anführungszeichen, „die |
| 478 | diese Anwendung in ihren eigenen Hinweistexten setzt“. Das |
| 479 | Zerlegungsangebot bekam den Text dagegen ungefiltert. |
| 480 | |
| 481 | Folge: „Hiebwaffen.“ (mit Punkt) versprach eine andere Zahl, als der |
| 482 | eigene Klick einlöste, und bei „Reizstoffwaffe?“ verschwand das Angebot |
| 483 | ganz — die einzige Hilfe aus der Sackgasse. Der Grundsatz derselben Datei: |
| 484 | Ein Angebot verspricht nur das, was sein eigener Klick einlöst. |
| 485 | */ |
| 486 | it('zerlegt ein Wort mit Satzzeichen wie ohne', () => { |
| 487 | const index = suchindexBauen(KATALOG); |
| 488 | |
| 489 | for (const [roh, sauber] of [ |
| 490 | ['Hiebwaffen.', 'Hiebwaffen'], |
| 491 | ['Hiebwaffen?', 'Hiebwaffen'], |
| 492 | ['„Hiebwaffen“', 'Hiebwaffen'], |
| 493 | ['(Hiebwaffen)', 'Hiebwaffen'], |
| 494 | ]) { |
| 495 | const mitZeichen = zerlegung(index, roh ?? ''); |
| 496 | const ohneZeichen = zerlegung(index, sauber ?? ''); |
| 497 | expect(mitZeichen?.anzahl ?? null, `„${roh ?? ''}“`).toBe(ohneZeichen?.anzahl ?? null); |
| 498 | } |
| 499 | }); |
| 500 | }); |