waffensachkunde

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

/ app tests suchtext.test.ts

11,3 KB Rohdatei
app/tests/suchtext.test.ts — 308 Zeilen
1 // @vitest-environment node
2 /**
3 * Die Kanonform der Suche, gemessen am echten Katalog.
4 *
5 * Diese Tests arbeiten bewusst **nicht** mit ausgedachten Beispielen. Eine
6 * Faltung, die an zwanzig handverlesenen Wörtern richtig liegt, kann am
7 * einundzwanzigsten alles verlieren – und niemand merkt es, weil die Suche
8 * weiter antwortet, nur mit weniger. Gemessen wird deshalb über alle
9 * Wortformen des amtlichen Katalogs.
10 */
11
12 import { readFileSync } from 'node:fs';
13 import { join } from 'node:path';
14 import { fileURLToPath } from 'node:url';
15
16 import { describe, expect, it } from 'vitest';
17
18 import type { Katalog } from '../src/shared/katalog';
19 import { aufUrsprung, falten, faltenMitZuordnung, istWortanfang } from '../src/shared/suchtext';
20
21 const wurzel = fileURLToPath(new URL('..', import.meta.url));
22
23 const KATALOG = JSON.parse(
24 readFileSync(join(wurzel, '..', 'content', 'katalog', 'katalog.json'), 'utf8'),
25 ) as Katalog;
26
27 /** Jeder Textblock des Katalogs: Frage, Antwortoptionen, Musterantwort. */
28 function bloecke(): string[] {
29 const aus: string[] = [];
30 for (const frage of KATALOG.fragen) {
31 aus.push(frage.frage.text);
32 for (const option of frage.optionen ?? []) {
33 aus.push(option.inhalt.text);
34 }
35 if (frage.musterantwort) {
36 aus.push(frage.musterantwort.text);
37 }
38 }
39 return aus;
40 }
41
42 /** Alle im Katalog vorkommenden Wortformen, ungefaltet. */
43 function wortformen(): string[] {
44 const menge = new Set<string>();
45 for (const block of bloecke()) {
46 for (const wort of block.split(/[^\p{L}\p{N}]+/u)) {
47 if (wort.length > 0) {
48 menge.add(wort);
49 }
50 }
51 }
52 return [...menge];
53 }
54
55 const BLOECKE = bloecke();
56 const WORTFORMEN = wortformen();
57
58 describe('Kanonform – Grundzusagen', () => {
59 it('führt alle Schreibweisen von „Büchse“ zusammen', () => {
60 const ziel = falten('Büchse');
61 expect(falten('Buechse')).toBe(ziel);
62 expect(falten('buchse')).toBe(ziel);
63 expect(falten('BÜCHSE')).toBe(ziel);
64 });
65
66 it('führt alle Schreibweisen von „Schießstätte“ zusammen', () => {
67 const ziel = falten('Schießstätte');
68 expect(falten('Schiessstätte')).toBe(ziel);
69 expect(falten('Schiesstätte')).toBe(ziel);
70 expect(falten('schiesstaette')).toBe(ziel);
71 });
72
73 it('faltet ein zerlegt gespeichertes ä wie ein zusammengesetztes', () => {
74 /* NFD: „a“ plus kombinierendes Trema. Beide Wege müssen bei „a“ enden,
75 sonst hinge das Suchergebnis daran, wie der Text gespeichert wurde. */
76 expect(falten('ähnlich')).toBe(falten('ähnlich'));
77 });
78
79 it('ist idempotent – über alle Wortformen des Katalogs', () => {
80 const brueche = WORTFORMEN.filter((wort) => falten(falten(wort)) !== falten(wort));
81 expect(brueche, `Nicht idempotent: ${brueche.slice(0, 10).join(', ')}`).toEqual([]);
82 });
83
84 it('ist idempotent – auch über die ungeteilten Textblöcke', () => {
85 /*
86 Diese Zusicherung sieht wie eine Wiederholung der vorigen aus und ist
87 keine. Die Wortformen entstehen durch Zerlegen an Nicht-Buchstaben –
88 Bindestriche, Punkte und Klammern verschwinden dabei, und genau dort
89 saß der Fehler: Die Regel für den Bindestrich sah auf das
90 Ursprungszeichen, die Kleinschreibung geschah aber vorher.
91 `falten("Wadcutter-Geschoss")` behielt den Strich, ein zweiter Durchlauf
92 entfernte ihn. Über Wortformen war das unsichtbar.
93 */
94 const brueche = BLOECKE.filter((t) => falten(falten(t)) !== falten(t));
95 expect(brueche.length, `Nicht idempotent: ${brueche.slice(0, 3).join(' | ')}`).toBe(0);
96 });
97
98 it('deckt mit den Wortformen einen nennenswerten Bestand ab', () => {
99 /* Eine Zusicherung über den Test selbst: Bräche das Einlesen, liefen die
100 Messungen oben über eine leere Liste und meldeten grün. */
101 expect(WORTFORMEN.length).toBeGreaterThan(3000);
102 expect(BLOECKE.length).toBeGreaterThan(2000);
103 });
104 });
105
106 describe('Kanonform – Ziffern bleiben unangetastet', () => {
107 /*
108 Der Laufkollaps gilt nur für Buchstaben. Die naheliegende Fassung
109 /(.)\1+/u fräße auch Ziffern – in einem Korpus aus Joule-, Millimeter-
110 und Kaliberangaben wäre das ein stiller Falschtrefferautomat.
111 */
112 it('hält 10, 100 und 1000 auseinander', () => {
113 expect(falten('10')).toBe('10');
114 expect(falten('100')).toBe('100');
115 expect(falten('1000')).toBe('1000');
116 });
117
118 it('hält 2 und 22, 26,5 und 265 auseinander', () => {
119 expect(falten('2')).not.toBe(falten('22'));
120 expect(falten('26,5')).not.toBe(falten('265'));
121 });
122
123 it('lässt Maßangaben unverändert erkennbar', () => {
124 expect(falten('7,5 Joule')).toBe('7,5 joule');
125 expect(falten('Kaliber 12/70')).toBe('kaliber 12/70');
126 });
127 });
128
129 describe('Kanonform – was sie kostet', () => {
130 /*
131 Die Faltung wirft Unterschiede weg, und einige davon sind echte. Diese
132 Liste ist ein FESTWERT, keine Obergrenze: Sie steht hier, damit eine
133 spätere „Verbesserung“ auffällt – in beide Richtungen. Wer eine Gruppe
134 auflöst, muss sie streichen; wer eine neue erzeugt, muss sie eintragen
135 und dabei erklären, warum der Verlust hinnehmbar ist.
136 */
137 it('erzeugt genau die bekannten Kollisionsgruppen', () => {
138 const nachForm = new Map<string, Set<string>>();
139 for (const wort of WORTFORMEN) {
140 const schluessel = falten(wort);
141 const gruppe = nachForm.get(schluessel) ?? new Set<string>();
142 gruppe.add(wort.toLowerCase());
143 nachForm.set(schluessel, gruppe);
144 }
145
146 const gruppen = [...nachForm.values()]
147 .filter((g) => g.size > 1)
148 .map((g) => [...g].sort().join('/'))
149 .sort();
150
151 expect(gruppen).toEqual([
152 'das/dass',
153 'den/denn',
154 'erhalt/erhält',
155 'fahrt/fährt',
156 'fallen/fällen',
157 /* Kein Verlust, sondern ein Gewinn: Der Katalog schreibt an fünf
158 Stellen „Geschossfang“ und an einer „Geschoßfang“. Die ß-Faltung
159 führt beide zusammen – wer das eine sucht, findet auch das andere. */
160 'geschossfang/geschoßfang',
161 'hohe/höhe',
162 'i/ii',
163 'lange/länge',
164 'langer/länger',
165 'm/mm',
166 /* Ebenso: die amtliche Schreibung schwankt zwischen „Mitgliedstaat“
167 und „Mitgliedsstaat“. */
168 'mitgliedsstaat/mitgliedstaat',
169 'mitgliedsstaates/mitgliedstaates',
170 'munitionserwerbschein/munitionserwerbsschein',
171 'musste/müsste',
172 'nahe/nähe',
173 'neu/neue',
174 'starke/stärke',
175 'wen/wenn',
176 'zahlen/zählen',
177 ]);
178 });
179 });
180
181 describe('Kanonform – Bindestriche zwischen Buchstaben', () => {
182 /*
183 Drei Stellen im amtlichen Katalog tragen einen Strich mitten im Wort, und
184 keine davon lässt sich beheben: „er-klärt“ und „orange-farbenen“ stehen so
185 im Original, „lever-action“ ist richtig so. Am amtlichen Wortlaut wird
186 nicht gearbeitet – also liest die Suche darüber hinweg. Und wer
187 „Double-Action-Revolver“ sucht, tippt den Strich mal mit und mal ohne.
188 */
189 it('findet „erklärt“ auch dort, wo „er-klärt“ steht', () => {
190 expect(falten('er-klärt')).toBe(falten('erklärt'));
191 expect(falten('orange-farbenen')).toBe(falten('orangefarbenen'));
192 expect(falten('lever-action')).toBe(falten('leveraction'));
193 });
194
195 it('gilt unabhängig von der Schreibung – sonst wäre die Faltung nicht idempotent', () => {
196 /* Genau hier saß der Fehler: „Wadcutter-Geschoss“ hat einen Großbuchstaben
197 hinter dem Strich, die gefaltete Form nicht mehr. Sähe die Regel auf die
198 Schreibung, lieferten beide Durchläufe Verschiedenes. */
199 expect(falten('Wadcutter-Geschoss')).toBe(falten('wadcuttergeschoss'));
200 expect(falten('Physikalisch-Technische')).toBe(falten('physikalischtechnische'));
201 expect(falten('Double-Action-Revolver')).toBe(falten('doubleactionrevolver'));
202 });
203
204 it('lässt den Strich neben einer Ziffer stehen', () => {
205 // Damit Fragennummern und technische Bezeichnungen erkennbar bleiben.
206 expect(falten('CO2-Waffen')).toContain('-');
207 expect(falten('II-45')).toContain('-');
208 expect(falten('I.2-150')).toContain('-');
209 });
210
211 it('lässt den Ergänzungsstrich vor einem Leerzeichen stehen', () => {
212 // „Hieb- und Stoßwaffen“ – der Strich vertritt hier ein ganzes Wort.
213 expect(falten('Hieb- und Stoßwaffen')).toContain('- ');
214 });
215
216 it('greift auch im echten Bestand', () => {
217 const gefaltet = BLOECKE.map((t) => falten(t)).join('\n');
218 for (const wort of ['erklart', 'orangefarbenen', 'leveraction', 'wadcutergeschos']) {
219 expect(gefaltet, `„${wort}“ nicht auffindbar`).toContain(wort);
220 }
221 });
222 });
223
224 describe('Positionszuordnung', () => {
225 it('stimmt mit der schnellen Faltung überein – über alle Textblöcke', () => {
226 /* Zwei Umsetzungen derselben Regel liefen unweigerlich auseinander, und
227 der Schaden wäre still: richtig gefunden, falsch markiert. */
228 const abweichungen = BLOECKE.filter((t) => faltenMitZuordnung(t).gefaltet !== falten(t));
229 expect(abweichungen.length).toBe(0);
230 });
231
232 it('trifft die Fundstelle im Ursprungstext – über alle Fragen', () => {
233 /*
234 Die eigentliche Zusicherung: Wird eine Fundstelle im gefalteten Text
235 auf den Ursprung zurückgerechnet, muss der herausgeschnittene
236 Originalabschnitt dasselbe gefaltete Wort ergeben. Gemessen wird über
237 alle 575 Fragen mit einer Batterie von Suchwörtern – ausdrücklich auch
238 an Fragen mit Umlauten, denn dort ändert die Faltung die Längen.
239 */
240 const woerter = [
241 'schiessen',
242 'buechse',
243 'waffe',
244 'erwerben',
245 'gross',
246 'fuehren',
247 'zulaessig',
248 'schusswaffe',
249 'munition',
250 'aufbewahrung',
251 'notwehr',
252 'geschoss',
253 'patrone',
254 'sportschuetze',
255 'jaeger',
256 'behoerde',
257 'gefaehrlich',
258 'draussen',
259 'massnahme',
260 'ueberlassen',
261 ].map((w) => falten(w));
262
263 let geprueft = 0;
264 const fehler: string[] = [];
265
266 for (const frage of KATALOG.fragen) {
267 const roh = frage.frage.text;
268 const faltung = faltenMitZuordnung(roh);
269 for (const wort of woerter) {
270 const stelle = faltung.gefaltet.indexOf(wort);
271 if (stelle === -1) {
272 continue;
273 }
274 const spanne = aufUrsprung(faltung, stelle, stelle + wort.length);
275 if (spanne === null) {
276 fehler.push(`${frage.id}: keine Spanne für „${wort}“`);
277 continue;
278 }
279 const ausschnitt = roh.slice(spanne.von, spanne.bis);
280 geprueft++;
281 if (!falten(ausschnitt).includes(wort)) {
282 fehler.push(`${frage.id}: „${wort}“ → „${ausschnitt}“ (${falten(ausschnitt)})`);
283 }
284 }
285 }
286
287 expect(fehler, fehler.slice(0, 5).join('\n')).toEqual([]);
288 // Ohne Mindestzahl bewiese ein leeres Ergebnis nichts.
289 expect(geprueft).toBeGreaterThan(200);
290 });
291
292 it('verweigert die Auskunft bei unplausiblen Spannen', () => {
293 const f = faltenMitZuordnung('Waffe');
294 expect(aufUrsprung(f, 0, 0)).toBeNull();
295 expect(aufUrsprung(f, -1, 2)).toBeNull();
296 expect(aufUrsprung(f, 0, 99)).toBeNull();
297 });
298 });
299
300 describe('Wortanfang', () => {
301 it('erkennt den Anfang am Zeichenrand und nach Trennzeichen', () => {
302 const t = falten('Sport und Transport, § 3');
303 expect(istWortanfang(t, 0)).toBe(true);
304 expect(istWortanfang(t, t.indexOf('transport'))).toBe(true);
305 // „port“ in „transport“ ist kein Wortanfang – das trennt die Rangklassen.
306 expect(istWortanfang(t, t.indexOf('transport') + 5)).toBe(false);
307 });
308 });