waffensachkunde

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

/ app tests katalog.test.ts

14,7 KB Rohdatei
app/tests/katalog.test.ts — 424 Zeilen
1 // @vitest-environment node
2 // Der Katalog wird im Main-Prozess von der Platte gelesen – jsdom hätte hier
3 // nichts zu tun und würde nur Dateizugriffe verkomplizieren.
4
5 import { join } from 'node:path';
6
7 import { beforeEach, describe, expect, it, vi } from 'vitest';
8
9 import type { Katalog } from '../src/shared/katalog';
10
11 /**
12 * `app.getAppPath()` liefert im Entwicklungsmodus das Verzeichnis mit der
13 * package.json – unter Vitest ist das genau das Arbeitsverzeichnis.
14 */
15 vi.mock('electron', () => ({
16 app: {
17 isPackaged: false,
18 getAppPath: (): string => process.cwd(),
19 },
20 }));
21
22 const {
23 bildAufloesen,
24 katalogBild,
25 katalogLaden,
26 katalogValidieren,
27 katalogVerzeichnis,
28 katalogZuruecksetzen,
29 } = await import('../src/main/katalog');
30
31 // ─── Prüfmuster ─────────────────────────────────────────────────────────────
32
33 /** Kleiner, gültiger Katalog. Die Tests verbiegen jeweils genau ein Feld. */
34 function musterkatalog(): Record<string, unknown> {
35 return {
36 meta: {
37 titel: 'Prüfkatalog',
38 herausgeber: 'Bundesverwaltungsamt',
39 stand: '2024-12-16',
40 quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.',
41 quelle_url: 'https://www.bva.bund.de/',
42 quelldatei_sha256: 'a'.repeat(64),
43 fragen_gesamt: 2,
44 },
45 kapitel: [
46 {
47 id: 'I',
48 titel: 'Waffenrecht',
49 abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }],
50 },
51 { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
52 ],
53 bilder: [
54 {
55 id: 'zeichen-1',
56 datei: 'zeichen-1.png',
57 breite: 73,
58 hoehe: 73,
59 alt: 'Rundes Zeichen',
60 beschreibung: 'Zulassungszeichen für bauartgeprüfte Waffen.',
61 },
62 ],
63 fragen: [
64 {
65 id: 'I.1-01',
66 amtliche_nummer: '1.01',
67 kapitel: 'I',
68 abschnitt: 'I.1',
69 typ: 'mc',
70 seite: 6,
71 frage: { text: 'Umgang mit einer Schusswaffe hat…', segmente: [{ t: 'Umgang…' }] },
72 bilder: [],
73 optionen: [
74 { label: 'a', inhalt: { text: 'wer schießt.', segmente: [] }, korrekt: true, bilder: [] },
75 { label: 'b', inhalt: { text: 'niemand.', segmente: [] }, korrekt: false, bilder: [] },
76 ],
77 },
78 {
79 id: 'II-01',
80 amtliche_nummer: '2.01',
81 kapitel: 'II',
82 abschnitt: null,
83 typ: 'freitext',
84 seite: 40,
85 frage: { text: 'Welches Zeichen ist abgebildet?', segmente: [{ t: 'Welches Zeichen?' }] },
86 bilder: ['zeichen-1'],
87 musterantwort: {
88 text: 'Das PTB-Zeichen.',
89 segmente: [{ t: 'Das ' }, { t: 'PTB-Zeichen', h: true }, { t: '.' }],
90 },
91 },
92 ],
93 };
94 }
95
96 /** Baut einen Musterkatalog und lässt eine Änderung darauf laufen. */
97 function verbogen(aendern: (katalog: Record<string, unknown>) => void): Record<string, unknown> {
98 const katalog = musterkatalog();
99 aendern(katalog);
100 return katalog;
101 }
102
103 function fragen(katalog: Record<string, unknown>): Record<string, unknown>[] {
104 return katalog['fragen'] as Record<string, unknown>[];
105 }
106
107 function meta(katalog: Record<string, unknown>): Record<string, unknown> {
108 return katalog['meta'] as Record<string, unknown>;
109 }
110
111 beforeEach(() => {
112 katalogZuruecksetzen();
113 });
114
115 // ─── Validierung ────────────────────────────────────────────────────────────
116
117 describe('katalogValidieren', () => {
118 it('nimmt einen vollständigen Katalog an', () => {
119 const katalog = katalogValidieren(musterkatalog());
120
121 expect(katalog.fragen).toHaveLength(2);
122 expect(katalog.meta.fragen_gesamt).toBe(2);
123 expect(katalog.fragen[0]?.optionen).toHaveLength(2);
124 expect(katalog.fragen[1]?.musterantwort?.segmente[1]).toEqual({ t: 'PTB-Zeichen', h: true });
125 });
126
127 it('weist eine abweichende Kopfzahl zurück und nennt beide Zahlen', () => {
128 const roh = verbogen((k) => {
129 meta(k)['fragen_gesamt'] = 575;
130 });
131
132 expect(() => katalogValidieren(roh)).toThrow(/meta\.fragen_gesamt meldet 575/u);
133 expect(() => katalogValidieren(roh)).toThrow(/enthalten sind aber 2/u);
134 });
135
136 it.each([
137 ['titel', 'meta.titel'],
138 ['quellenangabe', 'meta.quellenangabe'],
139 ['quelldatei_sha256', 'meta.quelldatei_sha256'],
140 ])('vermisst das Pflichtfeld meta.%s', (feld, pfad) => {
141 const roh = verbogen((k) => {
142 // `undefined` und „nicht vorhanden“ müssen gleich behandelt werden.
143 meta(k)[feld] = undefined;
144 });
145
146 expect(() => katalogValidieren(roh)).toThrow(
147 new RegExp(`${pfad.replace('.', '\\.')} fehlt`, 'u'),
148 );
149 });
150
151 /*
152 Zweistufiges Bildkonzept (Prüfplan S4): `alt` bleibt neutral, die
153 erklärende `beschreibung` wird nach dem Antworten nachgereicht. Beide
154 Felder dürfen fehlen – ein Katalog ohne Beschreibungen bleibt gültig,
155 die Oberfläche lässt den Aufklapper dann einfach weg.
156 */
157 it('reicht die erklärende Bildbeschreibung durch', () => {
158 const katalog = katalogValidieren(musterkatalog());
159
160 expect(katalog.bilder[0]?.beschreibung).toBe('Zulassungszeichen für bauartgeprüfte Waffen.');
161 });
162
163 it('nimmt ein Bild ohne Beschreibung an und liefert null', () => {
164 const roh = verbogen((k) => {
165 delete (k['bilder'] as Record<string, unknown>[])[0]!['beschreibung'];
166 });
167
168 expect(katalogValidieren(roh).bilder[0]?.beschreibung).toBeNull();
169 });
170
171 it('weist eine Beschreibung zurück, die keine Zeichenkette ist', () => {
172 const roh = verbogen((k) => {
173 (k['bilder'] as Record<string, unknown>[])[0]!['beschreibung'] = 42;
174 });
175
176 expect(() => katalogValidieren(roh)).toThrow(
177 /bilder\[0\]\.beschreibung ist weder Zeichenkette noch null/u,
178 );
179 });
180
181 it('weist eine leere Zeichenkette wie ein fehlendes Feld zurück', () => {
182 const roh = verbogen((k) => {
183 meta(k)['herausgeber'] = ' ';
184 });
185
186 expect(() => katalogValidieren(roh)).toThrow(/meta\.herausgeber fehlt/u);
187 });
188
189 it('weist einen unbekannten Fragetyp zurück', () => {
190 const roh = verbogen((k) => {
191 fragen(k)[0]!['typ'] = 'ankreuzen';
192 });
193
194 expect(() => katalogValidieren(roh)).toThrow(/typ ist unbekannt: „ankreuzen“/u);
195 });
196
197 it('weist eine Multiple-Choice-Frage ohne richtige Option zurück', () => {
198 const roh = verbogen((k) => {
199 const optionen = fragen(k)[0]!['optionen'] as Record<string, unknown>[];
200 for (const option of optionen) {
201 option['korrekt'] = false;
202 }
203 });
204
205 expect(() => katalogValidieren(roh)).toThrow(/keine richtige Antwortoption/u);
206 });
207
208 it('weist doppelte Frage-IDs zurück', () => {
209 const roh = verbogen((k) => {
210 fragen(k)[1]!['id'] = 'I.1-01';
211 });
212
213 expect(() => katalogValidieren(roh)).toThrow(/ID „I\.1-01“ mehrfach/u);
214 });
215
216 it('weist einen Verweis auf ein unbekanntes Kapitel zurück', () => {
217 const roh = verbogen((k) => {
218 fragen(k)[0]!['kapitel'] = 'IX';
219 });
220
221 expect(() => katalogValidieren(roh)).toThrow(/unbekannte Kapitel „IX“/u);
222 });
223
224 it('weist einen Verweis auf ein unbekanntes Bild zurück', () => {
225 const roh = verbogen((k) => {
226 fragen(k)[1]!['bilder'] = ['zeichen-erfunden'];
227 });
228
229 expect(() => katalogValidieren(roh)).toThrow(/unbekannte Bild „zeichen-erfunden“/u);
230 });
231
232 it.each([
233 ['../../etc/passwd.png'],
234 ['assets/zeichen-1.png'],
235 ['..\\zeichen-1.png'],
236 ['zeichen-1.exe'],
237 ])('weist den Dateinamen %s zurück', (datei) => {
238 const roh = verbogen((k) => {
239 (k['bilder'] as Record<string, unknown>[])[0]!['datei'] = datei;
240 });
241
242 expect(() => katalogValidieren(roh)).toThrow(/kein einfacher PNG-Dateiname/u);
243 });
244
245 it('weist eine Nutzlast zurück, die gar kein Objekt ist', () => {
246 expect(() => katalogValidieren('katalog')).toThrow(/Katalog ist kein Objekt/u);
247 expect(() => katalogValidieren(null)).toThrow(/Katalog ist kein Objekt/u);
248 expect(() => katalogValidieren([])).toThrow(/Katalog ist kein Objekt/u);
249 });
250 });
251
252 // ─── Bild-IDs ───────────────────────────────────────────────────────────────
253
254 describe('bildAufloesen', () => {
255 let katalog: Katalog;
256
257 beforeEach(() => {
258 katalog = katalogValidieren(musterkatalog());
259 });
260
261 it('findet ein bekanntes Prüfzeichen', () => {
262 expect(bildAufloesen(katalog, 'zeichen-1').datei).toBe('zeichen-1.png');
263 });
264
265 it.each([['zeichen-2'], ['ZEICHEN-1'], ['zeichen-1.png'], ['zeichen-1 '], ['']])(
266 'weist die unbekannte Bild-ID „%s“ zurück',
267 (bildId) => {
268 expect(() => bildAufloesen(katalog, bildId)).toThrow(/Bild-ID/u);
269 },
270 );
271
272 it.each([
273 ['../assets/zeichen-1.png'],
274 ['../../../../windows/win.ini'],
275 ['..\\..\\zeichen-1.png'],
276 ['/etc/passwd'],
277 ['C:\\Windows\\System32\\config\\SAM'],
278 ['assets/../assets/zeichen-1.png'],
279 ['%2e%2e%2fzeichen-1.png'],
280 ])('weist die pfadartige Bild-ID „%s“ zurück', (bildId) => {
281 expect(() => bildAufloesen(katalog, bildId)).toThrow(/Unbekannte Bild-ID/u);
282 });
283
284 it.each([[null], [undefined], [42], [{ id: 'zeichen-1' }], [['zeichen-1']]])(
285 'weist die Bild-ID %o zurück, weil sie keine Zeichenkette ist',
286 (bildId) => {
287 expect(() => bildAufloesen(katalog, bildId)).toThrow(/Ungültige Bild-ID/u);
288 },
289 );
290
291 it('nimmt Steuerzeichen nicht in die Fehlermeldung auf', () => {
292 // Nullbyte und ANSI-Escape: beides darf nicht unverändert in einer
293 // Meldung oder Protokollzeile landen.
294 const nullbyte = String.fromCharCode(0);
295 const escape = String.fromCharCode(27);
296 const bildId = `zeichen${nullbyte}-${escape}[31mrot`;
297
298 let meldung = '';
299 try {
300 bildAufloesen(katalog, bildId);
301 } catch (fehler) {
302 meldung = fehler instanceof Error ? fehler.message : String(fehler);
303 }
304
305 expect(meldung).toContain('Unbekannte Bild-ID');
306 expect(meldung).toContain('zeichen?-?[31mrot');
307 expect(meldung).not.toContain(nullbyte);
308 expect(meldung).not.toContain(escape);
309 });
310 });
311
312 // ─── Auflösung des Ablageortes ──────────────────────────────────────────────
313
314 describe('katalogVerzeichnis', () => {
315 it('zeigt in der Entwicklung auf content/katalog neben dem app-Verzeichnis', () => {
316 expect(katalogVerzeichnis()).toBe(join(process.cwd(), '..', 'content', 'katalog'));
317 });
318
319 it('zeigt im gepackten Build auf resources/katalog', async () => {
320 const vorher = process.resourcesPath;
321 Object.defineProperty(process, 'resourcesPath', {
322 value: join('C:', 'Programme', 'Waffensachkunde', 'resources'),
323 configurable: true,
324 });
325 vi.resetModules();
326 vi.doMock('electron', () => ({
327 app: { isPackaged: true, getAppPath: (): string => process.cwd() },
328 }));
329
330 try {
331 const modul = await import('../src/main/katalog');
332 expect(modul.katalogVerzeichnis()).toBe(join(process.resourcesPath, 'katalog'));
333 } finally {
334 Object.defineProperty(process, 'resourcesPath', { value: vorher, configurable: true });
335 vi.doUnmock('electron');
336 vi.resetModules();
337 }
338 });
339 });
340
341 // ─── Der tatsächlich ausgelieferte Katalog ──────────────────────────────────
342
343 describe('ausgelieferter Fragenkatalog', () => {
344 it('besteht die Validierung und enthält die angekündigte Fragenzahl', () => {
345 const katalog = katalogLaden();
346
347 expect(katalog.fragen).toHaveLength(katalog.meta.fragen_gesamt);
348 expect(katalog.meta.fragen_gesamt).toBe(575);
349 expect(katalog.kapitel.map((k) => k.id)).toEqual(['I', 'II', 'III', 'IV']);
350 expect(katalog.bilder).toHaveLength(17);
351 });
352
353 /*
354 Der Prüfplan (Szenario S4) verlangt zwei Stufen je Zeichen: einen
355 neutralen Alt-Text und eine erklärende Beschreibung, die nach dem
356 Antworten nachgereicht wird. Bis Fassung 0.20.0 existierte nur die erste
357 Stufe – Sehende bekamen über die Erklärung mehr Kontext zum Zeichen als
358 Screenreader-Nutzer über das Bild. Alle 17 Zeichen sind belegt
359 (content/alttexte.json, hergeleitet aus den Erklärungstexten).
360 */
361 it('führt zu jedem Prüfzeichen beide Stufen: Alt-Text und Beschreibung', () => {
362 const { bilder } = katalogLaden();
363
364 expect(bilder.filter((b) => (b.alt ?? '').trim().length > 0)).toHaveLength(17);
365 expect(bilder.filter((b) => (b.beschreibung ?? '').trim().length > 0)).toHaveLength(17);
366 });
367
368 /*
369 Frage 3.05 fragt, welche Kennzeichen *nicht* auf einer
370 waffenbesitzkartenpflichtigen Schusswaffe zu finden sind. BKA-Raute und
371 PTB-Trapez gehören zusammen zur richtigen Antwort c). Bis Fassung 0.19.2
372 zerschnitt der Katalogeinleser das Doppelzeichen und hängte die BKA-Raute
373 an b) – die falsche Antwort. Ein Zeichen an der falschen Antwort ist in
374 einer Prüfungssoftware kein Schönheitsfehler, deshalb steht die Zuordnung
375 hier fest.
376 */
377 it('führt das Doppelzeichen der Frage 3.05 ungeteilt an der richtigen Antwort', () => {
378 const frage = katalogLaden().fragen.find((f) => f.id === 'I.3-05');
379 const optionen = frage?.optionen ?? [];
380
381 expect(optionen.map((o) => [o.label, o.korrekt, o.bilder.length])).toEqual([
382 ['a', true, 1],
383 ['b', false, 2],
384 ['c', true, 1],
385 ]);
386 expect(frage?.warnungen ?? []).toEqual([]);
387 });
388
389 it('vermerkt keine Antwortmöglichkeit ohne Text und ohne Bild', () => {
390 /* Der Einleser rät nicht mehr, sondern meldet. Bleibt hier etwas übrig,
391 ist eine Antwort im Katalog leer – und niemand sähe es sonst. */
392 const leer = katalogLaden().fragen.flatMap((f) => f.warnungen ?? []);
393
394 expect(leer).toEqual([]);
395 });
396
397 it('wird nur einmal von der Platte gelesen', () => {
398 expect(katalogLaden()).toBe(katalogLaden());
399 });
400
401 it('liefert Prüfzeichen als Data-URL mit gültiger PNG-Signatur', () => {
402 const katalog = katalogLaden();
403 const bildId = katalog.bilder[0]!.id;
404
405 const datenUrl = katalogBild(bildId);
406
407 expect(datenUrl.startsWith('data:image/png;base64,')).toBe(true);
408 const roh = Buffer.from(datenUrl.slice('data:image/png;base64,'.length), 'base64');
409 expect([...roh.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
410 });
411
412 it('gibt dieselbe Data-URL aus dem Zwischenspeicher zurück', () => {
413 const bildId = katalogLaden().bilder[0]!.id;
414
415 expect(katalogBild(bildId)).toBe(katalogBild(bildId));
416 });
417
418 it('liest keine Datei, wenn die Bild-ID unbekannt oder pfadartig ist', () => {
419 katalogLaden();
420
421 expect(() => katalogBild('../katalog.json')).toThrow(/Unbekannte Bild-ID/u);
422 expect(() => katalogBild('zeichen-gibt-es-nicht')).toThrow(/Unbekannte Bild-ID/u);
423 });
424 });