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