/** * Die beiden Einstellungen, die zuvor nur im Vertrag standen. * * `optionenMischen` und `vorlesen` waren in `Einstellungen` deklariert, mit * Standardwert versehen und vom Anwendungskern gespeichert – nur wirkte * keine von beiden. Diese Datei prüft, dass sie ankommen: in der * Zusammenstellung der Sitzung, im Prüfungsauftrag und in der Sprachausgabe. * * Die Sprachausgabe wird dabei nachgebildet. Ob dieses Gerät eine deutsche * Stimme besitzt, entscheidet das Betriebssystem und darf keinen Testlauf * scheitern lassen; geprüft wird, **was** gesprochen würde und **wann**. */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/renderer/src/App'; import { ANZEIGEGROESSEN, anzeigegroesseText } from '../src/shared/ansicht'; import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; import type { Erklaerungen } from '../src/shared/erklaerungen'; import type { Glossar } from '../src/shared/glossar'; import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type Einstellungen, type LernAppBridge, } from '../src/shared/ipc'; import type { Frage, Katalog, RichText } from '../src/shared/katalog'; import type { Lernplan } from '../src/shared/lernplan'; import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand'; import { NORMTEXTE_LEER } from '../src/shared/normtexte'; import { THEMEN_LEER } from '../src/shared/themen'; type Nutzer = ReturnType; type TeilBridge = Partial; // ─── Katalogattrappe ──────────────────────────────────────────────────── function nurText(text: string): RichText { return { text, segmente: [{ t: text }] }; } const FRAGE: Frage = { id: 'I.1-87', amtliche_nummer: '1.87', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 23, frage: nurText('Wonach richtet sich der Erwerb nach § 12 Abs. 4 WaffG?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Nach dem Beschussgesetz'), korrekt: false, bilder: [] }, { label: 'b', inhalt: nurText('Nach dem Waffengesetz'), korrekt: true, bilder: [] }, { label: 'c', inhalt: nurText('Nach dem Sprengstoffgesetz'), korrekt: false, bilder: [] }, ], }; const KATALOG: Katalog = { meta: { titel: 'Attrappe', herausgeber: 'Attrappe', stand: '2024-01-01', quellenangabe: 'Attrappe', quelle_url: 'https://example.invalid/', quelldatei_sha256: '0'.repeat(64), fragen_gesamt: 1, }, kapitel: [ { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }], }, ], fragen: [FRAGE], bilder: [], }; const ERKLAERUNGEN: Erklaerungen = { meta: { version: 1, stand: '2026-08-01', hinweis: 'Attrappe', gesetzesstand: {} }, zuFrage: { 'I.1-87': { kurz: 'Der Erwerb richtet sich nach dem WaffG.', text: 'Ausführlich: Der Erwerb richtet sich nach § 12 Abs. 4 WaffG.', fundstellen: [{ gesetz: 'WaffG', norm: '§ 12', absatz: '4' }], }, }, }; const GLOSSAR: Glossar = { meta: { version: 1, stand: '2026-08-01', hinweis: 'Attrappe', gesetzesstand: {} }, eintraege: [], }; const PROFIL: Profil = { id: 1, name: 'Standard', pruefungstermin: null, kapitelAusschluss: [], erstelltAm: '2026-01-01T00:00:00.000Z', }; const LERNPLAN: Lernplan = { termin: null, tageBisTermin: null, gesamtFragen: 1, nieBeantwortet: 1, faellig: 0, zielquote: 0.9, prognoseHeute: 0, prognoseAmTermin: null, pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 }, machbarkeit: 'kein_termin', sekundenProFrage: 25, }; const UEBERSICHT: Lernuebersicht = { fragenGesamt: 1, beantwortet: 0, belegt: 0, reifegrad: 0 / 1, stufe: 'ohne_beleg', deckelnd: [], faellig: 0, gemerkt: 0, offen: 0, fehler: 0, heuteRichtig: 0, heuteFalsch: 0, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, bereiche: [], themengruppen: [], }; const STAND: FrageStand = { frageId: FRAGE.id, versuche: 1, richtige: 1, zuletztBeantwortet: '2026-01-01T08:00:00.000Z', faelligAb: null, gemerkt: false, letzteBewertung: 'gut', }; const INFO: AnwendungsInfo = { anwendungsVersion: '0.0.0-attrappe', baukennung: 'abc1234', baustand: '2026-08-21', electronVersion: '43.4.1', chromeVersion: '140.0.0.0', nodeVersion: '22.19.1', plattform: 'win32', datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' }, }; function reihenfolge(frage: Frage): SitzungsFrage { return { frageId: frage.id, optionsReihenfolge: (frage.optionen ?? []).map((o) => o.label), gemerkt: false, }; } function attrappe(ueberschreibungen: TeilBridge = {}): TeilBridge { return { hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)), hilfsmittelBeobachten: vi.fn(() => () => undefined), menuebefehlBeobachten: vi.fn(() => () => undefined), /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), ), einstellungenSchreiben: vi.fn((aenderung: Partial) => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }), ), katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)), glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)), /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0. Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext; den prueft tests/normstelle.test.tsx an eigenen Faellen. */ normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)), themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)), anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), profilListe: vi.fn(() => Promise.resolve([PROFIL])), profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)), profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)), profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])), lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE)])), lernAntworten: vi.fn(() => Promise.resolve(STAND)), lernMerken: vi.fn(() => Promise.resolve(STAND)), lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), ...ueberschreibungen, }; } // ─── Attrappe der Sprachausgabe ───────────────────────────────────────── interface GesprochenesStueck { readonly text: string; readonly lang: string; } class AeusserungAttrappe { lang = ''; voice: unknown = null; onend: (() => void) | null = null; onerror: ((ereignis: { error: string }) => void) | null = null; constructor(public readonly text: string) {} } /** * Bildet `window.speechSynthesis` nach. * * `getVoices()` gibt bewusst eine leere Liste zurück – genau so verhält sich * Electron unter Windows vor der ersten Äußerung. Die Anwendung darf davon * nicht abhängen. */ function sprachausgabeStellen(): { gesprochen: GesprochenesStueck[]; abbrueche: () => number; beenden: () => void; } { const gesprochen: GesprochenesStueck[] = []; let abgebrochen = 0; let letzte: AeusserungAttrappe | null = null; vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe); vi.stubGlobal('speechSynthesis', { speak: (aeusserung: AeusserungAttrappe) => { gesprochen.push({ text: aeusserung.text, lang: aeusserung.lang }); letzte = aeusserung; }, cancel: () => { abgebrochen += 1; }, getVoices: () => [], speaking: false, pending: false, paused: false, }); return { gesprochen, abbrueche: () => abgebrochen, beenden: () => { letzte?.onend?.(); }, }; } function starten(bruecke: TeilBridge = attrappe()): Nutzer { vi.stubGlobal('lernApp', bruecke); const nutzer = userEvent.setup(); render(); return nutzer; } async function sitzungOeffnen(bruecke: TeilBridge = attrappe()): Promise { const nutzer = starten(bruecke); const knopf = await screen.findByRole('button', { name: /Weiterlernen/u }); await waitFor(() => { expect(knopf).toBeEnabled(); }); await nutzer.click(knopf); await screen.findByRole('heading', { name: /Frage 1\.87/u }); return nutzer; } afterEach(() => { vi.unstubAllGlobals(); erklaerungenZwischenspeicherLeeren(); glossarZwischenspeicherLeeren(); normtexteZwischenspeicherLeeren(); themenZwischenspeicherLeeren(); }); // ─── Tests ────────────────────────────────────────────────────────────── /* Textabstände (WCAG 1.4.12). Im Browser erfüllt man das Kriterium, indem nutzereigene Stilblätter toleriert werden. In der ausgelieferten Anwendung gibt es weder Erweiterungen noch Entwicklerwerkzeuge – die Anwendung ist die einzige Stelle, die Zeilen-, Wort- und Zeichenabstand ändern kann, und sie bot es bis 0.22.0 nicht an. */ describe('Einstellung „Textdarstellung“', () => { it('steht als eigene Karte in den Einstellungen', async () => { /* Getrennt von der Anzeigegröße: Der Zoom skaliert alles gemeinsam, der Abstand gibt bei gleicher Größe mehr Luft. Wer mit Legasthenie liest, braucht oft das Zweite. */ starten(); expect(await screen.findByRole('heading', { name: 'Textdarstellung' })).toBeInTheDocument(); }); it('beginnt bei der gewohnten Darstellung', async () => { starten(); expect( await screen.findByRole('button', { name: 'Normal', pressed: true }), ).toBeInTheDocument(); }); it('schreibt die Stufe an das Wurzelelement', async () => { /* Dort und nur dort wirken die Token aus `tokens.css`. Ohne diese Zeile wäre die Einstellung gespeichert und wirkungslos. */ const nutzer = starten(); await nutzer.click(await screen.findByRole('button', { name: 'Weit' })); await waitFor(() => { expect(document.documentElement.dataset['textabstand']).toBe('weit'); }); }); it('sichert die Stufe im Anwendungskern', async () => { const schreiben = vi.fn((aenderung: Partial) => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }), ); const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben })); await nutzer.click(await screen.findByRole('button', { name: 'Sehr weit' })); expect(schreiben).toHaveBeenCalledWith({ textabstand: 'sehr-weit' }); }); /* Bis 0.24.1 setzte `Textabstandswahl` das Attribut selbst – also nur, solange der Startbildschirm im Baum stand. Die Zuschnittfrage kommt beim Erststart davor, und wer „Sehr weit“ eingestellt hatte, las ausgerechnet sie in der Werksdarstellung. */ it('trägt die Stufe schon vor dem Startbildschirm, auf der Zuschnittfrage', async () => { starten( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: false, textabstand: 'sehr-weit' as const, }), ), }), ); /* Die Zuschnittfrage steht, der Startbildschirm noch nicht. */ await screen.findByRole('heading', { name: /Lernen Sie .* mit\?/u }); await waitFor(() => { expect(document.documentElement.dataset['textabstand']).toBe('sehr-weit'); }); }); it('sagt die gewählte Stufe an', async () => { const nutzer = starten(); await nutzer.click(await screen.findByRole('button', { name: 'Weit' })); await waitFor(() => { expect(screen.getByText('Textabstand: Weit.')).toBeInTheDocument(); }); }); }); describe('Einstellung „Antwortmöglichkeiten mischen“', () => { it('steht als Schalter im Startbildschirm und ist voreingestellt aus', async () => { /* Ab Werk aus: Ein Lernvorteil des Mischens ist nicht belegt, der Aufwand beim Abgleich mit dem amtlichen Katalog dagegen messbar – 83 % der Auswahlfragen erscheinen gemischt in anderer Reihenfolge. Siehe docs/entscheidung-antwortreihenfolge.md. */ starten(); const schalter = await screen.findByRole('checkbox', { name: 'Antwortmöglichkeiten mischen', }); expect(schalter).not.toBeChecked(); }); it('sichert das Ein- und Ausschalten im Anwendungskern', async () => { const schreiben = vi.fn((aenderung: Partial) => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, ...aenderung }), ); const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben })); const schalter = await screen.findByRole('checkbox', { name: 'Antwortmöglichkeiten mischen', }); await nutzer.click(schalter); expect(schreiben).toHaveBeenCalledWith({ optionenMischen: true }); await nutzer.click(schalter); expect(schreiben).toHaveBeenCalledWith({ optionenMischen: false }); }); it('steht unter einer Überschrift „Schwierigkeit“ neben dem zweiten Regler', async () => { /* Die beiden Regler gehören zusammen und sind als das beschriftet, was sie sind: Erschwernisse, die man bewusst wählt. */ starten(); expect( await screen.findByRole('heading', { name: 'Schwierigkeit', level: 3 }), ).toBeInTheDocument(); expect( await screen.findByRole('checkbox', { name: 'Anzahl der richtigen Antworten verbergen' }), ).toBeInTheDocument(); }); it('verspricht keinen Lernvorteil und behauptet nichts über den Prüfungsbogen', async () => { /* Der frühere Text sagte „in der Prüfung stehen sie ohnehin anders“ – eine Tatsachenbehauptung über die echte Prüfung, für die es keine Quelle gibt. Weder das BVA-Vorwort noch die AWaffV noch ein Behördenmerkblatt regeln die Anordnung der Antwortmöglichkeiten. */ starten(); expect( await screen.findByText(/Ein Lernvorteil des Mischens ist nicht belegt/u), ).toBeInTheDocument(); expect(screen.queryByText(/in der Prüfung stehen sie ohnehin anders/u)).toBeNull(); }); it('reicht die Entscheidung an die Zusammenstellung der Sitzung weiter', async () => { /* Bewusst mit dem vom Standard ABWEICHENDEN Wert geprüft: Stünde hier `false`, wäre der Test seit der Umstellung der Vorgabe inhaltsleer – er bliebe auch dann grün, wenn die Oberfläche die Einstellung gar nicht mehr läse. */ const lernSitzung = vi.fn(() => Promise.resolve([reihenfolge(FRAGE)])); const nutzer = starten( attrappe({ lernSitzung, einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, optionenMischen: true, }), ), }), ); const knopf = await screen.findByRole('button', { name: /Weiterlernen/u }); await waitFor(() => { expect(knopf).toBeEnabled(); }); await nutzer.click(knopf); await waitFor(() => { expect(lernSitzung).toHaveBeenCalledWith( 1, expect.objectContaining({ optionenMischen: true }), ); }); }); it('sagt, dass sie auch in der Prüfungssimulation gilt', async () => { starten(); expect(await screen.findByText(/gilt auch in der Prüfungssimulation/u)).toBeInTheDocument(); }); }); describe('Einstellung „Vorlesen“', () => { it('blendet ohne Sprachausgabe des Systems keinen Vorleseknopf ein', async () => { /* Ohne `speechSynthesis` – der Normalfall in jsdom. */ await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), ), }), ); expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument(); }); it('erklärt, wenn das System keine Sprachausgabe anbietet', async () => { starten(); expect( await screen.findByText('Dieses System bietet keine Sprachausgabe an.'), ).toBeInTheDocument(); /* Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein natives `disabled`, dafür `aria-disabled`. Gerade hier trägt das etwas – der Satz darunter erklärt ja, WARUM das Kästchen nichts nützt, und ein `disabled` Kästchen nimmt keinen Fokus, sodass er einem Bildschirmleser nie begegnete. */ const schalter = screen.getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen', }); expect(schalter).toBeEnabled(); expect(schalter).toHaveAttribute('aria-disabled', 'true'); }); it('lässt das gesperrte Vorlesekästchen nichts umstellen', async () => { const einstellungenSchreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)); const nutzer = starten(attrappe({ einstellungenSchreiben })); const schalter = await screen.findByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen', }); await nutzer.click(schalter); expect(schalter).not.toBeChecked(); expect(einstellungenSchreiben).not.toHaveBeenCalled(); }); it('zeigt den Knopf erst, wenn die Einstellung an ist', async () => { sprachausgabeStellen(); await sitzungOeffnen(); expect(screen.queryByRole('button', { name: /vorlesen/iu })).not.toBeInTheDocument(); }); it('liest die Frage samt Antwortmöglichkeiten und löst Kürzel auf', async () => { const stimme = sprachausgabeStellen(); const nutzer = await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), ), }), ); await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' '); expect(gesamt).toContain('Paragraf 12 Absatz 4 Waffengesetz'); expect(gesamt).toContain('b: Nach dem Waffengesetz'); expect(stimme.gesprochen.every((stueck) => stueck.lang === 'de-DE')).toBe(true); }); it('bietet während der Ausgabe das Anhalten an (WCAG 1.4.2)', async () => { const stimme = sprachausgabeStellen(); const nutzer = await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), ), }), ); await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); const halt = await screen.findByRole('button', { name: /Vorlesen beenden/u }); const vorher = stimme.abbrueche(); await nutzer.click(halt); expect(stimme.abbrueche()).toBeGreaterThan(vorher); expect(await screen.findByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument(); }); it('sagt von selbst nichts, solange nur die Schaltfläche eingeschaltet ist', async () => { /* Gemeldet von einem Nutzer: Nach dem Bestätigen begann die Ansage von selbst. Die eigenen Texte gaben ihm recht – Schalter wie Handbuch sagten eine Schaltfläche zu, von selbstständigem Sprechen stand in keinem etwas. */ const stimme = sprachausgabeStellen(); const nutzer = await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), ), }), ); stimme.gesprochen.length = 0; await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await screen.findByRole('heading', { name: 'Warum das so ist' }); expect(stimme.gesprochen).toHaveLength(0); /* Der Knopf ist trotzdem da und liest jetzt die Rückmeldung. */ expect(screen.getByRole('button', { name: /Antwort vorlesen/u })).toBeInTheDocument(); }); it('liest Frage und Rückmeldung von selbst, wenn man es einschaltet', async () => { const stimme = sprachausgabeStellen(); const nutzer = await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true, vorlesenAutomatisch: true, }), ), }), ); /* Zuerst die Frage – ohne dass jemand gedrückt hat. */ await waitFor(() => { expect(stimme.gesprochen.length).toBeGreaterThan(0); }); expect(stimme.gesprochen.map((s) => s.text).join(' ')).toContain('Waffengesetz'); stimme.gesprochen.length = 0; await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await waitFor(() => { expect(stimme.gesprochen.length).toBeGreaterThan(0); }); const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' '); expect(gesamt).toContain('Richtig'); expect(gesamt).toContain('Richtig ist Antwort b.'); /* Die Kurzfassung der Erklärung, nicht der Volltext: Der dauert gesprochen über eine Minute und die nächste Frage wartet. */ expect(gesamt).toContain('Der Erwerb richtet sich nach dem Waffengesetz.'); expect(gesamt).not.toContain('Ausführlich'); }); it('spricht dieselbe Stelle nicht zweimal', async () => { const stimme = sprachausgabeStellen(); const nutzer = await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true, vorlesenAutomatisch: true, }), ), }), ); await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await waitFor(() => { expect(stimme.gesprochen.length).toBeGreaterThan(0); }); const nachErgebnis = stimme.gesprochen.length; /* Ein Klick, der die Ansicht neu zeichnet, ohne die Frage zu wechseln. */ await nutzer.click(screen.getByRole('button', { name: /Frage merken/u })); expect(stimme.gesprochen).toHaveLength(nachErgebnis); }); it('liest die ausführliche Begründung nur bei falscher Antwort', async () => { /* Der zweite Schalter, und zwar allein: Ohne „von selbst vorlesen“ bleibt es bei der einen Ansage, die man bestellt hat – dem langen Text, wenn es danebenging. Genau die Kombination, die sich sonst nicht einstellen ließe: selbst lesen, aber bei einem Fehler die Begründung hören. */ const stimme = sprachausgabeStellen(); const nutzer = await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true, vorlesenErklaerungBeiFehler: true, }), ), }), ); stimme.gesprochen.length = 0; /* Die falsche der beiden Antwortmöglichkeiten. */ await nutzer.click(screen.getByRole('radio', { name: /Nach dem Sprengstoffgesetz/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await waitFor(() => { expect(stimme.gesprochen.length).toBeGreaterThan(0); }); const gesamt = stimme.gesprochen.map((stueck) => stueck.text).join(' '); expect(gesamt).toContain('Ausführlich'); /* Nur die Begründung: Die Rückmeldung selbst hat niemand bestellt. */ expect(gesamt).not.toContain('Richtig ist Antwort b.'); }); it('schweigt bei richtiger Antwort, auch wenn die Begründung eingeschaltet ist', async () => { const stimme = sprachausgabeStellen(); const nutzer = await sitzungOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true, vorlesenErklaerungBeiFehler: true, }), ), }), ); stimme.gesprochen.length = 0; await nutzer.click(screen.getByRole('radio', { name: /Nach dem Waffengesetz/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await screen.findByRole('heading', { name: 'Warum das so ist' }); expect(stimme.gesprochen).toHaveLength(0); }); it('zeigt die Zusatzschalter erst, wenn die Sprachausgabe an ist', async () => { /* Nicht als gesperrte Kästchen: Gesperrte Bedienelemente nehmen keinen Fokus, ihre Erklärung würde nie vorgelesen. Dass es sie gibt, sagt der Satz am Schalter darüber. */ sprachausgabeStellen(); const nutzer = starten(attrappe()); expect( await screen.findByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }), ).toBeInTheDocument(); expect(screen.queryByRole('checkbox', { name: /von selbst vorlesen/u })).toBeNull(); expect(screen.queryByRole('checkbox', { name: /ausführliche Begründung/u })).toBeNull(); await nutzer.click( screen.getByRole('checkbox', { name: 'Fragen und Antworten vorlesen lassen' }), ); expect( await screen.findByRole('checkbox', { name: 'Frage und Rückmeldung von selbst vorlesen' }), ).not.toBeChecked(); expect( screen.getByRole('checkbox', { name: 'Bei falscher Antwort die ausführliche Begründung vorlesen', }), ).not.toBeChecked(); }); it('warnt, wenn zusätzlich ein Screenreader läuft', async () => { sprachausgabeStellen(); starten( attrappe({ hilfsmittelAktiv: vi.fn(() => Promise.resolve(true)), einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), ), }), ); expect(await screen.findByText(/Beide zugleich sprechen durcheinander/u)).toBeInTheDocument(); }); }); describe('Neu anfangen', () => { it('setzt den Lernstand erst nach einer Rückfrage zurück', async () => { /* Unwiderruflich, deshalb eine Bestätigung (WCAG 3.3.4). Und sie sagt, was verschwindet – eine Rückfrage, die nur „Sind Sie sicher?“ fragt, hilft beim Entscheiden nicht. */ const zuruecksetzen = vi.fn(() => Promise.resolve(UEBERSICHT)); const nutzer = starten(attrappe({ lernZuruecksetzen: zuruecksetzen })); await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' })); expect( screen.getByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }), ).toBeInTheDocument(); expect(zuruecksetzen).not.toHaveBeenCalled(); await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' })); await waitFor(() => { expect(zuruecksetzen).toHaveBeenCalledWith(1, null); }); expect(await screen.findByText(/Ihr Lernstand ist zurückgesetzt/u)).toBeInTheDocument(); }); it('gibt den Fokus an den auslösenden Knopf zurück', async () => { /* Die Rückfrage holt den Fokus zu sich. Verschwände sie, ohne ihn zurückzugeben, fiele er auf – wer per Tastatur bedient, stünde am Seitenanfang und müsste sich durch alle Einstellungen zurückarbeiten. */ const nutzer = starten(); const knopf = await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }); await nutzer.click(knopf); await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' })); expect(screen.getByRole('button', { name: 'Lernstand zurücksetzen' })).toHaveFocus(); }); it('lässt sich abbrechen, ohne etwas zu löschen', async () => { const zuruecksetzen = vi.fn(() => Promise.resolve(UEBERSICHT)); const nutzer = starten(attrappe({ lernZuruecksetzen: zuruecksetzen })); await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' })); await nutzer.click(screen.getByRole('button', { name: 'Nein, alles behalten' })); expect(zuruecksetzen).not.toHaveBeenCalled(); expect( screen.queryByRole('heading', { name: 'Lernstand wirklich zurücksetzen?' }), ).not.toBeInTheDocument(); }); it('sagt vorher, was verschwindet und was bleibt', async () => { starten(); const text = await screen.findByText(/Setzt Ihren Lernstand auf null/u); expect(text).toHaveTextContent('Merkliste'); expect(text).toHaveTextContent('Prüfungssimulationen'); expect(text).toHaveTextContent('Einstellungen'); }); it('meldet einen Fehlschlag, statt Erfolg vorzutäuschen', async () => { const nutzer = starten( attrappe({ lernZuruecksetzen: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))), }), ); await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' })); await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' })); expect(await screen.findByText(/Datenbank gesperrt/u)).toBeInTheDocument(); }); }); describe('Anzeigegröße über die Tastatur', () => { /* Gemeldet und nachgemessen: `Strg` und `+` taten unter Windows nichts. Electron liest den Beschleunigernamen `Plus` als die umgeschaltete Form von `=` und meldet damit faktisch `Strg+Umschalt+=` beim Betriebssystem an — richtig für eine amerikanische Tastatur, falsch für eine deutsche, auf der `+` eine eigene Taste ohne Umschalt ist. Geprüft wird hier das Zeichen, das die Tastatur liefert. Das Layout selbst muss die Anwendung nie kennen — wer es abfragt, trifft Annahmen, die bei der nächsten Tastatur wieder falsch sind. Genau dieser Fehler steckte im Beschleuniger. */ /* Die gemeinsame Attrappe dieser Datei fuehrt die Anzeigekanaele bewusst nicht - andere Tests hier pruefen gerade den Fall ohne Anwendungskern. Fuer die Tastatur braucht es sie, also kommen sie hier dazu. */ function zoomAttrappe(ueberschreibungen: TeilBridge = {}): TeilBridge { return attrappe({ anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)), anzeigegroesseBeobachten: vi.fn(() => () => undefined), ...ueberschreibungen, }); } /* Der Aufbau ist die einzige Stelle hier, die wirklich warten muss – und die einzige, die je in einen Zeitablauf lief. Nachgemessen: Die Schaltfläche „Weiterlernen“ erscheint erst, wenn die gespeicherten Einstellungen geladen sind. Mit einer künstlich um zwei Sekunden verzögerten Antwort wird sie überhaupt nicht gefunden. Es hängen also drei Brückenaufrufe und der vollständige Aufbau der Anwendung an diesem einen Warten. Die Vorgabe von Testing Library dafür sind 1000 ms. Im vollen Testlauf mit 40 Dateien reicht das gelegentlich nicht: Der Test fiel zweimal aus, beide Male nach 1071 bzw. 1083 ms – die Kennmarke genau dieses Zeitablaufs. Allein lief er immer durch. Die 5000 ms sind keine Nachsicht mit einem wackligen Test, sondern die Berichtigung einer Annahme, die nirgends zugesagt ist: Die Anwendung verspricht nicht, unter voller Testlast binnen einer Sekunde aufzubauen. Was hier geprüft werden soll, ist die Tastatur – und die wird unten ohne jedes Warten geprüft. */ async function startenMit(bruecke: TeilBridge): Promise { vi.stubGlobal('lernApp', bruecke); render(); await screen.findByRole('button', { name: /Weiterlernen/u }, { timeout: 5000 }); } function taste(init: KeyboardEventInit): void { window.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init })); } it.each([ ['deutsche Plustaste', { key: '+', code: 'Equal', ctrlKey: true }], ['amerikanisch, ohne Umschalt', { key: '=', code: 'Equal', ctrlKey: true }], ['amerikanisch, mit Umschalt', { key: '+', code: 'Equal', ctrlKey: true, shiftKey: true }], ['Ziffernblock', { key: '+', code: 'NumpadAdd', ctrlKey: true }], ])('vergrößert über die %s', async (_name, init) => { const bruecke = zoomAttrappe(); await startenMit(bruecke); taste(init); /* Ohne `waitFor`, und das ist eine Verschärfung: `beiTaste` ruft die Brücke im selben Durchlauf wie das Ereignis. Ein Warten darum herum prüfte nicht mehr, sondern verdeckte nur, wenn es einmal nicht so wäre – und war die einzige Stelle, die ablaufen konnte. */ expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110); }); it('verkleinert und setzt zurück', async () => { const bruecke = zoomAttrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 125 }), ), }); await startenMit(bruecke); /* Erst wenn die gespeicherten 125 Prozent AUF DEM BILDSCHIRM stehen, darf die Taste fallen. Bis 0.20.0 stand hier nur der Aufbau aus `startenMit`, und die Prüfung, dass die 125 angekommen sind, lag allein im Ergebnis: Von 100 aus wären es 90 gewesen, nicht 110. Das ist als Zusicherung richtig und als Voraussetzung falsch – die Anwendung sagt nirgends zu, dass mit „Weiterlernen“ auch schon `einstellungenLesen` beantwortet ist. Unter voller Testlast fiel der Test deshalb in einem von drei Läufen um, einzeln lief er immer durch. Der Anzeigestand hängt am selben Wert wie der Tastenhaken (`App` reicht `einstellungen.werte.anzeigegroesse` an beide weiter). Steht er da, ist der Wert in derselben Renderrunde auch im Haken angekommen – die Wirkung der Taste bleibt damit ohne jedes Warten geprüft, so wie oben bei `vergrößert über die %s`. */ expect(await screen.findByText(anzeigegroesseText(125))).toBeInTheDocument(); /* Von 100 aus wären es 90, nicht 110 – die Stufe belegt zusätzlich, dass der gelesene Wert und nicht der Vorgabewert gilt. */ taste({ key: '-', code: 'Minus', ctrlKey: true }); expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110); taste({ key: '0', code: 'Digit0', ctrlKey: true }); expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(100); }); /* Strg und Mausrad – der unter Windows verbreitetste Zoomgriff. Er fehlte bis 0.22.0 ausgerechnet in einer Anwendung, deren Anzeigegröße bis 400 Prozent ein zentrales Merkmal ist; Electron zoomt von sich aus nicht. Das Trackpad ist mit abgedeckt: Chromium meldet die Kneifgeste ebenfalls als `wheel` mit `ctrlKey`. */ function rad(init: WheelEventInit): WheelEvent { const ereignis = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init }); window.dispatchEvent(ereignis); return ereignis; } it('vergrößert beim Drehen nach oben', async () => { const bruecke = zoomAttrappe(); await startenMit(bruecke); rad({ deltaY: -100, ctrlKey: true }); expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(110); }); it('verkleinert beim Drehen nach unten', async () => { const bruecke = zoomAttrappe(); await startenMit(bruecke); rad({ deltaY: 100, ctrlKey: true }); expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90); }); it('hält das Rollen der Seite an, wenn es zoomt', async () => { /* Ohne `{ passive: false }` bliebe `preventDefault()` wirkungslos, und die Seite rollte zusätzlich zum Zoom. */ const bruecke = zoomAttrappe(); await startenMit(bruecke); const ereignis = rad({ deltaY: -100, ctrlKey: true }); expect(ereignis.defaultPrevented).toBe(true); }); it('lässt gewöhnliches Rollen in Ruhe', async () => { /* Ohne Strg gehört das Rad der Seite. Griffe die Anwendung hier zu, ließe sich kein Text mehr scrollen. */ const bruecke = zoomAttrappe(); await startenMit(bruecke); const ereignis = rad({ deltaY: -100 }); expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled(); expect(ereignis.defaultPrevented).toBe(false); }); /* Die drei Knöpfe waren bis Fassung 0.19.2 `disabled`, sobald die Skala am Ende war. Wer sich die Schrift größer stellte, bis es nicht weiter ging, verlor den Knopf im letzten Klick unter dem Finger: Ein gesperrtes Element fällt aus der Tabulatorreihenfolge, und der Fokus landet auf `` – also am Seitenanfang, weit weg von der Stelle, an der man gerade war. Deshalb wird hier der letzte Schritt selbst gegangen, nicht die Endlage hingestellt: von der vorletzten auf die oberste Stufe. Genau in diesem Übergang trat es auf. Die beiden Stufen werden aus `ANZEIGEGROESSEN` gelesen, damit der Test die Endlage prüft und nicht eine Zahl, die beim Verlängern der Leiter mitten in der Skala liegt (0.20.0: bis 400 statt bis 200). */ it('behält den Knopf im letzten Schritt zur Höchstgröße', async () => { const nutzer = userEvent.setup(); const oberste = Math.max(...ANZEIGEGROESSEN); const vorletzte = Math.max(...ANZEIGEGROESSEN.filter((stufe) => stufe < oberste)); const bruecke = zoomAttrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: vorletzte, }), ), }); await startenMit(bruecke); const groesser = await screen.findByRole('button', { name: /Größer/u }); await nutzer.click(groesser); await waitFor(() => { expect(groesser).toHaveAttribute('aria-disabled', 'true'); }); expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(oberste); expect(groesser).not.toBeDisabled(); expect(groesser).toHaveFocus(); }); it('setzt am Ende der Skala nichts mehr, lässt aber die Gegenrichtung zu', async () => { const nutzer = userEvent.setup(); const bruecke = zoomAttrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, anzeigegroesse: 80 }), ), }); await startenMit(bruecke); const kleiner = await screen.findByRole('button', { name: /Kleiner/u }); await waitFor(() => { expect(kleiner).toHaveAttribute('aria-disabled', 'true'); }); await nutzer.click(kleiner); expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled(); /* Die Gegenprobe: nach oben geht es sehr wohl weiter. */ await nutzer.click(screen.getByRole('button', { name: /Größer/u })); expect(bruecke.anzeigegroesseSetzen).toHaveBeenCalledWith(90); }); it('lässt jede andere Kombination mit Strg in Ruhe', async () => { /* Der Zuhörer liegt am Fenster und sieht alles. Fasste er mehr an als die drei eigenen Tasten, bräche er Kopieren, Einfügen und Neu laden. */ const bruecke = zoomAttrappe(); await startenMit(bruecke); for (const init of [ { key: 'a', code: 'KeyA', ctrlKey: true }, { key: 'c', code: 'KeyC', ctrlKey: true }, { key: '1', code: 'Digit1', ctrlKey: true }, // Ohne Strg ist ein Pluszeichen einfach ein Pluszeichen. { key: '+', code: 'Equal' }, // Unter macOS trägt der Menü-Beschleuniger; sonst zoomte es doppelt. { key: '+', code: 'Equal', metaKey: true }, ]) { taste(init); } await new Promise((weiter) => setTimeout(weiter, 20)); expect(bruecke.anzeigegroesseSetzen).not.toHaveBeenCalled(); }); });