waffensachkunde

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

/ app tests renderer-pruefung.test.tsx

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