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 2138 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 { readdirSync, 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 2138 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('nennt den Änderungsstand und nicht die Neufassungs-Bekanntmachung', () => { |
| 199 | /* |
| 200 | Die Wache, die gefehlt hat – und der Grund, warum sie hier steht. |
| 201 | |
| 202 | Das amtliche XML führt die Standangabe je nach Gesetz ein- oder |
| 203 | zweimal: Wer seit seiner Neufassung geändert wurde, trägt zuerst einen |
| 204 | Eintrag vom Typ `Neuf` („Neugefasst durch Bek. v. …“) und danach den |
| 205 | vom Typ `Stand` („zuletzt geändert durch …“). `gesetze_index.py` las |
| 206 | mit `standangabe/standkommentar` blind den ersten. Bei StGB, SprengG |
| 207 | und 1. SprengV – zusammen 40 der 148 ausgelieferten Normen – stand |
| 208 | dadurch unter dem Wortlaut die Bekanntmachung statt des |
| 209 | Änderungsstands; beim StGB die von 1998 statt der von 2026. |
| 210 | |
| 211 | Zweite Wirkung, und der Grund für die Schwere: Der Freigabeschritt |
| 212 | „Gesetzesstand ansehen“ in `docs/veroeffentlichen.md` vergleicht die |
| 213 | Angabe mit gesetze-im-internet.de. Eine Neufassungs-Bekanntmachung |
| 214 | ändert sich durch keine Gesetzesänderung – die Aktualitätsprüfung war |
| 215 | für diese drei Gesetze dauerhaft wirkungslos. |
| 216 | |
| 217 | Geprüft wird ohne die Zuordnung Kürzel → Datei, die das Werkzeug |
| 218 | führt: Aus jedem XML kommt die Angabe, die dort gelten muss (der |
| 219 | `Stand`-Eintrag, sonst der einzige vorhandene), und jede Angabe im |
| 220 | Index muss eine davon sein. So schlägt der Fall auch an, wenn der |
| 221 | Index gegenüber einem neu geladenen XML zurückbleibt. |
| 222 | */ |
| 223 | const zulaessig = new Set<string>(); |
| 224 | |
| 225 | for (const name of readdirSync(join(WURZEL, 'content', 'gesetze'))) { |
| 226 | if (!name.endsWith('.xml')) { |
| 227 | continue; |
| 228 | } |
| 229 | |
| 230 | const roh = readFileSync(join(WURZEL, 'content', 'gesetze', name), 'utf8'); |
| 231 | /* Nur der erste Datensatz trägt die Angaben zum Gesetz als Ganzes – |
| 232 | dieselbe Stelle, die `gesetze_index.py` liest. */ |
| 233 | const kopf = roh.slice(0, roh.indexOf('</metadaten>')); |
| 234 | |
| 235 | let erste = ''; |
| 236 | let stand = ''; |
| 237 | for (const treffer of kopf.matchAll( |
| 238 | /<standangabe[^>]*>\s*<standtyp>([^<]*)<\/standtyp>\s*<standkommentar>([^<]*)<\/standkommentar>/gu, |
| 239 | )) { |
| 240 | const typ = treffer[1] ?? ''; |
| 241 | const text = (treffer[2] ?? '').trim(); |
| 242 | if (erste === '') { |
| 243 | erste = text; |
| 244 | } |
| 245 | if (typ === 'Stand') { |
| 246 | stand = text; |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | zulaessig.add(stand === '' ? erste : stand); |
| 251 | } |
| 252 | |
| 253 | expect(zulaessig.size, 'Standangaben aus content/gesetze/*.xml').toBe(7); |
| 254 | |
| 255 | for (const [kuerzel, gesetz] of Object.entries(index.gesetze)) { |
| 256 | expect( |
| 257 | [...zulaessig], |
| 258 | `Standangabe von ${kuerzel} – neu erzeugen mit: python data-pipeline/gesetze_index.py`, |
| 259 | ).toContain(gesetz.stand); |
| 260 | } |
| 261 | }); |
| 262 | |
| 263 | it('gibt die Standangaben unverändert an die Normtexte weiter', () => { |
| 264 | /* `normtexte_bauen.py` schreibt `meta.gesetzesstand` aus dem Index ab. |
| 265 | Läuft nur einer der beiden Erzeuger, zeigt die Anwendung eine andere |
| 266 | Angabe als die, gegen die geprüft wurde. */ |
| 267 | expect(Object.fromEntries(Object.entries(index.gesetze).map(([k, g]) => [k, g.stand]))).toEqual( |
| 268 | NORMTEXTE.meta.gesetzesstand, |
| 269 | ); |
| 270 | }); |
| 271 | |
| 272 | it('wird von der Veröffentlichungsliste tatsächlich angesehen', () => { |
| 273 | /* Ohne diesen Fall wäre die Angabe da und niemand sähe sie an. Der |
| 274 | Schritt in der Liste ist der einzige Ort, an dem aus dem Datum eine |
| 275 | Handlung wird. */ |
| 276 | const liste = readFileSync(join(WURZEL, 'docs', 'veroeffentlichen.md'), 'utf8'); |
| 277 | |
| 278 | expect(liste).toContain('Gesetzesstand ansehen'); |
| 279 | expect(liste).toContain('gesetze_index.py'); |
| 280 | }); |
| 281 | }); |
| 282 | |
| 283 | describe('Die Zahlen über den Bestand sind gezählt', () => { |
| 284 | /* |
| 285 | Die Wache, die gefehlt hat – dreimal in Folge. |
| 286 | |
| 287 | „588 von 632“, „43 von 44 Abbildungen“, „1853 Fundstellen“, „2123“, |
| 288 | „2137“, „503 KiB“: Zahlen über diesen Bestand standen in `docs/stand.md`, |
| 289 | im Kopf von `src/shared/normtexte.ts`, im Kopf dieser Datei und in |
| 290 | `data-pipeline/normtexte_bauen.py` – jede von Hand abgeschrieben, jede |
| 291 | beim nächsten Redaktionsschritt still veraltet. Zweimal wurde an einer |
| 292 | Stelle berichtigt, während die anderen stehen blieben. |
| 293 | |
| 294 | Gezählt wird deshalb hier, aus dem Bestand selbst, mit derselben Funktion, |
| 295 | die auch die Anzeige benutzt. Die Dokumente müssen die gezählte Zahl |
| 296 | tragen; wer eine Fundstelle ergänzt, sieht sofort, wo sie nachzuführen |
| 297 | ist. `docs/stand.md` ist nach CLAUDE.md die einzige Stelle für den |
| 298 | Umsetzungsstand – dann muss sie auch stimmen. |
| 299 | */ |
| 300 | const stand = readFileSync(join(WURZEL, 'docs', 'stand.md'), 'utf8'); |
| 301 | const modulkopf = readFileSync(join(process.cwd(), 'src', 'shared', 'normtexte.ts'), 'utf8'); |
| 302 | |
| 303 | const anlagenzitate = ALLE_FUNDSTELLEN.filter((f) => f.norm.startsWith('Anlage')); |
| 304 | const absatzzitate = ALLE_FUNDSTELLEN.filter( |
| 305 | (f) => !f.norm.startsWith('Anlage') && f.absatz !== undefined, |
| 306 | ); |
| 307 | const auszuege = anlagenzitate.map((f) => auszugBilden(NORMTEXTE, f)); |
| 308 | const getroffen = auszuege.filter((a) => a.art === 'block').length; |
| 309 | const offen = auszuege.filter((a) => a.art === 'anlage'); |
| 310 | const abbildungen = offen.filter((a) => a.abbildung).length; |
| 311 | |
| 312 | it('nennt in docs/stand.md die gezählte Zahl der Fundstellen', () => { |
| 313 | const erklaerungen = Object.values(ERKLAERUNGEN.zuFrage).flatMap((e) => e.fundstellen).length; |
| 314 | |
| 315 | /* Vier Stellen, alle in der Gegenwartsform – nicht die Rückblicke auf |
| 316 | einzelne Behebungen, die eine damalige Zahl nennen dürfen. */ |
| 317 | const zahl = String(erklaerungen); |
| 318 | expect(stand).toContain(`Alle 575 Fragen, ${zahl} vollständig nachgewiesene`); |
| 319 | expect(stand).toContain(`jede der ${zahl} Fundstellen **existiert**`); |
| 320 | expect(stand).toContain(`sind es **${zahl} = ${zahl} + 0**`); |
| 321 | expect(stand).toContain(`heute sind es **${zahl}**`); |
| 322 | }); |
| 323 | |
| 324 | const absaetze = String(absatzzitate.length); |
| 325 | const anlagen = String(anlagenzitate.length); |
| 326 | const genau = String(getroffen); |
| 327 | const rest = String(offen.length); |
| 328 | const bilder = String(abbildungen); |
| 329 | |
| 330 | it('nennt in docs/stand.md die gezählte Auflösungsquote der Anlagenzitate', () => { |
| 331 | expect(stand).toContain( |
| 332 | `${absaetze} von ${absaetze} Absatzzitaten und ` + |
| 333 | `${genau} von ${anlagen} Anlagenzitaten treffen genau`, |
| 334 | ); |
| 335 | expect(stand).toContain(`${bilder} der ${rest} nennen eine Abbildung`); |
| 336 | expect(stand).toContain(`über alle ${String(ALLE_FUNDSTELLEN.length)} Fundstellen`); |
| 337 | }); |
| 338 | |
| 339 | it('nennt im Kopf von normtexte.ts dieselben gezählten Zahlen', () => { |
| 340 | expect(modulkopf).toContain(`Von ${anlagen} Anlagenzitaten lösen sich ${genau} eindeutig auf.`); |
| 341 | expect(modulkopf).toContain(`verbleibenden ${rest} nennen ${bilder}`); |
| 342 | expect(modulkopf).toContain(`den ${rest} nicht auflösbaren Zitaten`); |
| 343 | }); |
| 344 | }); |
| 345 | |
| 346 | describe('Der ausgelieferte Bestand', () => { |
| 347 | it('enthält nur die zitierten Normen', () => { |
| 348 | /* Der Index führt die sieben Gesetze vollständig – 914 Normen, darunter |
| 349 | katalogfremde wie § 173 StGB. Ausgeliefert wird der Auszug. */ |
| 350 | const zitiert = new Set(ALLE_FUNDSTELLEN.map((f) => `${f.gesetz}|${f.norm}`)); |
| 351 | const geliefert = Object.entries(NORMTEXTE.gesetze).flatMap(([kuerzel, gesetz]) => |
| 352 | Object.keys(gesetz.normen).map((norm) => `${kuerzel}|${norm}`), |
| 353 | ); |
| 354 | |
| 355 | expect(geliefert.length).toBeGreaterThan(0); |
| 356 | for (const norm of geliefert) { |
| 357 | expect(zitiert.has(norm)).toBe(true); |
| 358 | } |
| 359 | }); |
| 360 | |
| 361 | it('kennt jede Norm, auf die eine Fundstelle zeigt', () => { |
| 362 | const fehlend = ALLE_FUNDSTELLEN.filter( |
| 363 | (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm] === undefined, |
| 364 | ).map((f) => `${f.gesetz} ${f.norm}`); |
| 365 | |
| 366 | expect([...new Set(fehlend)]).toEqual([]); |
| 367 | }); |
| 368 | |
| 369 | it('kennt jeden zitierten Absatz', () => { |
| 370 | /* Die eigentliche Zusage: 1501 Paragrafenzitate, und keines davon läuft |
| 371 | ins Leere. Ohne diesen Test bliebe eine Lücke unbemerkt, bis sie |
| 372 | jemandem beim Lernen begegnet. */ |
| 373 | const fehlend = ALLE_FUNDSTELLEN.filter((f) => { |
| 374 | const norm = NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm]; |
| 375 | return ( |
| 376 | norm !== undefined && |
| 377 | !norm.istAnlage && |
| 378 | f.absatz !== undefined && |
| 379 | norm.absaetze?.[f.absatz] === undefined |
| 380 | ); |
| 381 | }).map((f) => `${f.gesetz} ${f.norm} Abs. ${f.absatz ?? ''}`); |
| 382 | |
| 383 | expect([...new Set(fehlend)]).toEqual([]); |
| 384 | }); |
| 385 | |
| 386 | it('löst mindestens neun von zehn Anlagenzitaten eindeutig auf', () => { |
| 387 | /* Kein Anspruch auf hundert Prozent, und das mit Grund: Die Abbildungen |
| 388 | der Anlage II BeschussV stehen im amtlichen XML nur als |
| 389 | Bildunterschrift. Was nicht auflöst, zeigt die ganze Anlage und sagt, |
| 390 | warum – falsch wird dabei nichts. */ |
| 391 | const anlagenzitate = ALLE_FUNDSTELLEN.filter( |
| 392 | (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm]?.istAnlage === true, |
| 393 | ); |
| 394 | const getroffen = anlagenzitate.filter( |
| 395 | (f) => auszugBilden(NORMTEXTE, f).art === 'block', |
| 396 | ).length; |
| 397 | |
| 398 | expect(anlagenzitate.length).toBeGreaterThan(600); |
| 399 | expect(getroffen / anlagenzitate.length).toBeGreaterThan(0.9); |
| 400 | }); |
| 401 | |
| 402 | it('liefert zu jeder Fundstelle etwas Anzeigbares', () => { |
| 403 | const leer = ALLE_FUNDSTELLEN.filter((f) => auszugBilden(NORMTEXTE, f).art === 'fehlt'); |
| 404 | |
| 405 | expect(leer).toEqual([]); |
| 406 | }); |
| 407 | |
| 408 | it('steht auf demselben Gesetzesstand wie Erklärungen und Glossar', () => { |
| 409 | /* Die eine Falle des Bauwegs: Nach einer Gesetzesänderung wird der Index |
| 410 | neu erzeugt und die Fundstellen werden gegen ihn geprüft – wer dann |
| 411 | vergisst, `normtexte_bauen.py` laufen zu lassen, liefert den Wortlaut |
| 412 | des alten Standes zu Fundstellen des neuen aus. Von außen sähe das |
| 413 | tadellos aus: Jede Norm da, jeder Absatz da, nur eben von gestern. */ |
| 414 | expect(NORMTEXTE.meta.gesetzesstand).toEqual(ERKLAERUNGEN.meta.gesetzesstand); |
| 415 | expect(NORMTEXTE.meta.gesetzesstand).toEqual(GLOSSAR.meta.gesetzesstand); |
| 416 | }); |
| 417 | |
| 418 | it('nennt zu jedem Gesetz den Änderungsstand', () => { |
| 419 | // Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute, |
| 420 | // die morgen falsch sein kann. |
| 421 | for (const [kuerzel, gesetz] of Object.entries(NORMTEXTE.gesetze)) { |
| 422 | expect(gesetz.stand.length, kuerzel).toBeGreaterThan(0); |
| 423 | expect(NORMTEXTE.meta.gesetzesstand[kuerzel]).toBe(gesetz.stand); |
| 424 | } |
| 425 | }); |
| 426 | |
| 427 | it('hält den Wortlaut der Anlagen verlustfrei', () => { |
| 428 | /* Die Pipeline rechnet das beim Bauen nach; hier steht die Zusage noch |
| 429 | einmal am ausgelieferten Bestand. Ein beschnittenes Gesetzeszitat wäre |
| 430 | der schlimmste Fehler, den diese Anwendung machen kann – deshalb wird |
| 431 | nirgends geschnitten, nur zerlegt. */ |
| 432 | for (const gesetz of Object.values(NORMTEXTE.gesetze)) { |
| 433 | for (const [name, norm] of Object.entries(gesetz.normen)) { |
| 434 | if (!norm.istAnlage) { |
| 435 | continue; |
| 436 | } |
| 437 | const bloecke = norm.bloecke ?? []; |
| 438 | expect(bloecke.length, name).toBeGreaterThan(0); |
| 439 | // Jeder Block beginnt mit seiner eigenen Marke – die Marke bleibt im |
| 440 | // Text stehen, sie wird nicht in ein Feld herausgezogen. |
| 441 | for (const block of bloecke) { |
| 442 | if (block.marke !== null) { |
| 443 | expect(block.text.trimStart().startsWith(block.marke), `${name} ${block.marke}`).toBe( |
| 444 | true, |
| 445 | ); |
| 446 | } |
| 447 | } |
| 448 | } |
| 449 | } |
| 450 | }); |
| 451 | }); |
| 452 | |
| 453 | describe('Buchstabenzusätze: Absätze und Nummern', () => { |
| 454 | /* |
| 455 | Zwei Befunde der Prüfrunde zu 0.27.2, beide am amtlichen Wortlaut. |
| 456 | |
| 457 | Erstens die Reihenfolge. `Object.entries` gibt bei einem Objekt zuerst |
| 458 | alle ganzzahligen Schlüssel aufsteigend zurück und danach erst die |
| 459 | übrigen. Ein Paragraf mit den Absätzen 1, 2, 2a, 3 erschien deshalb als |
| 460 | 1, 2, 3, 2a — in einer Ansicht, die zusagt, der Text werde „nicht |
| 461 | gekürzt, nicht zusammengesetzt und nicht umsortiert“. Nachgemessen an |
| 462 | `content/normtexte.json`: acht Normen sind betroffen, darunter WaffG § 42 |
| 463 | und § 32. |
| 464 | |
| 465 | Zweitens die Fundstellen. Das Muster für „Nr. …“ las nur Ziffern und |
| 466 | Punkte; aus „Nr. 2a“ wurde die Zahl 2, und gesucht wurde der Block mit der |
| 467 | Marke „2“ — die Überschrift eine Ebene darüber. Acht Zitate in |
| 468 | Erklärungen und Glossar nennen „Nr. 2a“ oder „Nr. 2b“, und **kein |
| 469 | einziger** Block im Bestand trägt eine Marke mit Buchstaben. Der Auszug |
| 470 | zeigte damit eine andere Stelle als die zitierte. |
| 471 | */ |
| 472 | const MIT_BUCHSTABEN: Normtexte = { |
| 473 | meta: { version: 1, stand: '2026-09-03', hinweis: 'Prüfstand', gesetzesstand: {} }, |
| 474 | gesetze: { |
| 475 | WaffG: { |
| 476 | bezeichnung: 'Waffengesetz', |
| 477 | stand: 'Prüfstand', |
| 478 | quelle: 'gesetze-im-internet.de', |
| 479 | normen: { |
| 480 | '§ 42': { |
| 481 | titel: 'Verbot des Führens', |
| 482 | istAnlage: false, |
| 483 | absaetze: { |
| 484 | '1': 'Erster Absatz.', |
| 485 | '2': 'Zweiter Absatz.', |
| 486 | '3': 'Dritter Absatz.', |
| 487 | '4': 'Vierter Absatz.', |
| 488 | '4a': 'Absatz vier a.', |
| 489 | '5': 'Fünfter Absatz.', |
| 490 | }, |
| 491 | }, |
| 492 | 'Anlage 2': { |
| 493 | titel: '(zu § 2 Abs. 2 bis 4)', |
| 494 | istAnlage: true, |
| 495 | bloecke: [ |
| 496 | { |
| 497 | marke: '2', |
| 498 | pfad: ['Abschnitt 2', 'Unterabschnitt 2'], |
| 499 | text: '2.\nErlaubnisfreier Erwerb durch Inhaber einer Waffenbesitzkarte', |
| 500 | }, |
| 501 | ], |
| 502 | }, |
| 503 | }, |
| 504 | }, |
| 505 | }, |
| 506 | }; |
| 507 | |
| 508 | it('lässt die Absätze in der Reihenfolge der Quelle', () => { |
| 509 | const auszug = auszugBilden(MIT_BUCHSTABEN, { gesetz: 'WaffG', norm: '§ 42' }); |
| 510 | |
| 511 | expect(auszug.art).toBe('norm'); |
| 512 | if (auszug.art !== 'norm') return; |
| 513 | const stellen = ['(1)', '(2)', '(3)', '(4)', '(4a)', '(5)'].map((m) => auszug.text.indexOf(m)); |
| 514 | expect(stellen.every((stelle) => stelle >= 0)).toBe(true); |
| 515 | expect([...stellen].sort((a, b) => a - b)).toEqual(stellen); |
| 516 | }); |
| 517 | |
| 518 | it('springt bei „Nr. 2a“ nicht auf den Block „2“', () => { |
| 519 | const auszug = auszugBilden(MIT_BUCHSTABEN, { |
| 520 | gesetz: 'WaffG', |
| 521 | norm: 'Anlage 2', |
| 522 | stelle: 'Abschnitt 2 Unterabschnitt 2 Nr. 2a', |
| 523 | }); |
| 524 | |
| 525 | /* Kein Block trägt die Marke „2a“. Richtig ist dann die ganze Anlage mit |
| 526 | dem Hinweis, dass die Stelle nicht genau zu treffen war – nicht der |
| 527 | Block eine Ebene darüber. */ |
| 528 | expect(auszug.art).toBe('anlage'); |
| 529 | }); |
| 530 | |
| 531 | it('trifft „Nr. 2“ weiterhin genau', () => { |
| 532 | /* Die Gegenprobe: Das schärfere Muster darf die gewöhnlichen Nummern |
| 533 | nicht verlieren. */ |
| 534 | const auszug = auszugBilden(MIT_BUCHSTABEN, { |
| 535 | gesetz: 'WaffG', |
| 536 | norm: 'Anlage 2', |
| 537 | stelle: 'Abschnitt 2 Unterabschnitt 2 Nr. 2', |
| 538 | }); |
| 539 | |
| 540 | expect(auszug.art).toBe('block'); |
| 541 | }); |
| 542 | }); |