waffensachkunde

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

/ app tests normtexte.test.ts

12,6 KB Rohdatei
app/tests/normtexte.test.ts — 314 Zeilen
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 });