waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Die mitgelieferten Normtexte. |
| 3 | * |
| 4 | * Zwei Sorten Prüfung: die Auflösungsregel an gebauten Fällen – dort lässt |
| 5 | * sich die Mehrdeutigkeit gezielt herstellen – und der **ganze ausgelieferte |
| 6 | * Bestand** gegen alle 2123 Fundstellen der Erklärungen und des Glossars. |
| 7 | * Letzteres ist die eigentliche Zusage: Wer „Im Gesetz nachlesen“ liest, soll |
| 8 | * etwas vorfinden. |
| 9 | */ |
| 10 | |
| 11 | import { readFileSync } from 'node:fs'; |
| 12 | import { join } from 'node:path'; |
| 13 | |
| 14 | import { describe, expect, it } from 'vitest'; |
| 15 | |
| 16 | import type { Erklaerungen, Fundstelle } from '../src/shared/erklaerungen'; |
| 17 | import type { Glossar } from '../src/shared/glossar'; |
| 18 | import { |
| 19 | auszugBilden, |
| 20 | blockFinden, |
| 21 | NORMTEXTE_LEER, |
| 22 | zeigtAufAbbildung, |
| 23 | type Anlagenblock, |
| 24 | type Normtexte, |
| 25 | } from '../src/shared/normtexte'; |
| 26 | |
| 27 | const WURZEL = join(process.cwd(), '..'); |
| 28 | |
| 29 | function inhalt(datei: string): unknown { |
| 30 | return JSON.parse(readFileSync(join(WURZEL, 'content', datei), 'utf8')); |
| 31 | } |
| 32 | |
| 33 | /* Gelesen wird der ausgelieferte Bestand selbst, nicht eine Attrappe: Die |
| 34 | Zusage lautet, dass jede der 2123 Fundstellen etwas vorfindet – und das |
| 35 | lässt sich nur an der echten Datei prüfen. */ |
| 36 | const NORMTEXTE = inhalt('normtexte.json') as Normtexte; |
| 37 | const ERKLAERUNGEN = inhalt('erklaerungen.json') as Erklaerungen; |
| 38 | const GLOSSAR = inhalt('glossar.json') as Glossar; |
| 39 | |
| 40 | const ALLE_FUNDSTELLEN: readonly Fundstelle[] = [ |
| 41 | ...Object.values(ERKLAERUNGEN.zuFrage).flatMap((eintrag) => eintrag.fundstellen), |
| 42 | ...GLOSSAR.eintraege.flatMap((eintrag) => eintrag.fundstellen), |
| 43 | ]; |
| 44 | |
| 45 | function block(marke: string | null, pfad: string[], text = 'Text'): Anlagenblock { |
| 46 | return { marke, pfad, text }; |
| 47 | } |
| 48 | |
| 49 | describe('blockFinden', () => { |
| 50 | const bloecke: Anlagenblock[] = [ |
| 51 | block(null, []), |
| 52 | block('Abschnitt 1', []), |
| 53 | block('Unterabschnitt 1', ['Abschnitt 1']), |
| 54 | block('1.1', ['Abschnitt 1', 'Unterabschnitt 1'], 'Schusswaffen sind …'), |
| 55 | block('1.3.1.3', ['Abschnitt 1', 'Unterabschnitt 1'], 'Tiefe Nummer'), |
| 56 | block('Unterabschnitt 3', ['Abschnitt 1']), |
| 57 | block('1.1', ['Abschnitt 1', 'Unterabschnitt 3'], 'Etwas ganz anderes'), |
| 58 | block('Abschnitt 2', []), |
| 59 | block('1.1', ['Abschnitt 2'], 'Und noch etwas anderes'), |
| 60 | ]; |
| 61 | |
| 62 | it('trifft die Nummer im richtigen Unterabschnitt', () => { |
| 63 | /* Der Kern der Sache: „1.1“ kommt hier dreimal vor, in Anlage 1 des WaffG |
| 64 | sogar viermal. Ohne den Pfad spränge die Fundstelle unbemerkt an die |
| 65 | falsche Stelle des Gesetzes. */ |
| 66 | expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 1 Nr. 1.1')?.text).toBe( |
| 67 | 'Schusswaffen sind …', |
| 68 | ); |
| 69 | expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 3 Nr. 1.1')?.text).toBe( |
| 70 | 'Etwas ganz anderes', |
| 71 | ); |
| 72 | expect(blockFinden(bloecke, 'Abschnitt 2 Nr. 1.1')?.text).toBe('Und noch etwas anderes'); |
| 73 | }); |
| 74 | |
| 75 | it('nimmt die tiefste genannte Nummer', () => { |
| 76 | expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 1 Nr. 1.3.1.3')?.marke).toBe('1.3.1.3'); |
| 77 | }); |
| 78 | |
| 79 | it('findet einen Abschnitt auch ohne Nummer', () => { |
| 80 | expect(blockFinden(bloecke, 'Abschnitt 2')?.marke).toBe('Abschnitt 2'); |
| 81 | expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 3')?.marke).toBe('Unterabschnitt 3'); |
| 82 | }); |
| 83 | |
| 84 | it('gibt bei Mehrdeutigkeit lieber nichts zurück', () => { |
| 85 | // „Nr. 1.1“ allein passt auf drei Blöcke. Einen davon zu wählen hieße, |
| 86 | // zwei Drittel der Leser an die falsche Stelle zu schicken. |
| 87 | expect(blockFinden(bloecke, 'Nr. 1.1')).toBeNull(); |
| 88 | }); |
| 89 | |
| 90 | it('gibt bei fehlendem Treffer nichts zurück', () => { |
| 91 | expect(blockFinden(bloecke, 'Abschnitt 9 Nr. 4.2')).toBeNull(); |
| 92 | expect(blockFinden(bloecke, 'Abbildung 5b')).toBeNull(); |
| 93 | expect(blockFinden(bloecke, '')).toBeNull(); |
| 94 | }); |
| 95 | }); |
| 96 | |
| 97 | describe('zeigtAufAbbildung', () => { |
| 98 | it('erkennt die Abbildungen der Anlage II', () => { |
| 99 | expect(zeigtAufAbbildung('Abbildung 5b')).toBe(true); |
| 100 | expect(zeigtAufAbbildung('Abbildung 3 – Ortszeichen der zuständigen Behörden')).toBe(true); |
| 101 | }); |
| 102 | |
| 103 | it('hält gewöhnliche Stellen auseinander', () => { |
| 104 | expect(zeigtAufAbbildung('Abschnitt 1 Unterabschnitt 1 Nr. 1.1')).toBe(false); |
| 105 | }); |
| 106 | }); |
| 107 | |
| 108 | describe('auszugBilden', () => { |
| 109 | it('liefert den zitierten Absatz', () => { |
| 110 | const auszug = auszugBilden(NORMTEXTE, { gesetz: 'WaffG', norm: '§ 1', absatz: '1' }); |
| 111 | |
| 112 | expect(auszug.art).toBe('absatz'); |
| 113 | expect(auszug.art === 'absatz' ? auszug.text : '').toContain('Umgang mit Waffen oder Munition'); |
| 114 | }); |
| 115 | |
| 116 | it('setzt eine Norm ohne Absatzangabe aus ihren Absätzen zusammen', () => { |
| 117 | const auszug = auszugBilden(NORMTEXTE, { gesetz: 'WaffG', norm: '§ 1' }); |
| 118 | |
| 119 | expect(auszug.art).toBe('norm'); |
| 120 | expect(auszug.art === 'norm' ? auszug.text : '').toContain('(1)'); |
| 121 | }); |
| 122 | |
| 123 | it('meldet ehrlich, wenn nichts vorliegt', () => { |
| 124 | expect(auszugBilden(NORMTEXTE_LEER, { gesetz: 'WaffG', norm: '§ 1' }).art).toBe('fehlt'); |
| 125 | expect(auszugBilden(NORMTEXTE, { gesetz: 'StGB', norm: '§ 173' }).art).toBe('fehlt'); |
| 126 | }); |
| 127 | |
| 128 | it('zeigt bei einer Anlage den getroffenen Block', () => { |
| 129 | const auszug = auszugBilden(NORMTEXTE, { |
| 130 | gesetz: 'WaffG', |
| 131 | norm: 'Anlage 1', |
| 132 | stelle: 'Abschnitt 1 Unterabschnitt 1 Nr. 1.1', |
| 133 | }); |
| 134 | |
| 135 | expect(auszug.art).toBe('block'); |
| 136 | expect(auszug.art === 'block' ? auszug.block.text : '').toContain('Schusswaffen sind'); |
| 137 | }); |
| 138 | |
| 139 | it('zeigt die ganze Anlage, wenn die Stelle eine Abbildung ist', () => { |
| 140 | const auszug = auszugBilden(NORMTEXTE, { |
| 141 | gesetz: 'BeschussV', |
| 142 | norm: 'Anlage II', |
| 143 | stelle: 'Abbildung 5b', |
| 144 | }); |
| 145 | |
| 146 | expect(auszug.art).toBe('anlage'); |
| 147 | expect(auszug.art === 'anlage' ? auszug.abbildung : false).toBe(true); |
| 148 | expect(auszug.art === 'anlage' ? auszug.bloecke.length : 0).toBeGreaterThan(0); |
| 149 | }); |
| 150 | }); |
| 151 | |
| 152 | describe('Der Gesetzesabzug sagt, wann er gezogen wurde', () => { |
| 153 | /* |
| 154 | Die Wache, die gefehlt hat. |
| 155 | |
| 156 | `content/gesetze/index.json` führte zu jedem Gesetz die amtliche |
| 157 | Standangabe („Zuletzt geändert durch …“) – aber nirgends stand, wann |
| 158 | jemand zuletzt nachgesehen hat, ob es dabei geblieben ist. Alle 1854 |
| 159 | Fundstellen werden bei jedem Lauf gegen genau diesen Abzug geprüft; ist |
| 160 | er veraltet, meldet `pruefe_erklaerungen.py` weiter grün, gegen eine |
| 161 | Vorschrift, die es so nicht mehr gibt. Am 01.09.2026 lagen zwischen dem |
| 162 | ältesten Abzug (Beschussgesetz, 09.09.2020) und dem jüngsten |
| 163 | (Sprengstoffgesetz, 23.07.2026) fast sechs Jahre. |
| 164 | |
| 165 | Geprüft wird hier ausdrücklich **nicht** das Alter. Ob eine Vorschrift |
| 166 | sich geändert hat, kann nur ein Mensch nachsehen, und eine Prüfung, die |
| 167 | allein durch Zeitablauf rot wird, meldet an ihrem Tag keinen Befund, |
| 168 | sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts` |
| 169 | schon einmal zugeschlagen hat. Geprüft wird, dass die Angabe **da** ist; |
| 170 | hinsehen muss `docs/veroeffentlichen.md`. |
| 171 | */ |
| 172 | const index = JSON.parse( |
| 173 | readFileSync(join(WURZEL, 'content', 'gesetze', 'index.json'), 'utf8'), |
| 174 | ) as { gesetze: Record<string, { stand?: string; abzug?: string }> }; |
| 175 | |
| 176 | it('trägt zu jedem Gesetz ein Abzugsdatum', () => { |
| 177 | const ohne = Object.entries(index.gesetze) |
| 178 | .filter(([, gesetz]) => gesetz.abzug === undefined) |
| 179 | .map(([kuerzel]) => kuerzel); |
| 180 | |
| 181 | expect(ohne, 'Neu erzeugen mit: python data-pipeline/gesetze_index.py').toEqual([]); |
| 182 | }); |
| 183 | |
| 184 | it('schreibt das Abzugsdatum als ISO-Datum', () => { |
| 185 | for (const [kuerzel, gesetz] of Object.entries(index.gesetze)) { |
| 186 | expect(gesetz.abzug, `Abzugsdatum von ${kuerzel}`).toMatch(/^\d{4}-\d{2}-\d{2}$/u); |
| 187 | } |
| 188 | }); |
| 189 | |
| 190 | it('trägt zu jedem Gesetz auch die amtliche Standangabe', () => { |
| 191 | /* Beides zusammen ergibt erst die Auskunft: Der Abzug sagt, wann |
| 192 | nachgesehen wurde, die Standangabe, was dabei zu sehen war. */ |
| 193 | for (const [kuerzel, gesetz] of Object.entries(index.gesetze)) { |
| 194 | expect((gesetz.stand ?? '').length, `Standangabe von ${kuerzel}`).toBeGreaterThan(10); |
| 195 | } |
| 196 | }); |
| 197 | |
| 198 | it('wird von der Veröffentlichungsliste tatsächlich angesehen', () => { |
| 199 | /* Ohne diesen Fall wäre die Angabe da und niemand sähe sie an. Der |
| 200 | Schritt in der Liste ist der einzige Ort, an dem aus dem Datum eine |
| 201 | Handlung wird. */ |
| 202 | const liste = readFileSync(join(WURZEL, 'docs', 'veroeffentlichen.md'), 'utf8'); |
| 203 | |
| 204 | expect(liste).toContain('Gesetzesstand ansehen'); |
| 205 | expect(liste).toContain('gesetze_index.py'); |
| 206 | }); |
| 207 | }); |
| 208 | |
| 209 | describe('Der ausgelieferte Bestand', () => { |
| 210 | it('enthält nur die zitierten Normen', () => { |
| 211 | /* Der Index führt die sieben Gesetze vollständig – 914 Normen, darunter |
| 212 | katalogfremde wie § 173 StGB. Ausgeliefert wird der Auszug. */ |
| 213 | const zitiert = new Set(ALLE_FUNDSTELLEN.map((f) => `${f.gesetz}|${f.norm}`)); |
| 214 | const geliefert = Object.entries(NORMTEXTE.gesetze).flatMap(([kuerzel, gesetz]) => |
| 215 | Object.keys(gesetz.normen).map((norm) => `${kuerzel}|${norm}`), |
| 216 | ); |
| 217 | |
| 218 | expect(geliefert.length).toBeGreaterThan(0); |
| 219 | for (const norm of geliefert) { |
| 220 | expect(zitiert.has(norm)).toBe(true); |
| 221 | } |
| 222 | }); |
| 223 | |
| 224 | it('kennt jede Norm, auf die eine Fundstelle zeigt', () => { |
| 225 | const fehlend = ALLE_FUNDSTELLEN.filter( |
| 226 | (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm] === undefined, |
| 227 | ).map((f) => `${f.gesetz} ${f.norm}`); |
| 228 | |
| 229 | expect([...new Set(fehlend)]).toEqual([]); |
| 230 | }); |
| 231 | |
| 232 | it('kennt jeden zitierten Absatz', () => { |
| 233 | /* Die eigentliche Zusage: 1501 Paragrafenzitate, und keines davon läuft |
| 234 | ins Leere. Ohne diesen Test bliebe eine Lücke unbemerkt, bis sie |
| 235 | jemandem beim Lernen begegnet. */ |
| 236 | const fehlend = ALLE_FUNDSTELLEN.filter((f) => { |
| 237 | const norm = NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm]; |
| 238 | return ( |
| 239 | norm !== undefined && |
| 240 | !norm.istAnlage && |
| 241 | f.absatz !== undefined && |
| 242 | norm.absaetze?.[f.absatz] === undefined |
| 243 | ); |
| 244 | }).map((f) => `${f.gesetz} ${f.norm} Abs. ${f.absatz ?? ''}`); |
| 245 | |
| 246 | expect([...new Set(fehlend)]).toEqual([]); |
| 247 | }); |
| 248 | |
| 249 | it('löst mindestens neun von zehn Anlagenzitaten eindeutig auf', () => { |
| 250 | /* Kein Anspruch auf hundert Prozent, und das mit Grund: Die Abbildungen |
| 251 | der Anlage II BeschussV stehen im amtlichen XML nur als |
| 252 | Bildunterschrift. Was nicht auflöst, zeigt die ganze Anlage und sagt, |
| 253 | warum – falsch wird dabei nichts. */ |
| 254 | const anlagenzitate = ALLE_FUNDSTELLEN.filter( |
| 255 | (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm]?.istAnlage === true, |
| 256 | ); |
| 257 | const getroffen = anlagenzitate.filter( |
| 258 | (f) => auszugBilden(NORMTEXTE, f).art === 'block', |
| 259 | ).length; |
| 260 | |
| 261 | expect(anlagenzitate.length).toBeGreaterThan(600); |
| 262 | expect(getroffen / anlagenzitate.length).toBeGreaterThan(0.9); |
| 263 | }); |
| 264 | |
| 265 | it('liefert zu jeder Fundstelle etwas Anzeigbares', () => { |
| 266 | const leer = ALLE_FUNDSTELLEN.filter((f) => auszugBilden(NORMTEXTE, f).art === 'fehlt'); |
| 267 | |
| 268 | expect(leer).toEqual([]); |
| 269 | }); |
| 270 | |
| 271 | it('steht auf demselben Gesetzesstand wie Erklärungen und Glossar', () => { |
| 272 | /* Die eine Falle des Bauwegs: Nach einer Gesetzesänderung wird der Index |
| 273 | neu erzeugt und die Fundstellen werden gegen ihn geprüft – wer dann |
| 274 | vergisst, `normtexte_bauen.py` laufen zu lassen, liefert den Wortlaut |
| 275 | des alten Standes zu Fundstellen des neuen aus. Von außen sähe das |
| 276 | tadellos aus: Jede Norm da, jeder Absatz da, nur eben von gestern. */ |
| 277 | expect(NORMTEXTE.meta.gesetzesstand).toEqual(ERKLAERUNGEN.meta.gesetzesstand); |
| 278 | expect(NORMTEXTE.meta.gesetzesstand).toEqual(GLOSSAR.meta.gesetzesstand); |
| 279 | }); |
| 280 | |
| 281 | it('nennt zu jedem Gesetz den Änderungsstand', () => { |
| 282 | // Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute, |
| 283 | // die morgen falsch sein kann. |
| 284 | for (const [kuerzel, gesetz] of Object.entries(NORMTEXTE.gesetze)) { |
| 285 | expect(gesetz.stand.length, kuerzel).toBeGreaterThan(0); |
| 286 | expect(NORMTEXTE.meta.gesetzesstand[kuerzel]).toBe(gesetz.stand); |
| 287 | } |
| 288 | }); |
| 289 | |
| 290 | it('hält den Wortlaut der Anlagen verlustfrei', () => { |
| 291 | /* Die Pipeline rechnet das beim Bauen nach; hier steht die Zusage noch |
| 292 | einmal am ausgelieferten Bestand. Ein beschnittenes Gesetzeszitat wäre |
| 293 | der schlimmste Fehler, den diese Anwendung machen kann – deshalb wird |
| 294 | nirgends geschnitten, nur zerlegt. */ |
| 295 | for (const gesetz of Object.values(NORMTEXTE.gesetze)) { |
| 296 | for (const [name, norm] of Object.entries(gesetz.normen)) { |
| 297 | if (!norm.istAnlage) { |
| 298 | continue; |
| 299 | } |
| 300 | const bloecke = norm.bloecke ?? []; |
| 301 | expect(bloecke.length, name).toBeGreaterThan(0); |
| 302 | // Jeder Block beginnt mit seiner eigenen Marke – die Marke bleibt im |
| 303 | // Text stehen, sie wird nicht in ein Feld herausgezogen. |
| 304 | for (const block of bloecke) { |
| 305 | if (block.marke !== null) { |
| 306 | expect(block.text.trimStart().startsWith(block.marke), `${name} ${block.marke}`).toBe( |
| 307 | true, |
| 308 | ); |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | } |
| 313 | }); |
| 314 | }); |