waffensachkunde

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

/ app tests normtexte.test.ts

22,2 KB Rohdatei
app/tests/normtexte.test.ts — 542 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 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 });