/** * Verhaltenstests des Lernmodus. * * Geprüft wird, was Lernende tatsächlich tun: auswählen, bestätigen, sich * selbst bewerten, merken, weitergehen – per Maus und per Tastatur. Die * Attrappe von `window.lernApp` liefert dazu realistische Katalogdaten * (Einfachauswahl, Mehrfachauswahl, offene Frage mit Kernelementen und eine * Frage, deren Antwortoption ein Prüfzeichen ist). */ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import axe from 'axe-core'; import { jsdomOptionen } from '../src/shared/wcag'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/renderer/src/App'; import { programmangabenVergessen } from '../src/renderer/src/lernen/programmangaben'; 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 type { Lernplan } from '../src/shared/lernplan'; import type { Frage, Katalog, KatalogBild, RichText } from '../src/shared/katalog'; import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc'; import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand'; import { EUPL_HINWEIS, EUPL_KENNUNG, KATALOG_HINWEIS, type Lizenzangaben, } from '../src/shared/lizenzen'; import type { Pruefungsergebnis } from '../src/shared/pruefung'; import { NORMTEXTE_LEER } from '../src/shared/normtexte'; import { THEMEN_LEER } from '../src/shared/themen'; type Nutzer = ReturnType; // ─── Katalogattrappe ──────────────────────────────────────────────────── function nurText(text: string): RichText { return { text, segmente: [{ t: text }] }; } const ZEICHEN_PTB: KatalogBild = { id: 'zeichen-ptb', datei: 'zeichen-ptb.png', breite: 73, hoehe: 73, alt: 'Rundes Zeichen mit der Buchstabenfolge PTB', beschreibung: 'Zulassungszeichen der Physikalisch-Technischen Bundesanstalt für Schreckschuss-, Reizstoff- und Signalwaffen zugelassener Bauart.', }; /* Ohne Beschreibung: Der Bestand wächst schrittweise, die Oberfläche muss ein Zeichen ohne zweite Stufe unverändert darstellen. */ const ZEICHEN_F: KatalogBild = { id: 'zeichen-f', datei: 'zeichen-f.png', breite: 72, hoehe: 74, alt: 'Fünfeckiges Zeichen mit dem Buchstaben F', beschreibung: null, }; /** Multiple Choice mit genau einer richtigen Antwort. */ const FRAGE_EINFACH: Frage = { id: 'I.1-87', amtliche_nummer: '1.87', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 23, frage: nurText('Welcher der genannten Gegenstände ist eine Anscheinswaffe?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Sportgewehr im Kaliber .223 Remington'), korrekt: false, bilder: [], }, { label: 'b', inhalt: nurText('Schreckschusspistole mit Zulassungszeichen'), korrekt: false, bilder: [], }, { label: 'c', inhalt: nurText( 'Spielzeugpistole, die einer echten Pistole zum Verwechseln ähnlich sieht und die niemand führt', ), korrekt: true, bilder: [], }, ], }; /** Multiple Choice mit zwei richtigen Antworten. */ const FRAGE_MEHRFACH: Frage = { id: 'I.2-10', amtliche_nummer: '2.10', kapitel: 'I', abschnitt: 'I.2', typ: 'mc', seite: 40, frage: nurText('Welche Pflichten treffen den Inhaber einer Waffenbesitzkarte?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Er muss die Waffe sorgfältig aufbewahren.'), korrekt: true, bilder: [], }, { label: 'b', inhalt: nurText('Er muss den Erwerb unverzüglich anzeigen.'), korrekt: true, bilder: [], }, { label: 'c', inhalt: nurText('Er muss die Waffe jährlich vorführen.'), korrekt: false, bilder: [], }, ], }; /** Offene Frage mit unterstrichenen Kernelementen in der Musterantwort. */ const FRAGE_FREITEXT: Frage = { id: 'I.1-01', amtliche_nummer: '1.01', kapitel: 'I', abschnitt: 'I.1', typ: 'freitext', seite: 6, frage: nurText('Was regelt das Waffengesetz?'), bilder: [], musterantwort: { text: 'Das Waffengesetz regelt den Umgang mit Waffen oder Munition unter Berücksichtigung der öffentlichen Sicherheit.', segmente: [ { t: 'Das Waffengesetz regelt den ' }, /* Ein durchgehender Lauf, wie ihn der Katalog seit 0.17.0 liefert. Vorher standen hier zwei Läufe mit einem blanken Leerzeichen dazwischen – die Form, die `parse_catalog.py` am Zeilenumbruch erzeugte und die `validate_catalog.py` heute abweist. */ { t: 'Umgang mit Waffen oder Munition', h: true }, { t: ' unter Berücksichtigung der ' }, { t: 'öffentlichen Sicherheit', h: true }, { t: '.' }, ], }, }; /** Frage, deren erste Antwortoption ausschließlich aus einem Bild besteht. */ const FRAGE_BILD: Frage = { id: 'I.3-05', amtliche_nummer: '3.05', kapitel: 'I', abschnitt: 'I.3', typ: 'mc', seite: 61, frage: nurText('Welches Kennzeichen findet sich auf einer Schreckschusswaffe?'), bilder: [], optionen: [ { label: 'a', inhalt: { text: '', segmente: [] }, korrekt: true, bilder: ['zeichen-ptb'] }, { label: 'b', inhalt: nurText('Beschusszeichen:'), korrekt: false, bilder: ['zeichen-f'] }, ], }; /** Offene Frage mit Zeichen am Fragetext – der zweite Ort für Bilder. */ const FRAGE_BILD_KOPF: Frage = { id: 'I.3-06', amtliche_nummer: '3.06', kapitel: 'I', abschnitt: 'I.3', typ: 'freitext', seite: 62, frage: nurText('Was bedeutet das abgebildete Zeichen?'), bilder: ['zeichen-ptb'], musterantwort: { text: 'Es ist ein Zulassungszeichen.', segmente: [{ t: 'Es ist ein Zulassungszeichen.' }], }, }; const KATALOG: Katalog = { meta: { titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: '7e5e41fa', fragen_gesamt: 5, }, kapitel: [ { id: 'I', titel: 'Waffenrecht und sonstige Rechtsvorschriften', abschnitte: [ { id: 'I.1', titel: 'Begriffe des Waffenrechts' }, { id: 'I.2', titel: 'Rechte und Pflichten' }, { id: 'I.3', titel: 'Kennzeichnung von Schusswaffen' }, ], }, ], bilder: [ZEICHEN_PTB, ZEICHEN_F], fragen: [FRAGE_FREITEXT, FRAGE_EINFACH, FRAGE_MEHRFACH, FRAGE_BILD, FRAGE_BILD_KOPF], }; // ─── Bridge-Attrappe ──────────────────────────────────────────────────── const PROFIL: Profil = { id: 1, name: 'Testprofil', pruefungstermin: null, kapitelAusschluss: [], erstelltAm: '2026-01-01T08:00:00.000Z', }; const UEBERSICHT: Lernuebersicht = { fragenGesamt: 575, beantwortet: 120, belegt: 48, reifegrad: 48 / 575, stufe: 'zurueck', deckelnd: [], faellig: 12, gemerkt: 5, offen: 0, fehler: 0, heuteRichtig: 8, heuteFalsch: 2, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, bereiche: [ { id: 'I', titel: 'Waffenrecht und sonstige Rechtsvorschriften', fragenGesamt: 300, beantwortet: 100, belegt: 40, reifegrad: 40 / 300, stufe: 'zurueck', }, ], themengruppen: [], }; const STAND: FrageStand = { frageId: 'I.1-87', 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.' }, }; const BILD_DATEN = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; function reihenfolge(frage: Frage): SitzungsFrage { return { frageId: frage.id, optionsReihenfolge: (frage.optionen ?? []).map((option) => option.label), gemerkt: false, }; } /* Die Prüfungs- und Lizenzkanäle gehören nicht zum Lernmodus, sind aber Teil des Bridge-Vertrags. Sie werden hier nur bedient, damit die Attrappe vollständig ist – geprüft werden sie in tests/renderer-pruefung.test.tsx und tests/renderer-ueber.test.tsx. */ const PRUEFUNGSERGEBNIS: Pruefungsergebnis = { profilId: 'standard', gesamt: 0, richtig: 0, falsch: 0, unbeantwortet: 0, quote: 0, urteil: 'nicht_bestanden', begruendung: '0 Prozent richtig, nötig waren 80 Prozent.', verletzteKriterien: [], bereiche: [], fehlerIds: [], dauerMs: 0, zeitAbgelaufen: false, zeitpunkt: '2026-01-01T08:00:00.000Z', }; const LIZENZEN: Lizenzangaben = { eigene: { kennung: EUPL_KENNUNG, name: 'EUPL 1.2', hinweis: EUPL_HINWEIS, text: null }, katalog: { quellenangabe: KATALOG.meta.quellenangabe, quelleUrl: KATALOG.meta.quelle_url, hinweis: KATALOG_HINWEIS, }, dritte: [], }; /** Lernplan für die Attrappe – ohne Termin, damit nichts vom Datum abhängt. */ const LERNPLAN: Lernplan = { termin: null, tageBisTermin: null, gesamtFragen: 8, nieBeantwortet: 6, faellig: 1, zielquote: 0.9, prognoseHeute: 0.25, prognoseAmTermin: null, pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 }, machbarkeit: 'kein_termin', sekundenProFrage: 25, }; /** Erklaerungen fuer die Attrappe – ein Eintrag genuegt fuer die Anzeige. */ const ERKLAERUNGEN: Erklaerungen = { meta: { version: 1, stand: '2026-08-20', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.', }, zuFrage: { 'I.1-87': { kurz: 'Anscheinswaffen sehen echten Schusswaffen zum Verwechseln aehnlich.', text: 'Entscheidend ist der aeussere Eindruck, nicht die Funktion: Wer den Gegenstand sieht, muss ihn fuer eine wirkliche Schusswaffe halten koennen.', fundstellen: [ { gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 1 Unterabschnitt 1 Nr. 1.6' }, { gesetz: 'WaffG', norm: '§ 42a', absatz: '1', nummer: '1' }, ], merksatz: 'Was aussieht wie eine Waffe, wird wie eine behandelt.', /* Beidseitig wie im echten Bestand – ein einseitiger Verweis zeigte die Verbindung nur dem, der zufaellig von der richtigen Seite kommt. */ verwandt: ['I.1-01'], }, /* I.2-10 bekommt bewusst keine Erklaerung: Der Bestand waechst schrittweise, und die Oberflaeche muss das aushalten. */ 'I.1-01': { // Der Regelfall in Waffentechnik und Handhabung: keine Fundstelle, // dafuer die ausdrueckliche Begruendung, warum es keine gibt. kurz: 'Das Waffengesetz regelt den Umgang mit Waffen und Munition.', text: 'Wie ein Masseverschluss arbeitet, steht in keinem Gesetz - das ist Technik. Die Erklaerung sagt das ausdruecklich, statt eine Fundstelle zu dehnen.', fundstellen: [], ohneFundstelleGrund: 'Die Sicherheitsregeln zur Handhabung sind fachliche Praxis und stehen so nicht im Gesetz.', /* Gegenstueck zum Verweis aus I.1-87: Der Bestand verlangt Beidseitigkeit, und die Attrappe soll ihn nicht unterlaufen. */ verwandt: ['I.1-87'], /* Die Pruefliste zur Selbstbewertung - eine Einschaetzung dieser Software, keine Aussage darueber, was eine Antwort enthalten muss. */ kernpunkte: ['Gegenstand: der Umgang mit Waffen', 'Massstab: die oeffentliche Sicherheit'], }, }, }; /** Glossar fuer die Attrappe – zwei Eintraege genuegen fuer die Anzeige. */ const GLOSSAR: Glossar = { meta: { version: 1, stand: '2026-08-21', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt.', }, eintraege: [ { begriff: 'Führen', art: 'begriff', kurz: 'Die tatsächliche Gewalt über eine Waffe außerhalb der eigenen Wohnung ausüben.', varianten: ['führt', 'führen', 'geführt'], fundstellen: [{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 2 Nr. 4' }], siehe: ['WaffG'], }, { begriff: 'WaffG', art: 'abkuerzung', kurz: 'Waffengesetz.', varianten: ['WaffG', 'Waffengesetzes', 'Waffengesetz'], fundstellen: [], ohneFundstelleGrund: 'Die amtliche Kurzbezeichnung eines Gesetzes steht nicht im Gesetz selbst.', }, ], }; function attrappe(ueberschreibungen: Partial = {}): LernAppBridge { return { pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })), pruefungAuswerten: vi.fn(() => Promise.resolve(PRUEFUNGSERGEBNIS)), pruefungVerlauf: vi.fn(() => Promise.resolve([])), pruefungSichern: vi.fn(() => Promise.resolve(true)), pruefungOffen: vi.fn(() => Promise.resolve(null)), pruefungVerwerfen: vi.fn(() => Promise.resolve()), pruefungLaeuft: vi.fn(() => Promise.resolve()), sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })), fehlerprotokollDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }), ), fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })), lernberichtDrucken: vi.fn(() => Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }), ), /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt, dass die Brücke den Kanal kennt. */ datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })), lizenzenLesen: vi.fn(() => Promise.resolve(LIZENZEN)), anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)), anzeigegroesseBeobachten: vi.fn(() => () => undefined), 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(() => Promise.resolve(EINSTELLUNGEN_STANDARD)), katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), katalogBild: vi.fn(() => Promise.resolve(BILD_DATEN)), 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)), 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_EINFACH)])), lernAntworten: vi.fn(() => Promise.resolve(STAND)), lernMerken: vi.fn((_profilId: number, frageId: string, gemerkt: boolean) => Promise.resolve({ ...STAND, frageId, gemerkt }), ), lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), ...ueberschreibungen, }; } /** Rendert die Anwendung mit genau dieser Bridge und öffnet die Sitzung. */ async function mitBrueckeOeffnen(bruecke: Partial): Promise { vi.stubGlobal('lernApp', bruecke); const nutzer = userEvent.setup(); render(); const knopf = await screen.findByRole('button', { name: /Weiterlernen/u }); /* Der Einstieg ist nie `disabled`, sondern meldet sich über `aria-disabled` (Projektregel, docs/stand.md 7.13). `toBeEnabled()` wäre hier immer wahr und liefe dem geladenen Katalog nicht mehr hinterher. */ await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); return nutzer; } /** Rendert die Anwendung mit vorbereiteter Bridge und öffnet die Sitzung. */ async function lernsitzungOeffnen(ueberschreibungen: Partial = {}): Promise { return mitBrueckeOeffnen(attrappe(ueberschreibungen)); } /** * Öffnet die Sitzung mit einer Bridge, der ein Kanal fehlt. * * Eine eigene Funktion, weil sich ein Kanal nicht über * {@link lernsitzungOeffnen} entfernen lässt: Dort wird die Attrappe über die * Überschreibungen gelegt, und ein fehlender Schlüssel setzt den Wert der * Grundlage nicht zurück – er lässt ihn stehen. */ async function ohneKanalOeffnen(kanal: keyof LernAppBridge): Promise { const bruecke = attrappe(); Reflect.deleteProperty(bruecke, kanal); return mitBrueckeOeffnen(bruecke); } /** * Die Live-Region der Sitzung – nicht die des Themenwechsels und nicht die * des Meldeblocks. * * Seit es „Fehler zu dieser Frage melden“ gibt, stehen zwei Regionen in * derselben Landmarke: die unsichtbare Rückmeldung zur Antwort und die * sichtbare Bestätigung des Kopierens. Unterschieden wird über die Klasse und * nicht über eine Beschriftung – eine Beschriftung an der Rückmeldung würde * bei `aria-atomic="true"` mit vorgelesen und änderte damit, was ein * Bildschirmleser nach jeder Antwort hört. Der Test hat sich nach der * Anwendung zu richten, nicht umgekehrt. */ function ansagebereich(): HTMLElement { const sitzung = screen.getByRole('region', { name: /Lernsitzung/u }); const bereich = sitzung.querySelector('.nur-screenreader[role="status"]'); if (bereich === null) { throw new Error('Die Live-Region der Sitzung fehlt.'); } return bereich; } afterEach(() => { /* Die Programmangaben liegen in einem modulweiten Zwischenspeicher – ohne Leeren sähe der nächste Test die Antwort des vorigen. */ programmangabenVergessen(); vi.unstubAllGlobals(); /* Die Erklaerungen liegen in einem Modul-weiten Zwischenspeicher – ohne Leeren wuerde der naechste Test die Antwort des vorigen sehen. */ erklaerungenZwischenspeicherLeeren(); glossarZwischenspeicherLeeren(); normtexteZwischenspeicherLeeren(); themenZwischenspeicherLeeren(); }); // ─── Tests ────────────────────────────────────────────────────────────── describe('Lernsitzung – Aufbau und Fortschritt', () => { it('zeigt amtliche Nummer, Kapitel und Fragetyp', async () => { await lernsitzungOeffnen(); expect( await screen.findByRole('heading', { name: /Frage 1\.87/u, level: 1 }), ).toBeInTheDocument(); expect(screen.getByText(/Kapitel I – Waffenrecht/u)).toBeInTheDocument(); expect(screen.getByText(/Abschnitt I\.1 – Begriffe des Waffenrechts/u)).toBeInTheDocument(); expect(screen.getByText('Multiple Choice')).toBeInTheDocument(); }); it('meldet den Fortschritt als progressbar und als Text', async () => { await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), }); const balken = await screen.findByRole('progressbar', { name: 'Fortschritt der Lernsitzung' }); expect(balken).toHaveAttribute('aria-valuenow', '1'); expect(balken).toHaveAttribute('aria-valuemin', '0'); expect(balken).toHaveAttribute('aria-valuemax', '2'); expect(balken).toHaveAttribute('aria-valuetext', 'Frage 1 von 2'); expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument(); }); }); describe('Lernsitzung – Einfachauswahl', () => { it('bietet eine Radiogruppe mit dem Hinweis auf genau eine Antwort', async () => { await lernsitzungOeffnen(); expect(await screen.findByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument(); expect( screen.getByRole('group', { name: /genau eine Antwort ist richtig/u }), ).toBeInTheDocument(); expect(screen.getAllByRole('radio')).toHaveLength(3); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); it('bewertet eine richtige Auswahl und sagt sie über die Live-Region an', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument(); expect(ansagebereich()).toHaveTextContent('Richtig. Richtig ist Antwort c.'); }); it('bewertet eine falsche Auswahl und nennt die Lösung', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Falsch' })).toBeInTheDocument(); expect(ansagebereich()).toHaveTextContent('Falsch. Richtig ist Antwort c.'); expect(screen.getByText('Richtig – nicht gewählt')).toBeInTheDocument(); expect(screen.getByText('Falsch – von Ihnen gewählt')).toBeInTheDocument(); }); /* Dieselbe Fehlerklasse wie beim Freitextfeld, nur auf dem Hauptweg: Bis Fassung 0.19.2 trug das Feldset `disabled`, und ein gesperrtes Feldset sperrt jedes Bedienelement darin. Nach dem Bestätigen nahm keine Antwortmöglichkeit mehr den Fokus – die Rückmeldung ließ sich nicht mehr durchgehen, obwohl genau dafür die Optionen stehenbleiben. */ it('lässt die Antwortmöglichkeiten nach dem Bestätigen ansteuerbar', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); for (const feld of screen.getAllByRole('radio')) { expect(feld).not.toBeDisabled(); expect(feld).toHaveAttribute('aria-disabled', 'true'); feld.focus(); expect(feld).toHaveFocus(); } }); it('ändert die Auswahl nach dem Bestätigen nicht mehr', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); /* Erreichbar heißt nicht änderbar: Ein Klick auf eine andere Möglichkeit darf die bewertete Antwort nicht nachträglich verschieben. */ await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); expect(screen.getByRole('radio', { name: /Sportgewehr/u })).toBeChecked(); expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).not.toBeChecked(); expect(screen.getByRole('group', { name: 'Falsch' })).toBeInTheDocument(); }); it('verlangt eine Auswahl, bevor bestätigt werden kann', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(ansagebereich()).toHaveTextContent('Bitte wählen Sie zuerst eine Antwort aus.'); expect(screen.queryByRole('group', { name: 'Richtig' })).not.toBeInTheDocument(); }); it('protokolliert die Antwort über den Lernstand', async () => { const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); /* Gebucht wird seit 0.22.0 erst beim Weiterblättern: Bis dahin lässt sich die Selbsteinschätzung noch nachreichen („Ich hatte geraten“). Die Antwort selbst bleibt unantastbar. */ await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' })); expect(lernAntworten).toHaveBeenCalledTimes(1); expect(lernAntworten).toHaveBeenCalledWith( 1, expect.objectContaining({ frageId: 'I.1-87', auswahl: ['c'], richtig: true, bewertung: 'gut', }), ); }); }); describe('Lernsitzung – Fachbegriffe zur Frage', () => { it('bietet die Begriffe der Frage zugeklappt an', async () => { /* WCAG 3.1.3: Wer ein Fachwort nicht kennt, versteht die Frage nicht. Der Prüfplan verlangt den Zugang ausdrücklich aus der Frage heraus – also vor dem Beantworten, nicht erst danach. */ await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); const schalter = screen.getByText('Ein Fachbegriff in dieser Frage'); const aufklapper = schalter.closest('details'); expect(aufklapper).not.toBeNull(); expect(aufklapper).not.toHaveAttribute('open'); }); it('nennt die gefundenen Begriffe im zugänglichen Namen', async () => { // „Ein Fachbegriff in dieser Frage" allein sagt nicht, welcher. await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); expect(screen.getByText('Ein Fachbegriff in dieser Frage').parentElement).toHaveTextContent( 'Führen', ); }); it('warnt vor dem Beantworten, dass die Antwort darin stehen kann', async () => { /* Bei einer Definitionsfrage ist die Begriffsbestimmung die Antwort. Wer die Hilfe braucht, bekommt sie – wer sich die Frage nicht verderben will, stolpert nicht hinein. */ const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByText('Ein Fachbegriff in dieser Frage')); expect(screen.getByText(/steht die Antwort hier bereits/u)).toBeInTheDocument(); expect(screen.getByText(/außerhalb der eigenen Wohnung/u)).toBeInTheDocument(); }); it('lässt die Warnung weg, sobald die Frage beantwortet ist', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByText('Ein Fachbegriff in dieser Frage')); expect(screen.queryByText(/steht die Antwort hier bereits/u)).not.toBeInTheDocument(); expect(screen.getByText(/außerhalb der eigenen Wohnung/u)).toBeInTheDocument(); }); it('zeigt nichts an, wenn die Frage keinen Fachbegriff enthält', async () => { const suche = /Fachbegriff(e)? in dieser Frage/u; await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])), }); await screen.findByRole('heading', { name: /Frage 2\.10/u }); expect(screen.queryByText(suche)).not.toBeInTheDocument(); }); it('bleibt bedienbar, wenn der Kern das Glossar nicht anbietet', async () => { const nutzer = await ohneKanalOeffnen('glossarLaden'); await screen.findByRole('heading', { name: /Frage 1\.87/u }); expect(screen.queryByText(/Fachbegriff/u)).not.toBeInTheDocument(); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument(); }); }); describe('Lernsitzung – Erklärung zur Antwort', () => { async function bisZumErgebnis(ueberschreibungen: Partial = {}): Promise { const nutzer = await lernsitzungOeffnen(ueberschreibungen); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); return nutzer; } it('zeigt die Begründung erst nach dem Bestätigen', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); // Vorher darf nichts von der Lösung durchscheinen. expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument(); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(await screen.findByRole('heading', { name: 'Warum das so ist' })).toBeInTheDocument(); }); it('nennt Kurzfassung, Begründung und Merksatz', async () => { await bisZumErgebnis(); await screen.findByRole('heading', { name: 'Warum das so ist' }); expect(screen.getByText(/zum Verwechseln aehnlich/u)).toBeInTheDocument(); expect(screen.getByText(/Entscheidend ist der aeussere Eindruck/u)).toBeInTheDocument(); expect(screen.getByText(/Was aussieht wie eine Waffe/u)).toBeInTheDocument(); }); it('führt die Fundstellen in juristischer Kurzform auf', async () => { await bisZumErgebnis(); /* Keine Überschrift mehr, seit die Erklärung aufklappbar ist: Sie trägt ihre eigene Überschrift im ``, und eine weitere Ebene darunter wäre ein Sprung. Der Text bleibt und benennt weiterhin die Liste. */ const titel = await screen.findByText('Im Gesetz nachlesen'); const liste = screen.getByRole('list', { name: 'Im Gesetz nachlesen' }); expect(titel).toBeInTheDocument(); expect(within(liste).getAllByRole('listitem')).toHaveLength(2); expect( within(liste).getByText('Anlage 1 Abschnitt 1 Unterabschnitt 1 Nr. 1.6 WaffG'), ).toBeInTheDocument(); expect(within(liste).getByText('§ 42a Abs. 1 Nr. 1 WaffG')).toBeInTheDocument(); }); it('schreibt die Fundstellen für Bildschirmleser aus', async () => { /* „§ 12 Abs. 1“ wird sonst buchstabiert oder falsch betont. Sichtbar bleibt die Kurzform, vorgelesen wird die Langform. */ await bisZumErgebnis(); const liste = await screen.findByRole('list', { name: 'Im Gesetz nachlesen' }); expect( within(liste).getByText('Paragraf 42a Absatz 1 Nummer 1 Waffengesetz'), ).toBeInTheDocument(); expect(within(liste).getByText(/Anlage 1 .* Waffengesetz$/u)).toBeInTheDocument(); }); it('macht die Fundstellen aufklappbar, sobald Normtexte beiliegen', async () => { /* Der Kern von M30: „Im Gesetz nachlesen“ stand als Aufforderung da, und eine ausdrücklich vollständig offline arbeitende Software lieferte keinen einzigen Normtext mit. Hier wird die Verdrahtung geprüft – der Auszug selbst hat eigene Fälle in tests/normstelle.test.tsx. */ const nutzer = await bisZumErgebnis({ normtexteLaden: vi.fn(() => Promise.resolve({ meta: { version: 1, stand: '2026-08-29', hinweis: 'Auszug', gesetzesstand: { WaffG: 'Pruefstand' }, }, gesetze: { WaffG: { bezeichnung: 'Waffengesetz', stand: 'Pruefstand', quelle: 'https://www.gesetze-im-internet.de/waffg_2002/', normen: { '§ 42a': { titel: 'Verbot des Führens von Anscheinswaffen', istAnlage: false, absaetze: { '1': 'Es ist verboten, Anscheinswaffen zu fuehren.' }, }, }, }, }, }), ), }); const liste = await screen.findByRole('list', { name: 'Im Gesetz nachlesen' }); await waitFor(() => { expect(within(liste).getAllByText(/im Wortlaut anzeigen/u).length).toBeGreaterThan(0); }); await nutzer.click(within(liste).getByText('§ 42a Abs. 1 Nr. 1 WaffG')); expect(screen.getByText(/Es ist verboten, Anscheinswaffen zu fuehren/u)).toBeInTheDocument(); }); it('nennt die verwandten Fragen zum selben Gegenstand', async () => { /* Der amtliche Katalog kennt keine Frage-zu-Frage-Beziehung, obwohl die Bündel auf der Hand liegen. Wer eine Frage gerade verstanden hat, ist im besten Moment, die Nachbarn mitzunehmen. */ const nutzer = await bisZumErgebnis(); const schalter = await screen.findByText(/Verwandte Fragen \(1\)/u); await nutzer.click(schalter); const liste = screen.getByRole('list', { name: /Diese Fragen behandeln denselben/u }); expect(within(liste).getByText(/Frage 1\.01/u)).toBeInTheDocument(); expect( screen.getByText(/Die Zuordnung ist eine Ergänzung dieser Software/u), ).toBeInTheDocument(); }); it('bietet mitten in der Sitzung keine Übungstaste an', async () => { /* Sie wäre hier eine Falle: Eine Sitzung zu starten ersetzt die laufende, und zwar ohne Rückfrage. Wer mitten in zwanzig Fragen steckt, verlöre seine Warteschlange für einen Knopf, den er vielleicht nur neugierig gedrückt hat. Die Taste steht am Ende der Sitzung. */ const nutzer = await bisZumErgebnis(); await nutzer.click(await screen.findByText(/Verwandte Fragen \(1\)/u)); expect( screen.queryByRole('button', { name: /Verwandte Fragen üben/u }), ).not.toBeInTheDocument(); }); it('weist die Erklärung als eigenen Zusatz aus', async () => { // Der amtliche Katalog darf nicht verändert wiedergegeben werden; was // eigener Zusatz ist, muss erkennbar sein. await bisZumErgebnis(); await screen.findByRole('heading', { name: 'Warum das so ist' }); expect( screen.getByText(/Diese Erklärung ist eine Ergänzung dieser Software/u), ).toBeInTheDocument(); }); it('kommt ohne Erklärung aus, wenn es zu einer Frage keine gibt', async () => { // Zwei Fragen, damit sich weiterblättern lässt: Die zweite hat keine // Erklärung – so wächst der Bestand auch in Wirklichkeit. const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await screen.findByRole('heading', { name: 'Warum das so ist' }); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await screen.findByRole('heading', { name: /Frage 2\.10/u }); await nutzer.click(screen.getAllByRole('checkbox')[0]!); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect( screen.getByRole('group', { name: /Unvollständig|Falsch|Richtig/u }), ).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument(); }); it('sagt ausdrücklich, wenn es keine gesetzliche Grundlage gibt', async () => { /* In Waffentechnik und Handhabung ist das der Regelfall. Eine gedehnte Fundstelle wäre schlimmer als keine – sie täuscht eine Verbindlichkeit vor, die es nicht gibt. */ const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])), }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: /^Gewusst/u })); await screen.findByRole('heading', { name: 'Warum das so ist' }); expect( screen.getByText(/Sicherheitsregeln zur Handhabung sind fachliche Praxis/u), ).toBeInTheDocument(); // Kein leerer Fundstellenblock, wo es nichts nachzulesen gibt. expect(screen.queryByRole('heading', { name: 'Im Gesetz nachlesen' })).not.toBeInTheDocument(); }); it('bleibt bedienbar, wenn der Kern den Kanal nicht anbietet', async () => { const nutzer = await ohneKanalOeffnen('erklaerungenLaden'); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Warum das so ist' })).not.toBeInTheDocument(); }); }); describe('Lernsitzung – Mehrfachauswahl', () => { const nurMehrfach = { lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])), }; it('nutzt Kontrollkästchen und weist auf mehrere richtige Antworten hin', async () => { await lernsitzungOeffnen(nurMehrfach); expect(await screen.findByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument(); expect(screen.getAllByRole('checkbox')).toHaveLength(3); expect(screen.getByText('Wählen Sie alle zutreffenden aus.')).toBeInTheDocument(); expect( screen.getByRole('group', { name: /mehrere Antworten sind richtig/u }), ).toBeInTheDocument(); }); it('wertet eine unvollständige Auswahl als unvollständig', async () => { const nutzer = await lernsitzungOeffnen(nurMehrfach); await screen.findByRole('heading', { name: /Frage 2\.10/u }); await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Unvollständig' })).toBeInTheDocument(); expect(ansagebereich()).toHaveTextContent('Unvollständig. Es fehlt Antwort b.'); }); it('wertet die vollständige Auswahl als richtig', async () => { const nutzer = await lernsitzungOeffnen(nurMehrfach); await screen.findByRole('heading', { name: /Frage 2\.10/u }); await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u })); await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument(); expect(ansagebereich()).toHaveTextContent('Richtig sind die Antworten a und b.'); }); it('wertet eine zusätzliche falsche Auswahl als falsch', async () => { const nutzer = await lernsitzungOeffnen(nurMehrfach); await screen.findByRole('heading', { name: /Frage 2\.10/u }); await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u })); await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u })); await nutzer.click(screen.getByRole('checkbox', { name: /jährlich vorführen/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Falsch' })).toBeInTheDocument(); }); }); describe('Lernsitzung – offene Fragen', () => { const nurFreitext = { lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])), }; /* Der Text lag seit jeher in `antwort_log.freitext` und wurde nie gezeigt. Sein Wert liegt im Wiedersehen – aber erst NACH dem Bestätigen: vorher wäre er eine Vorlage zum Abschreiben und machte aus dem Ausformulieren ein Kopieren. */ it('zeigt die frühere eigene Antwort erst nach dem Bestätigen', async () => { const mitFrueher = { ...nurFreitext, lernSitzung: vi.fn(() => Promise.resolve([ { frageId: 'I.1-01', optionsReihenfolge: [], gemerkt: false, letzterFreitext: { text: 'Den Umgang mit Waffen, glaube ich.', zeitpunkt: '2026-08-09T09:00:00.000Z', }, }, ]), ), }; const nutzer = await lernsitzungOeffnen(mitFrueher); await screen.findByRole('heading', { name: /Frage 1\.01/u }); // Vor dem Bestätigen: nichts davon zu sehen. expect(screen.queryByText(/Den Umgang mit Waffen, glaube ich\./u)).not.toBeInTheDocument(); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Zweiter Versuch.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(screen.getByText(/Den Umgang mit Waffen, glaube ich\./u)).toBeVisible(); /* Mit Datum, nicht bloß „beim letzten Mal“: Ob es gestern war oder vor einem Monat, ändert die Bedeutung des Vergleichs. */ expect(screen.getByText(/Am 09\.08\.2026 schrieben Sie:/u)).toBeVisible(); }); it('lässt den Block weg, wenn es keine frühere Antwort gibt', async () => { const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(screen.queryByText(/schrieben Sie:/u)).not.toBeInTheDocument(); }); it('nennt die Kernpunkte als Einschätzung dieser Software', async () => { /* Bei 63 der 104 offenen Fragen ist im Katalog nichts unterstrichen. Der Lernende stand bei der Selbstbewertung ohne jedes Kriterium da – die Hilfe warnt vor zu milder Bewertung, gab aber kein Werkzeug. */ const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); const liste = screen.getByRole('list', { name: 'Nach Einschätzung dieser Software gehört zu einer vollständigen Antwort:', }); expect(within(liste).getAllByRole('listitem')).toHaveLength(2); expect( screen.getByText(/Diese Punkte sind eine Ergänzung dieser Software/u), ).toBeInTheDocument(); }); it('behauptet nie, was eine Antwort enthalten muss', async () => { /* Die Leitplanke aus `docs/entscheidung-freitext.md` Abschnitt 8. Bis Fassung 0.17.0 stand über einer ähnlichen Liste „Diese Kernelemente muss Ihre Antwort enthalten“ – unbelegt, und ausdrücklich abgeschafft. */ const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(screen.queryByText(/muss Ihre Antwort enthalten/u)).not.toBeInTheDocument(); expect(screen.queryByText(/zwingend erforderlich/u)).not.toBeInTheDocument(); }); it('zeigt die Musterantwort erst nach dem Bestätigen', async () => { const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' }); expect(screen.queryByRole('heading', { name: 'Musterantwort' })).not.toBeInTheDocument(); await nutzer.type(feld, 'Den Umgang mit Waffen.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(screen.getByRole('heading', { name: 'Musterantwort' })).toBeInTheDocument(); const unterstrichen = screen.getByRole('list', { name: 'Im amtlichen Fragenkatalog ist hier unterstrichen:', }); expect( within(unterstrichen) .getAllByRole('listitem') .map((eintrag) => eintrag.textContent), ).toEqual(['Umgang mit Waffen oder Munition', 'öffentlichen Sicherheit']); }); /* Bis 0.17.0 stand über dieser Liste „Diese Kernelemente muss Ihre Antwort enthalten:“. Beides war unbelegt: Das Amt sagt nirgends, was die Unterstreichung bedeutet, und bei 63 der 104 offenen Fragen ist gar nichts unterstrichen. Ohne diese Prüfung schriebe die Behauptung irgendwann jemand zurück, weil die alte Formulierung griffiger klingt. */ it('nennt die Unterstreichung als Tatsache und nicht als Vorgabe', async () => { const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Etwas.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(screen.queryByText(/muss Ihre Antwort enthalten/u)).not.toBeInTheDocument(); expect(screen.getByText(/keine Vorgabe für Ihre Antwort/u)).toHaveTextContent( /Eine richtige Antwort kann anders formuliert sein/u, ); }); /* Das Feld bleibt nach dem Bestätigen stehen, damit sich die eigene Antwort gegen die Musterantwort halten lässt. Mit `disabled` war genau das per Tastatur unmöglich – ein disabled-Feld nimmt keinen Fokus, ein Screenreader kam an den eigenen Text nicht mehr heran. */ it('lässt die eigene Antwort nach dem Bestätigen lesbar und ansteuerbar', async () => { const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' }); await nutzer.type(feld, 'Den Umgang mit Waffen.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(feld).toHaveValue('Den Umgang mit Waffen.'); expect(feld).not.toBeDisabled(); expect(feld).toHaveAttribute('readonly'); feld.focus(); expect(feld).toHaveFocus(); }); it('übernimmt die Selbstbewertung „Gewusst“ als richtig', async () => { const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Den Umgang.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Gewusst' })); expect(screen.getByRole('group', { name: 'Gewusst' })).toBeInTheDocument(); expect(ansagebereich()).toHaveTextContent('Bewertung: Gewusst. Sie lässt sich noch ändern.'); /* Gebucht wird erst beim Weiterblättern – siehe den Block „Selbstbewertung zurücknehmen“ weiter unten. */ await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' })); expect(lernAntworten).toHaveBeenCalledWith( 1, expect.objectContaining({ frageId: 'I.1-01', freitext: 'Den Umgang.', richtig: true, bewertung: 'gut', }), ); }); it('wertet „Nicht gewusst“ als falsch', async () => { const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); expect(lernAntworten).toHaveBeenCalledWith( 1, expect.objectContaining({ richtig: false, bewertung: 'nochmal' }), ); }); it('bietet alle vier Bewertungsstufen in einer benannten Gruppe an', async () => { const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); const gruppe = screen.getByRole('group', { name: 'Wie gut wussten Sie die Antwort?' }); expect( within(gruppe) .getAllByRole('button') .map((knopf) => knopf.textContent), ).toEqual(['Nicht gewusst', 'Mit Mühe gewusst', 'Gewusst', 'Sicher gewusst']); }); }); /* Die Selbstbewertung zurücknehmen. Bis 0.22.0 buchte ein Klick auf eine der vier Stufen sofort und endgültig: Ein Fehlgriff auf „Gewusst“ statt „Nicht gewusst“ schrieb eine falsche FSRS-Buchung – hohe Stabilität, langes Intervall, unter Umständen ein Reife-Beleg –, und das Programm bot nie wieder an, das zu berichtigen. Bei vier eng beieinanderliegenden Knöpfen ist das kein Randfall. Die andere Richtung braucht nichts: Eine zu streng bewertete Antwort kommt in derselben Sitzung wieder, und FSRS verkürzt das Intervall ohnehin. Gefährlich ist allein die zu milde Bewertung. */ describe('Lernsitzung – Selbstbewertung zurücknehmen', () => { const nurFreitext = { lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])), }; it('bucht erst beim Weiterblättern, nicht schon beim Bewerten', async () => { const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' })); expect(lernAntworten).not.toHaveBeenCalled(); }); it('verwirft die zu milde Bewertung, statt sie zu buchen', async () => { /* Der eigentliche Schadensfall: „Sicher gewusst“ statt „Nicht gewusst“. Nach der Korrektur darf ausschließlich die berichtigte Bewertung im Lernstand ankommen – keine zwei Einträge, kein „leicht“. */ const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Sicher gewusst' })); await nutzer.click(screen.getByRole('button', { name: 'Bewertung ändern' })); await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); expect(lernAntworten).toHaveBeenCalledTimes(1); expect(lernAntworten).toHaveBeenCalledWith( 1, expect.objectContaining({ richtig: false, bewertung: 'nochmal' }), ); }); it('führt zurück zur Bewertungsgruppe und sagt es an', async () => { const nutzer = await lernsitzungOeffnen(nurFreitext); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Gewusst' })); await nutzer.click(screen.getByRole('button', { name: 'Bewertung ändern' })); expect( screen.getByRole('group', { name: 'Wie gut wussten Sie die Antwort?' }), ).toBeInTheDocument(); expect(ansagebereich()).toHaveTextContent(/zurückgenommen/u); }); it('bietet die Rücknahme bei Auswahlfragen nicht an', async () => { /* Dort steht die Lösung auf dem Bildschirm. Eine änderbare Antwort wäre kein Korrekturweg, sondern ein zweiter Versuch mit bekannter Lösung. */ const nutzer = await lernsitzungOeffnen(); const optionen = await screen.findAllByRole('radio'); await nutzer.click(optionen[0]!); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.queryByRole('button', { name: 'Bewertung ändern' })).not.toBeInTheDocument(); }); /* „Ich hatte geraten“ – die Selbsteinschätzung bei Auswahlfragen. `bewertungAusErgebnis` vergibt für jede richtige Antwort ausnahmslos „gut“ – auch für die geratene. `docs/entscheidung-ratewahrscheinlichkeit.md` lehnt zwei **automatische** Milderungen ab und benennt die Ursache wörtlich: „Das Problem sitzt im Eingang (ein Klick ist keine Selbsteinschätzung), nicht in den Parametern.“ Dieser Knopf öffnet genau diesen Eingang – freiwillig, und wer ihn übersieht, merkt nichts. */ it('bietet die Selbsteinschätzung nach einer richtigen Auswahlantwort an', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('button', { name: 'Ich hatte geraten' })).toBeInTheDocument(); }); it('bietet sie nach einer falschen Antwort nicht an', async () => { /* Da gibt es nichts einzugestehen – die Frage kommt ohnehin wieder. */ const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.queryByRole('button', { name: 'Ich hatte geraten' })).toBeNull(); }); it('bucht die Antwort als „nicht gewusst“, ohne sie falsch zu machen', async () => { /* Angekreuzt war das Richtige, gewusst war es nicht: `richtig` bleibt wahr – der Anwendungskern rechnet es ohnehin selbst aus der Auswahl nach –, allein die Bewertung ändert sich. */ const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Ich hatte geraten' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); expect(lernAntworten).toHaveBeenCalledTimes(1); expect(lernAntworten).toHaveBeenCalledWith( 1, expect.objectContaining({ richtig: true, bewertung: 'nochmal' }), ); }); it('sagt an, was das Eingeständnis bewirkt', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Ich hatte geraten' })); /* Genau so weit, wie es der Anwendungskern einhält: Ein Zufallstreffer darf keinen Beleg SETZEN; einen vorhandenen nimmt nur eine falsche Antwort weg. Bis 0.24.1 versprach die Ansage das Weitergehende und widersprach damit dem Kern. */ expect(ansagebereich()).toHaveTextContent( /als neuer Beleg für Ihren Reifegrad zählt sie nicht/u, ); expect(ansagebereich()).not.toHaveTextContent(/Reifegrad zählt sie nicht mit/u); /* Zweimal drücken soll nichts Weiteres tun. */ expect(screen.queryByRole('button', { name: 'Ich hatte geraten' })).toBeNull(); }); it('bucht auch dann, wenn die Sitzung vorzeitig verlassen wird', async () => { /* Wer bewertet und dann zum Start geht, hat die Frage bearbeitet – sie darf nicht verfallen. */ const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ ...nurFreitext, lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Gewusst' })); await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' })); expect(lernAntworten).toHaveBeenCalledWith( 1, expect.objectContaining({ richtig: true, bewertung: 'gut' }), ); }); }); describe('Lernsitzung – Bildoptionen', () => { const nurBild = { lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_BILD)])) }; it('macht eine reine Bildoption auswählbar und beschriftet sie über den Alternativtext', async () => { const nutzer = await lernsitzungOeffnen(nurBild); await screen.findByRole('heading', { name: /Frage 3\.05/u }); const option = await screen.findByRole('radio', { name: /Rundes Zeichen mit der Buchstabenfolge PTB/u, }); await nutzer.click(option); expect(option).toBeChecked(); expect( screen.getByRole('img', { name: 'Rundes Zeichen mit der Buchstabenfolge PTB' }), ).toBeInTheDocument(); }); /* Zweite Stufe des Bildbeschreibungskonzepts (Prüfplan S4): Der Alt-Text bleibt neutral, weil das Zeichen hier selbst die Antwortoption ist – die Bedeutung stünde sonst schon in der Beschriftung und verriete die Lösung. Nachgereicht wird sie erst mit der Lösung. */ it('verrät die Bedeutung des Zeichens vor dem Bestätigen nicht', async () => { await lernsitzungOeffnen(nurBild); await screen.findByRole('heading', { name: /Frage 3\.05/u }); expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument(); expect(screen.queryByText(/Physikalisch-Technischen Bundesanstalt/u)).not.toBeInTheDocument(); }); it('reicht die Bedeutung des Zeichens nach dem Bestätigen aufklappbar nach', async () => { const nutzer = await lernsitzungOeffnen(nurBild); await screen.findByRole('heading', { name: /Frage 3\.05/u }); await nutzer.click( screen.getByRole('radio', { name: /Rundes Zeichen mit der Buchstabenfolge PTB/u }), ); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); const schalter = screen.getByText('Was dieses Zeichen bedeutet'); expect(schalter.closest('details')).not.toHaveAttribute('open'); await nutzer.click(schalter); expect( screen.getByText(/Zulassungszeichen der Physikalisch-Technischen Bundesanstalt/u), ).toBeVisible(); }); it('lässt den Aufklapper bei Zeichen ohne hinterlegte Bedeutung weg', async () => { /* Option b) trägt das F im Fünfeck ohne Beschreibung – dort darf nach dem Bestätigen kein leerer Aufklappbereich stehen. Übrig bleibt genau der eine an Option a). */ const nutzer = await lernsitzungOeffnen(nurBild); await screen.findByRole('heading', { name: /Frage 3\.05/u }); await nutzer.click( screen.getByRole('radio', { name: /Rundes Zeichen mit der Buchstabenfolge PTB/u }), ); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getAllByText('Was dieses Zeichen bedeutet')).toHaveLength(1); }); it('reicht die Bedeutung auch bei Zeichen am Fragetext nach', async () => { /* Der zweite Ort für Bilder: nicht in einer Option, sondern an der Frage selbst. Auch dort gilt: vorher nichts, nachher aufklappbar. */ const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_BILD_KOPF)])), }); await screen.findByRole('heading', { name: /Frage 3\.06/u }); expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); const schalter = screen.getByText('Was dieses Zeichen bedeutet'); await nutzer.click(schalter); expect( screen.getByText(/Zulassungszeichen der Physikalisch-Technischen Bundesanstalt/u), ).toBeVisible(); }); }); describe('Lernsitzung – kein Autosprung und keine Zeitvorgabe', () => { it('bleibt nach dem Bestätigen bei derselben Frage (WCAG 3.2.2)', async () => { const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument(); expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument(); const weiter = screen.getByRole('button', { name: 'Nächste Frage' }); await nutzer.click(weiter); expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument(); expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument(); }); }); describe('Lernsitzung – Merkliste', () => { it('schaltet das Merken um und meldet es an den Lernstand', async () => { const lernMerken = vi.fn((_profilId: number, frageId: string, gemerkt: boolean) => Promise.resolve({ ...STAND, frageId, gemerkt }), ); const nutzer = await lernsitzungOeffnen({ lernMerken }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); const knopf = screen.getByRole('button', { name: 'Frage merken' }); expect(knopf).toHaveAttribute('aria-pressed', 'false'); await nutzer.click(knopf); expect(knopf).toHaveAttribute('aria-pressed', 'true'); expect(lernMerken).toHaveBeenLastCalledWith(1, 'I.1-87', true); await nutzer.click(knopf); expect(knopf).toHaveAttribute('aria-pressed', 'false'); expect(lernMerken).toHaveBeenLastCalledWith(1, 'I.1-87', false); }); }); describe('Lernsitzung – Tastaturbedienung', () => { it('wählt Optionen mit den Ziffern und bestätigt mit der Eingabetaste', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.keyboard('3'); expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).toBeChecked(); expect(ansagebereich()).toHaveTextContent('Antwort c gewählt.'); await nutzer.keyboard('{Enter}'); expect(screen.getByRole('group', { name: 'Richtig' })).toBeInTheDocument(); }); it('merkt die Frage mit der Taste M', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.keyboard('m'); expect(screen.getByRole('button', { name: 'Frage merken' })).toHaveAttribute( 'aria-pressed', 'true', ); }); it('schaltet mit der Eingabetaste zur nächsten Frage weiter', async () => { const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.keyboard('1'); await nutzer.keyboard('{Enter}'); expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument(); await nutzer.keyboard('{Enter}'); expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument(); }); it('bewegt sich in der Radiogruppe mit den Pfeiltasten', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); const erste = screen.getByRole('radio', { name: /Sportgewehr/u }); erste.focus(); await nutzer.keyboard('{ArrowDown}'); expect(screen.getByRole('radio', { name: /Schreckschusspistole/u })).toBeChecked(); }); it('lässt Ziffern unbeachtet, wenn die Kürzel abgeschaltet sind (WCAG 2.1.4)', async () => { window.localStorage.setItem('waffensachkunde.tastenkuerzel', 'aus'); const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.keyboard('3'); expect(screen.getByRole('radio', { name: /Spielzeugpistole/u })).not.toBeChecked(); }); it('schreibt Ziffern in das Freitextfeld, statt Optionen zu wählen', async () => { const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_FREITEXT)])), }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); const feld = screen.getByRole('textbox', { name: 'Ihre Antwort' }); await nutzer.click(feld); await nutzer.keyboard('3 Gründe'); expect(feld).toHaveValue('3 Gründe'); }); }); describe('Lernsitzung – Fokusführung', () => { it('setzt den Fokus nach dem Bestätigen auf den Ergebnisbereich', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); expect(screen.getByRole('group', { name: 'Richtig' })).toHaveFocus(); }); it('setzt den Fokus bei der nächsten Frage auf deren Überschrift', async () => { const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); expect(screen.getByRole('heading', { name: /Frage 2\.10/u })).toHaveFocus(); }); }); describe('Sitzungsende', () => { it('fasst die Sitzung zusammen und bietet die Fehler zur Wiederholung an', async () => { const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await nutzer.click(screen.getByRole('checkbox', { name: /jährlich vorführen/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); /* Die falsche Antwort reiht die Frage wieder ein; die Wiederholung wird richtig beantwortet und zählt als eigener Versuch – zwei von drei Antworten richtig, aber nur eine falsch beantwortete Frage. */ await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u })); await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' })); expect( screen.getByRole('heading', { name: 'Auswertung: Weiterlernen', level: 1 }), ).toBeInTheDocument(); expect( screen.getByText('2 von 3 beantworteten Fragen richtig – Trefferquote 67 Prozent.'), ).toBeInTheDocument(); const fehlerliste = screen.getByRole('list', { name: 'Falsch beantwortete Fragen' }); expect(within(fehlerliste).getAllByRole('listitem')).toHaveLength(1); expect(within(fehlerliste).getByText(/Frage 2\.10/u)).toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Fehler wiederholen (1)' })); expect(await screen.findByRole('heading', { name: /Frage 2\.10/u })).toBeInTheDocument(); }); it('bietet die verwandten Fragen zu den Fehlern zum Üben an', async () => { /* Hier und nicht in der laufenden Sitzung: Am Ende läuft nichts mehr, und es ist genau der Moment, in dem entschieden wird, was als Nächstes drankommt. Angeboten wird, was zu den **Fehlern** gehört – wer eine Frage sicher konnte, braucht ihre Nachbarn nicht. */ const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH)])), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); // Falsch antworten, damit I.1-87 in der Fehlerliste landet. await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' })); const taste = await screen.findByRole('button', { name: 'Verwandte Fragen üben (1)' }); await nutzer.click(taste); expect(await screen.findByRole('heading', { name: /Frage 1\.01/u })).toBeInTheDocument(); }); /* Der häufigste Weg nach einer Sitzung ist die nächste. Das Tagespensum umfasst oft mehrere Sitzungen. Bis 0.22.0 führte dieser Weg über den Startbildschirm zurück – rund 8000 Bildpunkte hoch – und dort erneut über „Weiterlernen“. */ it('bietet die nächste Sitzung an, ohne den Umweg über den Start', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' })); await nutzer.click(await screen.findByRole('button', { name: 'Weiterlernen' })); expect(await screen.findByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument(); }); }); describe('Lernsitzung – falsch beantwortete Fragen kommen wieder', () => { /** Wählt die falsche Antwort der Einfachauswahl-Frage und bestätigt. */ async function falschAntworten(nutzer: Nutzer): Promise { await nutzer.click(screen.getByRole('radio', { name: /Sportgewehr/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); } it('reiht eine falsch beantwortete Frage ans Ende der Sitzung', async () => { const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await falschAntworten(nutzer); // Die Anzeige bleibt ehrlich: Y wächst mit der Wiedereinreihung. expect(screen.getByText('Frage 1 von 3')).toBeInTheDocument(); expect(ansagebereich()).toHaveTextContent('Die Frage kommt in dieser Sitzung noch einmal.'); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await screen.findByRole('heading', { name: /Frage 2\.10/u }); await nutzer.click(screen.getByRole('checkbox', { name: /sorgfältig aufbewahren/u })); await nutzer.click(screen.getByRole('checkbox', { name: /unverzüglich anzeigen/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); // Am Ende steht die falsch beantwortete Frage noch einmal. expect(screen.getByRole('heading', { name: /Frage 1\.87/u })).toBeInTheDocument(); expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument(); }); it('reiht dieselbe Frage höchstens zweimal wieder ein', async () => { const nutzer = await lernsitzungOeffnen(); // eine einzige Frage await screen.findByRole('heading', { name: /Frage 1\.87/u }); await falschAntworten(nutzer); expect(screen.getByText('Frage 1 von 2')).toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await falschAntworten(nutzer); expect(screen.getByText('Frage 2 von 3')).toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await falschAntworten(nutzer); /* Keine dritte Wiedereinreihung: Die Sitzung endet, danach übernimmt die Wiedervorlage – „nochmal“ macht die Frage sofort wieder fällig, die nächste Sitzung legt sie vorn vor. */ expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument(); expect(ansagebereich()).not.toHaveTextContent('kommt in dieser Sitzung noch einmal'); expect(screen.getByRole('button', { name: 'Sitzung auswerten' })).toBeInTheDocument(); }); it('lässt eine richtig beantwortete Wiederholung nicht erneut erscheinen', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await falschAntworten(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument(); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); // Richtig beantwortet: nichts wächst nach, die Sitzung ist zu Ende. expect(screen.getByText('Frage 2 von 2')).toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' })); expect( screen.getByText('1 von 2 beantworteten Fragen richtig – Trefferquote 50 Prozent.'), ).toBeInTheDocument(); // Je Frage ein Eintrag, auch wenn sie zweimal vorgelegt wurde. const fehlerliste = screen.getByRole('list', { name: 'Falsch beantwortete Fragen' }); expect(within(fehlerliste).getAllByRole('listitem')).toHaveLength(1); }); it('bucht die Wiederholungsantwort über denselben Antwortweg', async () => { const lernAntworten = vi.fn(() => Promise.resolve(STAND)); const nutzer = await lernsitzungOeffnen({ lernAntworten }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await falschAntworten(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); /* Beide Buchungen fallen beim Weiterblättern – seit 0.22.0 wird bis dahin zurückgehalten, damit sich „Ich hatte geraten“ nachreichen lässt. */ await nutzer.click(screen.getByRole('button', { name: 'Sitzung auswerten' })); // Beide Vorlagen sind reguläre Antworten – FSRS-6 verrechnet die // Same-Day-Wiederholung über seine Kurzzeitparameter im Anwendungskern. expect(lernAntworten).toHaveBeenCalledTimes(2); expect(lernAntworten).toHaveBeenNthCalledWith( 1, 1, expect.objectContaining({ frageId: 'I.1-87', richtig: false, bewertung: 'nochmal' }), ); expect(lernAntworten).toHaveBeenNthCalledWith( 2, 1, expect.objectContaining({ frageId: 'I.1-87', richtig: true, bewertung: 'gut' }), ); }); /* Die Wiedereinreihung schafft für offene Fragen einen Fall, den es vorher nicht gab: dieselbe Frage zweimal in einer Sitzung. „Was zuletzt geschrieben wurde“ ist dann die Antwort von eben – der Eintrag aus dem Lernstand wäre die falsche Auskunft und der schwächere Vergleich. */ it('zeigt bei der Wiederholung die Antwort von eben statt der aus dem Lernstand', async () => { const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([ { frageId: 'I.1-01', optionsReihenfolge: [], gemerkt: false, letzterFreitext: { text: 'Den Umgang mit Waffen, glaube ich.', zeitpunkt: '2026-08-09T09:00:00.000Z', }, }, ]), ), }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Erster Anlauf.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await nutzer.type(screen.getByRole('textbox', { name: 'Ihre Antwort' }), 'Zweiter Anlauf.'); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(screen.getByText(/Erster Anlauf\./u)).toBeVisible(); expect(screen.queryByText(/Den Umgang mit Waffen, glaube ich\./u)).not.toBeInTheDocument(); }); it('behält den Eintrag aus dem Lernstand, wenn nichts geschrieben wurde', async () => { /* Leer lassen ist erlaubt und ist kein Schreiben: Zuletzt geschrieben wurde dann weiterhin das Frühere. */ const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([ { frageId: 'I.1-01', optionsReihenfolge: [], gemerkt: false, letzterFreitext: { text: 'Den Umgang mit Waffen, glaube ich.', zeitpunkt: '2026-08-09T09:00:00.000Z', }, }, ]), ), }); await screen.findByRole('heading', { name: /Frage 1\.01/u }); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nicht gewusst' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await nutzer.click(screen.getByRole('button', { name: 'Musterantwort anzeigen' })); expect(screen.getByText(/Am 09\.08\.2026 schrieben Sie:/u)).toBeVisible(); }); }); describe('Lernsitzung – Ausfall des Anwendungskerns', () => { it('übt ersatzweise aus dem Katalog, wenn der Sitzungskanal scheitert', async () => { await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))), }); expect( await screen.findByText(/Ihre Lernsitzung konnte nicht geladen werden/u), ).toBeInTheDocument(); expect(screen.getByText(/Ihre Antworten werden nicht gespeichert/u)).toBeInTheDocument(); expect(screen.getAllByRole('heading', { level: 1 }).length).toBeGreaterThan(0); }); it('erklärt, dass „Nur Fehler“ ohne Lernstand nicht möglich ist', async () => { vi.stubGlobal( 'lernApp', attrappe({ lernSitzung: vi.fn(() => Promise.reject(new Error('kein Zugriff'))) }), ); const nutzer = userEvent.setup(); render(); const knopf = await screen.findByRole('button', { name: /Nur Fehler/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); expect(await screen.findByText(/braucht Ihren gespeicherten Lernstand/u)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Zum Start' })).toBeInTheDocument(); }); }); describe('Antwortbuchstabe gehört zum Inhalt, nicht zur Stelle', () => { /* Diese beiden Tests halten eine Entscheidung fest, nicht bloß das heutige Verhalten – deshalb stehen sie hier und nicht bei den Einstellungen. Der Buchstabe vor jeder Option ist der amtliche aus dem Katalog. Er ist zugleich der Verweis auf die gedruckte Vorlage und auf die Erklärungen, von denen 368 der 575 ihn beim Namen nennen („Zu a):“). Würde er stattdessen aus der Anzeigestelle abgeleitet, träfe er in fast zwei Dritteln aller Fragen eine andere Aussage als die Erklärung darunter. Der übrige Testbestand kann das nicht bemerken: Seine Attrappe reicht ausnahmslos die ungemischte Katalogreihenfolge herein, in der Buchstabe und Stelle ohnehin übereinstimmen. Hier wird bewusst verdreht. Siehe docs/entscheidung-antwortreihenfolge.md. */ it('zeigt bei verdrehter Reihenfolge den amtlichen Buchstaben am zugehörigen Inhalt', async () => { const verdreht: SitzungsFrage = { frageId: FRAGE_EINFACH.id, optionsReihenfolge: ['c', 'a', 'b'], gemerkt: false, }; await mitBrueckeOeffnen(attrappe({ lernSitzung: vi.fn(() => Promise.resolve([verdreht])) })); await screen.findByRole('heading', { name: /Frage 1\.87/u }); const optionen = screen.getAllByRole('radio').map((feld) => feld.closest('.antwortoption')); const marke = (stelle: number): string => optionen[stelle]?.querySelector('.antwortoption__marke')?.textContent ?? ''; const inhalt = (stelle: number): string => optionen[stelle]?.textContent ?? ''; expect(optionen).toHaveLength(3); // Erste Stelle trägt c) – der Buchstabe ist mit seinem Inhalt gewandert. expect(marke(0)).toBe('c)'); expect(inhalt(0)).toContain('Spielzeugpistole'); expect(marke(1)).toBe('a)'); expect(inhalt(1)).toContain('Sportgewehr'); expect(marke(2)).toBe('b)'); expect(inhalt(2)).toContain('Schreckschusspistole'); /* Und genau hier entsteht der Befund, der zu dieser Umstellung geführt hat: Die Ziffer ist das Tastenkürzel und zählt die Stelle, der Buchstabe gehört zum Inhalt. Gemischt laufen beide auseinander – auf dem Schirm steht dann „1 c)“. Das ist kein Fehler, sondern der Preis des Mischens, und deshalb steht der Schalter ab Werk auf aus. */ expect(inhalt(0)).toMatch(/^1c\)/u); }); it('nennt im Lösungssatz denselben Buchstaben, den die richtige Antwort auf dem Schirm trägt', async () => { const verdreht: SitzungsFrage = { frageId: FRAGE_EINFACH.id, optionsReihenfolge: ['c', 'a', 'b'], gemerkt: false, }; const nutzer = await mitBrueckeOeffnen( attrappe({ lernSitzung: vi.fn(() => Promise.resolve([verdreht])) }), ); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); /* Richtig ist die Option mit dem amtlichen Buchstaben c – auch wenn sie hier an erster Stelle steht. Stünde hier „Antwort a“, wäre der Buchstabe aus der Stelle abgeleitet worden. */ /* Der Satz steht zweimal da: sichtbar im Ergebnisbereich und in der Live-Region für Bildschirmleser. Beide müssen denselben Buchstaben nennen. */ const saetze = await screen.findAllByText(/Richtig ist Antwort c\./u); expect(saetze.length).toBeGreaterThan(0); expect(screen.queryByText(/Richtig ist Antwort a\./u)).toBeNull(); }); }); /** Öffnet eine Sitzung mit einer Einfachauswahl-Frage und verborgener Antwortzahl. */ async function mitVerborgenerAntwortzahl(): Promise { const nutzer = await mitBrueckeOeffnen( attrappe({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH)])), einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, antwortzahlVerbergen: true, }), ), }), ); await screen.findByRole('heading', { name: /Frage 1\.87/u }); return nutzer; } describe('Einstellung „Anzahl der richtigen Antworten verbergen“', () => { it('nennt ohne sie die Zahl der richtigen Antworten', async () => { await mitBrueckeOeffnen( attrappe({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_MEHRFACH)])) }), ); await screen.findByRole('heading', { name: /Frage 2\.10/u }); expect(screen.getByText(/mehrere Antworten sind richtig/u)).toBeInTheDocument(); expect(screen.getAllByRole('checkbox')).toHaveLength(3); }); it('verschweigt sie eingeschaltet – wie die Prüfung', async () => { await mitVerborgenerAntwortzahl(); /* Einfachauswahl, aber Kästchen statt Radios: Sonst verriete schon die Bauart des Bedienelements, dass genau eine Antwort richtig ist. */ expect( await screen.findByText(/ist nicht angegeben, wie viele Antworten richtig sind/u), ).toBeInTheDocument(); expect(screen.getAllByRole('checkbox')).toHaveLength(3); expect(screen.queryAllByRole('radio')).toHaveLength(0); }); it('lässt bei Einfachauswahl zwei Kästchen zugleich stehen', async () => { /* Der eigentliche Prüfstein, und der Grund, warum ein Blick auf die Bauart der Bedienelemente nicht genügt: Bliebe die Auswahllogik an der Zahl der richtigen Antworten hängen, ginge beim Anhaken des zweiten Kästchens das erste stumm aus – und verriete damit genau das, was die Einstellung verschweigen soll. Eine zu große Auswahl muss möglich und schlicht falsch sein, wie in der Prüfung (vgl. usePruefung.optionWaehlen). */ const nutzer = await mitVerborgenerAntwortzahl(); const kaestchen = screen.getAllByRole('checkbox'); await nutzer.click(kaestchen[0]!); await nutzer.click(kaestchen[1]!); expect(kaestchen[0]).toBeChecked(); expect(kaestchen[1]).toBeChecked(); // Und abwählbar muss jedes einzelne bleiben. await nutzer.click(kaestchen[0]!); expect(kaestchen[0]).not.toBeChecked(); expect(kaestchen[1]).toBeChecked(); }); it('sagt das Abwählen an, statt nur das Wählen', async () => { /* Mit erzwungener Einfachauswahl war der Zweig „abgewählt“ in diesem Zustand unerreichbar – für Bildschirmleser ein stummer Zustandswechsel. */ const nutzer = await mitVerborgenerAntwortzahl(); await nutzer.keyboard('1'); expect(ansagebereich()).toHaveTextContent('Antwort a gewählt.'); await nutzer.keyboard('1'); expect(ansagebereich()).toHaveTextContent('Antwort a abgewählt.'); }); it('bleibt im verborgenen Zustand frei von axe-Verstößen', async () => { const nutzer = await mitVerborgenerAntwortzahl(); await nutzer.click(screen.getAllByRole('checkbox')[0]!); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]); }); }); describe('Lernsitzung – Barrierefreiheits-Rauchtest (axe-core)', () => { it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => { const nutzer = await lernsitzungOeffnen(); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]); }); }); describe('Vorlesen und Fragenwechsel', () => { /* Gemeldet von einem Nutzer: „Wenn eine Frage oder Antwort vorgelesen wird und es wird die nächste Frage ausgewählt, dann wird immer noch die alte Frage oder Antwort vorgelesen.“ Die Stimme gehört zu dem, was auf dem Bildschirm steht. Bleibt sie beim Wechsel stehen, hört man etwas anderes, als man sieht – für jemanden, der sich die Fragen vorlesen lässt, ist das nicht eine Unschönheit, sondern der Verlust der Orientierung. */ interface Stimmwerk { readonly gesprochen: string[]; readonly abbrueche: () => number; } function sprachausgabeStellen(): Stimmwerk { const gesprochen: string[] = []; let abbrueche = 0; class AeusserungAttrappe { lang = ''; voice: unknown = null; onend: (() => void) | null = null; onerror: ((ereignis: { error: string }) => void) | null = null; constructor(public readonly text: string) {} } vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe); vi.stubGlobal('speechSynthesis', { speak: (aeusserung: AeusserungAttrappe) => gesprochen.push(aeusserung.text), cancel: () => { abbrueche += 1; }, getVoices: () => [], speaking: false, pending: false, paused: false, }); return { gesprochen, abbrueche: () => abbrueche }; } /** Sitzung mit eingeschalteter Sprachausgabe. */ async function mitStimmeOeffnen(): Promise<{ nutzer: Nutzer; werk: Stimmwerk }> { const werk = sprachausgabeStellen(); /* Zwei Fragen, damit es ein „danach" gibt – bei einer einzigen heißt der Knopf „Sitzung beenden", und die Ansicht wird verlassen statt gewechselt. Der Fehler steckt aber im Wechsel. */ const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); await nutzer.click(screen.getByRole('radio', { name: /Spielzeugpistole/u })); return { nutzer, werk }; } it('bricht die laufende Frage ab, sobald die nächste Frage kommt', async () => { const { nutzer, werk } = await mitStimmeOeffnen(); await nutzer.click(await screen.findByRole('button', { name: /Frage vorlesen/u })); expect(werk.gesprochen.length).toBeGreaterThan(0); /* Nach dem Bestätigen liest die Anwendung die Rückmeldung von selbst vor und bricht dafür ohnehin ab. Gemessen wird deshalb erst ab hier: Der Wechsel zur nächsten Frage spricht nichts Neues – wenn hier nicht abgebrochen wird, redet die alte Rückmeldung weiter. */ await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await screen.findByRole('heading', { name: 'Warum das so ist' }); const vorDemWechsel = werk.abbrueche(); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); await screen.findByRole('heading', { name: /Frage 2\.10/u }); expect(werk.abbrueche()).toBeGreaterThan(vorDemWechsel); }); it('lässt den Halt-Knopf nach dem Wechsel verschwinden', async () => { /* Die sichtbare Seite desselben Fehlers: Der Knopf „Vorlesen beenden“ steht nur, solange etwas läuft. Blieb der Zustand hängen, bot die Oberfläche an, etwas zu beenden, das zur alten Frage gehörte. */ const { nutzer } = await mitStimmeOeffnen(); await nutzer.click(await screen.findByRole('button', { name: /Frage vorlesen/u })); expect(screen.getByRole('button', { name: /Vorlesen beenden/u })).toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Antwort bestätigen' })); await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' })); expect(screen.queryByRole('button', { name: /Vorlesen beenden/u })).toBeNull(); expect(screen.getByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument(); }); }); /* Anhalten und Fortsetzen. Bis 0.22.0 kannte die Vorlesetaste nur Start und Beenden. Wer bei einer über eine Minute langen Erklärung kurz unterbrochen wurde, konnte nur abbrechen und musste von vorn beginnen. Gebaut ist es **nicht** über `speechSynthesis.pause()` / `resume()`: Das ist in Chromium unter Windows unzuverlässig – die Ausgabe bleibt gelegentlich stumm stehen. Stattdessen wird abgebrochen und die Stelle in der Häppchen-Warteschlange gemerkt; „Weiterlesen“ reiht ab dort neu ein. */ describe('Vorlesen anhalten und fortsetzen', () => { interface Stimmwerk { readonly gesprochen: string[]; readonly starte: (text: string) => void; readonly tempi: number[]; } function sprachausgabeStellen(): Stimmwerk { const gesprochen: string[] = []; const tempi: number[] = []; const offene: { text: string; onstart: (() => void) | null }[] = []; class AeusserungAttrappe { lang = ''; rate = 1; voice: unknown = null; onstart: (() => void) | null = null; onend: (() => void) | null = null; onerror: ((ereignis: { error: string }) => void) | null = null; constructor(public readonly text: string) {} } vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe); vi.stubGlobal('speechSynthesis', { speak: (aeusserung: AeusserungAttrappe) => { gesprochen.push(aeusserung.text); tempi.push(aeusserung.rate); offene.push({ text: aeusserung.text, onstart: aeusserung.onstart }); }, cancel: () => { offene.length = 0; }, getVoices: () => [], speaking: false, pending: false, paused: false, }); /* Bildet nach, dass die Sprachausgabe ein bestimmtes Stück beginnt – daran hängt, wo „Weiterlesen“ ansetzt. */ const starte = (text: string): void => { offene.find((eintrag) => eintrag.text === text)?.onstart?.(); }; return { gesprochen, starte, tempi }; } async function mitStimmeOeffnen( einstellungen: Record = {}, ): Promise<{ nutzer: Nutzer; werk: Stimmwerk }> { const werk = sprachausgabeStellen(); const nutzer = await lernsitzungOeffnen({ lernSitzung: vi.fn(() => Promise.resolve([reihenfolge(FRAGE_EINFACH), reihenfolge(FRAGE_MEHRFACH)]), ), einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true, ...einstellungen, }), ), }); await screen.findByRole('heading', { name: /Frage 1\.87/u }); return { nutzer, werk }; } it('bietet das Anhalten erst an, wenn etwas läuft', async () => { const { nutzer } = await mitStimmeOeffnen(); expect(screen.queryByRole('button', { name: /anhalten/u })).toBeNull(); await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); expect(screen.getByRole('button', { name: 'Vorlesen anhalten' })).toBeInTheDocument(); }); it('setzt an der angehaltenen Stelle fort, statt von vorn zu beginnen', async () => { const { nutzer, werk } = await mitStimmeOeffnen(); await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); const stuecke = [...werk.gesprochen]; expect(stuecke.length).toBeGreaterThan(1); /* Die Ausgabe ist beim zweiten Stück angekommen. */ const zweites = stuecke[1] ?? ''; werk.starte(zweites); await nutzer.click(screen.getByRole('button', { name: 'Vorlesen anhalten' })); werk.gesprochen.length = 0; await nutzer.click(screen.getByRole('button', { name: 'Weiterlesen' })); expect(werk.gesprochen[0]).toBe(zweites); expect(werk.gesprochen).not.toContain(stuecke[0]); }); it('gibt das eingestellte Sprechtempo an die Ausgabe weiter', async () => { /* `rate` wurde bis 0.22.0 nie gesetzt – das Tempo war nicht einstellbar. */ const { nutzer, werk } = await mitStimmeOeffnen({ sprechtempo: 1.5 }); await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); expect(werk.tempi.length).toBeGreaterThan(0); expect(new Set(werk.tempi)).toEqual(new Set([1.5])); }); it('weist ein unmögliches Tempo ab, statt es durchzureichen', async () => { /* `rate` erlaubt 0,1 bis 10. Ein von Hand in die Datei geschriebener Wert machte die Funktion unbrauchbar – unverständlich langsam oder schnell. */ const { nutzer, werk } = await mitStimmeOeffnen({ sprechtempo: 9 }); await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u })); expect(new Set(werk.tempi)).toEqual(new Set([1])); }); }); describe('Fehler zu dieser Frage melden', () => { /* Der kurze Weg aus der Sitzung heraus (D12). Wer mitten im Lernen einen Fehler im Katalog bemerkt, soll ihn melden koennen, ohne die Ansicht zu verlassen und die Fragennummer nachzuschlagen. */ it('steht zugeklappt da und nennt seinen Zweck unmissverstaendlich', async () => { /* „Fehler melden" allein staende in der Rueckmeldung neben dem Wort „Falsch" und laese sich als *meinen* Fehler vermerken. */ await lernsitzungOeffnen(); const aufklapper = await screen.findByText('Fehler zu dieser Frage melden'); expect(aufklapper.closest('details')?.open).toBe(false); }); it('zeigt den Text, bevor er kopiert werden kann', async () => { /* Der Text verlaesst spaeter das Geraet. Wer ihn weitergibt, muss vorher lesen koennen, was drinsteht - anders ist die Zusage „nichts ueber Sie" nicht ueberpruefbar. */ const nutzer = await lernsitzungOeffnen(); await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden')); const feld = await screen.findByLabelText('Meldetext'); await waitFor(() => { expect((feld as HTMLTextAreaElement).value).toContain('Kennung I.1-87'); }); /* Schreibgeschuetzt, aber nicht gesperrt: markieren und Strg+C muessen gehen, denn das ist der Weg ohne Zwischenablage-Kanal. */ expect(feld).toHaveAttribute('readonly'); expect(feld).not.toBeDisabled(); }); it('kopiert genau das, was im Feld steht', async () => { const zwischenablageSchreiben = vi.fn((_text: string) => Promise.resolve(true)); const nutzer = await lernsitzungOeffnen({ zwischenablageSchreiben }); await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden')); const knopf = await screen.findByRole('button', { name: 'Text kopieren' }); await nutzer.click(knopf); await waitFor(() => { expect(zwischenablageSchreiben).toHaveBeenCalledTimes(1); }); const uebergeben = zwischenablageSchreiben.mock.calls[0]![0]; expect(screen.getByLabelText('Meldetext')).toHaveValue(uebergeben); }); it('sagt den Erfolg an', async () => { const nutzer = await lernsitzungOeffnen({ zwischenablageSchreiben: vi.fn(() => Promise.resolve(true)), }); await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden')); await nutzer.click(await screen.findByRole('button', { name: 'Text kopieren' })); expect(await screen.findByText(/liegt in der Zwischenablage/u)).toBeInTheDocument(); }); it('nennt den Weg ueber die Tastatur, wenn das Kopieren scheitert', async () => { const nutzer = await lernsitzungOeffnen({ zwischenablageSchreiben: vi.fn(() => Promise.resolve(false)), }); await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden')); await nutzer.click(await screen.findByRole('button', { name: 'Text kopieren' })); expect(await screen.findByText(/Strg und C/u)).toBeInTheDocument(); }); it('laesst den Knopf fort, wenn der Kanal fehlt - und nennt den Ersatzweg', async () => { /* Kein gesperrter Knopf: Gesperrte Bedienelemente nehmen keinen Fokus, ihre Erklaerung wuerde nie vorgelesen. Der Text steht ohnehin da. */ const nutzer = await lernsitzungOeffnen(); await nutzer.click(await screen.findByText('Fehler zu dieser Frage melden')); await screen.findByLabelText('Meldetext'); expect(screen.queryByRole('button', { name: 'Text kopieren' })).toBeNull(); expect(screen.getByText(/Markieren Sie den Text im Feld/u)).toBeInTheDocument(); }); });