waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | }); |