waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests renderer-hilfe.test.tsx
| 1 | /** |
| 2 | * Verhaltenstests des Hilfefensters. |
| 3 | * |
| 4 | * Zwei Zusagen stehen hier auf dem Prüfstand: |
| 5 | * |
| 6 | * 1. **Erreichbarkeit** – WCAG 3.2.6 verlangt konsistente Hilfe: Sie steht in |
| 7 | * jeder Ansicht an derselben Stelle. Geprüft wird das quer durch alle |
| 8 | * Ansichten, einschließlich Lernsitzung und Prüfungslauf. |
| 9 | * 2. **Folgenlosigkeit** – die Hilfe ist ein Dialog und keine Ansicht. Wer sie |
| 10 | * mitten in einer Sitzung öffnet, findet danach dieselbe Frage vor. |
| 11 | * |
| 12 | * ## Was diese Tests NICHT prüfen können |
| 13 | * |
| 14 | * jsdom 27 kennt `<dialog>` als Element, aber weder `showModal()` noch |
| 15 | * `close()`. Der Dialog läuft hier über den Rückfallweg (das `open`-Attribut |
| 16 | * von Hand) und ist damit **nicht modal**: Es gibt keine Fokusfalle und nichts |
| 17 | * dahinter wird inert. Beides ist Sache des echten Fensters und wird in |
| 18 | * `e2e/hilfe.spec.ts` gemessen. Ein Test, der hier „modal“ behauptete, würde |
| 19 | * eine Eigenschaft bescheinigen, die die Umgebung gar nicht hat. |
| 20 | */ |
| 21 | |
| 22 | import { render, screen, waitFor, within } from '@testing-library/react'; |
| 23 | import userEvent from '@testing-library/user-event'; |
| 24 | import axe from 'axe-core'; |
| 25 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 26 | |
| 27 | import { App } from '../src/renderer/src/App'; |
| 28 | import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; |
| 29 | import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; |
| 30 | import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; |
| 31 | import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; |
| 32 | import type { Erklaerungen } from '../src/shared/erklaerungen'; |
| 33 | import type { Glossar } from '../src/shared/glossar'; |
| 34 | import { HILFE_KAPITEL, HILFE_TITEL } from '../src/shared/hilfe'; |
| 35 | import { |
| 36 | EINSTELLUNGEN_STANDARD, |
| 37 | type AnwendungsInfo, |
| 38 | type LernAppBridge, |
| 39 | type Menuebefehl, |
| 40 | } from '../src/shared/ipc'; |
| 41 | import type { Katalog, RichText } from '../src/shared/katalog'; |
| 42 | import type { Lernplan } from '../src/shared/lernplan'; |
| 43 | import type { FrageStand, Lernuebersicht, Profil } from '../src/shared/lernstand'; |
| 44 | import { EUPL_HINWEIS, EUPL_KENNUNG, KATALOG_HINWEIS } from '../src/shared/lizenzen'; |
| 45 | import type { Pruefungsergebnis } from '../src/shared/pruefung'; |
| 46 | import { jsdomOptionen } from '../src/shared/wcag'; |
| 47 | import { NORMTEXTE_LEER } from '../src/shared/normtexte'; |
| 48 | import { THEMEN_LEER } from '../src/shared/themen'; |
| 49 | |
| 50 | type Nutzer = ReturnType<typeof userEvent.setup>; |
| 51 | |
| 52 | // ─── Attrappen ────────────────────────────────────────────────────────── |
| 53 | |
| 54 | function nurText(text: string): RichText { |
| 55 | return { text, segmente: [{ t: text }] }; |
| 56 | } |
| 57 | |
| 58 | const FRAGE_TEXT = 'Welcher Gegenstand ist eine Anscheinswaffe?'; |
| 59 | |
| 60 | const KATALOG: Katalog = { |
| 61 | meta: { |
| 62 | titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', |
| 63 | herausgeber: 'Bundesverwaltungsamt', |
| 64 | stand: '2024-12-16', |
| 65 | quellenangabe: |
| 66 | 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.', |
| 67 | quelle_url: 'https://www.bva.bund.de/', |
| 68 | quelldatei_sha256: '7e5e41fa', |
| 69 | fragen_gesamt: 575, |
| 70 | }, |
| 71 | kapitel: [{ id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }], |
| 72 | bilder: [], |
| 73 | fragen: [ |
| 74 | { |
| 75 | id: 'I.1-87', |
| 76 | amtliche_nummer: '1.87', |
| 77 | kapitel: 'I', |
| 78 | abschnitt: 'I.1', |
| 79 | typ: 'mc', |
| 80 | seite: 23, |
| 81 | frage: nurText(FRAGE_TEXT), |
| 82 | bilder: [], |
| 83 | optionen: [ |
| 84 | { label: 'a', inhalt: nurText('Sportgewehr'), korrekt: false, bilder: [] }, |
| 85 | { label: 'b', inhalt: nurText('Spielzeugpistole'), korrekt: true, bilder: [] }, |
| 86 | ], |
| 87 | }, |
| 88 | ], |
| 89 | }; |
| 90 | |
| 91 | const PROFIL: Profil = { |
| 92 | id: 1, |
| 93 | name: 'Testprofil', |
| 94 | pruefungstermin: null, |
| 95 | kapitelAusschluss: [], |
| 96 | erstelltAm: '2026-01-01T08:00:00.000Z', |
| 97 | }; |
| 98 | |
| 99 | const UEBERSICHT: Lernuebersicht = { |
| 100 | fragenGesamt: 575, |
| 101 | beantwortet: 0, |
| 102 | belegt: 0, |
| 103 | reifegrad: 0 / 575, |
| 104 | stufe: 'ohne_beleg', |
| 105 | deckelnd: [], |
| 106 | faellig: 0, |
| 107 | gemerkt: 0, |
| 108 | offen: 0, |
| 109 | fehler: 0, |
| 110 | heuteRichtig: 0, |
| 111 | heuteFalsch: 0, |
| 112 | heuteBearbeitet: 0, |
| 113 | tageSeitLetzterAntwort: null, |
| 114 | bereiche: [], |
| 115 | themengruppen: [], |
| 116 | }; |
| 117 | |
| 118 | const STAND: FrageStand = { |
| 119 | frageId: 'I.1-87', |
| 120 | versuche: 0, |
| 121 | richtige: 0, |
| 122 | zuletztBeantwortet: null, |
| 123 | faelligAb: null, |
| 124 | gemerkt: false, |
| 125 | letzteBewertung: null, |
| 126 | }; |
| 127 | |
| 128 | const INFO: AnwendungsInfo = { |
| 129 | anwendungsVersion: '0.0.0-attrappe', |
| 130 | baukennung: 'abc1234', |
| 131 | baustand: '2026-08-22', |
| 132 | electronVersion: '43.4.1', |
| 133 | chromeVersion: '140.0.0.0', |
| 134 | nodeVersion: '22.19.1', |
| 135 | plattform: 'win32', |
| 136 | datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' }, |
| 137 | }; |
| 138 | |
| 139 | const ERGEBNIS: Pruefungsergebnis = { |
| 140 | profilId: 'standard', |
| 141 | gesamt: 0, |
| 142 | richtig: 0, |
| 143 | falsch: 0, |
| 144 | unbeantwortet: 0, |
| 145 | quote: 0, |
| 146 | urteil: 'nicht_bestanden', |
| 147 | begruendung: '0 Prozent richtig, nötig waren 80 Prozent.', |
| 148 | verletzteKriterien: [], |
| 149 | bereiche: [], |
| 150 | fehlerIds: [], |
| 151 | dauerMs: 0, |
| 152 | zeitAbgelaufen: false, |
| 153 | zeitpunkt: '2026-01-01T08:00:00.000Z', |
| 154 | }; |
| 155 | |
| 156 | const LERNPLAN: Lernplan = { |
| 157 | termin: null, |
| 158 | tageBisTermin: null, |
| 159 | gesamtFragen: 1, |
| 160 | nieBeantwortet: 1, |
| 161 | faellig: 0, |
| 162 | zielquote: 0.9, |
| 163 | prognoseHeute: 0, |
| 164 | prognoseAmTermin: null, |
| 165 | pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 }, |
| 166 | machbarkeit: 'kein_termin', |
| 167 | sekundenProFrage: 25, |
| 168 | }; |
| 169 | |
| 170 | const ERKLAERUNGEN: Erklaerungen = { |
| 171 | meta: { |
| 172 | version: 1, |
| 173 | stand: '2026-08-20', |
| 174 | gesetzesstand: { WaffG: 'Pruefstand' }, |
| 175 | hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.', |
| 176 | }, |
| 177 | zuFrage: {}, |
| 178 | }; |
| 179 | |
| 180 | const GLOSSAR: Glossar = { |
| 181 | meta: { |
| 182 | version: 1, |
| 183 | stand: '2026-08-21', |
| 184 | gesetzesstand: { WaffG: 'Pruefstand' }, |
| 185 | hinweis: 'Eigener redaktioneller Inhalt.', |
| 186 | }, |
| 187 | eintraege: [ |
| 188 | { |
| 189 | begriff: 'WaffG', |
| 190 | art: 'abkuerzung', |
| 191 | kurz: 'Waffengesetz.', |
| 192 | varianten: ['WaffG'], |
| 193 | fundstellen: [], |
| 194 | ohneFundstelleGrund: 'Die amtliche Kurzbezeichnung steht nicht im Gesetz selbst.', |
| 195 | }, |
| 196 | ], |
| 197 | }; |
| 198 | |
| 199 | function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge { |
| 200 | return { |
| 201 | anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), |
| 202 | anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)), |
| 203 | anzeigegroesseBeobachten: vi.fn(() => () => undefined), |
| 204 | hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)), |
| 205 | hilfsmittelBeobachten: vi.fn(() => () => undefined), |
| 206 | menuebefehlBeobachten: vi.fn(() => () => undefined), |
| 207 | /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: |
| 208 | Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ |
| 209 | einstellungenLesen: vi.fn(() => |
| 210 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), |
| 211 | ), |
| 212 | einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)), |
| 213 | katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), |
| 214 | katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')), |
| 215 | erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)), |
| 216 | glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)), |
| 217 | /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0. |
| 218 | Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext; |
| 219 | den prueft tests/normstelle.test.tsx an eigenen Faellen. */ |
| 220 | normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)), |
| 221 | themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)), |
| 222 | profilListe: vi.fn(() => Promise.resolve([PROFIL])), |
| 223 | profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)), |
| 224 | profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)), |
| 225 | profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])), |
| 226 | lernSitzung: vi.fn(() => |
| 227 | Promise.resolve([{ frageId: 'I.1-87', optionsReihenfolge: ['a', 'b'], gemerkt: false }]), |
| 228 | ), |
| 229 | lernAntworten: vi.fn(() => Promise.resolve(STAND)), |
| 230 | lernMerken: vi.fn(() => Promise.resolve(STAND)), |
| 231 | lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 232 | lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), |
| 233 | lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 234 | pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })), |
| 235 | pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)), |
| 236 | pruefungVerlauf: vi.fn(() => Promise.resolve([])), |
| 237 | pruefungSichern: vi.fn(() => Promise.resolve(true)), |
| 238 | pruefungOffen: vi.fn(() => Promise.resolve(null)), |
| 239 | pruefungVerwerfen: vi.fn(() => Promise.resolve()), |
| 240 | pruefungLaeuft: vi.fn(() => Promise.resolve()), |
| 241 | sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })), |
| 242 | fehlerprotokollDrucken: vi.fn(() => |
| 243 | Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }), |
| 244 | ), |
| 245 | fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })), |
| 246 | lernberichtDrucken: vi.fn(() => |
| 247 | Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }), |
| 248 | ), |
| 249 | /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt, |
| 250 | dass die Brücke den Kanal kennt. */ |
| 251 | datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })), |
| 252 | lizenzenLesen: vi.fn(() => |
| 253 | Promise.resolve({ |
| 254 | eigene: { |
| 255 | kennung: EUPL_KENNUNG, |
| 256 | name: 'European Union Public Licence, Version 1.2', |
| 257 | hinweis: EUPL_HINWEIS, |
| 258 | text: null, |
| 259 | }, |
| 260 | katalog: { |
| 261 | quellenangabe: KATALOG.meta.quellenangabe, |
| 262 | quelleUrl: KATALOG.meta.quelle_url, |
| 263 | hinweis: KATALOG_HINWEIS, |
| 264 | }, |
| 265 | dritte: [], |
| 266 | }), |
| 267 | ), |
| 268 | ...ueberschreibungen, |
| 269 | }; |
| 270 | } |
| 271 | |
| 272 | // ─── Hilfsgriffe ──────────────────────────────────────────────────────── |
| 273 | |
| 274 | async function anwendungOeffnen(bruecke: LernAppBridge = attrappe()): Promise<Nutzer> { |
| 275 | vi.stubGlobal('lernApp', bruecke); |
| 276 | const nutzer = userEvent.setup(); |
| 277 | render(<App />); |
| 278 | await screen.findByRole('button', { name: /Weiterlernen/u }); |
| 279 | return nutzer; |
| 280 | } |
| 281 | |
| 282 | /** Die Schaltfläche im Fußbereich – der Weg zur Hilfe in jeder Ansicht. */ |
| 283 | function hilfeknopf(): HTMLElement { |
| 284 | return screen.getByRole('button', { name: 'Hilfe' }); |
| 285 | } |
| 286 | |
| 287 | /** Der Dialog selbst; er trägt seinen Namen aus der Überschrift. */ |
| 288 | function hilfefenster(): HTMLElement { |
| 289 | return screen.getByRole('dialog', { name: HILFE_TITEL }); |
| 290 | } |
| 291 | |
| 292 | afterEach(() => { |
| 293 | vi.unstubAllGlobals(); |
| 294 | erklaerungenZwischenspeicherLeeren(); |
| 295 | glossarZwischenspeicherLeeren(); |
| 296 | normtexteZwischenspeicherLeeren(); |
| 297 | themenZwischenspeicherLeeren(); |
| 298 | }); |
| 299 | |
| 300 | // ─── Tests ────────────────────────────────────────────────────────────── |
| 301 | |
| 302 | describe('Hilfe – Erreichbarkeit (WCAG 3.2.6)', () => { |
| 303 | it('bietet den Weg zur Hilfe schon auf dem Startbildschirm an', async () => { |
| 304 | await anwendungOeffnen(); |
| 305 | expect(hilfeknopf()).toBeInTheDocument(); |
| 306 | }); |
| 307 | |
| 308 | it('nennt die Taste, mit der sie auch ohne die Schaltfläche aufgeht', async () => { |
| 309 | /* Der Menü-Accelerator F1 ist für Sehende im Menü sichtbar. Wer mit |
| 310 | einem Bildschirmleser arbeitet, erfährt ihn über aria-keyshortcuts. */ |
| 311 | await anwendungOeffnen(); |
| 312 | |
| 313 | expect(hilfeknopf()).toHaveAttribute('aria-keyshortcuts', 'F1'); |
| 314 | expect(hilfeknopf()).toHaveAttribute('aria-haspopup', 'dialog'); |
| 315 | }); |
| 316 | |
| 317 | it('steht in jeder Ansicht an derselben Stelle', async () => { |
| 318 | const nutzer = await anwendungOeffnen(); |
| 319 | |
| 320 | /* |
| 321 | Die Kernaussage von 3.2.6 ist die gleiche relative Reihenfolge. Der |
| 322 | Fußbereich ist die einzige Landmarke, die es in allen Ansichten gibt – |
| 323 | geprüft wird deshalb, dass die Hilfe dort steht und dort ihr erstes |
| 324 | Bedienelement ist. |
| 325 | */ |
| 326 | const wege: readonly (readonly [string, RegExp])[] = [ |
| 327 | ['Kapitelwahl', /Kapitel wählen/u], |
| 328 | ['Glossar', /Fachbegriffe nachschlagen/u], |
| 329 | ['Über diese Software', /Lizenzen und Herkunft anzeigen/u], |
| 330 | ['Prüfungssimulation', /Prüfungssimulation vorbereiten/u], |
| 331 | ]; |
| 332 | |
| 333 | for (const [name, knopfname] of wege) { |
| 334 | await nutzer.click(await screen.findByRole('button', { name: knopfname })); |
| 335 | |
| 336 | const fuss = screen.getByRole('contentinfo'); |
| 337 | const erstes = within(fuss).getAllByRole('button')[0]; |
| 338 | expect(erstes, `${name}: keine Hilfe im Fußbereich`).toHaveAccessibleName('Hilfe'); |
| 339 | |
| 340 | await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!); |
| 341 | } |
| 342 | }); |
| 343 | |
| 344 | it('steht auch in der laufenden Lernsitzung im Fußbereich', async () => { |
| 345 | const nutzer = await anwendungOeffnen(); |
| 346 | await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u })); |
| 347 | await screen.findByText(FRAGE_TEXT); |
| 348 | |
| 349 | const fuss = screen.getByRole('contentinfo'); |
| 350 | expect(within(fuss).getAllByRole('button')[0]).toHaveAccessibleName('Hilfe'); |
| 351 | }); |
| 352 | }); |
| 353 | |
| 354 | describe('Hilfe – Inhalt', () => { |
| 355 | it('zeigt alle Kapitel mit Überschrift', async () => { |
| 356 | const nutzer = await anwendungOeffnen(); |
| 357 | await nutzer.click(hilfeknopf()); |
| 358 | |
| 359 | const fenster = hilfefenster(); |
| 360 | for (const kapitel of HILFE_KAPITEL) { |
| 361 | expect( |
| 362 | within(fenster).getByRole('heading', { name: kapitel.titel, level: 3 }), |
| 363 | `Kapitel fehlt: ${kapitel.id}`, |
| 364 | ).toBeInTheDocument(); |
| 365 | } |
| 366 | }); |
| 367 | |
| 368 | it('führt ein Inhaltsverzeichnis, dessen Sprungziele es gibt', async () => { |
| 369 | const nutzer = await anwendungOeffnen(); |
| 370 | await nutzer.click(hilfeknopf()); |
| 371 | |
| 372 | const verzeichnis = screen.getByRole('navigation', { name: 'Kapitel dieser Hilfe' }); |
| 373 | const verweise = within(verzeichnis).getAllByRole('link'); |
| 374 | expect(verweise).toHaveLength(HILFE_KAPITEL.length); |
| 375 | |
| 376 | for (const verweis of verweise) { |
| 377 | const ziel = (verweis.getAttribute('href') ?? '').replace(/^#/u, ''); |
| 378 | /* Ein Verzeichniseintrag, der ins Leere zeigt, ist schlimmer als |
| 379 | keiner: Er sagt zu, dass es dort weitergeht. */ |
| 380 | expect(document.getElementById(ziel), `Sprungziel fehlt: ${ziel}`).not.toBeNull(); |
| 381 | } |
| 382 | }); |
| 383 | |
| 384 | it('öffnet von sich aus keine Verbindung nach außen', async () => { |
| 385 | /* Dieselbe Zusage wie in „Über diese Software“: kein mailto:, kein tel:, |
| 386 | kein https: – die Anwendung startet kein fremdes Programm. */ |
| 387 | const nutzer = await anwendungOeffnen(); |
| 388 | await nutzer.click(hilfeknopf()); |
| 389 | |
| 390 | for (const verweis of within(hilfefenster()).getAllByRole('link')) { |
| 391 | expect(verweis.getAttribute('href') ?? '').toMatch(/^#/u); |
| 392 | } |
| 393 | }); |
| 394 | }); |
| 395 | |
| 396 | describe('Hilfe – Öffnen und Schließen', () => { |
| 397 | /* |
| 398 | Bis Fassung 0.24.2 landete der Fokus hier auf dem Dialogtitel, weil der |
| 399 | Startbildschirm als einzige Ansicht kein zugeordnetes Kapitel hatte. |
| 400 | Seit 0.25.0 führt er nach „So kommen Sie durch“ — dem Kapitel, das genau |
| 401 | die Frage des Startbildschirms beantwortet. |
| 402 | |
| 403 | Die Zusage dahinter ist unverändert und die eigentliche: Beim Öffnen |
| 404 | steht der Fokus **im Dialog** und auf einer Überschrift, nie im |
| 405 | Hintergrund und nie auf `body`. Das Inhaltsverzeichnis bleibt erreichbar; |
| 406 | es steht über den Kapiteln (siehe `Hilfedialog.tsx`). |
| 407 | */ |
| 408 | it('setzt den Fokus beim Öffnen auf die Überschrift des zugeordneten Kapitels', async () => { |
| 409 | const nutzer = await anwendungOeffnen(); |
| 410 | await nutzer.click(hilfeknopf()); |
| 411 | |
| 412 | const kapitel = within(hilfefenster()).getByRole('heading', { |
| 413 | name: 'So kommen Sie durch', |
| 414 | level: 3, |
| 415 | }); |
| 416 | expect(kapitel).toHaveFocus(); |
| 417 | expect(hilfefenster().contains(document.activeElement)).toBe(true); |
| 418 | }); |
| 419 | |
| 420 | it('führt den Dialogtitel weiterhin als h2', async () => { |
| 421 | /* Der Titel bleibt die Überschrift des Dialogs, auch wenn der Fokus |
| 422 | tiefer steht – sonst hätte der Dialog keinen zugänglichen Namen. */ |
| 423 | const nutzer = await anwendungOeffnen(); |
| 424 | await nutzer.click(hilfeknopf()); |
| 425 | |
| 426 | expect( |
| 427 | within(hilfefenster()).getByRole('heading', { name: HILFE_TITEL, level: 2 }), |
| 428 | ).toBeInTheDocument(); |
| 429 | }); |
| 430 | |
| 431 | it('bleibt bei genau einer h1 im Dokument', async () => { |
| 432 | /* |
| 433 | Jede Ansicht führt genau eine h1, die sagt, wo man ist – ein |
| 434 | E2E-Test wacht darüber. Der Dialog ist keine Ansicht; eine zweite h1 |
| 435 | würde diese Zusage brechen. Deshalb h2, und deshalb dieser Test. |
| 436 | */ |
| 437 | const nutzer = await anwendungOeffnen(); |
| 438 | await nutzer.click(hilfeknopf()); |
| 439 | |
| 440 | expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); |
| 441 | }); |
| 442 | |
| 443 | it('schließt über den Knopf', async () => { |
| 444 | const nutzer = await anwendungOeffnen(); |
| 445 | await nutzer.click(hilfeknopf()); |
| 446 | |
| 447 | await nutzer.click(within(hilfefenster()).getAllByRole('button', { name: 'Schließen' })[0]!); |
| 448 | |
| 449 | await waitFor(() => { |
| 450 | expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); |
| 451 | }); |
| 452 | }); |
| 453 | |
| 454 | it('schließt mit Esc', async () => { |
| 455 | const nutzer = await anwendungOeffnen(); |
| 456 | await nutzer.click(hilfeknopf()); |
| 457 | |
| 458 | await nutzer.keyboard('{Escape}'); |
| 459 | |
| 460 | await waitFor(() => { |
| 461 | expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); |
| 462 | }); |
| 463 | }); |
| 464 | |
| 465 | it('räumt seine Überschriften weg, solange es zu ist', async () => { |
| 466 | /* Ein geschlossener Dialog ist unsichtbar, aber sein Inhalt stünde |
| 467 | trotzdem im Baum – und liefe jedem Überschriftentest der übrigen |
| 468 | Ansichten in die Quere. */ |
| 469 | await anwendungOeffnen(); |
| 470 | expect(screen.queryByRole('heading', { name: HILFE_TITEL })).not.toBeInTheDocument(); |
| 471 | }); |
| 472 | }); |
| 473 | |
| 474 | describe('Hilfe – nimmt nichts mit', () => { |
| 475 | it('lässt die laufende Lernsitzung stehen', async () => { |
| 476 | /* |
| 477 | Der eigentliche Grund für einen Dialog statt einer eigenen Ansicht: |
| 478 | Wer mitten in der Sitzung wissen will, wie das Merken geht, soll |
| 479 | danach dieselbe Frage vorfinden – nicht den Startbildschirm. |
| 480 | */ |
| 481 | const nutzer = await anwendungOeffnen(); |
| 482 | await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u })); |
| 483 | await screen.findByText(FRAGE_TEXT); |
| 484 | |
| 485 | await nutzer.click(hilfeknopf()); |
| 486 | expect(hilfefenster()).toBeInTheDocument(); |
| 487 | |
| 488 | await nutzer.keyboard('{Escape}'); |
| 489 | await waitFor(() => { |
| 490 | expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); |
| 491 | }); |
| 492 | |
| 493 | expect(screen.getByText(FRAGE_TEXT)).toBeInTheDocument(); |
| 494 | }); |
| 495 | }); |
| 496 | |
| 497 | describe('Hilfe – Menübefehl', () => { |
| 498 | it('geht auf, wenn der Anwendungskern „hilfe“ meldet', async () => { |
| 499 | /* Das ist der Weg, den F1 und der Menüeintrag „Handbuch“ nehmen: Die |
| 500 | Menüleiste liegt im Hauptprozess und erreicht die Ansicht nur so. */ |
| 501 | const kern: { melden?: (befehl: Menuebefehl) => void } = {}; |
| 502 | |
| 503 | await anwendungOeffnen( |
| 504 | attrappe({ |
| 505 | menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => { |
| 506 | kern.melden = rueckruf; |
| 507 | return () => undefined; |
| 508 | }), |
| 509 | }), |
| 510 | ); |
| 511 | |
| 512 | expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); |
| 513 | expect(kern.melden).toBeDefined(); |
| 514 | |
| 515 | kern.melden?.('hilfe'); |
| 516 | |
| 517 | expect(await screen.findByRole('dialog', { name: HILFE_TITEL })).toBeInTheDocument(); |
| 518 | }); |
| 519 | |
| 520 | it('kommt auch ohne diesen Kanal zurecht', async () => { |
| 521 | /* Der Anwendungskern entsteht parallel; fehlt der Kanal, bleibt die |
| 522 | Hilfe über die Schaltfläche erreichbar. */ |
| 523 | const bruecke = attrappe(); |
| 524 | Reflect.deleteProperty(bruecke, 'menuebefehlBeobachten'); |
| 525 | |
| 526 | const nutzer = await anwendungOeffnen(bruecke); |
| 527 | await nutzer.click(hilfeknopf()); |
| 528 | |
| 529 | expect(hilfefenster()).toBeInTheDocument(); |
| 530 | }); |
| 531 | }); |
| 532 | |
| 533 | describe('Hilfe – Barrierefreiheit', () => { |
| 534 | it('hat im geöffneten Zustand keine axe-Verstöße', async () => { |
| 535 | const nutzer = await anwendungOeffnen(); |
| 536 | await nutzer.click(hilfeknopf()); |
| 537 | |
| 538 | const ergebnis = await axe.run(document.body, jsdomOptionen()); |
| 539 | expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]); |
| 540 | }); |
| 541 | }); |
| 542 | |
| 543 | describe('Gehe zu – die Navigation im Anwendungsmenü', () => { |
| 544 | /** |
| 545 | * Öffnet die Anwendung und gibt den Draht zurück, über den der |
| 546 | * Hauptprozess Menübefehle meldet. |
| 547 | */ |
| 548 | async function mitMenue( |
| 549 | weitere: Partial<LernAppBridge> = {}, |
| 550 | ): Promise<{ melden: (befehl: Menuebefehl) => void }> { |
| 551 | const kern: { melden?: (befehl: Menuebefehl) => void } = {}; |
| 552 | await anwendungOeffnen( |
| 553 | attrappe({ |
| 554 | menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => { |
| 555 | kern.melden = rueckruf; |
| 556 | return () => undefined; |
| 557 | }), |
| 558 | ...weitere, |
| 559 | }), |
| 560 | ); |
| 561 | if (kern.melden === undefined) { |
| 562 | throw new Error('Der Kanal wurde nicht beobachtet.'); |
| 563 | } |
| 564 | return { melden: kern.melden }; |
| 565 | } |
| 566 | |
| 567 | /* |
| 568 | Bis Fassung 0.25.0 führte das Anwendungsmenü zu keiner einzigen der zehn |
| 569 | Ansichten. Es ist die einzige Fläche des Fensters, die nicht wegrollt – |
| 570 | und der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 hoch. Wer am |
| 571 | Sprungmenü der Seite vorbeigerollt war, hatte für die restlichen rund |
| 572 | 7500 Bildpunkte keinen Weg mehr in eine andere Ansicht. |
| 573 | */ |
| 574 | it('führt in jede der angebotenen Ansichten', async () => { |
| 575 | const { melden } = await mitMenue(); |
| 576 | |
| 577 | melden('gehe-zu-suche'); |
| 578 | expect( |
| 579 | await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }), |
| 580 | ).toBeInTheDocument(); |
| 581 | |
| 582 | melden('gehe-zu-glossar'); |
| 583 | expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument(); |
| 584 | |
| 585 | melden('gehe-zu-start'); |
| 586 | expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); |
| 587 | }); |
| 588 | |
| 589 | /* |
| 590 | Aus einer laufenden Sitzung führt kein Menüeintrag heraus. Der Grund |
| 591 | steht in `App.tsx` an der Stelle, an der die Zurückleiste dort ausgelassen |
| 592 | wird: „Dort ist der Ausstieg eine Entscheidung mit Folgen … und steht bei |
| 593 | den übrigen Aktionen, nicht als beiläufiger Pfeil am Rand.“ Ein |
| 594 | Menüeintrag ist derselbe beiläufige Weg, nur weiter oben. |
| 595 | |
| 596 | Ausgegraut werden die Einträge nicht – der Kanal ist einseitig, der |
| 597 | Hauptprozess erfährt vom Zustand der Oberfläche nichts. Statt |
| 598 | stillschweigend nichts zu tun, sagt die Anwendung, warum sie nicht folgt. |
| 599 | */ |
| 600 | it('verlässt eine laufende Lernsitzung nicht und sagt, warum', async () => { |
| 601 | const { melden } = await mitMenue(); |
| 602 | |
| 603 | const nutzer = userEvent.setup(); |
| 604 | const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u }); |
| 605 | /* Der Einstieg ist nie `disabled`, sondern meldet sich über |
| 606 | `aria-disabled` (Projektregel, docs/stand.md 7.13). */ |
| 607 | await waitFor(() => { |
| 608 | expect(einstieg).toHaveAttribute('aria-disabled', 'false'); |
| 609 | }); |
| 610 | await nutzer.click(einstieg); |
| 611 | /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift |
| 612 | ist die Fragennummer und hängt am Katalog der Attrappe. */ |
| 613 | await screen.findByRole('button', { name: 'Sitzung beenden' }); |
| 614 | |
| 615 | melden('gehe-zu-glossar'); |
| 616 | |
| 617 | /* Die Sitzung steht noch. */ |
| 618 | expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument(); |
| 619 | expect(await screen.findByText(/Beenden Sie zuerst die Lernsitzung/u)).toBeInTheDocument(); |
| 620 | }); |
| 621 | |
| 622 | it('führt ohne Fragenkatalog nicht in eine leere Ansicht', async () => { |
| 623 | /* |
| 624 | Befund der Prüfrunde zu 0.27.2. Drei Ansichten hängen an |
| 625 | `katalogIndex !== null` und rendern ohne ihn gar nichts; zugleich |
| 626 | entfällt in jeder Ansicht außer dem Startbildschirm der Seitenkopf. Über |
| 627 | das Menü – seit 0.25.0 der zweite vollwertige Navigationsweg – landete |
| 628 | man damit in einem leeren Hauptbereich: kein `h1` im ganzen Dokument, |
| 629 | kein Fokusanker, keine Auskunft. Der Startbildschirm sperrt seine Knöpfe |
| 630 | genau dafür und begründet die Sperre am Knopf. |
| 631 | */ |
| 632 | const { melden } = await mitMenue({ |
| 633 | katalogLaden: vi.fn(() => Promise.reject(new Error('Katalog kaputt'))), |
| 634 | }); |
| 635 | |
| 636 | melden('gehe-zu-suche'); |
| 637 | |
| 638 | await screen.findByText(/Der Fragenkatalog ist nicht geladen/u); |
| 639 | |
| 640 | expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); |
| 641 | expect(document.body).not.toHaveFocus(); |
| 642 | }); |
| 643 | |
| 644 | it('spricht die Farbschema-Ansage nicht ein zweites Mal', async () => { |
| 645 | /* |
| 646 | Befund der Prüfrunde zu 0.27.2. Die Live-Region der Anwendung hatte zwei |
| 647 | Quellen und einen Rückfall: `menueansage === "" ? ansage : menueansage`. |
| 648 | Die Ansage des Farbschemas wird aber nie zurückgenommen — sie steht |
| 649 | nach der ersten Umschaltung für immer da. Sobald ein Menübefehl seine |
| 650 | Meldung wieder leerte, wechselte der Inhalt der Region von der |
| 651 | Menümeldung zurück auf „Dunkles Farbschema …“, und ein |
| 652 | Bildschirmleser sprach sie ein zweites Mal, ohne dass jemand etwas am |
| 653 | Farbschema getan hätte. |
| 654 | */ |
| 655 | const { melden } = await mitMenue(); |
| 656 | const nutzer = userEvent.setup(); |
| 657 | |
| 658 | await nutzer.click(await screen.findByRole('radio', { name: /Dunkel/u })); |
| 659 | |
| 660 | /* Die Live-Region der Anwendung – die eine, in der beide Quellen landen. */ |
| 661 | const region = await waitFor(() => { |
| 662 | const treffer = screen |
| 663 | .getAllByRole('status') |
| 664 | .find((knoten) => knoten.textContent.includes('Darstellung umgestellt')); |
| 665 | expect(treffer).toBeDefined(); |
| 666 | return treffer!; |
| 667 | }); |
| 668 | |
| 669 | /* Ein Menübefehl, der nicht ausgeführt wird: Die Region trägt seine |
| 670 | Begründung. */ |
| 671 | const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u }); |
| 672 | await waitFor(() => { |
| 673 | expect(einstieg).toHaveAttribute('aria-disabled', 'false'); |
| 674 | }); |
| 675 | await nutzer.click(einstieg); |
| 676 | await screen.findByRole('button', { name: 'Sitzung beenden' }); |
| 677 | melden('gehe-zu-glossar'); |
| 678 | await screen.findByText(/Beenden Sie zuerst die Lernsitzung/u); |
| 679 | |
| 680 | /* Und einer, der ausgeführt wird: Danach steht dort nichts mehr — und |
| 681 | ganz sicher nicht wieder die Ansage von vorhin. */ |
| 682 | await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' })); |
| 683 | await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!); |
| 684 | await screen.findByRole('heading', { name: 'Heute lernen' }); |
| 685 | melden('gehe-zu-glossar'); |
| 686 | await screen.findByRole('heading', { name: 'Glossar', level: 1 }); |
| 687 | |
| 688 | expect(region.textContent).toBe(''); |
| 689 | }); |
| 690 | |
| 691 | it('sperrt auch die Auswertung und gibt den Weg erst auf dem Startbildschirm frei', async () => { |
| 692 | /* |
| 693 | Zwei Zusagen in einem Lauf. |
| 694 | |
| 695 | Erstens: Die Auswertung ist ebenso gesperrt wie die Sitzung. Ihr Inhalt |
| 696 | steht im Ansichtsobjekt (`bilanz` in `lernen/typen.ts`), und das Menü |
| 697 | kennt kein Ziel, das ihn wiederherstellte — ein Sprung verwürfe die |
| 698 | Fehlerliste ohne Rückfrage. Diese Sperre fehlte in der ersten Fassung |
| 699 | des Menüs und war damit ein Fehler von 0.26.0. |
| 700 | |
| 701 | Zweitens die Gegenprobe: Die Sperre darf nicht hängenbleiben. Vom |
| 702 | Startbildschirm aus nimmt der Weg wieder an. |
| 703 | */ |
| 704 | const { melden } = await mitMenue(); |
| 705 | |
| 706 | const nutzer = userEvent.setup(); |
| 707 | const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u }); |
| 708 | /* Der Einstieg ist nie `disabled`, sondern meldet sich über |
| 709 | `aria-disabled` (Projektregel, docs/stand.md 7.13). */ |
| 710 | await waitFor(() => { |
| 711 | expect(einstieg).toHaveAttribute('aria-disabled', 'false'); |
| 712 | }); |
| 713 | await nutzer.click(einstieg); |
| 714 | /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift |
| 715 | ist die Fragennummer und hängt am Katalog der Attrappe. */ |
| 716 | await screen.findByRole('button', { name: 'Sitzung beenden' }); |
| 717 | await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' })); |
| 718 | |
| 719 | /* In der Auswertung: gesperrt. */ |
| 720 | melden('gehe-zu-glossar'); |
| 721 | expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument(); |
| 722 | expect( |
| 723 | await screen.findByText(/Diese Auswertung lässt sich nicht wieder aufrufen/u), |
| 724 | ).toBeInTheDocument(); |
| 725 | |
| 726 | /* Auf dem Startbildschirm: wieder frei. */ |
| 727 | await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!); |
| 728 | await screen.findByRole('heading', { name: 'Heute lernen' }); |
| 729 | |
| 730 | melden('gehe-zu-glossar'); |
| 731 | expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument(); |
| 732 | }); |
| 733 | }); |