/** * Die mitgelieferten Normtexte. * * Zwei Sorten Prüfung: die Auflösungsregel an gebauten Fällen – dort lässt * sich die Mehrdeutigkeit gezielt herstellen – und der **ganze ausgelieferte * Bestand** gegen alle 2123 Fundstellen der Erklärungen und des Glossars. * Letzteres ist die eigentliche Zusage: Wer „Im Gesetz nachlesen“ liest, soll * etwas vorfinden. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import type { Erklaerungen, Fundstelle } from '../src/shared/erklaerungen'; import type { Glossar } from '../src/shared/glossar'; import { auszugBilden, blockFinden, NORMTEXTE_LEER, zeigtAufAbbildung, type Anlagenblock, type Normtexte, } from '../src/shared/normtexte'; const WURZEL = join(process.cwd(), '..'); function inhalt(datei: string): unknown { return JSON.parse(readFileSync(join(WURZEL, 'content', datei), 'utf8')); } /* Gelesen wird der ausgelieferte Bestand selbst, nicht eine Attrappe: Die Zusage lautet, dass jede der 2123 Fundstellen etwas vorfindet – und das lässt sich nur an der echten Datei prüfen. */ const NORMTEXTE = inhalt('normtexte.json') as Normtexte; const ERKLAERUNGEN = inhalt('erklaerungen.json') as Erklaerungen; const GLOSSAR = inhalt('glossar.json') as Glossar; const ALLE_FUNDSTELLEN: readonly Fundstelle[] = [ ...Object.values(ERKLAERUNGEN.zuFrage).flatMap((eintrag) => eintrag.fundstellen), ...GLOSSAR.eintraege.flatMap((eintrag) => eintrag.fundstellen), ]; function block(marke: string | null, pfad: string[], text = 'Text'): Anlagenblock { return { marke, pfad, text }; } describe('blockFinden', () => { const bloecke: Anlagenblock[] = [ block(null, []), block('Abschnitt 1', []), block('Unterabschnitt 1', ['Abschnitt 1']), block('1.1', ['Abschnitt 1', 'Unterabschnitt 1'], 'Schusswaffen sind …'), block('1.3.1.3', ['Abschnitt 1', 'Unterabschnitt 1'], 'Tiefe Nummer'), block('Unterabschnitt 3', ['Abschnitt 1']), block('1.1', ['Abschnitt 1', 'Unterabschnitt 3'], 'Etwas ganz anderes'), block('Abschnitt 2', []), block('1.1', ['Abschnitt 2'], 'Und noch etwas anderes'), ]; it('trifft die Nummer im richtigen Unterabschnitt', () => { /* Der Kern der Sache: „1.1“ kommt hier dreimal vor, in Anlage 1 des WaffG sogar viermal. Ohne den Pfad spränge die Fundstelle unbemerkt an die falsche Stelle des Gesetzes. */ expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 1 Nr. 1.1')?.text).toBe( 'Schusswaffen sind …', ); expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 3 Nr. 1.1')?.text).toBe( 'Etwas ganz anderes', ); expect(blockFinden(bloecke, 'Abschnitt 2 Nr. 1.1')?.text).toBe('Und noch etwas anderes'); }); it('nimmt die tiefste genannte Nummer', () => { expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 1 Nr. 1.3.1.3')?.marke).toBe('1.3.1.3'); }); it('findet einen Abschnitt auch ohne Nummer', () => { expect(blockFinden(bloecke, 'Abschnitt 2')?.marke).toBe('Abschnitt 2'); expect(blockFinden(bloecke, 'Abschnitt 1 Unterabschnitt 3')?.marke).toBe('Unterabschnitt 3'); }); it('gibt bei Mehrdeutigkeit lieber nichts zurück', () => { // „Nr. 1.1“ allein passt auf drei Blöcke. Einen davon zu wählen hieße, // zwei Drittel der Leser an die falsche Stelle zu schicken. expect(blockFinden(bloecke, 'Nr. 1.1')).toBeNull(); }); it('gibt bei fehlendem Treffer nichts zurück', () => { expect(blockFinden(bloecke, 'Abschnitt 9 Nr. 4.2')).toBeNull(); expect(blockFinden(bloecke, 'Abbildung 5b')).toBeNull(); expect(blockFinden(bloecke, '')).toBeNull(); }); }); describe('zeigtAufAbbildung', () => { it('erkennt die Abbildungen der Anlage II', () => { expect(zeigtAufAbbildung('Abbildung 5b')).toBe(true); expect(zeigtAufAbbildung('Abbildung 3 – Ortszeichen der zuständigen Behörden')).toBe(true); }); it('hält gewöhnliche Stellen auseinander', () => { expect(zeigtAufAbbildung('Abschnitt 1 Unterabschnitt 1 Nr. 1.1')).toBe(false); }); }); describe('auszugBilden', () => { it('liefert den zitierten Absatz', () => { const auszug = auszugBilden(NORMTEXTE, { gesetz: 'WaffG', norm: '§ 1', absatz: '1' }); expect(auszug.art).toBe('absatz'); expect(auszug.art === 'absatz' ? auszug.text : '').toContain('Umgang mit Waffen oder Munition'); }); it('setzt eine Norm ohne Absatzangabe aus ihren Absätzen zusammen', () => { const auszug = auszugBilden(NORMTEXTE, { gesetz: 'WaffG', norm: '§ 1' }); expect(auszug.art).toBe('norm'); expect(auszug.art === 'norm' ? auszug.text : '').toContain('(1)'); }); it('meldet ehrlich, wenn nichts vorliegt', () => { expect(auszugBilden(NORMTEXTE_LEER, { gesetz: 'WaffG', norm: '§ 1' }).art).toBe('fehlt'); expect(auszugBilden(NORMTEXTE, { gesetz: 'StGB', norm: '§ 173' }).art).toBe('fehlt'); }); it('zeigt bei einer Anlage den getroffenen Block', () => { const auszug = auszugBilden(NORMTEXTE, { gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 1 Unterabschnitt 1 Nr. 1.1', }); expect(auszug.art).toBe('block'); expect(auszug.art === 'block' ? auszug.block.text : '').toContain('Schusswaffen sind'); }); it('zeigt die ganze Anlage, wenn die Stelle eine Abbildung ist', () => { const auszug = auszugBilden(NORMTEXTE, { gesetz: 'BeschussV', norm: 'Anlage II', stelle: 'Abbildung 5b', }); expect(auszug.art).toBe('anlage'); expect(auszug.art === 'anlage' ? auszug.abbildung : false).toBe(true); expect(auszug.art === 'anlage' ? auszug.bloecke.length : 0).toBeGreaterThan(0); }); }); describe('Der Gesetzesabzug sagt, wann er gezogen wurde', () => { /* Die Wache, die gefehlt hat. `content/gesetze/index.json` führte zu jedem Gesetz die amtliche Standangabe („Zuletzt geändert durch …“) – aber nirgends stand, wann jemand zuletzt nachgesehen hat, ob es dabei geblieben ist. Alle 1854 Fundstellen werden bei jedem Lauf gegen genau diesen Abzug geprüft; ist er veraltet, meldet `pruefe_erklaerungen.py` weiter grün, gegen eine Vorschrift, die es so nicht mehr gibt. Am 01.09.2026 lagen zwischen dem ältesten Abzug (Beschussgesetz, 09.09.2020) und dem jüngsten (Sprengstoffgesetz, 23.07.2026) fast sechs Jahre. Geprüft wird hier ausdrücklich **nicht** das Alter. Ob eine Vorschrift sich geändert hat, kann nur ein Mensch nachsehen, und eine Prüfung, die allein durch Zeitablauf rot wird, meldet an ihrem Tag keinen Befund, sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts` schon einmal zugeschlagen hat. Geprüft wird, dass die Angabe **da** ist; hinsehen muss `docs/veroeffentlichen.md`. */ const index = JSON.parse( readFileSync(join(WURZEL, 'content', 'gesetze', 'index.json'), 'utf8'), ) as { gesetze: Record }; it('trägt zu jedem Gesetz ein Abzugsdatum', () => { const ohne = Object.entries(index.gesetze) .filter(([, gesetz]) => gesetz.abzug === undefined) .map(([kuerzel]) => kuerzel); expect(ohne, 'Neu erzeugen mit: python data-pipeline/gesetze_index.py').toEqual([]); }); it('schreibt das Abzugsdatum als ISO-Datum', () => { for (const [kuerzel, gesetz] of Object.entries(index.gesetze)) { expect(gesetz.abzug, `Abzugsdatum von ${kuerzel}`).toMatch(/^\d{4}-\d{2}-\d{2}$/u); } }); it('trägt zu jedem Gesetz auch die amtliche Standangabe', () => { /* Beides zusammen ergibt erst die Auskunft: Der Abzug sagt, wann nachgesehen wurde, die Standangabe, was dabei zu sehen war. */ for (const [kuerzel, gesetz] of Object.entries(index.gesetze)) { expect((gesetz.stand ?? '').length, `Standangabe von ${kuerzel}`).toBeGreaterThan(10); } }); it('wird von der Veröffentlichungsliste tatsächlich angesehen', () => { /* Ohne diesen Fall wäre die Angabe da und niemand sähe sie an. Der Schritt in der Liste ist der einzige Ort, an dem aus dem Datum eine Handlung wird. */ const liste = readFileSync(join(WURZEL, 'docs', 'veroeffentlichen.md'), 'utf8'); expect(liste).toContain('Gesetzesstand ansehen'); expect(liste).toContain('gesetze_index.py'); }); }); describe('Der ausgelieferte Bestand', () => { it('enthält nur die zitierten Normen', () => { /* Der Index führt die sieben Gesetze vollständig – 914 Normen, darunter katalogfremde wie § 173 StGB. Ausgeliefert wird der Auszug. */ const zitiert = new Set(ALLE_FUNDSTELLEN.map((f) => `${f.gesetz}|${f.norm}`)); const geliefert = Object.entries(NORMTEXTE.gesetze).flatMap(([kuerzel, gesetz]) => Object.keys(gesetz.normen).map((norm) => `${kuerzel}|${norm}`), ); expect(geliefert.length).toBeGreaterThan(0); for (const norm of geliefert) { expect(zitiert.has(norm)).toBe(true); } }); it('kennt jede Norm, auf die eine Fundstelle zeigt', () => { const fehlend = ALLE_FUNDSTELLEN.filter( (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm] === undefined, ).map((f) => `${f.gesetz} ${f.norm}`); expect([...new Set(fehlend)]).toEqual([]); }); it('kennt jeden zitierten Absatz', () => { /* Die eigentliche Zusage: 1501 Paragrafenzitate, und keines davon läuft ins Leere. Ohne diesen Test bliebe eine Lücke unbemerkt, bis sie jemandem beim Lernen begegnet. */ const fehlend = ALLE_FUNDSTELLEN.filter((f) => { const norm = NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm]; return ( norm !== undefined && !norm.istAnlage && f.absatz !== undefined && norm.absaetze?.[f.absatz] === undefined ); }).map((f) => `${f.gesetz} ${f.norm} Abs. ${f.absatz ?? ''}`); expect([...new Set(fehlend)]).toEqual([]); }); it('löst mindestens neun von zehn Anlagenzitaten eindeutig auf', () => { /* Kein Anspruch auf hundert Prozent, und das mit Grund: Die Abbildungen der Anlage II BeschussV stehen im amtlichen XML nur als Bildunterschrift. Was nicht auflöst, zeigt die ganze Anlage und sagt, warum – falsch wird dabei nichts. */ const anlagenzitate = ALLE_FUNDSTELLEN.filter( (f) => NORMTEXTE.gesetze[f.gesetz]?.normen[f.norm]?.istAnlage === true, ); const getroffen = anlagenzitate.filter( (f) => auszugBilden(NORMTEXTE, f).art === 'block', ).length; expect(anlagenzitate.length).toBeGreaterThan(600); expect(getroffen / anlagenzitate.length).toBeGreaterThan(0.9); }); it('liefert zu jeder Fundstelle etwas Anzeigbares', () => { const leer = ALLE_FUNDSTELLEN.filter((f) => auszugBilden(NORMTEXTE, f).art === 'fehlt'); expect(leer).toEqual([]); }); it('steht auf demselben Gesetzesstand wie Erklärungen und Glossar', () => { /* Die eine Falle des Bauwegs: Nach einer Gesetzesänderung wird der Index neu erzeugt und die Fundstellen werden gegen ihn geprüft – wer dann vergisst, `normtexte_bauen.py` laufen zu lassen, liefert den Wortlaut des alten Standes zu Fundstellen des neuen aus. Von außen sähe das tadellos aus: Jede Norm da, jeder Absatz da, nur eben von gestern. */ expect(NORMTEXTE.meta.gesetzesstand).toEqual(ERKLAERUNGEN.meta.gesetzesstand); expect(NORMTEXTE.meta.gesetzesstand).toEqual(GLOSSAR.meta.gesetzesstand); }); it('nennt zu jedem Gesetz den Änderungsstand', () => { // Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute, // die morgen falsch sein kann. for (const [kuerzel, gesetz] of Object.entries(NORMTEXTE.gesetze)) { expect(gesetz.stand.length, kuerzel).toBeGreaterThan(0); expect(NORMTEXTE.meta.gesetzesstand[kuerzel]).toBe(gesetz.stand); } }); it('hält den Wortlaut der Anlagen verlustfrei', () => { /* Die Pipeline rechnet das beim Bauen nach; hier steht die Zusage noch einmal am ausgelieferten Bestand. Ein beschnittenes Gesetzeszitat wäre der schlimmste Fehler, den diese Anwendung machen kann – deshalb wird nirgends geschnitten, nur zerlegt. */ for (const gesetz of Object.values(NORMTEXTE.gesetze)) { for (const [name, norm] of Object.entries(gesetz.normen)) { if (!norm.istAnlage) { continue; } const bloecke = norm.bloecke ?? []; expect(bloecke.length, name).toBeGreaterThan(0); // Jeder Block beginnt mit seiner eigenen Marke – die Marke bleibt im // Text stehen, sie wird nicht in ein Feld herausgezogen. for (const block of bloecke) { if (block.marke !== null) { expect(block.text.trimStart().startsWith(block.marke), `${name} ${block.marke}`).toBe( true, ); } } } } }); });