waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests fragensuche.test.ts

18,3 KB Rohdatei
app/tests/fragensuche.test.ts — 500 Zeilen
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 });