waffensachkunde

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

/ app tests renderer-lernmodus.test.tsx

107,5 KB Rohdatei
app/tests/renderer-lernmodus.test.tsx — 2550 Zeilen
1 /**
2 * Verhaltenstests des Lernmodus.
3 *
4 * Geprüft wird, was Lernende tatsächlich tun: auswählen, bestätigen, sich
5 * selbst bewerten, merken, weitergehen – per Maus und per Tastatur. Die
6 * Attrappe von `window.lernApp` liefert dazu realistische Katalogdaten
7 * (Einfachauswahl, Mehrfachauswahl, offene Frage mit Kernelementen und eine
8 * Frage, deren Antwortoption ein Prüfzeichen ist).
9 */
10
11 import { 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 { programmangabenVergessen } from '../src/renderer/src/lernen/programmangaben';
19 import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
20 import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar';
21 import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte';
22 import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen';
23 import type { Erklaerungen } from '../src/shared/erklaerungen';
24 import type { Glossar } from '../src/shared/glossar';
25 import type { Lernplan } from '../src/shared/lernplan';
26 import type { Frage, Katalog, KatalogBild, RichText } from '../src/shared/katalog';
27 import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc';
28 import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand';
29 import {
30 EUPL_HINWEIS,
31 EUPL_KENNUNG,
32 KATALOG_HINWEIS,
33 type Lizenzangaben,
34 } from '../src/shared/lizenzen';
35 import type { Pruefungsergebnis } from '../src/shared/pruefung';
36 import { NORMTEXTE_LEER } from '../src/shared/normtexte';
37 import { THEMEN_LEER } from '../src/shared/themen';
38
39 type Nutzer = ReturnType<typeof userEvent.setup>;
40
41 // ─── Katalogattrappe ────────────────────────────────────────────────────
42
43 function nurText(text: string): RichText {
44 return { text, segmente: [{ t: text }] };
45 }
46
47 const ZEICHEN_PTB: KatalogBild = {
48 id: 'zeichen-ptb',
49 datei: 'zeichen-ptb.png',
50 breite: 73,
51 hoehe: 73,
52 alt: 'Rundes Zeichen mit der Buchstabenfolge PTB',
53 beschreibung:
54 'Zulassungszeichen der Physikalisch-Technischen Bundesanstalt für Schreckschuss-, Reizstoff- und Signalwaffen zugelassener Bauart.',
55 };
56
57 /* Ohne Beschreibung: Der Bestand wächst schrittweise, die Oberfläche muss
58 ein Zeichen ohne zweite Stufe unverändert darstellen. */
59 const ZEICHEN_F: KatalogBild = {
60 id: 'zeichen-f',
61 datei: 'zeichen-f.png',
62 breite: 72,
63 hoehe: 74,
64 alt: 'Fünfeckiges Zeichen mit dem Buchstaben F',
65 beschreibung: null,
66 };
67
68 /** Multiple Choice mit genau einer richtigen Antwort. */
69 const FRAGE_EINFACH: Frage = {
70 id: 'I.1-87',
71 amtliche_nummer: '1.87',
72 kapitel: 'I',
73 abschnitt: 'I.1',
74 typ: 'mc',
75 seite: 23,
76 frage: nurText('Welcher der genannten Gegenstände ist eine Anscheinswaffe?'),
77 bilder: [],
78 optionen: [
79 {
80 label: 'a',
81 inhalt: nurText('Sportgewehr im Kaliber .223 Remington'),
82 korrekt: false,
83 bilder: [],
84 },
85 {
86 label: 'b',
87 inhalt: nurText('Schreckschusspistole mit Zulassungszeichen'),
88 korrekt: false,
89 bilder: [],
90 },
91 {
92 label: 'c',
93 inhalt: nurText(
94 'Spielzeugpistole, die einer echten Pistole zum Verwechseln ähnlich sieht und die niemand führt',
95 ),
96 korrekt: true,
97 bilder: [],
98 },
99 ],
100 };
101
102 /** Multiple Choice mit zwei richtigen Antworten. */
103 const FRAGE_MEHRFACH: Frage = {
104 id: 'I.2-10',
105 amtliche_nummer: '2.10',
106 kapitel: 'I',
107 abschnitt: 'I.2',
108 typ: 'mc',
109 seite: 40,
110 frage: nurText('Welche Pflichten treffen den Inhaber einer Waffenbesitzkarte?'),
111 bilder: [],
112 optionen: [
113 {
114 label: 'a',
115 inhalt: nurText('Er muss die Waffe sorgfältig aufbewahren.'),
116 korrekt: true,
117 bilder: [],
118 },
119 {
120 label: 'b',
121 inhalt: nurText('Er muss den Erwerb unverzüglich anzeigen.'),
122 korrekt: true,
123 bilder: [],
124 },
125 {
126 label: 'c',
127 inhalt: nurText('Er muss die Waffe jährlich vorführen.'),
128 korrekt: false,
129 bilder: [],
130 },
131 ],
132 };
133
134 /** Offene Frage mit unterstrichenen Kernelementen in der Musterantwort. */
135 const FRAGE_FREITEXT: Frage = {
136 id: 'I.1-01',
137 amtliche_nummer: '1.01',
138 kapitel: 'I',
139 abschnitt: 'I.1',
140 typ: 'freitext',
141 seite: 6,
142 frage: nurText('Was regelt das Waffengesetz?'),
143 bilder: [],
144 musterantwort: {
145 text: 'Das Waffengesetz regelt den Umgang mit Waffen oder Munition unter Berücksichtigung der öffentlichen Sicherheit.',
146 segmente: [
147 { t: 'Das Waffengesetz regelt den ' },
148 /* Ein durchgehender Lauf, wie ihn der Katalog seit 0.17.0 liefert.
149 Vorher standen hier zwei Läufe mit einem blanken Leerzeichen
150 dazwischen – die Form, die `parse_catalog.py` am Zeilenumbruch
151 erzeugte und die `validate_catalog.py` heute abweist. */
152 { t: 'Umgang mit Waffen oder Munition', h: true },
153 { t: ' unter Berücksichtigung der ' },
154 { t: 'öffentlichen Sicherheit', h: true },
155 { t: '.' },
156 ],
157 },
158 };
159
160 /** Frage, deren erste Antwortoption ausschließlich aus einem Bild besteht. */
161 const FRAGE_BILD: Frage = {
162 id: 'I.3-05',
163 amtliche_nummer: '3.05',
164 kapitel: 'I',
165 abschnitt: 'I.3',
166 typ: 'mc',
167 seite: 61,
168 frage: nurText('Welches Kennzeichen findet sich auf einer Schreckschusswaffe?'),
169 bilder: [],
170 optionen: [
171 { label: 'a', inhalt: { text: '', segmente: [] }, korrekt: true, bilder: ['zeichen-ptb'] },
172 { label: 'b', inhalt: nurText('Beschusszeichen:'), korrekt: false, bilder: ['zeichen-f'] },
173 ],
174 };
175
176 /** Offene Frage mit Zeichen am Fragetext – der zweite Ort für Bilder. */
177 const FRAGE_BILD_KOPF: Frage = {
178 id: 'I.3-06',
179 amtliche_nummer: '3.06',
180 kapitel: 'I',
181 abschnitt: 'I.3',
182 typ: 'freitext',
183 seite: 62,
184 frage: nurText('Was bedeutet das abgebildete Zeichen?'),
185 bilder: ['zeichen-ptb'],
186 musterantwort: {
187 text: 'Es ist ein Zulassungszeichen.',
188 segmente: [{ t: 'Es ist ein Zulassungszeichen.' }],
189 },
190 };
191
192 const KATALOG: Katalog = {
193 meta: {
194 titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)',
195 herausgeber: 'Bundesverwaltungsamt',
196 stand: '2024-12-16',
197 quellenangabe:
198 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.',
199 quelle_url: 'https://www.bva.bund.de/',
200 quelldatei_sha256: '7e5e41fa',
201 fragen_gesamt: 5,
202 },
203 kapitel: [
204 {
205 id: 'I',
206 titel: 'Waffenrecht und sonstige Rechtsvorschriften',
207 abschnitte: [
208 { id: 'I.1', titel: 'Begriffe des Waffenrechts' },
209 { id: 'I.2', titel: 'Rechte und Pflichten' },
210 { id: 'I.3', titel: 'Kennzeichnung von Schusswaffen' },
211 ],
212 },
213 ],
214 bilder: [ZEICHEN_PTB, ZEICHEN_F],
215 fragen: [FRAGE_FREITEXT, FRAGE_EINFACH, FRAGE_MEHRFACH, FRAGE_BILD, FRAGE_BILD_KOPF],
216 };
217
218 // ─── Bridge-Attrappe ────────────────────────────────────────────────────
219
220 const PROFIL: Profil = {
221 id: 1,
222 name: 'Testprofil',
223 pruefungstermin: null,
224 kapitelAusschluss: [],
225 erstelltAm: '2026-01-01T08:00:00.000Z',
226 };
227
228 const UEBERSICHT: Lernuebersicht = {
229 fragenGesamt: 575,
230 beantwortet: 120,
231 belegt: 48,
232 reifegrad: 48 / 575,
233 stufe: 'zurueck',
234 deckelnd: [],
235 faellig: 12,
236 gemerkt: 5,
237 offen: 0,
238 fehler: 0,
239 heuteRichtig: 8,
240 heuteFalsch: 2,
241 heuteBearbeitet: 0,
242 tageSeitLetzterAntwort: null,
243 bereiche: [
244 {
245 id: 'I',
246 titel: 'Waffenrecht und sonstige Rechtsvorschriften',
247 fragenGesamt: 300,
248 beantwortet: 100,
249 belegt: 40,
250 reifegrad: 40 / 300,
251 stufe: 'zurueck',
252 },
253 ],
254 themengruppen: [],
255 };
256
257 const STAND: FrageStand = {
258 frageId: 'I.1-87',
259 versuche: 1,
260 richtige: 1,
261 zuletztBeantwortet: '2026-01-01T08:00:00.000Z',
262 faelligAb: null,
263 gemerkt: false,
264 letzteBewertung: 'gut',
265 };
266
267 const INFO: AnwendungsInfo = {
268 anwendungsVersion: '0.0.0-attrappe',
269 baukennung: 'abc1234',
270 baustand: '2026-08-21',
271 electronVersion: '43.4.1',
272 chromeVersion: '140.0.0.0',
273 nodeVersion: '22.19.1',
274 plattform: 'win32',
275 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
276 };
277
278 const BILD_DATEN =
279 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
280
281 function reihenfolge(frage: Frage): SitzungsFrage {
282 return {
283 frageId: frage.id,
284 optionsReihenfolge: (frage.optionen ?? []).map((option) => option.label),
285 gemerkt: false,
286 };
287 }
288
289 /*
290 Die Prüfungs- und Lizenzkanäle gehören nicht zum Lernmodus, sind aber Teil
291 des Bridge-Vertrags. Sie werden hier nur bedient, damit die Attrappe
292 vollständig ist – geprüft werden sie in tests/renderer-pruefung.test.tsx und
293 tests/renderer-ueber.test.tsx.
294 */
295 const PRUEFUNGSERGEBNIS: Pruefungsergebnis = {
296 profilId: 'standard',
297 gesamt: 0,
298 richtig: 0,
299 falsch: 0,
300 unbeantwortet: 0,
301 quote: 0,
302 urteil: 'nicht_bestanden',
303 begruendung: '0 Prozent richtig, nötig waren 80 Prozent.',
304 verletzteKriterien: [],
305 bereiche: [],
306 fehlerIds: [],
307 dauerMs: 0,
308 zeitAbgelaufen: false,
309 zeitpunkt: '2026-01-01T08:00:00.000Z',
310 };
311
312 const LIZENZEN: Lizenzangaben = {
313 eigene: { kennung: EUPL_KENNUNG, name: 'EUPL 1.2', hinweis: EUPL_HINWEIS, text: null },
314 katalog: {
315 quellenangabe: KATALOG.meta.quellenangabe,
316 quelleUrl: KATALOG.meta.quelle_url,
317 hinweis: KATALOG_HINWEIS,
318 },
319 dritte: [],
320 };
321
322 /** Lernplan für die Attrappe – ohne Termin, damit nichts vom Datum abhängt. */
323 const LERNPLAN: Lernplan = {
324 termin: null,
325 tageBisTermin: null,
326 gesamtFragen: 8,
327 nieBeantwortet: 6,
328 faellig: 1,
329 zielquote: 0.9,
330 prognoseHeute: 0.25,
331 prognoseAmTermin: null,
332 pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 },
333 machbarkeit: 'kein_termin',
334 sekundenProFrage: 25,
335 };
336
337 /** Erklaerungen fuer die Attrappe – ein Eintrag genuegt fuer die Anzeige. */
338 const ERKLAERUNGEN: Erklaerungen = {
339 meta: {
340 version: 1,
341 stand: '2026-08-20',
342 gesetzesstand: { WaffG: 'Pruefstand' },
343 hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.',
344 },
345 zuFrage: {
346 'I.1-87': {
347 kurz: 'Anscheinswaffen sehen echten Schusswaffen zum Verwechseln aehnlich.',
348 text: 'Entscheidend ist der aeussere Eindruck, nicht die Funktion: Wer den Gegenstand sieht, muss ihn fuer eine wirkliche Schusswaffe halten koennen.',
349 fundstellen: [
350 { gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 1 Unterabschnitt 1 Nr. 1.6' },
351 { gesetz: 'WaffG', norm: '§ 42a', absatz: '1', nummer: '1' },
352 ],
353 merksatz: 'Was aussieht wie eine Waffe, wird wie eine behandelt.',
354 /* Beidseitig wie im echten Bestand – ein einseitiger Verweis zeigte die
355 Verbindung nur dem, der zufaellig von der richtigen Seite kommt. */
356 verwandt: ['I.1-01'],
357 },
358 /* I.2-10 bekommt bewusst keine Erklaerung: Der Bestand waechst
359 schrittweise, und die Oberflaeche muss das aushalten. */
360 'I.1-01': {
361 // Der Regelfall in Waffentechnik und Handhabung: keine Fundstelle,
362 // dafuer die ausdrueckliche Begruendung, warum es keine gibt.
363 kurz: 'Das Waffengesetz regelt den Umgang mit Waffen und Munition.',
364 text: 'Wie ein Masseverschluss arbeitet, steht in keinem Gesetz - das ist Technik. Die Erklaerung sagt das ausdruecklich, statt eine Fundstelle zu dehnen.',
365 fundstellen: [],
366 ohneFundstelleGrund:
367 'Die Sicherheitsregeln zur Handhabung sind fachliche Praxis und stehen so nicht im Gesetz.',
368 /* Gegenstueck zum Verweis aus I.1-87: Der Bestand verlangt
369 Beidseitigkeit, und die Attrappe soll ihn nicht unterlaufen. */
370 verwandt: ['I.1-87'],
371 /* Die Pruefliste zur Selbstbewertung - eine Einschaetzung dieser
372 Software, keine Aussage darueber, was eine Antwort enthalten muss. */
373 kernpunkte: ['Gegenstand: der Umgang mit Waffen', 'Massstab: die oeffentliche Sicherheit'],
374 },
375 },
376 };
377
378 /** Glossar fuer die Attrappe – zwei Eintraege genuegen fuer die Anzeige. */
379 const GLOSSAR: Glossar = {
380 meta: {
381 version: 1,
382 stand: '2026-08-21',
383 gesetzesstand: { WaffG: 'Pruefstand' },
384 hinweis: 'Eigener redaktioneller Inhalt.',
385 },
386 eintraege: [
387 {
388 begriff: 'Führen',
389 art: 'begriff',
390 kurz: 'Die tatsächliche Gewalt über eine Waffe außerhalb der eigenen Wohnung ausüben.',
391 varianten: ['führt', 'führen', 'geführt'],
392 fundstellen: [{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 2 Nr. 4' }],
393 siehe: ['WaffG'],
394 },
395 {
396 begriff: 'WaffG',
397 art: 'abkuerzung',
398 kurz: 'Waffengesetz.',
399 varianten: ['WaffG', 'Waffengesetzes', 'Waffengesetz'],
400 fundstellen: [],
401 ohneFundstelleGrund:
402 'Die amtliche Kurzbezeichnung eines Gesetzes steht nicht im Gesetz selbst.',
403 },
404 ],
405 };
406
407 function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge {
408 return {
409 pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })),
410 pruefungAuswerten: vi.fn(() => Promise.resolve(PRUEFUNGSERGEBNIS)),
411 pruefungVerlauf: vi.fn(() => Promise.resolve([])),
412 pruefungSichern: vi.fn(() => Promise.resolve(true)),
413 pruefungOffen: vi.fn(() => Promise.resolve(null)),
414 pruefungVerwerfen: vi.fn(() => Promise.resolve()),
415 pruefungLaeuft: vi.fn(() => Promise.resolve()),
416 sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })),
417 fehlerprotokollDrucken: vi.fn(() =>
418 Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }),
419 ),
420 fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })),
421 lernberichtDrucken: vi.fn(() =>
422 Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }),
423 ),
424 /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt,
425 dass die Brücke den Kanal kennt. */
426 datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })),
427 lizenzenLesen: vi.fn(() => Promise.resolve(LIZENZEN)),
428 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
429 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
430 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
431 hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
432 hilfsmittelBeobachten: vi.fn(() => () => undefined),
433 menuebefehlBeobachten: vi.fn(() => () => undefined),
434 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
435 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
436 einstellungenLesen: vi.fn(() =>
437 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
438 ),
439 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
440 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
441 katalogBild: vi.fn(() => Promise.resolve(BILD_DATEN)),
442 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
443 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
444 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
445 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
446 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
447 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
448 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
449 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
450 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
451 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
452 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
453 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH)])),
454 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
455 lernMerken: vi.fn((_profilId: number, frageId: string, gemerkt: boolean) =>
456 Promise.resolve({ ...STAND, frageId, gemerkt }),
457 ),
458 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
459 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
460 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
461 ...ueberschreibungen,
462 };
463 }
464
465 /** Rendert die Anwendung mit genau dieser Bridge und öffnet die Sitzung. */
466 async function mitBrueckeOeffnen(bruecke: Partial<LernAppBridge>): Promise<Nutzer> {
467 vi.stubGlobal('lernApp', bruecke);
468 const nutzer = userEvent.setup();
469 render(<App />);
470
471 const knopf = await screen.findByRole('button', { name: /Weiterlernen/u });
472 /* Der Einstieg ist nie `disabled`, sondern meldet sich über `aria-disabled`
473 (Projektregel, docs/stand.md 7.13). `toBeEnabled()` wäre hier immer wahr
474 und liefe dem geladenen Katalog nicht mehr hinterher. */
475 await waitFor(() => {
476 expect(knopf).toHaveAttribute('aria-disabled', 'false');
477 });
478 await nutzer.click(knopf);
479 return nutzer;
480 }
481
482 /** Rendert die Anwendung mit vorbereiteter Bridge und öffnet die Sitzung. */
483 async function lernsitzungOeffnen(ueberschreibungen: Partial<LernAppBridge> = {}): Promise<Nutzer> {
484 return mitBrueckeOeffnen(attrappe(ueberschreibungen));
485 }
486
487 /**
488 * Öffnet die Sitzung mit einer Bridge, der ein Kanal fehlt.
489 *
490 * Eine eigene Funktion, weil sich ein Kanal nicht über
491 * {@link lernsitzungOeffnen} entfernen lässt: Dort wird die Attrappe über die
492 * Überschreibungen gelegt, und ein fehlender Schlüssel setzt den Wert der
493 * Grundlage nicht zurück – er lässt ihn stehen.
494 */
495 async function ohneKanalOeffnen(kanal: keyof LernAppBridge): Promise<Nutzer> {
496 const bruecke = attrappe();
497 Reflect.deleteProperty(bruecke, kanal);
498 return mitBrueckeOeffnen(bruecke);
499 }
500
501 /**
502 * Die Live-Region der Sitzung – nicht die des Themenwechsels und nicht die
503 * des Meldeblocks.
504 *
505 * Seit es „Fehler zu dieser Frage melden“ gibt, stehen zwei Regionen in
506 * derselben Landmarke: die unsichtbare Rückmeldung zur Antwort und die
507 * sichtbare Bestätigung des Kopierens. Unterschieden wird über die Klasse und
508 * nicht über eine Beschriftung – eine Beschriftung an der Rückmeldung würde
509 * bei `aria-atomic="true"` mit vorgelesen und änderte damit, was ein
510 * Bildschirmleser nach jeder Antwort hört. Der Test hat sich nach der
511 * Anwendung zu richten, nicht umgekehrt.
512 */
513 function ansagebereich(): HTMLElement {
514 const sitzung = screen.getByRole('region', { name: /Lernsitzung/u });
515 const bereich = sitzung.querySelector<HTMLElement>('.nur-screenreader[role="status"]');
516 if (bereich === null) {
517 throw new Error('Die Live-Region der Sitzung fehlt.');
518 }
519 return bereich;
520 }
521
522 afterEach(() => {
523 /* Die Programmangaben liegen in einem modulweiten Zwischenspeicher – ohne
524 Leeren sähe der nächste Test die Antwort des vorigen. */
525 programmangabenVergessen();
526 vi.unstubAllGlobals();
527 /* Die Erklaerungen liegen in einem Modul-weiten Zwischenspeicher – ohne
528 Leeren wuerde der naechste Test die Antwort des vorigen sehen. */
529 erklaerungenZwischenspeicherLeeren();
530 glossarZwischenspeicherLeeren();
531 normtexteZwischenspeicherLeeren();
532 themenZwischenspeicherLeeren();
533 });
534
535 // ─── Tests ──────────────────────────────────────────────────────────────
536
537 describe('Lernsitzung – Aufbau und Fortschritt', () => {
538 it('zeigt amtliche Nummer, Kapitel und Fragetyp', async () => {
539 await lernsitzungOeffnen();
540
541 expect(
542 await screen.findByRole('heading', { name: /Frage 1\.87/u, level: 1 }),
543 ).toBeInTheDocument();
544 expect(screen.getByText(/Kapitel I – Waffenrecht/u)).toBeInTheDocument();
545 expect(screen.getByText(/Abschnitt I\.1 – Begriffe des Waffenrechts/u)).toBeInTheDocument();
546 expect(screen.getByText('Multiple Choice')).toBeInTheDocument();
547 });
548
549 it('meldet den Fortschritt als progressbar und als Text', async () => {
550 await lernsitzungOeffnen({
551 lernSitzung: vi.fn(() =>
552 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
553 ),
554 });
555
556 const balken = await screen.findByRole('progressbar', { name: 'Fortschritt der Lernsitzung' });
557 expect(balken).toHaveAttribute('aria-valuenow', '1');
558 expect(balken).toHaveAttribute('aria-valuemin', '0');
559 expect(balken).toHaveAttribute('aria-valuemax', '2');
560 expect(balken).toHaveAttribute('aria-valuetext', 'Frage 1 von 2');
561 expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument();
562 });
563 });
564
565 describe('Lernsitzung – Einfachauswahl', () => {
566 it('bietet eine Radiogruppe mit dem Hinweis auf genau eine Antwort', async () => {
567 await lernsitzungOeffnen();
568
569 expect(await screen.findByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
570 expect(
571 screen.getByRole('group', { name: /genau eine Antwort ist richtig/u }),
572 ).toBeInTheDocument();
573 expect(screen.getAllByRole('radio')).toHaveLength(3);
574 expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
575 });
576
577 it('bewertet eine richtige Auswahl und sagt sie über die Live-Region an', async () => {
578 const nutzer = await lernsitzungOeffnen();
579 await screen.findByRole('heading', { name: /Frage 1\.87/u });
580
581 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
582 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
583
584 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
585 expect(ansagebereich()).toHaveTextContent('Richtig. Richtig ist Antwort c.');
586 });
587
588 it('bewertet eine falsche Auswahl und nennt die Lösung', async () => {
589 const nutzer = await lernsitzungOeffnen();
590 await screen.findByRole('heading', { name: /Frage 1\.87/u });
591
592 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
593 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
594
595 expect(screen.getByRole('group', { name: 'Falsch' })).toBeInTheDocument();
596 expect(ansagebereich()).toHaveTextContent('Falsch. Richtig ist Antwort c.');
597 expect(screen.getByText('Richtig – nicht gewählt')).toBeInTheDocument();
598 expect(screen.getByText('Falsch – von Ihnen gewählt')).toBeInTheDocument();
599 });
600
601 /*
602 Dieselbe Fehlerklasse wie beim Freitextfeld, nur auf dem Hauptweg: Bis
603 Fassung 0.19.2 trug das Feldset `disabled`, und ein gesperrtes Feldset
604 sperrt jedes Bedienelement darin. Nach dem Bestätigen nahm keine
605 Antwortmöglichkeit mehr den Fokus – die Rückmeldung ließ sich nicht mehr
606 durchgehen, obwohl genau dafür die Optionen stehenbleiben.
607 */
608 it('lässt die Antwortmöglichkeiten nach dem Bestätigen ansteuerbar', async () => {
609 const nutzer = await lernsitzungOeffnen();
610 await screen.findByRole('heading', { name: /Frage 1\.87/u });
611
612 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
613 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
614
615 for (const feld of screen.getAllByRole('radio')) {
616 expect(feld).not.toBeDisabled();
617 expect(feld).toHaveAttribute('aria-disabled', 'true');
618 feld.focus();
619 expect(feld).toHaveFocus();
620 }
621 });
622
623 it('ändert die Auswahl nach dem Bestätigen nicht mehr', async () => {
624 const nutzer = await lernsitzungOeffnen();
625 await screen.findByRole('heading', { name: /Frage 1\.87/u });
626
627 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
628 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
629
630 /* Erreichbar heißt nicht änderbar: Ein Klick auf eine andere Möglichkeit
631 darf die bewertete Antwort nicht nachträglich verschieben. */
632 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
633
634 expect(screen.getByRole('radio', { name: /Sportgewehr/u })).toBeChecked();
635 expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).not.toBeChecked();
636 expect(screen.getByRole('group', { name: 'Falsch' })).toBeInTheDocument();
637 });
638
639 it('verlangt eine Auswahl, bevor bestätigt werden kann', async () => {
640 const nutzer = await lernsitzungOeffnen();
641 await screen.findByRole('heading', { name: /Frage 1\.87/u });
642
643 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
644
645 expect(ansagebereich()).toHaveTextContent('Bitte wählen Sie zuerst eine Antwort aus.');
646 expect(screen.queryByRole('group', { name: 'Richtig' })).not.toBeInTheDocument();
647 });
648
649 it('protokolliert die Antwort über den Lernstand', async () => {
650 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
651 const nutzer = await lernsitzungOeffnen({ lernAntworten });
652 await screen.findByRole('heading', { name: /Frage 1\.87/u });
653
654 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
655 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
656
657 /* Gebucht wird seit 0.22.0 erst beim Weiterblättern: Bis dahin lässt
658 sich die Selbsteinschätzung noch nachreichen („Ich hatte geraten“).
659 Die Antwort selbst bleibt unantastbar. */
660 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
661
662 expect(lernAntworten).toHaveBeenCalledTimes(1);
663 expect(lernAntworten).toHaveBeenCalledWith(
664 1,
665 expect.objectContaining({
666 frageId: 'I.1-87',
667 auswahl: ['c'],
668 richtig: true,
669 bewertung: 'gut',
670 }),
671 );
672 });
673 });
674
675 describe('Lernsitzung – Fachbegriffe zur Frage', () => {
676 it('bietet die Begriffe der Frage zugeklappt an', async () => {
677 /* WCAG 3.1.3: Wer ein Fachwort nicht kennt, versteht die Frage nicht.
678 Der Prüfplan verlangt den Zugang ausdrücklich aus der Frage heraus –
679 also vor dem Beantworten, nicht erst danach. */
680 await lernsitzungOeffnen();
681 await screen.findByRole('heading', { name: /Frage 1\.87/u });
682
683 const schalter = screen.getByText('Ein Fachbegriff in dieser Frage');
684 const aufklapper = schalter.closest<HTMLElement>('details');
685
686 expect(aufklapper).not.toBeNull();
687 expect(aufklapper).not.toHaveAttribute('open');
688 });
689
690 it('nennt die gefundenen Begriffe im zugänglichen Namen', async () => {
691 // „Ein Fachbegriff in dieser Frage" allein sagt nicht, welcher.
692 await lernsitzungOeffnen();
693 await screen.findByRole('heading', { name: /Frage 1\.87/u });
694
695 expect(screen.getByText('Ein Fachbegriff in dieser Frage').parentElement).toHaveTextContent(
696 'Führen',
697 );
698 });
699
700 it('warnt vor dem Beantworten, dass die Antwort darin stehen kann', async () => {
701 /* Bei einer Definitionsfrage ist die Begriffsbestimmung die Antwort.
702 Wer die Hilfe braucht, bekommt sie – wer sich die Frage nicht
703 verderben will, stolpert nicht hinein. */
704 const nutzer = await lernsitzungOeffnen();
705 await screen.findByRole('heading', { name: /Frage 1\.87/u });
706
707 await nutzer.click(screen.getByText('Ein Fachbegriff in dieser Frage'));
708
709 expect(screen.getByText(/steht die Antwort hier bereits/u)).toBeInTheDocument();
710 expect(screen.getByText(/außerhalb der eigenen Wohnung/u)).toBeInTheDocument();
711 });
712
713 it('lässt die Warnung weg, sobald die Frage beantwortet ist', async () => {
714 const nutzer = await lernsitzungOeffnen();
715 await screen.findByRole('heading', { name: /Frage 1\.87/u });
716
717 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
718 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
719 await nutzer.click(screen.getByText('Ein Fachbegriff in dieser Frage'));
720
721 expect(screen.queryByText(/steht die Antwort hier bereits/u)).not.toBeInTheDocument();
722 expect(screen.getByText(/außerhalb der eigenen Wohnung/u)).toBeInTheDocument();
723 });
724
725 it('klappt bei der nächsten Frage wieder zu', async () => {
726 /*
727 Befund der Prüfrunde zu 0.27.2. Der Baustein sagt zu: „Deshalb ist die
728 Liste zugeklappt und sagt vor dem Beantworten dazu, worauf man sich
729 einlässt.“ Ein von Hand aufgeklapptes `<details>` bekommt React aber
730 nicht wieder zu – es vergleicht Eigenschaften mit Eigenschaften, nie mit
731 dem DOM. `Fehlermeldeblock` bekam dafür einen Schlüssel, dieser Block
732 nicht: Wer einmal aufklappte, sah bei jeder folgenden Frage die
733 Begriffsbestimmungen offen daliegen, bevor er geantwortet hatte – bei
734 einer Definitionsfrage ist das die Lösung.
735
736 Der Schlüssel hängt an der laufenden Nummer und nicht an der Frage: Eine
737 falsch beantwortete Frage kommt in derselben Sitzung noch einmal, und
738 dann ist es dieselbe Kennung, aber eine neue Vorlage.
739 */
740 const nutzer = await lernsitzungOeffnen({
741 lernSitzung: vi.fn(() =>
742 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_FREITEXT)]),
743 ),
744 });
745 await screen.findByRole('heading', { name: /Frage 1\.87/u });
746
747 await nutzer.click(screen.getByText('Ein Fachbegriff in dieser Frage'));
748 expect(screen.getByText('Ein Fachbegriff in dieser Frage').closest('details')).toHaveAttribute(
749 'open',
750 );
751
752 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
753 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
754 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
755
756 await screen.findByRole('heading', { name: /Frage 1\.01/u });
757 expect(
758 screen.getByText('Ein Fachbegriff in dieser Frage').closest('details'),
759 ).not.toHaveAttribute('open');
760 });
761
762 it('nennt vor dem Beantworten keinen Begriff, der nur in der Musterantwort steht', async () => {
763 /*
764 Befund der Prüfrunde zu 0.27.2. Gesucht wurde in Fragetext,
765 Antwortmöglichkeiten **und Musterantwort** — begründet damit, ein
766 Fachwort blockiere das Verständnis auch dann, wenn es erst in einer
767 Antwortmöglichkeit auftaucht. Bei den 104 offenen Fragen ist die
768 Musterantwort aber die Lösung: Ihre Fachbegriffe standen schon vor dem
769 Beantworten in der Zusammenfassung des Aufklappers — als Zahl für alle,
770 und im zugänglichen Namen namentlich für Bildschirmleser.
771
772 Die Frage „Was regelt das Waffengesetz?“ enthält den Glossarbegriff
773 „WaffG“ (Variante „Waffengesetz“) selbst; die Musterantwort bringt
774 zusätzlich „Umgang mit Waffen“ — dafür steht im Prüfglossar „Führen“
775 nicht, deshalb wird hier über die Zahl geprüft.
776 */
777 const nutzer = await lernsitzungOeffnen({
778 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
779 glossarLaden: vi.fn(() =>
780 Promise.resolve({
781 ...GLOSSAR,
782 eintraege: [
783 ...GLOSSAR.eintraege,
784 {
785 begriff: 'Munition',
786 art: 'begriff' as const,
787 kurz: 'Zum Verschießen bestimmte Geschosse mit Treibladung.',
788 varianten: ['Munition'],
789 fundstellen: [],
790 ohneFundstelleGrund: 'Prüfstand.',
791 },
792 ],
793 }),
794 ),
795 });
796 await screen.findByRole('heading', { name: /Frage 1\.01/u });
797
798 /* „Munition“ steht ausschließlich in der Musterantwort. */
799 const vorher = screen.getByText(/Fachbegriff(e)? in dieser Frage/u);
800 expect(vorher.parentElement).not.toHaveTextContent('Munition');
801
802 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
803
804 /* Nach dem Bestätigen steht die Musterantwort ohnehin da – dann gehört
805 ihr Fachwort auch in die Liste. */
806 expect(screen.getByText(/Fachbegriff(e)? in dieser Frage/u).parentElement).toHaveTextContent(
807 'Munition',
808 );
809 });
810
811 it('zeigt nichts an, wenn die Frage keinen Fachbegriff enthält', async () => {
812 const suche = /Fachbegriff(e)? in dieser Frage/u;
813 await lernsitzungOeffnen({
814 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])),
815 });
816
817 await screen.findByRole('heading', { name: /Frage 2\.10/u });
818 expect(screen.queryByText(suche)).not.toBeInTheDocument();
819 });
820
821 it('bleibt bedienbar, wenn der Kern das Glossar nicht anbietet', async () => {
822 const nutzer = await ohneKanalOeffnen('glossarLaden');
823 await screen.findByRole('heading', { name: /Frage 1\.87/u });
824
825 expect(screen.queryByText(/Fachbegriff/u)).not.toBeInTheDocument();
826 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
827 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
828 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
829 });
830 });
831
832 describe('Lernsitzung – Erklärung zur Antwort', () => {
833 async function bisZumErgebnis(ueberschreibungen: Partial<LernAppBridge> = {}): Promise<Nutzer> {
834 const nutzer = await lernsitzungOeffnen(ueberschreibungen);
835 await screen.findByRole('heading', { name: /Frage 1\.87/u });
836 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
837 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
838 return nutzer;
839 }
840
841 it('zeigt die Begründung erst nach dem Bestätigen', async () => {
842 const nutzer = await lernsitzungOeffnen();
843 await screen.findByRole('heading', { name: /Frage 1\.87/u });
844
845 // Vorher darf nichts von der Lösung durchscheinen.
846 expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument();
847
848 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
849 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
850
851 expect(await screen.findByRole('heading', { name: 'Warum das so ist' })).toBeInTheDocument();
852 });
853
854 it('nennt Kurzfassung, Begründung und Merksatz', async () => {
855 await bisZumErgebnis();
856 await screen.findByRole('heading', { name: 'Warum das so ist' });
857
858 expect(screen.getByText(/zum Verwechseln aehnlich/u)).toBeInTheDocument();
859 expect(screen.getByText(/Entscheidend ist der aeussere Eindruck/u)).toBeInTheDocument();
860 expect(screen.getByText(/Was aussieht wie eine Waffe/u)).toBeInTheDocument();
861 });
862
863 it('führt die Fundstellen in juristischer Kurzform auf', async () => {
864 await bisZumErgebnis();
865 /* Keine Überschrift mehr, seit die Erklärung aufklappbar ist: Sie trägt
866 ihre eigene Überschrift im `<summary>`, und eine weitere Ebene darunter
867 wäre ein Sprung. Der Text bleibt und benennt weiterhin die Liste. */
868 const titel = await screen.findByText('Im Gesetz nachlesen');
869
870 const liste = screen.getByRole('list', { name: 'Im Gesetz nachlesen' });
871 expect(titel).toBeInTheDocument();
872 expect(within(liste).getAllByRole('listitem')).toHaveLength(2);
873 expect(
874 within(liste).getByText('Anlage 1 Abschnitt 1 Unterabschnitt 1 Nr. 1.6 WaffG'),
875 ).toBeInTheDocument();
876 expect(within(liste).getByText('§ 42a Abs. 1 Nr. 1 WaffG')).toBeInTheDocument();
877 });
878
879 it('schreibt die Fundstellen für Bildschirmleser aus', async () => {
880 /* „§ 12 Abs. 1“ wird sonst buchstabiert oder falsch betont. Sichtbar
881 bleibt die Kurzform, vorgelesen wird die Langform. */
882 await bisZumErgebnis();
883 const liste = await screen.findByRole('list', { name: 'Im Gesetz nachlesen' });
884
885 expect(
886 within(liste).getByText('Paragraf 42a Absatz 1 Nummer 1 Waffengesetz'),
887 ).toBeInTheDocument();
888 expect(within(liste).getByText(/Anlage 1 .* Waffengesetz$/u)).toBeInTheDocument();
889 });
890
891 it('macht die Fundstellen aufklappbar, sobald Normtexte beiliegen', async () => {
892 /* Der Kern von M30: „Im Gesetz nachlesen“ stand als Aufforderung da, und
893 eine ausdrücklich vollständig offline arbeitende Software lieferte
894 keinen einzigen Normtext mit. Hier wird die Verdrahtung geprüft – der
895 Auszug selbst hat eigene Fälle in tests/normstelle.test.tsx. */
896 const nutzer = await bisZumErgebnis({
897 normtexteLaden: vi.fn(() =>
898 Promise.resolve({
899 meta: {
900 version: 1,
901 stand: '2026-08-29',
902 hinweis: 'Auszug',
903 gesetzesstand: { WaffG: 'Pruefstand' },
904 },
905 gesetze: {
906 WaffG: {
907 bezeichnung: 'Waffengesetz',
908 stand: 'Pruefstand',
909 quelle: 'https://www.gesetze-im-internet.de/waffg_2002/',
910 normen: {
911 '§ 42a': {
912 titel: 'Verbot des Führens von Anscheinswaffen',
913 istAnlage: false,
914 absaetze: { '1': 'Es ist verboten, Anscheinswaffen zu fuehren.' },
915 },
916 },
917 },
918 },
919 }),
920 ),
921 });
922
923 const liste = await screen.findByRole('list', { name: 'Im Gesetz nachlesen' });
924 await waitFor(() => {
925 expect(within(liste).getAllByText(/im Wortlaut anzeigen/u).length).toBeGreaterThan(0);
926 });
927
928 await nutzer.click(within(liste).getByText('§ 42a Abs. 1 Nr. 1 WaffG'));
929
930 expect(screen.getByText(/Es ist verboten, Anscheinswaffen zu fuehren/u)).toBeInTheDocument();
931 });
932
933 it('nennt die verwandten Fragen zum selben Gegenstand', async () => {
934 /* Der amtliche Katalog kennt keine Frage-zu-Frage-Beziehung, obwohl die
935 Bündel auf der Hand liegen. Wer eine Frage gerade verstanden hat, ist im
936 besten Moment, die Nachbarn mitzunehmen. */
937 const nutzer = await bisZumErgebnis();
938
939 const schalter = await screen.findByText(/Verwandte Fragen \(1\)/u);
940 await nutzer.click(schalter);
941
942 const liste = screen.getByRole('list', { name: /Diese Fragen behandeln denselben/u });
943 expect(within(liste).getByText(/Frage 1\.01/u)).toBeInTheDocument();
944 expect(
945 screen.getByText(/Die Zuordnung ist eine Ergänzung dieser Software/u),
946 ).toBeInTheDocument();
947 });
948
949 it('bietet mitten in der Sitzung keine Übungstaste an', async () => {
950 /* Sie wäre hier eine Falle: Eine Sitzung zu starten ersetzt die laufende,
951 und zwar ohne Rückfrage. Wer mitten in zwanzig Fragen steckt, verlöre
952 seine Warteschlange für einen Knopf, den er vielleicht nur neugierig
953 gedrückt hat. Die Taste steht am Ende der Sitzung. */
954 const nutzer = await bisZumErgebnis();
955 await nutzer.click(await screen.findByText(/Verwandte Fragen \(1\)/u));
956
957 expect(
958 screen.queryByRole('button', { name: /Verwandte Fragen üben/u }),
959 ).not.toBeInTheDocument();
960 });
961
962 it('weist die Erklärung als eigenen Zusatz aus', async () => {
963 // Der amtliche Katalog darf nicht verändert wiedergegeben werden; was
964 // eigener Zusatz ist, muss erkennbar sein.
965 await bisZumErgebnis();
966 await screen.findByRole('heading', { name: 'Warum das so ist' });
967
968 expect(
969 screen.getByText(/Diese Erklärung ist eine Ergänzung dieser Software/u),
970 ).toBeInTheDocument();
971 });
972
973 it('kommt ohne Erklärung aus, wenn es zu einer Frage keine gibt', async () => {
974 // Zwei Fragen, damit sich weiterblättern lässt: Die zweite hat keine
975 // Erklärung – so wächst der Bestand auch in Wirklichkeit.
976 const nutzer = await lernsitzungOeffnen({
977 lernSitzung: vi.fn(() =>
978 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
979 ),
980 });
981 await screen.findByRole('heading', { name: /Frage 1\.87/u });
982 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
983 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
984 await screen.findByRole('heading', { name: 'Warum das so ist' });
985
986 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
987 await screen.findByRole('heading', { name: /Frage 2\.10/u });
988 await nutzer.click(screen.getAllByRole('checkbox')[0]!);
989 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
990
991 expect(
992 screen.getByRole('group', { name: /Unvollständig|Falsch|Richtig/u }),
993 ).toBeInTheDocument();
994 expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument();
995 });
996
997 it('sagt ausdrücklich, wenn es keine gesetzliche Grundlage gibt', async () => {
998 /* In Waffentechnik und Handhabung ist das der Regelfall. Eine gedehnte
999 Fundstelle wäre schlimmer als keine – sie täuscht eine
1000 Verbindlichkeit vor, die es nicht gibt. */
1001 const nutzer = await lernsitzungOeffnen({
1002 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
1003 });
1004
1005 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1006 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1007 await nutzer.click(screen.getByRole('button', { name: /^Gewusst/u }));
1008
1009 await screen.findByRole('heading', { name: 'Warum das so ist' });
1010
1011 expect(
1012 screen.getByText(/Sicherheitsregeln zur Handhabung sind fachliche Praxis/u),
1013 ).toBeInTheDocument();
1014 // Kein leerer Fundstellenblock, wo es nichts nachzulesen gibt.
1015 expect(screen.queryByRole('heading', { name: 'Im Gesetz nachlesen' })).not.toBeInTheDocument();
1016 });
1017
1018 it('bleibt bedienbar, wenn der Kern den Kanal nicht anbietet', async () => {
1019 const nutzer = await ohneKanalOeffnen('erklaerungenLaden');
1020 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1021 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1022 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1023
1024 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
1025 expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument();
1026 });
1027 });
1028
1029 describe('Lernsitzung – Mehrfachauswahl', () => {
1030 const nurMehrfach = {
1031 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])),
1032 };
1033
1034 it('nutzt Kontrollkästchen und weist auf mehrere richtige Antworten hin', async () => {
1035 await lernsitzungOeffnen(nurMehrfach);
1036
1037 expect(await screen.findByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
1038 expect(screen.getAllByRole('checkbox')).toHaveLength(3);
1039 expect(screen.getByText('Wählen Sie alle zutreffenden aus.')).toBeInTheDocument();
1040 expect(
1041 screen.getByRole('group', { name: /mehrere Antworten sind richtig/u }),
1042 ).toBeInTheDocument();
1043 });
1044
1045 it('wertet eine unvollständige Auswahl als unvollständig', async () => {
1046 const nutzer = await lernsitzungOeffnen(nurMehrfach);
1047 await screen.findByRole('heading', { name: /Frage 2\.10/u });
1048
1049 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
1050 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1051
1052 expect(screen.getByRole('group', { name: 'Unvollständig' })).toBeInTheDocument();
1053 expect(ansagebereich()).toHaveTextContent('Unvollständig. Es fehlt Antwort b.');
1054 });
1055
1056 it('wertet die vollständige Auswahl als richtig', async () => {
1057 const nutzer = await lernsitzungOeffnen(nurMehrfach);
1058 await screen.findByRole('heading', { name: /Frage 2\.10/u });
1059
1060 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
1061 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
1062 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1063
1064 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
1065 expect(ansagebereich()).toHaveTextContent('Richtig sind die Antworten a und b.');
1066 });
1067
1068 it('wertet eine zusätzliche falsche Auswahl als falsch', async () => {
1069 const nutzer = await lernsitzungOeffnen(nurMehrfach);
1070 await screen.findByRole('heading', { name: /Frage 2\.10/u });
1071
1072 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
1073 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
1074 await nutzer.click(screen.getByRole('checkbox', { name: /jährlich vorführen/u }));
1075 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1076
1077 expect(screen.getByRole('group', { name: 'Falsch' })).toBeInTheDocument();
1078 });
1079 });
1080
1081 describe('Lernsitzung – offene Fragen', () => {
1082 const nurFreitext = {
1083 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
1084 };
1085
1086 /*
1087 Der Text lag seit jeher in `antwort_log.freitext` und wurde nie gezeigt.
1088 Sein Wert liegt im Wiedersehen – aber erst NACH dem Bestätigen: vorher
1089 wäre er eine Vorlage zum Abschreiben und machte aus dem Ausformulieren
1090 ein Kopieren.
1091 */
1092 it('zeigt die frühere eigene Antwort erst nach dem Bestätigen', async () => {
1093 const mitFrueher = {
1094 ...nurFreitext,
1095 lernSitzung: vi.fn(() =>
1096 Promise.resolve([
1097 {
1098 frageId: 'I.1-01',
1099 optionsReihenfolge: [],
1100 gemerkt: false,
1101 letzterFreitext: {
1102 text: 'Den Umgang mit Waffen, glaube ich.',
1103 zeitpunkt: '2026-08-09T09:00:00.000Z',
1104 },
1105 },
1106 ]),
1107 ),
1108 };
1109 const nutzer = await lernsitzungOeffnen(mitFrueher);
1110 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1111
1112 // Vor dem Bestätigen: nichts davon zu sehen.
1113 expect(screen.queryByText(/Den Umgang mit Waffen, glaube ich\./u)).not.toBeInTheDocument();
1114
1115 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Zweiter Versuch.');
1116 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1117
1118 expect(screen.getByText(/Den Umgang mit Waffen, glaube ich\./u)).toBeVisible();
1119 /* Mit Datum, nicht bloß „beim letzten Mal“: Ob es gestern war oder vor
1120 einem Monat, ändert die Bedeutung des Vergleichs. */
1121 expect(screen.getByText(/Am 09\.08\.2026 schrieben Sie:/u)).toBeVisible();
1122 });
1123
1124 it('lässt den Block weg, wenn es keine frühere Antwort gibt', async () => {
1125 const nutzer = await lernsitzungOeffnen(nurFreitext);
1126 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1127
1128 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1129 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1130
1131 expect(screen.queryByText(/schrieben Sie:/u)).not.toBeInTheDocument();
1132 });
1133
1134 it('nennt die Kernpunkte als Einschätzung dieser Software', async () => {
1135 /* Bei 63 der 104 offenen Fragen ist im Katalog nichts unterstrichen. Der
1136 Lernende stand bei der Selbstbewertung ohne jedes Kriterium da – die
1137 Hilfe warnt vor zu milder Bewertung, gab aber kein Werkzeug. */
1138 const nutzer = await lernsitzungOeffnen(nurFreitext);
1139 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1140
1141 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1142 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1143
1144 const liste = screen.getByRole('list', {
1145 name: 'Nach Einschätzung dieser Software gehört zu einer vollständigen Antwort:',
1146 });
1147 expect(within(liste).getAllByRole('listitem')).toHaveLength(2);
1148 expect(
1149 screen.getByText(/Diese Punkte sind eine Ergänzung dieser Software/u),
1150 ).toBeInTheDocument();
1151 });
1152
1153 it('behauptet nie, was eine Antwort enthalten muss', async () => {
1154 /* Die Leitplanke aus `docs/entscheidung-freitext.md` Abschnitt 8. Bis
1155 Fassung 0.17.0 stand über einer ähnlichen Liste „Diese Kernelemente muss
1156 Ihre Antwort enthalten“ – unbelegt, und ausdrücklich abgeschafft. */
1157 const nutzer = await lernsitzungOeffnen(nurFreitext);
1158 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1159
1160 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1161 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1162
1163 expect(screen.queryByText(/muss Ihre Antwort enthalten/u)).not.toBeInTheDocument();
1164 expect(screen.queryByText(/zwingend erforderlich/u)).not.toBeInTheDocument();
1165 });
1166
1167 it('zeigt die Musterantwort erst nach dem Bestätigen', async () => {
1168 const nutzer = await lernsitzungOeffnen(nurFreitext);
1169 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1170
1171 const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' });
1172 expect(screen.queryByRole('heading', { name: 'Musterantwort' })).not.toBeInTheDocument();
1173
1174 await nutzer.type(feld, 'Den Umgang mit Waffen.');
1175 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1176
1177 expect(screen.getByRole('heading', { name: 'Musterantwort' })).toBeInTheDocument();
1178 const unterstrichen = screen.getByRole('list', {
1179 name: 'Im amtlichen Fragenkatalog ist hier unterstrichen:',
1180 });
1181 expect(
1182 within(unterstrichen)
1183 .getAllByRole('listitem')
1184 .map((eintrag) => eintrag.textContent),
1185 ).toEqual(['Umgang mit Waffen oder Munition', 'öffentlichen Sicherheit']);
1186 });
1187
1188 /*
1189 Bis 0.17.0 stand über dieser Liste „Diese Kernelemente muss Ihre Antwort
1190 enthalten:“. Beides war unbelegt: Das Amt sagt nirgends, was die
1191 Unterstreichung bedeutet, und bei 63 der 104 offenen Fragen ist gar
1192 nichts unterstrichen. Ohne diese Prüfung schriebe die Behauptung
1193 irgendwann jemand zurück, weil die alte Formulierung griffiger klingt.
1194 */
1195 it('nennt die Unterstreichung als Tatsache und nicht als Vorgabe', async () => {
1196 const nutzer = await lernsitzungOeffnen(nurFreitext);
1197 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1198
1199 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1200 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1201
1202 expect(screen.queryByText(/muss Ihre Antwort enthalten/u)).not.toBeInTheDocument();
1203 expect(screen.getByText(/keine Vorgabe für Ihre Antwort/u)).toHaveTextContent(
1204 /Eine richtige Antwort kann anders formuliert sein/u,
1205 );
1206 });
1207
1208 /*
1209 Das Feld bleibt nach dem Bestätigen stehen, damit sich die eigene Antwort
1210 gegen die Musterantwort halten lässt. Mit `disabled` war genau das per
1211 Tastatur unmöglich – ein disabled-Feld nimmt keinen Fokus, ein
1212 Screenreader kam an den eigenen Text nicht mehr heran.
1213 */
1214 it('lässt die eigene Antwort nach dem Bestätigen lesbar und ansteuerbar', async () => {
1215 const nutzer = await lernsitzungOeffnen(nurFreitext);
1216 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1217
1218 const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' });
1219 await nutzer.type(feld, 'Den Umgang mit Waffen.');
1220 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1221
1222 expect(feld).toHaveValue('Den Umgang mit Waffen.');
1223 expect(feld).not.toBeDisabled();
1224 expect(feld).toHaveAttribute('readonly');
1225 feld.focus();
1226 expect(feld).toHaveFocus();
1227 });
1228
1229 it('übernimmt die Selbstbewertung „Gewusst“ als richtig', async () => {
1230 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1231 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1232 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1233
1234 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Den Umgang.');
1235 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1236 await nutzer.click(screen.getByRole('button', { name: 'Gewusst' }));
1237
1238 expect(screen.getByRole('group', { name: 'Gewusst' })).toBeInTheDocument();
1239 expect(ansagebereich()).toHaveTextContent('Bewertung: Gewusst. Sie lässt sich noch ändern.');
1240
1241 /* Gebucht wird erst beim Weiterblättern – siehe den Block
1242 „Selbstbewertung zurücknehmen“ weiter unten. */
1243 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1244
1245 expect(lernAntworten).toHaveBeenCalledWith(
1246 1,
1247 expect.objectContaining({
1248 frageId: 'I.1-01',
1249 freitext: 'Den Umgang.',
1250 richtig: true,
1251 bewertung: 'gut',
1252 }),
1253 );
1254 });
1255
1256 it('wertet „Nicht gewusst“ als falsch', async () => {
1257 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1258 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1259 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1260
1261 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1262 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
1263 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1264
1265 expect(lernAntworten).toHaveBeenCalledWith(
1266 1,
1267 expect.objectContaining({ richtig: false, bewertung: 'nochmal' }),
1268 );
1269 });
1270
1271 it('bietet alle vier Bewertungsstufen in einer benannten Gruppe an', async () => {
1272 const nutzer = await lernsitzungOeffnen(nurFreitext);
1273 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1274
1275 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1276
1277 const gruppe = screen.getByRole('group', { name: 'Wie gut wussten Sie die Antwort?' });
1278 expect(
1279 within(gruppe)
1280 .getAllByRole('button')
1281 .map((knopf) => knopf.textContent),
1282 ).toEqual(['Nicht gewusst', 'Mit Mühe gewusst', 'Gewusst', 'Sicher gewusst']);
1283 });
1284 });
1285
1286 /*
1287 Die Selbstbewertung zurücknehmen.
1288
1289 Bis 0.22.0 buchte ein Klick auf eine der vier Stufen sofort und endgültig:
1290 Ein Fehlgriff auf „Gewusst“ statt „Nicht gewusst“ schrieb eine falsche
1291 FSRS-Buchung – hohe Stabilität, langes Intervall, unter Umständen ein
1292 Reife-Beleg –, und das Programm bot nie wieder an, das zu berichtigen. Bei
1293 vier eng beieinanderliegenden Knöpfen ist das kein Randfall.
1294
1295 Die andere Richtung braucht nichts: Eine zu streng bewertete Antwort kommt
1296 in derselben Sitzung wieder, und FSRS verkürzt das Intervall ohnehin.
1297 Gefährlich ist allein die zu milde Bewertung.
1298 */
1299 describe('Lernsitzung – Selbstbewertung zurücknehmen', () => {
1300 const nurFreitext = {
1301 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
1302 };
1303
1304 it('bucht erst beim Weiterblättern, nicht schon beim Bewerten', async () => {
1305 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1306 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1307 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1308
1309 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1310 await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' }));
1311
1312 expect(lernAntworten).not.toHaveBeenCalled();
1313 });
1314
1315 it('behauptet im Ergebnisbereich keine Speicherung, die noch aussteht', async () => {
1316 /*
1317 Befund der Prüfrunde zu 0.27.2. Im Ergebnisbereich stand „Ihre
1318 Selbstbewertung ist gespeichert.“ – während die Buchung bis zum
1319 Weiterblättern zurückgehalten wird, damit sich ein Fehlgriff noch
1320 zurücknehmen lässt. Die Live-Region daneben sagte seit 0.22.0 das
1321 Richtige („Sie lässt sich noch ändern“), der sichtbare Absatz nicht.
1322
1323 Genau diese Unwahrheit hat `selbstbewertungAnsage` schon einmal
1324 abgelegt; sie stand nur an zwei Stellen und wurde an einer berichtigt.
1325 */
1326 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1327 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1328 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1329
1330 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1331 await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' }));
1332
1333 const bereich = screen.getByRole('group', { name: 'Sicher gewusst' });
1334 expect(lernAntworten).not.toHaveBeenCalled();
1335 expect(bereich).not.toHaveTextContent(/gespeichert/u);
1336 expect(bereich).toHaveTextContent(/ändern/u);
1337 });
1338
1339 it('lässt den Fokus nach „Bewertung ändern“ nicht auf <body> fallen', async () => {
1340 /*
1341 Befund der Prüfrunde zu 0.27.2. Der Knopf entfernt sich mit dem Klick
1342 selbst: `bewertungAendern` setzt die zurückgehaltene Buchung zurück,
1343 damit fällt `bewertungAenderbar` und mit ihm der gedrückte Knopf. Der
1344 Ergebnisbereich wird abgebaut, die Musterantwort bleibt stehen (ihr
1345 Fokuseffekt hängt an einer unveränderten Abhängigkeit), und die
1346 Bewertungsgruppe holte den Fokus nicht. Er lag danach auf `<body>`, und
1347 der nächste Tabulatordruck begann am Seitenanfang.
1348
1349 Dieselbe Regel, die `useFokusBeimAufbau` für Ergebnis und Musterantwort
1350 durchsetzt: „ohne diesen Sprung landet der Fokus beim Verschwinden der
1351 Schaltfläche auf `<body>`“.
1352 */
1353 const nutzer = await lernsitzungOeffnen(nurFreitext);
1354 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1355
1356 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1357 await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' }));
1358 await nutzer.click(screen.getByRole('button', { name: 'Bewertung ändern' }));
1359
1360 expect(document.body).not.toHaveFocus();
1361 expect(screen.getByRole('group', { name: 'Wie gut wussten Sie die Antwort?' })).toHaveFocus();
1362 });
1363
1364 it('lässt ihn auch nach „Ich hatte geraten“ nicht auf <body> fallen', async () => {
1365 /* Derselbe Bauart-Fehler am zweiten selbstentfernenden Knopf: Nach dem
1366 Eingeständnis ist `alsGeratenMoeglich` falsch, der Knopf verschwindet,
1367 und niemand übernimmt. */
1368 const nutzer = await lernsitzungOeffnen();
1369 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1370
1371 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1372 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1373 await nutzer.click(screen.getByRole('button', { name: 'Ich hatte geraten' }));
1374
1375 expect(document.body).not.toHaveFocus();
1376 expect(screen.getByRole('button', { name: /Nächste Frage|Sitzung auswerten/u })).toHaveFocus();
1377 });
1378
1379 it('verwirft die zu milde Bewertung, statt sie zu buchen', async () => {
1380 /* Der eigentliche Schadensfall: „Sicher gewusst“ statt „Nicht gewusst“.
1381 Nach der Korrektur darf ausschließlich die berichtigte Bewertung im
1382 Lernstand ankommen – keine zwei Einträge, kein „leicht“. */
1383 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1384 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1385 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1386
1387 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1388 await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' }));
1389 await nutzer.click(screen.getByRole('button', { name: 'Bewertung ändern' }));
1390 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
1391 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1392
1393 expect(lernAntworten).toHaveBeenCalledTimes(1);
1394 expect(lernAntworten).toHaveBeenCalledWith(
1395 1,
1396 expect.objectContaining({ richtig: false, bewertung: 'nochmal' }),
1397 );
1398 });
1399
1400 it('führt zurück zur Bewertungsgruppe und sagt es an', async () => {
1401 const nutzer = await lernsitzungOeffnen(nurFreitext);
1402 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1403
1404 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1405 await nutzer.click(screen.getByRole('button', { name: 'Gewusst' }));
1406 await nutzer.click(screen.getByRole('button', { name: 'Bewertung ändern' }));
1407
1408 expect(
1409 screen.getByRole('group', { name: 'Wie gut wussten Sie die Antwort?' }),
1410 ).toBeInTheDocument();
1411 expect(ansagebereich()).toHaveTextContent(/zurückgenommen/u);
1412 });
1413
1414 it('bietet die Rücknahme bei Auswahlfragen nicht an', async () => {
1415 /* Dort steht die Lösung auf dem Bildschirm. Eine änderbare Antwort wäre
1416 kein Korrekturweg, sondern ein zweiter Versuch mit bekannter Lösung. */
1417 const nutzer = await lernsitzungOeffnen();
1418 const optionen = await screen.findAllByRole('radio');
1419
1420 await nutzer.click(optionen[0]!);
1421 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1422
1423 expect(screen.queryByRole('button', { name: 'Bewertung ändern' })).not.toBeInTheDocument();
1424 });
1425
1426 /*
1427 „Ich hatte geraten“ – die Selbsteinschätzung bei Auswahlfragen.
1428
1429 `bewertungAusErgebnis` vergibt für jede richtige Antwort ausnahmslos
1430 „gut“ – auch für die geratene. `docs/entscheidung-ratewahrscheinlichkeit.md`
1431 lehnt zwei **automatische** Milderungen ab und benennt die Ursache
1432 wörtlich: „Das Problem sitzt im Eingang (ein Klick ist keine
1433 Selbsteinschätzung), nicht in den Parametern.“ Dieser Knopf öffnet genau
1434 diesen Eingang – freiwillig, und wer ihn übersieht, merkt nichts.
1435 */
1436 it('bietet die Selbsteinschätzung nach einer richtigen Auswahlantwort an', async () => {
1437 const nutzer = await lernsitzungOeffnen();
1438 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1439
1440 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1441 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1442
1443 expect(screen.getByRole('button', { name: 'Ich hatte geraten' })).toBeInTheDocument();
1444 });
1445
1446 it('bietet sie nach einer falschen Antwort nicht an', async () => {
1447 /* Da gibt es nichts einzugestehen – die Frage kommt ohnehin wieder. */
1448 const nutzer = await lernsitzungOeffnen();
1449 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1450
1451 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
1452 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1453
1454 expect(screen.queryByRole('button', { name: 'Ich hatte geraten' })).toBeNull();
1455 });
1456
1457 it('bucht die Antwort als „nicht gewusst“, ohne sie falsch zu machen', async () => {
1458 /* Angekreuzt war das Richtige, gewusst war es nicht: `richtig` bleibt
1459 wahr – der Anwendungskern rechnet es ohnehin selbst aus der Auswahl
1460 nach –, allein die Bewertung ändert sich. */
1461 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1462 const nutzer = await lernsitzungOeffnen({ lernAntworten });
1463 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1464
1465 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1466 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1467 await nutzer.click(screen.getByRole('button', { name: 'Ich hatte geraten' }));
1468 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1469
1470 expect(lernAntworten).toHaveBeenCalledTimes(1);
1471 expect(lernAntworten).toHaveBeenCalledWith(
1472 1,
1473 expect.objectContaining({ richtig: true, bewertung: 'nochmal' }),
1474 );
1475 });
1476
1477 it('sagt an, was das Eingeständnis bewirkt', async () => {
1478 const nutzer = await lernsitzungOeffnen();
1479 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1480
1481 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1482 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1483 await nutzer.click(screen.getByRole('button', { name: 'Ich hatte geraten' }));
1484
1485 /* Genau so weit, wie es der Anwendungskern einhält: Ein Zufallstreffer
1486 darf keinen Beleg SETZEN; einen vorhandenen nimmt nur eine falsche
1487 Antwort weg. Bis 0.24.1 versprach die Ansage das Weitergehende und
1488 widersprach damit dem Kern. */
1489 expect(ansagebereich()).toHaveTextContent(
1490 /als neuer Beleg für Ihren Reifegrad zählt sie nicht/u,
1491 );
1492 expect(ansagebereich()).not.toHaveTextContent(/Reifegrad zählt sie nicht mit/u);
1493 /* Zweimal drücken soll nichts Weiteres tun. */
1494 expect(screen.queryByRole('button', { name: 'Ich hatte geraten' })).toBeNull();
1495 });
1496
1497 it('bucht auch dann, wenn die Sitzung vorzeitig verlassen wird', async () => {
1498 /* Wer bewertet und dann zum Start geht, hat die Frage bearbeitet – sie
1499 darf nicht verfallen. */
1500 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1501 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1502 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1503
1504 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1505 await nutzer.click(screen.getByRole('button', { name: 'Gewusst' }));
1506 await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
1507
1508 expect(lernAntworten).toHaveBeenCalledWith(
1509 1,
1510 expect.objectContaining({ richtig: true, bewertung: 'gut' }),
1511 );
1512 });
1513 });
1514
1515 describe('Lernsitzung – Bildoptionen', () => {
1516 const nurBild = { lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_BILD)])) };
1517
1518 it('macht eine reine Bildoption auswählbar und beschriftet sie über den Alternativtext', async () => {
1519 const nutzer = await lernsitzungOeffnen(nurBild);
1520 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1521
1522 const option = await screen.findByRole('radio', {
1523 name: /Rundes Zeichen mit der Buchstabenfolge PTB/u,
1524 });
1525 await nutzer.click(option);
1526 expect(option).toBeChecked();
1527
1528 expect(
1529 screen.getByRole('img', { name: 'Rundes Zeichen mit der Buchstabenfolge PTB' }),
1530 ).toBeInTheDocument();
1531 });
1532
1533 /*
1534 Zweite Stufe des Bildbeschreibungskonzepts (Prüfplan S4): Der Alt-Text
1535 bleibt neutral, weil das Zeichen hier selbst die Antwortoption ist – die
1536 Bedeutung stünde sonst schon in der Beschriftung und verriete die Lösung.
1537 Nachgereicht wird sie erst mit der Lösung.
1538 */
1539 it('verrät die Bedeutung des Zeichens vor dem Bestätigen nicht', async () => {
1540 await lernsitzungOeffnen(nurBild);
1541 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1542
1543 expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();
1544 expect(screen.queryByText(/Physikalisch-Technischen Bundesanstalt/u)).not.toBeInTheDocument();
1545 });
1546
1547 it('reicht die Bedeutung des Zeichens nach dem Bestätigen aufklappbar nach', async () => {
1548 const nutzer = await lernsitzungOeffnen(nurBild);
1549 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1550
1551 await nutzer.click(
1552 screen.getByRole('radio', { name: /Rundes Zeichen mit der Buchstabenfolge PTB/u }),
1553 );
1554 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1555
1556 const schalter = screen.getByText('Was dieses Zeichen bedeutet');
1557 expect(schalter.closest('details')).not.toHaveAttribute('open');
1558
1559 await nutzer.click(schalter);
1560 expect(
1561 screen.getByText(/Zulassungszeichen der Physikalisch-Technischen Bundesanstalt/u),
1562 ).toBeVisible();
1563 });
1564
1565 it('lässt den Aufklapper bei Zeichen ohne hinterlegte Bedeutung weg', async () => {
1566 /* Option b) trägt das F im Fünfeck ohne Beschreibung – dort darf nach
1567 dem Bestätigen kein leerer Aufklappbereich stehen. Übrig bleibt genau
1568 der eine an Option a). */
1569 const nutzer = await lernsitzungOeffnen(nurBild);
1570 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1571
1572 await nutzer.click(
1573 screen.getByRole('radio', { name: /Rundes Zeichen mit der Buchstabenfolge PTB/u }),
1574 );
1575 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1576
1577 expect(screen.getAllByText('Was dieses Zeichen bedeutet')).toHaveLength(1);
1578 });
1579
1580 it('reicht die Bedeutung auch bei Zeichen am Fragetext nach', async () => {
1581 /* Der zweite Ort für Bilder: nicht in einer Option, sondern an der Frage
1582 selbst. Auch dort gilt: vorher nichts, nachher aufklappbar. */
1583 const nutzer = await lernsitzungOeffnen({
1584 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_BILD_KOPF)])),
1585 });
1586 await screen.findByRole('heading', { name: /Frage 3\.06/u });
1587
1588 expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();
1589
1590 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1591
1592 const schalter = screen.getByText('Was dieses Zeichen bedeutet');
1593 await nutzer.click(schalter);
1594 expect(
1595 screen.getByText(/Zulassungszeichen der Physikalisch-Technischen Bundesanstalt/u),
1596 ).toBeVisible();
1597 });
1598 });
1599
1600 describe('Lernsitzung – kein Autosprung und keine Zeitvorgabe', () => {
1601 it('bleibt nach dem Bestätigen bei derselben Frage (WCAG 3.2.2)', async () => {
1602 const nutzer = await lernsitzungOeffnen({
1603 lernSitzung: vi.fn(() =>
1604 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1605 ),
1606 });
1607 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1608
1609 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1610 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1611
1612 expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1613 expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument();
1614
1615 const weiter = screen.getByRole('button', { name: 'Nächste Frage' });
1616 await nutzer.click(weiter);
1617
1618 expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
1619 expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument();
1620 });
1621 });
1622
1623 describe('Lernsitzung – Merkliste', () => {
1624 it('schaltet das Merken um und meldet es an den Lernstand', async () => {
1625 const lernMerken = vi.fn((_profilId: number, frageId: string, gemerkt: boolean) =>
1626 Promise.resolve({ ...STAND, frageId, gemerkt }),
1627 );
1628 const nutzer = await lernsitzungOeffnen({ lernMerken });
1629 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1630
1631 const knopf = screen.getByRole('button', { name: 'Frage merken' });
1632 expect(knopf).toHaveAttribute('aria-pressed', 'false');
1633
1634 await nutzer.click(knopf);
1635 expect(knopf).toHaveAttribute('aria-pressed', 'true');
1636 expect(lernMerken).toHaveBeenLastCalledWith(1, 'I.1-87', true);
1637
1638 await nutzer.click(knopf);
1639 expect(knopf).toHaveAttribute('aria-pressed', 'false');
1640 expect(lernMerken).toHaveBeenLastCalledWith(1, 'I.1-87', false);
1641 });
1642 });
1643
1644 describe('Lernsitzung – Tastaturbedienung', () => {
1645 it('wählt Optionen mit den Ziffern und bestätigt mit der Eingabetaste', async () => {
1646 const nutzer = await lernsitzungOeffnen();
1647 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1648
1649 await nutzer.keyboard('3');
1650 expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).toBeChecked();
1651 expect(ansagebereich()).toHaveTextContent('Antwort c gewählt.');
1652
1653 await nutzer.keyboard('{Enter}');
1654 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
1655 });
1656
1657 it('merkt die Frage mit der Taste M', async () => {
1658 const nutzer = await lernsitzungOeffnen();
1659 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1660
1661 await nutzer.keyboard('m');
1662
1663 expect(screen.getByRole('button', { name: 'Frage merken' })).toHaveAttribute(
1664 'aria-pressed',
1665 'true',
1666 );
1667 });
1668
1669 it('schaltet mit der Eingabetaste zur nächsten Frage weiter', async () => {
1670 const nutzer = await lernsitzungOeffnen({
1671 lernSitzung: vi.fn(() =>
1672 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1673 ),
1674 });
1675 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1676
1677 await nutzer.keyboard('1');
1678 await nutzer.keyboard('{Enter}');
1679 expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1680
1681 await nutzer.keyboard('{Enter}');
1682 expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
1683 });
1684
1685 it('bewegt sich in der Radiogruppe mit den Pfeiltasten', async () => {
1686 const nutzer = await lernsitzungOeffnen();
1687 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1688
1689 const erste = screen.getByRole('radio', { name: /Sportgewehr/u });
1690 erste.focus();
1691 await nutzer.keyboard('{ArrowDown}');
1692
1693 expect(screen.getByRole('radio', { name: /Schreckschusspistole/u })).toBeChecked();
1694 });
1695
1696 it('lässt Ziffern unbeachtet, wenn die Kürzel abgeschaltet sind (WCAG 2.1.4)', async () => {
1697 window.localStorage.setItem('waffensachkunde.tastenkuerzel', 'aus');
1698 const nutzer = await lernsitzungOeffnen();
1699 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1700
1701 await nutzer.keyboard('3');
1702
1703 expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).not.toBeChecked();
1704 });
1705
1706 it('schreibt Ziffern in das Freitextfeld, statt Optionen zu wählen', async () => {
1707 const nutzer = await lernsitzungOeffnen({
1708 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
1709 });
1710 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1711
1712 const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' });
1713 await nutzer.click(feld);
1714 await nutzer.keyboard('3 Gründe');
1715
1716 expect(feld).toHaveValue('3 Gründe');
1717 });
1718 });
1719
1720 describe('Lernsitzung – Fokusführung', () => {
1721 it('setzt den Fokus nach dem Bestätigen auf den Ergebnisbereich', async () => {
1722 const nutzer = await lernsitzungOeffnen();
1723 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1724
1725 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1726 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1727
1728 expect(screen.getByRole('group', { name: 'Richtig' })).toHaveFocus();
1729 });
1730
1731 it('setzt den Fokus bei der nächsten Frage auf deren Überschrift', async () => {
1732 const nutzer = await lernsitzungOeffnen({
1733 lernSitzung: vi.fn(() =>
1734 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1735 ),
1736 });
1737 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1738
1739 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1740 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1741 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1742
1743 expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toHaveFocus();
1744 });
1745 });
1746
1747 describe('Sitzungsende', () => {
1748 it('fasst die Sitzung zusammen und bietet die Fehler zur Wiederholung an', async () => {
1749 const nutzer = await lernsitzungOeffnen({
1750 lernSitzung: vi.fn(() =>
1751 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1752 ),
1753 });
1754 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1755
1756 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1757 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1758 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1759
1760 await nutzer.click(screen.getByRole('checkbox', { name: /jährlich vorführen/u }));
1761 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1762
1763 /* Die falsche Antwort reiht die Frage wieder ein; die Wiederholung wird
1764 richtig beantwortet und zählt als eigener Versuch – zwei von drei
1765 Antworten richtig, aber nur eine falsch beantwortete Frage. */
1766 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1767 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
1768 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
1769 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1770 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1771
1772 expect(
1773 screen.getByRole('heading', { name: 'Auswertung: Weiterlernen', level: 1 }),
1774 ).toBeInTheDocument();
1775 expect(
1776 screen.getByText('2 von 3 beantworteten Fragen richtig – Trefferquote 67 Prozent.'),
1777 ).toBeInTheDocument();
1778
1779 const fehlerliste = screen.getByRole('list', { name: 'Falsch beantwortete Fragen' });
1780 expect(within(fehlerliste).getAllByRole('listitem')).toHaveLength(1);
1781 expect(within(fehlerliste).getByText(/Frage 2\.10/u)).toBeInTheDocument();
1782
1783 await nutzer.click(screen.getByRole('button', { name: 'Fehler wiederholen (1)' }));
1784 expect(await screen.findByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
1785 });
1786
1787 it('bietet die verwandten Fragen zu den Fehlern zum Üben an', async () => {
1788 /* Hier und nicht in der laufenden Sitzung: Am Ende läuft nichts mehr, und
1789 es ist genau der Moment, in dem entschieden wird, was als Nächstes
1790 drankommt. Angeboten wird, was zu den **Fehlern** gehört – wer eine
1791 Frage sicher konnte, braucht ihre Nachbarn nicht. */
1792 const nutzer = await lernsitzungOeffnen({
1793 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH)])),
1794 });
1795 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1796
1797 // Falsch antworten, damit I.1-87 in der Fehlerliste landet.
1798 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
1799 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1800 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1801 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1802 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1803 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1804
1805 const taste = await screen.findByRole('button', { name: 'Verwandte Fragen üben (1)' });
1806 await nutzer.click(taste);
1807
1808 expect(await screen.findByRole('heading', { name: /Frage 1\.01/u })).toBeInTheDocument();
1809 });
1810
1811 /*
1812 Der häufigste Weg nach einer Sitzung ist die nächste.
1813
1814 Das Tagespensum umfasst oft mehrere Sitzungen. Bis 0.22.0 führte dieser
1815 Weg über den Startbildschirm zurück – rund 8000 Bildpunkte hoch – und
1816 dort erneut über „Weiterlernen“.
1817 */
1818 it('bietet nach „Fehler wiederholen“ kein „Weiterlernen“ an', async () => {
1819 /*
1820 Befund der Prüfrunde zu 0.27.2. Der Kommentar in `App.tsx` sagt: „Nur
1821 nach einer gewöhnlichen Sitzung: Wer aus der Suche kam **oder gerade
1822 Fehler wiederholt hat**, will nicht ‚weiter‘, sondern zurück zu seinem
1823 Ausgangspunkt.“ Gebaut war nur der Suchfall — die Bedingung fragte
1824 allein `ausSuche`.
1825 */
1826 const nutzer = await lernsitzungOeffnen();
1827 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1828
1829 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
1830 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1831 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1832 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1833 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1834 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1835
1836 /* Die Gegenprobe steckt schon hier: In dieser ersten Auswertung steht
1837 „Weiterlernen“ zu Recht. */
1838 expect(await screen.findByRole('button', { name: 'Weiterlernen' })).toBeVisible();
1839 await nutzer.click(screen.getByRole('button', { name: /Fehler wiederholen/u }));
1840
1841 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1842 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1843 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1844 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1845
1846 await screen.findByRole('heading', { name: /Auswertung/u });
1847 expect(screen.queryByRole('button', { name: 'Weiterlernen' })).toBeNull();
1848 });
1849
1850 it('bietet die nächste Sitzung an, ohne den Umweg über den Start', async () => {
1851 const nutzer = await lernsitzungOeffnen();
1852 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1853
1854 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1855 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1856 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1857
1858 await nutzer.click(await screen.findByRole('button', { name: 'Weiterlernen' }));
1859
1860 expect(await screen.findByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1861 });
1862 });
1863
1864 describe('Lernsitzung – falsch beantwortete Fragen kommen wieder', () => {
1865 /** Wählt die falsche Antwort der Einfachauswahl-Frage und bestätigt. */
1866 async function falschAntworten(nutzer: Nutzer): Promise<void> {
1867 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
1868 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1869 }
1870
1871 it('reiht eine falsch beantwortete Frage ans Ende der Sitzung', async () => {
1872 const nutzer = await lernsitzungOeffnen({
1873 lernSitzung: vi.fn(() =>
1874 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1875 ),
1876 });
1877 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1878
1879 await falschAntworten(nutzer);
1880
1881 // Die Anzeige bleibt ehrlich: Y wächst mit der Wiedereinreihung.
1882 expect(screen.getByText('Frage 1 von 3')).toBeInTheDocument();
1883 expect(ansagebereich()).toHaveTextContent('Die Frage kommt in dieser Sitzung noch einmal.');
1884
1885 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1886 await screen.findByRole('heading', { name: /Frage 2\.10/u });
1887 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
1888 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
1889 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1890 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1891
1892 // Am Ende steht die falsch beantwortete Frage noch einmal.
1893 expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1894 expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument();
1895 });
1896
1897 it('reiht dieselbe Frage höchstens zweimal wieder ein', async () => {
1898 const nutzer = await lernsitzungOeffnen(); // eine einzige Frage
1899 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1900
1901 await falschAntworten(nutzer);
1902 expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument();
1903 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1904
1905 await falschAntworten(nutzer);
1906 expect(screen.getByText('Frage 2 von 3')).toBeInTheDocument();
1907 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1908
1909 await falschAntworten(nutzer);
1910
1911 /* Keine dritte Wiedereinreihung: Die Sitzung endet, danach übernimmt die
1912 Wiedervorlage – „nochmal“ macht die Frage sofort wieder fällig, die
1913 nächste Sitzung legt sie vorn vor. */
1914 expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument();
1915 expect(ansagebereich()).not.toHaveTextContent('kommt in dieser Sitzung noch einmal');
1916 expect(screen.getByRole('button', { name: 'Sitzung auswerten' })).toBeInTheDocument();
1917 });
1918
1919 it('lässt eine richtig beantwortete Wiederholung nicht erneut erscheinen', async () => {
1920 const nutzer = await lernsitzungOeffnen();
1921 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1922
1923 await falschAntworten(nutzer);
1924 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1925
1926 expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument();
1927 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1928 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1929
1930 // Richtig beantwortet: nichts wächst nach, die Sitzung ist zu Ende.
1931 expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument();
1932 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1933
1934 expect(
1935 screen.getByText('1 von 2 beantworteten Fragen richtig – Trefferquote 50 Prozent.'),
1936 ).toBeInTheDocument();
1937 // Je Frage ein Eintrag, auch wenn sie zweimal vorgelegt wurde.
1938 const fehlerliste = screen.getByRole('list', { name: 'Falsch beantwortete Fragen' });
1939 expect(within(fehlerliste).getAllByRole('listitem')).toHaveLength(1);
1940 });
1941
1942 it('bucht die Wiederholungsantwort über denselben Antwortweg', async () => {
1943 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1944 const nutzer = await lernsitzungOeffnen({ lernAntworten });
1945 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1946
1947 await falschAntworten(nutzer);
1948 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1949 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1950 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1951 /* Beide Buchungen fallen beim Weiterblättern – seit 0.22.0 wird bis
1952 dahin zurückgehalten, damit sich „Ich hatte geraten“ nachreichen
1953 lässt. */
1954 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1955
1956 // Beide Vorlagen sind reguläre Antworten – FSRS-6 verrechnet die
1957 // Same-Day-Wiederholung über seine Kurzzeitparameter im Anwendungskern.
1958 expect(lernAntworten).toHaveBeenCalledTimes(2);
1959 expect(lernAntworten).toHaveBeenNthCalledWith(
1960 1,
1961 1,
1962 expect.objectContaining({ frageId: 'I.1-87', richtig: false, bewertung: 'nochmal' }),
1963 );
1964 expect(lernAntworten).toHaveBeenNthCalledWith(
1965 2,
1966 1,
1967 expect.objectContaining({ frageId: 'I.1-87', richtig: true, bewertung: 'gut' }),
1968 );
1969 });
1970
1971 /*
1972 Die Wiedereinreihung schafft für offene Fragen einen Fall, den es vorher
1973 nicht gab: dieselbe Frage zweimal in einer Sitzung. „Was zuletzt
1974 geschrieben wurde“ ist dann die Antwort von eben – der Eintrag aus dem
1975 Lernstand wäre die falsche Auskunft und der schwächere Vergleich.
1976 */
1977 it('zeigt bei der Wiederholung die Antwort von eben statt der aus dem Lernstand', async () => {
1978 const nutzer = await lernsitzungOeffnen({
1979 lernSitzung: vi.fn(() =>
1980 Promise.resolve([
1981 {
1982 frageId: 'I.1-01',
1983 optionsReihenfolge: [],
1984 gemerkt: false,
1985 letzterFreitext: {
1986 text: 'Den Umgang mit Waffen, glaube ich.',
1987 zeitpunkt: '2026-08-09T09:00:00.000Z',
1988 },
1989 },
1990 ]),
1991 ),
1992 });
1993 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1994
1995 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Erster Anlauf.');
1996 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1997 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
1998 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1999
2000 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Zweiter Anlauf.');
2001 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
2002
2003 expect(screen.getByText(/Erster Anlauf\./u)).toBeVisible();
2004 expect(screen.queryByText(/Den Umgang mit Waffen, glaube ich\./u)).not.toBeInTheDocument();
2005 });
2006
2007 it('behält den Eintrag aus dem Lernstand, wenn nichts geschrieben wurde', async () => {
2008 /* Leer lassen ist erlaubt und ist kein Schreiben: Zuletzt geschrieben
2009 wurde dann weiterhin das Frühere. */
2010 const nutzer = await lernsitzungOeffnen({
2011 lernSitzung: vi.fn(() =>
2012 Promise.resolve([
2013 {
2014 frageId: 'I.1-01',
2015 optionsReihenfolge: [],
2016 gemerkt: false,
2017 letzterFreitext: {
2018 text: 'Den Umgang mit Waffen, glaube ich.',
2019 zeitpunkt: '2026-08-09T09:00:00.000Z',
2020 },
2021 },
2022 ]),
2023 ),
2024 });
2025 await screen.findByRole('heading', { name: /Frage 1\.01/u });
2026
2027 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
2028 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
2029 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
2030 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
2031
2032 expect(screen.getByText(/Am 09\.08\.2026 schrieben Sie:/u)).toBeVisible();
2033 });
2034 });
2035
2036 describe('Lernsitzung – Ausfall des Anwendungskerns', () => {
2037 it('übt ersatzweise aus dem Katalog, wenn der Sitzungskanal scheitert', async () => {
2038 await lernsitzungOeffnen({
2039 lernSitzung: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))),
2040 });
2041
2042 expect(
2043 await screen.findByText(/Ihre Lernsitzung konnte nicht geladen werden/u),
2044 ).toBeInTheDocument();
2045 expect(screen.getByText(/Ihre Antworten werden nicht gespeichert/u)).toBeInTheDocument();
2046 expect(screen.getAllByRole('heading', { level: 1 }).length).toBeGreaterThan(0);
2047 });
2048
2049 it('erklärt, dass „Nur Fehler“ ohne Lernstand nicht möglich ist', async () => {
2050 vi.stubGlobal(
2051 'lernApp',
2052 attrappe({ lernSitzung: vi.fn(() => Promise.reject(new Error('kein Zugriff'))) }),
2053 );
2054 const nutzer = userEvent.setup();
2055 render(<App />);
2056
2057 const knopf = await screen.findByRole('button', { name: /Nur Fehler/u });
2058 await waitFor(() => {
2059 expect(knopf).toHaveAttribute('aria-disabled', 'false');
2060 });
2061 await nutzer.click(knopf);
2062
2063 expect(await screen.findByText(/braucht Ihren gespeicherten Lernstand/u)).toBeInTheDocument();
2064 expect(screen.getByRole('button', { name: 'Zum Start' })).toBeInTheDocument();
2065 });
2066 });
2067
2068 describe('Antwortbuchstabe gehört zum Inhalt, nicht zur Stelle', () => {
2069 /*
2070 Diese beiden Tests halten eine Entscheidung fest, nicht bloß das heutige
2071 Verhalten – deshalb stehen sie hier und nicht bei den Einstellungen.
2072
2073 Der Buchstabe vor jeder Option ist der amtliche aus dem Katalog. Er ist
2074 zugleich der Verweis auf die gedruckte Vorlage und auf die Erklärungen,
2075 von denen 368 der 575 ihn beim Namen nennen („Zu a):“). Würde er stattdessen
2076 aus der Anzeigestelle abgeleitet, träfe er in fast zwei Dritteln
2077 aller Fragen eine andere Aussage als die Erklärung darunter.
2078
2079 Der übrige Testbestand kann das nicht bemerken: Seine Attrappe reicht
2080 ausnahmslos die ungemischte Katalogreihenfolge herein, in der Buchstabe
2081 und Stelle ohnehin übereinstimmen. Hier wird bewusst verdreht.
2082
2083 Siehe docs/entscheidung-antwortreihenfolge.md.
2084 */
2085
2086 it('zeigt bei verdrehter Reihenfolge den amtlichen Buchstaben am zugehörigen Inhalt', async () => {
2087 const verdreht: SitzungsFrage = {
2088 frageId: FRAGE_EINFACH.id,
2089 optionsReihenfolge: ['c', 'a', 'b'],
2090 gemerkt: false,
2091 };
2092 await mitBrueckeOeffnen(attrappe({ lernSitzung: vi.fn(() => Promise.resolve([verdreht])) }));
2093 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2094
2095 const optionen = screen.getAllByRole('radio').map((feld) => feld.closest('.antwortoption'));
2096 const marke = (stelle: number): string =>
2097 optionen[stelle]?.querySelector('.antwortoption__marke')?.textContent ?? '';
2098 const inhalt = (stelle: number): string => optionen[stelle]?.textContent ?? '';
2099
2100 expect(optionen).toHaveLength(3);
2101 // Erste Stelle trägt c) – der Buchstabe ist mit seinem Inhalt gewandert.
2102 expect(marke(0)).toBe('c)');
2103 expect(inhalt(0)).toContain('Spielzeugpistole');
2104 expect(marke(1)).toBe('a)');
2105 expect(inhalt(1)).toContain('Sportgewehr');
2106 expect(marke(2)).toBe('b)');
2107 expect(inhalt(2)).toContain('Schreckschusspistole');
2108
2109 /* Und genau hier entsteht der Befund, der zu dieser Umstellung geführt
2110 hat: Die Ziffer ist das Tastenkürzel und zählt die Stelle, der
2111 Buchstabe gehört zum Inhalt. Gemischt laufen beide auseinander –
2112 auf dem Schirm steht dann „1 c)“. Das ist kein Fehler, sondern der
2113 Preis des Mischens, und deshalb steht der Schalter ab Werk auf aus. */
2114 expect(inhalt(0)).toMatch(/^1c\)/u);
2115 });
2116
2117 it('nennt im Lösungssatz denselben Buchstaben, den die richtige Antwort auf dem Schirm trägt', async () => {
2118 const verdreht: SitzungsFrage = {
2119 frageId: FRAGE_EINFACH.id,
2120 optionsReihenfolge: ['c', 'a', 'b'],
2121 gemerkt: false,
2122 };
2123 const nutzer = await mitBrueckeOeffnen(
2124 attrappe({ lernSitzung: vi.fn(() => Promise.resolve([verdreht])) }),
2125 );
2126 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2127
2128 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
2129 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
2130
2131 /* Richtig ist die Option mit dem amtlichen Buchstaben c – auch wenn sie
2132 hier an erster Stelle steht. Stünde hier „Antwort a“, wäre der
2133 Buchstabe aus der Stelle abgeleitet worden. */
2134 /* Der Satz steht zweimal da: sichtbar im Ergebnisbereich und in der
2135 Live-Region für Bildschirmleser. Beide müssen denselben Buchstaben
2136 nennen. */
2137 const saetze = await screen.findAllByText(/Richtig ist Antwort c\./u);
2138 expect(saetze.length).toBeGreaterThan(0);
2139 expect(screen.queryByText(/Richtig ist Antwort a\./u)).toBeNull();
2140 });
2141 });
2142
2143 /** Öffnet eine Sitzung mit einer Einfachauswahl-Frage und verborgener Antwortzahl. */
2144 async function mitVerborgenerAntwortzahl(): Promise<Nutzer> {
2145 const nutzer = await mitBrueckeOeffnen(
2146 attrappe({
2147 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH)])),
2148 einstellungenLesen: vi.fn(() =>
2149 Promise.resolve({
2150 ...EINSTELLUNGEN_STANDARD,
2151 zuschnittGefragt: true,
2152 antwortzahlVerbergen: true,
2153 }),
2154 ),
2155 }),
2156 );
2157 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2158 return nutzer;
2159 }
2160
2161 describe('Einstellung „Anzahl der richtigen Antworten verbergen“', () => {
2162 it('nennt ohne sie die Zahl der richtigen Antworten', async () => {
2163 await mitBrueckeOeffnen(
2164 attrappe({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])) }),
2165 );
2166 await screen.findByRole('heading', { name: /Frage 2\.10/u });
2167
2168 expect(screen.getByText(/mehrere Antworten sind richtig/u)).toBeInTheDocument();
2169 expect(screen.getAllByRole('checkbox')).toHaveLength(3);
2170 });
2171
2172 it('verschweigt sie eingeschaltet – wie die Prüfung', async () => {
2173 await mitVerborgenerAntwortzahl();
2174
2175 /* Einfachauswahl, aber Kästchen statt Radios: Sonst verriete schon die
2176 Bauart des Bedienelements, dass genau eine Antwort richtig ist. */
2177 expect(
2178 await screen.findByText(/ist nicht angegeben, wie viele Antworten richtig sind/u),
2179 ).toBeInTheDocument();
2180 expect(screen.getAllByRole('checkbox')).toHaveLength(3);
2181 expect(screen.queryAllByRole('radio')).toHaveLength(0);
2182 });
2183
2184 it('lässt bei Einfachauswahl zwei Kästchen zugleich stehen', async () => {
2185 /*
2186 Der eigentliche Prüfstein, und der Grund, warum ein Blick auf die Bauart
2187 der Bedienelemente nicht genügt: Bliebe die Auswahllogik an der Zahl der
2188 richtigen Antworten hängen, ginge beim Anhaken des zweiten Kästchens das
2189 erste stumm aus – und verriete damit genau das, was die Einstellung
2190 verschweigen soll. Eine zu große Auswahl muss möglich und schlicht
2191 falsch sein, wie in der Prüfung (vgl. usePruefung.optionWaehlen).
2192 */
2193 const nutzer = await mitVerborgenerAntwortzahl();
2194 const kaestchen = screen.getAllByRole('checkbox');
2195
2196 await nutzer.click(kaestchen[0]!);
2197 await nutzer.click(kaestchen[1]!);
2198
2199 expect(kaestchen[0]).toBeChecked();
2200 expect(kaestchen[1]).toBeChecked();
2201
2202 // Und abwählbar muss jedes einzelne bleiben.
2203 await nutzer.click(kaestchen[0]!);
2204 expect(kaestchen[0]).not.toBeChecked();
2205 expect(kaestchen[1]).toBeChecked();
2206 });
2207
2208 it('sagt das Abwählen an, statt nur das Wählen', async () => {
2209 /* Mit erzwungener Einfachauswahl war der Zweig „abgewählt“ in diesem
2210 Zustand unerreichbar – für Bildschirmleser ein stummer Zustandswechsel. */
2211 const nutzer = await mitVerborgenerAntwortzahl();
2212
2213 await nutzer.keyboard('1');
2214 expect(ansagebereich()).toHaveTextContent('Antwort a gewählt.');
2215
2216 await nutzer.keyboard('1');
2217 expect(ansagebereich()).toHaveTextContent('Antwort a abgewählt.');
2218 });
2219
2220 it('bleibt im verborgenen Zustand frei von axe-Verstößen', async () => {
2221 const nutzer = await mitVerborgenerAntwortzahl();
2222 await nutzer.click(screen.getAllByRole('checkbox')[0]!);
2223
2224 const ergebnis = await axe.run(document.body, jsdomOptionen());
2225
2226 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
2227 });
2228 });
2229
2230 describe('Lernsitzung – Barrierefreiheits-Rauchtest (axe-core)', () => {
2231 it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => {
2232 const nutzer = await lernsitzungOeffnen();
2233 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2234
2235 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
2236 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
2237
2238 const ergebnis = await axe.run(document.body, jsdomOptionen());
2239
2240 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
2241 });
2242 });
2243
2244 describe('Vorlesen und Fragenwechsel', () => {
2245 /*
2246 Gemeldet von einem Nutzer: „Wenn eine Frage oder Antwort vorgelesen wird
2247 und es wird die nächste Frage ausgewählt, dann wird immer noch die alte
2248 Frage oder Antwort vorgelesen.“
2249
2250 Die Stimme gehört zu dem, was auf dem Bildschirm steht. Bleibt sie beim
2251 Wechsel stehen, hört man etwas anderes, als man sieht – für jemanden, der
2252 sich die Fragen vorlesen lässt, ist das nicht eine Unschönheit, sondern
2253 der Verlust der Orientierung.
2254 */
2255 interface Stimmwerk {
2256 readonly gesprochen: string[];
2257 readonly abbrueche: () => number;
2258 }
2259
2260 function sprachausgabeStellen(): Stimmwerk {
2261 const gesprochen: string[] = [];
2262 let abbrueche = 0;
2263
2264 class AeusserungAttrappe {
2265 lang = '';
2266 voice: unknown = null;
2267 onend: (() => void) | null = null;
2268 onerror: ((ereignis: { error: string }) => void) | null = null;
2269 constructor(public readonly text: string) {}
2270 }
2271
2272 vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
2273 vi.stubGlobal('speechSynthesis', {
2274 speak: (aeusserung: AeusserungAttrappe) => gesprochen.push(aeusserung.text),
2275 cancel: () => {
2276 abbrueche += 1;
2277 },
2278 getVoices: () => [],
2279 speaking: false,
2280 pending: false,
2281 paused: false,
2282 });
2283
2284 return { gesprochen, abbrueche: () => abbrueche };
2285 }
2286
2287 /** Sitzung mit eingeschalteter Sprachausgabe. */
2288 async function mitStimmeOeffnen(): Promise<{ nutzer: Nutzer; werk: Stimmwerk }> {
2289 const werk = sprachausgabeStellen();
2290 /* Zwei Fragen, damit es ein „danach" gibt – bei einer einzigen heißt der
2291 Knopf „Sitzung beenden", und die Ansicht wird verlassen statt
2292 gewechselt. Der Fehler steckt aber im Wechsel. */
2293 const nutzer = await lernsitzungOeffnen({
2294 lernSitzung: vi.fn(() =>
2295 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
2296 ),
2297 einstellungenLesen: vi.fn(() =>
2298 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
2299 ),
2300 });
2301 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2302 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
2303 return { nutzer, werk };
2304 }
2305
2306 it('bricht die laufende Frage ab, sobald die nächste Frage kommt', async () => {
2307 const { nutzer, werk } = await mitStimmeOeffnen();
2308
2309 await nutzer.click(await screen.findByRole('button', { name: /Frage vorlesen/u }));
2310 expect(werk.gesprochen.length).toBeGreaterThan(0);
2311
2312 /* Nach dem Bestätigen liest die Anwendung die Rückmeldung von selbst vor
2313 und bricht dafür ohnehin ab. Gemessen wird deshalb erst ab hier: Der
2314 Wechsel zur nächsten Frage spricht nichts Neues – wenn hier nicht
2315 abgebrochen wird, redet die alte Rückmeldung weiter. */
2316 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
2317 await screen.findByRole('heading', { name: 'Warum das so ist' });
2318 const vorDemWechsel = werk.abbrueche();
2319
2320 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
2321 await screen.findByRole('heading', { name: /Frage 2\.10/u });
2322
2323 expect(werk.abbrueche()).toBeGreaterThan(vorDemWechsel);
2324 });
2325
2326 it('lässt den Halt-Knopf nach dem Wechsel verschwinden', async () => {
2327 /* Die sichtbare Seite desselben Fehlers: Der Knopf „Vorlesen beenden“
2328 steht nur, solange etwas läuft. Blieb der Zustand hängen, bot die
2329 Oberfläche an, etwas zu beenden, das zur alten Frage gehörte. */
2330 const { nutzer } = await mitStimmeOeffnen();
2331
2332 await nutzer.click(await screen.findByRole('button', { name: /Frage vorlesen/u }));
2333 expect(screen.getByRole('button', { name: /Vorlesen beenden/u })).toBeInTheDocument();
2334
2335 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
2336 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
2337
2338 expect(screen.queryByRole('button', { name: /Vorlesen beenden/u })).toBeNull();
2339 expect(screen.getByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument();
2340 });
2341 });
2342
2343 /*
2344 Anhalten und Fortsetzen.
2345
2346 Bis 0.22.0 kannte die Vorlesetaste nur Start und Beenden. Wer bei einer über
2347 eine Minute langen Erklärung kurz unterbrochen wurde, konnte nur abbrechen
2348 und musste von vorn beginnen.
2349
2350 Gebaut ist es **nicht** über `speechSynthesis.pause()` / `resume()`: Das ist
2351 in Chromium unter Windows unzuverlässig – die Ausgabe bleibt gelegentlich
2352 stumm stehen. Stattdessen wird abgebrochen und die Stelle in der
2353 Häppchen-Warteschlange gemerkt; „Weiterlesen“ reiht ab dort neu ein.
2354 */
2355 describe('Vorlesen anhalten und fortsetzen', () => {
2356 interface Stimmwerk {
2357 readonly gesprochen: string[];
2358 readonly starte: (text: string) => void;
2359 readonly tempi: number[];
2360 }
2361
2362 function sprachausgabeStellen(): Stimmwerk {
2363 const gesprochen: string[] = [];
2364 const tempi: number[] = [];
2365 const offene: { text: string; onstart: (() => void) | null }[] = [];
2366
2367 class AeusserungAttrappe {
2368 lang = '';
2369 rate = 1;
2370 voice: unknown = null;
2371 onstart: (() => void) | null = null;
2372 onend: (() => void) | null = null;
2373 onerror: ((ereignis: { error: string }) => void) | null = null;
2374 constructor(public readonly text: string) {}
2375 }
2376
2377 vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
2378 vi.stubGlobal('speechSynthesis', {
2379 speak: (aeusserung: AeusserungAttrappe) => {
2380 gesprochen.push(aeusserung.text);
2381 tempi.push(aeusserung.rate);
2382 offene.push({ text: aeusserung.text, onstart: aeusserung.onstart });
2383 },
2384 cancel: () => {
2385 offene.length = 0;
2386 },
2387 getVoices: () => [],
2388 speaking: false,
2389 pending: false,
2390 paused: false,
2391 });
2392
2393 /* Bildet nach, dass die Sprachausgabe ein bestimmtes Stück beginnt –
2394 daran hängt, wo „Weiterlesen“ ansetzt. */
2395 const starte = (text: string): void => {
2396 offene.find((eintrag) => eintrag.text === text)?.onstart?.();
2397 };
2398
2399 return { gesprochen, starte, tempi };
2400 }
2401
2402 async function mitStimmeOeffnen(
2403 einstellungen: Record<string, unknown> = {},
2404 ): Promise<{ nutzer: Nutzer; werk: Stimmwerk }> {
2405 const werk = sprachausgabeStellen();
2406 const nutzer = await lernsitzungOeffnen({
2407 lernSitzung: vi.fn(() =>
2408 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
2409 ),
2410 einstellungenLesen: vi.fn(() =>
2411 Promise.resolve({
2412 ...EINSTELLUNGEN_STANDARD,
2413 zuschnittGefragt: true,
2414 vorlesen: true,
2415 ...einstellungen,
2416 }),
2417 ),
2418 });
2419 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2420 return { nutzer, werk };
2421 }
2422
2423 it('bietet das Anhalten erst an, wenn etwas läuft', async () => {
2424 const { nutzer } = await mitStimmeOeffnen();
2425
2426 expect(screen.queryByRole('button', { name: /anhalten/u })).toBeNull();
2427
2428 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2429
2430 expect(screen.getByRole('button', { name: 'Vorlesen anhalten' })).toBeInTheDocument();
2431 });
2432
2433 it('setzt an der angehaltenen Stelle fort, statt von vorn zu beginnen', async () => {
2434 const { nutzer, werk } = await mitStimmeOeffnen();
2435
2436 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2437 const stuecke = [...werk.gesprochen];
2438 expect(stuecke.length).toBeGreaterThan(1);
2439
2440 /* Die Ausgabe ist beim zweiten Stück angekommen. */
2441 const zweites = stuecke[1] ?? '';
2442 werk.starte(zweites);
2443 await nutzer.click(screen.getByRole('button', { name: 'Vorlesen anhalten' }));
2444
2445 werk.gesprochen.length = 0;
2446 await nutzer.click(screen.getByRole('button', { name: 'Weiterlesen' }));
2447
2448 expect(werk.gesprochen[0]).toBe(zweites);
2449 expect(werk.gesprochen).not.toContain(stuecke[0]);
2450 });
2451
2452 it('gibt das eingestellte Sprechtempo an die Ausgabe weiter', async () => {
2453 /* `rate` wurde bis 0.22.0 nie gesetzt – das Tempo war nicht einstellbar. */
2454 const { nutzer, werk } = await mitStimmeOeffnen({ sprechtempo: 1.5 });
2455
2456 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2457
2458 expect(werk.tempi.length).toBeGreaterThan(0);
2459 expect(new Set(werk.tempi)).toEqual(new Set([1.5]));
2460 });
2461
2462 it('weist ein unmögliches Tempo ab, statt es durchzureichen', async () => {
2463 /* `rate` erlaubt 0,1 bis 10. Ein von Hand in die Datei geschriebener Wert
2464 machte die Funktion unbrauchbar – unverständlich langsam oder schnell. */
2465 const { nutzer, werk } = await mitStimmeOeffnen({ sprechtempo: 9 });
2466
2467 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2468
2469 expect(new Set(werk.tempi)).toEqual(new Set([1]));
2470 });
2471 });
2472
2473 describe('Fehler zu dieser Frage melden', () => {
2474 /*
2475 Der kurze Weg aus der Sitzung heraus (D12). Wer mitten im Lernen einen
2476 Fehler im Katalog bemerkt, soll ihn melden koennen, ohne die Ansicht zu
2477 verlassen und die Fragennummer nachzuschlagen.
2478 */
2479 it('steht zugeklappt da und nennt seinen Zweck unmissverstaendlich', async () => {
2480 /* „Fehler melden" allein staende in der Rueckmeldung neben dem Wort
2481 „Falsch" und laese sich als *meinen* Fehler vermerken. */
2482 await lernsitzungOeffnen();
2483
2484 const aufklapper = await screen.findByText('Fehler zu dieser Frage melden');
2485 expect(aufklapper.closest('details')?.open).toBe(false);
2486 });
2487
2488 it('zeigt den Text, bevor er kopiert werden kann', async () => {
2489 /* Der Text verlaesst spaeter das Geraet. Wer ihn weitergibt, muss vorher
2490 lesen koennen, was drinsteht - anders ist die Zusage „nichts ueber Sie"
2491 nicht ueberpruefbar. */
2492 const nutzer = await lernsitzungOeffnen();
2493 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2494
2495 const feld = await screen.findByLabelText('Meldetext');
2496 await waitFor(() => {
2497 expect((feld as HTMLTextAreaElement).value).toContain('Kennung I.1-87');
2498 });
2499 /* Schreibgeschuetzt, aber nicht gesperrt: markieren und Strg+C muessen
2500 gehen, denn das ist der Weg ohne Zwischenablage-Kanal. */
2501 expect(feld).toHaveAttribute('readonly');
2502 expect(feld).not.toBeDisabled();
2503 });
2504
2505 it('kopiert genau das, was im Feld steht', async () => {
2506 const zwischenablageSchreiben = vi.fn((_text: string) => Promise.resolve(true));
2507 const nutzer = await lernsitzungOeffnen({ zwischenablageSchreiben });
2508
2509 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2510 const knopf = await screen.findByRole('button', { name: 'Text kopieren' });
2511 await nutzer.click(knopf);
2512
2513 await waitFor(() => {
2514 expect(zwischenablageSchreiben).toHaveBeenCalledTimes(1);
2515 });
2516 const uebergeben = zwischenablageSchreiben.mock.calls[0]![0];
2517 expect(screen.getByLabelText('Meldetext')).toHaveValue(uebergeben);
2518 });
2519
2520 it('sagt den Erfolg an', async () => {
2521 const nutzer = await lernsitzungOeffnen({
2522 zwischenablageSchreiben: vi.fn(() => Promise.resolve(true)),
2523 });
2524 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2525 await nutzer.click(await screen.findByRole('button', { name: 'Text kopieren' }));
2526
2527 expect(await screen.findByText(/liegt in der Zwischenablage/u)).toBeInTheDocument();
2528 });
2529
2530 it('nennt den Weg ueber die Tastatur, wenn das Kopieren scheitert', async () => {
2531 const nutzer = await lernsitzungOeffnen({
2532 zwischenablageSchreiben: vi.fn(() => Promise.resolve(false)),
2533 });
2534 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2535 await nutzer.click(await screen.findByRole('button', { name: 'Text kopieren' }));
2536
2537 expect(await screen.findByText(/Strg und C/u)).toBeInTheDocument();
2538 });
2539
2540 it('laesst den Knopf fort, wenn der Kanal fehlt - und nennt den Ersatzweg', async () => {
2541 /* Kein gesperrter Knopf: Gesperrte Bedienelemente nehmen keinen Fokus,
2542 ihre Erklaerung wuerde nie vorgelesen. Der Text steht ohnehin da. */
2543 const nutzer = await lernsitzungOeffnen();
2544 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2545 await screen.findByLabelText('Meldetext');
2546
2547 expect(screen.queryByRole('button', { name: 'Text kopieren' })).toBeNull();
2548 expect(screen.getByText(/Markieren Sie den Text im Feld/u)).toBeInTheDocument();
2549 });
2550 });