waffensachkunde

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

/ app tests druck-struktur.test.ts

6,3 KB Rohdatei
app/tests/druck-struktur.test.ts — 204 Zeilen
1 /**
2 * Das Druckdokument, durch einen echten Parser gesehen.
3 *
4 * Die Bausteine prüft `druck.test.ts` als Zeichenketten. Hier wird das
5 * fertige Dokument geparst und mit denselben Regeln geprüft, die auch für die
6 * Oberfläche gelten – Überschriftenfolge, Tabellenzuordnung, Sprache. Genau
7 * diese Angaben trägt Chromium in den Strukturbaum des getaggten PDF; was
8 * hier fehlt, fehlt dort auch.
9 *
10 * Die Grenze ist ehrlich zu benennen: jsdom rechnet kein Layout und rendert
11 * kein CSS. Farbkontraste prüft deshalb `druck.test.ts` rechnerisch an den
12 * Farbwerten, und ob der Stil überhaupt greift, misst `e2e/druck.spec.ts` am
13 * erzeugten PDF.
14 */
15
16 import axe from 'axe-core';
17 import { jsdomOptionen } from '../src/shared/wcag';
18 import { describe, expect, it } from 'vitest';
19
20 import { lernberichtBauen } from '../src/shared/druck/lernbericht';
21 import type { Quellenangabe } from '../src/shared/druck/dokument';
22 import type { Lernplan } from '../src/shared/lernplan';
23 import type { Lernuebersicht } from '../src/shared/lernstand';
24 import type { Pruefungsverlauf } from '../src/shared/pruefung';
25
26 const QUELLE: Quellenangabe = {
27 amtlich: 'Amtlicher Fragenkatalog für die Waffensachkundeprüfung, Stand 16.12.2024.',
28 herausgeber: 'Bundesverwaltungsamt',
29 stand: '2024-12-16',
30 quelleUrl: 'https://www.bva.bund.de/',
31 umfang: { art: 'kein amtlicher wortlaut' },
32 };
33
34 const UEBERSICHT: Lernuebersicht = {
35 fragenGesamt: 575,
36 beantwortet: 120,
37 belegt: 48,
38 reifegrad: 48 / 575,
39 stufe: 'zurueck',
40 deckelnd: [],
41 faellig: 12,
42 gemerkt: 5,
43 offen: 0,
44 fehler: 0,
45 heuteRichtig: 8,
46 heuteFalsch: 2,
47 heuteBearbeitet: 0,
48 tageSeitLetzterAntwort: null,
49 bereiche: [
50 {
51 id: 'I',
52 titel: 'Waffenrecht und sonstige Vorschriften',
53 fragenGesamt: 300,
54 beantwortet: 100,
55 belegt: 40,
56 reifegrad: 40 / 300,
57 stufe: 'zurueck',
58 },
59 {
60 id: 'I.1',
61 titel: 'Begriffe des Waffenrechts',
62 fragenGesamt: 90,
63 beantwortet: 40,
64 belegt: 22,
65 reifegrad: 22 / 90,
66 stufe: 'zurueck',
67 },
68 ],
69 themengruppen: [],
70 };
71
72 const PLAN: Lernplan = {
73 termin: '2026-10-01',
74 tageBisTermin: 41,
75 gesamtFragen: 575,
76 nieBeantwortet: 455,
77 faellig: 12,
78 zielquote: 0.9,
79 prognoseHeute: 0.42,
80 prognoseAmTermin: 0.31,
81 pensum: { neu: 12, wiederholung: 8, gesamt: 20, minuten: 9 },
82 machbarkeit: 'machbar',
83 sekundenProFrage: 25,
84 };
85
86 const VERLAUF: Pruefungsverlauf[] = [
87 {
88 id: 3,
89 profilId: 'standard',
90 profilName: 'Standard',
91 zeitpunkt: '2026-08-20T10:00:00.000Z',
92 gesamt: 80,
93 richtig: 70,
94 quote: 0.875,
95 urteil: 'bestanden',
96 dauerMs: 71 * 60_000,
97 zeitmodus: 'normal',
98 unbeantwortet: 0,
99 zeitAbgelaufen: false,
100 bereiche: null,
101 bestehensQuote: 0.8,
102 },
103 ];
104
105 function dokument(): Document {
106 return new DOMParser().parseFromString(
107 lernberichtBauen({
108 uebersicht: UEBERSICHT,
109 plan: PLAN,
110 verlauf: VERLAUF,
111 profilName: 'Testprofil',
112 quelle: QUELLE,
113 erstelltAm: '2026-08-21T06:00:00.000Z',
114 schriftgroesse: 'normal',
115 }),
116 'text/html',
117 );
118 }
119
120 describe('Druckdokument als geparste Seite', () => {
121 const doc = dokument();
122
123 it('lässt sich überhaupt parsen', () => {
124 expect(doc.querySelector('parsererror')).toBeNull();
125 expect(doc.title).toBe('Lernbericht Waffensachkunde');
126 });
127
128 it('trägt die Sprache am Wurzelelement', () => {
129 /* Ohne sie liest ein Screenreader deutschen Text mit englischer
130 Aussprache – und im PDF fehlt dann /Lang. */
131 expect(doc.documentElement.lang).toBe('de');
132 });
133
134 it('hat genau eine h1 und keine Sprünge', () => {
135 const ueberschriften = [...doc.querySelectorAll('h1, h2, h3, h4, h5, h6')];
136 const ebenen = ueberschriften.map((h) => Number(h.tagName.slice(1)));
137
138 expect(ebenen.filter((e) => e === 1)).toHaveLength(1);
139 expect(ebenen[0]).toBe(1);
140
141 let vorige = 1;
142 for (const [i, ebene] of ebenen.entries()) {
143 const wo = ueberschriften[i]?.textContent ?? '(ohne Text)';
144 expect(ebene, `Sprung bei „${wo}“`).toBeLessThanOrEqual(vorige + 1);
145 vorige = ebene;
146 }
147 });
148
149 it('gibt jeder Tabelle eine Beschriftung und Kopfzellen mit Richtung', () => {
150 const tabellen = [...doc.querySelectorAll('table')];
151
152 expect(tabellen.length).toBeGreaterThan(2);
153 for (const t of tabellen) {
154 expect(t.querySelector('caption')?.textContent.trim() ?? '').not.toBe('');
155 expect(t.querySelectorAll('th[scope="col"]').length).toBeGreaterThan(0);
156 for (const th of t.querySelectorAll('th')) {
157 expect(th.getAttribute('scope')).toMatch(/^(col|row)$/u);
158 }
159 }
160 });
161
162 it('enthält kein Skript und keinen Verweis nach draußen', () => {
163 expect(doc.querySelectorAll('script')).toHaveLength(0);
164 expect(doc.querySelectorAll('a[href], link[href]')).toHaveLength(0);
165
166 /*
167 Bilder sind erlaubt, aber nur eingebettet. Die Unterscheidung ist der
168 eigentliche Punkt: Ein `img` mit `data:`-URI fuehrt nirgendwohin, ein
169 `img` mit `http:`- oder `file:`-Adresse verliesse das Dokument. Bis
170 Fassung 0.18.0 verbot diese Zusicherung beides zusammen - damit waere
171 jede Fragenliste rot gewesen, obwohl bei drei Fragen die Pruefzeichen
172 selbst die Antwortmoeglichkeiten sind und die Option ohne Bild leer
173 bliebe.
174 */
175 for (const bild of doc.querySelectorAll('img')) {
176 expect(bild.getAttribute('src') ?? '').toMatch(/^data:image\//u);
177 expect(bild.getAttribute('alt')).toBeTruthy();
178 }
179 });
180
181 it('nennt die Herkunft im sichtbaren Text', () => {
182 const text = doc.body.textContent;
183
184 expect(text).toContain('Bundesverwaltungsamt');
185 expect(text).toContain('2024-12-16');
186 expect(text).toContain('EUPL-1.2');
187 });
188 });
189
190 describe('Druckdokument – axe-core', () => {
191 it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => {
192 /* Der Inhalt wird in das lebende Dokument übernommen: axe braucht einen
193 echten Baum mit einem echten Wurzelelement, kein losgelöstes
194 Parse-Ergebnis. */
195 const geparst = dokument();
196 document.documentElement.lang = geparst.documentElement.lang;
197 document.title = geparst.title;
198 document.body.innerHTML = geparst.body.innerHTML;
199
200 const ergebnis = await axe.run(document.body, jsdomOptionen());
201
202 expect(ergebnis.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
203 });
204 });