// @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 „&“, und
der Leser sähe auf dem Papier „&“ stehen. */
expect(heikelDokument).not.toContain('&');
expect(heikelDokument).not.toContain('"');
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');
});
});