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