waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests renderer-profile.test.tsx
| 1 | /** |
| 2 | * Verhaltenstests der Lernprofile. |
| 3 | * |
| 4 | * Schwerpunkt: Ein Profil bleibt unsichtbar, mehrere werden gefragt – und |
| 5 | * nichts wechselt, ohne dass jemand es verlangt hat. Der Anwendungskern |
| 6 | * konnte Profile von Anfang an; geprüft wird hier der Weg dorthin. |
| 7 | */ |
| 8 | |
| 9 | import { render, screen, waitFor, within } from '@testing-library/react'; |
| 10 | import userEvent from '@testing-library/user-event'; |
| 11 | import axe from 'axe-core'; |
| 12 | import { jsdomOptionen } from '../src/shared/wcag'; |
| 13 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 14 | |
| 15 | import { App } from '../src/renderer/src/App'; |
| 16 | import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; |
| 17 | import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; |
| 18 | import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; |
| 19 | import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; |
| 20 | import type { Frage, Katalog, RichText } from '../src/shared/katalog'; |
| 21 | import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc'; |
| 22 | import type { Erklaerungen } from '../src/shared/erklaerungen'; |
| 23 | import type { Glossar } from '../src/shared/glossar'; |
| 24 | import type { Lernplan } from '../src/shared/lernplan'; |
| 25 | import type { Lernuebersicht, Profil } from '../src/shared/lernstand'; |
| 26 | import { NORMTEXTE_LEER } from '../src/shared/normtexte'; |
| 27 | import { THEMEN_LEER } from '../src/shared/themen'; |
| 28 | |
| 29 | type TeilBridge = { -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] }; |
| 30 | |
| 31 | function nurText(text: string): RichText { |
| 32 | return { text, segmente: [{ t: text }] }; |
| 33 | } |
| 34 | |
| 35 | const FRAGE: Frage = { |
| 36 | id: 'I.1-01', |
| 37 | amtliche_nummer: '1.01', |
| 38 | kapitel: 'I', |
| 39 | abschnitt: 'I.1', |
| 40 | typ: 'mc', |
| 41 | seite: 6, |
| 42 | frage: nurText('Was regelt das Waffengesetz?'), |
| 43 | bilder: [], |
| 44 | optionen: [ |
| 45 | { label: 'a', inhalt: nurText('Den Umgang mit Waffen.'), korrekt: true, bilder: [] }, |
| 46 | { label: 'b', inhalt: nurText('Den Handel mit Autos.'), korrekt: false, bilder: [] }, |
| 47 | ], |
| 48 | }; |
| 49 | |
| 50 | const KATALOG: Katalog = { |
| 51 | meta: { |
| 52 | titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', |
| 53 | herausgeber: 'Bundesverwaltungsamt', |
| 54 | stand: '2024-12-16', |
| 55 | quellenangabe: 'Amtlicher Fragenkatalog des Bundesverwaltungsamtes, Stand 16.12.2024.', |
| 56 | quelle_url: 'https://www.bva.bund.de/', |
| 57 | quelldatei_sha256: '7e5e41fa', |
| 58 | fragen_gesamt: 1, |
| 59 | }, |
| 60 | kapitel: [ |
| 61 | { |
| 62 | id: 'I', |
| 63 | titel: 'Waffenrecht und sonstige Rechtsvorschriften', |
| 64 | abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }], |
| 65 | }, |
| 66 | ], |
| 67 | bilder: [], |
| 68 | fragen: [FRAGE], |
| 69 | }; |
| 70 | |
| 71 | function profil(id: number, name: string): Profil { |
| 72 | return { |
| 73 | id, |
| 74 | name, |
| 75 | pruefungstermin: null, |
| 76 | kapitelAusschluss: [], |
| 77 | erstelltAm: '2026-01-01T08:00:00.000Z', |
| 78 | }; |
| 79 | } |
| 80 | |
| 81 | const OLAF = profil(1, 'Olaf'); |
| 82 | const ANNA = profil(2, 'Anna'); |
| 83 | |
| 84 | const UEBERSICHT: Lernuebersicht = { |
| 85 | fragenGesamt: 575, |
| 86 | beantwortet: 120, |
| 87 | belegt: 48, |
| 88 | reifegrad: 48 / 575, |
| 89 | stufe: 'zurueck', |
| 90 | deckelnd: [], |
| 91 | faellig: 12, |
| 92 | gemerkt: 5, |
| 93 | offen: 0, |
| 94 | fehler: 0, |
| 95 | heuteRichtig: 8, |
| 96 | heuteFalsch: 2, |
| 97 | heuteBearbeitet: 0, |
| 98 | tageSeitLetzterAntwort: null, |
| 99 | bereiche: [], |
| 100 | }; |
| 101 | |
| 102 | const LERNPLAN: Lernplan = { |
| 103 | termin: null, |
| 104 | tageBisTermin: null, |
| 105 | gesamtFragen: 8, |
| 106 | nieBeantwortet: 6, |
| 107 | faellig: 1, |
| 108 | zielquote: 0.9, |
| 109 | prognoseHeute: 0.25, |
| 110 | prognoseAmTermin: null, |
| 111 | pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 }, |
| 112 | machbarkeit: 'kein_termin', |
| 113 | sekundenProFrage: 25, |
| 114 | }; |
| 115 | |
| 116 | const INFO: AnwendungsInfo = { |
| 117 | anwendungsVersion: '0.0.0-attrappe', |
| 118 | baukennung: 'abc1234', |
| 119 | baustand: '2026-08-21', |
| 120 | electronVersion: '43.4.1', |
| 121 | chromeVersion: '140.0.0.0', |
| 122 | nodeVersion: '22.19.1', |
| 123 | plattform: 'win32', |
| 124 | datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' }, |
| 125 | }; |
| 126 | |
| 127 | const ERKLAERUNGEN: Erklaerungen = { |
| 128 | meta: { |
| 129 | version: 1, |
| 130 | stand: '2026-08-20', |
| 131 | gesetzesstand: { WaffG: 'Pruefstand' }, |
| 132 | hinweis: 'Eigener redaktioneller Inhalt.', |
| 133 | }, |
| 134 | zuFrage: {}, |
| 135 | }; |
| 136 | |
| 137 | const GLOSSAR: Glossar = { |
| 138 | meta: { |
| 139 | version: 1, |
| 140 | stand: '2026-08-21', |
| 141 | gesetzesstand: { WaffG: 'Pruefstand' }, |
| 142 | hinweis: 'Eigener redaktioneller Inhalt.', |
| 143 | }, |
| 144 | eintraege: [], |
| 145 | }; |
| 146 | |
| 147 | function attrappe(ueberschreibungen: TeilBridge = {}): TeilBridge { |
| 148 | return { |
| 149 | anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), |
| 150 | /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: |
| 151 | Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ |
| 152 | einstellungenLesen: vi.fn(() => |
| 153 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), |
| 154 | ), |
| 155 | einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)), |
| 156 | katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), |
| 157 | katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')), |
| 158 | erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)), |
| 159 | glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)), |
| 160 | /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0. |
| 161 | Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext; |
| 162 | den prueft tests/normstelle.test.tsx an eigenen Faellen. */ |
| 163 | normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)), |
| 164 | themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)), |
| 165 | profilListe: vi.fn(() => Promise.resolve([OLAF, ANNA])), |
| 166 | profilAnlegen: vi.fn(() => Promise.resolve(profil(3, 'Neu'))), |
| 167 | profilAktualisieren: vi.fn(() => Promise.resolve(OLAF)), |
| 168 | profilLoeschen: vi.fn(() => Promise.resolve([OLAF])), |
| 169 | lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 170 | lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), |
| 171 | lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 172 | ...ueberschreibungen, |
| 173 | }; |
| 174 | } |
| 175 | |
| 176 | function starten(bruecke: TeilBridge = attrappe()): ReturnType<typeof userEvent.setup> { |
| 177 | vi.stubGlobal('lernApp', bruecke); |
| 178 | const nutzer = userEvent.setup(); |
| 179 | render(<App />); |
| 180 | return nutzer; |
| 181 | } |
| 182 | |
| 183 | /** Beantwortet die Startfrage, damit der Startbildschirm sichtbar wird. */ |
| 184 | async function durchsTor(nutzer: ReturnType<typeof userEvent.setup>): Promise<void> { |
| 185 | await nutzer.click(await screen.findByRole('button', { name: 'Weiter' })); |
| 186 | await screen.findByRole('heading', { name: 'Heute lernen' }); |
| 187 | } |
| 188 | |
| 189 | /** Öffnet den Abschnitt „Profile“ und liefert seine Liste. */ |
| 190 | async function profilliste(): Promise<HTMLElement> { |
| 191 | await screen.findByRole('heading', { name: 'Profile' }); |
| 192 | const listen = screen.getAllByRole('list'); |
| 193 | const treffer = listen.find((liste) => liste.classList.contains('profile__liste')); |
| 194 | if (treffer === undefined) { |
| 195 | throw new Error('Die Profilliste steht nicht im Dokument.'); |
| 196 | } |
| 197 | return treffer; |
| 198 | } |
| 199 | |
| 200 | afterEach(() => { |
| 201 | vi.unstubAllGlobals(); |
| 202 | erklaerungenZwischenspeicherLeeren(); |
| 203 | glossarZwischenspeicherLeeren(); |
| 204 | normtexteZwischenspeicherLeeren(); |
| 205 | themenZwischenspeicherLeeren(); |
| 206 | }); |
| 207 | |
| 208 | describe('Startfrage „Wer lernt?“', () => { |
| 209 | it('bleibt aus, wenn es nur ein Profil gibt', async () => { |
| 210 | starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) })); |
| 211 | |
| 212 | /* Wer allein an seinem Rechner lernt, soll keinen Anmeldebildschirm |
| 213 | wegklicken müssen. */ |
| 214 | expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); |
| 215 | expect(screen.queryByRole('heading', { name: 'Wer lernt?' })).not.toBeInTheDocument(); |
| 216 | }); |
| 217 | |
| 218 | it('nennt bei einem Profil auch nicht, wessen Lernstand da steht', async () => { |
| 219 | starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) })); |
| 220 | |
| 221 | await screen.findByRole('heading', { name: 'Heute lernen' }); |
| 222 | expect(screen.queryByText(/Lernstand von/)).not.toBeInTheDocument(); |
| 223 | }); |
| 224 | |
| 225 | it('fragt ab dem zweiten Profil und wählt das zuletzt benutzte vor', async () => { |
| 226 | starten( |
| 227 | attrappe({ |
| 228 | einstellungenLesen: vi.fn(() => |
| 229 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, profilId: 2 }), |
| 230 | ), |
| 231 | }), |
| 232 | ); |
| 233 | |
| 234 | expect(await screen.findByRole('heading', { name: 'Wer lernt?' })).toBeInTheDocument(); |
| 235 | expect(screen.getByRole('radio', { name: 'Anna' })).toBeChecked(); |
| 236 | expect(screen.getByRole('radio', { name: 'Olaf' })).not.toBeChecked(); |
| 237 | // Der Startbildschirm ist so lange nicht da. |
| 238 | expect(screen.queryByRole('heading', { name: 'Heute lernen' })).not.toBeInTheDocument(); |
| 239 | }); |
| 240 | |
| 241 | it('übergeht ein gespeichertes Profil, das es nicht mehr gibt', async () => { |
| 242 | starten( |
| 243 | attrappe({ |
| 244 | einstellungenLesen: vi.fn(() => |
| 245 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, profilId: 99 }), |
| 246 | ), |
| 247 | }), |
| 248 | ); |
| 249 | |
| 250 | /* Sonst stünde die Anwendung nach einem gelöschten Profil ohne |
| 251 | Lernstand da. */ |
| 252 | expect(await screen.findByRole('radio', { name: 'Olaf' })).toBeChecked(); |
| 253 | }); |
| 254 | |
| 255 | it('wechselt nicht beim Auswählen, sondern erst beim Weitergehen', async () => { |
| 256 | const uebersicht = vi.fn(() => Promise.resolve(UEBERSICHT)); |
| 257 | const nutzer = starten(attrappe({ lernUebersicht: uebersicht })); |
| 258 | |
| 259 | await screen.findByRole('heading', { name: 'Wer lernt?' }); |
| 260 | uebersicht.mockClear(); |
| 261 | |
| 262 | await nutzer.click(screen.getByRole('radio', { name: 'Anna' })); |
| 263 | /* WCAG 3.2.2: Eine Auswahl allein darf den Zusammenhang nicht |
| 264 | verändern. Vorher wurde nichts nachgeladen. */ |
| 265 | expect(uebersicht).not.toHaveBeenCalled(); |
| 266 | |
| 267 | await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); |
| 268 | await waitFor(() => { |
| 269 | expect(uebersicht).toHaveBeenCalledWith(2); |
| 270 | }); |
| 271 | }); |
| 272 | |
| 273 | it('merkt sich die Wahl für den nächsten Start', async () => { |
| 274 | const schreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)); |
| 275 | const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben })); |
| 276 | |
| 277 | await screen.findByRole('heading', { name: 'Wer lernt?' }); |
| 278 | await nutzer.click(screen.getByRole('radio', { name: 'Anna' })); |
| 279 | await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); |
| 280 | |
| 281 | await waitFor(() => { |
| 282 | expect(schreiben).toHaveBeenCalledWith({ profilId: 2 }); |
| 283 | }); |
| 284 | }); |
| 285 | |
| 286 | it('zeigt danach den Startbildschirm und nennt, wessen Lernstand er zeigt', async () => { |
| 287 | const nutzer = starten(); |
| 288 | |
| 289 | await screen.findByRole('heading', { name: 'Wer lernt?' }); |
| 290 | await nutzer.click(screen.getByRole('radio', { name: 'Anna' })); |
| 291 | await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); |
| 292 | |
| 293 | const ueberschrift = await screen.findByRole('heading', { name: 'Heute lernen' }); |
| 294 | expect(ueberschrift).toBeInTheDocument(); |
| 295 | // Der Fokus wandert mit – sonst stünde man wieder ganz oben. |
| 296 | expect(ueberschrift).toHaveFocus(); |
| 297 | |
| 298 | const einstieg = screen.getByRole('region', { name: 'Heute lernen' }); |
| 299 | expect(within(einstieg).getByText('Anna')).toBeInTheDocument(); |
| 300 | }); |
| 301 | |
| 302 | it('ist frei von automatisch prüfbaren Verstößen', async () => { |
| 303 | starten(); |
| 304 | await screen.findByRole('heading', { name: 'Wer lernt?' }); |
| 305 | |
| 306 | const ergebnis = await axe.run(document.body, jsdomOptionen()); |
| 307 | expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]); |
| 308 | }); |
| 309 | }); |
| 310 | |
| 311 | describe('Erststart-Frage nach dem abwählbaren Kapitel', () => { |
| 312 | /* |
| 313 | Der Schalter dafür liegt unter „Ihr Lernplan“ – und wer nie |
| 314 | hinunterscrollt, schleppt 89 von 575 Fragen mit, ein knappes Sechstel des |
| 315 | Katalogs. Die Frage holt ihn dorthin, wo er wirkt. |
| 316 | |
| 317 | PLAN.md sagt drei Zielprofile zu (Sportschütze, Jäger, Wassersport). |
| 318 | Nachgezählt trägt der Katalog das nicht: Die Fragen zum Wassersport liegen |
| 319 | vollständig in Kapitel IV, die zu Jagd und Sportschießen verteilen sich |
| 320 | auf drei bzw. vier Bereiche und sind Pflichtstoff. Abtrennbar ist genau |
| 321 | ein Teil – deshalb eine Frage statt einer Profilwahl. |
| 322 | */ |
| 323 | const frisch = (ueberschreibungen: Partial<LernAppBridge> = {}) => |
| 324 | attrappe({ |
| 325 | profilListe: vi.fn(() => Promise.resolve([OLAF])), |
| 326 | einstellungenLesen: vi.fn(() => |
| 327 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: false }), |
| 328 | ), |
| 329 | lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, beantwortet: 0 })), |
| 330 | ...ueberschreibungen, |
| 331 | }); |
| 332 | |
| 333 | it('fragt beim allerersten Start, bevor der Startbildschirm kommt', async () => { |
| 334 | starten(frisch()); |
| 335 | |
| 336 | expect( |
| 337 | await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }), |
| 338 | ).toBeInTheDocument(); |
| 339 | expect(screen.queryByRole('heading', { name: 'Heute lernen' })).not.toBeInTheDocument(); |
| 340 | }); |
| 341 | |
| 342 | it('hat „mitlernen“ vorausgewählt – die vorsichtige Vorgabe', async () => { |
| 343 | starten(frisch()); |
| 344 | |
| 345 | await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); |
| 346 | expect(screen.getByRole('radio', { name: /Ja, mitlernen/u })).toBeChecked(); |
| 347 | }); |
| 348 | |
| 349 | it('schaltet bei „mitlernen“ nur weiter und wählt nichts ab', async () => { |
| 350 | const profilAktualisieren = vi.fn(() => Promise.resolve(OLAF)); |
| 351 | const nutzer = starten(frisch({ profilAktualisieren })); |
| 352 | |
| 353 | await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); |
| 354 | await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); |
| 355 | |
| 356 | expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); |
| 357 | expect(profilAktualisieren).not.toHaveBeenCalled(); |
| 358 | }); |
| 359 | |
| 360 | it('wählt bei „weglassen“ das Kapitel dauerhaft ab', async () => { |
| 361 | const profilAktualisieren = vi.fn(() => Promise.resolve(OLAF)); |
| 362 | const nutzer = starten(frisch({ profilAktualisieren })); |
| 363 | |
| 364 | await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); |
| 365 | await nutzer.click(screen.getByRole('radio', { name: /Nein, weglassen/u })); |
| 366 | await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); |
| 367 | |
| 368 | await waitFor(() => { |
| 369 | expect(profilAktualisieren).toHaveBeenCalledWith( |
| 370 | 1, |
| 371 | expect.objectContaining({ kapitelAusschluss: ['IV'] }), |
| 372 | ); |
| 373 | }); |
| 374 | }); |
| 375 | |
| 376 | it('merkt sich, dass gefragt wurde – sonst käme sie beim nächsten Start wieder', async () => { |
| 377 | const einstellungenSchreiben = vi.fn(() => |
| 378 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), |
| 379 | ); |
| 380 | const nutzer = starten(frisch({ einstellungenSchreiben })); |
| 381 | |
| 382 | await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); |
| 383 | await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); |
| 384 | |
| 385 | expect(einstellungenSchreiben).toHaveBeenCalledWith( |
| 386 | expect.objectContaining({ zuschnittGefragt: true }), |
| 387 | ); |
| 388 | }); |
| 389 | |
| 390 | it('bleibt aus, wenn schon gelernt wurde', async () => { |
| 391 | /* Wer schon gelernt hat, bekommt sie nicht mehr vorgesetzt – für ihn ist |
| 392 | es kein erster Start, und der Schalter steht unter „Ihr Lernplan“. */ |
| 393 | starten( |
| 394 | frisch({ lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, beantwortet: 3 })) }), |
| 395 | ); |
| 396 | |
| 397 | expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); |
| 398 | expect(screen.queryByRole('heading', { name: /Lernen Sie .* mit\?/u })).not.toBeInTheDocument(); |
| 399 | }); |
| 400 | |
| 401 | it('kommt nach „Wer lernt?“, nicht davor', async () => { |
| 402 | /* Erst wer, dann was: Die Kapitelwahl gehört zum Lernprofil. */ |
| 403 | const nutzer = starten(frisch({ profilListe: vi.fn(() => Promise.resolve([OLAF, ANNA])) })); |
| 404 | |
| 405 | await screen.findByRole('heading', { name: 'Wer lernt?' }); |
| 406 | expect(screen.queryByRole('heading', { name: /Lernen Sie .* mit\?/u })).not.toBeInTheDocument(); |
| 407 | |
| 408 | await nutzer.click(screen.getByRole('button', { name: 'Weiter' })); |
| 409 | |
| 410 | expect( |
| 411 | await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }), |
| 412 | ).toBeInTheDocument(); |
| 413 | }); |
| 414 | }); |
| 415 | |
| 416 | describe('Profile verwalten', () => { |
| 417 | it('führt alle Profile auf und kennzeichnet das laufende', async () => { |
| 418 | const nutzer = starten(); |
| 419 | await durchsTor(nutzer); |
| 420 | |
| 421 | const liste = await profilliste(); |
| 422 | const zeilen = within(liste).getAllByRole('listitem'); |
| 423 | expect(zeilen).toHaveLength(2); |
| 424 | expect(within(zeilen[0]!).getByText('– lernt gerade')).toBeInTheDocument(); |
| 425 | }); |
| 426 | |
| 427 | it('lässt das letzte Profil nicht löschen', async () => { |
| 428 | starten(attrappe({ profilListe: vi.fn(() => Promise.resolve([OLAF])) })); |
| 429 | await screen.findByRole('heading', { name: 'Heute lernen' }); |
| 430 | |
| 431 | const liste = await profilliste(); |
| 432 | expect(within(liste).queryByRole('button', { name: /^Löschen/ })).not.toBeInTheDocument(); |
| 433 | expect(screen.getByText(/Das letzte Profil lässt sich nicht löschen/)).toBeInTheDocument(); |
| 434 | }); |
| 435 | |
| 436 | it('legt ein neues Profil an', async () => { |
| 437 | const anlegen = vi.fn(() => Promise.resolve(profil(3, 'Bernd'))); |
| 438 | const nutzer = starten(attrappe({ profilAnlegen: anlegen })); |
| 439 | await durchsTor(nutzer); |
| 440 | |
| 441 | await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); |
| 442 | await nutzer.type(screen.getByLabelText('Name des neuen Profils'), 'Bernd'); |
| 443 | await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); |
| 444 | |
| 445 | await waitFor(() => { |
| 446 | expect(anlegen).toHaveBeenCalledWith('Bernd'); |
| 447 | }); |
| 448 | }); |
| 449 | |
| 450 | it('weist einen leeren Namen ab, ohne den Anwendungskern zu fragen', async () => { |
| 451 | const anlegen = vi.fn(() => Promise.resolve(profil(3, 'Neu'))); |
| 452 | const nutzer = starten(attrappe({ profilAnlegen: anlegen })); |
| 453 | await durchsTor(nutzer); |
| 454 | |
| 455 | await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); |
| 456 | await nutzer.type(screen.getByLabelText('Name des neuen Profils'), ' '); |
| 457 | await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); |
| 458 | |
| 459 | expect(await screen.findByText('Bitte geben Sie einen Namen ein.')).toBeInTheDocument(); |
| 460 | expect(anlegen).not.toHaveBeenCalled(); |
| 461 | expect(screen.getByLabelText('Name des neuen Profils')).toHaveFocus(); |
| 462 | }); |
| 463 | |
| 464 | /* |
| 465 | Der Fehlergrund muss am Feld nachlesbar bleiben (WCAG 3.3.1). |
| 466 | |
| 467 | Bis 0.22.0 hing er allein an `aria-errormessage`. Das werten VoiceOver und |
| 468 | ältere NVDA- und JAWS-Stände nicht oder unvollständig aus: Beim erneuten |
| 469 | Fokussieren hörte man dort nur „ungültig“, aber nicht warum – die |
| 470 | `role="alert"`-Ansage beim Erscheinen fängt nur den ersten Augenblick ab. |
| 471 | Die Prüfungswahl hält es seit jeher über `aria-describedby`; dies war die |
| 472 | einzige Stelle im Baum, die davon abwich. |
| 473 | */ |
| 474 | it('verknüpft den Fehlergrund so, dass jeder Screenreader ihn am Feld findet', async () => { |
| 475 | const nutzer = starten(attrappe()); |
| 476 | await durchsTor(nutzer); |
| 477 | |
| 478 | await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); |
| 479 | const feld = screen.getByLabelText('Name des neuen Profils'); |
| 480 | await nutzer.type(feld, ' '); |
| 481 | await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); |
| 482 | |
| 483 | await screen.findByText('Bitte geben Sie einen Namen ein.'); |
| 484 | expect(feld).toHaveAttribute('aria-invalid', 'true'); |
| 485 | |
| 486 | const fehlerId = feld.getAttribute('aria-errormessage'); |
| 487 | expect(fehlerId).not.toBeNull(); |
| 488 | /* Beide Wege zeigen auf dieselbe Stelle – der Hilfetext bleibt daneben |
| 489 | stehen, sonst verlöre man ihn im Fehlerfall. */ |
| 490 | expect(feld.getAttribute('aria-describedby')?.split(' ')).toContain(fehlerId); |
| 491 | expect(feld.getAttribute('aria-describedby')?.split(' ').length).toBe(2); |
| 492 | }); |
| 493 | |
| 494 | it('nimmt den Fehlerverweis wieder heraus, sobald weitergetippt wird', async () => { |
| 495 | /* Ein Verweis auf einen Fehlertext, den es nicht mehr gibt, ist eine |
| 496 | kaputte Verknüpfung: Der Screenreader nennt dann eine leere Stelle. */ |
| 497 | const nutzer = starten(attrappe()); |
| 498 | await durchsTor(nutzer); |
| 499 | |
| 500 | await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); |
| 501 | const feld = screen.getByLabelText('Name des neuen Profils'); |
| 502 | await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); |
| 503 | await screen.findByText('Bitte geben Sie einen Namen ein.'); |
| 504 | |
| 505 | await nutzer.type(feld, 'Bernd'); |
| 506 | |
| 507 | expect(feld).not.toHaveAttribute('aria-invalid'); |
| 508 | expect(feld.getAttribute('aria-describedby')?.split(' ').length).toBe(1); |
| 509 | }); |
| 510 | |
| 511 | it('benennt ein Profil um', async () => { |
| 512 | const aktualisieren = vi.fn(() => Promise.resolve(profil(1, 'Olaf W.'))); |
| 513 | const nutzer = starten(attrappe({ profilAktualisieren: aktualisieren })); |
| 514 | await durchsTor(nutzer); |
| 515 | |
| 516 | const liste = await profilliste(); |
| 517 | const zeile = within(liste).getAllByRole('listitem')[0]!; |
| 518 | await nutzer.click(within(zeile).getByRole('button', { name: /^Umbenennen/ })); |
| 519 | |
| 520 | const feld = screen.getByLabelText('Neuer Name'); |
| 521 | expect(feld).toHaveValue('Olaf'); |
| 522 | await nutzer.clear(feld); |
| 523 | await nutzer.type(feld, 'Olaf W.'); |
| 524 | await nutzer.click(screen.getByRole('button', { name: 'Namen speichern' })); |
| 525 | |
| 526 | await waitFor(() => { |
| 527 | expect(aktualisieren).toHaveBeenCalledWith(1, { name: 'Olaf W.' }); |
| 528 | }); |
| 529 | }); |
| 530 | |
| 531 | it('gibt den Fokus nach dem Abbrechen an den auslösenden Knopf zurück', async () => { |
| 532 | const nutzer = starten(); |
| 533 | await durchsTor(nutzer); |
| 534 | |
| 535 | const liste = await profilliste(); |
| 536 | const zeile = within(liste).getAllByRole('listitem')[0]!; |
| 537 | const ausloeser = within(zeile).getByRole('button', { name: /^Umbenennen/ }); |
| 538 | await nutzer.click(ausloeser); |
| 539 | |
| 540 | await nutzer.click(screen.getByRole('button', { name: 'Abbrechen' })); |
| 541 | |
| 542 | /* Ohne diesen Rücksprung fiele der Fokus auf `<body>` – wer per |
| 543 | Tastatur bedient, stünde wieder am Seitenanfang. */ |
| 544 | await waitFor(() => { |
| 545 | expect(within(zeile).getByRole('button', { name: /^Umbenennen/ })).toHaveFocus(); |
| 546 | }); |
| 547 | }); |
| 548 | |
| 549 | it('löscht erst nach einer Rückfrage', async () => { |
| 550 | const loeschen = vi.fn(() => Promise.resolve([OLAF])); |
| 551 | const nutzer = starten(attrappe({ profilLoeschen: loeschen })); |
| 552 | await durchsTor(nutzer); |
| 553 | |
| 554 | const liste = await profilliste(); |
| 555 | const zeile = within(liste).getAllByRole('listitem')[1]!; |
| 556 | await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ })); |
| 557 | |
| 558 | expect( |
| 559 | await screen.findByRole('heading', { name: 'Profil „Anna“ wirklich löschen?' }), |
| 560 | ).toBeInTheDocument(); |
| 561 | expect(loeschen).not.toHaveBeenCalled(); |
| 562 | |
| 563 | await nutzer.click(screen.getByRole('button', { name: 'Nein, behalten' })); |
| 564 | expect(loeschen).not.toHaveBeenCalled(); |
| 565 | |
| 566 | await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ })); |
| 567 | await nutzer.click(screen.getByRole('button', { name: 'Ja, Profil löschen' })); |
| 568 | |
| 569 | await waitFor(() => { |
| 570 | expect(loeschen).toHaveBeenCalledWith(2); |
| 571 | }); |
| 572 | }); |
| 573 | |
| 574 | /* |
| 575 | Die Knöpfe tragen `aria-disabled` und bleiben deshalb klickbar – ein |
| 576 | `disabled` verlöre den Fokus. Ohne Riegel im Behandler rief der zweite |
| 577 | Klick den Kanal ein zweites Mal: Bei zwei Profilen ging der erste Versuch |
| 578 | durch, der zweite lief in „Das letzte Profil lässt sich nicht löschen“, |
| 579 | und der Nutzer las eine rote Fehlermeldung, obwohl sein Profil weg war. |
| 580 | */ |
| 581 | it('löscht auch bei zwei schnellen Klicks nur einmal', async () => { |
| 582 | /* Der Kanal bleibt offen: Nur solange er läuft, ist der Riegel zu |
| 583 | beobachten. Aufgelöst wird er am Ende, damit der Test nichts |
| 584 | Schwebendes hinterlässt. */ |
| 585 | const gebremst: { fertig: ((profile: Profil[]) => void) | undefined } = { fertig: undefined }; |
| 586 | const loeschen = vi.fn( |
| 587 | () => |
| 588 | new Promise<Profil[]>((aufloesen) => { |
| 589 | gebremst.fertig = aufloesen; |
| 590 | }), |
| 591 | ); |
| 592 | const nutzer = starten(attrappe({ profilLoeschen: loeschen })); |
| 593 | await durchsTor(nutzer); |
| 594 | |
| 595 | const liste = await profilliste(); |
| 596 | const zeile = within(liste).getAllByRole('listitem')[1]!; |
| 597 | await nutzer.click(within(zeile).getByRole('button', { name: /^Löschen/ })); |
| 598 | |
| 599 | const ja = screen.getByRole('button', { name: 'Ja, Profil löschen' }); |
| 600 | await nutzer.click(ja); |
| 601 | await nutzer.click(ja); |
| 602 | |
| 603 | expect(loeschen).toHaveBeenCalledTimes(1); |
| 604 | |
| 605 | gebremst.fertig?.([OLAF]); |
| 606 | await waitFor(() => { |
| 607 | expect(loeschen).toHaveBeenCalledTimes(1); |
| 608 | }); |
| 609 | }); |
| 610 | |
| 611 | it('wechselt über die Liste und lädt den Lernstand des neuen Profils', async () => { |
| 612 | const uebersicht = vi.fn(() => Promise.resolve(UEBERSICHT)); |
| 613 | const nutzer = starten(attrappe({ lernUebersicht: uebersicht })); |
| 614 | await durchsTor(nutzer); |
| 615 | uebersicht.mockClear(); |
| 616 | |
| 617 | const liste = await profilliste(); |
| 618 | const zeile = within(liste).getAllByRole('listitem')[1]!; |
| 619 | await nutzer.click(within(zeile).getByRole('button', { name: /^Wechseln/ })); |
| 620 | |
| 621 | await waitFor(() => { |
| 622 | expect(uebersicht).toHaveBeenCalledWith(2); |
| 623 | }); |
| 624 | expect(await screen.findByText('Sie lernen jetzt als „Anna“.')).toBeInTheDocument(); |
| 625 | }); |
| 626 | |
| 627 | it('sagt es, wenn der Anwendungskern den Kanal nicht anbietet', async () => { |
| 628 | const bruecke = attrappe(); |
| 629 | Reflect.deleteProperty(bruecke, 'profilAnlegen'); |
| 630 | const nutzer = starten(bruecke); |
| 631 | await durchsTor(nutzer); |
| 632 | |
| 633 | await nutzer.click(screen.getByRole('button', { name: 'Neues Profil anlegen' })); |
| 634 | await nutzer.type(screen.getByLabelText('Name des neuen Profils'), 'Bernd'); |
| 635 | await nutzer.click(screen.getByRole('button', { name: 'Profil anlegen' })); |
| 636 | |
| 637 | expect(await screen.findByText(/profil:anlegen/)).toBeInTheDocument(); |
| 638 | }); |
| 639 | }); |