waffensachkunde

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

/ app tests normtexte.test.ts

10,0 KB Rohdatei
app/tests/normtexte.test.ts — 257 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 ausgelieferte Bestand', () => {
153 it('enthält nur die zitierten Normen', () => {
154 /* Der Index führt die sieben Gesetze vollständig – 914 Normen, darunter
155 katalogfremde wie § 173 StGB. Ausgeliefert wird der Auszug. */
156 const zitiert = new Set(ALLE_FUNDSTELLEN.map((f) => `${f.gesetz}|${f.norm}`));
157 const geliefert = Object.entries(NORMTEXTE.gesetze).flatMap(([kuerzel, gesetz]) =>
158 Object.keys(gesetz.normen).map((norm) => `${kuerzel}|${norm}`),
159 );
160
161 expect(geliefert.length).toBeGreaterThan(0);
162 for (const norm of geliefert) {
163 expect(zitiert.has(norm)).toBe(true);
164 }
165 });
166
167 it('kennt jede Norm, auf die eine Fundstelle zeigt', () => {
168 const fehlend = ALLE_FUNDSTELLEN.filter(
169 (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm] === undefined,
170 ).map((f) => `${f.gesetz} ${f.norm}`);
171
172 expect([...new Set(fehlend)]).toEqual([]);
173 });
174
175 it('kennt jeden zitierten Absatz', () => {
176 /* Die eigentliche Zusage: 1501 Paragrafenzitate, und keines davon läuft
177 ins Leere. Ohne diesen Test bliebe eine Lücke unbemerkt, bis sie
178 jemandem beim Lernen begegnet. */
179 const fehlend = ALLE_FUNDSTELLEN.filter((f) => {
180 const norm = NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm];
181 return (
182 norm !== undefined &&
183 !norm.istAnlage &&
184 f.absatz !== undefined &&
185 norm.absaetze?.[f.absatz] === undefined
186 );
187 }).map((f) => `${f.gesetz} ${f.norm} Abs. ${f.absatz ?? ''}`);
188
189 expect([...new Set(fehlend)]).toEqual([]);
190 });
191
192 it('löst mindestens neun von zehn Anlagenzitaten eindeutig auf', () => {
193 /* Kein Anspruch auf hundert Prozent, und das mit Grund: Die Abbildungen
194 der Anlage II BeschussV stehen im amtlichen XML nur als
195 Bildunterschrift. Was nicht auflöst, zeigt die ganze Anlage und sagt,
196 warum – falsch wird dabei nichts. */
197 const anlagenzitate = ALLE_FUNDSTELLEN.filter(
198 (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm]?.istAnlage === true,
199 );
200 const getroffen = anlagenzitate.filter(
201 (f) => auszugBilden(NORMTEXTE, f).art === 'block',
202 ).length;
203
204 expect(anlagenzitate.length).toBeGreaterThan(600);
205 expect(getroffen / anlagenzitate.length).toBeGreaterThan(0.9);
206 });
207
208 it('liefert zu jeder Fundstelle etwas Anzeigbares', () => {
209 const leer = ALLE_FUNDSTELLEN.filter((f) => auszugBilden(NORMTEXTE, f).art === 'fehlt');
210
211 expect(leer).toEqual([]);
212 });
213
214 it('steht auf demselben Gesetzesstand wie Erklärungen und Glossar', () => {
215 /* Die eine Falle des Bauwegs: Nach einer Gesetzesänderung wird der Index
216 neu erzeugt und die Fundstellen werden gegen ihn geprüft – wer dann
217 vergisst, `normtexte_bauen.py` laufen zu lassen, liefert den Wortlaut
218 des alten Standes zu Fundstellen des neuen aus. Von außen sähe das
219 tadellos aus: Jede Norm da, jeder Absatz da, nur eben von gestern. */
220 expect(NORMTEXTE.meta.gesetzesstand).toEqual(ERKLAERUNGEN.meta.gesetzesstand);
221 expect(NORMTEXTE.meta.gesetzesstand).toEqual(GLOSSAR.meta.gesetzesstand);
222 });
223
224 it('nennt zu jedem Gesetz den Änderungsstand', () => {
225 // Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute,
226 // die morgen falsch sein kann.
227 for (const [kuerzel, gesetz] of Object.entries(NORMTEXTE.gesetze)) {
228 expect(gesetz.stand.length, kuerzel).toBeGreaterThan(0);
229 expect(NORMTEXTE.meta.gesetzesstand[kuerzel]).toBe(gesetz.stand);
230 }
231 });
232
233 it('hält den Wortlaut der Anlagen verlustfrei', () => {
234 /* Die Pipeline rechnet das beim Bauen nach; hier steht die Zusage noch
235 einmal am ausgelieferten Bestand. Ein beschnittenes Gesetzeszitat wäre
236 der schlimmste Fehler, den diese Anwendung machen kann – deshalb wird
237 nirgends geschnitten, nur zerlegt. */
238 for (const gesetz of Object.values(NORMTEXTE.gesetze)) {
239 for (const [name, norm] of Object.entries(gesetz.normen)) {
240 if (!norm.istAnlage) {
241 continue;
242 }
243 const bloecke = norm.bloecke ?? [];
244 expect(bloecke.length, name).toBeGreaterThan(0);
245 // Jeder Block beginnt mit seiner eigenen Marke – die Marke bleibt im
246 // Text stehen, sie wird nicht in ein Feld herausgezogen.
247 for (const block of bloecke) {
248 if (block.marke !== null) {
249 expect(block.text.trimStart().startsWith(block.marke), `${name} ${block.marke}`).toBe(
250 true,
251 );
252 }
253 }
254 }
255 }
256 });
257 });