waffensachkunde

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

/ app tests inhalte-laden.test.ts

6,9 KB Rohdatei
app/tests/inhalte-laden.test.ts — 193 Zeilen
1 // @vitest-environment node
2 /**
3 * Der echte Inhalt durch den echten Lader.
4 *
5 * Zweimal ist derselbe Fehler passiert: Die Vorprüfung in `data-pipeline/`
6 * war laxer als der Lader in `src/main/`, der Inhalt kam durch die Prüfung,
7 * und die **gepackte** Anwendung startete mit einem leeren Bestand – obwohl
8 * die Datei vollständig im Paket lag. Beim ersten Mal waren es 450 Angaben
9 * als Zahl statt als Zeichenkette, beim zweiten Mal neun Glossareinträge ohne
10 * Wortform.
11 *
12 * Aufgefallen ist beides erst im Rauchtest gegen das gepackte Programm – also
13 * spät und nur, weil es diesen Test gibt. Hier fällt es früher auf: Diese
14 * Datei schickt die ausgelieferten Inhalte durch dieselben Prüfungen, die der
15 * Hauptprozess anwendet, und zwar bei jedem `npm test`.
16 *
17 * Die Ladefunktionen selbst brauchen Electron (für den Pfad). Geprüft wird
18 * deshalb die reine Validierung – genau der Teil, der bisher versagt hat.
19 */
20
21 import { readFileSync } from 'node:fs';
22 import { join } from 'node:path';
23 import { fileURLToPath } from 'node:url';
24
25 import { describe, expect, it } from 'vitest';
26
27 import { GESETZE } from '../src/shared/erklaerungen';
28 import { begriffssucheBauen } from '../src/shared/glossar';
29
30 const wurzel = fileURLToPath(new URL('..', import.meta.url));
31
32 function inhalt(name: string): unknown {
33 return JSON.parse(readFileSync(join(wurzel, '..', 'content', name), 'utf8'));
34 }
35
36 /**
37 * Die Prüfregeln der Lader, ohne deren Electron-Abhängigkeit.
38 *
39 * Die **Muster** sind bewusst eine eigene, knappe Nachbildung: Sie stehen in
40 * `main/erklaerungen.ts` und `main/glossar.ts`, und die importieren `electron`,
41 * das hier nicht läuft. Laufen sie auseinander, ist das genau der Befund, den
42 * dieser Test sucht.
43 *
44 * Die **Gesetzesliste** dagegen wird eingelesen und nicht nachgebaut. Sie ist
45 * kein Muster, sondern eine Aufzählung, deren Nachbildung bis Fassung 0.24.1
46 * hier zeichengleich ein zweites Mal stand: Ein achtes Gesetz in
47 * `shared/erklaerungen.ts` hätte diesen Test dann rot gemacht, obwohl nichts
48 * falsch wäre – und der bequemste Weg zu Grün wäre gewesen, die Liste hier
49 * nachzuziehen, statt zu prüfen. `shared/erklaerungen.ts` ist frei von
50 * Electron und lässt sich anstandslos einlesen.
51 */
52 const NORM_MUSTER = /^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$/u;
53 const GLIEDERUNG_MUSTER = /^[0-9]{1,3}[a-z]?$/u;
54 const BUCHSTABE_MUSTER = /^[a-z]{1,2}$/u;
55
56 interface RohFundstelle {
57 readonly gesetz?: unknown;
58 readonly norm?: unknown;
59 readonly absatz?: unknown;
60 readonly nummer?: unknown;
61 readonly buchstabe?: unknown;
62 readonly satz?: unknown;
63 readonly stelle?: unknown;
64 }
65
66 /** Alle Verstöße einer Fundstelle gegen die Laderegeln. */
67 function fundstellenfehler(f: RohFundstelle, wo: string): string[] {
68 const fehler: string[] = [];
69
70 for (const [feld, wert] of Object.entries(f)) {
71 if (wert !== undefined && typeof wert !== 'string') {
72 fehler.push(`${wo}.${feld} ist ${typeof wert}, muss Zeichenkette sein`);
73 }
74 }
75 if (typeof f.gesetz !== 'string' || !(GESETZE as readonly string[]).includes(f.gesetz)) {
76 fehler.push(`${wo}.gesetz unbekannt: ${String(f.gesetz)}`);
77 }
78 if (typeof f.norm !== 'string' || !NORM_MUSTER.test(f.norm)) {
79 fehler.push(`${wo}.norm ungültig: ${String(f.norm)}`);
80 }
81 for (const feld of ['absatz', 'nummer', 'satz'] as const) {
82 const wert = f[feld];
83 if (typeof wert === 'string' && !GLIEDERUNG_MUSTER.test(wert)) {
84 fehler.push(`${wo}.${feld} ungültig: ${wert}`);
85 }
86 }
87 if (typeof f.buchstabe === 'string' && !BUCHSTABE_MUSTER.test(f.buchstabe)) {
88 fehler.push(`${wo}.buchstabe ungültig: ${f.buchstabe}`);
89 }
90 return fehler;
91 }
92
93 describe('Ausgelieferte Erklärungen', () => {
94 /* Die Felder stehen hier als optional, weil geparstes JSON nichts
95 garantiert – genau das prüft diese Datei ja. Ein Typ, der mehr
96 behauptet, würde die Prüfung wegoptimieren. */
97 const daten = inhalt('erklaerungen.json') as {
98 meta: { version?: number };
99 zuFrage: Record<
100 string,
101 {
102 kurz?: string;
103 text?: string;
104 fundstellen?: RohFundstelle[];
105 ohneFundstelleGrund?: string;
106 }
107 >;
108 };
109
110 it('hat einen brauchbaren Kopf', () => {
111 expect(Number.isInteger(daten.meta.version)).toBe(true);
112 expect(daten.meta.version ?? 0).toBeGreaterThanOrEqual(1);
113 });
114
115 it('erfüllt in jeder Fundstelle die Regeln des Laders', () => {
116 const fehler = Object.entries(daten.zuFrage).flatMap(([id, e]) =>
117 (e.fundstellen ?? []).flatMap((f, i) =>
118 fundstellenfehler(f, `${id}.fundstellen[${String(i)}]`),
119 ),
120 );
121
122 expect(fehler, 'Der Lader würde den gesamten Bestand verwerfen.').toEqual([]);
123 });
124
125 it('begründet jede fehlende Fundstelle', () => {
126 const ohne = Object.entries(daten.zuFrage)
127 .filter(([, e]) => (e.fundstellen ?? []).length === 0 && !e.ohneFundstelleGrund)
128 .map(([id]) => id);
129
130 expect(ohne).toEqual([]);
131 });
132 });
133
134 describe('Ausgeliefertes Glossar', () => {
135 const daten = inhalt('glossar.json') as {
136 meta: { version?: number };
137 eintraege: {
138 begriff: string;
139 art: string;
140 kurz: string;
141 varianten?: string[];
142 fundstellen?: RohFundstelle[];
143 ohneFundstelleGrund?: string;
144 siehe?: string[];
145 }[];
146 };
147
148 it('erfüllt in jeder Fundstelle die Regeln des Laders', () => {
149 const fehler = daten.eintraege.flatMap((e) =>
150 (e.fundstellen ?? []).flatMap((f, i) =>
151 fundstellenfehler(f, `${e.begriff}.fundstellen[${String(i)}]`),
152 ),
153 );
154
155 expect(fehler, 'Der Lader würde das gesamte Glossar verwerfen.').toEqual([]);
156 });
157
158 it('nennt zu jedem Eintrag Art, Begriff und Bedeutung', () => {
159 for (const eintrag of daten.eintraege) {
160 expect(['begriff', 'abkuerzung']).toContain(eintrag.art);
161 expect(eintrag.begriff.trim().length).toBeGreaterThan(1);
162 expect(eintrag.kurz.trim().length).toBeGreaterThan(14);
163 expect(Array.isArray(eintrag.varianten)).toBe(true);
164 }
165 });
166
167 it('führt keinen Begriff doppelt', () => {
168 const begriffe = daten.eintraege.map((e) => e.begriff);
169
170 expect(begriffe).toHaveLength(new Set(begriffe).size);
171 });
172
173 it('lässt jeden Querverweis auf einen vorhandenen Eintrag zeigen', () => {
174 const bekannt = new Set(daten.eintraege.map((e) => e.begriff));
175 const offen = daten.eintraege.flatMap((e) =>
176 (e.siehe ?? []).filter((v) => !bekannt.has(v)).map((v) => `${e.begriff} → ${v}`),
177 );
178
179 expect(offen).toEqual([]);
180 });
181
182 it('findet mit den hinterlegten Wortformen tatsächlich Begriffe', () => {
183 /* Der Sinn des Glossars an der Frage. Fände die Suche nichts, wäre die
184 ganze Zuordnung tot – und das fiele in keinem anderen Test auf. */
185 const suche = begriffssucheBauen(daten as never);
186 const gefunden = suche.imText(
187 'Wer eine Schusswaffe außerhalb des befriedeten Besitztums führt, braucht einen Waffenschein.',
188 );
189
190 expect(gefunden.map((e) => e.begriff)).toContain('Führen');
191 expect(gefunden.length).toBeGreaterThan(2);
192 });
193 });