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