waffensachkunde

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

/ app tests renderer-einstellungen.test.tsx

43,3 KB Rohdatei
app/tests/renderer-einstellungen.test.tsx — 1163 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 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 it('vergiftet den Zwischenspeicher nicht, wenn das Sichern scheitert', async () => {
340 /*
341 Befund der Prüfrunde zu 0.27.2. Beim Farbschema ist die Regel
342 ausdrücklich begründet: „Er wird nur geschrieben, wenn die Datei den
343 Wert hat … Sonst überlebte ein gescheiterter Schreibversuch als
344 ‚gültiger‘ Startwert und erzeugte beim nächsten Start genau das
345 Aufblitzen, das er verhindern soll.“ Der Textabstand hielt sie nicht:
346 Er folgte dem optimistisch gesetzten Wert und legte ihn ab, bevor die
347 Datei ihn trug. Nach einem gescheiterten Schreibversuch stand beim
348 nächsten Start zuerst „Sehr weit“ und sprang dann auf „Normal“ zurück –
349 ausgerechnet bei jemandem, dem gerade gesagt wurde, dass nichts
350 gesichert wurde.
351 */
352 vi.spyOn(console, 'warn').mockImplementation(() => undefined);
353 window.localStorage.setItem('waffensachkunde.textabstand', 'normal');
354 const nutzer = starten(
355 attrappe({
356 einstellungenSchreiben: vi.fn(() => Promise.reject(new Error('Datei schreibgeschützt'))),
357 }),
358 );
359
360 await nutzer.click(await screen.findByRole('button', { name: 'Sehr weit' }));
361
362 /* Angezeigt wird die Wahl trotzdem – die Einstellung gilt für diese
363 Sitzung, und das sagt die Meldung auch. */
364 await waitFor(() => {
365 expect(document.documentElement.dataset['textabstand']).toBe('sehr-weit');
366 });
367 await screen.findByText(/nicht dauerhaft gespeichert/u);
368
369 expect(window.localStorage.getItem('waffensachkunde.textabstand')).toBe('normal');
370 });
371
372 /*
373 Bis 0.24.1 setzte `Textabstandswahl` das Attribut selbst – also nur,
374 solange der Startbildschirm im Baum stand. Die Zuschnittfrage kommt beim
375 Erststart davor, und wer „Sehr weit“ eingestellt hatte, las ausgerechnet
376 sie in der Werksdarstellung.
377 */
378 it('trägt die Stufe schon vor dem Startbildschirm, auf der Zuschnittfrage', async () => {
379 starten(
380 attrappe({
381 einstellungenLesen: vi.fn(() =>
382 Promise.resolve({
383 ...EINSTELLUNGEN_STANDARD,
384 zuschnittGefragt: false,
385 textabstand: 'sehr-weit' as const,
386 }),
387 ),
388 }),
389 );
390
391 /* Die Zuschnittfrage steht, der Startbildschirm noch nicht. */
392 await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u });
393
394 await waitFor(() => {
395 expect(document.documentElement.dataset['textabstand']).toBe('sehr-weit');
396 });
397 });
398
399 it('sagt die gewählte Stufe an', async () => {
400 const nutzer = starten();
401
402 await nutzer.click(await screen.findByRole('button', { name: 'Weit' }));
403
404 await waitFor(() => {
405 expect(screen.getByText('Textabstand: Weit.')).toBeInTheDocument();
406 });
407 });
408 });
409
410 describe('Einstellung „Antwortmöglichkeiten mischen“', () => {
411 it('steht als Schalter im Startbildschirm und ist voreingestellt aus', async () => {
412 /* Ab Werk aus: Ein Lernvorteil des Mischens ist nicht belegt, der
413 Aufwand beim Abgleich mit dem amtlichen Katalog dagegen messbar –
414 83 % der Auswahlfragen erscheinen gemischt in anderer Reihenfolge.
415 Siehe docs/entscheidung-antwortreihenfolge.md. */
416 starten();
417
418 const schalter = await screen.findByRole('checkbox', {
419 name: 'Antwortmöglichkeiten mischen',
420 });
421
422 expect(schalter).not.toBeChecked();
423 });
424
425 it('sichert das Ein- und Ausschalten im Anwendungskern', async () => {
426 const schreiben = vi.fn((aenderung: Partial<Einstellungen>) =>
427 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }),
428 );
429 const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben }));
430 const schalter = await screen.findByRole('checkbox', {
431 name: 'Antwortmöglichkeiten mischen',
432 });
433
434 await nutzer.click(schalter);
435 expect(schreiben).toHaveBeenCalledWith({ optionenMischen: true });
436
437 await nutzer.click(schalter);
438 expect(schreiben).toHaveBeenCalledWith({ optionenMischen: false });
439 });
440
441 it('steht unter einer Überschrift „Schwierigkeit“ neben dem zweiten Regler', async () => {
442 /* Die beiden Regler gehören zusammen und sind als das beschriftet, was
443 sie sind: Erschwernisse, die man bewusst wählt. */
444 starten();
445
446 expect(
447 await screen.findByRole('heading', { name: 'Schwierigkeit', level: 3 }),
448 ).toBeInTheDocument();
449 expect(
450 await screen.findByRole('checkbox', { name: 'Anzahl der richtigen Antworten verbergen' }),
451 ).toBeInTheDocument();
452 });
453
454 it('verspricht keinen Lernvorteil und behauptet nichts über den Prüfungsbogen', async () => {
455 /* Der frühere Text sagte „in der Prüfung stehen sie ohnehin anders“ –
456 eine Tatsachenbehauptung über die echte Prüfung, für die es keine
457 Quelle gibt. Weder das BVA-Vorwort noch die AWaffV noch ein
458 Behördenmerkblatt regeln die Anordnung der Antwortmöglichkeiten. */
459 starten();
460
461 expect(
462 await screen.findByText(/Ein Lernvorteil des Mischens ist nicht belegt/u),
463 ).toBeInTheDocument();
464 expect(screen.queryByText(/in der Prüfung stehen sie ohnehin anders/u)).toBeNull();
465 });
466
467 it('reicht die Entscheidung an die Zusammenstellung der Sitzung weiter', async () => {
468 /* Bewusst mit dem vom Standard ABWEICHENDEN Wert geprüft: Stünde hier
469 `false`, wäre der Test seit der Umstellung der Vorgabe inhaltsleer –
470 er bliebe auch dann grün, wenn die Oberfläche die Einstellung gar
471 nicht mehr läse. */
472 const lernSitzung = vi.fn(() => Promise.resolve([reihenfolge(FRAGE)]));
473 const nutzer = starten(
474 attrappe({
475 lernSitzung,
476 einstellungenLesen: vi.fn(() =>
477 Promise.resolve({
478 ...EINSTELLUNGEN_STANDARD,
479 zuschnittGefragt: true,
480 optionenMischen: true,
481 }),
482 ),
483 }),
484 );
485
486 const knopf = await screen.findByRole('button', { name: /Weiterlernen/u });
487 await waitFor(() => {
488 expect(knopf).toBeEnabled();
489 });
490 await nutzer.click(knopf);
491
492 await waitFor(() => {
493 expect(lernSitzung).toHaveBeenCalledWith(
494 1,
495 expect.objectContaining({ optionenMischen: true }),
496 );
497 });
498 });
499
500 it('sagt, dass sie auch in der Prüfungssimulation gilt', async () => {
501 starten();
502
503 expect(await screen.findByText(/gilt auch in der Prüfungssimulation/u)).toBeInTheDocument();
504 });
505 });
506
507 describe('Einstellung „Vorlesen“', () => {
508 it('blendet ohne Sprachausgabe des Systems keinen Vorleseknopf ein', async () => {
509 /* Ohne `speechSynthesis` – der Normalfall in jsdom. */
510 await sitzungOeffnen(
511 attrappe({
512 einstellungenLesen: vi.fn(() =>
513 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
514 ),
515 }),
516 );
517
518 expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument();
519 });
520
521 it('erklärt, wenn das System keine Sprachausgabe anbietet', async () => {
522 starten();
523
524 expect(
525 await screen.findByText('Dieses System bietet keine Sprachausgabe an.'),
526 ).toBeInTheDocument();
527
528 /*
529 Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein
530 natives `disabled`, dafür `aria-disabled`. Gerade hier trägt das etwas –
531 der Satz darunter erklärt ja, WARUM das Kästchen nichts nützt, und ein
532 `disabled` Kästchen nimmt keinen Fokus, sodass er einem Bildschirmleser
533 nie begegnete.
534 */
535 const schalter = screen.getByRole('checkbox', {
536 name: 'Fragen und Antworten vorlesen lassen',
537 });
538 expect(schalter).toBeEnabled();
539 expect(schalter).toHaveAttribute('aria-disabled', 'true');
540 });
541
542 it('lässt das gesperrte Vorlesekästchen nichts umstellen', async () => {
543 const einstellungenSchreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD));
544 const nutzer = starten(attrappe({ einstellungenSchreiben }));
545
546 const schalter = await screen.findByRole('checkbox', {
547 name: 'Fragen und Antworten vorlesen lassen',
548 });
549 await nutzer.click(schalter);
550
551 expect(schalter).not.toBeChecked();
552 expect(einstellungenSchreiben).not.toHaveBeenCalled();
553 });
554
555 it('zeigt den Knopf erst, wenn die Einstellung an ist', async () => {
556 sprachausgabeStellen();
557 await sitzungOeffnen();
558
559 expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument();
560 });
561
562 it('liest die Frage samt Antwortmöglichkeiten und löst Kürzel auf', async () => {
563 const stimme = sprachausgabeStellen();
564 const nutzer = await sitzungOeffnen(
565 attrappe({
566 einstellungenLesen: vi.fn(() =>
567 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
568 ),
569 }),
570 );
571
572 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
573
574 const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' ');
575 expect(gesamt).toContain('Paragraf 12 Absatz 4 Waffengesetz');
576 expect(gesamt).toContain('b: Nach dem Waffengesetz');
577 expect(stimme.gesprochen.every((stueck) => stueck.lang === 'de-DE')).toBe(true);
578 });
579
580 it('bietet während der Ausgabe das Anhalten an (WCAG 1.4.2)', async () => {
581 const stimme = sprachausgabeStellen();
582 const nutzer = await sitzungOeffnen(
583 attrappe({
584 einstellungenLesen: vi.fn(() =>
585 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
586 ),
587 }),
588 );
589
590 await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
591
592 const halt = await screen.findByRole('button', { name: /Vorlesen beenden/u });
593 const vorher = stimme.abbrueche();
594 await nutzer.click(halt);
595
596 expect(stimme.abbrueche()).toBeGreaterThan(vorher);
597 expect(await screen.findByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument();
598 });
599
600 it('sagt von selbst nichts, solange nur die Schaltfläche eingeschaltet ist', async () => {
601 /*
602 Gemeldet von einem Nutzer: Nach dem Bestätigen begann die Ansage von
603 selbst. Die eigenen Texte gaben ihm recht – Schalter wie Handbuch sagten
604 eine Schaltfläche zu, von selbstständigem Sprechen stand in keinem etwas.
605 */
606 const stimme = sprachausgabeStellen();
607 const nutzer = await sitzungOeffnen(
608 attrappe({
609 einstellungenLesen: vi.fn(() =>
610 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
611 ),
612 }),
613 );
614
615 stimme.gesprochen.length = 0;
616 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
617 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
618 await screen.findByRole('heading', { name: 'Warum das so ist' });
619
620 expect(stimme.gesprochen).toHaveLength(0);
621 /* Der Knopf ist trotzdem da und liest jetzt die Rückmeldung. */
622 expect(screen.getByRole('button', { name: /Antwort vorlesen/u })).toBeInTheDocument();
623 });
624
625 it('liest Frage und Rückmeldung von selbst, wenn man es einschaltet', async () => {
626 const stimme = sprachausgabeStellen();
627 const nutzer = await sitzungOeffnen(
628 attrappe({
629 einstellungenLesen: vi.fn(() =>
630 Promise.resolve({
631 ...EINSTELLUNGEN_STANDARD,
632 zuschnittGefragt: true,
633 vorlesen: true,
634 vorlesenAutomatisch: true,
635 }),
636 ),
637 }),
638 );
639
640 /* Zuerst die Frage – ohne dass jemand gedrückt hat. */
641 await waitFor(() => {
642 expect(stimme.gesprochen.length).toBeGreaterThan(0);
643 });
644 expect(stimme.gesprochen.map((s) => s.text).join(' ')).toContain('Waffengesetz');
645
646 stimme.gesprochen.length = 0;
647 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
648 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
649
650 await waitFor(() => {
651 expect(stimme.gesprochen.length).toBeGreaterThan(0);
652 });
653 const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' ');
654 expect(gesamt).toContain('Richtig');
655 expect(gesamt).toContain('Richtig ist Antwort b.');
656 /* Die Kurzfassung der Erklärung, nicht der Volltext: Der dauert
657 gesprochen über eine Minute und die nächste Frage wartet. */
658 expect(gesamt).toContain('Der Erwerb richtet sich nach dem Waffengesetz.');
659 expect(gesamt).not.toContain('Ausführlich');
660 });
661
662 it('spricht dieselbe Stelle nicht zweimal', async () => {
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 vorlesenAutomatisch: true,
672 }),
673 ),
674 }),
675 );
676
677 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
678 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
679 await waitFor(() => {
680 expect(stimme.gesprochen.length).toBeGreaterThan(0);
681 });
682
683 const nachErgebnis = stimme.gesprochen.length;
684 /* Ein Klick, der die Ansicht neu zeichnet, ohne die Frage zu wechseln. */
685 await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));
686
687 expect(stimme.gesprochen).toHaveLength(nachErgebnis);
688 });
689
690 it('liest die ausführliche Begründung nur bei falscher Antwort', async () => {
691 /*
692 Der zweite Schalter, und zwar allein: Ohne „von selbst vorlesen“ bleibt
693 es bei der einen Ansage, die man bestellt hat – dem langen Text, wenn es
694 danebenging. Genau die Kombination, die sich sonst nicht einstellen
695 ließe: selbst lesen, aber bei einem Fehler die Begründung hören.
696 */
697 const stimme = sprachausgabeStellen();
698 const nutzer = await sitzungOeffnen(
699 attrappe({
700 einstellungenLesen: vi.fn(() =>
701 Promise.resolve({
702 ...EINSTELLUNGEN_STANDARD,
703 zuschnittGefragt: true,
704 vorlesen: true,
705 vorlesenErklaerungBeiFehler: true,
706 }),
707 ),
708 }),
709 );
710
711 stimme.gesprochen.length = 0;
712 /* Die falsche der beiden Antwortmöglichkeiten. */
713 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Sprengstoffgesetz/u }));
714 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
715
716 await waitFor(() => {
717 expect(stimme.gesprochen.length).toBeGreaterThan(0);
718 });
719 const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' ');
720 expect(gesamt).toContain('Ausführlich');
721 /* Nur die Begründung: Die Rückmeldung selbst hat niemand bestellt. */
722 expect(gesamt).not.toContain('Richtig ist Antwort b.');
723 });
724
725 it('schweigt bei richtiger Antwort, auch wenn die Begründung eingeschaltet ist', async () => {
726 const stimme = sprachausgabeStellen();
727 const nutzer = await sitzungOeffnen(
728 attrappe({
729 einstellungenLesen: vi.fn(() =>
730 Promise.resolve({
731 ...EINSTELLUNGEN_STANDARD,
732 zuschnittGefragt: true,
733 vorlesen: true,
734 vorlesenErklaerungBeiFehler: true,
735 }),
736 ),
737 }),
738 );
739
740 stimme.gesprochen.length = 0;
741 await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u }));
742 await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' }));
743 await screen.findByRole('heading', { name: 'Warum das so ist' });
744
745 expect(stimme.gesprochen).toHaveLength(0);
746 });
747
748 it('zeigt die Zusatzschalter erst, wenn die Sprachausgabe an ist', async () => {
749 /* Nicht als gesperrte Kästchen: Gesperrte Bedienelemente nehmen keinen
750 Fokus, ihre Erklärung würde nie vorgelesen. Dass es sie gibt, sagt der
751 Satz am Schalter darüber. */
752 sprachausgabeStellen();
753 const nutzer = starten(attrappe());
754
755 expect(
756 await screen.findByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }),
757 ).toBeInTheDocument();
758 expect(screen.queryByRole('checkbox', { name: /von selbst vorlesen/u })).toBeNull();
759 expect(screen.queryByRole('checkbox', { name: /ausführliche Begründung/u })).toBeNull();
760
761 await nutzer.click(
762 screen.getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }),
763 );
764
765 expect(
766 await screen.findByRole('checkbox', { name: 'Frage und Rückmeldung von selbst vorlesen' }),
767 ).not.toBeChecked();
768 expect(
769 screen.getByRole('checkbox', {
770 name: 'Bei falscher Antwort die ausführliche Begründung vorlesen',
771 }),
772 ).not.toBeChecked();
773 });
774
775 it('warnt, wenn zusätzlich ein Screenreader läuft', async () => {
776 sprachausgabeStellen();
777 starten(
778 attrappe({
779 hilfsmittelAktiv: vi.fn(() => Promise.resolve(true)),
780 einstellungenLesen: vi.fn(() =>
781 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
782 ),
783 }),
784 );
785
786 expect(await screen.findByText(/Beide zugleich sprechen durcheinander/u)).toBeInTheDocument();
787 });
788 });
789
790 describe('Neu anfangen', () => {
791 it('setzt den Lernstand erst nach einer Rückfrage zurück', async () => {
792 /*
793 Unwiderruflich, deshalb eine Bestätigung (WCAG 3.3.4). Und sie sagt,
794 was verschwindet – eine Rückfrage, die nur „Sind Sie sicher?“ fragt,
795 hilft beim Entscheiden nicht.
796 */
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
802 expect(
803 screen.getByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }),
804 ).toBeInTheDocument();
805 expect(zuruecksetzen).not.toHaveBeenCalled();
806
807 await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' }));
808
809 await waitFor(() => {
810 expect(zuruecksetzen).toHaveBeenCalledWith(1, null);
811 });
812 expect(await screen.findByText(/Ihr Lernstand ist zurückgesetzt/u)).toBeInTheDocument();
813 });
814
815 it('rechnet Lernplan, Verlauf und hartnäckige Fragen danach neu', async () => {
816 /*
817 Befund der Prüfrunde zu 0.27.2. Zurückgesetzt wurde der Lernstand, die
818 Karten daneben blieben auf dem alten Stand stehen: Die Einstiegskarte
819 sagte weiter „Sie sind auf dem Laufenden“, obwohl der ganze Katalog
820 wieder offen ist, und die Liste der hartnäckigen Fragen hielt dem
821 Lernenden Schwächen aus einem Stand vor, den es nicht mehr gibt — ihr
822 eigener Kommentar nennt das „eine fremde Auskunft unter dem falschen
823 Namen“. Der Weg über eine eingespielte Sicherung lud den Plan längst
824 neu; dieser hier nicht.
825
826 Der Startbildschirm bleibt dabei stehen: `lernstand.neuLaden()` ändert
827 die Profil-ID ausdrücklich nicht, und ohne Ansichtswechsel baut sich
828 nichts neu auf.
829 */
830 const lernPlan = vi.fn(() => Promise.resolve(LERNPLAN));
831 const lernHartnaeckige = vi.fn(() => Promise.resolve([]));
832 const lernVerlauf = vi.fn(() => Promise.resolve([]));
833 const nutzer = starten(attrappe({ lernPlan, lernHartnaeckige, lernVerlauf }));
834
835 await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }));
836 const vorher = {
837 plan: lernPlan.mock.calls.length,
838 hartnaeckig: lernHartnaeckige.mock.calls.length,
839 verlauf: lernVerlauf.mock.calls.length,
840 };
841
842 await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' }));
843 await screen.findByText(/Ihr Lernstand ist zurückgesetzt/u);
844
845 await waitFor(() => {
846 expect(lernPlan.mock.calls.length).toBeGreaterThan(vorher.plan);
847 });
848 await waitFor(() => {
849 expect(lernHartnaeckige.mock.calls.length).toBeGreaterThan(vorher.hartnaeckig);
850 });
851 expect(lernVerlauf.mock.calls.length).toBeGreaterThan(vorher.verlauf);
852 });
853
854 it('gibt den Fokus an den auslösenden Knopf zurück', async () => {
855 /*
856 Die Rückfrage holt den Fokus zu sich. Verschwände sie, ohne ihn
857 zurückzugeben, fiele er auf <body> – wer per Tastatur bedient, stünde
858 am Seitenanfang und müsste sich durch alle Einstellungen zurückarbeiten.
859 */
860 const nutzer = starten();
861 const knopf = await screen.findByRole('button', { name: 'Lernstand zurücksetzen' });
862
863 await nutzer.click(knopf);
864 await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' }));
865
866 expect(screen.getByRole('button', { name: 'Lernstand zurücksetzen' })).toHaveFocus();
867 });
868
869 it('lässt sich abbrechen, ohne etwas zu löschen', async () => {
870 const zuruecksetzen = vi.fn(() => Promise.resolve(UEBERSICHT));
871 const nutzer = starten(attrappe({ lernZuruecksetzen: zuruecksetzen }));
872
873 await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }));
874 await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' }));
875
876 expect(zuruecksetzen).not.toHaveBeenCalled();
877 expect(
878 screen.queryByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }),
879 ).not.toBeInTheDocument();
880 });
881
882 it('sagt vorher, was verschwindet und was bleibt', async () => {
883 starten();
884
885 const text = await screen.findByText(/Setzt Ihren Lernstand auf null/u);
886
887 expect(text).toHaveTextContent('Merkliste');
888 expect(text).toHaveTextContent('Prüfungssimulationen');
889 expect(text).toHaveTextContent('Einstellungen');
890 });
891
892 it('meldet einen Fehlschlag, statt Erfolg vorzutäuschen', async () => {
893 const nutzer = starten(
894 attrappe({
895 lernZuruecksetzen: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))),
896 }),
897 );
898
899 await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }));
900 await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' }));
901
902 expect(await screen.findByText(/Datenbank gesperrt/u)).toBeInTheDocument();
903 });
904 });
905
906 describe('Anzeigegröße über die Tastatur', () => {
907 /*
908 Gemeldet und nachgemessen: `Strg` und `+` taten unter Windows nichts.
909 Electron liest den Beschleunigernamen `Plus` als die umgeschaltete Form von
910 `=` und meldet damit faktisch `Strg+Umschalt+=` beim Betriebssystem an —
911 richtig für eine amerikanische Tastatur, falsch für eine deutsche, auf der
912 `+` eine eigene Taste ohne Umschalt ist.
913
914 Geprüft wird hier das Zeichen, das die Tastatur liefert. Das Layout selbst
915 muss die Anwendung nie kennen — wer es abfragt, trifft Annahmen, die bei
916 der nächsten Tastatur wieder falsch sind. Genau dieser Fehler steckte im
917 Beschleuniger.
918 */
919 /* Die gemeinsame Attrappe dieser Datei fuehrt die Anzeigekanaele bewusst
920 nicht - andere Tests hier pruefen gerade den Fall ohne Anwendungskern.
921 Fuer die Tastatur braucht es sie, also kommen sie hier dazu. */
922 function zoomAttrappe(ueberschreibungen: TeilBridge = {}): TeilBridge {
923 return attrappe({
924 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
925 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
926 ...ueberschreibungen,
927 });
928 }
929
930 /*
931 Der Aufbau ist die einzige Stelle hier, die wirklich warten muss – und die
932 einzige, die je in einen Zeitablauf lief.
933
934 Nachgemessen: Die Schaltfläche „Weiterlernen“ erscheint erst, wenn die
935 gespeicherten Einstellungen geladen sind. Mit einer künstlich um zwei
936 Sekunden verzögerten Antwort wird sie überhaupt nicht gefunden. Es hängen
937 also drei Brückenaufrufe und der vollständige Aufbau der Anwendung an
938 diesem einen Warten.
939
940 Die Vorgabe von Testing Library dafür sind 1000 ms. Im vollen Testlauf mit
941 40 Dateien reicht das gelegentlich nicht: Der Test fiel zweimal aus, beide
942 Male nach 1071 bzw. 1083 ms – die Kennmarke genau dieses Zeitablaufs.
943 Allein lief er immer durch.
944
945 Die 5000 ms sind keine Nachsicht mit einem wackligen Test, sondern die
946 Berichtigung einer Annahme, die nirgends zugesagt ist: Die Anwendung
947 verspricht nicht, unter voller Testlast binnen einer Sekunde aufzubauen.
948 Was hier geprüft werden soll, ist die Tastatur – und die wird unten ohne
949 jedes Warten geprüft.
950 */
951 async function startenMit(bruecke: TeilBridge): Promise<void> {
952 vi.stubGlobal('lernApp', bruecke);
953 render(<App />);
954 await screen.findByRole('button', { name: /Weiterlernen/u }, { timeout: 5000 });
955 }
956
957 function taste(init: KeyboardEventInit): void {
958 window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }));
959 }
960
961 it.each([
962 ['deutsche Plustaste', { key: '+', code: 'Equal', ctrlKey: true }],
963 ['amerikanisch, ohne Umschalt', { key: '=', code: 'Equal', ctrlKey: true }],
964 ['amerikanisch, mit Umschalt', { key: '+', code: 'Equal', ctrlKey: true, shiftKey: true }],
965 ['Ziffernblock', { key: '+', code: 'NumpadAdd', ctrlKey: true }],
966 ])('vergrößert über die %s', async (_name, init) => {
967 const bruecke = zoomAttrappe();
968 await startenMit(bruecke);
969
970 taste(init);
971
972 /* Ohne `waitFor`, und das ist eine Verschärfung: `beiTaste` ruft die
973 Brücke im selben Durchlauf wie das Ereignis. Ein Warten darum herum
974 prüfte nicht mehr, sondern verdeckte nur, wenn es einmal nicht so
975 wäre – und war die einzige Stelle, die ablaufen konnte. */
976 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110);
977 });
978
979 it('verkleinert und setzt zurück', async () => {
980 const bruecke = zoomAttrappe({
981 einstellungenLesen: vi.fn(() =>
982 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 125 }),
983 ),
984 });
985 await startenMit(bruecke);
986
987 /*
988 Erst wenn die gespeicherten 125 Prozent AUF DEM BILDSCHIRM stehen, darf
989 die Taste fallen.
990
991 Bis 0.20.0 stand hier nur der Aufbau aus `startenMit`, und die Prüfung,
992 dass die 125 angekommen sind, lag allein im Ergebnis: Von 100 aus wären
993 es 90 gewesen, nicht 110. Das ist als Zusicherung richtig und als
994 Voraussetzung falsch – die Anwendung sagt nirgends zu, dass mit
995 „Weiterlernen“ auch schon `einstellungenLesen` beantwortet ist. Unter
996 voller Testlast fiel der Test deshalb in einem von drei Läufen um,
997 einzeln lief er immer durch.
998
999 Der Anzeigestand hängt am selben Wert wie der Tastenhaken
1000 (`App` reicht `einstellungen.werte.anzeigegroesse` an beide weiter).
1001 Steht er da, ist der Wert in derselben Renderrunde auch im Haken
1002 angekommen – die Wirkung der Taste bleibt damit ohne jedes Warten
1003 geprüft, so wie oben bei `vergrößert über die %s`.
1004 */
1005 expect(await screen.findByText(anzeigegroesseText(125))).toBeInTheDocument();
1006
1007 /* Von 100 aus wären es 90, nicht 110 – die Stufe belegt zusätzlich, dass
1008 der gelesene Wert und nicht der Vorgabewert gilt. */
1009 taste({ key: '-', code: 'Minus', ctrlKey: true });
1010 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110);
1011
1012 taste({ key: '0', code: 'Digit0', ctrlKey: true });
1013 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(100);
1014 });
1015
1016 /*
1017 Strg und Mausrad – der unter Windows verbreitetste Zoomgriff.
1018
1019 Er fehlte bis 0.22.0 ausgerechnet in einer Anwendung, deren Anzeigegröße
1020 bis 400 Prozent ein zentrales Merkmal ist; Electron zoomt von sich aus
1021 nicht. Das Trackpad ist mit abgedeckt: Chromium meldet die Kneifgeste
1022 ebenfalls als `wheel` mit `ctrlKey`.
1023 */
1024 function rad(init: WheelEventInit): WheelEvent {
1025 const ereignis = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
1026 window.dispatchEvent(ereignis);
1027 return ereignis;
1028 }
1029
1030 it('vergrößert beim Drehen nach oben', async () => {
1031 const bruecke = zoomAttrappe();
1032 await startenMit(bruecke);
1033
1034 rad({ deltaY: -100, ctrlKey: true });
1035
1036 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110);
1037 });
1038
1039 it('verkleinert beim Drehen nach unten', async () => {
1040 const bruecke = zoomAttrappe();
1041 await startenMit(bruecke);
1042
1043 rad({ deltaY: 100, ctrlKey: true });
1044
1045 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90);
1046 });
1047
1048 it('hält das Rollen der Seite an, wenn es zoomt', async () => {
1049 /* Ohne `{ passive: false }` bliebe `preventDefault()` wirkungslos, und
1050 die Seite rollte zusätzlich zum Zoom. */
1051 const bruecke = zoomAttrappe();
1052 await startenMit(bruecke);
1053
1054 const ereignis = rad({ deltaY: -100, ctrlKey: true });
1055
1056 expect(ereignis.defaultPrevented).toBe(true);
1057 });
1058
1059 it('lässt gewöhnliches Rollen in Ruhe', async () => {
1060 /* Ohne Strg gehört das Rad der Seite. Griffe die Anwendung hier zu,
1061 ließe sich kein Text mehr scrollen. */
1062 const bruecke = zoomAttrappe();
1063 await startenMit(bruecke);
1064
1065 const ereignis = rad({ deltaY: -100 });
1066
1067 expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled();
1068 expect(ereignis.defaultPrevented).toBe(false);
1069 });
1070
1071 /*
1072 Die drei Knöpfe waren bis Fassung 0.19.2 `disabled`, sobald die Skala am
1073 Ende war. Wer sich die Schrift größer stellte, bis es nicht weiter ging,
1074 verlor den Knopf im letzten Klick unter dem Finger: Ein gesperrtes Element
1075 fällt aus der Tabulatorreihenfolge, und der Fokus landet auf `<body>` –
1076 also am Seitenanfang, weit weg von der Stelle, an der man gerade war.
1077
1078 Deshalb wird hier der letzte Schritt selbst gegangen, nicht die Endlage
1079 hingestellt: von der vorletzten auf die oberste Stufe. Genau in diesem
1080 Übergang trat es auf. Die beiden Stufen werden aus `ANZEIGEGROESSEN`
1081 gelesen, damit der Test die Endlage prüft und nicht eine Zahl, die beim
1082 Verlängern der Leiter mitten in der Skala liegt (0.20.0: bis 400 statt
1083 bis 200).
1084 */
1085 it('behält den Knopf im letzten Schritt zur Höchstgröße', async () => {
1086 const nutzer = userEvent.setup();
1087 const oberste = Math.max(...ANZEIGEGROESSEN);
1088 const vorletzte = Math.max(...ANZEIGEGROESSEN.filter((stufe) => stufe < oberste));
1089 const bruecke = zoomAttrappe({
1090 einstellungenLesen: vi.fn(() =>
1091 Promise.resolve({
1092 ...EINSTELLUNGEN_STANDARD,
1093 zuschnittGefragt: true,
1094 anzeigegroesse: vorletzte,
1095 }),
1096 ),
1097 });
1098 await startenMit(bruecke);
1099
1100 const groesser = await screen.findByRole('button', { name: /Größer/u });
1101 await nutzer.click(groesser);
1102
1103 await waitFor(() => {
1104 expect(groesser).toHaveAttribute('aria-disabled', 'true');
1105 });
1106 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(oberste);
1107 expect(groesser).not.toBeDisabled();
1108 expect(groesser).toHaveFocus();
1109 });
1110
1111 it('setzt am Ende der Skala nichts mehr, lässt aber die Gegenrichtung zu', async () => {
1112 const nutzer = userEvent.setup();
1113 const bruecke = zoomAttrappe({
1114 einstellungenLesen: vi.fn(() =>
1115 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 80 }),
1116 ),
1117 });
1118 await startenMit(bruecke);
1119
1120 const kleiner = await screen.findByRole('button', { name: /Kleiner/u });
1121 await waitFor(() => {
1122 expect(kleiner).toHaveAttribute('aria-disabled', 'true');
1123 });
1124
1125 await nutzer.click(kleiner);
1126 expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled();
1127
1128 /* Die Gegenprobe: nach oben geht es sehr wohl weiter. */
1129 await nutzer.click(screen.getByRole('button', { name: /Größer/u }));
1130 expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90);
1131 });
1132
1133 it('lässt jede andere Kombination mit Strg in Ruhe', async () => {
1134 /* Der Zuhörer liegt am Fenster und sieht alles. Fasste er mehr an als die
1135 drei eigenen Tasten, bräche er Kopieren, Einfügen und Neu laden. */
1136 const bruecke = zoomAttrappe();
1137 await startenMit(bruecke);
1138
1139 for (const init of [
1140 { key: 'a', code: 'KeyA', ctrlKey: true },
1141 { key: 'c', code: 'KeyC', ctrlKey: true },
1142 { key: '1', code: 'Digit1', ctrlKey: true },
1143 // Ohne Strg ist ein Pluszeichen einfach ein Pluszeichen.
1144 { key: '+', code: 'Equal' },
1145 // Unter macOS trägt der Menü-Beschleuniger; sonst zoomte es doppelt.
1146 { key: '+', code: 'Equal', metaKey: true },
1147 /*
1148 Befund der Prüfrunde zu 0.27.2. Bei ausgeschaltetem NumLock ist die
1149 0 des Ziffernblocks die Einfügen-Taste, und Strg+Einfg ist unter
1150 Windows der Kopierbefehl. Chromium meldet dafür `key: 'Insert'` bei
1151 `code: 'Numpad0'`. Die Prüfung sah nur auf `code`, verschluckte den
1152 Befehl und setzte dabei die Anzeigegröße auf 100 zurück — genau das,
1153 was der Kommentar an dieser Stelle ausschließt.
1154 */
1155 { key: 'Insert', code: 'Numpad0', ctrlKey: true },
1156 ]) {
1157 taste(init);
1158 }
1159
1160 await new Promise((weiter) => setTimeout(weiter, 20));
1161 expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled();
1162 });
1163 });