waffensachkunde

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

/ app tests druck.test.ts

15,5 KB Rohdatei
app/tests/druck.test.ts — 492 Zeilen
1 // @vitest-environment node
2 /**
3 * Die Bausteine gedruckter Dokumente.
4 *
5 * Geprüft wird hier alles, was ohne Electron prüfbar ist: Maskierung,
6 * Struktur, Quellenangabe, Farbkontrast. Ob Chromium daraus ein brauchbares
7 * PDF macht, misst `e2e/druck.spec.ts` am erzeugten Artefakt – behauptet wird
8 * es an keiner Stelle.
9 */
10
11 import { describe, expect, it } from 'vitest';
12
13 import {
14 abschnitt,
15 absatz,
16 dokumentBauen,
17 fusszeilenVorlage,
18 kurzquelle,
19 maskiert,
20 quellensatz,
21 tabelle,
22 type Quellenangabe,
23 } from '../src/shared/druck/dokument';
24 import { dateiname, lernberichtBauen } from '../src/shared/druck/lernbericht';
25 import { DRUCK_FARBEN, druckStil } from '../src/shared/druck/stil';
26 import type { Lernplan } from '../src/shared/lernplan';
27 import type { Lernuebersicht } from '../src/shared/lernstand';
28 import type { Pruefungsverlauf } from '../src/shared/pruefung';
29
30 const QUELLE: Quellenangabe = {
31 amtlich: 'Amtlicher Fragenkatalog für die Waffensachkundeprüfung, Stand 16.12.2024.',
32 herausgeber: 'Bundesverwaltungsamt',
33 stand: '2024-12-16',
34 quelleUrl: 'https://www.bva.bund.de/',
35 umfang: { art: 'kein amtlicher wortlaut' },
36 };
37
38 describe('maskiert', () => {
39 it('entschärft alle fünf gefährlichen Zeichen', () => {
40 expect(maskiert(`<a href="x" class='y'>Müller & Co</a>`)).toBe(
41 '&lt;a href=&quot;x&quot; class=&#39;y&#39;&gt;Müller &amp; Co&lt;/a&gt;',
42 );
43 });
44
45 it('maskiert das kaufmännische Und zuerst', () => {
46 /* Andernfalls würde aus `&lt;` am Ende `&amp;lt;` – die häufigste Falle
47 bei handgeschriebener Maskierung. */
48 expect(maskiert('<')).toBe('&lt;');
49 expect(maskiert('&lt;')).toBe('&amp;lt;');
50 });
51 });
52
53 describe('dokumentBauen', () => {
54 const dokument = dokumentBauen({
55 titel: 'Testbericht',
56 augenbraue: 'Profil "Max & Moritz"',
57 quelle: QUELLE,
58 schriftgroesse: 'normal',
59 abschnitte: [abschnitt('Erster Teil', [absatz('Ein Satz.')])],
60 });
61
62 it('nennt die Sprache am Wurzelelement', () => {
63 expect(dokument).toContain('<html lang="de">');
64 });
65
66 it('hat genau eine Hauptüberschrift', () => {
67 expect(dokument.match(/<h1[ >]/gu) ?? []).toHaveLength(1);
68 });
69
70 it('führt keine Sprünge in der Überschriftenfolge', () => {
71 const ebenen = [...dokument.matchAll(/<h([1-6])[ >]/gu)].map((t) => Number(t[1]));
72
73 expect(ebenen[0]).toBe(1);
74 let vorige = 1;
75 for (const ebene of ebenen) {
76 expect(ebene).toBeLessThanOrEqual(vorige + 1);
77 vorige = ebene;
78 }
79 });
80
81 it('maskiert auch die Kopfzeilen', () => {
82 expect(dokument).toContain('Profil &quot;Max &amp; Moritz&quot;');
83 expect(dokument).not.toContain('Profil "Max & Moritz"');
84 });
85
86 it('enthält kein Skript und keine ladbare Adresse', () => {
87 expect(dokument).not.toMatch(/<script/iu);
88 expect(dokument).not.toMatch(/(?:src|href)\s*=\s*["']https?:/iu);
89 });
90
91 it('bringt eine eigene, strenge Inhaltsrichtlinie mit', () => {
92 expect(dokument).toContain("default-src 'none'");
93 expect(dokument).toContain("style-src 'unsafe-inline'");
94 });
95
96 describe('Quellenangabe', () => {
97 it('steht im Dokument, nicht nur in den Metadaten', () => {
98 expect(dokument).toContain('Bundesverwaltungsamt');
99 expect(dokument).toContain('Stand: 2024-12-16');
100 expect(dokument).toContain('https://www.bva.bund.de/');
101 });
102
103 it('nennt auch die eigene Lizenz der redaktionellen Teile', () => {
104 expect(dokument).toContain('EUPL-1.2');
105 expect(dokument).toContain('Olaf Willerding');
106 });
107
108 it('sagt, ob amtlicher Wortlaut enthalten ist', () => {
109 expect(dokument).toContain('enthält keinen Wortlaut des amtlichen Fragenkatalogs');
110 });
111
112 /*
113 Bis Fassung 0.18.0 stand hier unbedingt „Dieses Dokument gibt nur einen
114 Teil des amtlichen Fragenkatalogs wieder“. Für eine Fragenliste über den
115 ganzen Katalog war das nachweislich falsch – 575 von 575 Fragen sind
116 kein Teil. Der Satz wird deshalb gezählt, nicht behauptet, und diese
117 Prüfungen halten jede Zählung fest.
118 */
119 it('nennt bei einer Vollwiedergabe „alle“ und nicht „einen Teil“', () => {
120 expect(
121 quellensatz({
122 art: 'wiedergabe',
123 fragen: 575,
124 fragenGesamt: 575,
125 loesungen: 'keine',
126 optionen: 'alle',
127 }),
128 ).toContain('alle 575 Fragen');
129 expect(
130 quellensatz({
131 art: 'wiedergabe',
132 fragen: 575,
133 fragenGesamt: 575,
134 loesungen: 'keine',
135 optionen: 'alle',
136 }),
137 ).not.toContain('der 575 Fragen');
138 });
139
140 it('zählt bei einer Teilwiedergabe die Fragen', () => {
141 expect(
142 quellensatz({
143 art: 'wiedergabe',
144 fragen: 42,
145 fragenGesamt: 575,
146 loesungen: 'alle',
147 optionen: 'alle',
148 }),
149 ).toContain('42 der 575 Fragen');
150 });
151
152 it('kennt den Singular – ein einziger Fehler ist erreichbar', () => {
153 const satz = quellensatz({
154 art: 'wiedergabe',
155 fragen: 1,
156 fragenGesamt: 575,
157 loesungen: 'alle',
158 optionen: 'alle',
159 });
160
161 expect(satz).toContain('1 der 575 Fragen');
162 expect(satz).not.toContain('1 Fragen des');
163 });
164
165 it('sagt ausdrücklich, wenn die Lösungskennzeichnung fehlt', () => {
166 expect(
167 quellensatz({
168 art: 'wiedergabe',
169 fragen: 90,
170 fragenGesamt: 575,
171 loesungen: 'keine',
172 optionen: 'alle',
173 }),
174 ).toContain('die amtliche Kennzeichnung fehlt hier vollständig');
175 });
176
177 it('sagt ausdrücklich, wenn Antwortmöglichkeiten weggelassen wurden', () => {
178 expect(
179 quellensatz({
180 art: 'wiedergabe',
181 fragen: 90,
182 fragenGesamt: 575,
183 loesungen: 'alle',
184 optionen: 'nur die richtigen',
185 }),
186 ).toContain('die übrigen fehlen');
187 });
188
189 /*
190 Für einen blinden Leser ersetzt der Alternativtext das amtliche Zeichen.
191 Er ist eigener Inhalt dieser Software – wer ihn hört, muss das erfahren.
192 */
193 it('weist die Bildbeschreibungen als eigenen Inhalt aus', () => {
194 expect(
195 quellensatz({
196 art: 'wiedergabe',
197 fragen: 5,
198 fragenGesamt: 575,
199 loesungen: 'alle',
200 optionen: 'alle',
201 }),
202 ).toContain('stammen nicht aus dem amtlichen Fragenkatalog');
203 });
204
205 it('lässt sich nicht weglassen', () => {
206 /* Es gibt keinen Schalter dafür – geprüft wird der einzige Weg, auf dem
207 sie fehlen könnte: eine leere Angabe im Katalog. */
208 expect(() =>
209 dokumentBauen({
210 titel: 'T',
211 augenbraue: 'A',
212 quelle: { ...QUELLE, amtlich: ' ' },
213 schriftgroesse: 'normal',
214 abschnitte: [],
215 }),
216 ).toThrow(/Quellenangabe/u);
217 });
218
219 it('steht als Kurzform auch in der Fußzeile jeder Seite', () => {
220 const fuss = fusszeilenVorlage(QUELLE);
221
222 expect(fuss).toContain('Bundesverwaltungsamt');
223 expect(fuss).toContain('2024-12-16');
224 expect(fuss).toContain('class="pageNumber"');
225 expect(fuss).toContain('class="totalPages"');
226 });
227
228 it('maskiert auch die Kurzform', () => {
229 const fuss = fusszeilenVorlage({ ...QUELLE, herausgeber: 'A & B <GmbH>' });
230
231 expect(fuss).toContain('A &amp; B &lt;GmbH&gt;');
232 expect(kurzquelle({ ...QUELLE, herausgeber: 'A & B' })).toContain('A & B');
233 });
234 });
235 });
236
237 describe('tabelle', () => {
238 const html = tabelle(
239 'Werte',
240 ['Bereich', 'Anzahl'],
241 [[{ text: 'Kapitel I' }, { text: '42', zahl: true }]],
242 );
243
244 it('trägt eine Beschriftung', () => {
245 expect(html).toContain('<caption>Werte</caption>');
246 });
247
248 it('weist jeder Kopfzelle ihre Richtung zu', () => {
249 /* Ohne `scope` weiß ein Screenreader beim Vorlesen einer Zelle nicht,
250 wozu sie gehört – und im getaggten PDF fehlt dieselbe Zuordnung. */
251 expect(html).toContain('<th scope="col">Bereich</th>');
252 expect(html).toContain('<th scope="row">Kapitel I</th>');
253 });
254
255 it('maskiert den Zellinhalt', () => {
256 const boese = tabelle('T', ['A'], [[{ text: '<script>alert(1)</script>' }]]);
257
258 expect(boese).not.toContain('<script>');
259 expect(boese).toContain('&lt;script&gt;');
260 });
261 });
262
263 describe('Farben des Ausdrucks', () => {
264 /** Relative Leuchtdichte nach WCAG 2.x. */
265 function leuchtdichte(hex: string): number {
266 const kanal = (n: number): number => {
267 const s = n / 255;
268 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
269 };
270 const r = Number.parseInt(hex.slice(1, 3), 16);
271 const g = Number.parseInt(hex.slice(3, 5), 16);
272 const b = Number.parseInt(hex.slice(5, 7), 16);
273 return 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b);
274 }
275
276 function kontrast(vorne: string, hinten: string): number {
277 const a = leuchtdichte(vorne);
278 const b = leuchtdichte(hinten);
279 return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
280 }
281
282 /*
283 Der Kommentar in stil.ts nennt Zahlen. Hier werden sie nachgerechnet,
284 damit sie Angaben bleiben und nicht zu Behauptungen verkommen. Papier ist
285 weiß – Hintergrundfarben druckt kaum ein Gerät.
286 */
287 it.each([
288 ['text', DRUCK_FARBEN.text, 7],
289 ['leise', DRUCK_FARBEN.leise, 7],
290 ['erfolg', DRUCK_FARBEN.erfolg, 7],
291 ['fehler', DRUCK_FARBEN.fehler, 7],
292 ['akzent', DRUCK_FARBEN.akzent, 7],
293 ])('hält %s über %s zu 1 gegen weißes Papier', (_name, farbe, mindestens) => {
294 expect(kontrast(farbe, '#ffffff')).toBeGreaterThanOrEqual(mindestens);
295 });
296
297 it('hält den Text auch auf der hellen Tabellenkopffläche über 7 zu 1', () => {
298 expect(kontrast(DRUCK_FARBEN.text, DRUCK_FARBEN.flaecheLeise)).toBeGreaterThanOrEqual(7);
299 });
300
301 it('verlässt sich nicht auf den Abstand zwischen Grün und Rot', () => {
302 /*
303 Im Graustufendruck fallen die beiden fast zusammen. Genau deshalb steht
304 neben jeder farbigen Angabe ein Wort – der Test hält den Befund fest,
305 damit niemand die Farbe später zum alleinigen Träger macht.
306 */
307 expect(kontrast(DRUCK_FARBEN.erfolg, DRUCK_FARBEN.fehler)).toBeLessThan(2);
308 });
309 });
310
311 describe('druckStil', () => {
312 it('setzt keine Seitengeometrie – die kommt aus den Optionen', () => {
313 /* Zwei Quellen für dieselbe Größe wären eine Fehlerquelle, und sobald
314 `@page` im Spiel ist, übergeht Electron die Option `landscape`. */
315 expect(druckStil('normal')).not.toContain('@page');
316 });
317
318 it('vergrößert im Großdruck auch Zeilenlänge und Überschriften', () => {
319 const normal = druckStil('normal');
320 const gross = druckStil('gross');
321
322 expect(normal).toContain('font-size: 12pt');
323 expect(gross).toContain('font-size: 17pt');
324 expect(normal).toContain('max-width: 78ch');
325 expect(gross).toContain('max-width: 52ch');
326 });
327
328 it('wiederholt den Tabellenkopf auf Folgeseiten', () => {
329 expect(druckStil('normal')).toContain('thead { display: table-header-group; }');
330 });
331 });
332
333 // ─── Der Lernbericht ────────────────────────────────────────────────────
334
335 const UEBERSICHT: Lernuebersicht = {
336 fragenGesamt: 575,
337 beantwortet: 120,
338 belegt: 48,
339 reifegrad: 48 / 575,
340 stufe: 'zurueck',
341 deckelnd: [],
342 faellig: 12,
343 gemerkt: 5,
344 offen: 0,
345 fehler: 0,
346 heuteRichtig: 8,
347 heuteFalsch: 2,
348 heuteBearbeitet: 0,
349 tageSeitLetzterAntwort: null,
350 bereiche: [
351 {
352 id: 'I',
353 titel: 'Waffenrecht & Vorschriften',
354 fragenGesamt: 300,
355 beantwortet: 100,
356 belegt: 40,
357 reifegrad: 40 / 300,
358 stufe: 'zurueck',
359 },
360 ],
361 };
362
363 const PLAN: Lernplan = {
364 termin: '2026-10-01',
365 tageBisTermin: 41,
366 gesamtFragen: 575,
367 nieBeantwortet: 455,
368 faellig: 12,
369 zielquote: 0.9,
370 prognoseHeute: 0.42,
371 prognoseAmTermin: 0.31,
372 pensum: { neu: 12, wiederholung: 8, gesamt: 20, minuten: 9 },
373 machbarkeit: 'machbar',
374 sekundenProFrage: 25,
375 };
376
377 const VERLAUF: Pruefungsverlauf[] = [
378 {
379 id: 3,
380 profilId: 'standard',
381 profilName: 'Standard',
382 zeitpunkt: '2026-08-20T10:00:00.000Z',
383 gesamt: 80,
384 richtig: 70,
385 quote: 0.875,
386 urteil: 'bestanden',
387 dauerMs: 71 * 60_000,
388 zeitmodus: 'normal',
389 unbeantwortet: 0,
390 zeitAbgelaufen: false,
391 bereiche: null,
392 bestehensQuote: 0.8,
393 },
394 ];
395
396 describe('lernberichtBauen', () => {
397 const bericht = lernberichtBauen({
398 uebersicht: UEBERSICHT,
399 plan: PLAN,
400 verlauf: VERLAUF,
401 profilName: 'Max & Moritz',
402 quelle: QUELLE,
403 erstelltAm: '2026-08-21T06:00:00.000Z',
404 schriftgroesse: 'normal',
405 });
406
407 it('nennt die vier Abschnitte', () => {
408 for (const titel of [
409 'Ihr Stand insgesamt',
410 'Nach Bereichen',
411 'Ihr Lernplan',
412 'Prüfungssimulationen',
413 ]) {
414 expect(bericht).toContain(titel);
415 }
416 });
417
418 it('gibt die Zahlen des Lernstands wieder', () => {
419 expect(bericht).toContain('48 von 575 Fragen sitzen belegt.');
420 expect(bericht).toContain('8 %');
421 });
422
423 it('übernimmt die Bereichsbezeichnung des Katalogs unverändert und maskiert', () => {
424 /* Änderungsverbot: Der amtliche Titel darf nicht umformuliert werden –
425 maskieren ist keine Änderung des Wortlauts, sondern seine korrekte
426 Darstellung. */
427 expect(bericht).toContain('I – Waffenrecht &amp; Vorschriften');
428 });
429
430 it('sagt bei der Prognose zum Termin dazu, worauf sie beruht', () => {
431 expect(bericht).toContain('Ohne weiteres Lernen');
432 });
433
434 it('nennt das Urteil eines Laufs als Wort', () => {
435 expect(bericht).toContain('Bestanden');
436 });
437
438 it('nennt zu jedem Lauf die Bearbeitungsdauer', () => {
439 /* Auch auf dem Papier: Zeitnot ist ein eigenes Durchfallrisiko und an der
440 Trefferquote nicht abzulesen. In vollen Minuten – zum Vergleich
441 mehrerer Läufe untereinander sind Sekunden Rauschen. */
442 expect(bericht).toContain('>Dauer</th>');
443 expect(bericht).toContain('71 Minuten');
444 });
445
446 it('kommt ohne Lernplan aus', () => {
447 const ohne = lernberichtBauen({
448 uebersicht: UEBERSICHT,
449 plan: null,
450 verlauf: [],
451 profilName: 'A',
452 quelle: QUELLE,
453 erstelltAm: '2026-08-21T06:00:00.000Z',
454 schriftgroesse: 'normal',
455 });
456
457 expect(ohne).toContain('konnte für diesen Bericht nicht ermittelt werden');
458 expect(ohne).toContain('noch keine Prüfungssimulation');
459 });
460
461 it('enthält keine Frage und keine Antwort im Wortlaut', () => {
462 /* Der Bericht soll den Stand zeigen, nicht den Katalog ersetzen.
463
464 Geprüft wird der Inhalt ohne das eingebettete Stylesheet: Dessen
465 Kommentare erklären Regeln für die anderen Dokumente und nennen dabei
466 Wörter wie „Antwortmöglichkeiten“. Sie stehen in jedem Dokument, auch
467 in diesem – gemeint ist hier aber, was der Leser sieht. */
468 const inhalt = bericht.replace(/<style>[\s\S]*?<\/style>/u, '');
469
470 expect(inhalt).not.toMatch(/Antwortm[oö]glichkeit/u);
471 expect(inhalt).not.toContain('Musterantwort');
472 });
473 });
474
475 describe('dateiname', () => {
476 it('macht aus Namen und Datum einen brauchbaren Dateinamen', () => {
477 expect(dateiname('Max Mustermann', '2026-08-21T06:00:00.000Z')).toBe(
478 'Lernbericht-Max-Mustermann-2026-08-21.pdf',
479 );
480 });
481
482 it('entfernt Zeichen, mit denen Dateisysteme nichts anfangen', () => {
483 const name = dateiname('Müller/Meier: "Test" \\ *?', '2026-08-21T06:00:00.000Z');
484
485 expect(name).toMatch(/^Lernbericht-[A-Za-z0-9-]+-2026-08-21\.pdf$/u);
486 expect(name).not.toMatch(/[/\\:*?"<>|]/u);
487 });
488
489 it('kommt mit einem leeren Namen und einem unbrauchbaren Datum zurecht', () => {
490 expect(dateiname('', 'unsinn')).toBe('Lernbericht-ohne-datum.pdf');
491 });
492 });