waffensachkunde

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

/ app tests renderer-pruefung.test.tsx

84,6 KB Rohdatei
app/tests/renderer-pruefung.test.tsx — 2188 Zeilen
1 /**
2 * Verhaltenstests der Prüfungssimulation.
3 *
4 * Geprüft wird, was in einer Prüfung tatsächlich zählt: dass das gewählte
5 * Profil unverändert im Auftrag landet, dass „Ohne Zeitbegrenzung“ wirklich
6 * keine Uhr startet, dass es vor der Abgabe keinerlei Rückmeldung gibt, dass
7 * Antworten bis zuletzt änderbar bleiben, dass ohne Rückfrage nichts
8 * abgegeben wird – und dass die Auswertung ihr Urteil begründet.
9 */
10
11 import { act, render, screen, waitFor, within } from '@testing-library/react';
12 import userEvent from '@testing-library/user-event';
13 import axe from 'axe-core';
14 import { jsdomOptionen } from '../src/shared/wcag';
15 import { afterEach, describe, expect, it, vi } from 'vitest';
16
17 import { App } from '../src/renderer/src/App';
18 import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
19 import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc';
20 import type { Erklaerungen } from '../src/shared/erklaerungen';
21 import type { Glossar } from '../src/shared/glossar';
22 import type { Lernplan } from '../src/shared/lernplan';
23 import type { Frage, Katalog, KatalogBild, RichText } from '../src/shared/katalog';
24 import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand';
25 import { restzeitGenau } from '../src/renderer/src/pruefung/zeit';
26 import { NORMTEXTE_LEER } from '../src/shared/normtexte';
27 import { THEMEN_LEER } from '../src/shared/themen';
28 import {
29 EUPL_HINWEIS,
30 EUPL_KENNUNG,
31 KATALOG_HINWEIS,
32 type Lizenzangaben,
33 } from '../src/shared/lizenzen';
34 import {
35 profilFinden,
36 type OffenerLauf,
37 type Pruefungsbogen,
38 type Pruefungsergebnis,
39 type Pruefungsfrage,
40 type Pruefungsverlauf,
41 } from '../src/shared/pruefung';
42
43 type Nutzer = ReturnType<typeof userEvent.setup>;
44
45 // ─── Katalogattrappe ────────────────────────────────────────────────────
46
47 function nurText(text: string): RichText {
48 return { text, segmente: [{ t: text }] };
49 }
50
51 /*
52 Ein Zeichen MIT hinterlegter Bedeutung: Der Prüfungslauf darf sie trotzdem
53 nie zeigen – in der echten Prüfung gibt es auch für Sehende keine
54 Erklärungen. Der Lernmodus-Test prüft die Gegenseite.
55 */
56 const ZEICHEN_PTB: KatalogBild = {
57 id: 'zeichen-ptb',
58 datei: 'zeichen-ptb.png',
59 breite: 73,
60 hoehe: 73,
61 alt: 'Rundes Zeichen mit der Buchstabenfolge PTB',
62 beschreibung:
63 'Zulassungszeichen der Physikalisch-Technischen Bundesanstalt für Schreckschuss-, Reizstoff- und Signalwaffen zugelassener Bauart.',
64 };
65
66 /** Multiple Choice mit genau einer richtigen Antwort. */
67 const FRAGE_EINFACH: Frage = {
68 id: 'I.1-87',
69 amtliche_nummer: '1.87',
70 kapitel: 'I',
71 abschnitt: 'I.1',
72 typ: 'mc',
73 seite: 23,
74 frage: nurText('Welcher der genannten Gegenstände ist eine Anscheinswaffe?'),
75 bilder: [],
76 optionen: [
77 {
78 label: 'a',
79 inhalt: nurText('Sportgewehr im Kaliber .223 Remington'),
80 korrekt: false,
81 bilder: [],
82 },
83 {
84 label: 'b',
85 inhalt: nurText('Schreckschusspistole mit Zulassungszeichen'),
86 korrekt: false,
87 bilder: ['zeichen-ptb'],
88 },
89 {
90 label: 'c',
91 inhalt: nurText('Spielzeugpistole ohne Unterscheidbarkeit'),
92 korrekt: true,
93 bilder: [],
94 },
95 ],
96 };
97
98 /** Multiple Choice mit zwei richtigen Antworten. */
99 const FRAGE_MEHRFACH: Frage = {
100 id: 'I.2-10',
101 amtliche_nummer: '2.10',
102 kapitel: 'I',
103 abschnitt: 'I.2',
104 typ: 'mc',
105 seite: 40,
106 frage: nurText('Welche Pflichten treffen den Inhaber einer Waffenbesitzkarte?'),
107 bilder: [],
108 optionen: [
109 {
110 label: 'a',
111 inhalt: nurText('Er muss die Waffe sorgfältig aufbewahren.'),
112 korrekt: true,
113 bilder: [],
114 },
115 {
116 label: 'b',
117 inhalt: nurText('Er muss den Erwerb unverzüglich anzeigen.'),
118 korrekt: true,
119 bilder: [],
120 },
121 {
122 label: 'c',
123 inhalt: nurText('Er muss die Waffe jährlich vorführen.'),
124 korrekt: false,
125 bilder: [],
126 },
127 ],
128 };
129
130 const FRAGE_NOTWEHR: Frage = {
131 id: 'I.5-04',
132 amtliche_nummer: '5.04',
133 kapitel: 'I',
134 abschnitt: 'I.5',
135 typ: 'mc',
136 seite: 88,
137 frage: nurText('Wann liegt Notwehr vor?'),
138 bilder: [],
139 optionen: [
140 {
141 label: 'a',
142 inhalt: nurText('Bei einem gegenwärtigen rechtswidrigen Angriff'),
143 korrekt: true,
144 bilder: [],
145 },
146 {
147 label: 'b',
148 inhalt: nurText('Bei einem bereits beendeten Angriff'),
149 korrekt: false,
150 bilder: [],
151 },
152 ],
153 };
154
155 /** Offene Frage mit Kernelementen – Grundlage der Nachbewertung. */
156 const FRAGE_FREITEXT: Frage = {
157 id: 'I.1-01',
158 amtliche_nummer: '1.01',
159 kapitel: 'I',
160 abschnitt: 'I.1',
161 typ: 'freitext',
162 seite: 6,
163 frage: nurText('Was regelt das Waffengesetz?'),
164 bilder: [],
165 musterantwort: {
166 text: 'Das Waffengesetz regelt den Umgang mit Waffen oder Munition.',
167 segmente: [
168 { t: 'Das Waffengesetz regelt den ' },
169 { t: 'Umgang mit Waffen oder Munition', h: true },
170 { t: '.' },
171 ],
172 },
173 };
174
175 /** Frage aus dem abwählbaren Kapitel IV. */
176 const FRAGE_KAPITEL_IV: Frage = {
177 id: 'IV-12',
178 amtliche_nummer: '4.12',
179 kapitel: 'IV',
180 abschnitt: null,
181 typ: 'mc',
182 seite: 210,
183 frage: nurText('Welche Munition ist für die Jagd auf Schalenwild zulässig?'),
184 bilder: [],
185 optionen: [
186 { label: 'a', inhalt: nurText('Deformationsgeschosse'), korrekt: true, bilder: [] },
187 { label: 'b', inhalt: nurText('Vollmantelgeschosse'), korrekt: false, bilder: [] },
188 ],
189 };
190
191 const KATALOG: Katalog = {
192 meta: {
193 titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)',
194 herausgeber: 'Bundesverwaltungsamt',
195 stand: '2024-12-16',
196 quellenangabe:
197 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.',
198 quelle_url: 'https://www.bva.bund.de/',
199 quelldatei_sha256: '7e5e41fa',
200 fragen_gesamt: 5,
201 },
202 kapitel: [
203 {
204 id: 'I',
205 titel: 'Waffenrecht und sonstige Rechtsvorschriften',
206 abschnitte: [
207 { id: 'I.1', titel: 'Begriffe des Waffenrechts' },
208 { id: 'I.2', titel: 'Rechte und Pflichten' },
209 { id: 'I.5', titel: 'Notwehr und Notstand' },
210 ],
211 },
212 { id: 'IV', titel: 'Jagdrecht', abschnitte: [] },
213 ],
214 bilder: [ZEICHEN_PTB],
215 fragen: [FRAGE_FREITEXT, FRAGE_EINFACH, FRAGE_MEHRFACH, FRAGE_NOTWEHR, FRAGE_KAPITEL_IV],
216 };
217
218 // ─── Bridge-Attrappe ────────────────────────────────────────────────────
219
220 const PROFIL: Profil = {
221 id: 1,
222 name: 'Testprofil',
223 pruefungstermin: null,
224 kapitelAusschluss: [],
225 erstelltAm: '2026-01-01T08:00:00.000Z',
226 };
227
228 const UEBERSICHT: Lernuebersicht = {
229 fragenGesamt: 575,
230 beantwortet: 120,
231 belegt: 48,
232 reifegrad: 48 / 575,
233 stufe: 'zurueck',
234 deckelnd: [],
235 faellig: 12,
236 gemerkt: 5,
237 offen: 0,
238 fehler: 0,
239 heuteRichtig: 8,
240 heuteFalsch: 2,
241 heuteBearbeitet: 0,
242 tageSeitLetzterAntwort: null,
243 bereiche: [],
244 themengruppen: [],
245 };
246
247 const STAND: FrageStand = {
248 frageId: 'I.1-87',
249 versuche: 1,
250 richtige: 1,
251 zuletztBeantwortet: '2026-01-01T08:00:00.000Z',
252 faelligAb: null,
253 gemerkt: false,
254 letzteBewertung: 'gut',
255 };
256
257 const INFO: AnwendungsInfo = {
258 anwendungsVersion: '0.0.0-attrappe',
259 baukennung: 'abc1234',
260 baustand: '2026-08-21',
261 electronVersion: '43.4.1',
262 chromeVersion: '140.0.0.0',
263 nodeVersion: '22.19.1',
264 plattform: 'win32',
265 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
266 };
267
268 const LIZENZEN: Lizenzangaben = {
269 eigene: { kennung: EUPL_KENNUNG, name: 'EUPL 1.2', hinweis: EUPL_HINWEIS, text: null },
270 katalog: {
271 quellenangabe: KATALOG.meta.quellenangabe,
272 quelleUrl: KATALOG.meta.quelle_url,
273 hinweis: KATALOG_HINWEIS,
274 },
275 dritte: [],
276 };
277
278 /** Ergebnis, das die Attrappe des Anwendungskerns liefert. */
279 const ERGEBNIS: Pruefungsergebnis = {
280 profilId: 'standard',
281 gesamt: 3,
282 richtig: 2,
283 falsch: 1,
284 unbeantwortet: 0,
285 quote: 2 / 3,
286 urteil: 'nachpruefung',
287 begruendung: '67 Prozent richtig. Bestanden wäre ab 80 Prozent.',
288 verletzteKriterien: ['Notwehr und Notstand: 3 Fehler bei höchstens 2 zulässigen'],
289 bereiche: [
290 { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 1, richtig: 1 },
291 { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 1 },
292 ],
293 fehlerIds: ['I.2-10'],
294 dauerMs: 95_000,
295 zeitAbgelaufen: false,
296 zeitpunkt: '2026-02-01T10:00:00.000Z',
297 };
298
299 const VERLAUF: Pruefungsverlauf[] = [
300 {
301 id: 7,
302 profilId: 'standard',
303 profilName: 'Standard',
304 zeitpunkt: '2026-01-20T09:30:00.000Z',
305 gesamt: 80,
306 richtig: 70,
307 quote: 0.875,
308 urteil: 'bestanden',
309 dauerMs: 71 * 60_000,
310 zeitmodus: 'normal',
311 unbeantwortet: 0,
312 zeitAbgelaufen: false,
313 bereiche: [
314 { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 10, richtig: 9 },
315 { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 10, richtig: 4 },
316 ],
317 bestehensQuote: 0.8,
318 },
319 {
320 // Ein Lauf aus der Zeit vor Schema-Version 4: Zeitstufe nicht vermerkt.
321 id: 6,
322 profilId: 'standard',
323 profilName: 'Standard',
324 zeitpunkt: '2026-01-19T09:30:00.000Z',
325 gesamt: 80,
326 richtig: 40,
327 quote: 0.5,
328 urteil: 'nicht_bestanden',
329 dauerMs: 95 * 60_000,
330 zeitmodus: null,
331 // Und aus derselben Zeit stammen die Angaben aus Fassung 10 nicht:
332 // „nicht festgehalten“ ist etwas anderes als „nichts offen geblieben“.
333 unbeantwortet: null,
334 zeitAbgelaufen: null,
335 bereiche: null,
336 bestehensQuote: null,
337 },
338 ];
339
340 function bogeneintrag(frage: Frage): Pruefungsfrage {
341 return {
342 frageId: frage.id,
343 optionsReihenfolge: (frage.optionen ?? []).map((option) => option.label),
344 };
345 }
346
347 /* Der Prüfungsbogen und die Lernsitzung sehen sich ähnlich, sind aber zwei
348 Verträge: Nur die Lernsitzung führt den Merkzustand mit. */
349 function sitzungseintrag(frage: Frage): SitzungsFrage {
350 return { ...bogeneintrag(frage), gemerkt: false };
351 }
352
353 const STANDARDFRAGEN: Pruefungsfrage[] = [
354 bogeneintrag(FRAGE_EINFACH),
355 bogeneintrag(FRAGE_MEHRFACH),
356 bogeneintrag(FRAGE_NOTWEHR),
357 ];
358
359 /** Ein Bogen ohne Abweichung – der Regelfall. */
360 const STANDARDBOGEN: Pruefungsbogen = { fragen: STANDARDFRAGEN, warnungen: [] };
361
362 /** Lernplan für die Attrappe – ohne Termin, damit nichts vom Datum abhängt. */
363 const LERNPLAN: Lernplan = {
364 termin: null,
365 tageBisTermin: null,
366 gesamtFragen: 8,
367 nieBeantwortet: 6,
368 faellig: 1,
369 zielquote: 0.9,
370 prognoseHeute: 0.25,
371 prognoseAmTermin: null,
372 pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 },
373 machbarkeit: 'kein_termin',
374 sekundenProFrage: 25,
375 };
376
377 /** Erklaerungen fuer die Attrappe – ein Eintrag genuegt fuer die Anzeige. */
378 const ERKLAERUNGEN: Erklaerungen = {
379 meta: {
380 version: 1,
381 stand: '2026-08-20',
382 gesetzesstand: { WaffG: 'Pruefstand' },
383 hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.',
384 },
385 zuFrage: {
386 'I.2-10': {
387 kurz: 'Der Erwerb ist binnen zwei Wochen bei der Behoerde anzuzeigen.',
388 text: 'Die Frist laeuft ab dem Erwerb, nicht ab dem Kaufvertrag. Versaeumt man sie, ist das eine Ordnungswidrigkeit - der Erwerb selbst bleibt davon unberuehrt.',
389 fundstellen: [{ gesetz: 'WaffG', norm: '§ 10', absatz: '1' }],
390 },
391 'I.1-01': {
392 kurz: 'Das Waffengesetz regelt den Umgang mit Waffen und Munition.',
393 text: 'Massgeblich ist § 1 Abs. 1 WaffG: Gegenstand ist der Umgang, Zweck die oeffentliche Sicherheit und Ordnung.',
394 fundstellen: [{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }],
395 merksatz: 'Gegenstand: der Umgang. Zweck: die Sicherheit.',
396 },
397 },
398 };
399
400 /** Glossar fuer die Attrappe – zwei Eintraege genuegen fuer die Anzeige. */
401 const GLOSSAR: Glossar = {
402 meta: {
403 version: 1,
404 stand: '2026-08-21',
405 gesetzesstand: { WaffG: 'Pruefstand' },
406 hinweis: 'Eigener redaktioneller Inhalt.',
407 },
408 eintraege: [
409 {
410 begriff: 'Führen',
411 art: 'begriff',
412 kurz: 'Die tatsächliche Gewalt über eine Waffe außerhalb der eigenen Wohnung ausüben.',
413 varianten: ['führt', 'führen', 'geführt'],
414 fundstellen: [{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 2 Nr. 4' }],
415 siehe: ['WaffG'],
416 },
417 {
418 begriff: 'WaffG',
419 art: 'abkuerzung',
420 kurz: 'Waffengesetz.',
421 varianten: ['WaffG', 'Waffengesetzes', 'Waffengesetz'],
422 fundstellen: [],
423 ohneFundstelleGrund:
424 'Die amtliche Kurzbezeichnung eines Gesetzes steht nicht im Gesetz selbst.',
425 },
426 ],
427 };
428
429 function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge {
430 return {
431 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
432 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
433 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
434 hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
435 hilfsmittelBeobachten: vi.fn(() => () => undefined),
436 menuebefehlBeobachten: vi.fn(() => () => undefined),
437 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
438 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
439 einstellungenLesen: vi.fn(() =>
440 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
441 ),
442 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
443 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
444 katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
445 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
446 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
447 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
448 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
449 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
450 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
451 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
452 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
453 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
454 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
455 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
456 lernSitzung: vi.fn(() => Promise.resolve([sitzungseintrag(FRAGE_EINFACH)])),
457 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
458 lernMerken: vi.fn(() => Promise.resolve(STAND)),
459 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
460 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
461 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
462 pruefungStarten: vi.fn(() => Promise.resolve(STANDARDBOGEN)),
463 pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)),
464 pruefungVerlauf: vi.fn(() => Promise.resolve(VERLAUF)),
465 pruefungSichern: vi.fn(() => Promise.resolve(true)),
466 pruefungOffen: vi.fn(() => Promise.resolve(null)),
467 pruefungVerwerfen: vi.fn(() => Promise.resolve()),
468 pruefungLaeuft: vi.fn(() => Promise.resolve()),
469 sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })),
470 fehlerprotokollDrucken: vi.fn(() =>
471 Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }),
472 ),
473 fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })),
474 lernberichtDrucken: vi.fn(() =>
475 Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }),
476 ),
477 /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt,
478 dass die Brücke den Kanal kennt. */
479 datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })),
480 lizenzenLesen: vi.fn(() => Promise.resolve(LIZENZEN)),
481 ...ueberschreibungen,
482 };
483 }
484
485 /** Bridge-Sicht, in der einzelne Kanäle fehlen dürfen. */
486 type LueckenhafteBruecke = {
487 -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] | undefined;
488 };
489
490 /** Bridge ohne einen bestimmten Kanal – der Anwendungskern entsteht parallel. */
491 function ohneKanal(kanal: keyof LernAppBridge): LueckenhafteBruecke {
492 const bruecke: LueckenhafteBruecke = { ...attrappe() };
493 bruecke[kanal] = undefined;
494 return bruecke;
495 }
496
497 /** Rendert die Anwendung und öffnet die Auswahlansicht der Prüfung. */
498 async function auswahlOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise<Nutzer> {
499 vi.stubGlobal('lernApp', bruecke);
500 const nutzer = userEvent.setup();
501 render(<App />);
502
503 const knopf = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
504 /* Der Knopf ist nie `disabled`, sondern meldet sich über `aria-disabled`
505 (Projektregel, docs/stand.md 7.13) – `toBeEnabled()` wäre hier immer wahr
506 und liefe dem Katalog nicht mehr hinterher. */
507 await waitFor(() => {
508 expect(knopf).toHaveAttribute('aria-disabled', 'false');
509 });
510 await nutzer.click(knopf);
511 await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten' });
512 return nutzer;
513 }
514
515 /** Öffnet die Auswahl und startet sofort mit dem vorgegebenen Profil. */
516 async function pruefungOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise<Nutzer> {
517 const nutzer = await auswahlOeffnen(bruecke);
518 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
519 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
520 return nutzer;
521 }
522
523 /**
524 * Bildet `window.speechSynthesis` nach und gibt zurück, was gesprochen wird.
525 *
526 * `getVoices()` liefert bewusst nichts – genau so verhält sich Electron
527 * unter Windows vor der ersten Äußerung.
528 */
529 function sprachausgabeStellen(): string[] {
530 const gesprochen: string[] = [];
531
532 class AeusserungAttrappe {
533 lang = '';
534 voice: unknown = null;
535 onend: (() => void) | null = null;
536 onerror: ((ereignis: { error: string }) => void) | null = null;
537 constructor(public readonly text: string) {}
538 }
539
540 vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
541 vi.stubGlobal('speechSynthesis', {
542 speak: (aeusserung: AeusserungAttrappe) => gesprochen.push(aeusserung.text),
543 cancel: () => undefined,
544 getVoices: () => [],
545 speaking: false,
546 pending: false,
547 paused: false,
548 });
549
550 return gesprochen;
551 }
552
553 /** Die Live-Region der Prüfung – nicht die des Themenwechsels. */
554 function ansagebereich(): HTMLElement {
555 const bereich = screen.getByRole('region', { name: /Prüfungssimulation:/u });
556 return within(bereich).getByRole('status');
557 }
558
559 afterEach(() => {
560 vi.unstubAllGlobals();
561 vi.useRealTimers();
562 /* Die Erklaerungen liegen in einem Modul-weiten Zwischenspeicher – ohne
563 Leeren wuerde der naechste Test die Antwort des vorigen sehen. */
564 erklaerungenZwischenspeicherLeeren();
565 });
566
567 // ─── Auswahlansicht ─────────────────────────────────────────────────────
568
569 describe('Prüfungswahl – Profile', () => {
570 it('stellt alle fünf Profile mit ihren Eckdaten zur Wahl', async () => {
571 await auswahlOeffnen();
572
573 const gruppe = screen.getByRole('group', { name: 'Prüfungsprofil' });
574 expect(within(gruppe).getAllByRole('radio')).toHaveLength(5);
575 expect(
576 within(gruppe).getByRole('radio', {
577 name: /Standard.*80 Fragen, 120 Minuten, bestanden ab 80 Prozent richtig/su,
578 }),
579 ).toBeChecked();
580 expect(within(gruppe).getByRole('radio', { name: /höchstens 15 Fehler/u })).toBeInTheDocument();
581 expect(screen.getByText(/Zwischen 60 und 74 Prozent/u)).toBeInTheDocument();
582 });
583
584 it('übergibt das gewählte Profil unverändert an den Anwendungskern', async () => {
585 const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
586 const nutzer = await auswahlOeffnen(attrappe({ pruefungStarten }));
587
588 await nutzer.click(screen.getByRole('radio', { name: /Nach Art des BDMP/u }));
589 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
590 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
591
592 expect(pruefungStarten).toHaveBeenCalledWith(1, {
593 profilId: 'bdmp',
594 zeitmodus: 'normal',
595 kapitelAusschluss: [],
596 /* Kommt nicht aus der Prüfungswahl, sondern aus den Einstellungen –
597 und steht ab Werk auf „aus“: Die Simulation soll dem gedruckten
598 Bogen nahekommen, und wer die Antworten über ihre Stelle behalten
599 muss, braucht die feste Reihenfolge gerade hier. */
600 optionenMischen: false,
601 });
602 });
603
604 /*
605 Die Sprachausgabe muss auch hier zur Verfügung stehen. Wer den Text
606 hören muss, weil ihm Lesen schwerfällt, bräuchte die Hilfe sonst
607 überall außer in der Prüfungsvorbereitung – also dort, wo es am meisten
608 darauf ankommt. Vorgelesen wird nur die Frage: Eine Rückmeldung gibt es
609 in der Simulation von niemandem.
610 */
611 it('liest die Frage auch in der Simulation vor', async () => {
612 const gesprochen = sprachausgabeStellen();
613 const nutzer = await pruefungOeffnen(
614 attrappe({
615 einstellungenLesen: vi.fn(() =>
616 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
617 ),
618 }),
619 );
620
621 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
622
623 expect(gesprochen.join(' ')).toContain('Anscheinswaffe');
624 expect(screen.getByRole('button', { name: /Vorlesen beenden/u })).toBeInTheDocument();
625 });
626
627 it('schweigt, sobald geblättert wird', async () => {
628 /*
629 Gemeldet von einem Nutzer für die Lernsitzung; der Prüfungslauf hatte
630 denselben Fehler. Hier wiegt er schwerer: Im Lauf lässt sich in beide
631 Richtungen blättern, und wer die vorige Frage weiterhört, während die
632 nächste dasteht, beantwortet unter Umständen die falsche.
633 */
634 sprachausgabeStellen();
635 const nutzer = await pruefungOeffnen(
636 attrappe({
637 einstellungenLesen: vi.fn(() =>
638 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
639 ),
640 }),
641 );
642
643 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
644 expect(screen.getByRole('button', { name: /Vorlesen beenden/u })).toBeInTheDocument();
645
646 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
647
648 expect(screen.queryByRole('button', { name: /Vorlesen beenden/u })).toBeNull();
649 expect(screen.getByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument();
650 });
651
652 it('schließt Kapitel IV auf Wunsch aus', async () => {
653 const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
654 const nutzer = await auswahlOeffnen(attrappe({ pruefungStarten }));
655
656 await nutzer.click(screen.getByRole('checkbox', { name: /Kapitel IV – Jagdrecht/u }));
657 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
658 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
659
660 expect(pruefungStarten).toHaveBeenCalledWith(
661 1,
662 expect.objectContaining({ kapitelAusschluss: ['IV'] }),
663 );
664 });
665
666 /*
667 Die Erststart-Frage sagt zu, dass abgewählte Fragen „in keiner Sitzung und
668 in keiner Zahl mehr“ vorkommen. Bis Fassung 0.20.0 stand das Kästchen hier
669 trotzdem fest auf „einbeziehen“: Wer Kapitel IV abgewählt hatte, musste 89
670 von 575 Fragen in jeder Simulation von Hand wieder abwählen.
671 */
672 it('übernimmt den Kapitelausschluss des Lernprofils als Vorgabe', async () => {
673 const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
674 const nutzer = await auswahlOeffnen(
675 attrappe({
676 pruefungStarten,
677 profilListe: vi.fn(() => Promise.resolve([{ ...PROFIL, kapitelAusschluss: ['IV'] }])),
678 }),
679 );
680
681 const kaestchen = screen.getByRole('checkbox', { name: /Kapitel IV – Jagdrecht/u });
682
683 expect(kaestchen).not.toBeChecked();
684 /* Woher die Vorbelegung kommt, gehört zum Kästchen: Wer sich per Tabulator
685 durchs Formular arbeitet, hört sonst nur „nicht angehakt“. */
686 expect(kaestchen).toHaveAccessibleDescription(/unter „Ihr Lernplan“ abgewählt/u);
687
688 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
689 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
690
691 expect(pruefungStarten).toHaveBeenCalledWith(
692 1,
693 expect.objectContaining({ kapitelAusschluss: ['IV'] }),
694 );
695 });
696
697 /* Vorgabe heißt Vorgabe, nicht Sperre: Für eine einzelne Simulation darf
698 das Kapitel zurückkommen – am Lernplan ändert das nichts. */
699 it('lässt den Ausschluss für eine einzelne Simulation zurücknehmen', async () => {
700 const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
701 const profilAktualisieren = vi.fn(() => Promise.resolve(PROFIL));
702 const nutzer = await auswahlOeffnen(
703 attrappe({
704 pruefungStarten,
705 profilAktualisieren,
706 profilListe: vi.fn(() => Promise.resolve([{ ...PROFIL, kapitelAusschluss: ['IV'] }])),
707 }),
708 );
709
710 await nutzer.click(screen.getByRole('checkbox', { name: /Kapitel IV – Jagdrecht/u }));
711 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
712 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
713
714 expect(pruefungStarten).toHaveBeenCalledWith(
715 1,
716 expect.objectContaining({ kapitelAusschluss: [] }),
717 );
718 expect(profilAktualisieren).not.toHaveBeenCalled();
719 });
720
721 /* Projektregel „aria-disabled statt disabled“ (docs/stand.md 7.13): Ein
722 gesperrter Knopf fällt aus der Tabulatorreihenfolge, und der Fokus landet
723 auf `<body>`. Der Startknopf trug bis Fassung 0.20.0 natives `disabled`. */
724 it('sperrt den Startknopf nicht mit disabled', async () => {
725 await auswahlOeffnen();
726
727 const knopf = screen.getByRole('button', { name: 'Simulation starten' });
728
729 expect(knopf).not.toBeDisabled();
730 knopf.focus();
731 expect(knopf).toHaveFocus();
732 });
733
734 it('übernimmt die frei eingestellten Werte in den Auftrag', async () => {
735 const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
736 const nutzer = await auswahlOeffnen(attrappe({ pruefungStarten }));
737
738 await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
739
740 const fragenfeld = screen.getByRole('spinbutton', { name: 'Fragen im Bogen' });
741 await nutzer.clear(fragenfeld);
742 await nutzer.type(fragenfeld, '12');
743
744 const zeitfeld = screen.getByRole('spinbutton', { name: 'Bearbeitungszeit' });
745 await nutzer.clear(zeitfeld);
746 await nutzer.type(zeitfeld, '30');
747
748 const grenzfeld = screen.getByRole('spinbutton', { name: 'Bestehensgrenze' });
749 await nutzer.clear(grenzfeld);
750 await nutzer.type(grenzfeld, '70');
751
752 const offenfeld = screen.getByRole('spinbutton', { name: 'Anteil offener Fragen' });
753 await nutzer.clear(offenfeld);
754 await nutzer.type(offenfeld, '25');
755
756 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
757 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
758
759 expect(pruefungStarten).toHaveBeenCalledWith(1, {
760 profilId: 'frei',
761 zeitmodus: 'normal',
762 kapitelAusschluss: [],
763 optionenMischen: false,
764 fragenAnzahl: 12,
765 bestehensQuote: 0.7,
766 zeitMinuten: 30,
767 anteilOffen: 0.25,
768 });
769 });
770
771 /*
772 Der Regler ist ein Wunsch, keine Zusage. Damit das niemanden überrascht,
773 muss die Wahl es vorher sagen: Die Warnung des Anwendungskerns kommt zwar
774 inzwischen bis auf den Bildschirm, aber erst im laufenden Bogen.
775 */
776 it('rechnet den Anteil in Fragen um und nennt den Vorrat', async () => {
777 const nutzer = await auswahlOeffnen(attrappe({}));
778 await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
779
780 const fragenfeld = screen.getByRole('spinbutton', { name: 'Fragen im Bogen' });
781 await nutzer.clear(fragenfeld);
782 await nutzer.type(fragenfeld, '20');
783
784 const offenfeld = screen.getByRole('spinbutton', { name: 'Anteil offener Fragen' });
785 await nutzer.clear(offenfeld);
786 await nutzer.type(offenfeld, '50');
787
788 // 50 Prozent von 20 sind 10; der Testkatalog hat weniger.
789 const hinweis = await screen.findByText(/Das ergibt 10 offene Fragen/u);
790 expect(hinweis).toHaveTextContent(/mit Auswahlfragen aufgefüllt/u);
791 });
792
793 it('sagt bei null Prozent, dass der Bogen nur aus Auswahlfragen besteht', async () => {
794 const nutzer = await auswahlOeffnen(attrappe({}));
795 await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
796
797 expect(await screen.findByText(/ausschließlich aus Auswahlfragen/u)).toBeInTheDocument();
798 });
799 });
800
801 describe('Prüfungswahl – Grenzwerte der freien Eingabe', () => {
802 /*
803 Werte außerhalb der Grenzen werden ohnehin gekappt. Sie stillschweigend zu
804 korrigieren wäre aber irreführend: Im Feld stünde 900, gerechnet würde mit
805 575. Der wirksame Wert muss deshalb dastehen (WCAG 3.3.1 und 3.3.3).
806 */
807 async function freiOeffnen(): Promise<Nutzer> {
808 const nutzer = await auswahlOeffnen();
809 await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
810 return nutzer;
811 }
812
813 it('meldet eine zu große Fragenzahl und nennt den wirksamen Wert', async () => {
814 const nutzer = await freiOeffnen();
815 const feld = screen.getByLabelText('Fragen im Bogen');
816
817 await nutzer.clear(feld);
818 await nutzer.type(feld, '900');
819
820 expect(feld).toHaveAttribute('aria-invalid', 'true');
821 expect(screen.getByText(/Derzeit wird mit 575 gerechnet/u)).toBeInTheDocument();
822 });
823
824 it('meldet ein leeres Feld und nennt den wirksamen Wert', async () => {
825 const nutzer = await freiOeffnen();
826 const feld = screen.getByLabelText('Fragen im Bogen');
827
828 await nutzer.clear(feld);
829
830 expect(feld).toHaveAttribute('aria-invalid', 'true');
831 expect(screen.getByText(/Derzeit wird mit 5 gerechnet/u)).toBeInTheDocument();
832 });
833
834 it('meldet einen gültigen Wert nicht als Fehler', async () => {
835 const nutzer = await freiOeffnen();
836 const feld = screen.getByLabelText('Fragen im Bogen');
837
838 await nutzer.clear(feld);
839 await nutzer.type(feld, '25');
840
841 expect(feld).toHaveAttribute('aria-invalid', 'false');
842 expect(screen.queryByText(/Derzeit wird mit/u)).not.toBeInTheDocument();
843 });
844 });
845
846 describe('Prüfungswahl – Zeitstufen (WCAG 2.2.1)', () => {
847 it('bietet alle vier Stufen einschließlich „Ohne Zeitbegrenzung“ gleichrangig an', async () => {
848 await auswahlOeffnen();
849
850 const gruppe = screen.getByRole('group', { name: 'Zeitvorgabe' });
851 const stufen = within(gruppe).getAllByRole('radio');
852 expect(stufen.map((stufe) => stufe.getAttribute('value'))).toEqual([
853 'aus',
854 'normal',
855 'plus25',
856 'plus50',
857 ]);
858
859 expect(within(gruppe).getByRole('radio', { name: 'Ohne Zeitbegrenzung' })).toBeEnabled();
860 expect(
861 within(gruppe).getByRole('radio', { name: 'Vorgesehene Zeit – 120 Minuten' }),
862 ).toBeChecked();
863 expect(
864 within(gruppe).getByRole('radio', {
865 name: 'Vorgesehene Zeit plus 25 Prozent – 150 Minuten',
866 }),
867 ).toBeInTheDocument();
868 expect(
869 within(gruppe).getByRole('radio', {
870 name: 'Vorgesehene Zeit plus 50 Prozent – 180 Minuten',
871 }),
872 ).toBeInTheDocument();
873 });
874
875 it('kündigt die automatische Abgabe vorab an', async () => {
876 const nutzer = await auswahlOeffnen();
877
878 expect(
879 screen.getByText(/wird die Simulation automatisch abgegeben und ausgewertet/u),
880 ).toBeInTheDocument();
881
882 await nutzer.click(screen.getByRole('radio', { name: 'Ohne Zeitbegrenzung' }));
883 expect(
884 screen.getByText('Es läuft keine Uhr mit. Sie geben ab, wann Sie möchten.'),
885 ).toBeInTheDocument();
886 });
887 });
888
889 // ─── Prüfungsansicht ────────────────────────────────────────────────────
890
891 describe('Prüfungsansicht – Aufbau', () => {
892 /*
893 Der Sekundentakt der Restzeit ist der einzige Zeitgeber der Prüfung.
894 Andere Intervalle – Testing Library prüft im 50-Millisekunden-Takt, ob eine
895 Zusicherung schon zutrifft – bleiben außen vor.
896 */
897 function sekundentakte(aufrufe: readonly (readonly unknown[])[]): number {
898 return aufrufe.filter((aufruf) => aufruf[1] === 1000).length;
899 }
900
901 it('meldet den Fortschritt als progressbar und als Text', async () => {
902 await pruefungOeffnen();
903
904 const balken = screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' });
905 expect(balken).toHaveAttribute('aria-valuenow', '1');
906 expect(balken).toHaveAttribute('aria-valuemax', '3');
907 expect(balken).toHaveAttribute('aria-valuetext', 'Frage 1 von 3');
908 expect(screen.getByText('Frage 1 von 3')).toBeInTheDocument();
909 });
910
911 it('startet mit Zeitmodus „aus“ keinen Zeitgeber', async () => {
912 const intervall = vi.spyOn(window, 'setInterval');
913 const nutzer = await auswahlOeffnen();
914
915 await nutzer.click(screen.getByRole('radio', { name: 'Ohne Zeitbegrenzung' }));
916 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
917 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
918
919 expect(screen.getByText('Ohne Zeitbegrenzung')).toBeInTheDocument();
920 expect(
921 screen.getByText('Es läuft keine Uhr mit. Sie geben ab, wann Sie möchten.'),
922 ).toBeInTheDocument();
923 expect(sekundentakte(intervall.mock.calls)).toBe(0);
924 });
925
926 it('zeigt die Restzeit als vorlesbaren Text und lässt dafür die Uhr laufen', async () => {
927 const intervall = vi.spyOn(window, 'setInterval');
928 await pruefungOeffnen();
929
930 expect(screen.getByText('Restzeit:')).toBeInTheDocument();
931 expect(screen.getByText('2 Stunden')).toBeInTheDocument();
932 expect(sekundentakte(intervall.mock.calls)).toBeGreaterThan(0);
933 });
934 });
935
936 /*
937 Befund 7.10: Der Anwendungskern meldete vier Abweichungen beim
938 Zusammenstellen eines Bogens ausschließlich über `console.warn` in das
939 Protokoll des Hauptprozesses. Wer simulierte, hielt seinen Bogen für
940 profilgetreu. Sie hängen jetzt am Bogen und stehen über den Fragen.
941 */
942 describe('Prüfungsansicht – Abweichungen des Bogens', () => {
943 function mitWarnungen(warnungen: readonly string[]): LueckenhafteBruecke {
944 return attrappe({
945 pruefungStarten: vi.fn(() => Promise.resolve({ fragen: STANDARDFRAGEN, warnungen })),
946 });
947 }
948
949 it('gibt eine einzelne Abweichung als angesagten Hinweis aus', async () => {
950 await pruefungOeffnen(
951 mitWarnungen([
952 'Im Bogen: 2 offene Fragen wurden durch 2 Auswahlfragen ersetzt – so viele offene Fragen hat der Katalog dort nicht.',
953 ]),
954 );
955
956 /* `role="alert"`, weil der Bogen erst nach dem Bildaufbau eintrifft:
957 Ohne Ansage bliebe die Auskunft Sehenden vorbehalten. */
958 const meldung = screen.getByRole('alert');
959
960 expect(meldung).toHaveTextContent('Dieser Bogen weicht von den Vorgaben ab.');
961 expect(meldung).toHaveTextContent('durch 2 Auswahlfragen ersetzt');
962 });
963
964 it('listet mehrere Abweichungen einzeln auf', async () => {
965 await pruefungOeffnen(
966 mitWarnungen([
967 'Im Themenbereich „I.2 – Rechte und Pflichten“: Vorgesehen sind 20 Fragen, verfügbar sind 6.',
968 'Das Profil „Standard“ hat feste Vorgaben: die Fragenzahl wurde nicht übernommen.',
969 ]),
970 );
971
972 const meldung = screen.getByRole('alert');
973 const punkte = within(meldung).getAllByRole('listitem');
974
975 expect(punkte).toHaveLength(2);
976 expect(punkte[0]).toHaveTextContent('Im Themenbereich „I.2 – Rechte und Pflichten“');
977 expect(punkte[1]).toHaveTextContent('die Fragenzahl wurde nicht übernommen');
978 });
979
980 it('schweigt, wenn der Bogen den Vorgaben folgt', async () => {
981 await pruefungOeffnen();
982
983 expect(screen.queryByRole('alert')).toBeNull();
984 });
985 });
986
987 /*
988 Projektregel „aria-disabled statt disabled“ (docs/stand.md 7.13): An den
989 Enden des Bogens verschwand der jeweilige Blätterknopf im letzten Klick unter
990 dem Finger aus der Tabulatorreihenfolge, und der Fokus fiel auf `<body>`.
991 */
992 describe('Prüfungsansicht – Blättern an den Enden des Bogens', () => {
993 it('hält „Vorherige Frage“ bei Frage 1 erreichbar und nennt den Grund', async () => {
994 const nutzer = await pruefungOeffnen();
995 const zurueck = screen.getByRole('button', { name: 'Vorherige Frage' });
996
997 expect(zurueck).not.toBeDisabled();
998 expect(zurueck).toHaveAttribute('aria-disabled', 'true');
999 expect(zurueck).toHaveAccessibleDescription(
1000 'Sie sind bei der ersten Frage; zurück geht es von hier nicht.',
1001 );
1002
1003 zurueck.focus();
1004 expect(zurueck).toHaveFocus();
1005
1006 await nutzer.click(zurueck);
1007
1008 expect(screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' })).toHaveAttribute(
1009 'aria-valuenow',
1010 '1',
1011 );
1012 });
1013
1014 it('hält „Nächste Frage“ bei der letzten Frage erreichbar und nennt den Grund', async () => {
1015 const nutzer = await pruefungOeffnen();
1016 const weiter = screen.getByRole('button', { name: 'Nächste Frage' });
1017
1018 expect(weiter).not.toHaveAttribute('aria-disabled', 'true');
1019 await nutzer.click(weiter);
1020 await nutzer.click(weiter);
1021
1022 expect(weiter).toHaveAttribute('aria-disabled', 'true');
1023 expect(weiter).toHaveAccessibleDescription(
1024 'Sie sind bei der letzten Frage; weiter geht es von hier nicht.',
1025 );
1026
1027 await nutzer.click(weiter);
1028
1029 expect(screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' })).toHaveAttribute(
1030 'aria-valuenow',
1031 '3',
1032 );
1033 });
1034 });
1035
1036 describe('Prüfungsansicht – kein Feedback vor der Abgabe', () => {
1037 it('bewertet eine Antwort nicht und verrät die Lösung nicht', async () => {
1038 const nutzer = await pruefungOeffnen();
1039
1040 await nutzer.click(screen.getByRole('checkbox', { name: /Sportgewehr/u }));
1041
1042 expect(screen.queryByRole('group', { name: 'Richtig' })).not.toBeInTheDocument();
1043 expect(screen.queryByRole('group', { name: 'Falsch' })).not.toBeInTheDocument();
1044 expect(screen.queryByText(/Richtig – nicht gewählt/u)).not.toBeInTheDocument();
1045 expect(screen.queryByText(/Falsch – von Ihnen gewählt/u)).not.toBeInTheDocument();
1046 expect(screen.queryByRole('heading', { name: 'Musterantwort' })).not.toBeInTheDocument();
1047 });
1048
1049 it('reicht im Prüfungslauf keine Bedeutung der Zeichen nach', async () => {
1050 /*
1051 Die zweite Stufe des Bildbeschreibungskonzepts gehört in den Lernmodus.
1052 In der echten Prüfung gibt es keine Erklärungen – also auch hier nicht,
1053 obwohl das Zeichen an Option b) eine Beschreibung hinterlegt hat. Der
1054 neutrale Alt-Text bleibt selbstverständlich stehen.
1055 */
1056 const nutzer = await pruefungOeffnen();
1057
1058 expect(
1059 screen.getByRole('img', { name: 'Rundes Zeichen mit der Buchstabenfolge PTB' }),
1060 ).toBeInTheDocument();
1061 expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();
1062
1063 await nutzer.click(screen.getByRole('checkbox', { name: /Schreckschusspistole/u }));
1064
1065 expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();
1066 expect(screen.queryByText(/Physikalisch-Technischen Bundesanstalt/u)).not.toBeInTheDocument();
1067 });
1068
1069 it('lässt die Antwort bis zur Abgabe ändern', async () => {
1070 const nutzer = await pruefungOeffnen();
1071
1072 const erste = screen.getByRole('checkbox', { name: /Sportgewehr/u });
1073 const dritte = screen.getByRole('checkbox', { name: /Spielzeugpistole/u });
1074
1075 await nutzer.click(erste);
1076 expect(erste).toBeChecked();
1077
1078 // Abwählen ist möglich …
1079 await nutzer.click(erste);
1080 expect(erste).not.toBeChecked();
1081
1082 // … und eine andere Option lässt sich stattdessen wählen.
1083 await nutzer.click(dritte);
1084 expect(dritte).toBeChecked();
1085 });
1086
1087 it('sammelt die Auswahl auch bei Fragen mit nur einer richtigen Antwort', async () => {
1088 /*
1089 In der Prüfung steht nicht dabei, wie viele Antworten richtig sind. Würde
1090 die Oberfläche bei Einfachfragen die vorige Auswahl ersetzen, verriete
1091 schon dieses Verhalten die Lösung. Zu viel angekreuzt zu haben ist
1092 schlicht falsch – genau wie in der echten Prüfung.
1093 */
1094 const nutzer = await pruefungOeffnen();
1095
1096 const erste = screen.getByRole('checkbox', { name: /Sportgewehr/u });
1097 const dritte = screen.getByRole('checkbox', { name: /Spielzeugpistole/u });
1098
1099 await nutzer.click(erste);
1100 await nutzer.click(dritte);
1101
1102 expect(erste).toBeChecked();
1103 expect(dritte).toBeChecked();
1104 });
1105
1106 it('stellt Einfach- und Mehrfachfragen ununterscheidbar dar', async () => {
1107 /*
1108 Der Kern des Befunds: Weder die Art des Bedienelements noch die
1109 Beschriftung darf verraten, wie viele Antworten richtig sind. Die
1110 Beschriftung nennt nur, was auch im amtlichen Vorwort steht.
1111 */
1112 const nutzer = await pruefungOeffnen();
1113
1114 // Erste Frage: genau eine richtige Antwort.
1115 expect(screen.queryAllByRole('radio')).toHaveLength(0);
1116 expect(
1117 screen.getByText(/Antwortmöglichkeiten – mindestens eine Antwort ist richtig/u),
1118 ).toBeInTheDocument();
1119 expect(screen.queryByText(/Mehrere Antworten sind richtig\./u)).not.toBeInTheDocument();
1120
1121 // Zweite Frage: mehrere richtige Antworten – gleiche Darstellung.
1122 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1123
1124 expect(screen.queryAllByRole('radio')).toHaveLength(0);
1125 expect(
1126 screen.getByText(/Antwortmöglichkeiten – mindestens eine Antwort ist richtig/u),
1127 ).toBeInTheDocument();
1128 expect(screen.queryByText(/Mehrere Antworten sind richtig\./u)).not.toBeInTheDocument();
1129 });
1130
1131 it('behält die Antwort beim Blättern bei', async () => {
1132 const nutzer = await pruefungOeffnen();
1133
1134 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1135 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1136 await screen.findByRole('heading', { name: /Frage 2\.10, Frage \d+ von \d+$/u, level: 2 });
1137
1138 await nutzer.click(screen.getByRole('button', { name: 'Vorherige Frage' }));
1139 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
1140
1141 expect(screen.getByRole('checkbox', { name: /Spielzeugpistole/u })).toBeChecked();
1142 });
1143 });
1144
1145 describe('Prüfungsansicht – Fragenübersicht', () => {
1146 it('unterscheidet beantwortet und offen im zugänglichen Namen, nicht nur farblich', async () => {
1147 const nutzer = await pruefungOeffnen();
1148
1149 expect(
1150 screen.getByRole('button', { name: 'Frage 1 von 3, offen, aktuelle Frage' }),
1151 ).toBeInTheDocument();
1152 expect(screen.getByRole('button', { name: 'Frage 2 von 3, offen' })).toBeInTheDocument();
1153 expect(screen.getByText('0 von 3 Fragen beantwortet, 3 noch offen.')).toBeInTheDocument();
1154
1155 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1156
1157 expect(
1158 screen.getByRole('button', { name: 'Frage 1 von 3, beantwortet, aktuelle Frage' }),
1159 ).toHaveAttribute('aria-current', 'true');
1160 expect(screen.getByText('1 von 3 Fragen beantwortet, 2 noch offen.')).toBeInTheDocument();
1161 });
1162
1163 it('springt frei zu einer anderen Frage', async () => {
1164 const nutzer = await pruefungOeffnen();
1165
1166 await nutzer.click(screen.getByRole('button', { name: 'Frage 3 von 3, offen' }));
1167
1168 expect(
1169 await screen.findByRole('heading', { name: /Frage 5\.04, Frage \d+ von \d+$/u, level: 2 }),
1170 ).toHaveFocus();
1171 expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument();
1172 });
1173 });
1174
1175 describe('Prüfungsansicht – Abgabe', () => {
1176 it('gibt erst nach ausdrücklicher Rückfrage ab und nennt die offenen Fragen', async () => {
1177 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1178 const nutzer = await pruefungOeffnen(attrappe({ pruefungAuswerten }));
1179
1180 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1181 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1182
1183 expect(pruefungAuswerten).not.toHaveBeenCalled();
1184 const rueckfrage = screen.getByRole('group', { name: 'Simulation wirklich abgeben?' });
1185 expect(rueckfrage).toHaveFocus();
1186 expect(
1187 within(rueckfrage).getByText(
1188 /Sie haben 1 von 3 Fragen beantwortet\. 2 Fragen sind noch offen/u,
1189 ),
1190 ).toBeInTheDocument();
1191
1192 await nutzer.click(screen.getByRole('button', { name: 'Weiter bearbeiten' }));
1193 expect(pruefungAuswerten).not.toHaveBeenCalled();
1194 expect(
1195 screen.queryByRole('group', { name: 'Simulation wirklich abgeben?' }),
1196 ).not.toBeInTheDocument();
1197 /* Der Fokus gehört zurück auf den auslösenden Knopf. Landete er auf der
1198 Fragenüberschrift, müsste man sich durch alle Antwortoptionen
1199 zurückarbeiten, um wieder zu den Schaltflächen zu kommen. */
1200 expect(screen.getByRole('button', { name: 'Simulation abgeben' })).toHaveFocus();
1201
1202 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1203 await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
1204
1205 await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
1206 expect(pruefungAuswerten).toHaveBeenCalledTimes(1);
1207 expect(pruefungAuswerten).toHaveBeenCalledWith(
1208 1,
1209 expect.objectContaining({ profilId: 'standard' }),
1210 [
1211 { frageId: 'I.1-87', auswahl: ['c'], freitext: '' },
1212 { frageId: 'I.2-10', auswahl: [], freitext: '' },
1213 { frageId: 'I.5-04', auswahl: [], freitext: '' },
1214 ],
1215 expect.any(Number),
1216 false,
1217 );
1218 });
1219
1220 it('lässt den Lauf jederzeit ohne Auswertung verwerfen', async () => {
1221 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1222 const nutzer = await pruefungOeffnen(attrappe({ pruefungAuswerten }));
1223
1224 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1225 await nutzer.click(screen.getByRole('button', { name: 'Ohne Auswertung verwerfen' }));
1226
1227 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
1228 expect(pruefungAuswerten).not.toHaveBeenCalled();
1229 });
1230 });
1231
1232 describe('Prüfungsansicht – Zeitablauf', () => {
1233 /**
1234 * Startet mit dem frei einstellbaren Profil und der angegebenen Minutenzahl.
1235 *
1236 * `shouldAdvanceTime` ist hier nicht optional: Testing Library erkennt nur
1237 * Jests Zeitattrappe und wartet sonst auf echte Zeitgeber, die die Attrappe
1238 * angehalten hat – der Lauf bliebe stehen.
1239 */
1240 async function mitZeit(
1241 minuten: string,
1242 pruefungAuswerten: () => Promise<Pruefungsergebnis>,
1243 ): Promise<Nutzer> {
1244 vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 10 });
1245 vi.stubGlobal('lernApp', attrappe({ pruefungAuswerten }));
1246 const nutzer = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
1247 render(<App />);
1248
1249 const start = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
1250 /* Der Knopf ist nie `disabled`, sondern meldet sich über `aria-disabled`
1251 (Projektregel, docs/stand.md 7.13) – `toBeEnabled()` wäre hier immer
1252 wahr und liefe dem Katalog nicht mehr hinterher. */
1253 await waitFor(() => {
1254 expect(start).toHaveAttribute('aria-disabled', 'false');
1255 });
1256 await nutzer.click(start);
1257 await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten' });
1258
1259 await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
1260 const zeitfeld = screen.getByRole('spinbutton', { name: 'Bearbeitungszeit' });
1261 await nutzer.clear(zeitfeld);
1262 await nutzer.type(zeitfeld, minuten);
1263 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
1264 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
1265 return nutzer;
1266 }
1267
1268 it('weist fünf Minuten vor Schluss einmalig höflich auf die Abgabe hin', async () => {
1269 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1270 await mitZeit('10', pruefungAuswerten);
1271
1272 const region = ansagebereich();
1273 expect(region).toHaveAttribute('aria-live', 'polite');
1274 expect(region).toHaveTextContent('');
1275
1276 await act(async () => {
1277 await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 2000);
1278 });
1279
1280 await waitFor(() => {
1281 expect(ansagebereich()).toHaveTextContent(
1282 'Noch fünf Minuten Restzeit. Danach wird die Simulation automatisch abgegeben und ausgewertet.',
1283 );
1284 });
1285 expect(pruefungAuswerten).not.toHaveBeenCalled();
1286 });
1287
1288 it('gibt bei abgelaufener Zeit selbsttätig ab', async () => {
1289 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1290 await mitZeit('1', pruefungAuswerten);
1291
1292 await act(async () => {
1293 await vi.advanceTimersByTimeAsync(61_000);
1294 });
1295
1296 await waitFor(() => {
1297 expect(pruefungAuswerten).toHaveBeenCalledTimes(1);
1298 });
1299 expect(pruefungAuswerten).toHaveBeenCalledWith(
1300 1,
1301 expect.objectContaining({ profilId: 'frei' }),
1302 expect.any(Array),
1303 expect.any(Number),
1304 true,
1305 );
1306 });
1307 });
1308
1309 // ─── Energiesparmodus ───────────────────────────────────────────────────
1310
1311 /*
1312 Die Uhr der Simulation hing bis 0.22.0 an der Wanduhr: `endeMs` und
1313 `verbrauchtMs` leiten sich beide aus `startMs` ab, und niemand fragte, ob
1314 der Rechner zwischendurch schlief. Wer mitten in einem Bogen den Laptop
1315 zuklappte, verbrauchte die Pause als Bearbeitungszeit – und war beim
1316 Aufwachen abgelaufen. Der Hauptprozess misst die Schlafdauer
1317 (`tests/energie.test.ts`), hier wird die andere Hälfte geprüft: dass die
1318 Oberfläche daraus wirklich eine stillstehende Uhr macht.
1319 */
1320 describe('Prüfungsansicht – Energiesparmodus', () => {
1321 /** Startet einen Lauf und gibt die Hand auf die Schlafmeldung zurück. */
1322 async function mitSchlafmeldung(
1323 minuten: string,
1324 pruefungAuswerten: () => Promise<Pruefungsergebnis>,
1325 ): Promise<{ readonly nutzer: Nutzer; readonly schlafen: (ms: number) => void }> {
1326 let melden: ((schlafMs: number) => void) | null = null;
1327
1328 vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 10 });
1329 vi.stubGlobal(
1330 'lernApp',
1331 attrappe({
1332 pruefungAuswerten,
1333 energiesparenBeobachten: vi.fn((rueckruf: (schlafMs: number) => void) => {
1334 melden = rueckruf;
1335 return () => {
1336 melden = null;
1337 };
1338 }),
1339 }),
1340 );
1341 const nutzer = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
1342 render(<App />);
1343
1344 const start = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
1345 await waitFor(() => {
1346 expect(start).toHaveAttribute('aria-disabled', 'false');
1347 });
1348 await nutzer.click(start);
1349 await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten' });
1350
1351 await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
1352 const zeitfeld = screen.getByRole('spinbutton', { name: 'Bearbeitungszeit' });
1353 await nutzer.clear(zeitfeld);
1354 await nutzer.type(zeitfeld, minuten);
1355 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
1356 await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
1357
1358 /* Der Schlaf selbst: Die Wanduhr springt vor, ohne dass ein Zeitgeber
1359 liefe – genau wie bei einem zugeklappten Rechner –, und danach meldet
1360 der Hauptprozess die Dauer. */
1361 const schlafen = (ms: number): void => {
1362 vi.setSystemTime(new Date(Date.now() + ms));
1363 melden?.(ms);
1364 };
1365
1366 return { nutzer, schlafen };
1367 }
1368
1369 it('schreibt die Schlafzeit gut, statt sie zu verbrauchen', async () => {
1370 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1371 const { schlafen } = await mitSchlafmeldung('30', pruefungAuswerten);
1372
1373 await act(async () => {
1374 await vi.advanceTimersByTimeAsync(2000);
1375 });
1376
1377 await act(async () => {
1378 schlafen(20 * 60 * 1000);
1379 await vi.advanceTimersByTimeAsync(2000);
1380 });
1381
1382 /* Ohne Gutschrift stünden hier zehn Minuten. */
1383 await waitFor(() => {
1384 expect(screen.getByText(/30 Minuten/u)).toBeInTheDocument();
1385 });
1386 expect(pruefungAuswerten).not.toHaveBeenCalled();
1387 });
1388
1389 it('gibt nicht ab, wenn die Zeit erst im Schlaf ablief', async () => {
1390 /*
1391 Der eigentliche Schadensfall des Befunds: Beim Aufwachen holt Chromium
1392 den versäumten Takt nach und sieht eine abgelaufene Wanduhr – die
1393 Gutschrift ist da noch unterwegs. Ohne die Karenz in `useRestzeit`
1394 wäre der Bogen abgegeben, bevor die Uhr überhaupt zurückgestellt ist.
1395 */
1396 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1397 const { schlafen } = await mitSchlafmeldung('10', pruefungAuswerten);
1398
1399 await act(async () => {
1400 await vi.advanceTimersByTimeAsync(2000);
1401 });
1402
1403 // Erst der nachgeholte Takt, dann die Meldung – in dieser Reihenfolge.
1404 await act(async () => {
1405 vi.setSystemTime(new Date(Date.now() + 25 * 60 * 1000));
1406 await vi.advanceTimersByTimeAsync(1000);
1407 });
1408 expect(pruefungAuswerten).not.toHaveBeenCalled();
1409
1410 await act(async () => {
1411 schlafen(0);
1412 await vi.advanceTimersByTimeAsync(0);
1413 });
1414 // Die Gutschrift kommt getrennt, weil `schlafen(0)` die Uhr nicht bewegt.
1415 await act(async () => {
1416 await vi.advanceTimersByTimeAsync(2000);
1417 });
1418
1419 expect(pruefungAuswerten).not.toHaveBeenCalled();
1420 });
1421
1422 it('gibt nach der Karenz doch ab, wenn keine Gutschrift kommt', async () => {
1423 /*
1424 Die Gegenprobe zum Test darüber – und der Grund, warum die Taktlücke
1425 nur aufschiebt und nie gutschreibt: Ein minimiertes Fenster erzeugt
1426 dieselbe Lücke wie ein schlafender Rechner. Ließe sich daraus Zeit
1427 gutschreiben, hielte man die Prüfungsuhr durch Minimieren an.
1428 */
1429 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1430 await mitSchlafmeldung('10', pruefungAuswerten);
1431
1432 await act(async () => {
1433 vi.setSystemTime(new Date(Date.now() + 25 * 60 * 1000));
1434 await vi.advanceTimersByTimeAsync(5000);
1435 });
1436
1437 await waitFor(() => {
1438 expect(pruefungAuswerten).toHaveBeenCalledTimes(1);
1439 });
1440 });
1441
1442 it('sagt die Pause an, ohne die Restzeitwarnung zu verdrängen', async () => {
1443 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1444 const { schlafen } = await mitSchlafmeldung('30', pruefungAuswerten);
1445
1446 await act(async () => {
1447 schlafen(20 * 60 * 1000);
1448 await vi.advanceTimersByTimeAsync(2000);
1449 });
1450
1451 await waitFor(() => {
1452 expect(ansagebereich()).toHaveTextContent(/Der Rechner war im Energiesparmodus/u);
1453 });
1454 expect(ansagebereich()).toHaveTextContent(/20 Minuten/u);
1455 });
1456 });
1457
1458 // ─── Offene Fragen ──────────────────────────────────────────────────────
1459
1460 describe('Prüfungsansicht – offene Fragen', () => {
1461 it('zeigt die Musterantwort erst nach der Abgabe und übernimmt die Selbstbewertung', async () => {
1462 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1463 vi.stubGlobal(
1464 'lernApp',
1465 attrappe({
1466 pruefungStarten: vi.fn(() =>
1467 Promise.resolve({ fragen: [bogeneintrag(FRAGE_FREITEXT)], warnungen: [] }),
1468 ),
1469 pruefungAuswerten,
1470 }),
1471 );
1472 const nutzer = userEvent.setup();
1473 render(<App />);
1474
1475 const start = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
1476 /* Der Knopf ist nie `disabled`, sondern meldet sich über `aria-disabled`
1477 (Projektregel, docs/stand.md 7.13) – `toBeEnabled()` wäre hier immer
1478 wahr und liefe dem Katalog nicht mehr hinterher. */
1479 await waitFor(() => {
1480 expect(start).toHaveAttribute('aria-disabled', 'false');
1481 });
1482 await nutzer.click(start);
1483 await nutzer.click(await screen.findByRole('button', { name: 'Simulation starten' }));
1484 await screen.findByRole('heading', { name: /Frage 1\.01, Frage \d+ von \d+$/u, level: 2 });
1485
1486 await nutzer.type(
1487 screen.getByRole('textbox', { name: 'Ihre Antwort' }),
1488 'Den Umgang mit Waffen.',
1489 );
1490 expect(screen.queryByRole('heading', { name: 'Musterantwort' })).not.toBeInTheDocument();
1491
1492 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1493 await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
1494
1495 expect(
1496 await screen.findByRole('heading', { name: 'Offene Fragen selbst bewerten' }),
1497 ).toHaveFocus();
1498 expect(screen.getByRole('heading', { name: 'Musterantwort', level: 3 })).toBeInTheDocument();
1499 expect(pruefungAuswerten).not.toHaveBeenCalled();
1500
1501 await nutzer.click(screen.getByRole('radio', { name: 'Als richtig werten' }));
1502 await nutzer.click(screen.getByRole('button', { name: 'Auswertung anzeigen' }));
1503
1504 await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
1505 expect(pruefungAuswerten).toHaveBeenCalledWith(
1506 1,
1507 expect.anything(),
1508 [
1509 {
1510 frageId: 'I.1-01',
1511 auswahl: [],
1512 freitext: 'Den Umgang mit Waffen.',
1513 selbstAlsRichtig: true,
1514 },
1515 ],
1516 expect.any(Number),
1517 false,
1518 );
1519 });
1520 });
1521
1522 // ─── Auswertung ─────────────────────────────────────────────────────────
1523
1524 describe('Prüfungsansicht – Abbruch ohne Wertung', () => {
1525 /*
1526 Wer die Simulation nur ausprobiert, muss sie verlassen können, ohne einen
1527 Knopf zu drücken, der „abgeben“ heißt. Der Ausstieg lag zunächst hinter
1528 der Abgabe verborgen; diese Tests halten fest, dass er unmittelbar
1529 erreichbar bleibt und nichts auswertet.
1530 */
1531 it('bietet den Abbruch unmittelbar in der Bedienleiste an', async () => {
1532 await pruefungOeffnen();
1533
1534 expect(screen.getByRole('button', { name: 'Simulation abbrechen' })).toBeInTheDocument();
1535 });
1536
1537 it('fragt beim Abbruch mit passendem Wortlaut zurück und wertet nichts aus', async () => {
1538 const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
1539 const nutzer = await pruefungOeffnen(attrappe({ pruefungAuswerten }));
1540
1541 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1542 await nutzer.click(screen.getByRole('button', { name: 'Simulation abbrechen' }));
1543
1544 // Der Titel benennt den Abbruch, nicht die Abgabe.
1545 const rueckfrage = screen.getByRole('group', { name: 'Simulation wirklich abbrechen?' });
1546 expect(rueckfrage).toHaveFocus();
1547 expect(pruefungAuswerten).not.toHaveBeenCalled();
1548
1549 await nutzer.click(screen.getByRole('button', { name: 'Abbrechen und verwerfen' }));
1550
1551 // Zurück am Start, ohne Auswertung.
1552 await screen.findByRole('heading', { name: 'Heute lernen' });
1553 expect(pruefungAuswerten).not.toHaveBeenCalled();
1554 expect(
1555 screen.queryByRole('heading', { name: 'Auswertung der Prüfungssimulation' }),
1556 ).not.toBeInTheDocument();
1557 });
1558
1559 it('lässt aus der Abbruch-Rückfrage zurück in die Prüfung', async () => {
1560 const nutzer = await pruefungOeffnen();
1561
1562 await nutzer.click(screen.getByRole('button', { name: 'Simulation abbrechen' }));
1563 await nutzer.click(screen.getByRole('button', { name: 'Weiter bearbeiten' }));
1564
1565 expect(
1566 screen.queryByRole('group', { name: 'Simulation wirklich abbrechen?' }),
1567 ).not.toBeInTheDocument();
1568 expect(
1569 screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' }),
1570 ).toBeInTheDocument();
1571 // Auch hier zurück auf den Knopf, der die Rückfrage geöffnet hat.
1572 expect(screen.getByRole('button', { name: 'Simulation abbrechen' })).toHaveFocus();
1573 });
1574
1575 it('nennt in der Fragenüberschrift auch die Position im Bogen', async () => {
1576 /* Beim Blättern wandert der Fokus auf die Überschrift. Angesagt würde
1577 sonst nur die amtliche Katalognummer, die nichts darüber sagt, wie weit
1578 man ist. Der sichtbare Text bleibt Bestandteil des zugänglichen Namens
1579 (WCAG 2.5.3). */
1580 const nutzer = await pruefungOeffnen();
1581
1582 const erste = await screen.findByRole('heading', {
1583 name: /Frage 1\.87, Frage 1 von 3$/u,
1584 level: 2,
1585 });
1586 expect(erste).toHaveTextContent('Frage 1.87');
1587
1588 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1589
1590 const zweite = await screen.findByRole('heading', {
1591 name: /Frage 2\.10, Frage 2 von 3$/u,
1592 level: 2,
1593 });
1594 expect(zweite).toHaveFocus();
1595 });
1596
1597 it('fängt den Fokus in der Auswertungsphase auf', async () => {
1598 /* Zwischen Abgabe und Auswertung verschwindet die auslösende
1599 Schaltfläche. Ohne Anker fiele der Fokus auf `body` – bei Zeitablauf,
1600 wo gar kein Klick vorausging, ebenso. */
1601 const nutzer = await pruefungOeffnen(
1602 attrappe({
1603 pruefungAuswerten: vi.fn(
1604 () => new Promise<never>(() => undefined), // bleibt in der Wartephase
1605 ),
1606 }),
1607 );
1608
1609 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1610 await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
1611
1612 const warten = await screen.findByText('Ihre Simulation wird ausgewertet …');
1613 expect(warten).toHaveFocus();
1614 });
1615 });
1616
1617 describe('Auswertung', () => {
1618 async function auswertungOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise<Nutzer> {
1619 const nutzer = await pruefungOeffnen(bruecke);
1620 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1621 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1622 await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
1623 await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
1624 return nutzer;
1625 }
1626
1627 it('zeigt Urteil, Begründung, Trefferquote und Themenanalyse', async () => {
1628 await auswertungOeffnen();
1629
1630 expect(screen.getByText('Mündliche Nachprüfung')).toBeInTheDocument();
1631 expect(
1632 screen.getByText('67 Prozent richtig. Bestanden wäre ab 80 Prozent.'),
1633 ).toBeInTheDocument();
1634 expect(
1635 screen.getByText('2 von 3 Fragen richtig – Trefferquote 67 Prozent.'),
1636 ).toBeInTheDocument();
1637 expect(
1638 screen.getByText('Notwehr und Notstand: 3 Fehler bei höchstens 2 zulässigen'),
1639 ).toBeInTheDocument();
1640
1641 const analyse = screen.getByRole('list', { name: 'Ergebnis nach Themenbereichen' });
1642 expect(within(analyse).getAllByRole('listitem')).toHaveLength(2);
1643 expect(
1644 within(analyse).getByRole('progressbar', { name: 'Ergebnis in Rechte und Pflichten' }),
1645 ).toHaveAttribute('aria-valuetext', '1 von 2 Fragen richtig');
1646 });
1647
1648 it('nennt den Vorbehalt, dass der Prüfungsausschuss entscheidet', async () => {
1649 await auswertungOeffnen();
1650
1651 expect(screen.getByText(/allein der zuständige Prüfungsausschuss/u)).toBeInTheDocument();
1652 });
1653
1654 it('führt frühere Läufe auf', async () => {
1655 await auswertungOeffnen();
1656
1657 // Nach dem Lauf wird der Lernstand neu geladen; der Verlauf folgt deshalb
1658 // erst, wenn das Lernprofil wieder feststeht.
1659 const tabelle = await screen.findByRole('table', {
1660 name: 'Ihre bisherigen Simulationen, neueste zuerst',
1661 });
1662 expect(within(tabelle).getByText('70 von 80')).toBeInTheDocument();
1663 expect(within(tabelle).getByText('88 Prozent')).toBeInTheDocument();
1664 expect(within(tabelle).getByText(/Bestanden/u)).toBeInTheDocument();
1665 });
1666
1667 it('nennt zu jedem Lauf die Bearbeitungsdauer', async () => {
1668 /* Die Zahl wird seit Schema-Version 2 gespeichert, stand aber bis 0.22.0
1669 nicht in der Tabelle. Zeitnot ist ein eigenes Durchfallrisiko. */
1670 await auswertungOeffnen();
1671
1672 const tabelle = await screen.findByRole('table', {
1673 name: 'Ihre bisherigen Simulationen, neueste zuerst',
1674 });
1675
1676 expect(within(tabelle).getByRole('columnheader', { name: 'Dauer' })).toBeInTheDocument();
1677 expect(within(tabelle).getByText('71 Minuten 0 Sekunden')).toBeInTheDocument();
1678 });
1679
1680 it('ordnet den Lauf gegenüber dem letzten vergleichbaren ein', async () => {
1681 await auswertungOeffnen();
1682
1683 const satz = await screen.findByText(
1684 /Gegenüber Ihrer letzten Simulation mit demselben Profil und derselben Zeitvorgabe/u,
1685 );
1686
1687 expect(satz).toHaveTextContent('21 Prozentpunkte schlechter');
1688 expect(satz).toHaveTextContent('69 Minuten schneller');
1689 });
1690
1691 it('sagt beim Vergleich dazu, dass die Bogengrößen auseinandergingen', async () => {
1692 await auswertungOeffnen();
1693
1694 expect(
1695 await screen.findByText('Der Bogen hatte damals 80 Fragen, dieser 3 Fragen.'),
1696 ).toBeInTheDocument();
1697 });
1698
1699 it('vergleicht nicht mit einem Lauf anderer Zeitstufe', async () => {
1700 const nurAndereStufe = attrappe({
1701 pruefungVerlauf: vi.fn(() =>
1702 Promise.resolve([{ ...VERLAUF[0]!, id: 42, zeitmodus: 'plus50' as const }]),
1703 ),
1704 });
1705 await auswertungOeffnen(nurAndereStufe);
1706 await screen.findByRole('table', { name: 'Ihre bisherigen Simulationen, neueste zuerst' });
1707
1708 // Ein Bogen ohne Uhr und einer unter Zeitdruck sind zwei verschiedene
1709 // Aufgaben; sie zu vergleichen verbuchte den Nachteilsausgleich als
1710 // Fortschritt. Lieber gar nichts sagen.
1711 expect(screen.queryByText(/Gegenüber Ihrer letzten Simulation/u)).not.toBeInTheDocument();
1712 });
1713
1714 it('benennt Bereiche, die mehrfach unter der Grenze lagen', async () => {
1715 const mehrereLaeufe = attrappe({
1716 pruefungVerlauf: vi.fn(() =>
1717 Promise.resolve([
1718 { ...VERLAUF[0]!, id: 42 },
1719 {
1720 ...VERLAUF[0]!,
1721 id: 41,
1722 zeitpunkt: '2026-01-10T09:30:00.000Z',
1723 bereiche: [
1724 { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 10, richtig: 8 },
1725 { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 10, richtig: 3 },
1726 ],
1727 themengruppen: [],
1728 },
1729 ]),
1730 ),
1731 });
1732 await auswertungOeffnen(mehrereLaeufe);
1733
1734 const liste = await screen.findByRole('list', { name: 'Wiederkehrende Lücken' });
1735 const eintraege = within(liste).getAllByRole('listitem');
1736
1737 // I.2 lag zweimal unter 80 Prozent, I.1 nur einmal – ein schlechter Tag
1738 // ist keine Lücke.
1739 expect(eintraege).toHaveLength(1);
1740 expect(eintraege[0]).toHaveTextContent('I.2 – Rechte und Pflichten');
1741 expect(eintraege[0]).toHaveTextContent('in 2 von 2 Simulationen unter der Grenze');
1742 expect(eintraege[0]).toHaveTextContent('zusammen 7 von 20 Fragen richtig');
1743 });
1744
1745 it('schweigt über wiederkehrende Lücken, solange es keine gibt', async () => {
1746 await auswertungOeffnen();
1747 await screen.findByRole('table', { name: 'Ihre bisherigen Simulationen, neueste zuerst' });
1748
1749 expect(
1750 screen.queryByRole('heading', { name: 'Wiederkehrende Lücken' }),
1751 ).not.toBeInTheDocument();
1752 });
1753
1754 it('nennt zu jedem Lauf die Zeitvorgabe', async () => {
1755 /* Ohne diese Spalte stünden ein Lauf ohne Uhr und einer unter Zeitdruck
1756 nebeneinander, als wären sie vergleichbar – und die Tabelle lädt
1757 ausdrücklich zum Vergleich ein. */
1758 await auswertungOeffnen();
1759
1760 const tabelle = await screen.findByRole('table', {
1761 name: 'Ihre bisherigen Simulationen, neueste zuerst',
1762 });
1763
1764 expect(within(tabelle).getByRole('columnheader', { name: 'Zeitvorgabe' })).toBeInTheDocument();
1765 expect(within(tabelle).getByText('Vorgesehene Zeit')).toBeInTheDocument();
1766 // Läufe von vor Schema-Version 4 tragen keine – geraten wird nicht.
1767 expect(within(tabelle).getByText('nicht vermerkt')).toBeInTheDocument();
1768 });
1769
1770 it('hält die Begründung zu jeder falschen Frage bereit, zugeklappt', async () => {
1771 /* Nach einem vollen Durchgang stehen hier bis zu 80 Fragen. Die
1772 Begründung darf die Liste deshalb nicht aufblähen – sie ist einen
1773 Klick entfernt, nicht sofort sichtbar. */
1774 const nutzer = await auswertungOeffnen();
1775
1776 const liste = screen.getByRole('list', { name: 'Nicht richtig beantwortete Fragen' });
1777 const schalter = within(liste).getByText('Warum war das falsch?');
1778
1779 /* Der Bezug zur Frage muss im Schalter stehen: „Warum war das falsch?"
1780 allein waere in einer Liste mit 80 Eintraegen nicht unterscheidbar. */
1781 expect(schalter.parentElement).toHaveTextContent('Frage 2.10');
1782
1783 const aufklapper = schalter.closest('details');
1784 expect(aufklapper).not.toBeNull();
1785 expect(aufklapper).not.toHaveAttribute('open');
1786
1787 await nutzer.click(schalter);
1788
1789 expect(aufklapper).toHaveAttribute('open');
1790 expect(
1791 within(aufklapper!).getByRole('heading', { name: 'Warum das so ist' }),
1792 ).toBeInTheDocument();
1793 expect(within(aufklapper!).getByText(/binnen zwei Wochen/u)).toBeInTheDocument();
1794 expect(within(aufklapper!).getByText('§ 10 Abs. 1 WaffG')).toBeInTheDocument();
1795 });
1796
1797 it('übernimmt die falsch beantworteten Fragen in eine Lernsitzung', async () => {
1798 const nutzer = await auswertungOeffnen();
1799
1800 const liste = screen.getByRole('list', { name: 'Nicht richtig beantwortete Fragen' });
1801 const [eintrag] = within(liste).getAllByRole('listitem');
1802 expect(eintrag).toHaveTextContent('Frage 2.10');
1803
1804 await nutzer.click(screen.getByRole('button', { name: 'Fehler wiederholen (1)' }));
1805
1806 expect(
1807 await screen.findByRole('heading', { name: /Frage 2\.10/u, level: 1 }),
1808 ).toBeInTheDocument();
1809 expect(screen.getByText('Frage 1 von 1')).toBeInTheDocument();
1810 });
1811 });
1812
1813 // ─── Ausfall des Anwendungskerns ────────────────────────────────────────
1814
1815 describe('Prüfung – Ausfall des Anwendungskerns', () => {
1816 it('stellt den Bogen ersatzweise aus dem Katalog zusammen und sagt das', async () => {
1817 const nutzer = await auswahlOeffnen(ohneKanal('pruefungStarten'));
1818 await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
1819
1820 expect(
1821 await screen.findByText(/ersatzweise aus dem Fragenkatalog zusammengestellt/u),
1822 ).toBeInTheDocument();
1823 expect(screen.getAllByRole('heading', { level: 3 }).length).toBeGreaterThan(0);
1824 });
1825
1826 it('rechnet das Ergebnis selbst aus, wenn der Auswertungskanal fehlt', async () => {
1827 const nutzer = await pruefungOeffnen(ohneKanal('pruefungAuswerten'));
1828
1829 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1830 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1831 await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
1832
1833 await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
1834
1835 /* Gezielt im Urteilsblock suchen: In der Verlaufstabelle weiter unten
1836 steht dasselbe Urteil noch einmal.
1837 Trefferzahlen statt gerundeter Prozente – 80 Prozent von 3 sind 3. */
1838 const begruendung = screen.getByText('1 von 3 richtig, nötig waren 3.');
1839 const urteilBlock = begruendung.closest<HTMLElement>('.urteil');
1840 expect(urteilBlock).not.toBeNull();
1841 expect(within(urteilBlock!).getByText('Nicht bestanden')).toBeInTheDocument();
1842
1843 expect(
1844 screen.getByText('1 von 3 Fragen richtig – Trefferquote 33 Prozent.'),
1845 ).toBeInTheDocument();
1846 expect(screen.getByText(/in der Oberfläche berechnet/u)).toBeInTheDocument();
1847 });
1848 });
1849
1850 // ─── Barrierefreiheit ───────────────────────────────────────────────────
1851
1852 describe('Prüfung – Barrierefreiheits-Rauchtest (axe-core)', () => {
1853 it('meldet in der Prüfungsansicht keine Verstöße gegen WCAG 2.1 A/AA', async () => {
1854 const nutzer = await pruefungOeffnen();
1855 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1856 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1857
1858 const ergebnis = await axe.run(document.body, jsdomOptionen());
1859
1860 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
1861 });
1862
1863 it('meldet in der Auswahlansicht keine Verstöße gegen WCAG 2.1 A/AA', async () => {
1864 const nutzer = await auswahlOeffnen();
1865 await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
1866
1867 const ergebnis = await axe.run(document.body, jsdomOptionen());
1868
1869 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
1870 });
1871
1872 it('meldet in der Auswertung keine Verstöße gegen WCAG 2.1 A/AA', async () => {
1873 /* Die längste Ansicht der Simulation und bis 0.22.0 die einzige ohne
1874 eigenen Rauchtest: Urteil, Zahlen, Vergleich, Themenanalyse,
1875 wiederkehrende Lücken, Fehlerliste und Verlaufstabelle auf einer Seite.
1876 Der Verlauf ist mit Absicht so gewählt, dass alle Blöcke dastehen. */
1877 const mitAllenBloecken = attrappe({
1878 pruefungVerlauf: vi.fn(() =>
1879 Promise.resolve([
1880 { ...VERLAUF[0]!, id: 42 },
1881 {
1882 ...VERLAUF[0]!,
1883 id: 41,
1884 zeitpunkt: '2026-01-10T09:30:00.000Z',
1885 unbeantwortet: 4,
1886 zeitAbgelaufen: true,
1887 bereiche: [
1888 { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 10, richtig: 8 },
1889 { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 10, richtig: 3 },
1890 ],
1891 themengruppen: [],
1892 },
1893 ]),
1894 ),
1895 });
1896 const nutzer = await pruefungOeffnen(mitAllenBloecken);
1897 await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
1898 await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
1899 await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
1900 await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
1901 await screen.findByRole('list', { name: 'Wiederkehrende Lücken' });
1902
1903 const ergebnis = await axe.run(document.body, jsdomOptionen());
1904
1905 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
1906 });
1907 });
1908
1909 // ─── Unterbrochene Simulation ───────────────────────────────────────────────
1910
1911 describe('Unterbrochene Simulation', () => {
1912 /** Ein unterbrochener Lauf über den Standardbogen. */
1913 function offenerLauf(teil: Partial<OffenerLauf> = {}): OffenerLauf {
1914 return {
1915 auftrag: { profilId: 'standard', zeitmodus: 'normal' },
1916 profil: profilFinden('standard')!,
1917 bogen: STANDARDFRAGEN,
1918 eingaben: { [FRAGE_EINFACH.id]: { auswahl: ['a'], freitext: '' } },
1919 position: 1,
1920 phase: 'bearbeiten',
1921 zeitAbgelaufen: false,
1922 verbrauchtMs: 30 * 60_000,
1923 begonnenAm: '2026-08-20T14:12:00.000Z',
1924 gesichertAm: '2026-08-20T14:42:00.000Z',
1925 ...teil,
1926 };
1927 }
1928
1929 function mitLauf(lauf: OffenerLauf | null = offenerLauf()): LueckenhafteBruecke {
1930 return attrappe({ pruefungOffen: vi.fn(() => Promise.resolve(lauf)) });
1931 }
1932
1933 async function startbildschirm(bruecke: LueckenhafteBruecke): Promise<Nutzer> {
1934 vi.stubGlobal('lernApp', bruecke);
1935 const nutzer = userEvent.setup();
1936 render(<App />);
1937 await screen.findByRole('heading', { name: 'Heute lernen' });
1938 return nutzer;
1939 }
1940
1941 it('zeigt keine Karte, wenn nichts offensteht', async () => {
1942 await startbildschirm(mitLauf(null));
1943
1944 expect(
1945 screen.queryByRole('heading', { name: 'Unterbrochene Simulation' }),
1946 ).not.toBeInTheDocument();
1947 });
1948
1949 it('nennt Profil, Stand, Restzeit und Zeitpunkt', async () => {
1950 await startbildschirm(mitLauf());
1951
1952 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
1953 // 120 Minuten Vorgabe, 30 verbraucht.
1954 expect(screen.getByText('1 Stunde 30 Minuten')).toBeInTheDocument();
1955 expect(screen.getByText(/Frage 2 von 3, 1 beantwortet/u)).toBeInTheDocument();
1956 expect(screen.getByText(/Die Uhr stand still/u)).toBeInTheDocument();
1957 });
1958
1959 it('warnt an der Prüfungskarte, dass ein neuer Start den Bogen verwirft', async () => {
1960 await startbildschirm(mitLauf());
1961
1962 expect(await screen.findByText(/Eine neue zu starten verwirft sie/u)).toBeInTheDocument();
1963 });
1964
1965 it('startet die Uhr nicht schon beim Klick auf „Fortsetzen“', async () => {
1966 /* Die Karte drängt sich zwischen die täglichen Schaltflächen; ein
1967 Fehlgriff darf nichts kosten. */
1968 const nutzer = await startbildschirm(mitLauf());
1969 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
1970
1971 await nutzer.click(screen.getByRole('button', { name: 'Simulation fortsetzen' }));
1972
1973 expect(
1974 screen.getByRole('heading', { name: 'Simulation jetzt fortsetzen?' }),
1975 ).toBeInTheDocument();
1976 // Der Lauf ist noch nicht da.
1977 expect(screen.queryByRole('heading', { name: /Frage \d+ von 3$/u })).not.toBeInTheDocument();
1978 });
1979
1980 it('setzt bei der gespeicherten Frage mit den gespeicherten Eingaben fort', async () => {
1981 const nutzer = await startbildschirm(mitLauf());
1982 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
1983
1984 await nutzer.click(screen.getByRole('button', { name: 'Simulation fortsetzen' }));
1985 await nutzer.click(screen.getByRole('button', { name: 'Jetzt fortsetzen' }));
1986
1987 // position 1 heißt: die zweite Frage des Bogens.
1988 await screen.findByRole('heading', { name: /Frage 2 von 3$/u, level: 2 });
1989
1990 // Die Antwort auf Frage 1 steht noch.
1991 await nutzer.click(screen.getByRole('button', { name: 'Vorherige Frage' }));
1992 await screen.findByRole('heading', { name: /Frage 1 von 3$/u, level: 2 });
1993 /* Im Pruefungsmodus sind alle Auswahlfragen Kontrollkaestchen - die
1994 Zahl der richtigen Antworten verraet die Simulation nicht. */
1995 expect(screen.getByRole('checkbox', { name: /Sportgewehr/u })).toBeChecked();
1996 });
1997
1998 it('rechnet die verbrauchte Zeit auf die Restzeit an', async () => {
1999 const nutzer = await startbildschirm(mitLauf());
2000 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
2001
2002 await nutzer.click(screen.getByRole('button', { name: 'Simulation fortsetzen' }));
2003 await nutzer.click(screen.getByRole('button', { name: 'Jetzt fortsetzen' }));
2004 await screen.findByRole('heading', { name: /Frage 2 von 3$/u, level: 2 });
2005
2006 /* Ohne Rückdatierung des Zeitursprungs stünden hier zwei Stunden. */
2007 expect(screen.getByText(/1 Stunde 30 Minuten/u)).toBeInTheDocument();
2008 });
2009
2010 it('meldet dem Anwendungskern, dass geprüft wird', async () => {
2011 const laeuft = vi.fn(() => Promise.resolve());
2012 await pruefungOeffnen(attrappe({ pruefungLaeuft: laeuft }));
2013
2014 await waitFor(() => {
2015 expect(laeuft).toHaveBeenCalledWith(true);
2016 });
2017 });
2018
2019 it('verwirft den Bogen erst nach einer Rückfrage', async () => {
2020 const verwerfen = vi.fn(() => Promise.resolve());
2021 const nutzer = await startbildschirm(
2022 attrappe({
2023 pruefungOffen: vi.fn(() => Promise.resolve(offenerLauf())),
2024 pruefungVerwerfen: verwerfen,
2025 }),
2026 );
2027 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
2028
2029 await nutzer.click(screen.getByRole('button', { name: 'Simulation verwerfen' }));
2030 expect(
2031 screen.getByRole('heading', { name: 'Unterbrochene Simulation verwerfen?' }),
2032 ).toBeInTheDocument();
2033 expect(verwerfen).not.toHaveBeenCalled();
2034
2035 await nutzer.click(screen.getByRole('button', { name: 'Nein, Simulation behalten' }));
2036 expect(verwerfen).not.toHaveBeenCalled();
2037
2038 await nutzer.click(screen.getByRole('button', { name: 'Simulation verwerfen' }));
2039 await nutzer.click(screen.getByRole('button', { name: 'Ja, Simulation verwerfen' }));
2040
2041 await waitFor(() => {
2042 expect(verwerfen).toHaveBeenCalledWith(1);
2043 });
2044 await waitFor(() => {
2045 expect(
2046 screen.queryByRole('heading', { name: 'Unterbrochene Simulation' }),
2047 ).not.toBeInTheDocument();
2048 });
2049 });
2050
2051 it('gibt den Fokus nach dem Abbrechen der Rückfrage zurück', async () => {
2052 const nutzer = await startbildschirm(mitLauf());
2053 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
2054
2055 await nutzer.click(screen.getByRole('button', { name: 'Simulation verwerfen' }));
2056 await nutzer.click(screen.getByRole('button', { name: 'Nein, Simulation behalten' }));
2057
2058 await waitFor(() => {
2059 expect(screen.getByRole('button', { name: 'Simulation verwerfen' })).toHaveFocus();
2060 });
2061 });
2062
2063 it('sagt bei einem Lauf ohne Uhr nichts von Restzeit', async () => {
2064 await startbildschirm(
2065 mitLauf(offenerLauf({ auftrag: { profilId: 'standard', zeitmodus: 'aus' } })),
2066 );
2067
2068 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
2069 expect(screen.getByText('Ohne Zeitbegrenzung')).toBeInTheDocument();
2070 expect(screen.queryByText(/Die Uhr stand still/u)).not.toBeInTheDocument();
2071 });
2072
2073 it('ist frei von automatisch prüfbaren Verstößen', async () => {
2074 await startbildschirm(mitLauf());
2075 await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
2076
2077 const ergebnis = await axe.run(document.body, jsdomOptionen());
2078 expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]);
2079 });
2080 });
2081
2082 describe('Merken im Prüfungslauf', () => {
2083 /*
2084 Die Lücke, die das schließt.
2085
2086 `merken()` ist im Kern seit jeher gebaut und geprüft – im Bogen fehlte
2087 allein der Weg dorthin. Wer im Lauf geraten hat oder unsicher war, konnte
2088 das nirgends festhalten: Nach der Abgabe zählt nur richtig oder falsch,
2089 und eine glücklich getroffene Frage hinterließ keine Spur.
2090 */
2091 it('bietet einen Merkknopf an, der zu Anfang nicht gedrückt ist', async () => {
2092 await pruefungOeffnen();
2093
2094 const knopf = screen.getByRole('button', { name: /Frage merken/u });
2095 expect(knopf).toHaveAttribute('aria-pressed', 'false');
2096 });
2097
2098 it('meldet den Merkwunsch an den Lernstand und zeigt ihn am Knopf', async () => {
2099 const lernMerken = vi.fn(() => Promise.resolve(STAND));
2100 const nutzer = await pruefungOeffnen({ ...attrappe(), lernMerken });
2101
2102 await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));
2103
2104 expect(lernMerken).toHaveBeenCalledWith(PROFIL.id, 'I.1-87', true);
2105 expect(screen.getByRole('button', { name: /Frage merken/u })).toHaveAttribute(
2106 'aria-pressed',
2107 'true',
2108 );
2109 });
2110
2111 it('sagt das Merken an – sonst bliebe es Sehenden vorbehalten', async () => {
2112 const nutzer = await pruefungOeffnen();
2113
2114 await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));
2115
2116 expect(await screen.findByText('Frage gemerkt.')).toBeInTheDocument();
2117 });
2118
2119 it('nimmt das Merkzeichen zurück, wenn es sich nicht speichern ließ', async () => {
2120 /* Ein Merkzeichen, das nach dem Lauf fehlt, wäre schlimmer als eines,
2121 das gar nicht erst erscheint. */
2122 const lernMerken = vi.fn(() => Promise.reject(new Error('kein Lernstand')));
2123 const nutzer = await pruefungOeffnen({ ...attrappe(), lernMerken });
2124
2125 await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));
2126
2127 expect(await screen.findByText('Das Merken ließ sich nicht speichern.')).toBeInTheDocument();
2128 expect(screen.getByRole('button', { name: /Frage merken/u })).toHaveAttribute(
2129 'aria-pressed',
2130 'false',
2131 );
2132 });
2133
2134 it('holt den vorhandenen Merkstand nach, sobald die Frage erscheint', async () => {
2135 /* Anders als die Lernsitzung bringt der Bogen den Merkzustand nicht mit:
2136 Er entsteht im Renderer aus dem Katalog und kennt den Lernstand nicht. */
2137 const lernFragestand = vi.fn(() => Promise.resolve({ ...STAND, gemerkt: true }));
2138 await pruefungOeffnen({ ...attrappe(), lernFragestand });
2139
2140 expect(lernFragestand).toHaveBeenCalledWith(PROFIL.id, 'I.1-87');
2141 expect(await screen.findByRole('button', { name: /Frage merken/u })).toHaveAttribute(
2142 'aria-pressed',
2143 'true',
2144 );
2145 });
2146 });
2147
2148 describe('Restzeit auf Knopfdruck ansagen', () => {
2149 /*
2150 Die Lücke, die das schließt.
2151
2152 Die Anzeige ist bewusst ruhig und ohne Live-Region – ein sekündlich
2153 sprechender Zähler machte die Simulation unbenutzbar. Die Warnung bei
2154 fünf Minuten wird angesagt, aber dazwischen gab es keinen Weg, die
2155 Restzeit zu erfahren, ohne sie zu **sehen**.
2156 */
2157 it('sagt die Restzeit auf die Sekunde genau an', async () => {
2158 const nutzer = await pruefungOeffnen();
2159
2160 await nutzer.click(screen.getByRole('button', { name: 'Restzeit ansagen' }));
2161
2162 const ansage = await screen.findByText(/^Restzeit: /u);
2163 expect(ansage).toHaveAttribute('role', 'status');
2164 expect(ansage.textContent).toMatch(/^Restzeit: .*(Minute|Sekunde)/u);
2165 });
2166
2167 it('nennt Sekunden, damit zwei Fragen in derselben Minute verschieden lauten', () => {
2168 /* Ohne Sekunden stünde in der Live-Region zweimal dasselbe – und beim
2169 zweiten Mal bliebe sie stumm, weil sich nichts geändert hat. */
2170 expect(restzeitGenau(12 * 60_000 + 30_000)).toBe('12 Minuten und 30 Sekunden');
2171 expect(restzeitGenau(12 * 60_000 + 5_000)).toBe('12 Minuten und 5 Sekunden');
2172 expect(restzeitGenau(60_000)).toBe('1 Minute');
2173 expect(restzeitGenau(45_000)).toBe('45 Sekunden');
2174 expect(restzeitGenau(0)).toBe('0 Sekunden');
2175 });
2176
2177 it('bietet den Knopf nicht an, wo keine Uhr läuft', async () => {
2178 /* Ohne Zeitbegrenzung gibt es nichts anzusagen – dort steht statt der
2179 Uhr der Satz, dass keine mitläuft. */
2180 const nutzer = await auswahlOeffnen();
2181 await nutzer.click(screen.getByRole('radio', { name: 'Ohne Zeitbegrenzung' }));
2182
2183 expect(
2184 screen.getByText('Es läuft keine Uhr mit. Sie geben ab, wann Sie möchten.'),
2185 ).toBeInTheDocument();
2186 expect(screen.queryByRole('button', { name: 'Restzeit ansagen' })).not.toBeInTheDocument();
2187 });
2188 });