waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests druck-struktur.test.ts
| 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 | }; |
| 70 | |
| 71 | const PLAN: Lernplan = { |
| 72 | termin: '2026-10-01', |
| 73 | tageBisTermin: 41, |
| 74 | gesamtFragen: 575, |
| 75 | nieBeantwortet: 455, |
| 76 | faellig: 12, |
| 77 | zielquote: 0.9, |
| 78 | prognoseHeute: 0.42, |
| 79 | prognoseAmTermin: 0.31, |
| 80 | pensum: { neu: 12, wiederholung: 8, gesamt: 20, minuten: 9 }, |
| 81 | machbarkeit: 'machbar', |
| 82 | sekundenProFrage: 25, |
| 83 | }; |
| 84 | |
| 85 | const VERLAUF: Pruefungsverlauf[] = [ |
| 86 | { |
| 87 | id: 3, |
| 88 | profilId: 'standard', |
| 89 | profilName: 'Standard', |
| 90 | zeitpunkt: '2026-08-20T10:00:00.000Z', |
| 91 | gesamt: 80, |
| 92 | richtig: 70, |
| 93 | quote: 0.875, |
| 94 | urteil: 'bestanden', |
| 95 | dauerMs: 71 * 60_000, |
| 96 | zeitmodus: 'normal', |
| 97 | unbeantwortet: 0, |
| 98 | zeitAbgelaufen: false, |
| 99 | bereiche: null, |
| 100 | bestehensQuote: 0.8, |
| 101 | }, |
| 102 | ]; |
| 103 | |
| 104 | function dokument(): Document { |
| 105 | return new DOMParser().parseFromString( |
| 106 | lernberichtBauen({ |
| 107 | uebersicht: UEBERSICHT, |
| 108 | plan: PLAN, |
| 109 | verlauf: VERLAUF, |
| 110 | profilName: 'Testprofil', |
| 111 | quelle: QUELLE, |
| 112 | erstelltAm: '2026-08-21T06:00:00.000Z', |
| 113 | schriftgroesse: 'normal', |
| 114 | }), |
| 115 | 'text/html', |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | describe('Druckdokument als geparste Seite', () => { |
| 120 | const doc = dokument(); |
| 121 | |
| 122 | it('lässt sich überhaupt parsen', () => { |
| 123 | expect(doc.querySelector('parsererror')).toBeNull(); |
| 124 | expect(doc.title).toBe('Lernbericht Waffensachkunde'); |
| 125 | }); |
| 126 | |
| 127 | it('trägt die Sprache am Wurzelelement', () => { |
| 128 | /* Ohne sie liest ein Screenreader deutschen Text mit englischer |
| 129 | Aussprache – und im PDF fehlt dann /Lang. */ |
| 130 | expect(doc.documentElement.lang).toBe('de'); |
| 131 | }); |
| 132 | |
| 133 | it('hat genau eine h1 und keine Sprünge', () => { |
| 134 | const ueberschriften = [...doc.querySelectorAll('h1, h2, h3, h4, h5, h6')]; |
| 135 | const ebenen = ueberschriften.map((h) => Number(h.tagName.slice(1))); |
| 136 | |
| 137 | expect(ebenen.filter((e) => e === 1)).toHaveLength(1); |
| 138 | expect(ebenen[0]).toBe(1); |
| 139 | |
| 140 | let vorige = 1; |
| 141 | for (const [i, ebene] of ebenen.entries()) { |
| 142 | const wo = ueberschriften[i]?.textContent ?? '(ohne Text)'; |
| 143 | expect(ebene, `Sprung bei „${wo}“`).toBeLessThanOrEqual(vorige + 1); |
| 144 | vorige = ebene; |
| 145 | } |
| 146 | }); |
| 147 | |
| 148 | it('gibt jeder Tabelle eine Beschriftung und Kopfzellen mit Richtung', () => { |
| 149 | const tabellen = [...doc.querySelectorAll('table')]; |
| 150 | |
| 151 | expect(tabellen.length).toBeGreaterThan(2); |
| 152 | for (const t of tabellen) { |
| 153 | expect(t.querySelector('caption')?.textContent.trim() ?? '').not.toBe(''); |
| 154 | expect(t.querySelectorAll('th[scope="col"]').length).toBeGreaterThan(0); |
| 155 | for (const th of t.querySelectorAll('th')) { |
| 156 | expect(th.getAttribute('scope')).toMatch(/^(col|row)$/u); |
| 157 | } |
| 158 | } |
| 159 | }); |
| 160 | |
| 161 | it('enthält kein Skript und keinen Verweis nach draußen', () => { |
| 162 | expect(doc.querySelectorAll('script')).toHaveLength(0); |
| 163 | expect(doc.querySelectorAll('a[href], link[href]')).toHaveLength(0); |
| 164 | |
| 165 | /* |
| 166 | Bilder sind erlaubt, aber nur eingebettet. Die Unterscheidung ist der |
| 167 | eigentliche Punkt: Ein `img` mit `data:`-URI fuehrt nirgendwohin, ein |
| 168 | `img` mit `http:`- oder `file:`-Adresse verliesse das Dokument. Bis |
| 169 | Fassung 0.18.0 verbot diese Zusicherung beides zusammen - damit waere |
| 170 | jede Fragenliste rot gewesen, obwohl bei drei Fragen die Pruefzeichen |
| 171 | selbst die Antwortmoeglichkeiten sind und die Option ohne Bild leer |
| 172 | bliebe. |
| 173 | */ |
| 174 | for (const bild of doc.querySelectorAll('img')) { |
| 175 | expect(bild.getAttribute('src') ?? '').toMatch(/^data:image\//u); |
| 176 | expect(bild.getAttribute('alt')).toBeTruthy(); |
| 177 | } |
| 178 | }); |
| 179 | |
| 180 | it('nennt die Herkunft im sichtbaren Text', () => { |
| 181 | const text = doc.body.textContent; |
| 182 | |
| 183 | expect(text).toContain('Bundesverwaltungsamt'); |
| 184 | expect(text).toContain('2024-12-16'); |
| 185 | expect(text).toContain('EUPL-1.2'); |
| 186 | }); |
| 187 | }); |
| 188 | |
| 189 | describe('Druckdokument – axe-core', () => { |
| 190 | it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => { |
| 191 | /* Der Inhalt wird in das lebende Dokument übernommen: axe braucht einen |
| 192 | echten Baum mit einem echten Wurzelelement, kein losgelöstes |
| 193 | Parse-Ergebnis. */ |
| 194 | const geparst = dokument(); |
| 195 | document.documentElement.lang = geparst.documentElement.lang; |
| 196 | document.title = geparst.title; |
| 197 | document.body.innerHTML = geparst.body.innerHTML; |
| 198 | |
| 199 | const ergebnis = await axe.run(document.body, jsdomOptionen()); |
| 200 | |
| 201 | expect(ergebnis.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]); |
| 202 | }); |
| 203 | }); |