// @vitest-environment jsdom /** * Fehlerprotokoll und Fragenliste – Aufbau, Wahrheitsgehalt, Umfang. * * Geprüft wird an einem kleinen, aber echten Fragenzuschnitt: eine * Auswahlfrage mit einer richtigen Antwort, eine mit zweien, eine mit * Abbildung an der Option, eine offene Frage. Genau diese vier Fälle sind es, * an denen die Dokumente auseinandergingen. */ import axe from 'axe-core'; import { describe, expect, it } from 'vitest'; import { jsdomOptionen } from '../src/shared/wcag'; import { fehlerprotokollBauen, fehlerprotokollDateiname, } from '../src/shared/druck/fehlerprotokoll'; import { fragenlisteBauen, fragenlisteDateiname } from '../src/shared/druck/fragenliste'; import type { Papierfrage } from '../src/shared/druck/frageblock'; import { frageKurzbezeichnung } from '../src/shared/druck/frageblock'; import type { Quellenangabe } from '../src/shared/druck/dokument'; import { seitenFehlerprotokoll, seitenFragenliste, umfangssatz } from '../src/shared/druck/umfang'; import type { Frage, Kapitel } from '../src/shared/katalog'; const BILD_URL = 'data:image/png;base64,iVBORw0KGgo='; function option(label: string, text: string, korrekt: boolean, bilder: string[] = []) { return { label, inhalt: { text, segmente: [{ t: text }] }, korrekt, bilder }; } const EINE_RICHTIGE: Frage = { id: 'I.1-01', amtliche_nummer: '1.01', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 1, frage: { text: 'Was regelt das Waffengesetz?', segmente: [{ t: 'Was regelt das Waffengesetz?' }], }, bilder: [], optionen: [ option('a', 'Den Umgang mit Waffen.', true), option('b', 'Die Jagd.', false), option('c', 'Den Straßenverkehr.', false), ], }; const ZWEI_RICHTIGE: Frage = { ...EINE_RICHTIGE, id: 'I.1-02', amtliche_nummer: '1.02', frage: { text: 'Welche Aussagen treffen zu?', segmente: [{ t: 'Welche Aussagen treffen zu?' }] }, optionen: [ option('a', 'Erste zutreffende Aussage.', true), option('b', 'Falsche Aussage.', false), option('c', 'Zweite zutreffende Aussage.', true), ], }; /* Der Härtefall aus dem echten Katalog: Bei Frage 3.05 haben zwei Optionen überhaupt keinen Text, dort steht nur das Prüfzeichen. */ const BILDOPTIONEN: Frage = { ...EINE_RICHTIGE, id: 'I.3-05', amtliche_nummer: '3.05', abschnitt: 'I.3', frage: { text: 'Welches Zeichen ist richtig?', segmente: [{ t: 'Welches Zeichen ist richtig?' }], }, optionen: [option('a', '', true, ['zeichen-1']), option('b', '', false, ['zeichen-2'])], }; const OFFEN: Frage = { id: 'II-01', amtliche_nummer: '2.01', kapitel: 'II', abschnitt: null, typ: 'freitext', seite: 2, frage: { text: 'Erklären Sie den Begriff Führen.', segmente: [{ t: 'Erklären Sie den Begriff Führen.' }], }, bilder: [], musterantwort: { text: 'Das Ausüben der tatsächlichen Gewalt außerhalb der eigenen Wohnung.', segmente: [ { t: 'Das ' }, { t: 'Ausüben der tatsächlichen Gewalt', h: true }, { t: ' außerhalb der eigenen Wohnung.' }, ], }, }; const KAPITEL: readonly Kapitel[] = [ { id: 'I', titel: 'Waffenrecht', abschnitte: [ { id: 'I.1', titel: 'Begriffe' }, { id: 'I.3', titel: 'Kennzeichnung' }, ], }, { id: 'II', titel: 'Technik', abschnitte: [] }, ]; const QUELLE = ( fragen: number, loesungen: 'alle' | 'keine', optionen: 'alle' | 'nur die richtigen' = 'alle', ): Quellenangabe => ({ amtlich: 'Amtlicher Fragenkatalog, Stand 16.12.2024.', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quelleUrl: 'https://www.bva.bund.de/', umfang: { art: 'wiedergabe', fragen, fragenGesamt: 575, loesungen, optionen }, }); function papier(frage: Frage, mitErklaerung = false): Papierfrage { return { frage, kapitelTitel: frage.kapitel === 'I' ? 'Waffenrecht' : 'Technik', bilder: new Map([ ['zeichen-1', BILD_URL], ['zeichen-2', BILD_URL], ]), alttexte: new Map([ ['zeichen-1', 'Adler über dem Buchstaben N'], ['zeichen-2', 'Krone über den Buchstaben PTB'], ]), erklaerung: mitErklaerung ? { kurz: 'Es regelt den Umgang.', text: 'Ausführlich: Antwort b ist falsch, weil die Jagd im Bundesjagdgesetz steht.', merksatz: 'Umgang, nicht Gegenstand.', fundstellen: ['§ 1 Abs. 1 WaffG'], } : undefined, }; } /* Wie in `druck-struktur.test.ts`: Das Dokument ist eine vollständige HTML-Datei, kein Fragment – geparst wird es deshalb, statt es in den Testbaum zu hängen. */ function baum(html: string): Document { return new DOMParser().parseFromString(html, 'text/html'); } // ─── Fehlerprotokoll ──────────────────────────────────────────────────── describe('Fehlerprotokoll', () => { const dokument = fehlerprotokollBauen({ fragen: [papier(EINE_RICHTIGE, true), papier(ZWEI_RICHTIGE, true)], profilName: 'Max', erstelltAm: '2026-08-23T09:00:00.000Z', tiefe: 'kurz', schriftgroesse: 'normal', quelle: QUELLE(2, 'alle'), }); /* Der entscheidende Punkt. Nachgemessen nennen 302 der 471 Erklärungen zu Auswahlfragen eine FALSCHE Option beim Buchstaben („Antwort b ist falsch …“). Ein Dokument mit nur der richtigen Antwort machte seine eigenen Begründungen unlesbar. */ it('druckt alle Antwortmöglichkeiten, nicht nur die richtige', () => { expect(dokument).toContain('Den Umgang mit Waffen.'); expect(dokument).toContain('Die Jagd.'); expect(dokument).toContain('Den Straßenverkehr.'); }); it('kennzeichnet die richtige Antwort und sagt es bei mehreren', () => { const doc = baum(dokument); const punkte = [...doc.querySelectorAll('.optionen li')].map((li) => li.textContent); expect(punkte.filter((t) => t.includes('☑'))).toHaveLength(3); expect(dokument).toContain('Bei dieser Frage sind mehrere Antworten richtig.'); }); it('nennt in jeder Überschrift Kapitel und Nummer', () => { /* 575 Fragen tragen nur 437 verschiedene amtliche Nummern; 227 Fragen teilen sich eine mit einer anderen. Ohne das Kapitel ist die Frage im amtlichen Werk nicht nachschlagbar. */ const doc = baum(dokument); for (const h3 of doc.querySelectorAll('h3')) { expect(h3.textContent).toMatch(/^Kapitel [IVX]+, Frage /u); } }); it('kennzeichnet die Erklärung als eigenen Inhalt – vollständig', () => { expect(dokument).toContain( 'Diese Erklärung ist eine Ergänzung dieser Software und nicht Teil des amtlichen Fragenkatalogs.', ); }); it('lässt die ausführliche Begründung bei „kurz“ weg', () => { expect(dokument).toContain('Es regelt den Umgang.'); expect(dokument).not.toContain('Bundesjagdgesetz'); expect(dokument).not.toContain('§ 1 Abs. 1 WaffG'); }); it('nimmt bei „voll“ Begründung, Merksatz und Fundstellen mit', () => { const voll = fehlerprotokollBauen({ fragen: [papier(EINE_RICHTIGE, true)], profilName: 'Max', erstelltAm: '2026-08-23T09:00:00.000Z', tiefe: 'voll', schriftgroesse: 'normal', quelle: QUELLE(1, 'alle'), }); expect(voll).toContain('Bundesjagdgesetz'); expect(voll).toContain('Umgang, nicht Gegenstand.'); expect(voll).toContain('§ 1 Abs. 1 WaffG'); }); it('sagt bei null Fehlern, dass das Dokument leer bleibt', () => { const leer = fehlerprotokollBauen({ fragen: [], profilName: 'Max', erstelltAm: '2026-08-23T09:00:00.000Z', tiefe: 'kurz', schriftgroesse: 'normal', quelle: QUELLE(0, 'alle'), }); expect(leer).toContain('keine Frage als zuletzt falsch beantwortet vermerkt'); }); it('spricht im Singular, wenn es eine einzige Frage ist', () => { const eine = fehlerprotokollBauen({ fragen: [papier(EINE_RICHTIGE)], profilName: 'Max', erstelltAm: '2026-08-23T09:00:00.000Z', tiefe: 'kurz', schriftgroesse: 'normal', quelle: QUELLE(1, 'alle'), }); expect(eine).toContain('die eine Frage, die Sie zuletzt falsch beantwortet haben'); }); it('macht aus Name und Datum einen brauchbaren Dateinamen', () => { expect(fehlerprotokollDateiname('Max Mustermann', '2026-08-23T09:00:00.000Z')).toBe( 'Fehlerprotokoll-Max-Mustermann-2026-08-23.pdf', ); }); /* Die Wache, die gefehlt hat. `absatz()` maskiert seinen ganzen Parameter. `frageblock.ts` übergab ihm bis 0.24.1 Zeichenketten, die bereits maskiert waren UND Auszeichnung enthielten. Auf dem Papier stand deshalb an jeder Frage mit Erklärung wörtlich „Kurz:“, und aus jedem kaufmännischen Und wurde „&“. Kein Test hat es gemeldet, weil alle Prüftexte aus Buchstaben und Punkten bestanden. Deshalb trägt der Prüftext hier jetzt genau die Zeichen, an denen es schiefging. */ const HEIKEL: Frage = { ...EINE_RICHTIGE, id: 'I.2-08', amtliche_nummer: '2.08', frage: { text: 'Welche Erlaubnis braucht eine Pistole .32 S&W?', segmente: [{ t: 'Welche Erlaubnis braucht eine Pistole .32 S&W?' }], }, optionen: [option('a', 'Waffenbesitzkarte & Bedürfnis', true), option('b', 'Keine', false)], }; const heikelDokument = fehlerprotokollBauen({ fragen: [ { ...papier(HEIKEL, true), erklaerung: { kurz: 'Kurz mit & und "Zitat".', text: 'Volltext mit & Zeichen.', merksatz: 'Merken: A & B.', fundstellen: ['§ 1 Abs. 1 WaffG'], }, }, ], profilName: 'Max & Moritz', erstelltAm: '2026-08-23T09:00:00.000Z', tiefe: 'voll', schriftgroesse: 'normal', quelle: QUELLE(1, 'alle'), }); it('setzt die Beschriftungen als Fettschrift und nicht als sichtbaren Quelltext', () => { expect(heikelDokument).toContain('Kurz:'); expect(heikelDokument).toContain('Merksatz:'); expect(heikelDokument).not.toContain('<strong>'); }); it('maskiert Sonderzeichen genau einmal', () => { /* Einmal maskiert ist „&“ – zweimal maskiert wäre „&amp;“, und der Leser sähe auf dem Papier „&“ stehen. */ expect(heikelDokument).not.toContain('&amp;'); expect(heikelDokument).not.toContain('&quot;'); expect(heikelDokument).toContain('Pistole .32 S&W?'); const text = baum(heikelDokument).body.textContent; expect(text).toContain('Pistole .32 S&W?'); expect(text).toContain('Waffenbesitzkarte & Bedürfnis'); expect(text).toContain('Kurz mit & und "Zitat".'); expect(text).toContain('Merken: A & B.'); expect(text).not.toContain(''); }); /* Das Fehlerprotokoll hat keinen Lösungsanhang – anders als die Fragenliste. Wer es hört, muss die Lösung trotzdem erfahren; das Kästchen ☑ ist `aria-hidden` und trägt sie nicht. */ it('nennt die richtige Antwort auch im gelesenen Text, nicht nur im Kästchen', () => { const punkte = [...baum(heikelDokument).querySelectorAll('.optionen li')]; expect(punkte).toHaveLength(2); expect(punkte[0]?.textContent).toContain('Richtig:'); expect(punkte[1]?.textContent).toContain('Falsch:'); }); }); // ─── Fragenliste ──────────────────────────────────────────────────────── describe('Fragenliste', () => { const daten = { fragen: [papier(EINE_RICHTIGE), papier(BILDOPTIONEN), papier(OFFEN)], auswahl: 'gesamter Fragenkatalog', erstelltAm: '2026-08-23T09:00:00.000Z', kapitel: KAPITEL, schriftgroesse: 'normal' as const, }; const ohneLoesung = fragenlisteBauen({ ...daten, mitLoesungen: false, quelle: QUELLE(3, 'keine'), }); const mitLoesung = fragenlisteBauen({ ...daten, mitLoesungen: true, quelle: QUELLE(3, 'alle'), }); it('verrät im Hauptteil nicht, welche Antwort richtig ist', () => { const doc = baum(ohneLoesung); const punkte = [...doc.querySelectorAll('.optionen li')].map((li) => li.textContent); expect(punkte.length).toBeGreaterThan(0); expect(punkte.some((t) => t.includes('☑'))).toBe(false); expect(punkte.every((t) => t.includes('☐'))).toBe(true); }); it('druckt trotzdem alle Antwortmöglichkeiten im Wortlaut', () => { expect(ohneLoesung).toContain('Den Umgang mit Waffen.'); expect(ohneLoesung).toContain('Die Jagd.'); }); it('bettet die Prüfzeichen ein – sonst wäre die Option leer', () => { /* Bei Frage 3.05 haben zwei Optionen keinen Text. Ohne Bild stünde dort nichts, und ein Bildschirmleser fände einen leeren Listenpunkt. */ const doc = baum(ohneLoesung); const bilder = [...doc.querySelectorAll('.optionen img')]; expect(bilder).toHaveLength(2); for (const b of bilder) { expect(b.getAttribute('src')).toMatch(/^data:image\//u); expect(b.getAttribute('alt')).toBeTruthy(); } }); it('gibt offenen Fragen Platz zum Schreiben statt der Musterantwort', () => { expect(ohneLoesung).toContain('schreibfeld'); expect(ohneLoesung).not.toContain('Ausüben der tatsächlichen Gewalt'); }); it('führt den Lösungsanhang nur auf Wunsch – und nennt dort Kapitel und Nummer', () => { /* Geprüft wird die Überschrift, nicht das Wort: Der Vorspann nennt den Anhang auch dann, wenn er fehlt – er sagt dort, dass er sich zuschalten lässt. */ const ueberschriften = (html: string): string[] => [...baum(html).querySelectorAll('h2')].map((e) => e.textContent); expect(ueberschriften(ohneLoesung)).not.toContain('Lösungsanhang'); expect(ueberschriften(mitLoesung)).toContain('Lösungsanhang'); expect(mitLoesung).toContain(`${frageKurzbezeichnung(EINE_RICHTIGE)}:`); expect(mitLoesung).toContain('Richtig ist a'); }); it('nennt im Anhang die Musterantwort offener Fragen', () => { expect(mitLoesung).toContain('Ausüben der tatsächlichen Gewalt'); }); it('fasst die Fragen unter Zwischenüberschriften je Bereich', () => { const doc = baum(ohneLoesung); const h2 = [...doc.querySelectorAll('h2')].map((e) => e.textContent); expect(h2).toContain('I.1 – Begriffe'); expect(h2).toContain('II – Technik'); }); it('macht aus Auswahl und Datum einen brauchbaren Dateinamen', () => { expect(fragenlisteDateiname('Kapitel I – Waffenrecht', '2026-08-23T09:00:00.000Z')).toBe( 'Fragenliste-Kapitel-I-Waffenrecht-2026-08-23.pdf', ); }); }); // ─── Umfangsschätzung ─────────────────────────────────────────────────── describe('Umfangsschätzung', () => { /* Die Stützstellen stammen aus wirklich gedruckten PDF. Der erste Anlauf rechnete mit einer Faustregel und lag um Faktor 1,7 bis 2,8 zu niedrig – die Fragenliste über den ganzen Katalog wurde mit 55 Seiten angekündigt und hatte 162. Diese Prüfungen halten die gemessenen Werte fest. */ it.each([ ['Fehlerprotokoll 150 kurz', seitenFehlerprotokoll(150, 'kurz', 'normal'), 70], ['Fehlerprotokoll 150 voll', seitenFehlerprotokoll(150, 'voll', 'normal'), 169], ['Fehlerprotokoll 486 voll', seitenFehlerprotokoll(486, 'voll', 'normal'), 539], ['Fragenliste 575', seitenFragenliste(575, false, 'normal'), 162], ['Fragenliste 575 mit Anhang', seitenFragenliste(575, true, 'normal'), 185], ])('trifft %s auf 15 Prozent', (_name, geschaetzt, gemessen) => { expect(Math.abs(geschaetzt - gemessen) / gemessen).toBeLessThan(0.15); }); it('schätzt den Großdruck eher zu hoch als zu niedrig', () => { /* Lieber ein Dokument, das kürzer ausfällt als angekündigt. */ expect(seitenFragenliste(575, false, 'gross')).toBeGreaterThanOrEqual(279); expect(seitenFehlerprotokoll(486, 'voll', 'gross')).toBeGreaterThanOrEqual(982); }); it('nennt Blatt und Seiten, weil beides verschiedene Dinge sind', () => { expect(umfangssatz(982)).toContain('982 Seiten'); expect(umfangssatz(982)).toContain('491 Blatt'); }); it('bleibt bei einer Seite im Singular', () => { expect(umfangssatz(1)).toBe('Geschätzt eine Seite.'); expect(seitenFragenliste(0, true, 'normal')).toBe(1); }); }); // ─── Struktur des erzeugten Dokuments ─────────────────────────────────── describe('Struktur beider Dokumente', () => { const beide = [ [ 'Fehlerprotokoll', fehlerprotokollBauen({ fragen: [papier(EINE_RICHTIGE, true), papier(BILDOPTIONEN), papier(OFFEN)], profilName: 'Max', erstelltAm: '2026-08-23T09:00:00.000Z', tiefe: 'voll', schriftgroesse: 'normal', quelle: QUELLE(3, 'alle'), }), ], [ 'Fragenliste', fragenlisteBauen({ fragen: [papier(EINE_RICHTIGE), papier(BILDOPTIONEN), papier(OFFEN)], auswahl: 'gesamter Fragenkatalog', erstelltAm: '2026-08-23T09:00:00.000Z', mitLoesungen: true, kapitel: KAPITEL, schriftgroesse: 'normal', quelle: QUELLE(3, 'alle'), }), ], ] as const; it.each(beide)('%s: hält die Überschriftenhierarchie ohne Sprünge ein', (_name, html) => { /* Ohne diese Regel wird der Strukturbaum des PDF unbrauchbar – und er ist die einzige Orientierung, die ein Bildschirmleser im PDF hat. */ const ebenen = [...baum(html).querySelectorAll('h1, h2, h3, h4, h5, h6')].map((e) => Number(e.tagName.slice(1)), ); expect(ebenen[0]).toBe(1); expect(ebenen.filter((e) => e === 1)).toHaveLength(1); for (let stelle = 1; stelle < ebenen.length; stelle += 1) { expect(ebenen[stelle]!).toBeLessThanOrEqual(ebenen[stelle - 1]! + 1); } }); it.each(beide)('%s: bettet Bilder ein und führt nirgendwohin', (_name, html) => { const doc = baum(html); expect(doc.querySelectorAll('script')).toHaveLength(0); expect(doc.querySelectorAll('a[href], link[href]')).toHaveLength(0); for (const b of doc.querySelectorAll('img')) { expect(b.getAttribute('src') ?? '').toMatch(/^data:image\//u); expect(b.getAttribute('alt')).toBeTruthy(); } }); it.each(beide)('%s: nennt die Herkunft im sichtbaren Text', (_name, html) => { const text = baum(html).body.textContent; expect(text).toContain('Bundesverwaltungsamt'); expect(text).toContain('des amtlichen Fragenkatalogs wieder'); expect(text).toContain('stammen nicht aus dem amtlichen Fragenkatalog'); }); it.each(beide)('%s: besteht axe ohne Verstoß', async (_name, html) => { const geparst = baum(html); 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([]); }); }); describe('Fehlende Fundstelle: der Grund steht auch auf dem Papier', () => { /* Befund der Prüfrunde zu 0.27.2, der schwerste dieser Runde. Nachgemessen an `content/erklaerungen.json`: 84 der 575 Erklärungen haben keine Fundstelle — und **alle 84** tragen `ohneFundstelleGrund`, den Satz, der sagt, warum es keine gibt. `shared/erklaerungen.ts` begründet das Feld: „Dieses Feld verlangt, das auszusprechen – die Alternative wäre, eine Fundstelle zu erfinden.“ Bildschirm, Sprachausgabe und Glossar zeigen ihn. Der Druckweg verlor ihn an zwei Stellen: `erklaerungenFuer` kopierte das Feld nicht, und `Frageerklaerung` kannte es gar nicht. Auf dem Papier — dem einzigen Erzeugnis, das die Anwendung aus der Hand gibt — endete die Begründung dann ohne Beleg und ohne die Auskunft, dass es keinen gibt, unter einem Vorspann, der „die Fundstellen im Gesetz“ zu jeder Frage zusagt. */ const OHNE_GRUNDLAGE = 'Das Waffenrecht bestimmt diese Wörter nirgends; es ist Waffentechnik.'; function dokumentOhneFundstelle(tiefe: 'kurz' | 'voll'): string { return fehlerprotokollBauen({ fragen: [ { ...papier(EINE_RICHTIGE, true), erklaerung: { kurz: 'Kurz gesagt.', text: 'Ausführlich gesagt.', fundstellen: [], ohneFundstelleGrund: OHNE_GRUNDLAGE, }, }, ], profilName: 'Standard', erstelltAm: '2026-08-23T09:00:00.000Z', tiefe, schriftgroesse: 'normal', quelle: QUELLE(1, 'alle'), }); } it('druckt den Grund, wo keine Fundstelle steht', () => { const html = dokumentOhneFundstelle('voll'); expect(html).toContain(OHNE_GRUNDLAGE); expect(html).not.toContain('Im Gesetz nachlesen'); }); it('lässt bei der Kurzfassung beides weg', () => { /* Die Gegenprobe: Die Kurzfassung führt keine Fundstellen – dann gehört auch die Auskunft über ihr Fehlen nicht dorthin. */ const html = dokumentOhneFundstelle('kurz'); expect(html).not.toContain(OHNE_GRUNDLAGE); expect(html).not.toContain('Im Gesetz nachlesen'); }); });