waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests renderer-lernmodus.test.tsx
| 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 | }); |