waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests renderer-einstellungen.test.tsx

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