waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests renderer-einstellungen.test.tsx
| 1 | /** |
| 2 | * Die beiden Einstellungen, die zuvor nur im Vertrag standen. |
| 3 | * |
| 4 | * `optionenMischen` und `vorlesen` waren in `Einstellungen` deklariert, mit |
| 5 | * Standardwert versehen und vom Anwendungskern gespeichert – nur wirkte |
| 6 | * keine von beiden. Diese Datei prüft, dass sie ankommen: in der |
| 7 | * Zusammenstellung der Sitzung, im Prüfungsauftrag und in der Sprachausgabe. |
| 8 | * |
| 9 | * Die Sprachausgabe wird dabei nachgebildet. Ob dieses Gerät eine deutsche |
| 10 | * Stimme besitzt, entscheidet das Betriebssystem und darf keinen Testlauf |
| 11 | * scheitern lassen; geprüft wird, **was** gesprochen würde und **wann**. |
| 12 | */ |
| 13 | |
| 14 | import { render, screen, waitFor } from '@testing-library/react'; |
| 15 | import userEvent from '@testing-library/user-event'; |
| 16 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 17 | |
| 18 | import { App } from '../src/renderer/src/App'; |
| 19 | import { ANZEIGEGROESSEN, anzeigegroesseText } from '../src/shared/ansicht'; |
| 20 | import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; |
| 21 | import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; |
| 22 | import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; |
| 23 | import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; |
| 24 | import type { Erklaerungen } from '../src/shared/erklaerungen'; |
| 25 | import type { Glossar } from '../src/shared/glossar'; |
| 26 | import { |
| 27 | EINSTELLUNGEN_STANDARD, |
| 28 | type AnwendungsInfo, |
| 29 | type Einstellungen, |
| 30 | type LernAppBridge, |
| 31 | } from '../src/shared/ipc'; |
| 32 | import type { Frage, Katalog, RichText } from '../src/shared/katalog'; |
| 33 | import type { Lernplan } from '../src/shared/lernplan'; |
| 34 | import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand'; |
| 35 | import { NORMTEXTE_LEER } from '../src/shared/normtexte'; |
| 36 | import { THEMEN_LEER } from '../src/shared/themen'; |
| 37 | |
| 38 | type Nutzer = ReturnType<typeof userEvent.setup>; |
| 39 | type TeilBridge = Partial<LernAppBridge>; |
| 40 | |
| 41 | // ─── Katalogattrappe ──────────────────────────────────────────────────── |
| 42 | |
| 43 | function nurText(text: string): RichText { |
| 44 | return { text, segmente: [{ t: text }] }; |
| 45 | } |
| 46 | |
| 47 | const FRAGE: Frage = { |
| 48 | id: 'I.1-87', |
| 49 | amtliche_nummer: '1.87', |
| 50 | kapitel: 'I', |
| 51 | abschnitt: 'I.1', |
| 52 | typ: 'mc', |
| 53 | seite: 23, |
| 54 | frage: nurText('Wonach richtet sich der Erwerb nach § 12 Abs. 4 WaffG?'), |
| 55 | bilder: [], |
| 56 | optionen: [ |
| 57 | { label: 'a', inhalt: nurText('Nach dem Beschussgesetz'), korrekt: false, bilder: [] }, |
| 58 | { label: 'b', inhalt: nurText('Nach dem Waffengesetz'), korrekt: true, bilder: [] }, |
| 59 | { label: 'c', inhalt: nurText('Nach dem Sprengstoffgesetz'), korrekt: false, bilder: [] }, |
| 60 | ], |
| 61 | }; |
| 62 | |
| 63 | const KATALOG: Katalog = { |
| 64 | meta: { |
| 65 | titel: 'Attrappe', |
| 66 | herausgeber: 'Attrappe', |
| 67 | stand: '2024-01-01', |
| 68 | quellenangabe: 'Attrappe', |
| 69 | quelle_url: 'https://example.invalid/', |
| 70 | quelldatei_sha256: '0'.repeat(64), |
| 71 | fragen_gesamt: 1, |
| 72 | }, |
| 73 | kapitel: [ |
| 74 | { |
| 75 | id: 'I', |
| 76 | titel: 'Waffenrecht', |
| 77 | abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }], |
| 78 | }, |
| 79 | ], |
| 80 | fragen: [FRAGE], |
| 81 | bilder: [], |
| 82 | }; |
| 83 | |
| 84 | const ERKLAERUNGEN: Erklaerungen = { |
| 85 | meta: { version: 1, stand: '2026-08-01', hinweis: 'Attrappe', gesetzesstand: {} }, |
| 86 | zuFrage: { |
| 87 | 'I.1-87': { |
| 88 | kurz: 'Der Erwerb richtet sich nach dem WaffG.', |
| 89 | text: 'Ausführlich: Der Erwerb richtet sich nach § 12 Abs. 4 WaffG.', |
| 90 | fundstellen: [{ gesetz: 'WaffG', norm: '§ 12', absatz: '4' }], |
| 91 | }, |
| 92 | }, |
| 93 | }; |
| 94 | |
| 95 | const GLOSSAR: Glossar = { |
| 96 | meta: { version: 1, stand: '2026-08-01', hinweis: 'Attrappe', gesetzesstand: {} }, |
| 97 | eintraege: [], |
| 98 | }; |
| 99 | |
| 100 | const PROFIL: Profil = { |
| 101 | id: 1, |
| 102 | name: 'Standard', |
| 103 | pruefungstermin: null, |
| 104 | kapitelAusschluss: [], |
| 105 | erstelltAm: '2026-01-01T00:00:00.000Z', |
| 106 | }; |
| 107 | |
| 108 | const LERNPLAN: Lernplan = { |
| 109 | termin: null, |
| 110 | tageBisTermin: null, |
| 111 | gesamtFragen: 1, |
| 112 | nieBeantwortet: 1, |
| 113 | faellig: 0, |
| 114 | zielquote: 0.9, |
| 115 | prognoseHeute: 0, |
| 116 | prognoseAmTermin: null, |
| 117 | pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 }, |
| 118 | machbarkeit: 'kein_termin', |
| 119 | sekundenProFrage: 25, |
| 120 | }; |
| 121 | |
| 122 | const UEBERSICHT: Lernuebersicht = { |
| 123 | fragenGesamt: 1, |
| 124 | beantwortet: 0, |
| 125 | belegt: 0, |
| 126 | reifegrad: 0 / 1, |
| 127 | stufe: 'ohne_beleg', |
| 128 | deckelnd: [], |
| 129 | faellig: 0, |
| 130 | gemerkt: 0, |
| 131 | offen: 0, |
| 132 | fehler: 0, |
| 133 | heuteRichtig: 0, |
| 134 | heuteFalsch: 0, |
| 135 | heuteBearbeitet: 0, |
| 136 | tageSeitLetzterAntwort: null, |
| 137 | bereiche: [], |
| 138 | themengruppen: [], |
| 139 | }; |
| 140 | |
| 141 | const STAND: FrageStand = { |
| 142 | frageId: FRAGE.id, |
| 143 | versuche: 1, |
| 144 | richtige: 1, |
| 145 | zuletztBeantwortet: '2026-01-01T08:00:00.000Z', |
| 146 | faelligAb: null, |
| 147 | gemerkt: false, |
| 148 | letzteBewertung: 'gut', |
| 149 | }; |
| 150 | |
| 151 | const INFO: AnwendungsInfo = { |
| 152 | anwendungsVersion: '0.0.0-attrappe', |
| 153 | baukennung: 'abc1234', |
| 154 | baustand: '2026-08-21', |
| 155 | electronVersion: '43.4.1', |
| 156 | chromeVersion: '140.0.0.0', |
| 157 | nodeVersion: '22.19.1', |
| 158 | plattform: 'win32', |
| 159 | datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' }, |
| 160 | }; |
| 161 | |
| 162 | function reihenfolge(frage: Frage): SitzungsFrage { |
| 163 | return { |
| 164 | frageId: frage.id, |
| 165 | optionsReihenfolge: (frage.optionen ?? []).map((o) => o.label), |
| 166 | gemerkt: false, |
| 167 | }; |
| 168 | } |
| 169 | |
| 170 | function attrappe(ueberschreibungen: TeilBridge = {}): TeilBridge { |
| 171 | return { |
| 172 | hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)), |
| 173 | hilfsmittelBeobachten: vi.fn(() => () => undefined), |
| 174 | menuebefehlBeobachten: vi.fn(() => () => undefined), |
| 175 | /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: |
| 176 | Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ |
| 177 | einstellungenLesen: vi.fn(() => |
| 178 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), |
| 179 | ), |
| 180 | einstellungenSchreiben: vi.fn((aenderung: Partial<Einstellungen>) => |
| 181 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }), |
| 182 | ), |
| 183 | katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), |
| 184 | erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)), |
| 185 | glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)), |
| 186 | /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0. |
| 187 | Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext; |
| 188 | den prueft tests/normstelle.test.tsx an eigenen Faellen. */ |
| 189 | normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)), |
| 190 | themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)), |
| 191 | anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), |
| 192 | profilListe: vi.fn(() => Promise.resolve([PROFIL])), |
| 193 | profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)), |
| 194 | profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)), |
| 195 | profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])), |
| 196 | lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE)])), |
| 197 | lernAntworten: vi.fn(() => Promise.resolve(STAND)), |
| 198 | lernMerken: vi.fn(() => Promise.resolve(STAND)), |
| 199 | lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 200 | lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), |
| 201 | lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 202 | ...ueberschreibungen, |
| 203 | }; |
| 204 | } |
| 205 | |
| 206 | // ─── Attrappe der Sprachausgabe ───────────────────────────────────────── |
| 207 | |
| 208 | interface GesprochenesStueck { |
| 209 | readonly text: string; |
| 210 | readonly lang: string; |
| 211 | } |
| 212 | |
| 213 | class AeusserungAttrappe { |
| 214 | lang = ''; |
| 215 | voice: unknown = null; |
| 216 | onend: (() => void) | null = null; |
| 217 | onerror: ((ereignis: { error: string }) => void) | null = null; |
| 218 | constructor(public readonly text: string) {} |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Bildet `window.speechSynthesis` nach. |
| 223 | * |
| 224 | * `getVoices()` gibt bewusst eine leere Liste zurück – genau so verhält sich |
| 225 | * Electron unter Windows vor der ersten Äußerung. Die Anwendung darf davon |
| 226 | * nicht abhängen. |
| 227 | */ |
| 228 | function sprachausgabeStellen(): { |
| 229 | gesprochen: GesprochenesStueck[]; |
| 230 | abbrueche: () => number; |
| 231 | beenden: () => void; |
| 232 | } { |
| 233 | const gesprochen: GesprochenesStueck[] = []; |
| 234 | let abgebrochen = 0; |
| 235 | let letzte: AeusserungAttrappe | null = null; |
| 236 | |
| 237 | vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe); |
| 238 | vi.stubGlobal('speechSynthesis', { |
| 239 | speak: (aeusserung: AeusserungAttrappe) => { |
| 240 | gesprochen.push({ text: aeusserung.text, lang: aeusserung.lang }); |
| 241 | letzte = aeusserung; |
| 242 | }, |
| 243 | cancel: () => { |
| 244 | abgebrochen += 1; |
| 245 | }, |
| 246 | getVoices: () => [], |
| 247 | speaking: false, |
| 248 | pending: false, |
| 249 | paused: false, |
| 250 | }); |
| 251 | |
| 252 | return { |
| 253 | gesprochen, |
| 254 | abbrueche: () => abgebrochen, |
| 255 | beenden: () => { |
| 256 | letzte?.onend?.(); |
| 257 | }, |
| 258 | }; |
| 259 | } |
| 260 | |
| 261 | function starten(bruecke: TeilBridge = attrappe()): Nutzer { |
| 262 | vi.stubGlobal('lernApp', bruecke); |
| 263 | const nutzer = userEvent.setup(); |
| 264 | render(<App />); |
| 265 | return nutzer; |
| 266 | } |
| 267 | |
| 268 | async function sitzungOeffnen(bruecke: TeilBridge = attrappe()): Promise<Nutzer> { |
| 269 | const nutzer = starten(bruecke); |
| 270 | const knopf = await screen.findByRole('button', { name: /Weiterlernen/u }); |
| 271 | await waitFor(() => { |
| 272 | expect(knopf).toBeEnabled(); |
| 273 | }); |
| 274 | await nutzer.click(knopf); |
| 275 | await screen.findByRole('heading', { name: /Frage 1\.87/u }); |
| 276 | return nutzer; |
| 277 | } |
| 278 | |
| 279 | afterEach(() => { |
| 280 | vi.unstubAllGlobals(); |
| 281 | erklaerungenZwischenspeicherLeeren(); |
| 282 | glossarZwischenspeicherLeeren(); |
| 283 | normtexteZwischenspeicherLeeren(); |
| 284 | themenZwischenspeicherLeeren(); |
| 285 | }); |
| 286 | |
| 287 | // ─── Tests ────────────────────────────────────────────────────────────── |
| 288 | |
| 289 | /* |
| 290 | Textabstände (WCAG 1.4.12). |
| 291 | |
| 292 | Im Browser erfüllt man das Kriterium, indem nutzereigene Stilblätter |
| 293 | toleriert werden. In der ausgelieferten Anwendung gibt es weder |
| 294 | Erweiterungen noch Entwicklerwerkzeuge – die Anwendung ist die einzige |
| 295 | Stelle, die Zeilen-, Wort- und Zeichenabstand ändern kann, und sie bot es |
| 296 | bis 0.22.0 nicht an. |
| 297 | */ |
| 298 | describe('Einstellung „Textdarstellung“', () => { |
| 299 | it('steht als eigene Karte in den Einstellungen', async () => { |
| 300 | /* Getrennt von der Anzeigegröße: Der Zoom skaliert alles gemeinsam, der |
| 301 | Abstand gibt bei gleicher Größe mehr Luft. Wer mit Legasthenie liest, |
| 302 | braucht oft das Zweite. */ |
| 303 | starten(); |
| 304 | |
| 305 | expect(await screen.findByRole('heading', { name: 'Textdarstellung' })).toBeInTheDocument(); |
| 306 | }); |
| 307 | |
| 308 | it('beginnt bei der gewohnten Darstellung', async () => { |
| 309 | starten(); |
| 310 | |
| 311 | expect( |
| 312 | await screen.findByRole('button', { name: 'Normal', pressed: true }), |
| 313 | ).toBeInTheDocument(); |
| 314 | }); |
| 315 | |
| 316 | it('schreibt die Stufe an das Wurzelelement', async () => { |
| 317 | /* Dort und nur dort wirken die Token aus `tokens.css`. Ohne diese Zeile |
| 318 | wäre die Einstellung gespeichert und wirkungslos. */ |
| 319 | const nutzer = starten(); |
| 320 | |
| 321 | await nutzer.click(await screen.findByRole('button', { name: 'Weit' })); |
| 322 | |
| 323 | await waitFor(() => { |
| 324 | expect(document.documentElement.dataset['textabstand']).toBe('weit'); |
| 325 | }); |
| 326 | }); |
| 327 | |
| 328 | it('sichert die Stufe im Anwendungskern', async () => { |
| 329 | const schreiben = vi.fn((aenderung: Partial<Einstellungen>) => |
| 330 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }), |
| 331 | ); |
| 332 | const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben })); |
| 333 | |
| 334 | await nutzer.click(await screen.findByRole('button', { name: 'Sehr weit' })); |
| 335 | |
| 336 | expect(schreiben).toHaveBeenCalledWith({ textabstand: 'sehr-weit' }); |
| 337 | }); |
| 338 | |
| 339 | /* |
| 340 | Bis 0.24.1 setzte `Textabstandswahl` das Attribut selbst – also nur, |
| 341 | solange der Startbildschirm im Baum stand. Die Zuschnittfrage kommt beim |
| 342 | Erststart davor, und wer „Sehr weit“ eingestellt hatte, las ausgerechnet |
| 343 | sie in der Werksdarstellung. |
| 344 | */ |
| 345 | it('trägt die Stufe schon vor dem Startbildschirm, auf der Zuschnittfrage', async () => { |
| 346 | starten( |
| 347 | attrappe({ |
| 348 | einstellungenLesen: vi.fn(() => |
| 349 | Promise.resolve({ |
| 350 | ...EINSTELLUNGEN_STANDARD, |
| 351 | zuschnittGefragt: false, |
| 352 | textabstand: 'sehr-weit' as const, |
| 353 | }), |
| 354 | ), |
| 355 | }), |
| 356 | ); |
| 357 | |
| 358 | /* Die Zuschnittfrage steht, der Startbildschirm noch nicht. */ |
| 359 | await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); |
| 360 | |
| 361 | await waitFor(() => { |
| 362 | expect(document.documentElement.dataset['textabstand']).toBe('sehr-weit'); |
| 363 | }); |
| 364 | }); |
| 365 | |
| 366 | it('sagt die gewählte Stufe an', async () => { |
| 367 | const nutzer = starten(); |
| 368 | |
| 369 | await nutzer.click(await screen.findByRole('button', { name: 'Weit' })); |
| 370 | |
| 371 | await waitFor(() => { |
| 372 | expect(screen.getByText('Textabstand: Weit.')).toBeInTheDocument(); |
| 373 | }); |
| 374 | }); |
| 375 | }); |
| 376 | |
| 377 | describe('Einstellung „Antwortmöglichkeiten mischen“', () => { |
| 378 | it('steht als Schalter im Startbildschirm und ist voreingestellt aus', async () => { |
| 379 | /* Ab Werk aus: Ein Lernvorteil des Mischens ist nicht belegt, der |
| 380 | Aufwand beim Abgleich mit dem amtlichen Katalog dagegen messbar – |
| 381 | 83 % der Auswahlfragen erscheinen gemischt in anderer Reihenfolge. |
| 382 | Siehe docs/entscheidung-antwortreihenfolge.md. */ |
| 383 | starten(); |
| 384 | |
| 385 | const schalter = await screen.findByRole('checkbox', { |
| 386 | name: 'Antwortmöglichkeiten mischen', |
| 387 | }); |
| 388 | |
| 389 | expect(schalter).not.toBeChecked(); |
| 390 | }); |
| 391 | |
| 392 | it('sichert das Ein- und Ausschalten im Anwendungskern', async () => { |
| 393 | const schreiben = vi.fn((aenderung: Partial<Einstellungen>) => |
| 394 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }), |
| 395 | ); |
| 396 | const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben })); |
| 397 | const schalter = await screen.findByRole('checkbox', { |
| 398 | name: 'Antwortmöglichkeiten mischen', |
| 399 | }); |
| 400 | |
| 401 | await nutzer.click(schalter); |
| 402 | expect(schreiben).toHaveBeenCalledWith({ optionenMischen: true }); |
| 403 | |
| 404 | await nutzer.click(schalter); |
| 405 | expect(schreiben).toHaveBeenCalledWith({ optionenMischen: false }); |
| 406 | }); |
| 407 | |
| 408 | it('steht unter einer Überschrift „Schwierigkeit“ neben dem zweiten Regler', async () => { |
| 409 | /* Die beiden Regler gehören zusammen und sind als das beschriftet, was |
| 410 | sie sind: Erschwernisse, die man bewusst wählt. */ |
| 411 | starten(); |
| 412 | |
| 413 | expect( |
| 414 | await screen.findByRole('heading', { name: 'Schwierigkeit', level: 3 }), |
| 415 | ).toBeInTheDocument(); |
| 416 | expect( |
| 417 | await screen.findByRole('checkbox', { name: 'Anzahl der richtigen Antworten verbergen' }), |
| 418 | ).toBeInTheDocument(); |
| 419 | }); |
| 420 | |
| 421 | it('verspricht keinen Lernvorteil und behauptet nichts über den Prüfungsbogen', async () => { |
| 422 | /* Der frühere Text sagte „in der Prüfung stehen sie ohnehin anders“ – |
| 423 | eine Tatsachenbehauptung über die echte Prüfung, für die es keine |
| 424 | Quelle gibt. Weder das BVA-Vorwort noch die AWaffV noch ein |
| 425 | Behördenmerkblatt regeln die Anordnung der Antwortmöglichkeiten. */ |
| 426 | starten(); |
| 427 | |
| 428 | expect( |
| 429 | await screen.findByText(/Ein Lernvorteil des Mischens ist nicht belegt/u), |
| 430 | ).toBeInTheDocument(); |
| 431 | expect(screen.queryByText(/in der Prüfung stehen sie ohnehin anders/u)).toBeNull(); |
| 432 | }); |
| 433 | |
| 434 | it('reicht die Entscheidung an die Zusammenstellung der Sitzung weiter', async () => { |
| 435 | /* Bewusst mit dem vom Standard ABWEICHENDEN Wert geprüft: Stünde hier |
| 436 | `false`, wäre der Test seit der Umstellung der Vorgabe inhaltsleer – |
| 437 | er bliebe auch dann grün, wenn die Oberfläche die Einstellung gar |
| 438 | nicht mehr läse. */ |
| 439 | const lernSitzung = vi.fn(() => Promise.resolve([reihenfolge(FRAGE)])); |
| 440 | const nutzer = starten( |
| 441 | attrappe({ |
| 442 | lernSitzung, |
| 443 | einstellungenLesen: vi.fn(() => |
| 444 | Promise.resolve({ |
| 445 | ...EINSTELLUNGEN_STANDARD, |
| 446 | zuschnittGefragt: true, |
| 447 | optionenMischen: true, |
| 448 | }), |
| 449 | ), |
| 450 | }), |
| 451 | ); |
| 452 | |
| 453 | const knopf = await screen.findByRole('button', { name: /Weiterlernen/u }); |
| 454 | await waitFor(() => { |
| 455 | expect(knopf).toBeEnabled(); |
| 456 | }); |
| 457 | await nutzer.click(knopf); |
| 458 | |
| 459 | await waitFor(() => { |
| 460 | expect(lernSitzung).toHaveBeenCalledWith( |
| 461 | 1, |
| 462 | expect.objectContaining({ optionenMischen: true }), |
| 463 | ); |
| 464 | }); |
| 465 | }); |
| 466 | |
| 467 | it('sagt, dass sie auch in der Prüfungssimulation gilt', async () => { |
| 468 | starten(); |
| 469 | |
| 470 | expect(await screen.findByText(/gilt auch in der Prüfungssimulation/u)).toBeInTheDocument(); |
| 471 | }); |
| 472 | }); |
| 473 | |
| 474 | describe('Einstellung „Vorlesen“', () => { |
| 475 | it('blendet ohne Sprachausgabe des Systems keinen Vorleseknopf ein', async () => { |
| 476 | /* Ohne `speechSynthesis` – der Normalfall in jsdom. */ |
| 477 | await sitzungOeffnen( |
| 478 | attrappe({ |
| 479 | einstellungenLesen: vi.fn(() => |
| 480 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), |
| 481 | ), |
| 482 | }), |
| 483 | ); |
| 484 | |
| 485 | expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument(); |
| 486 | }); |
| 487 | |
| 488 | it('erklärt, wenn das System keine Sprachausgabe anbietet', async () => { |
| 489 | starten(); |
| 490 | |
| 491 | expect( |
| 492 | await screen.findByText('Dieses System bietet keine Sprachausgabe an.'), |
| 493 | ).toBeInTheDocument(); |
| 494 | |
| 495 | /* |
| 496 | Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein |
| 497 | natives `disabled`, dafür `aria-disabled`. Gerade hier trägt das etwas – |
| 498 | der Satz darunter erklärt ja, WARUM das Kästchen nichts nützt, und ein |
| 499 | `disabled` Kästchen nimmt keinen Fokus, sodass er einem Bildschirmleser |
| 500 | nie begegnete. |
| 501 | */ |
| 502 | const schalter = screen.getByRole('checkbox', { |
| 503 | name: 'Fragen und Antworten vorlesen lassen', |
| 504 | }); |
| 505 | expect(schalter).toBeEnabled(); |
| 506 | expect(schalter).toHaveAttribute('aria-disabled', 'true'); |
| 507 | }); |
| 508 | |
| 509 | it('lässt das gesperrte Vorlesekästchen nichts umstellen', async () => { |
| 510 | const einstellungenSchreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)); |
| 511 | const nutzer = starten(attrappe({ einstellungenSchreiben })); |
| 512 | |
| 513 | const schalter = await screen.findByRole('checkbox', { |
| 514 | name: 'Fragen und Antworten vorlesen lassen', |
| 515 | }); |
| 516 | await nutzer.click(schalter); |
| 517 | |
| 518 | expect(schalter).not.toBeChecked(); |
| 519 | expect(einstellungenSchreiben).not.toHaveBeenCalled(); |
| 520 | }); |
| 521 | |
| 522 | it('zeigt den Knopf erst, wenn die Einstellung an ist', async () => { |
| 523 | sprachausgabeStellen(); |
| 524 | await sitzungOeffnen(); |
| 525 | |
| 526 | expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument(); |
| 527 | }); |
| 528 | |
| 529 | it('liest die Frage samt Antwortmöglichkeiten und löst Kürzel auf', async () => { |
| 530 | const stimme = sprachausgabeStellen(); |
| 531 | const nutzer = await sitzungOeffnen( |
| 532 | attrappe({ |
| 533 | einstellungenLesen: vi.fn(() => |
| 534 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), |
| 535 | ), |
| 536 | }), |
| 537 | ); |
| 538 | |
| 539 | await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); |
| 540 | |
| 541 | const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' '); |
| 542 | expect(gesamt).toContain('Paragraf 12 Absatz 4 Waffengesetz'); |
| 543 | expect(gesamt).toContain('b: Nach dem Waffengesetz'); |
| 544 | expect(stimme.gesprochen.every((stueck) => stueck.lang === 'de-DE')).toBe(true); |
| 545 | }); |
| 546 | |
| 547 | it('bietet während der Ausgabe das Anhalten an (WCAG 1.4.2)', async () => { |
| 548 | const stimme = sprachausgabeStellen(); |
| 549 | const nutzer = await sitzungOeffnen( |
| 550 | attrappe({ |
| 551 | einstellungenLesen: vi.fn(() => |
| 552 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), |
| 553 | ), |
| 554 | }), |
| 555 | ); |
| 556 | |
| 557 | await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); |
| 558 | |
| 559 | const halt = await screen.findByRole('button', { name: /Vorlesen beenden/u }); |
| 560 | const vorher = stimme.abbrueche(); |
| 561 | await nutzer.click(halt); |
| 562 | |
| 563 | expect(stimme.abbrueche()).toBeGreaterThan(vorher); |
| 564 | expect(await screen.findByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument(); |
| 565 | }); |
| 566 | |
| 567 | it('sagt von selbst nichts, solange nur die Schaltfläche eingeschaltet ist', async () => { |
| 568 | /* |
| 569 | Gemeldet von einem Nutzer: Nach dem Bestätigen begann die Ansage von |
| 570 | selbst. Die eigenen Texte gaben ihm recht – Schalter wie Handbuch sagten |
| 571 | eine Schaltfläche zu, von selbstständigem Sprechen stand in keinem etwas. |
| 572 | */ |
| 573 | const stimme = sprachausgabeStellen(); |
| 574 | const nutzer = await sitzungOeffnen( |
| 575 | attrappe({ |
| 576 | einstellungenLesen: vi.fn(() => |
| 577 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), |
| 578 | ), |
| 579 | }), |
| 580 | ); |
| 581 | |
| 582 | stimme.gesprochen.length = 0; |
| 583 | await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); |
| 584 | await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); |
| 585 | await screen.findByRole('heading', { name: 'Warum das so ist' }); |
| 586 | |
| 587 | expect(stimme.gesprochen).toHaveLength(0); |
| 588 | /* Der Knopf ist trotzdem da und liest jetzt die Rückmeldung. */ |
| 589 | expect(screen.getByRole('button', { name: /Antwort vorlesen/u })).toBeInTheDocument(); |
| 590 | }); |
| 591 | |
| 592 | it('liest Frage und Rückmeldung von selbst, wenn man es einschaltet', async () => { |
| 593 | const stimme = sprachausgabeStellen(); |
| 594 | const nutzer = await sitzungOeffnen( |
| 595 | attrappe({ |
| 596 | einstellungenLesen: vi.fn(() => |
| 597 | Promise.resolve({ |
| 598 | ...EINSTELLUNGEN_STANDARD, |
| 599 | zuschnittGefragt: true, |
| 600 | vorlesen: true, |
| 601 | vorlesenAutomatisch: true, |
| 602 | }), |
| 603 | ), |
| 604 | }), |
| 605 | ); |
| 606 | |
| 607 | /* Zuerst die Frage – ohne dass jemand gedrückt hat. */ |
| 608 | await waitFor(() => { |
| 609 | expect(stimme.gesprochen.length).toBeGreaterThan(0); |
| 610 | }); |
| 611 | expect(stimme.gesprochen.map((s) => s.text).join(' ')).toContain('Waffengesetz'); |
| 612 | |
| 613 | stimme.gesprochen.length = 0; |
| 614 | await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); |
| 615 | await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); |
| 616 | |
| 617 | await waitFor(() => { |
| 618 | expect(stimme.gesprochen.length).toBeGreaterThan(0); |
| 619 | }); |
| 620 | const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' '); |
| 621 | expect(gesamt).toContain('Richtig'); |
| 622 | expect(gesamt).toContain('Richtig ist Antwort b.'); |
| 623 | /* Die Kurzfassung der Erklärung, nicht der Volltext: Der dauert |
| 624 | gesprochen über eine Minute und die nächste Frage wartet. */ |
| 625 | expect(gesamt).toContain('Der Erwerb richtet sich nach dem Waffengesetz.'); |
| 626 | expect(gesamt).not.toContain('Ausführlich'); |
| 627 | }); |
| 628 | |
| 629 | it('spricht dieselbe Stelle nicht zweimal', async () => { |
| 630 | const stimme = sprachausgabeStellen(); |
| 631 | const nutzer = await sitzungOeffnen( |
| 632 | attrappe({ |
| 633 | einstellungenLesen: vi.fn(() => |
| 634 | Promise.resolve({ |
| 635 | ...EINSTELLUNGEN_STANDARD, |
| 636 | zuschnittGefragt: true, |
| 637 | vorlesen: true, |
| 638 | vorlesenAutomatisch: true, |
| 639 | }), |
| 640 | ), |
| 641 | }), |
| 642 | ); |
| 643 | |
| 644 | await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); |
| 645 | await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); |
| 646 | await waitFor(() => { |
| 647 | expect(stimme.gesprochen.length).toBeGreaterThan(0); |
| 648 | }); |
| 649 | |
| 650 | const nachErgebnis = stimme.gesprochen.length; |
| 651 | /* Ein Klick, der die Ansicht neu zeichnet, ohne die Frage zu wechseln. */ |
| 652 | await nutzer.click(screen.getByRole('button', { name: /Frage merken/u })); |
| 653 | |
| 654 | expect(stimme.gesprochen).toHaveLength(nachErgebnis); |
| 655 | }); |
| 656 | |
| 657 | it('liest die ausführliche Begründung nur bei falscher Antwort', async () => { |
| 658 | /* |
| 659 | Der zweite Schalter, und zwar allein: Ohne „von selbst vorlesen“ bleibt |
| 660 | es bei der einen Ansage, die man bestellt hat – dem langen Text, wenn es |
| 661 | danebenging. Genau die Kombination, die sich sonst nicht einstellen |
| 662 | ließe: selbst lesen, aber bei einem Fehler die Begründung hören. |
| 663 | */ |
| 664 | const stimme = sprachausgabeStellen(); |
| 665 | const nutzer = await sitzungOeffnen( |
| 666 | attrappe({ |
| 667 | einstellungenLesen: vi.fn(() => |
| 668 | Promise.resolve({ |
| 669 | ...EINSTELLUNGEN_STANDARD, |
| 670 | zuschnittGefragt: true, |
| 671 | vorlesen: true, |
| 672 | vorlesenErklaerungBeiFehler: true, |
| 673 | }), |
| 674 | ), |
| 675 | }), |
| 676 | ); |
| 677 | |
| 678 | stimme.gesprochen.length = 0; |
| 679 | /* Die falsche der beiden Antwortmöglichkeiten. */ |
| 680 | await nutzer.click(screen.getByRole('radio', { name: /Nach dem Sprengstoffgesetz/u })); |
| 681 | await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); |
| 682 | |
| 683 | await waitFor(() => { |
| 684 | expect(stimme.gesprochen.length).toBeGreaterThan(0); |
| 685 | }); |
| 686 | const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' '); |
| 687 | expect(gesamt).toContain('Ausführlich'); |
| 688 | /* Nur die Begründung: Die Rückmeldung selbst hat niemand bestellt. */ |
| 689 | expect(gesamt).not.toContain('Richtig ist Antwort b.'); |
| 690 | }); |
| 691 | |
| 692 | it('schweigt bei richtiger Antwort, auch wenn die Begründung eingeschaltet ist', async () => { |
| 693 | const stimme = sprachausgabeStellen(); |
| 694 | const nutzer = await sitzungOeffnen( |
| 695 | attrappe({ |
| 696 | einstellungenLesen: vi.fn(() => |
| 697 | Promise.resolve({ |
| 698 | ...EINSTELLUNGEN_STANDARD, |
| 699 | zuschnittGefragt: true, |
| 700 | vorlesen: true, |
| 701 | vorlesenErklaerungBeiFehler: true, |
| 702 | }), |
| 703 | ), |
| 704 | }), |
| 705 | ); |
| 706 | |
| 707 | stimme.gesprochen.length = 0; |
| 708 | await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); |
| 709 | await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); |
| 710 | await screen.findByRole('heading', { name: 'Warum das so ist' }); |
| 711 | |
| 712 | expect(stimme.gesprochen).toHaveLength(0); |
| 713 | }); |
| 714 | |
| 715 | it('zeigt die Zusatzschalter erst, wenn die Sprachausgabe an ist', async () => { |
| 716 | /* Nicht als gesperrte Kästchen: Gesperrte Bedienelemente nehmen keinen |
| 717 | Fokus, ihre Erklärung würde nie vorgelesen. Dass es sie gibt, sagt der |
| 718 | Satz am Schalter darüber. */ |
| 719 | sprachausgabeStellen(); |
| 720 | const nutzer = starten(attrappe()); |
| 721 | |
| 722 | expect( |
| 723 | await screen.findByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }), |
| 724 | ).toBeInTheDocument(); |
| 725 | expect(screen.queryByRole('checkbox', { name: /von selbst vorlesen/u })).toBeNull(); |
| 726 | expect(screen.queryByRole('checkbox', { name: /ausführliche Begründung/u })).toBeNull(); |
| 727 | |
| 728 | await nutzer.click( |
| 729 | screen.getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }), |
| 730 | ); |
| 731 | |
| 732 | expect( |
| 733 | await screen.findByRole('checkbox', { name: 'Frage und Rückmeldung von selbst vorlesen' }), |
| 734 | ).not.toBeChecked(); |
| 735 | expect( |
| 736 | screen.getByRole('checkbox', { |
| 737 | name: 'Bei falscher Antwort die ausführliche Begründung vorlesen', |
| 738 | }), |
| 739 | ).not.toBeChecked(); |
| 740 | }); |
| 741 | |
| 742 | it('warnt, wenn zusätzlich ein Screenreader läuft', async () => { |
| 743 | sprachausgabeStellen(); |
| 744 | starten( |
| 745 | attrappe({ |
| 746 | hilfsmittelAktiv: vi.fn(() => Promise.resolve(true)), |
| 747 | einstellungenLesen: vi.fn(() => |
| 748 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), |
| 749 | ), |
| 750 | }), |
| 751 | ); |
| 752 | |
| 753 | expect(await screen.findByText(/Beide zugleich sprechen durcheinander/u)).toBeInTheDocument(); |
| 754 | }); |
| 755 | }); |
| 756 | |
| 757 | describe('Neu anfangen', () => { |
| 758 | it('setzt den Lernstand erst nach einer Rückfrage zurück', async () => { |
| 759 | /* |
| 760 | Unwiderruflich, deshalb eine Bestätigung (WCAG 3.3.4). Und sie sagt, |
| 761 | was verschwindet – eine Rückfrage, die nur „Sind Sie sicher?“ fragt, |
| 762 | hilft beim Entscheiden nicht. |
| 763 | */ |
| 764 | const zuruecksetzen = vi.fn(() => Promise.resolve(UEBERSICHT)); |
| 765 | const nutzer = starten(attrappe({ lernZuruecksetzen: zuruecksetzen })); |
| 766 | |
| 767 | await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' })); |
| 768 | |
| 769 | expect( |
| 770 | screen.getByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }), |
| 771 | ).toBeInTheDocument(); |
| 772 | expect(zuruecksetzen).not.toHaveBeenCalled(); |
| 773 | |
| 774 | await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' })); |
| 775 | |
| 776 | await waitFor(() => { |
| 777 | expect(zuruecksetzen).toHaveBeenCalledWith(1, null); |
| 778 | }); |
| 779 | expect(await screen.findByText(/Ihr Lernstand ist zurückgesetzt/u)).toBeInTheDocument(); |
| 780 | }); |
| 781 | |
| 782 | it('gibt den Fokus an den auslösenden Knopf zurück', async () => { |
| 783 | /* |
| 784 | Die Rückfrage holt den Fokus zu sich. Verschwände sie, ohne ihn |
| 785 | zurückzugeben, fiele er auf <body> – wer per Tastatur bedient, stünde |
| 786 | am Seitenanfang und müsste sich durch alle Einstellungen zurückarbeiten. |
| 787 | */ |
| 788 | const nutzer = starten(); |
| 789 | const knopf = await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }); |
| 790 | |
| 791 | await nutzer.click(knopf); |
| 792 | await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' })); |
| 793 | |
| 794 | expect(screen.getByRole('button', { name: 'Lernstand zurücksetzen' })).toHaveFocus(); |
| 795 | }); |
| 796 | |
| 797 | it('lässt sich abbrechen, ohne etwas zu löschen', async () => { |
| 798 | const zuruecksetzen = vi.fn(() => Promise.resolve(UEBERSICHT)); |
| 799 | const nutzer = starten(attrappe({ lernZuruecksetzen: zuruecksetzen })); |
| 800 | |
| 801 | await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' })); |
| 802 | await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' })); |
| 803 | |
| 804 | expect(zuruecksetzen).not.toHaveBeenCalled(); |
| 805 | expect( |
| 806 | screen.queryByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }), |
| 807 | ).not.toBeInTheDocument(); |
| 808 | }); |
| 809 | |
| 810 | it('sagt vorher, was verschwindet und was bleibt', async () => { |
| 811 | starten(); |
| 812 | |
| 813 | const text = await screen.findByText(/Setzt Ihren Lernstand auf null/u); |
| 814 | |
| 815 | expect(text).toHaveTextContent('Merkliste'); |
| 816 | expect(text).toHaveTextContent('Prüfungssimulationen'); |
| 817 | expect(text).toHaveTextContent('Einstellungen'); |
| 818 | }); |
| 819 | |
| 820 | it('meldet einen Fehlschlag, statt Erfolg vorzutäuschen', async () => { |
| 821 | const nutzer = starten( |
| 822 | attrappe({ |
| 823 | lernZuruecksetzen: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))), |
| 824 | }), |
| 825 | ); |
| 826 | |
| 827 | await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' })); |
| 828 | await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' })); |
| 829 | |
| 830 | expect(await screen.findByText(/Datenbank gesperrt/u)).toBeInTheDocument(); |
| 831 | }); |
| 832 | }); |
| 833 | |
| 834 | describe('Anzeigegröße über die Tastatur', () => { |
| 835 | /* |
| 836 | Gemeldet und nachgemessen: `Strg` und `+` taten unter Windows nichts. |
| 837 | Electron liest den Beschleunigernamen `Plus` als die umgeschaltete Form von |
| 838 | `=` und meldet damit faktisch `Strg+Umschalt+=` beim Betriebssystem an — |
| 839 | richtig für eine amerikanische Tastatur, falsch für eine deutsche, auf der |
| 840 | `+` eine eigene Taste ohne Umschalt ist. |
| 841 | |
| 842 | Geprüft wird hier das Zeichen, das die Tastatur liefert. Das Layout selbst |
| 843 | muss die Anwendung nie kennen — wer es abfragt, trifft Annahmen, die bei |
| 844 | der nächsten Tastatur wieder falsch sind. Genau dieser Fehler steckte im |
| 845 | Beschleuniger. |
| 846 | */ |
| 847 | /* Die gemeinsame Attrappe dieser Datei fuehrt die Anzeigekanaele bewusst |
| 848 | nicht - andere Tests hier pruefen gerade den Fall ohne Anwendungskern. |
| 849 | Fuer die Tastatur braucht es sie, also kommen sie hier dazu. */ |
| 850 | function zoomAttrappe(ueberschreibungen: TeilBridge = {}): TeilBridge { |
| 851 | return attrappe({ |
| 852 | anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)), |
| 853 | anzeigegroesseBeobachten: vi.fn(() => () => undefined), |
| 854 | ...ueberschreibungen, |
| 855 | }); |
| 856 | } |
| 857 | |
| 858 | /* |
| 859 | Der Aufbau ist die einzige Stelle hier, die wirklich warten muss – und die |
| 860 | einzige, die je in einen Zeitablauf lief. |
| 861 | |
| 862 | Nachgemessen: Die Schaltfläche „Weiterlernen“ erscheint erst, wenn die |
| 863 | gespeicherten Einstellungen geladen sind. Mit einer künstlich um zwei |
| 864 | Sekunden verzögerten Antwort wird sie überhaupt nicht gefunden. Es hängen |
| 865 | also drei Brückenaufrufe und der vollständige Aufbau der Anwendung an |
| 866 | diesem einen Warten. |
| 867 | |
| 868 | Die Vorgabe von Testing Library dafür sind 1000 ms. Im vollen Testlauf mit |
| 869 | 40 Dateien reicht das gelegentlich nicht: Der Test fiel zweimal aus, beide |
| 870 | Male nach 1071 bzw. 1083 ms – die Kennmarke genau dieses Zeitablaufs. |
| 871 | Allein lief er immer durch. |
| 872 | |
| 873 | Die 5000 ms sind keine Nachsicht mit einem wackligen Test, sondern die |
| 874 | Berichtigung einer Annahme, die nirgends zugesagt ist: Die Anwendung |
| 875 | verspricht nicht, unter voller Testlast binnen einer Sekunde aufzubauen. |
| 876 | Was hier geprüft werden soll, ist die Tastatur – und die wird unten ohne |
| 877 | jedes Warten geprüft. |
| 878 | */ |
| 879 | async function startenMit(bruecke: TeilBridge): Promise<void> { |
| 880 | vi.stubGlobal('lernApp', bruecke); |
| 881 | render(<App />); |
| 882 | await screen.findByRole('button', { name: /Weiterlernen/u }, { timeout: 5000 }); |
| 883 | } |
| 884 | |
| 885 | function taste(init: KeyboardEventInit): void { |
| 886 | window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init })); |
| 887 | } |
| 888 | |
| 889 | it.each([ |
| 890 | ['deutsche Plustaste', { key: '+', code: 'Equal', ctrlKey: true }], |
| 891 | ['amerikanisch, ohne Umschalt', { key: '=', code: 'Equal', ctrlKey: true }], |
| 892 | ['amerikanisch, mit Umschalt', { key: '+', code: 'Equal', ctrlKey: true, shiftKey: true }], |
| 893 | ['Ziffernblock', { key: '+', code: 'NumpadAdd', ctrlKey: true }], |
| 894 | ])('vergrößert über die %s', async (_name, init) => { |
| 895 | const bruecke = zoomAttrappe(); |
| 896 | await startenMit(bruecke); |
| 897 | |
| 898 | taste(init); |
| 899 | |
| 900 | /* Ohne `waitFor`, und das ist eine Verschärfung: `beiTaste` ruft die |
| 901 | Brücke im selben Durchlauf wie das Ereignis. Ein Warten darum herum |
| 902 | prüfte nicht mehr, sondern verdeckte nur, wenn es einmal nicht so |
| 903 | wäre – und war die einzige Stelle, die ablaufen konnte. */ |
| 904 | expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110); |
| 905 | }); |
| 906 | |
| 907 | it('verkleinert und setzt zurück', async () => { |
| 908 | const bruecke = zoomAttrappe({ |
| 909 | einstellungenLesen: vi.fn(() => |
| 910 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 125 }), |
| 911 | ), |
| 912 | }); |
| 913 | await startenMit(bruecke); |
| 914 | |
| 915 | /* |
| 916 | Erst wenn die gespeicherten 125 Prozent AUF DEM BILDSCHIRM stehen, darf |
| 917 | die Taste fallen. |
| 918 | |
| 919 | Bis 0.20.0 stand hier nur der Aufbau aus `startenMit`, und die Prüfung, |
| 920 | dass die 125 angekommen sind, lag allein im Ergebnis: Von 100 aus wären |
| 921 | es 90 gewesen, nicht 110. Das ist als Zusicherung richtig und als |
| 922 | Voraussetzung falsch – die Anwendung sagt nirgends zu, dass mit |
| 923 | „Weiterlernen“ auch schon `einstellungenLesen` beantwortet ist. Unter |
| 924 | voller Testlast fiel der Test deshalb in einem von drei Läufen um, |
| 925 | einzeln lief er immer durch. |
| 926 | |
| 927 | Der Anzeigestand hängt am selben Wert wie der Tastenhaken |
| 928 | (`App` reicht `einstellungen.werte.anzeigegroesse` an beide weiter). |
| 929 | Steht er da, ist der Wert in derselben Renderrunde auch im Haken |
| 930 | angekommen – die Wirkung der Taste bleibt damit ohne jedes Warten |
| 931 | geprüft, so wie oben bei `vergrößert über die %s`. |
| 932 | */ |
| 933 | expect(await screen.findByText(anzeigegroesseText(125))).toBeInTheDocument(); |
| 934 | |
| 935 | /* Von 100 aus wären es 90, nicht 110 – die Stufe belegt zusätzlich, dass |
| 936 | der gelesene Wert und nicht der Vorgabewert gilt. */ |
| 937 | taste({ key: '-', code: 'Minus', ctrlKey: true }); |
| 938 | expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110); |
| 939 | |
| 940 | taste({ key: '0', code: 'Digit0', ctrlKey: true }); |
| 941 | expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(100); |
| 942 | }); |
| 943 | |
| 944 | /* |
| 945 | Strg und Mausrad – der unter Windows verbreitetste Zoomgriff. |
| 946 | |
| 947 | Er fehlte bis 0.22.0 ausgerechnet in einer Anwendung, deren Anzeigegröße |
| 948 | bis 400 Prozent ein zentrales Merkmal ist; Electron zoomt von sich aus |
| 949 | nicht. Das Trackpad ist mit abgedeckt: Chromium meldet die Kneifgeste |
| 950 | ebenfalls als `wheel` mit `ctrlKey`. |
| 951 | */ |
| 952 | function rad(init: WheelEventInit): WheelEvent { |
| 953 | const ereignis = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init }); |
| 954 | window.dispatchEvent(ereignis); |
| 955 | return ereignis; |
| 956 | } |
| 957 | |
| 958 | it('vergrößert beim Drehen nach oben', async () => { |
| 959 | const bruecke = zoomAttrappe(); |
| 960 | await startenMit(bruecke); |
| 961 | |
| 962 | rad({ deltaY: -100, ctrlKey: true }); |
| 963 | |
| 964 | expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110); |
| 965 | }); |
| 966 | |
| 967 | it('verkleinert beim Drehen nach unten', async () => { |
| 968 | const bruecke = zoomAttrappe(); |
| 969 | await startenMit(bruecke); |
| 970 | |
| 971 | rad({ deltaY: 100, ctrlKey: true }); |
| 972 | |
| 973 | expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90); |
| 974 | }); |
| 975 | |
| 976 | it('hält das Rollen der Seite an, wenn es zoomt', async () => { |
| 977 | /* Ohne `{ passive: false }` bliebe `preventDefault()` wirkungslos, und |
| 978 | die Seite rollte zusätzlich zum Zoom. */ |
| 979 | const bruecke = zoomAttrappe(); |
| 980 | await startenMit(bruecke); |
| 981 | |
| 982 | const ereignis = rad({ deltaY: -100, ctrlKey: true }); |
| 983 | |
| 984 | expect(ereignis.defaultPrevented).toBe(true); |
| 985 | }); |
| 986 | |
| 987 | it('lässt gewöhnliches Rollen in Ruhe', async () => { |
| 988 | /* Ohne Strg gehört das Rad der Seite. Griffe die Anwendung hier zu, |
| 989 | ließe sich kein Text mehr scrollen. */ |
| 990 | const bruecke = zoomAttrappe(); |
| 991 | await startenMit(bruecke); |
| 992 | |
| 993 | const ereignis = rad({ deltaY: -100 }); |
| 994 | |
| 995 | expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled(); |
| 996 | expect(ereignis.defaultPrevented).toBe(false); |
| 997 | }); |
| 998 | |
| 999 | /* |
| 1000 | Die drei Knöpfe waren bis Fassung 0.19.2 `disabled`, sobald die Skala am |
| 1001 | Ende war. Wer sich die Schrift größer stellte, bis es nicht weiter ging, |
| 1002 | verlor den Knopf im letzten Klick unter dem Finger: Ein gesperrtes Element |
| 1003 | fällt aus der Tabulatorreihenfolge, und der Fokus landet auf `<body>` – |
| 1004 | also am Seitenanfang, weit weg von der Stelle, an der man gerade war. |
| 1005 | |
| 1006 | Deshalb wird hier der letzte Schritt selbst gegangen, nicht die Endlage |
| 1007 | hingestellt: von der vorletzten auf die oberste Stufe. Genau in diesem |
| 1008 | Übergang trat es auf. Die beiden Stufen werden aus `ANZEIGEGROESSEN` |
| 1009 | gelesen, damit der Test die Endlage prüft und nicht eine Zahl, die beim |
| 1010 | Verlängern der Leiter mitten in der Skala liegt (0.20.0: bis 400 statt |
| 1011 | bis 200). |
| 1012 | */ |
| 1013 | it('behält den Knopf im letzten Schritt zur Höchstgröße', async () => { |
| 1014 | const nutzer = userEvent.setup(); |
| 1015 | const oberste = Math.max(...ANZEIGEGROESSEN); |
| 1016 | const vorletzte = Math.max(...ANZEIGEGROESSEN.filter((stufe) => stufe < oberste)); |
| 1017 | const bruecke = zoomAttrappe({ |
| 1018 | einstellungenLesen: vi.fn(() => |
| 1019 | Promise.resolve({ |
| 1020 | ...EINSTELLUNGEN_STANDARD, |
| 1021 | zuschnittGefragt: true, |
| 1022 | anzeigegroesse: vorletzte, |
| 1023 | }), |
| 1024 | ), |
| 1025 | }); |
| 1026 | await startenMit(bruecke); |
| 1027 | |
| 1028 | const groesser = await screen.findByRole('button', { name: /Größer/u }); |
| 1029 | await nutzer.click(groesser); |
| 1030 | |
| 1031 | await waitFor(() => { |
| 1032 | expect(groesser).toHaveAttribute('aria-disabled', 'true'); |
| 1033 | }); |
| 1034 | expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(oberste); |
| 1035 | expect(groesser).not.toBeDisabled(); |
| 1036 | expect(groesser).toHaveFocus(); |
| 1037 | }); |
| 1038 | |
| 1039 | it('setzt am Ende der Skala nichts mehr, lässt aber die Gegenrichtung zu', async () => { |
| 1040 | const nutzer = userEvent.setup(); |
| 1041 | const bruecke = zoomAttrappe({ |
| 1042 | einstellungenLesen: vi.fn(() => |
| 1043 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 80 }), |
| 1044 | ), |
| 1045 | }); |
| 1046 | await startenMit(bruecke); |
| 1047 | |
| 1048 | const kleiner = await screen.findByRole('button', { name: /Kleiner/u }); |
| 1049 | await waitFor(() => { |
| 1050 | expect(kleiner).toHaveAttribute('aria-disabled', 'true'); |
| 1051 | }); |
| 1052 | |
| 1053 | await nutzer.click(kleiner); |
| 1054 | expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled(); |
| 1055 | |
| 1056 | /* Die Gegenprobe: nach oben geht es sehr wohl weiter. */ |
| 1057 | await nutzer.click(screen.getByRole('button', { name: /Größer/u })); |
| 1058 | expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90); |
| 1059 | }); |
| 1060 | |
| 1061 | it('lässt jede andere Kombination mit Strg in Ruhe', async () => { |
| 1062 | /* Der Zuhörer liegt am Fenster und sieht alles. Fasste er mehr an als die |
| 1063 | drei eigenen Tasten, bräche er Kopieren, Einfügen und Neu laden. */ |
| 1064 | const bruecke = zoomAttrappe(); |
| 1065 | await startenMit(bruecke); |
| 1066 | |
| 1067 | for (const init of [ |
| 1068 | { key: 'a', code: 'KeyA', ctrlKey: true }, |
| 1069 | { key: 'c', code: 'KeyC', ctrlKey: true }, |
| 1070 | { key: '1', code: 'Digit1', ctrlKey: true }, |
| 1071 | // Ohne Strg ist ein Pluszeichen einfach ein Pluszeichen. |
| 1072 | { key: '+', code: 'Equal' }, |
| 1073 | // Unter macOS trägt der Menü-Beschleuniger; sonst zoomte es doppelt. |
| 1074 | { key: '+', code: 'Equal', metaKey: true }, |
| 1075 | ]) { |
| 1076 | taste(init); |
| 1077 | } |
| 1078 | |
| 1079 | await new Promise((weiter) => setTimeout(weiter, 20)); |
| 1080 | expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled(); |
| 1081 | }); |
| 1082 | }); |