waffensachkunde

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

/ app tests renderer-pruefung.test.tsx

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