/** * Das Druckdokument, durch einen echten Parser gesehen. * * Die Bausteine prüft `druck.test.ts` als Zeichenketten. Hier wird das * fertige Dokument geparst und mit denselben Regeln geprüft, die auch für die * Oberfläche gelten – Überschriftenfolge, Tabellenzuordnung, Sprache. Genau * diese Angaben trägt Chromium in den Strukturbaum des getaggten PDF; was * hier fehlt, fehlt dort auch. * * Die Grenze ist ehrlich zu benennen: jsdom rechnet kein Layout und rendert * kein CSS. Farbkontraste prüft deshalb `druck.test.ts` rechnerisch an den * Farbwerten, und ob der Stil überhaupt greift, misst `e2e/druck.spec.ts` am * erzeugten PDF. */ import axe from 'axe-core'; import { jsdomOptionen } from '../src/shared/wcag'; import { describe, expect, it } from 'vitest'; import { lernberichtBauen } from '../src/shared/druck/lernbericht'; import type { Quellenangabe } from '../src/shared/druck/dokument'; import type { Lernplan } from '../src/shared/lernplan'; import type { Lernuebersicht } from '../src/shared/lernstand'; import type { Pruefungsverlauf } from '../src/shared/pruefung'; const QUELLE: Quellenangabe = { amtlich: 'Amtlicher Fragenkatalog für die Waffensachkundeprüfung, Stand 16.12.2024.', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quelleUrl: 'https://www.bva.bund.de/', umfang: { art: 'kein amtlicher wortlaut' }, }; const UEBERSICHT: Lernuebersicht = { fragenGesamt: 575, beantwortet: 120, belegt: 48, reifegrad: 48 / 575, stufe: 'zurueck', deckelnd: [], faellig: 12, gemerkt: 5, offen: 0, fehler: 0, heuteRichtig: 8, heuteFalsch: 2, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, bereiche: [ { id: 'I', titel: 'Waffenrecht und sonstige Vorschriften', fragenGesamt: 300, beantwortet: 100, belegt: 40, reifegrad: 40 / 300, stufe: 'zurueck', }, { id: 'I.1', titel: 'Begriffe des Waffenrechts', fragenGesamt: 90, beantwortet: 40, belegt: 22, reifegrad: 22 / 90, stufe: 'zurueck', }, ], }; const PLAN: Lernplan = { termin: '2026-10-01', tageBisTermin: 41, gesamtFragen: 575, nieBeantwortet: 455, faellig: 12, zielquote: 0.9, prognoseHeute: 0.42, prognoseAmTermin: 0.31, pensum: { neu: 12, wiederholung: 8, gesamt: 20, minuten: 9 }, machbarkeit: 'machbar', sekundenProFrage: 25, }; const VERLAUF: Pruefungsverlauf[] = [ { id: 3, profilId: 'standard', profilName: 'Standard', zeitpunkt: '2026-08-20T10:00:00.000Z', gesamt: 80, richtig: 70, quote: 0.875, urteil: 'bestanden', dauerMs: 71 * 60_000, zeitmodus: 'normal', unbeantwortet: 0, zeitAbgelaufen: false, bereiche: null, bestehensQuote: 0.8, }, ]; function dokument(): Document { return new DOMParser().parseFromString( lernberichtBauen({ uebersicht: UEBERSICHT, plan: PLAN, verlauf: VERLAUF, profilName: 'Testprofil', quelle: QUELLE, erstelltAm: '2026-08-21T06:00:00.000Z', schriftgroesse: 'normal', }), 'text/html', ); } describe('Druckdokument als geparste Seite', () => { const doc = dokument(); it('lässt sich überhaupt parsen', () => { expect(doc.querySelector('parsererror')).toBeNull(); expect(doc.title).toBe('Lernbericht Waffensachkunde'); }); it('trägt die Sprache am Wurzelelement', () => { /* Ohne sie liest ein Screenreader deutschen Text mit englischer Aussprache – und im PDF fehlt dann /Lang. */ expect(doc.documentElement.lang).toBe('de'); }); it('hat genau eine h1 und keine Sprünge', () => { const ueberschriften = [...doc.querySelectorAll('h1, h2, h3, h4, h5, h6')]; const ebenen = ueberschriften.map((h) => Number(h.tagName.slice(1))); expect(ebenen.filter((e) => e === 1)).toHaveLength(1); expect(ebenen[0]).toBe(1); let vorige = 1; for (const [i, ebene] of ebenen.entries()) { const wo = ueberschriften[i]?.textContent ?? '(ohne Text)'; expect(ebene, `Sprung bei „${wo}“`).toBeLessThanOrEqual(vorige + 1); vorige = ebene; } }); it('gibt jeder Tabelle eine Beschriftung und Kopfzellen mit Richtung', () => { const tabellen = [...doc.querySelectorAll('table')]; expect(tabellen.length).toBeGreaterThan(2); for (const t of tabellen) { expect(t.querySelector('caption')?.textContent.trim() ?? '').not.toBe(''); expect(t.querySelectorAll('th[scope="col"]').length).toBeGreaterThan(0); for (const th of t.querySelectorAll('th')) { expect(th.getAttribute('scope')).toMatch(/^(col|row)$/u); } } }); it('enthält kein Skript und keinen Verweis nach draußen', () => { expect(doc.querySelectorAll('script')).toHaveLength(0); expect(doc.querySelectorAll('a[href], link[href]')).toHaveLength(0); /* Bilder sind erlaubt, aber nur eingebettet. Die Unterscheidung ist der eigentliche Punkt: Ein `img` mit `data:`-URI fuehrt nirgendwohin, ein `img` mit `http:`- oder `file:`-Adresse verliesse das Dokument. Bis Fassung 0.18.0 verbot diese Zusicherung beides zusammen - damit waere jede Fragenliste rot gewesen, obwohl bei drei Fragen die Pruefzeichen selbst die Antwortmoeglichkeiten sind und die Option ohne Bild leer bliebe. */ for (const bild of doc.querySelectorAll('img')) { expect(bild.getAttribute('src') ?? '').toMatch(/^data:image\//u); expect(bild.getAttribute('alt')).toBeTruthy(); } }); it('nennt die Herkunft im sichtbaren Text', () => { const text = doc.body.textContent; expect(text).toContain('Bundesverwaltungsamt'); expect(text).toContain('2024-12-16'); expect(text).toContain('EUPL-1.2'); }); }); describe('Druckdokument – axe-core', () => { it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => { /* Der Inhalt wird in das lebende Dokument übernommen: axe braucht einen echten Baum mit einem echten Wurzelelement, kein losgelöstes Parse-Ergebnis. */ const geparst = dokument(); document.documentElement.lang = geparst.documentElement.lang; document.title = geparst.title; document.body.innerHTML = geparst.body.innerHTML; const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]); }); });