waffensachkunde

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

/ app tests renderer-lernmodus.test.tsx

98,6 KB Rohdatei
app/tests/renderer-lernmodus.test.tsx — 2368 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('zeigt nichts an, wenn die Frage keinen Fachbegriff enthält', async () => {
726 const suche = /Fachbegriff(e)? in dieser Frage/u;
727 await lernsitzungOeffnen({
728 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])),
729 });
730
731 await screen.findByRole('heading', { name: /Frage 2\.10/u });
732 expect(screen.queryByText(suche)).not.toBeInTheDocument();
733 });
734
735 it('bleibt bedienbar, wenn der Kern das Glossar nicht anbietet', async () => {
736 const nutzer = await ohneKanalOeffnen('glossarLaden');
737 await screen.findByRole('heading', { name: /Frage 1\.87/u });
738
739 expect(screen.queryByText(/Fachbegriff/u)).not.toBeInTheDocument();
740 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
741 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
742 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
743 });
744 });
745
746 describe('Lernsitzung – Erklärung zur Antwort', () => {
747 async function bisZumErgebnis(ueberschreibungen: Partial<LernAppBridge> = {}): Promise<Nutzer> {
748 const nutzer = await lernsitzungOeffnen(ueberschreibungen);
749 await screen.findByRole('heading', { name: /Frage 1\.87/u });
750 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
751 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
752 return nutzer;
753 }
754
755 it('zeigt die Begründung erst nach dem Bestätigen', async () => {
756 const nutzer = await lernsitzungOeffnen();
757 await screen.findByRole('heading', { name: /Frage 1\.87/u });
758
759 // Vorher darf nichts von der Lösung durchscheinen.
760 expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument();
761
762 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
763 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
764
765 expect(await screen.findByRole('heading', { name: 'Warum das so ist' })).toBeInTheDocument();
766 });
767
768 it('nennt Kurzfassung, Begründung und Merksatz', async () => {
769 await bisZumErgebnis();
770 await screen.findByRole('heading', { name: 'Warum das so ist' });
771
772 expect(screen.getByText(/zum Verwechseln aehnlich/u)).toBeInTheDocument();
773 expect(screen.getByText(/Entscheidend ist der aeussere Eindruck/u)).toBeInTheDocument();
774 expect(screen.getByText(/Was aussieht wie eine Waffe/u)).toBeInTheDocument();
775 });
776
777 it('führt die Fundstellen in juristischer Kurzform auf', async () => {
778 await bisZumErgebnis();
779 /* Keine Überschrift mehr, seit die Erklärung aufklappbar ist: Sie trägt
780 ihre eigene Überschrift im `<summary>`, und eine weitere Ebene darunter
781 wäre ein Sprung. Der Text bleibt und benennt weiterhin die Liste. */
782 const titel = await screen.findByText('Im Gesetz nachlesen');
783
784 const liste = screen.getByRole('list', { name: 'Im Gesetz nachlesen' });
785 expect(titel).toBeInTheDocument();
786 expect(within(liste).getAllByRole('listitem')).toHaveLength(2);
787 expect(
788 within(liste).getByText('Anlage 1 Abschnitt 1 Unterabschnitt 1 Nr. 1.6 WaffG'),
789 ).toBeInTheDocument();
790 expect(within(liste).getByText('§ 42a Abs. 1 Nr. 1 WaffG')).toBeInTheDocument();
791 });
792
793 it('schreibt die Fundstellen für Bildschirmleser aus', async () => {
794 /* „§ 12 Abs. 1“ wird sonst buchstabiert oder falsch betont. Sichtbar
795 bleibt die Kurzform, vorgelesen wird die Langform. */
796 await bisZumErgebnis();
797 const liste = await screen.findByRole('list', { name: 'Im Gesetz nachlesen' });
798
799 expect(
800 within(liste).getByText('Paragraf 42a Absatz 1 Nummer 1 Waffengesetz'),
801 ).toBeInTheDocument();
802 expect(within(liste).getByText(/Anlage 1 .* Waffengesetz$/u)).toBeInTheDocument();
803 });
804
805 it('macht die Fundstellen aufklappbar, sobald Normtexte beiliegen', async () => {
806 /* Der Kern von M30: „Im Gesetz nachlesen“ stand als Aufforderung da, und
807 eine ausdrücklich vollständig offline arbeitende Software lieferte
808 keinen einzigen Normtext mit. Hier wird die Verdrahtung geprüft – der
809 Auszug selbst hat eigene Fälle in tests/normstelle.test.tsx. */
810 const nutzer = await bisZumErgebnis({
811 normtexteLaden: vi.fn(() =>
812 Promise.resolve({
813 meta: {
814 version: 1,
815 stand: '2026-08-29',
816 hinweis: 'Auszug',
817 gesetzesstand: { WaffG: 'Pruefstand' },
818 },
819 gesetze: {
820 WaffG: {
821 bezeichnung: 'Waffengesetz',
822 stand: 'Pruefstand',
823 quelle: 'https://www.gesetze-im-internet.de/waffg_2002/',
824 normen: {
825 '§ 42a': {
826 titel: 'Verbot des Führens von Anscheinswaffen',
827 istAnlage: false,
828 absaetze: { '1': 'Es ist verboten, Anscheinswaffen zu fuehren.' },
829 },
830 },
831 },
832 },
833 }),
834 ),
835 });
836
837 const liste = await screen.findByRole('list', { name: 'Im Gesetz nachlesen' });
838 await waitFor(() => {
839 expect(within(liste).getAllByText(/im Wortlaut anzeigen/u).length).toBeGreaterThan(0);
840 });
841
842 await nutzer.click(within(liste).getByText('§ 42a Abs. 1 Nr. 1 WaffG'));
843
844 expect(screen.getByText(/Es ist verboten, Anscheinswaffen zu fuehren/u)).toBeInTheDocument();
845 });
846
847 it('nennt die verwandten Fragen zum selben Gegenstand', async () => {
848 /* Der amtliche Katalog kennt keine Frage-zu-Frage-Beziehung, obwohl die
849 Bündel auf der Hand liegen. Wer eine Frage gerade verstanden hat, ist im
850 besten Moment, die Nachbarn mitzunehmen. */
851 const nutzer = await bisZumErgebnis();
852
853 const schalter = await screen.findByText(/Verwandte Fragen \(1\)/u);
854 await nutzer.click(schalter);
855
856 const liste = screen.getByRole('list', { name: /Diese Fragen behandeln denselben/u });
857 expect(within(liste).getByText(/Frage 1\.01/u)).toBeInTheDocument();
858 expect(
859 screen.getByText(/Die Zuordnung ist eine Ergänzung dieser Software/u),
860 ).toBeInTheDocument();
861 });
862
863 it('bietet mitten in der Sitzung keine Übungstaste an', async () => {
864 /* Sie wäre hier eine Falle: Eine Sitzung zu starten ersetzt die laufende,
865 und zwar ohne Rückfrage. Wer mitten in zwanzig Fragen steckt, verlöre
866 seine Warteschlange für einen Knopf, den er vielleicht nur neugierig
867 gedrückt hat. Die Taste steht am Ende der Sitzung. */
868 const nutzer = await bisZumErgebnis();
869 await nutzer.click(await screen.findByText(/Verwandte Fragen \(1\)/u));
870
871 expect(
872 screen.queryByRole('button', { name: /Verwandte Fragen üben/u }),
873 ).not.toBeInTheDocument();
874 });
875
876 it('weist die Erklärung als eigenen Zusatz aus', async () => {
877 // Der amtliche Katalog darf nicht verändert wiedergegeben werden; was
878 // eigener Zusatz ist, muss erkennbar sein.
879 await bisZumErgebnis();
880 await screen.findByRole('heading', { name: 'Warum das so ist' });
881
882 expect(
883 screen.getByText(/Diese Erklärung ist eine Ergänzung dieser Software/u),
884 ).toBeInTheDocument();
885 });
886
887 it('kommt ohne Erklärung aus, wenn es zu einer Frage keine gibt', async () => {
888 // Zwei Fragen, damit sich weiterblättern lässt: Die zweite hat keine
889 // Erklärung – so wächst der Bestand auch in Wirklichkeit.
890 const nutzer = await lernsitzungOeffnen({
891 lernSitzung: vi.fn(() =>
892 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
893 ),
894 });
895 await screen.findByRole('heading', { name: /Frage 1\.87/u });
896 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
897 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
898 await screen.findByRole('heading', { name: 'Warum das so ist' });
899
900 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
901 await screen.findByRole('heading', { name: /Frage 2\.10/u });
902 await nutzer.click(screen.getAllByRole('checkbox')[0]!);
903 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
904
905 expect(
906 screen.getByRole('group', { name: /Unvollständig|Falsch|Richtig/u }),
907 ).toBeInTheDocument();
908 expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument();
909 });
910
911 it('sagt ausdrücklich, wenn es keine gesetzliche Grundlage gibt', async () => {
912 /* In Waffentechnik und Handhabung ist das der Regelfall. Eine gedehnte
913 Fundstelle wäre schlimmer als keine – sie täuscht eine
914 Verbindlichkeit vor, die es nicht gibt. */
915 const nutzer = await lernsitzungOeffnen({
916 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
917 });
918
919 await screen.findByRole('heading', { name: /Frage 1\.01/u });
920 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
921 await nutzer.click(screen.getByRole('button', { name: /^Gewusst/u }));
922
923 await screen.findByRole('heading', { name: 'Warum das so ist' });
924
925 expect(
926 screen.getByText(/Sicherheitsregeln zur Handhabung sind fachliche Praxis/u),
927 ).toBeInTheDocument();
928 // Kein leerer Fundstellenblock, wo es nichts nachzulesen gibt.
929 expect(screen.queryByRole('heading', { name: 'Im Gesetz nachlesen' })).not.toBeInTheDocument();
930 });
931
932 it('bleibt bedienbar, wenn der Kern den Kanal nicht anbietet', async () => {
933 const nutzer = await ohneKanalOeffnen('erklaerungenLaden');
934 await screen.findByRole('heading', { name: /Frage 1\.87/u });
935 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
936 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
937
938 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
939 expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument();
940 });
941 });
942
943 describe('Lernsitzung – Mehrfachauswahl', () => {
944 const nurMehrfach = {
945 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])),
946 };
947
948 it('nutzt Kontrollkästchen und weist auf mehrere richtige Antworten hin', async () => {
949 await lernsitzungOeffnen(nurMehrfach);
950
951 expect(await screen.findByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
952 expect(screen.getAllByRole('checkbox')).toHaveLength(3);
953 expect(screen.getByText('Wählen Sie alle zutreffenden aus.')).toBeInTheDocument();
954 expect(
955 screen.getByRole('group', { name: /mehrere Antworten sind richtig/u }),
956 ).toBeInTheDocument();
957 });
958
959 it('wertet eine unvollständige Auswahl als unvollständig', async () => {
960 const nutzer = await lernsitzungOeffnen(nurMehrfach);
961 await screen.findByRole('heading', { name: /Frage 2\.10/u });
962
963 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
964 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
965
966 expect(screen.getByRole('group', { name: 'Unvollständig' })).toBeInTheDocument();
967 expect(ansagebereich()).toHaveTextContent('Unvollständig. Es fehlt Antwort b.');
968 });
969
970 it('wertet die vollständige Auswahl als richtig', async () => {
971 const nutzer = await lernsitzungOeffnen(nurMehrfach);
972 await screen.findByRole('heading', { name: /Frage 2\.10/u });
973
974 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
975 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
976 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
977
978 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
979 expect(ansagebereich()).toHaveTextContent('Richtig sind die Antworten a und b.');
980 });
981
982 it('wertet eine zusätzliche falsche Auswahl als falsch', async () => {
983 const nutzer = await lernsitzungOeffnen(nurMehrfach);
984 await screen.findByRole('heading', { name: /Frage 2\.10/u });
985
986 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
987 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
988 await nutzer.click(screen.getByRole('checkbox', { name: /jährlich vorführen/u }));
989 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
990
991 expect(screen.getByRole('group', { name: 'Falsch' })).toBeInTheDocument();
992 });
993 });
994
995 describe('Lernsitzung – offene Fragen', () => {
996 const nurFreitext = {
997 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
998 };
999
1000 /*
1001 Der Text lag seit jeher in `antwort_log.freitext` und wurde nie gezeigt.
1002 Sein Wert liegt im Wiedersehen – aber erst NACH dem Bestätigen: vorher
1003 wäre er eine Vorlage zum Abschreiben und machte aus dem Ausformulieren
1004 ein Kopieren.
1005 */
1006 it('zeigt die frühere eigene Antwort erst nach dem Bestätigen', async () => {
1007 const mitFrueher = {
1008 ...nurFreitext,
1009 lernSitzung: vi.fn(() =>
1010 Promise.resolve([
1011 {
1012 frageId: 'I.1-01',
1013 optionsReihenfolge: [],
1014 gemerkt: false,
1015 letzterFreitext: {
1016 text: 'Den Umgang mit Waffen, glaube ich.',
1017 zeitpunkt: '2026-08-09T09:00:00.000Z',
1018 },
1019 },
1020 ]),
1021 ),
1022 };
1023 const nutzer = await lernsitzungOeffnen(mitFrueher);
1024 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1025
1026 // Vor dem Bestätigen: nichts davon zu sehen.
1027 expect(screen.queryByText(/Den Umgang mit Waffen, glaube ich\./u)).not.toBeInTheDocument();
1028
1029 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Zweiter Versuch.');
1030 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1031
1032 expect(screen.getByText(/Den Umgang mit Waffen, glaube ich\./u)).toBeVisible();
1033 /* Mit Datum, nicht bloß „beim letzten Mal“: Ob es gestern war oder vor
1034 einem Monat, ändert die Bedeutung des Vergleichs. */
1035 expect(screen.getByText(/Am 09\.08\.2026 schrieben Sie:/u)).toBeVisible();
1036 });
1037
1038 it('lässt den Block weg, wenn es keine frühere Antwort gibt', async () => {
1039 const nutzer = await lernsitzungOeffnen(nurFreitext);
1040 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1041
1042 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1043 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1044
1045 expect(screen.queryByText(/schrieben Sie:/u)).not.toBeInTheDocument();
1046 });
1047
1048 it('nennt die Kernpunkte als Einschätzung dieser Software', async () => {
1049 /* Bei 63 der 104 offenen Fragen ist im Katalog nichts unterstrichen. Der
1050 Lernende stand bei der Selbstbewertung ohne jedes Kriterium da – die
1051 Hilfe warnt vor zu milder Bewertung, gab aber kein Werkzeug. */
1052 const nutzer = await lernsitzungOeffnen(nurFreitext);
1053 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1054
1055 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1056 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1057
1058 const liste = screen.getByRole('list', {
1059 name: 'Nach Einschätzung dieser Software gehört zu einer vollständigen Antwort:',
1060 });
1061 expect(within(liste).getAllByRole('listitem')).toHaveLength(2);
1062 expect(
1063 screen.getByText(/Diese Punkte sind eine Ergänzung dieser Software/u),
1064 ).toBeInTheDocument();
1065 });
1066
1067 it('behauptet nie, was eine Antwort enthalten muss', async () => {
1068 /* Die Leitplanke aus `docs/entscheidung-freitext.md` Abschnitt 8. Bis
1069 Fassung 0.17.0 stand über einer ähnlichen Liste „Diese Kernelemente muss
1070 Ihre Antwort enthalten“ – unbelegt, und ausdrücklich abgeschafft. */
1071 const nutzer = await lernsitzungOeffnen(nurFreitext);
1072 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1073
1074 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1075 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1076
1077 expect(screen.queryByText(/muss Ihre Antwort enthalten/u)).not.toBeInTheDocument();
1078 expect(screen.queryByText(/zwingend erforderlich/u)).not.toBeInTheDocument();
1079 });
1080
1081 it('zeigt die Musterantwort erst nach dem Bestätigen', async () => {
1082 const nutzer = await lernsitzungOeffnen(nurFreitext);
1083 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1084
1085 const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' });
1086 expect(screen.queryByRole('heading', { name: 'Musterantwort' })).not.toBeInTheDocument();
1087
1088 await nutzer.type(feld, 'Den Umgang mit Waffen.');
1089 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1090
1091 expect(screen.getByRole('heading', { name: 'Musterantwort' })).toBeInTheDocument();
1092 const unterstrichen = screen.getByRole('list', {
1093 name: 'Im amtlichen Fragenkatalog ist hier unterstrichen:',
1094 });
1095 expect(
1096 within(unterstrichen)
1097 .getAllByRole('listitem')
1098 .map((eintrag) => eintrag.textContent),
1099 ).toEqual(['Umgang mit Waffen oder Munition', 'öffentlichen Sicherheit']);
1100 });
1101
1102 /*
1103 Bis 0.17.0 stand über dieser Liste „Diese Kernelemente muss Ihre Antwort
1104 enthalten:“. Beides war unbelegt: Das Amt sagt nirgends, was die
1105 Unterstreichung bedeutet, und bei 63 der 104 offenen Fragen ist gar
1106 nichts unterstrichen. Ohne diese Prüfung schriebe die Behauptung
1107 irgendwann jemand zurück, weil die alte Formulierung griffiger klingt.
1108 */
1109 it('nennt die Unterstreichung als Tatsache und nicht als Vorgabe', async () => {
1110 const nutzer = await lernsitzungOeffnen(nurFreitext);
1111 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1112
1113 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.');
1114 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1115
1116 expect(screen.queryByText(/muss Ihre Antwort enthalten/u)).not.toBeInTheDocument();
1117 expect(screen.getByText(/keine Vorgabe für Ihre Antwort/u)).toHaveTextContent(
1118 /Eine richtige Antwort kann anders formuliert sein/u,
1119 );
1120 });
1121
1122 /*
1123 Das Feld bleibt nach dem Bestätigen stehen, damit sich die eigene Antwort
1124 gegen die Musterantwort halten lässt. Mit `disabled` war genau das per
1125 Tastatur unmöglich – ein disabled-Feld nimmt keinen Fokus, ein
1126 Screenreader kam an den eigenen Text nicht mehr heran.
1127 */
1128 it('lässt die eigene Antwort nach dem Bestätigen lesbar und ansteuerbar', async () => {
1129 const nutzer = await lernsitzungOeffnen(nurFreitext);
1130 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1131
1132 const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' });
1133 await nutzer.type(feld, 'Den Umgang mit Waffen.');
1134 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1135
1136 expect(feld).toHaveValue('Den Umgang mit Waffen.');
1137 expect(feld).not.toBeDisabled();
1138 expect(feld).toHaveAttribute('readonly');
1139 feld.focus();
1140 expect(feld).toHaveFocus();
1141 });
1142
1143 it('übernimmt die Selbstbewertung „Gewusst“ als richtig', async () => {
1144 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1145 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1146 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1147
1148 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Den Umgang.');
1149 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1150 await nutzer.click(screen.getByRole('button', { name: 'Gewusst' }));
1151
1152 expect(screen.getByRole('group', { name: 'Gewusst' })).toBeInTheDocument();
1153 expect(ansagebereich()).toHaveTextContent('Bewertung: Gewusst. Sie lässt sich noch ändern.');
1154
1155 /* Gebucht wird erst beim Weiterblättern – siehe den Block
1156 „Selbstbewertung zurücknehmen“ weiter unten. */
1157 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1158
1159 expect(lernAntworten).toHaveBeenCalledWith(
1160 1,
1161 expect.objectContaining({
1162 frageId: 'I.1-01',
1163 freitext: 'Den Umgang.',
1164 richtig: true,
1165 bewertung: 'gut',
1166 }),
1167 );
1168 });
1169
1170 it('wertet „Nicht gewusst“ als falsch', async () => {
1171 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1172 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1173 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1174
1175 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1176 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
1177 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1178
1179 expect(lernAntworten).toHaveBeenCalledWith(
1180 1,
1181 expect.objectContaining({ richtig: false, bewertung: 'nochmal' }),
1182 );
1183 });
1184
1185 it('bietet alle vier Bewertungsstufen in einer benannten Gruppe an', async () => {
1186 const nutzer = await lernsitzungOeffnen(nurFreitext);
1187 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1188
1189 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1190
1191 const gruppe = screen.getByRole('group', { name: 'Wie gut wussten Sie die Antwort?' });
1192 expect(
1193 within(gruppe)
1194 .getAllByRole('button')
1195 .map((knopf) => knopf.textContent),
1196 ).toEqual(['Nicht gewusst', 'Mit Mühe gewusst', 'Gewusst', 'Sicher gewusst']);
1197 });
1198 });
1199
1200 /*
1201 Die Selbstbewertung zurücknehmen.
1202
1203 Bis 0.22.0 buchte ein Klick auf eine der vier Stufen sofort und endgültig:
1204 Ein Fehlgriff auf „Gewusst“ statt „Nicht gewusst“ schrieb eine falsche
1205 FSRS-Buchung – hohe Stabilität, langes Intervall, unter Umständen ein
1206 Reife-Beleg –, und das Programm bot nie wieder an, das zu berichtigen. Bei
1207 vier eng beieinanderliegenden Knöpfen ist das kein Randfall.
1208
1209 Die andere Richtung braucht nichts: Eine zu streng bewertete Antwort kommt
1210 in derselben Sitzung wieder, und FSRS verkürzt das Intervall ohnehin.
1211 Gefährlich ist allein die zu milde Bewertung.
1212 */
1213 describe('Lernsitzung – Selbstbewertung zurücknehmen', () => {
1214 const nurFreitext = {
1215 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
1216 };
1217
1218 it('bucht erst beim Weiterblättern, nicht schon beim Bewerten', async () => {
1219 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1220 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1221 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1222
1223 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1224 await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' }));
1225
1226 expect(lernAntworten).not.toHaveBeenCalled();
1227 });
1228
1229 it('verwirft die zu milde Bewertung, statt sie zu buchen', async () => {
1230 /* Der eigentliche Schadensfall: „Sicher gewusst“ statt „Nicht gewusst“.
1231 Nach der Korrektur darf ausschließlich die berichtigte Bewertung im
1232 Lernstand ankommen – keine zwei Einträge, kein „leicht“. */
1233 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1234 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1235 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1236
1237 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1238 await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' }));
1239 await nutzer.click(screen.getByRole('button', { name: 'Bewertung ändern' }));
1240 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
1241 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1242
1243 expect(lernAntworten).toHaveBeenCalledTimes(1);
1244 expect(lernAntworten).toHaveBeenCalledWith(
1245 1,
1246 expect.objectContaining({ richtig: false, bewertung: 'nochmal' }),
1247 );
1248 });
1249
1250 it('führt zurück zur Bewertungsgruppe und sagt es an', async () => {
1251 const nutzer = await lernsitzungOeffnen(nurFreitext);
1252 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1253
1254 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1255 await nutzer.click(screen.getByRole('button', { name: 'Gewusst' }));
1256 await nutzer.click(screen.getByRole('button', { name: 'Bewertung ändern' }));
1257
1258 expect(
1259 screen.getByRole('group', { name: 'Wie gut wussten Sie die Antwort?' }),
1260 ).toBeInTheDocument();
1261 expect(ansagebereich()).toHaveTextContent(/zurückgenommen/u);
1262 });
1263
1264 it('bietet die Rücknahme bei Auswahlfragen nicht an', async () => {
1265 /* Dort steht die Lösung auf dem Bildschirm. Eine änderbare Antwort wäre
1266 kein Korrekturweg, sondern ein zweiter Versuch mit bekannter Lösung. */
1267 const nutzer = await lernsitzungOeffnen();
1268 const optionen = await screen.findAllByRole('radio');
1269
1270 await nutzer.click(optionen[0]!);
1271 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1272
1273 expect(screen.queryByRole('button', { name: 'Bewertung ändern' })).not.toBeInTheDocument();
1274 });
1275
1276 /*
1277 „Ich hatte geraten“ – die Selbsteinschätzung bei Auswahlfragen.
1278
1279 `bewertungAusErgebnis` vergibt für jede richtige Antwort ausnahmslos
1280 „gut“ – auch für die geratene. `docs/entscheidung-ratewahrscheinlichkeit.md`
1281 lehnt zwei **automatische** Milderungen ab und benennt die Ursache
1282 wörtlich: „Das Problem sitzt im Eingang (ein Klick ist keine
1283 Selbsteinschätzung), nicht in den Parametern.“ Dieser Knopf öffnet genau
1284 diesen Eingang – freiwillig, und wer ihn übersieht, merkt nichts.
1285 */
1286 it('bietet die Selbsteinschätzung nach einer richtigen Auswahlantwort an', async () => {
1287 const nutzer = await lernsitzungOeffnen();
1288 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1289
1290 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1291 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1292
1293 expect(screen.getByRole('button', { name: 'Ich hatte geraten' })).toBeInTheDocument();
1294 });
1295
1296 it('bietet sie nach einer falschen Antwort nicht an', async () => {
1297 /* Da gibt es nichts einzugestehen – die Frage kommt ohnehin wieder. */
1298 const nutzer = await lernsitzungOeffnen();
1299 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1300
1301 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
1302 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1303
1304 expect(screen.queryByRole('button', { name: 'Ich hatte geraten' })).toBeNull();
1305 });
1306
1307 it('bucht die Antwort als „nicht gewusst“, ohne sie falsch zu machen', async () => {
1308 /* Angekreuzt war das Richtige, gewusst war es nicht: `richtig` bleibt
1309 wahr – der Anwendungskern rechnet es ohnehin selbst aus der Auswahl
1310 nach –, allein die Bewertung ändert sich. */
1311 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1312 const nutzer = await lernsitzungOeffnen({ lernAntworten });
1313 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1314
1315 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1316 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1317 await nutzer.click(screen.getByRole('button', { name: 'Ich hatte geraten' }));
1318 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1319
1320 expect(lernAntworten).toHaveBeenCalledTimes(1);
1321 expect(lernAntworten).toHaveBeenCalledWith(
1322 1,
1323 expect.objectContaining({ richtig: true, bewertung: 'nochmal' }),
1324 );
1325 });
1326
1327 it('sagt an, was das Eingeständnis bewirkt', async () => {
1328 const nutzer = await lernsitzungOeffnen();
1329 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1330
1331 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1332 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1333 await nutzer.click(screen.getByRole('button', { name: 'Ich hatte geraten' }));
1334
1335 /* Genau so weit, wie es der Anwendungskern einhält: Ein Zufallstreffer
1336 darf keinen Beleg SETZEN; einen vorhandenen nimmt nur eine falsche
1337 Antwort weg. Bis 0.24.1 versprach die Ansage das Weitergehende und
1338 widersprach damit dem Kern. */
1339 expect(ansagebereich()).toHaveTextContent(
1340 /als neuer Beleg für Ihren Reifegrad zählt sie nicht/u,
1341 );
1342 expect(ansagebereich()).not.toHaveTextContent(/Reifegrad zählt sie nicht mit/u);
1343 /* Zweimal drücken soll nichts Weiteres tun. */
1344 expect(screen.queryByRole('button', { name: 'Ich hatte geraten' })).toBeNull();
1345 });
1346
1347 it('bucht auch dann, wenn die Sitzung vorzeitig verlassen wird', async () => {
1348 /* Wer bewertet und dann zum Start geht, hat die Frage bearbeitet – sie
1349 darf nicht verfallen. */
1350 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1351 const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten });
1352 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1353
1354 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1355 await nutzer.click(screen.getByRole('button', { name: 'Gewusst' }));
1356 await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
1357
1358 expect(lernAntworten).toHaveBeenCalledWith(
1359 1,
1360 expect.objectContaining({ richtig: true, bewertung: 'gut' }),
1361 );
1362 });
1363 });
1364
1365 describe('Lernsitzung – Bildoptionen', () => {
1366 const nurBild = { lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_BILD)])) };
1367
1368 it('macht eine reine Bildoption auswählbar und beschriftet sie über den Alternativtext', async () => {
1369 const nutzer = await lernsitzungOeffnen(nurBild);
1370 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1371
1372 const option = await screen.findByRole('radio', {
1373 name: /Rundes Zeichen mit der Buchstabenfolge PTB/u,
1374 });
1375 await nutzer.click(option);
1376 expect(option).toBeChecked();
1377
1378 expect(
1379 screen.getByRole('img', { name: 'Rundes Zeichen mit der Buchstabenfolge PTB' }),
1380 ).toBeInTheDocument();
1381 });
1382
1383 /*
1384 Zweite Stufe des Bildbeschreibungskonzepts (Prüfplan S4): Der Alt-Text
1385 bleibt neutral, weil das Zeichen hier selbst die Antwortoption ist – die
1386 Bedeutung stünde sonst schon in der Beschriftung und verriete die Lösung.
1387 Nachgereicht wird sie erst mit der Lösung.
1388 */
1389 it('verrät die Bedeutung des Zeichens vor dem Bestätigen nicht', async () => {
1390 await lernsitzungOeffnen(nurBild);
1391 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1392
1393 expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();
1394 expect(screen.queryByText(/Physikalisch-Technischen Bundesanstalt/u)).not.toBeInTheDocument();
1395 });
1396
1397 it('reicht die Bedeutung des Zeichens nach dem Bestätigen aufklappbar nach', async () => {
1398 const nutzer = await lernsitzungOeffnen(nurBild);
1399 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1400
1401 await nutzer.click(
1402 screen.getByRole('radio', { name: /Rundes Zeichen mit der Buchstabenfolge PTB/u }),
1403 );
1404 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1405
1406 const schalter = screen.getByText('Was dieses Zeichen bedeutet');
1407 expect(schalter.closest('details')).not.toHaveAttribute('open');
1408
1409 await nutzer.click(schalter);
1410 expect(
1411 screen.getByText(/Zulassungszeichen der Physikalisch-Technischen Bundesanstalt/u),
1412 ).toBeVisible();
1413 });
1414
1415 it('lässt den Aufklapper bei Zeichen ohne hinterlegte Bedeutung weg', async () => {
1416 /* Option b) trägt das F im Fünfeck ohne Beschreibung – dort darf nach
1417 dem Bestätigen kein leerer Aufklappbereich stehen. Übrig bleibt genau
1418 der eine an Option a). */
1419 const nutzer = await lernsitzungOeffnen(nurBild);
1420 await screen.findByRole('heading', { name: /Frage 3\.05/u });
1421
1422 await nutzer.click(
1423 screen.getByRole('radio', { name: /Rundes Zeichen mit der Buchstabenfolge PTB/u }),
1424 );
1425 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1426
1427 expect(screen.getAllByText('Was dieses Zeichen bedeutet')).toHaveLength(1);
1428 });
1429
1430 it('reicht die Bedeutung auch bei Zeichen am Fragetext nach', async () => {
1431 /* Der zweite Ort für Bilder: nicht in einer Option, sondern an der Frage
1432 selbst. Auch dort gilt: vorher nichts, nachher aufklappbar. */
1433 const nutzer = await lernsitzungOeffnen({
1434 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_BILD_KOPF)])),
1435 });
1436 await screen.findByRole('heading', { name: /Frage 3\.06/u });
1437
1438 expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();
1439
1440 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1441
1442 const schalter = screen.getByText('Was dieses Zeichen bedeutet');
1443 await nutzer.click(schalter);
1444 expect(
1445 screen.getByText(/Zulassungszeichen der Physikalisch-Technischen Bundesanstalt/u),
1446 ).toBeVisible();
1447 });
1448 });
1449
1450 describe('Lernsitzung – kein Autosprung und keine Zeitvorgabe', () => {
1451 it('bleibt nach dem Bestätigen bei derselben Frage (WCAG 3.2.2)', async () => {
1452 const nutzer = await lernsitzungOeffnen({
1453 lernSitzung: vi.fn(() =>
1454 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1455 ),
1456 });
1457 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1458
1459 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1460 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1461
1462 expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1463 expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument();
1464
1465 const weiter = screen.getByRole('button', { name: 'Nächste Frage' });
1466 await nutzer.click(weiter);
1467
1468 expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
1469 expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument();
1470 });
1471 });
1472
1473 describe('Lernsitzung – Merkliste', () => {
1474 it('schaltet das Merken um und meldet es an den Lernstand', async () => {
1475 const lernMerken = vi.fn((_profilId: number, frageId: string, gemerkt: boolean) =>
1476 Promise.resolve({ ...STAND, frageId, gemerkt }),
1477 );
1478 const nutzer = await lernsitzungOeffnen({ lernMerken });
1479 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1480
1481 const knopf = screen.getByRole('button', { name: 'Frage merken' });
1482 expect(knopf).toHaveAttribute('aria-pressed', 'false');
1483
1484 await nutzer.click(knopf);
1485 expect(knopf).toHaveAttribute('aria-pressed', 'true');
1486 expect(lernMerken).toHaveBeenLastCalledWith(1, 'I.1-87', true);
1487
1488 await nutzer.click(knopf);
1489 expect(knopf).toHaveAttribute('aria-pressed', 'false');
1490 expect(lernMerken).toHaveBeenLastCalledWith(1, 'I.1-87', false);
1491 });
1492 });
1493
1494 describe('Lernsitzung – Tastaturbedienung', () => {
1495 it('wählt Optionen mit den Ziffern und bestätigt mit der Eingabetaste', async () => {
1496 const nutzer = await lernsitzungOeffnen();
1497 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1498
1499 await nutzer.keyboard('3');
1500 expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).toBeChecked();
1501 expect(ansagebereich()).toHaveTextContent('Antwort c gewählt.');
1502
1503 await nutzer.keyboard('{Enter}');
1504 expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument();
1505 });
1506
1507 it('merkt die Frage mit der Taste M', async () => {
1508 const nutzer = await lernsitzungOeffnen();
1509 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1510
1511 await nutzer.keyboard('m');
1512
1513 expect(screen.getByRole('button', { name: 'Frage merken' })).toHaveAttribute(
1514 'aria-pressed',
1515 'true',
1516 );
1517 });
1518
1519 it('schaltet mit der Eingabetaste zur nächsten Frage weiter', async () => {
1520 const nutzer = await lernsitzungOeffnen({
1521 lernSitzung: vi.fn(() =>
1522 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1523 ),
1524 });
1525 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1526
1527 await nutzer.keyboard('1');
1528 await nutzer.keyboard('{Enter}');
1529 expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1530
1531 await nutzer.keyboard('{Enter}');
1532 expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
1533 });
1534
1535 it('bewegt sich in der Radiogruppe mit den Pfeiltasten', async () => {
1536 const nutzer = await lernsitzungOeffnen();
1537 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1538
1539 const erste = screen.getByRole('radio', { name: /Sportgewehr/u });
1540 erste.focus();
1541 await nutzer.keyboard('{ArrowDown}');
1542
1543 expect(screen.getByRole('radio', { name: /Schreckschusspistole/u })).toBeChecked();
1544 });
1545
1546 it('lässt Ziffern unbeachtet, wenn die Kürzel abgeschaltet sind (WCAG 2.1.4)', async () => {
1547 window.localStorage.setItem('waffensachkunde.tastenkuerzel', 'aus');
1548 const nutzer = await lernsitzungOeffnen();
1549 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1550
1551 await nutzer.keyboard('3');
1552
1553 expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).not.toBeChecked();
1554 });
1555
1556 it('schreibt Ziffern in das Freitextfeld, statt Optionen zu wählen', async () => {
1557 const nutzer = await lernsitzungOeffnen({
1558 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])),
1559 });
1560 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1561
1562 const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' });
1563 await nutzer.click(feld);
1564 await nutzer.keyboard('3 Gründe');
1565
1566 expect(feld).toHaveValue('3 Gründe');
1567 });
1568 });
1569
1570 describe('Lernsitzung – Fokusführung', () => {
1571 it('setzt den Fokus nach dem Bestätigen auf den Ergebnisbereich', async () => {
1572 const nutzer = await lernsitzungOeffnen();
1573 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1574
1575 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1576 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1577
1578 expect(screen.getByRole('group', { name: 'Richtig' })).toHaveFocus();
1579 });
1580
1581 it('setzt den Fokus bei der nächsten Frage auf deren Überschrift', async () => {
1582 const nutzer = await lernsitzungOeffnen({
1583 lernSitzung: vi.fn(() =>
1584 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1585 ),
1586 });
1587 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1588
1589 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1590 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1591 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1592
1593 expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toHaveFocus();
1594 });
1595 });
1596
1597 describe('Sitzungsende', () => {
1598 it('fasst die Sitzung zusammen und bietet die Fehler zur Wiederholung an', async () => {
1599 const nutzer = await lernsitzungOeffnen({
1600 lernSitzung: vi.fn(() =>
1601 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1602 ),
1603 });
1604 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1605
1606 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1607 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1608 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1609
1610 await nutzer.click(screen.getByRole('checkbox', { name: /jährlich vorführen/u }));
1611 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1612
1613 /* Die falsche Antwort reiht die Frage wieder ein; die Wiederholung wird
1614 richtig beantwortet und zählt als eigener Versuch – zwei von drei
1615 Antworten richtig, aber nur eine falsch beantwortete Frage. */
1616 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1617 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
1618 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
1619 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1620 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1621
1622 expect(
1623 screen.getByRole('heading', { name: 'Auswertung: Weiterlernen', level: 1 }),
1624 ).toBeInTheDocument();
1625 expect(
1626 screen.getByText('2 von 3 beantworteten Fragen richtig – Trefferquote 67 Prozent.'),
1627 ).toBeInTheDocument();
1628
1629 const fehlerliste = screen.getByRole('list', { name: 'Falsch beantwortete Fragen' });
1630 expect(within(fehlerliste).getAllByRole('listitem')).toHaveLength(1);
1631 expect(within(fehlerliste).getByText(/Frage 2\.10/u)).toBeInTheDocument();
1632
1633 await nutzer.click(screen.getByRole('button', { name: 'Fehler wiederholen (1)' }));
1634 expect(await screen.findByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument();
1635 });
1636
1637 it('bietet die verwandten Fragen zu den Fehlern zum Üben an', async () => {
1638 /* Hier und nicht in der laufenden Sitzung: Am Ende läuft nichts mehr, und
1639 es ist genau der Moment, in dem entschieden wird, was als Nächstes
1640 drankommt. Angeboten wird, was zu den **Fehlern** gehört – wer eine
1641 Frage sicher konnte, braucht ihre Nachbarn nicht. */
1642 const nutzer = await lernsitzungOeffnen({
1643 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH)])),
1644 });
1645 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1646
1647 // Falsch antworten, damit I.1-87 in der Fehlerliste landet.
1648 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
1649 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1650 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1651 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1652 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1653 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1654
1655 const taste = await screen.findByRole('button', { name: 'Verwandte Fragen üben (1)' });
1656 await nutzer.click(taste);
1657
1658 expect(await screen.findByRole('heading', { name: /Frage 1\.01/u })).toBeInTheDocument();
1659 });
1660
1661 /*
1662 Der häufigste Weg nach einer Sitzung ist die nächste.
1663
1664 Das Tagespensum umfasst oft mehrere Sitzungen. Bis 0.22.0 führte dieser
1665 Weg über den Startbildschirm zurück – rund 8000 Bildpunkte hoch – und
1666 dort erneut über „Weiterlernen“.
1667 */
1668 it('bietet die nächste Sitzung an, ohne den Umweg über den Start', async () => {
1669 const nutzer = await lernsitzungOeffnen();
1670 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1671
1672 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1673 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1674 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1675
1676 await nutzer.click(await screen.findByRole('button', { name: 'Weiterlernen' }));
1677
1678 expect(await screen.findByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1679 });
1680 });
1681
1682 describe('Lernsitzung – falsch beantwortete Fragen kommen wieder', () => {
1683 /** Wählt die falsche Antwort der Einfachauswahl-Frage und bestätigt. */
1684 async function falschAntworten(nutzer: Nutzer): Promise<void> {
1685 await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u }));
1686 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1687 }
1688
1689 it('reiht eine falsch beantwortete Frage ans Ende der Sitzung', async () => {
1690 const nutzer = await lernsitzungOeffnen({
1691 lernSitzung: vi.fn(() =>
1692 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
1693 ),
1694 });
1695 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1696
1697 await falschAntworten(nutzer);
1698
1699 // Die Anzeige bleibt ehrlich: Y wächst mit der Wiedereinreihung.
1700 expect(screen.getByText('Frage 1 von 3')).toBeInTheDocument();
1701 expect(ansagebereich()).toHaveTextContent('Die Frage kommt in dieser Sitzung noch einmal.');
1702
1703 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1704 await screen.findByRole('heading', { name: /Frage 2\.10/u });
1705 await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u }));
1706 await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u }));
1707 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1708 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1709
1710 // Am Ende steht die falsch beantwortete Frage noch einmal.
1711 expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument();
1712 expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument();
1713 });
1714
1715 it('reiht dieselbe Frage höchstens zweimal wieder ein', async () => {
1716 const nutzer = await lernsitzungOeffnen(); // eine einzige Frage
1717 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1718
1719 await falschAntworten(nutzer);
1720 expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument();
1721 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1722
1723 await falschAntworten(nutzer);
1724 expect(screen.getByText('Frage 2 von 3')).toBeInTheDocument();
1725 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1726
1727 await falschAntworten(nutzer);
1728
1729 /* Keine dritte Wiedereinreihung: Die Sitzung endet, danach übernimmt die
1730 Wiedervorlage – „nochmal“ macht die Frage sofort wieder fällig, die
1731 nächste Sitzung legt sie vorn vor. */
1732 expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument();
1733 expect(ansagebereich()).not.toHaveTextContent('kommt in dieser Sitzung noch einmal');
1734 expect(screen.getByRole('button', { name: 'Sitzung auswerten' })).toBeInTheDocument();
1735 });
1736
1737 it('lässt eine richtig beantwortete Wiederholung nicht erneut erscheinen', async () => {
1738 const nutzer = await lernsitzungOeffnen();
1739 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1740
1741 await falschAntworten(nutzer);
1742 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1743
1744 expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument();
1745 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1746 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1747
1748 // Richtig beantwortet: nichts wächst nach, die Sitzung ist zu Ende.
1749 expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument();
1750 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1751
1752 expect(
1753 screen.getByText('1 von 2 beantworteten Fragen richtig – Trefferquote 50 Prozent.'),
1754 ).toBeInTheDocument();
1755 // Je Frage ein Eintrag, auch wenn sie zweimal vorgelegt wurde.
1756 const fehlerliste = screen.getByRole('list', { name: 'Falsch beantwortete Fragen' });
1757 expect(within(fehlerliste).getAllByRole('listitem')).toHaveLength(1);
1758 });
1759
1760 it('bucht die Wiederholungsantwort über denselben Antwortweg', async () => {
1761 const lernAntworten = vi.fn(() => Promise.resolve(STAND));
1762 const nutzer = await lernsitzungOeffnen({ lernAntworten });
1763 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1764
1765 await falschAntworten(nutzer);
1766 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1767 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1768 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1769 /* Beide Buchungen fallen beim Weiterblättern – seit 0.22.0 wird bis
1770 dahin zurückgehalten, damit sich „Ich hatte geraten“ nachreichen
1771 lässt. */
1772 await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' }));
1773
1774 // Beide Vorlagen sind reguläre Antworten – FSRS-6 verrechnet die
1775 // Same-Day-Wiederholung über seine Kurzzeitparameter im Anwendungskern.
1776 expect(lernAntworten).toHaveBeenCalledTimes(2);
1777 expect(lernAntworten).toHaveBeenNthCalledWith(
1778 1,
1779 1,
1780 expect.objectContaining({ frageId: 'I.1-87', richtig: false, bewertung: 'nochmal' }),
1781 );
1782 expect(lernAntworten).toHaveBeenNthCalledWith(
1783 2,
1784 1,
1785 expect.objectContaining({ frageId: 'I.1-87', richtig: true, bewertung: 'gut' }),
1786 );
1787 });
1788
1789 /*
1790 Die Wiedereinreihung schafft für offene Fragen einen Fall, den es vorher
1791 nicht gab: dieselbe Frage zweimal in einer Sitzung. „Was zuletzt
1792 geschrieben wurde“ ist dann die Antwort von eben – der Eintrag aus dem
1793 Lernstand wäre die falsche Auskunft und der schwächere Vergleich.
1794 */
1795 it('zeigt bei der Wiederholung die Antwort von eben statt der aus dem Lernstand', async () => {
1796 const nutzer = await lernsitzungOeffnen({
1797 lernSitzung: vi.fn(() =>
1798 Promise.resolve([
1799 {
1800 frageId: 'I.1-01',
1801 optionsReihenfolge: [],
1802 gemerkt: false,
1803 letzterFreitext: {
1804 text: 'Den Umgang mit Waffen, glaube ich.',
1805 zeitpunkt: '2026-08-09T09:00:00.000Z',
1806 },
1807 },
1808 ]),
1809 ),
1810 });
1811 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1812
1813 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Erster Anlauf.');
1814 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1815 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
1816 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1817
1818 await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Zweiter Anlauf.');
1819 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1820
1821 expect(screen.getByText(/Erster Anlauf\./u)).toBeVisible();
1822 expect(screen.queryByText(/Den Umgang mit Waffen, glaube ich\./u)).not.toBeInTheDocument();
1823 });
1824
1825 it('behält den Eintrag aus dem Lernstand, wenn nichts geschrieben wurde', async () => {
1826 /* Leer lassen ist erlaubt und ist kein Schreiben: Zuletzt geschrieben
1827 wurde dann weiterhin das Frühere. */
1828 const nutzer = await lernsitzungOeffnen({
1829 lernSitzung: vi.fn(() =>
1830 Promise.resolve([
1831 {
1832 frageId: 'I.1-01',
1833 optionsReihenfolge: [],
1834 gemerkt: false,
1835 letzterFreitext: {
1836 text: 'Den Umgang mit Waffen, glaube ich.',
1837 zeitpunkt: '2026-08-09T09:00:00.000Z',
1838 },
1839 },
1840 ]),
1841 ),
1842 });
1843 await screen.findByRole('heading', { name: /Frage 1\.01/u });
1844
1845 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1846 await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' }));
1847 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
1848 await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' }));
1849
1850 expect(screen.getByText(/Am 09\.08\.2026 schrieben Sie:/u)).toBeVisible();
1851 });
1852 });
1853
1854 describe('Lernsitzung – Ausfall des Anwendungskerns', () => {
1855 it('übt ersatzweise aus dem Katalog, wenn der Sitzungskanal scheitert', async () => {
1856 await lernsitzungOeffnen({
1857 lernSitzung: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))),
1858 });
1859
1860 expect(
1861 await screen.findByText(/Ihre Lernsitzung konnte nicht geladen werden/u),
1862 ).toBeInTheDocument();
1863 expect(screen.getByText(/Ihre Antworten werden nicht gespeichert/u)).toBeInTheDocument();
1864 expect(screen.getAllByRole('heading', { level: 1 }).length).toBeGreaterThan(0);
1865 });
1866
1867 it('erklärt, dass „Nur Fehler“ ohne Lernstand nicht möglich ist', async () => {
1868 vi.stubGlobal(
1869 'lernApp',
1870 attrappe({ lernSitzung: vi.fn(() => Promise.reject(new Error('kein Zugriff'))) }),
1871 );
1872 const nutzer = userEvent.setup();
1873 render(<App />);
1874
1875 const knopf = await screen.findByRole('button', { name: /Nur Fehler/u });
1876 await waitFor(() => {
1877 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1878 });
1879 await nutzer.click(knopf);
1880
1881 expect(await screen.findByText(/braucht Ihren gespeicherten Lernstand/u)).toBeInTheDocument();
1882 expect(screen.getByRole('button', { name: 'Zum Start' })).toBeInTheDocument();
1883 });
1884 });
1885
1886 describe('Antwortbuchstabe gehört zum Inhalt, nicht zur Stelle', () => {
1887 /*
1888 Diese beiden Tests halten eine Entscheidung fest, nicht bloß das heutige
1889 Verhalten – deshalb stehen sie hier und nicht bei den Einstellungen.
1890
1891 Der Buchstabe vor jeder Option ist der amtliche aus dem Katalog. Er ist
1892 zugleich der Verweis auf die gedruckte Vorlage und auf die Erklärungen,
1893 von denen 368 der 575 ihn beim Namen nennen („Zu a):“). Würde er stattdessen
1894 aus der Anzeigestelle abgeleitet, träfe er in fast zwei Dritteln
1895 aller Fragen eine andere Aussage als die Erklärung darunter.
1896
1897 Der übrige Testbestand kann das nicht bemerken: Seine Attrappe reicht
1898 ausnahmslos die ungemischte Katalogreihenfolge herein, in der Buchstabe
1899 und Stelle ohnehin übereinstimmen. Hier wird bewusst verdreht.
1900
1901 Siehe docs/entscheidung-antwortreihenfolge.md.
1902 */
1903
1904 it('zeigt bei verdrehter Reihenfolge den amtlichen Buchstaben am zugehörigen Inhalt', async () => {
1905 const verdreht: SitzungsFrage = {
1906 frageId: FRAGE_EINFACH.id,
1907 optionsReihenfolge: ['c', 'a', 'b'],
1908 gemerkt: false,
1909 };
1910 await mitBrueckeOeffnen(attrappe({ lernSitzung: vi.fn(() => Promise.resolve([verdreht])) }));
1911 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1912
1913 const optionen = screen.getAllByRole('radio').map((feld) => feld.closest('.antwortoption'));
1914 const marke = (stelle: number): string =>
1915 optionen[stelle]?.querySelector('.antwortoption__marke')?.textContent ?? '';
1916 const inhalt = (stelle: number): string => optionen[stelle]?.textContent ?? '';
1917
1918 expect(optionen).toHaveLength(3);
1919 // Erste Stelle trägt c) – der Buchstabe ist mit seinem Inhalt gewandert.
1920 expect(marke(0)).toBe('c)');
1921 expect(inhalt(0)).toContain('Spielzeugpistole');
1922 expect(marke(1)).toBe('a)');
1923 expect(inhalt(1)).toContain('Sportgewehr');
1924 expect(marke(2)).toBe('b)');
1925 expect(inhalt(2)).toContain('Schreckschusspistole');
1926
1927 /* Und genau hier entsteht der Befund, der zu dieser Umstellung geführt
1928 hat: Die Ziffer ist das Tastenkürzel und zählt die Stelle, der
1929 Buchstabe gehört zum Inhalt. Gemischt laufen beide auseinander –
1930 auf dem Schirm steht dann „1 c)“. Das ist kein Fehler, sondern der
1931 Preis des Mischens, und deshalb steht der Schalter ab Werk auf aus. */
1932 expect(inhalt(0)).toMatch(/^1c\)/u);
1933 });
1934
1935 it('nennt im Lösungssatz denselben Buchstaben, den die richtige Antwort auf dem Schirm trägt', async () => {
1936 const verdreht: SitzungsFrage = {
1937 frageId: FRAGE_EINFACH.id,
1938 optionsReihenfolge: ['c', 'a', 'b'],
1939 gemerkt: false,
1940 };
1941 const nutzer = await mitBrueckeOeffnen(
1942 attrappe({ lernSitzung: vi.fn(() => Promise.resolve([verdreht])) }),
1943 );
1944 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1945
1946 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
1947 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
1948
1949 /* Richtig ist die Option mit dem amtlichen Buchstaben c – auch wenn sie
1950 hier an erster Stelle steht. Stünde hier „Antwort a“, wäre der
1951 Buchstabe aus der Stelle abgeleitet worden. */
1952 /* Der Satz steht zweimal da: sichtbar im Ergebnisbereich und in der
1953 Live-Region für Bildschirmleser. Beide müssen denselben Buchstaben
1954 nennen. */
1955 const saetze = await screen.findAllByText(/Richtig ist Antwort c\./u);
1956 expect(saetze.length).toBeGreaterThan(0);
1957 expect(screen.queryByText(/Richtig ist Antwort a\./u)).toBeNull();
1958 });
1959 });
1960
1961 /** Öffnet eine Sitzung mit einer Einfachauswahl-Frage und verborgener Antwortzahl. */
1962 async function mitVerborgenerAntwortzahl(): Promise<Nutzer> {
1963 const nutzer = await mitBrueckeOeffnen(
1964 attrappe({
1965 lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH)])),
1966 einstellungenLesen: vi.fn(() =>
1967 Promise.resolve({
1968 ...EINSTELLUNGEN_STANDARD,
1969 zuschnittGefragt: true,
1970 antwortzahlVerbergen: true,
1971 }),
1972 ),
1973 }),
1974 );
1975 await screen.findByRole('heading', { name: /Frage 1\.87/u });
1976 return nutzer;
1977 }
1978
1979 describe('Einstellung „Anzahl der richtigen Antworten verbergen“', () => {
1980 it('nennt ohne sie die Zahl der richtigen Antworten', async () => {
1981 await mitBrueckeOeffnen(
1982 attrappe({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])) }),
1983 );
1984 await screen.findByRole('heading', { name: /Frage 2\.10/u });
1985
1986 expect(screen.getByText(/mehrere Antworten sind richtig/u)).toBeInTheDocument();
1987 expect(screen.getAllByRole('checkbox')).toHaveLength(3);
1988 });
1989
1990 it('verschweigt sie eingeschaltet – wie die Prüfung', async () => {
1991 await mitVerborgenerAntwortzahl();
1992
1993 /* Einfachauswahl, aber Kästchen statt Radios: Sonst verriete schon die
1994 Bauart des Bedienelements, dass genau eine Antwort richtig ist. */
1995 expect(
1996 await screen.findByText(/ist nicht angegeben, wie viele Antworten richtig sind/u),
1997 ).toBeInTheDocument();
1998 expect(screen.getAllByRole('checkbox')).toHaveLength(3);
1999 expect(screen.queryAllByRole('radio')).toHaveLength(0);
2000 });
2001
2002 it('lässt bei Einfachauswahl zwei Kästchen zugleich stehen', async () => {
2003 /*
2004 Der eigentliche Prüfstein, und der Grund, warum ein Blick auf die Bauart
2005 der Bedienelemente nicht genügt: Bliebe die Auswahllogik an der Zahl der
2006 richtigen Antworten hängen, ginge beim Anhaken des zweiten Kästchens das
2007 erste stumm aus – und verriete damit genau das, was die Einstellung
2008 verschweigen soll. Eine zu große Auswahl muss möglich und schlicht
2009 falsch sein, wie in der Prüfung (vgl. usePruefung.optionWaehlen).
2010 */
2011 const nutzer = await mitVerborgenerAntwortzahl();
2012 const kaestchen = screen.getAllByRole('checkbox');
2013
2014 await nutzer.click(kaestchen[0]!);
2015 await nutzer.click(kaestchen[1]!);
2016
2017 expect(kaestchen[0]).toBeChecked();
2018 expect(kaestchen[1]).toBeChecked();
2019
2020 // Und abwählbar muss jedes einzelne bleiben.
2021 await nutzer.click(kaestchen[0]!);
2022 expect(kaestchen[0]).not.toBeChecked();
2023 expect(kaestchen[1]).toBeChecked();
2024 });
2025
2026 it('sagt das Abwählen an, statt nur das Wählen', async () => {
2027 /* Mit erzwungener Einfachauswahl war der Zweig „abgewählt“ in diesem
2028 Zustand unerreichbar – für Bildschirmleser ein stummer Zustandswechsel. */
2029 const nutzer = await mitVerborgenerAntwortzahl();
2030
2031 await nutzer.keyboard('1');
2032 expect(ansagebereich()).toHaveTextContent('Antwort a gewählt.');
2033
2034 await nutzer.keyboard('1');
2035 expect(ansagebereich()).toHaveTextContent('Antwort a abgewählt.');
2036 });
2037
2038 it('bleibt im verborgenen Zustand frei von axe-Verstößen', async () => {
2039 const nutzer = await mitVerborgenerAntwortzahl();
2040 await nutzer.click(screen.getAllByRole('checkbox')[0]!);
2041
2042 const ergebnis = await axe.run(document.body, jsdomOptionen());
2043
2044 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
2045 });
2046 });
2047
2048 describe('Lernsitzung – Barrierefreiheits-Rauchtest (axe-core)', () => {
2049 it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => {
2050 const nutzer = await lernsitzungOeffnen();
2051 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2052
2053 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
2054 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
2055
2056 const ergebnis = await axe.run(document.body, jsdomOptionen());
2057
2058 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
2059 });
2060 });
2061
2062 describe('Vorlesen und Fragenwechsel', () => {
2063 /*
2064 Gemeldet von einem Nutzer: „Wenn eine Frage oder Antwort vorgelesen wird
2065 und es wird die nächste Frage ausgewählt, dann wird immer noch die alte
2066 Frage oder Antwort vorgelesen.“
2067
2068 Die Stimme gehört zu dem, was auf dem Bildschirm steht. Bleibt sie beim
2069 Wechsel stehen, hört man etwas anderes, als man sieht – für jemanden, der
2070 sich die Fragen vorlesen lässt, ist das nicht eine Unschönheit, sondern
2071 der Verlust der Orientierung.
2072 */
2073 interface Stimmwerk {
2074 readonly gesprochen: string[];
2075 readonly abbrueche: () => number;
2076 }
2077
2078 function sprachausgabeStellen(): Stimmwerk {
2079 const gesprochen: string[] = [];
2080 let abbrueche = 0;
2081
2082 class AeusserungAttrappe {
2083 lang = '';
2084 voice: unknown = null;
2085 onend: (() => void) | null = null;
2086 onerror: ((ereignis: { error: string }) => void) | null = null;
2087 constructor(public readonly text: string) {}
2088 }
2089
2090 vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
2091 vi.stubGlobal('speechSynthesis', {
2092 speak: (aeusserung: AeusserungAttrappe) => gesprochen.push(aeusserung.text),
2093 cancel: () => {
2094 abbrueche += 1;
2095 },
2096 getVoices: () => [],
2097 speaking: false,
2098 pending: false,
2099 paused: false,
2100 });
2101
2102 return { gesprochen, abbrueche: () => abbrueche };
2103 }
2104
2105 /** Sitzung mit eingeschalteter Sprachausgabe. */
2106 async function mitStimmeOeffnen(): Promise<{ nutzer: Nutzer; werk: Stimmwerk }> {
2107 const werk = sprachausgabeStellen();
2108 /* Zwei Fragen, damit es ein „danach" gibt – bei einer einzigen heißt der
2109 Knopf „Sitzung beenden", und die Ansicht wird verlassen statt
2110 gewechselt. Der Fehler steckt aber im Wechsel. */
2111 const nutzer = await lernsitzungOeffnen({
2112 lernSitzung: vi.fn(() =>
2113 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
2114 ),
2115 einstellungenLesen: vi.fn(() =>
2116 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
2117 ),
2118 });
2119 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2120 await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u }));
2121 return { nutzer, werk };
2122 }
2123
2124 it('bricht die laufende Frage ab, sobald die nächste Frage kommt', async () => {
2125 const { nutzer, werk } = await mitStimmeOeffnen();
2126
2127 await nutzer.click(await screen.findByRole('button', { name: /Frage vorlesen/u }));
2128 expect(werk.gesprochen.length).toBeGreaterThan(0);
2129
2130 /* Nach dem Bestätigen liest die Anwendung die Rückmeldung von selbst vor
2131 und bricht dafür ohnehin ab. Gemessen wird deshalb erst ab hier: Der
2132 Wechsel zur nächsten Frage spricht nichts Neues – wenn hier nicht
2133 abgebrochen wird, redet die alte Rückmeldung weiter. */
2134 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
2135 await screen.findByRole('heading', { name: 'Warum das so ist' });
2136 const vorDemWechsel = werk.abbrueche();
2137
2138 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
2139 await screen.findByRole('heading', { name: /Frage 2\.10/u });
2140
2141 expect(werk.abbrueche()).toBeGreaterThan(vorDemWechsel);
2142 });
2143
2144 it('lässt den Halt-Knopf nach dem Wechsel verschwinden', async () => {
2145 /* Die sichtbare Seite desselben Fehlers: Der Knopf „Vorlesen beenden“
2146 steht nur, solange etwas läuft. Blieb der Zustand hängen, bot die
2147 Oberfläche an, etwas zu beenden, das zur alten Frage gehörte. */
2148 const { nutzer } = await mitStimmeOeffnen();
2149
2150 await nutzer.click(await screen.findByRole('button', { name: /Frage vorlesen/u }));
2151 expect(screen.getByRole('button', { name: /Vorlesen beenden/u })).toBeInTheDocument();
2152
2153 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
2154 await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
2155
2156 expect(screen.queryByRole('button', { name: /Vorlesen beenden/u })).toBeNull();
2157 expect(screen.getByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument();
2158 });
2159 });
2160
2161 /*
2162 Anhalten und Fortsetzen.
2163
2164 Bis 0.22.0 kannte die Vorlesetaste nur Start und Beenden. Wer bei einer über
2165 eine Minute langen Erklärung kurz unterbrochen wurde, konnte nur abbrechen
2166 und musste von vorn beginnen.
2167
2168 Gebaut ist es **nicht** über `speechSynthesis.pause()` / `resume()`: Das ist
2169 in Chromium unter Windows unzuverlässig – die Ausgabe bleibt gelegentlich
2170 stumm stehen. Stattdessen wird abgebrochen und die Stelle in der
2171 Häppchen-Warteschlange gemerkt; „Weiterlesen“ reiht ab dort neu ein.
2172 */
2173 describe('Vorlesen anhalten und fortsetzen', () => {
2174 interface Stimmwerk {
2175 readonly gesprochen: string[];
2176 readonly starte: (text: string) => void;
2177 readonly tempi: number[];
2178 }
2179
2180 function sprachausgabeStellen(): Stimmwerk {
2181 const gesprochen: string[] = [];
2182 const tempi: number[] = [];
2183 const offene: { text: string; onstart: (() => void) | null }[] = [];
2184
2185 class AeusserungAttrappe {
2186 lang = '';
2187 rate = 1;
2188 voice: unknown = null;
2189 onstart: (() => void) | null = null;
2190 onend: (() => void) | null = null;
2191 onerror: ((ereignis: { error: string }) => void) | null = null;
2192 constructor(public readonly text: string) {}
2193 }
2194
2195 vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
2196 vi.stubGlobal('speechSynthesis', {
2197 speak: (aeusserung: AeusserungAttrappe) => {
2198 gesprochen.push(aeusserung.text);
2199 tempi.push(aeusserung.rate);
2200 offene.push({ text: aeusserung.text, onstart: aeusserung.onstart });
2201 },
2202 cancel: () => {
2203 offene.length = 0;
2204 },
2205 getVoices: () => [],
2206 speaking: false,
2207 pending: false,
2208 paused: false,
2209 });
2210
2211 /* Bildet nach, dass die Sprachausgabe ein bestimmtes Stück beginnt –
2212 daran hängt, wo „Weiterlesen“ ansetzt. */
2213 const starte = (text: string): void => {
2214 offene.find((eintrag) => eintrag.text === text)?.onstart?.();
2215 };
2216
2217 return { gesprochen, starte, tempi };
2218 }
2219
2220 async function mitStimmeOeffnen(
2221 einstellungen: Record<string, unknown> = {},
2222 ): Promise<{ nutzer: Nutzer; werk: Stimmwerk }> {
2223 const werk = sprachausgabeStellen();
2224 const nutzer = await lernsitzungOeffnen({
2225 lernSitzung: vi.fn(() =>
2226 Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]),
2227 ),
2228 einstellungenLesen: vi.fn(() =>
2229 Promise.resolve({
2230 ...EINSTELLUNGEN_STANDARD,
2231 zuschnittGefragt: true,
2232 vorlesen: true,
2233 ...einstellungen,
2234 }),
2235 ),
2236 });
2237 await screen.findByRole('heading', { name: /Frage 1\.87/u });
2238 return { nutzer, werk };
2239 }
2240
2241 it('bietet das Anhalten erst an, wenn etwas läuft', async () => {
2242 const { nutzer } = await mitStimmeOeffnen();
2243
2244 expect(screen.queryByRole('button', { name: /anhalten/u })).toBeNull();
2245
2246 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2247
2248 expect(screen.getByRole('button', { name: 'Vorlesen anhalten' })).toBeInTheDocument();
2249 });
2250
2251 it('setzt an der angehaltenen Stelle fort, statt von vorn zu beginnen', async () => {
2252 const { nutzer, werk } = await mitStimmeOeffnen();
2253
2254 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2255 const stuecke = [...werk.gesprochen];
2256 expect(stuecke.length).toBeGreaterThan(1);
2257
2258 /* Die Ausgabe ist beim zweiten Stück angekommen. */
2259 const zweites = stuecke[1] ?? '';
2260 werk.starte(zweites);
2261 await nutzer.click(screen.getByRole('button', { name: 'Vorlesen anhalten' }));
2262
2263 werk.gesprochen.length = 0;
2264 await nutzer.click(screen.getByRole('button', { name: 'Weiterlesen' }));
2265
2266 expect(werk.gesprochen[0]).toBe(zweites);
2267 expect(werk.gesprochen).not.toContain(stuecke[0]);
2268 });
2269
2270 it('gibt das eingestellte Sprechtempo an die Ausgabe weiter', async () => {
2271 /* `rate` wurde bis 0.22.0 nie gesetzt – das Tempo war nicht einstellbar. */
2272 const { nutzer, werk } = await mitStimmeOeffnen({ sprechtempo: 1.5 });
2273
2274 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2275
2276 expect(werk.tempi.length).toBeGreaterThan(0);
2277 expect(new Set(werk.tempi)).toEqual(new Set([1.5]));
2278 });
2279
2280 it('weist ein unmögliches Tempo ab, statt es durchzureichen', async () => {
2281 /* `rate` erlaubt 0,1 bis 10. Ein von Hand in die Datei geschriebener Wert
2282 machte die Funktion unbrauchbar – unverständlich langsam oder schnell. */
2283 const { nutzer, werk } = await mitStimmeOeffnen({ sprechtempo: 9 });
2284
2285 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
2286
2287 expect(new Set(werk.tempi)).toEqual(new Set([1]));
2288 });
2289 });
2290
2291 describe('Fehler zu dieser Frage melden', () => {
2292 /*
2293 Der kurze Weg aus der Sitzung heraus (D12). Wer mitten im Lernen einen
2294 Fehler im Katalog bemerkt, soll ihn melden koennen, ohne die Ansicht zu
2295 verlassen und die Fragennummer nachzuschlagen.
2296 */
2297 it('steht zugeklappt da und nennt seinen Zweck unmissverstaendlich', async () => {
2298 /* „Fehler melden" allein staende in der Rueckmeldung neben dem Wort
2299 „Falsch" und laese sich als *meinen* Fehler vermerken. */
2300 await lernsitzungOeffnen();
2301
2302 const aufklapper = await screen.findByText('Fehler zu dieser Frage melden');
2303 expect(aufklapper.closest('details')?.open).toBe(false);
2304 });
2305
2306 it('zeigt den Text, bevor er kopiert werden kann', async () => {
2307 /* Der Text verlaesst spaeter das Geraet. Wer ihn weitergibt, muss vorher
2308 lesen koennen, was drinsteht - anders ist die Zusage „nichts ueber Sie"
2309 nicht ueberpruefbar. */
2310 const nutzer = await lernsitzungOeffnen();
2311 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2312
2313 const feld = await screen.findByLabelText('Meldetext');
2314 await waitFor(() => {
2315 expect((feld as HTMLTextAreaElement).value).toContain('Kennung I.1-87');
2316 });
2317 /* Schreibgeschuetzt, aber nicht gesperrt: markieren und Strg+C muessen
2318 gehen, denn das ist der Weg ohne Zwischenablage-Kanal. */
2319 expect(feld).toHaveAttribute('readonly');
2320 expect(feld).not.toBeDisabled();
2321 });
2322
2323 it('kopiert genau das, was im Feld steht', async () => {
2324 const zwischenablageSchreiben = vi.fn((_text: string) => Promise.resolve(true));
2325 const nutzer = await lernsitzungOeffnen({ zwischenablageSchreiben });
2326
2327 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2328 const knopf = await screen.findByRole('button', { name: 'Text kopieren' });
2329 await nutzer.click(knopf);
2330
2331 await waitFor(() => {
2332 expect(zwischenablageSchreiben).toHaveBeenCalledTimes(1);
2333 });
2334 const uebergeben = zwischenablageSchreiben.mock.calls[0]![0];
2335 expect(screen.getByLabelText('Meldetext')).toHaveValue(uebergeben);
2336 });
2337
2338 it('sagt den Erfolg an', async () => {
2339 const nutzer = await lernsitzungOeffnen({
2340 zwischenablageSchreiben: vi.fn(() => Promise.resolve(true)),
2341 });
2342 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2343 await nutzer.click(await screen.findByRole('button', { name: 'Text kopieren' }));
2344
2345 expect(await screen.findByText(/liegt in der Zwischenablage/u)).toBeInTheDocument();
2346 });
2347
2348 it('nennt den Weg ueber die Tastatur, wenn das Kopieren scheitert', async () => {
2349 const nutzer = await lernsitzungOeffnen({
2350 zwischenablageSchreiben: vi.fn(() => Promise.resolve(false)),
2351 });
2352 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2353 await nutzer.click(await screen.findByRole('button', { name: 'Text kopieren' }));
2354
2355 expect(await screen.findByText(/Strg und C/u)).toBeInTheDocument();
2356 });
2357
2358 it('laesst den Knopf fort, wenn der Kanal fehlt - und nennt den Ersatzweg', async () => {
2359 /* Kein gesperrter Knopf: Gesperrte Bedienelemente nehmen keinen Fokus,
2360 ihre Erklaerung wuerde nie vorgelesen. Der Text steht ohnehin da. */
2361 const nutzer = await lernsitzungOeffnen();
2362 await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden'));
2363 await screen.findByLabelText('Meldetext');
2364
2365 expect(screen.queryByRole('button', { name: 'Text kopieren' })).toBeNull();
2366 expect(screen.getByText(/Markieren Sie den Text im Feld/u)).toBeInTheDocument();
2367 });
2368 });