/** * Verhaltenstests des Startbildschirms. * * Schwerpunkt: Prüfungsreife verständlich anzeigen, die vier Wege ins Lernen * anbieten – und dabei auch dann tragfähig bleiben, wenn der Anwendungskern * einen Kanal noch nicht bereitstellt. */ import { fireEvent, 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 { sicherungsstandSatz } from '../src/renderer/src/lernen/zeitsaetze'; 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 { Frage, Katalog, RichText } from '../src/shared/katalog'; import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc'; import type { Erklaerungen } from '../src/shared/erklaerungen'; import type { Glossar } from '../src/shared/glossar'; import type { Lernplan } from '../src/shared/lernplan'; import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand'; import { NORMTEXTE_LEER, type Normtexte } from '../src/shared/normtexte'; import { THEMEN_LEER } from '../src/shared/themen'; /** Bridge-Attrappe, aus der sich einzelne Kanäle entfernen lassen. */ type TeilBridge = { -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] }; function nurText(text: string): RichText { return { text, segmente: [{ t: text }] }; } const FRAGE_A: Frage = { id: 'I.1-01', amtliche_nummer: '1.01', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 6, frage: nurText('Was regelt das Waffengesetz?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Den Umgang mit Waffen und Munition.'), korrekt: true, bilder: [], }, { label: 'b', inhalt: nurText('Den Handel mit Kraftfahrzeugen.'), korrekt: false, bilder: [] }, ], }; const FRAGE_B: Frage = { id: 'I.2-04', amtliche_nummer: '2.04', kapitel: 'I', abschnitt: 'I.2', typ: 'mc', seite: 30, frage: nurText('Wer darf eine Waffe führen?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Wer einen Waffenschein besitzt.'), korrekt: true, bilder: [] }, { label: 'b', inhalt: nurText('Jede volljährige Person.'), korrekt: false, bilder: [] }, ], }; const KATALOG: Katalog = { meta: { titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog des Bundesverwaltungsamtes, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: '7e5e41fa', fragen_gesamt: 2, }, kapitel: [ { id: 'I', titel: 'Waffenrecht und sonstige Rechtsvorschriften', abschnitte: [ { id: 'I.1', titel: 'Begriffe des Waffenrechts' }, { id: 'I.2', titel: 'Rechte und Pflichten' }, ], }, ], bilder: [], fragen: [FRAGE_A, FRAGE_B], }; 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', }, { id: 'I.1', titel: 'Begriffe des Waffenrechts', fragenGesamt: 90, beantwortet: 30, belegt: 12, reifegrad: 12 / 90, stufe: 'zurueck', }, ], themengruppen: [], }; const STAND: FrageStand = { frageId: 'I.1-01', 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 SITZUNG: SitzungsFrage[] = [ { frageId: 'I.1-01', optionsReihenfolge: ['a', 'b'], gemerkt: false }, ]; /** 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-01': { kurz: 'Das Waffengesetz regelt den Umgang mit Waffen und Munition.', text: 'Massgeblich ist § 1 Abs. 1 WaffG: Gegenstand ist der Umgang, Zweck die oeffentliche Sicherheit und Ordnung.', fundstellen: [{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }], merksatz: 'Gegenstand: der Umgang. Zweck: die 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: TeilBridge = {}): TeilBridge { return { anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), /* 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('data:image/png;base64,')), 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(SITZUNG)), 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, }; } /** Bildet den Zustand nach, in dem der Anwendungskern einen Kanal noch nicht anbietet. */ function ohneKanaele(...entfernt: readonly (keyof LernAppBridge)[]): TeilBridge { const bruecke = attrappe(); for (const kanal of entfernt) { Reflect.deleteProperty(bruecke, kanal); } return bruecke; } function starten(bruecke: TeilBridge = attrappe()): ReturnType { vi.stubGlobal('lernApp', bruecke); const nutzer = userEvent.setup(); render(); return nutzer; } /** * Alle Halte des Tabulators in Dokumentreihenfolge. * * Eine Radiogruppe ist genau **ein** Halt – der Tabulator springt sie als * Ganzes an, innerhalb wird mit den Pfeiltasten gewählt. Wer sie einzeln * zählte, rechnete die Wege künstlich lang. */ function tabulatorhalte(): HTMLElement[] { const kandidaten = [ ...document.querySelectorAll( 'main a[href], main button, main input, main select, main textarea, main summary, main [tabindex]', ), ].filter((element) => { if (element.getAttribute('tabindex') === '-1') { return false; } return !(element as { disabled?: boolean }).disabled; }); const gesehen = new Set(); return kandidaten.filter((element) => { if (!(element instanceof HTMLInputElement) || element.type !== 'radio') { return true; } if (gesehen.has(element.name)) { return false; } gesehen.add(element.name); return true; }); } afterEach(() => { 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(); }); describe('Startbildschirm – Prüfungsreife', () => { it('nennt die Stufe als Wort und den Stand als nachprüfbare Zahl', async () => { /* Die Farbe trägt nichts, was nicht auch als Text dasteht (WCAG 1.4.1). Und die Zahl steht vor der Auslegung: „48 von 575 Fragen sitzen belegt“ ist nachprüfbar, „noch nicht so weit“ ist eine Auslegung davon. */ starten(); const balken = await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); expect(within(balken.closest('.reifeampel')!).getByText('Noch nicht so weit')).toBeVisible(); expect(balken).toHaveAttribute('aria-valuenow', '48'); expect(balken).toHaveAttribute('aria-valuemin', '0'); expect(balken).toHaveAttribute('aria-valuemax', '575'); expect(balken.getAttribute('aria-valuetext')).toContain('48 von 575 Fragen sitzen belegt.'); }); it('verspricht in der Ampel keine Bestehenswahrscheinlichkeit', async () => { /* Eine Angabe wie „neun von zehn Läufen“ setzte voraus, dass alle Fragen dieselbe Trefferchance haben. Das Gegenteil ist der Fall. */ starten(); const balken = await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); expect(balken.getAttribute('aria-valuetext')).not.toMatch(/Prozent|von zehn/u); }); it('erklärt aufklappbar, was „belegt“ heißt', async () => { /* Die Erläuterung steht in der Lernstandskarte, nicht auf dem Einstieg: Dort kostet jede Zeile Blickfeld (e2e/blickfeld.spec.ts). */ starten(); const erklaerung = await screen.findByText('Was heißt „belegt“?'); expect(erklaerung).toBeInTheDocument(); expect(screen.getByText(/nach mindestens einem Tag Abstand/u)).toBeInTheDocument(); }); it('zeigt den Reifegrad-Verlauf als Satz und als Tabelle', async () => { /* Die Linie ist `aria-hidden` und trägt nichts allein (WCAG 1.4.1). Wer sie nicht sehen kann, muss dieselbe Auskunft bekommen – deshalb steht sie hier im Satz und in einer echten Tabelle mit Spaltenköpfen. */ const nutzer = starten( attrappe({ lernVerlauf: vi.fn(() => Promise.resolve([ { tag: '2026-03-01', reifegrad: 0.02, belegt: 12, beantwortet: 30 }, { tag: '2026-03-02', reifegrad: 0.05, belegt: 29, beantwortet: 60 }, { tag: '2026-03-03', reifegrad: 0.083, belegt: 48, beantwortet: 120 }, ]), ), }), ); expect(await screen.findByText(/von 12 auf 48 von 575 Fragen gestiegen/u)).toBeVisible(); expect(screen.getByText(/plus 36/u)).toBeVisible(); /* Die Tabelle steht eingeklappt – achtundzwanzig Zeilen übernähmen sonst den Startbildschirm. Aufgeklappt wird sie so, wie ein Mensch es tut. */ await nutzer.click(screen.getByText('Die Zahlen Tag für Tag')); const tabelle = await screen.findByRole('table', { name: 'Reifegrad Tag für Tag' }); expect(within(tabelle).getAllByRole('row')).toHaveLength(4); expect(within(tabelle).getByRole('columnheader', { name: 'Belegte Fragen' })).toBeVisible(); expect(within(tabelle).getByRole('rowheader', { name: '3. März' })).toBeVisible(); }); it('lässt den Verlauf ganz weg, wenn die Fassung ihn nicht kennt', async () => { /* Eine leere Liste hieße „nichts gelernt“ und wäre eine Aussage. Ein fehlender Kanal heißt „diese Fassung kann das nicht“ und darf keine werden. */ starten(); /* Abgewartet, bis der Startbildschirm steht – sonst prüfte die Abwesenheit nur, dass noch gar nichts da ist. */ expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); expect(screen.queryByRole('table', { name: 'Reifegrad Tag für Tag' })).toBeNull(); expect(screen.queryByText(/Fragen gestiegen/u)).toBeNull(); }); it('führt jedes Kapitel und jeden Abschnitt einzeln auf', async () => { starten(); const liste = await screen.findByRole('list', { name: 'Nach Kapiteln und Abschnitten' }); const eintraege = within(liste).getAllByRole('listitem'); expect(eintraege).toHaveLength(2); expect( within(liste).getByRole('progressbar', { name: 'Prüfungsreife in Waffenrecht und sonstige Rechtsvorschriften', }), ).toHaveAttribute('aria-valuenow', '40'); expect( within(liste).getByRole('progressbar', { name: 'Prüfungsreife in Begriffe des Waffenrechts', }), ).toHaveAttribute('aria-valuenow', '12'); }); it('zeigt die weiteren Kennzahlen im Klartext', async () => { starten(); expect(await screen.findByText('120 von 575 Fragen')).toBeInTheDocument(); expect(screen.getByText('12 Fragen')).toBeInTheDocument(); expect(screen.getByText('8 richtig, 2 falsch')).toBeInTheDocument(); }); }); describe('Startbildschirm – Lernplan', () => { /** Plan mit Termin; die Vorgabe-Attrappe hat bewusst keinen. */ const MIT_TERMIN: Lernplan = { ...LERNPLAN, termin: '2026-06-15', tageBisTermin: 40, nieBeantwortet: 455, faellig: 12, prognoseHeute: 0.62, prognoseAmTermin: 0.41, pensum: { neu: 13, wiederholung: 12, gesamt: 25, minuten: 10 }, machbarkeit: 'entspannt', }; it('nennt die Einschätzung als ganzen Satz, nicht als Ampelwort', async () => { starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) })); expect(await screen.findByText(/Sie liegen gut in der Zeit/u)).toBeInTheDocument(); }); it('beginnt keinen Satz mit einem kleingeschriebenen Wort', async () => { /* Befund der Prüfrunde zu 0.27.2. `minutenText` liefert „etwa 10 Minuten“ – mitten im Satz richtig, am Satzanfang nicht. Bei „entspannt“ stand es hinter einem Punkt: „Sie liegen gut in der Zeit. etwa 10 Minuten täglich genügen.“ */ starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) })); const satz = await screen.findByText(/Sie liegen gut in der Zeit/u); expect(satz.textContent).not.toMatch(/\.\s+[a-zäöüß]/u); }); it('überspringt keine Überschriftenebene, wenn die Vorschau dasteht', async () => { /* Befund der Prüfrunde zu 0.27.2. Die Karte „Ihr Lernplan“ führt ein `h2`, die Arbeitslastvorschau darunter ein `h4` – dazwischen fehlt die Ebene 3. Wer mit einem Bildschirmleser nach Überschriften springt, verliert damit die Zuordnung. `e2e/ueberschriften.spec.ts` prüft genau diese Regel und sieht den Fall trotzdem nie: Die Vorschau erscheint erst mit einem eingetragenen Prüfungstermin, und der Lauf trägt keinen ein. */ starten( attrappe({ lernPlan: vi.fn(() => Promise.resolve({ ...LERNPLAN, termin: '2026-06-15', tageBisTermin: 40, vorschau: [3, 0, 5, 2, 0, 1, 4], }), ), }), ); await screen.findByText('Was in den nächsten Tagen ansteht'); const ebenen = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((knoten) => ({ ebene: Number(knoten.tagName.slice(1)), text: knoten.textContent.trim().slice(0, 40), })); let vorige = 1; for (const eintrag of ebenen) { expect( eintrag.ebene, `Sprung auf h${String(eintrag.ebene)} bei „${eintrag.text}“`, ).toBeLessThanOrEqual(vorige + 1); vorige = eintrag.ebene; } }); it('meldet einen gescheiterten Prüfungstermin als Fehler und behält die Eingabe', async () => { /* Befund der Prüfrunde zu 0.27.2. Misslang das Sichern, erschien die Meldung als „Hinweis“ – mit dem Symbol und der Farbe eines Hinweises, ohne Live-Region und damit ohne Ansage (WCAG 3.3.1). Und das Feld war leer: `uebernehmen` verwarf den Entwurf nach dem Speichern bedingungslos, auch wenn nichts gespeichert wurde. Wer das Datum eben getippt hatte, musste es neu eingeben, ohne zu wissen, warum. */ const nutzer = starten( attrappe({ profilAktualisieren: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))), }), ); const feld = await screen.findByLabelText(/Prüfungstermin/u); await nutzer.clear(feld); fireEvent.change(feld, { target: { value: '2026-06-15' } }); const meldung = await screen.findByText(/Datenbank gesperrt/u); expect(meldung.closest('.hinweis')).toHaveClass('hinweis--fehler'); expect(meldung.closest('[role="alert"]')).not.toBeNull(); expect(feld).toHaveValue('2026-06-15'); }); it('zeigt die Trefferquote nicht ein zweites Mal', () => { /* Bis Fassung 0.11.0 stand hier ein eigener Balken „Geschätzte Trefferquote“ mit dem Satz „Wenn Sie heute geprüft würden: 62 % richtig“. Seit dem Umbau ist `prognoseHeute` derselbe Wert wie der Reifegrad der Ampel weiter oben – zweimal dieselbe Größe auf einem Bildschirm, einmal als Fragenzahl, einmal als Prozentwert. Genau diese Doppelung war der Befund in docs/stand.md 7.1. */ starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) })); expect(screen.queryByRole('progressbar', { name: 'Geschätzte Trefferquote' })).toBeNull(); expect(screen.queryByText(/Wenn Sie heute geprüft würden/u)).toBeNull(); }); it('zeigt den Termin im deutschen Format samt Abstand', async () => { starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) })); expect(await screen.findByText('15.06.2026 – in 40 Tagen')).toBeInTheDocument(); }); it('beziffert, was Nichtstun bis zur Prüfung kostet – in derselben Einheit wie die Ampel', async () => { /* Fragen, nicht Prozent: Wer zwei Einheiten für dieselbe Größe liest, hält sie für zwei Größen. 41 % der 8 Fragen dieser Attrappe sind 3. */ starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) })); expect( await screen.findByText(/Am Prüfungstag säßen noch etwa 3 von 8 Fragen belegt/u), ).toBeInTheDocument(); }); it('nennt das Tagespensum in Fragen und in Minuten', async () => { starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) })); expect( await screen.findByText(/13 neue Fragen und 12 zur Wiederholung.*etwa 10 Minuten/su), ).toBeInTheDocument(); }); it('bietet ein beschriftetes Datumsfeld für den Termin', async () => { starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) })); const feld = await screen.findByLabelText('Ihr Prüfungstermin'); expect(feld).toHaveAttribute('type', 'date'); /* Das Feld steht sofort da, der gespeicherte Termin kommt erst mit dem geladenen Plan – deshalb darauf warten statt sofort zu prüfen. */ await waitFor(() => { expect(feld).toHaveValue('2026-06-15'); }); // Die Formathilfe muss dem Feld zugeordnet sein, nicht nur danebenstehen. expect(feld).toHaveAccessibleDescription(/Format Tag\.Monat\.Jahr/u); }); it('sichert einen eingetragenen Termin über die Bridge', async () => { const aktualisieren = vi.fn(() => Promise.resolve(PROFIL)); const nutzer = starten(attrappe({ profilAktualisieren: aktualisieren })); const feld = await screen.findByLabelText('Ihr Prüfungstermin'); await nutzer.clear(feld); fireEvent.change(feld, { target: { value: '2026-06-15' } }); await waitFor(() => { expect(aktualisieren).toHaveBeenCalledWith(1, { pruefungstermin: '2026-06-15' }); }); }); it('löscht den Termin, wenn das Feld geleert wird', async () => { const aktualisieren = vi.fn(() => Promise.resolve(PROFIL)); starten( attrappe({ profilAktualisieren: aktualisieren, lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)), }), ); const feld = await screen.findByLabelText('Ihr Prüfungstermin'); // Erst wenn der Termin wirklich im Feld steht, ist das Leeren eine Änderung. await waitFor(() => { expect(feld).toHaveValue('2026-06-15'); }); fireEvent.change(feld, { target: { value: '' } }); await waitFor(() => { expect(aktualisieren).toHaveBeenCalledWith(1, { pruefungstermin: null }); }); }); it('erklärt ohne Termin, was das Programm stattdessen tut', async () => { starten(); expect(await screen.findByText(/Ohne Prüfungstermin plant das Programm/u)).toBeInTheDocument(); }); it('bleibt bedienbar, wenn der Kern den Kanal nicht anbietet', async () => { starten(ohneKanaele('lernPlan')); expect(await screen.findByText(/lernen:plan/u)).toBeInTheDocument(); // Der Rest des Startbildschirms steht weiterhin. expect(screen.getByRole('button', { name: /Weiterlernen/u })).toBeInTheDocument(); }); it('erklärt einen Fehler beim Laden, statt leer zu bleiben', async () => { starten(attrappe({ lernPlan: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))) })); expect(await screen.findByText(/Datenbank gesperrt/u)).toBeInTheDocument(); }); }); describe('Glossar', () => { async function glossarOeffnen(bruecke = attrappe()): Promise> { const nutzer = starten(bruecke); const knopf = await screen.findByRole('button', { name: 'Fachbegriffe nachschlagen' }); await nutzer.click(knopf); await screen.findByRole('heading', { name: 'Glossar', level: 1 }); return nutzer; } it('ist vom Startbildschirm aus erreichbar', async () => { /* WCAG 3.1.3 und 3.1.4 verlangen einen Weg zur Bedeutung. Der Prüfplan verlangt zusätzlich, dass Hilfe an derselben Stelle steht (3.2.6). */ await glossarOeffnen(); expect(screen.getByRole('heading', { name: 'Glossar', level: 1 })).toHaveFocus(); }); it('verstummt, wenn der vorgelesene Eintrag weggefiltert wird', async () => { /* Befund der Prüfrunde zu 0.27.2. Die Sprachausgabe lief hier mit der festen Kennung „glossar“; ihr Aufräumer greift aber nur, wenn diese Kennung wechselt. Wer einen Eintrag vorlesen ließ und danach ins Filterfeld tippte, hörte ihn weiter — und sobald kein Treffer mehr übrig war, stand auf dem Bildschirm keine Schaltfläche mehr, die das anhalten konnte. Vorgelesen werden im Median 596 Zeichen, gesprochen weit über drei Sekunden: genau der Fall von WCAG 1.4.2. Der Haken sagt es selbst: „Was gesprochen wird, gehört zu dem, was zu sehen ist; verschwindet das eine, hat das andere zu schweigen.“ */ let abgebrochen = 0; 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: () => undefined, cancel: () => { abgebrochen += 1; }, getVoices: () => [], speaking: false, pending: false, paused: false, }); const nutzer = await glossarOeffnen( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }), ), }), ); await nutzer.click((await screen.findAllByRole('button', { name: /vorlesen/u }))[0]!); expect(screen.getAllByRole('button', { name: /Vorlesen beenden/u }).length).toBeGreaterThan(0); const vorher = abgebrochen; await nutzer.type(screen.getByLabelText(/Begriff suchen|suchen/u), 'zzz'); await waitFor(() => { expect(abgebrochen).toBeGreaterThan(vorher); }); expect(screen.queryByRole('button', { name: /Vorlesen beenden/u })).toBeNull(); }); it('sagt es, wenn das Glossar leer geladen wurde', async () => { /* Befund der Prüfrunde zu 0.27.2. `glossarLaden` im Hauptprozess fängt eine fehlende, leere oder kaputte `glossar.json` ab und liefert `GLOSSAR_LEER` – der Kanal gelingt, die Oberfläche bekommt ein Objekt. Die Ansicht prüfte aber auf `glossar === null` und zeigte deshalb Suchfeld, „0 Einträge“ und den Satz „Zu dieser Eingabe gibt es keinen Eintrag.“ – dem Lernenden wurde eine Eingabe angelastet, die er nicht gemacht hat. Die Nachbarschaft macht es richtig und zeigt, wie: `useNormtexte` führt ein Feld `vorhanden`, und die Gesetzesansicht stützt ihren Hinweis darauf. Die bisherige Wache traf den Fall nie, weil sie den Kanal ganz entfernte – der einzige Weg, der wirklich `null` erzeugt. */ const nutzer = starten( attrappe({ glossarLaden: vi.fn(() => Promise.resolve({ ...GLOSSAR, eintraege: [] })) }), ); await nutzer.click(await screen.findByRole('button', { name: 'Fachbegriffe nachschlagen' })); await screen.findByRole('heading', { name: 'Glossar', level: 1 }); expect(screen.getByText(/Das Glossar ist zurzeit nicht verfügbar/u)).toBeInTheDocument(); expect(screen.queryByLabelText('Begriff suchen')).toBeNull(); }); it('führt Begriffe und Abkürzungen mit ihrer Bedeutung auf', async () => { await glossarOeffnen(); expect(screen.getByRole('heading', { name: /^Führen/u, level: 2 })).toBeInTheDocument(); expect(screen.getByText(/außerhalb der eigenen Wohnung/u)).toBeInTheDocument(); expect(screen.getByText('Waffengesetz.')).toBeInTheDocument(); }); it('kennzeichnet Abkürzungen als solche', async () => { // WCAG 3.1.4: Die aufgelöste Form muss erkennbar eine Auflösung sein. await glossarOeffnen(); const eintrag = screen.getByRole('heading', { name: /^WaffG/u, level: 2 }); expect(eintrag).toHaveTextContent('(Abkürzung)'); }); it('nennt die Fundstelle einer gesetzlichen Bestimmung', async () => { await glossarOeffnen(); expect(screen.getByText('Anlage 1 Abschnitt 2 Nr. 4 WaffG')).toBeInTheDocument(); }); it('sagt, wo es keine gesetzliche Bestimmung gibt', async () => { await glossarOeffnen(); expect( screen.getByText(/Kurzbezeichnung eines Gesetzes steht nicht im Gesetz/u), ).toBeInTheDocument(); }); it('filtert auch über die Wortformen', async () => { /* Wer „führt" eingibt, sucht den Eintrag „Führen" – und weiß nicht, unter welchem Stichwort er steht. */ const nutzer = await glossarOeffnen(); await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt'); expect(screen.getByRole('heading', { name: /^Führen/u, level: 2 })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /^WaffG/u, level: 2 })).not.toBeInTheDocument(); }); it('meldet die Trefferzahl über eine Live-Region', async () => { const nutzer = await glossarOeffnen(); const meldung = screen.getByText('2 Einträge'); expect(meldung).toHaveAttribute('role', 'status'); await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt'); /* Seit 0.22.0 mit Ruhepause: Eine höfliche Live-Region, die bei jedem Tastenanschlag eine neue Zahl spricht, mischt einem Screenreader beim Tippen von „führt“ fünf einander unterbrechende Ansagen zwischen die Zeichen-Echos. Die Fragensuche hielt es längst so; das Glossar zog nach (`useVerzoegerteAnsage`). */ expect(await screen.findByText('1 von 2 Einträgen')).toBeInTheDocument(); }); it('sagt es, wenn nichts passt', async () => { const nutzer = await glossarOeffnen(); await nutzer.type(screen.getByLabelText('Begriff suchen'), 'Fahrraddiebstahl'); expect(screen.getByText('Zu dieser Eingabe gibt es keinen Eintrag.')).toBeInTheDocument(); }); /* „Siehe auch“ war bis 0.22.0 toter Text. Wer per Tastatur oder Bildschirmleser von einem Begriff zum verwandten wollte, musste zurück zum Filterfeld, den Begriff abtippen und die Liste neu durchgehen – obwohl beide Einträge in derselben Ansicht stehen und die A-bis-Z-Leiste den Sprungmechanismus längst mitbringt. */ it('macht „Siehe auch“ zu einem Sprungziel und nimmt den Fokus mit', async () => { const nutzer = await glossarOeffnen(); /* Der Eintrag „Führen“ verweist auf „WaffG“. */ await nutzer.click(screen.getByRole('button', { name: 'WaffG' })); expect(screen.getByRole('heading', { name: /^WaffG/u })).toHaveFocus(); }); it('leert den Filter, wenn das Sprungziel gerade ausgefiltert ist', async () => { /* Sonst liefe der Sprung ins Leere: Der Zieleintrag ist dann gar nicht gerendert, und der Fokus bliebe stehen, ohne dass etwas geschähe. */ const nutzer = await glossarOeffnen(); const feld = screen.getByLabelText('Begriff suchen'); await nutzer.type(feld, 'Führen'); /* Nur „Führen“ steht noch da – sein Verweisziel „WaffG“ ist ausgefiltert. */ expect(screen.queryByRole('heading', { name: /^WaffG/u })).toBeNull(); await nutzer.click(screen.getByRole('button', { name: 'WaffG' })); await waitFor(() => { expect(screen.getByRole('heading', { name: /^WaffG/u })).toHaveFocus(); }); expect(feld).toHaveValue(''); }); it('zeigt in der Sprungleiste nur Buchstaben, unter denen ein Eintrag steht', async () => { await glossarOeffnen(); const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' }); // Zwei Einträge, zwei Knöpfe – kein gesperrter Rest des Alphabets. expect( within(leiste) .getAllByRole('button') .map((k) => k.textContent), ).toEqual(['F', 'W']); }); it('springt zum Buchstaben und setzt den Fokus auf die erste Überschrift', async () => { /* Reines Rollen ohne Fokus wäre für Tastatur und Bildschirmleser wirkungslos – der nächste Tabulatorsprung begänne wieder ganz oben. */ const nutzer = await glossarOeffnen(); const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' }); await nutzer.click(within(leiste).getByRole('button', { name: 'W' })); expect(screen.getByRole('heading', { name: /^WaffG/u, level: 2 })).toHaveFocus(); }); it('blendet die Sprungleiste bei aktivem Filter aus', async () => { /* Eine Leiste, deren Knöpfe bei jedem Anschlag kommen und gehen, wäre ein bewegliches Ziel – die gefilterte Liste ist selbst der Sprung. */ const nutzer = await glossarOeffnen(); await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt'); expect(screen.queryByRole('navigation', { name: 'Anfangsbuchstaben' })).not.toBeInTheDocument(); await nutzer.clear(screen.getByLabelText('Begriff suchen')); expect(screen.getByRole('navigation', { name: 'Anfangsbuchstaben' })).toBeInTheDocument(); }); it('reiht Umlaute beim Grundbuchstaben ein, statt die Gruppe zu zerreißen', async () => { /* Die deutsche Sortierung stellt „Überlassen“ vor „Umgang“. Ein eigener Ü-Knopf zeigte also mitten in die U-Gruppe – deshalb fällt Ü auf U, und der U-Sprung landet auf dem ersten Eintrag der Gruppe. */ const eintrag = (begriff: string) => ({ begriff, art: 'begriff' as const, kurz: 'Prüfeintrag für die Sprungleiste.', varianten: [], fundstellen: [], }); const nutzer = await glossarOeffnen( attrappe({ glossarLaden: vi.fn(() => Promise.resolve({ ...GLOSSAR, eintraege: [eintrag('Umgang'), eintrag('Überlassen')] }), ), }), ); const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' }); expect(within(leiste).queryByRole('button', { name: 'Ü' })).not.toBeInTheDocument(); await nutzer.click(within(leiste).getByRole('button', { name: 'U' })); expect(screen.getByRole('heading', { name: 'Überlassen', level: 2 })).toHaveFocus(); }); it('meldet auch mit Sprungleiste keine axe-Verstöße', async () => { await glossarOeffnen(); screen.getByRole('navigation', { name: 'Anfangsbuchstaben' }); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]); }); it('führt vom Glossar zurück zum Start', async () => { const nutzer = await glossarOeffnen(); await nutzer.click( within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', { name: 'Zum Start', }), ); expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); }); /* Der Rückweg endet dort, wo er begann. Bis Fassung 0.25.0 endete JEDER Rückweg am Seitenanfang: Neun Rückwege laufen über `zumStart`, und der Fokuseffekt in `App.tsx` setzte den Fokus danach ausnahmslos auf `einstiegRef` – auf dem Startbildschirm also auf „Heute lernen“ ganz oben. Der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 Bildpunkte hoch (gemessen an `docs/bildschirmfotos/01-startbildschirm-hell.png`); die Karte „Über diese Software“ steht ganz unten. Wer von dort zurückkam, rollte den Weg noch einmal – mit der Tastatur waren es einundvierzig Tabulatorhalte. Geprüft wird der FOKUS und nicht die Bildlaufstelle, und das ist die Sache selbst: Es wurde nie eine Stelle falsch wiederhergestellt, sondern gar keine gemerkt. Der Bildlauf ist die Nebenwirkung des Fokussprungs. jsdom rollt ohnehin nicht; wer hier eine Bildlaufstelle prüfte, prüfte nichts. */ it('setzt den Fokus beim Zurückkommen auf die Karte, von der aus man ging', async () => { const nutzer = await glossarOeffnen(); await nutzer.click( within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', { name: 'Zum Start', }), ); const karte = await screen.findByRole('heading', { name: 'Glossar', level: 2 }); expect(karte).toHaveFocus(); /* Und nicht mehr die Einstiegsüberschrift – sonst wäre die Zusage leer. */ expect(screen.getByRole('heading', { name: 'Heute lernen' })).not.toHaveFocus(); }); it('führt beim Hinweg unverändert auf die Überschrift der neuen Ansicht', async () => { /* Gegenprobe: Der Hinweg darf sich nicht ändern. Dort ist die Einstiegsüberschrift richtig – der Nutzende ist an einem neuen Ort, und ein Bildschirmleser muss das erfahren. */ await glossarOeffnen(); expect(screen.getByRole('heading', { name: 'Glossar', level: 1 })).toHaveFocus(); }); it('bleibt bedienbar, wenn der Kern das Glossar nicht anbietet', async () => { await glossarOeffnen(ohneKanaele('glossarLaden')); expect(screen.getByText(/Glossar ist zurzeit nicht verfügbar/u)).toBeInTheDocument(); }); }); describe('Startbildschirm – Wege ins Lernen', () => { it('bietet die fünf Einstiege an', async () => { starten(); for (const name of [ /Weiterlernen/u, /Kapitel wählen/u, /Nur Fehler/u, /Gemerkte Fragen/u, /Offene Fragen/u, ]) { const knopf = await screen.findByRole('button', { name }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); /* Kein natives `disabled` – die Projektregel (docs/stand.md 7.13) verlangt einen Knopf, der auch gesperrt den Fokus behält. */ expect(knopf).toBeEnabled(); } }); /* Offene Fragen kamen bisher nur zufällig zwischen den Auswahlfragen vor – bei zwanzig Fragen im Mittel dreieinhalb. Sie sind der Teil der Prüfung, den ein Mensch bewertet. */ it('startet über „Offene Fragen“ eine Sitzung nur mit auszuformulierenden', async () => { const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG)); const nutzer = starten(attrappe({ lernSitzung })); const knopf = await screen.findByRole('button', { name: /Offene Fragen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); expect(lernSitzung).toHaveBeenCalledWith( 1, expect.objectContaining({ nurOffene: true, anzahl: 20 }), ); }); it('nennt an der Schaltfläche, wie viele offene Fragen der Lernumfang hat', async () => { /* Die Zahl kommt aus der Übersicht und nicht aus dem Katalog: Sie muss sich mit der Kapitelabwahl ändern, sonst verspräche sie Fragen, die das Zielprofil gar nicht enthält. */ starten(); expect(await screen.findByRole('button', { name: /Offene Fragen/u })).toHaveTextContent( /Zum Ausformulieren: \d+ Fragen/u, ); }); it('zeigt den Lernstand des Abschnitts dort, wo er geübt wird', async () => { /* Die Wache, die gefehlt hat. Der Lernstand je Bereich stand ausschließlich in der Lernstandsübersicht des Startbildschirms, und von dort führte kein Weg ins Üben. Wer wissen wollte, welcher Abschnitt zurückliegt, musste es sich merken, hierher wechseln und die Ampel im Kopf behalten – Anzeige und Entscheidung lagen an zwei verschiedenen Orten. */ const nutzer = starten(); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); /* Die Ampel steht IM Bereichsblock neben dem Knopf, der ihn übt – nicht irgendwo auf der Seite. Dieselbe Beschriftung gibt es auch in der Lernstandsübersicht des Startbildschirms; wer nur nach ihr sucht, prüft nichts. */ const knopfAbschnitt = await screen.findByRole('button', { name: 'I.1 – Begriffe des Waffenrechts', }); const block = knopfAbschnitt.closest('.kapitelwahl__bereich'); expect(block).not.toBeNull(); expect( within(block as HTMLElement).getByLabelText(/Prüfungsreife in Begriffe des Waffenrechts/u), ).toBeInTheDocument(); }); it('zeigt keine Ampel, solange der Lernstand nicht geladen ist', async () => { /* „Noch nicht geladen" ist nicht „null Prozent". Eine Ampel auf Verdacht wäre schlechter als keine. */ const nutzer = starten(ohneKanaele('lernUebersicht')); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 }); const knopfAbschnitt = screen.getByRole('button', { name: 'I.1 – Begriffe des Waffenrechts', }); const block = knopfAbschnitt.closest('.kapitelwahl__bereich'); expect(block).not.toBeNull(); expect( within(block as HTMLElement).queryByLabelText(/Prüfungsreife in/u), ).not.toBeInTheDocument(); }); it('startet über die Kapitelwahl eine Sitzung für einen Abschnitt', async () => { const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG)); const nutzer = starten(attrappe({ lernSitzung })); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); expect(screen.getByRole('heading', { name: 'Kapitel wählen', level: 1 })).toHaveFocus(); await nutzer.click(screen.getByRole('button', { name: 'I.1 – Begriffe des Waffenrechts' })); expect(lernSitzung).toHaveBeenCalledWith( 1, expect.objectContaining({ abschnitte: ['I.1'], anzahl: 20 }), ); expect(await screen.findByRole('heading', { name: /Frage 1\.01/u })).toBeInTheDocument(); }); }); describe('Startbildschirm – Überschriften und Landmarken', () => { it('hält die Überschriftenhierarchie ohne Sprünge ein', async () => { starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); const ebenen = screen .getAllByRole('heading') .map((ueberschrift) => Number(ueberschrift.tagName.slice(1))); expect(ebenen[0]).toBe(1); expect(ebenen.filter((ebene) => ebene === 1)).toHaveLength(1); for (let stelle = 1; stelle < ebenen.length; stelle += 1) { expect(ebenen[stelle]!).toBeLessThanOrEqual(ebenen[stelle - 1]! + 1); } }); it('bietet die erwarteten Landmarken', async () => { starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); expect(screen.getByRole('banner')).toBeInTheDocument(); expect(screen.getByRole('main')).toBeInTheDocument(); expect(screen.getByRole('contentinfo')).toBeInTheDocument(); }); }); describe('Startbildschirm – Tastenkürzel abschaltbar (WCAG 2.1.4)', () => { it('merkt sich die Abschaltung in den Einstellungen', async () => { /* Die Wahl geht in `einstellungen.json` statt in den `localStorage`: Bis 0.21.0 stand diese eine Einstellung außerhalb von Einstellungen und Profil und überlebte keinen Gerätewechsel. */ const schreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)); const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben })); const schalter = await screen.findByRole('checkbox', { name: 'Tastenkürzel in der Lernsitzung verwenden', }); expect(schalter).toBeChecked(); await nutzer.click(schalter); expect(schalter).not.toBeChecked(); await waitFor(() => { expect(schreiben).toHaveBeenCalledWith({ tastenkuerzel: false }); }); expect(window.localStorage.getItem('waffensachkunde.tastenkuerzel')).toBeNull(); }); it('legt die gespeicherte Abschaltung beim Start wieder an', async () => { starten( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, tastenkuerzel: false, }), ), }), ); const schalter = await screen.findByRole('checkbox', { name: 'Tastenkürzel in der Lernsitzung verwenden', }); await waitFor(() => { expect(schalter).not.toBeChecked(); }); }); }); describe('Startbildschirm – Baustand', () => { /* Die Versionsnummer allein benennt keinen Stand: Zwischen zwei Veröffentlichungen entstehen viele Bauten mit derselben Nummer. Wer eine Rückmeldung gibt, muss sagen können, welchen er vor sich hat. */ it('nennt Commit-Kürzel und Datum in deutscher Schreibweise', async () => { starten(); expect(await screen.findByText('abc1234')).toBeInTheDocument(); expect(screen.getByText(/vom 21\.08\.2026/u)).toBeInTheDocument(); }); it('lässt die Zeile weg, wenn beim Bauen kein Git zur Verfügung stand', async () => { /* Lieber nichts als ein nichtssagendes „unbekannt“ – die Zeile existiert allein zum Benennen eines Standes. */ starten( attrappe({ anwendungsInfoLesen: vi.fn(() => Promise.resolve({ ...INFO, baukennung: '', baustand: '' }), ), }), ); expect(await screen.findByText('Programmversion')).toBeInTheDocument(); expect(screen.queryByText('Baustand')).not.toBeInTheDocument(); }); it('nennt den Katalogstand und wo man nach einer neueren Fassung sieht', async () => { /* Die Anwendung kann nicht wissen, ob das Bundesverwaltungsamt inzwischen eine neuere Fassung veröffentlicht hat – sie geht nicht ins Netz (siehe docs/entscheidung-katalog-aktualitaet.md). Was sie kann, ist sagen, wie alt ihre Inhalte sind. Das Datum stammt aus dem Katalog selbst, nicht aus einer zweiten Pflege. */ starten(); expect(await screen.findByText('Fragenkatalog')).toBeInTheDocument(); /* Datum und Quellort gehören in dieselbe Zeile – getrennt geprüft könnten sie zu zwei verschiedenen Angaben gehören. */ const zeile = screen.getByText('www.bva.bund.de').closest('dd'); expect(zeile).toHaveTextContent('Stand 16.12.2024'); }); it('nennt den Quellort als Text, nicht als Verweis', async () => { /* Die Anwendung öffnet von sich aus nichts nach außen. Dieselbe Begründung wie bei der Quelle unter „Über diese Software“ und beim Rückmeldeweg in shared/kontakt.ts. */ starten(); const ort = await screen.findByText('www.bva.bund.de'); expect(ort.closest('a')).toBeNull(); }); it('sagt es offen, wenn der Katalog nicht geladen ist', async () => { starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) })); expect(await screen.findByText('Fragenkatalog')).toBeInTheDocument(); expect(screen.getByText('Nicht geladen.')).toBeInTheDocument(); }); it('zeigt ein Datum unverändert, das sich nicht lesen lässt', async () => { starten( attrappe({ anwendungsInfoLesen: vi.fn(() => Promise.resolve({ ...INFO, baustand: 'unbekannt' })), }), ); expect(await screen.findByText(/vom unbekannt/u)).toBeInTheDocument(); }); }); describe('Startbildschirm – Katalogwechsel', () => { /* Erscheint eine neue BVA-Fassung mit geänderter Nummerierung, zeigen gespeicherte Zeilen zu verschwundenen Fragennummern ins Leere. Der Kern zählt das beim Öffnen des Lernstands; die Oberfläche muss es sagen – was geschah, was betroffen ist, und dass nichts gelöscht wurde. */ it('meldet einen Katalogwechsel mit Zahlen und der Zusage, dass nichts gelöscht wurde', async () => { starten( attrappe({ anwendungsInfoLesen: vi.fn(() => Promise.resolve({ ...INFO, katalogwechsel: { vorher: '2024-12-16', nachher: '2025-06-01', verwaisteStaende: 2, verwaisteAntworten: 5, }, }), ), }), ); const meldung = await screen.findByText(/Der Fragenkatalog hat die Fassung gewechselt/u); expect(meldung).toHaveTextContent('bisher Stand 16.12.2024, jetzt Stand 01.06.2025'); expect(meldung).toHaveTextContent( '5 Einträge im Antwortprotokoll und 2 gespeicherte Fragenstände', ); expect(meldung).toHaveTextContent('Gelöscht wurde nichts'); }); it('schweigt, wenn der Kern keinen Wechsel meldet', async () => { /* `null` wie auch ein gänzlich fehlendes Feld heißen: nichts zu sagen. Ein Dauerhinweis wäre Rauschen – und beim echten Wechsel unglaubwürdig. */ starten(attrappe({ anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)) })); expect(await screen.findByText('Programmversion')).toBeInTheDocument(); expect(screen.queryByText(/Fassung gewechselt/u)).not.toBeInTheDocument(); }); }); describe('Startbildschirm – fehlende Kanäle', () => { it('erklärt einen fehlenden Fragenkatalog und sperrt die Einstiege', async () => { starten(ohneKanaele('katalogLaden')); expect( await screen.findByText(/Der Fragenkatalog konnte nicht geladen werden/u), ).toBeInTheDocument(); expect(screen.getByText(/lässt sich keine Lernsitzung starten/u)).toBeInTheDocument(); /* Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein natives `disabled`, dafür `aria-disabled` und ein Verweis auf den Satz, der den Grund nennt. Ein `disabled` Knopf nähme den Fokus mit auf `` – und seine Begründung würde nie vorgelesen. */ const knopf = screen.getByRole('button', { name: /Weiterlernen/u }); expect(knopf).toBeEnabled(); expect(knopf).toHaveAttribute('aria-disabled', 'true'); expect(knopf).toHaveAccessibleDescription(/lässt sich keine Lernsitzung starten/u); }); it('lässt den gesperrten Einstieg nichts auslösen und den Fokus behalten', async () => { const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG)); const nutzer = starten( attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))), lernSitzung, }), ); const knopf = await screen.findByRole('button', { name: /Weiterlernen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'true'); }); await nutzer.click(knopf); expect(lernSitzung).not.toHaveBeenCalled(); /* Der Knopf ist noch da und noch anwählbar – das ist der ganze Punkt. */ knopf.focus(); expect(knopf).toHaveFocus(); }); it('meldet einen Fehler des Katalogkanals und bietet einen neuen Versuch an', async () => { starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('Datei fehlt'))) })); expect(await screen.findByText(/Datei fehlt/u)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Fragenkatalog erneut laden' })).toBeEnabled(); }); it('bleibt ohne Lernprofil bedienbar und übt aus dem Katalog', async () => { const nutzer = starten(ohneKanaele('profilListe', 'lernUebersicht')); expect( await screen.findByText( /Sie können trotzdem üben; Ihre Antworten werden dabei nicht gespeichert/u, ), ).toBeInTheDocument(); const knopf = screen.getByRole('button', { name: /Weiterlernen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); expect(await screen.findByRole('heading', { name: /, Frage \d/u })).toBeInTheDocument(); expect(screen.getByText(/Ihre Antworten werden nicht gespeichert/u)).toBeInTheDocument(); }); it('zeigt den Lernstandsfehler an, ohne die Oberfläche zu blockieren', async () => { starten( attrappe({ lernUebersicht: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))) }), ); expect( await screen.findByText(/Ihre Prüfungsreife konnte nicht geladen werden/u), ).toBeInTheDocument(); const knopf = screen.getByRole('button', { name: /Weiterlernen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); }); }); describe('Startbildschirm – Barrierefreiheits-Rauchtest (axe-core)', () => { it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => { starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]); }); }); describe('Kapitel dauerhaft abwählen', () => { /* Die Oberflächenseite der dauerhaften Abwahl. Der Kern – welche Fragen danach noch kommen und wie sich Zähler und Prognose ändern – ist in `tests/lernstand.test.ts` gegen eine echte Datenbank gemessen. Hier geht es nur um das, was der Nutzer sieht und hört. Der eigene Katalog statt einer Erweiterung des gemeinsamen: Kapitel IV hier allgemein einzuhängen änderte die Kapitelwahl und die Wege ins Lernen für jeden anderen Test in dieser Datei mit. */ const KATALOG_MIT_IV: Katalog = { ...KATALOG, kapitel: [ ...KATALOG.kapitel, { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [{ id: 'IV.1', titel: 'Signalmittel' }], }, ], }; function mitKapitelIV(ueberschreibungen: TeilBridge = {}): TeilBridge { return attrappe({ katalogLaden: vi.fn(() => Promise.resolve(KATALOG_MIT_IV)), ...ueberschreibungen, }); } it('bietet den Schalter unter „Ihr Lernplan“ an, nicht in der Profilverwaltung', async () => { /* Die Profilverwaltung führt das Register – anlegen, wechseln, umbenennen, löschen. Der Schalter gehört dorthin, wo seine Wirkung sichtbar wird: neben den Prüfungstermin. Der Termin sagt, bis wann; die Kapitelwahl sagt, wie viel. */ starten(mitKapitelIV()); const schalter = await screen.findByRole('checkbox', { name: 'Kapitel IV – Not- und Seenotsignalmittel mitlernen', }); expect(schalter).toBeChecked(); expect(screen.getByRole('region', { name: 'Ihr Lernplan' })).toContainElement(schalter); }); it('bleibt fort, wenn der Katalog das Kapitel gar nicht führt', async () => { /* Der gemeinsame Katalog dieser Datei hat nur Kapitel I. Ein Schalter für ein Kapitel, das es nicht gibt, wäre eine leere Zusage. */ starten(); await screen.findByRole('region', { name: 'Ihr Lernplan' }); expect(screen.queryByRole('checkbox', { name: /mitlernen/u })).not.toBeInTheDocument(); }); it('meldet die Abwahl an den Anwendungskern und lädt beide Ansichten neu', async () => { const bruecke = mitKapitelIV(); const nutzer = starten(bruecke); await nutzer.click(await screen.findByRole('checkbox', { name: /mitlernen/u })); await waitFor(() => { expect(bruecke.profilAktualisieren).toHaveBeenCalledWith(1, { kapitelAusschluss: ['IV'] }); }); /* Lernplan und Lernstand sind entkoppelt und haben getrennte Zähler. Wer nur einen anstößt, zeigt für eine Weile 575 im Lernstand und 486 im Plan – untereinander auf demselben Bildschirm. */ await waitFor(() => { expect(bruecke.lernPlan).toHaveBeenCalledTimes(2); expect(bruecke.lernUebersicht).toHaveBeenCalledTimes(2); }); }); it('sagt die Umstellung an – die geänderten Zahlen stehen weiter oben', async () => { /* Der Schalter steht unter „Ihr Lernplan“, Fortschrittsbalken und Lernstand stehen im Dokument davor. Wer mit Bildschirmleser arbeitet, hat sie beim Umlegen längst passiert und merkte sonst nichts. */ const nutzer = starten(mitKapitelIV()); await nutzer.click(await screen.findByRole('checkbox', { name: /mitlernen/u })); expect( await screen.findByText(/Kapitel IV ist für dieses Profil abgewählt/u), ).toBeInTheDocument(); }); it('vermerkt das abgewählte Kapitel in der Kapitelwahl, statt es zu verstecken', async () => { /* Weder verschwinden noch sperren: Verschwände das Kapitel, fände niemand den Weg zurück; und gesperrte Bedienelemente nehmen keinen Fokus – ihre Erklärung würde nie vorgelesen. Üben bleibt möglich, es zählt nur nicht mit. */ const nutzer = starten( mitKapitelIV({ profilListe: vi.fn(() => Promise.resolve([{ ...PROFIL, kapitelAusschluss: ['IV'] }])), }), ); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); expect(await screen.findByText(/Für dieses Profil abgewählt/u)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Ganzes Kapitel IV üben' })).toBeEnabled(); }); it('bietet für Kapitel ohne amtliche Abschnitte die Themen an', async () => { /* Der amtliche Katalog gliedert nur Kapitel I in Abschnitte. Für die 230 Fragen der Kapitel II bis IV gab es bis 0.22.0 nur „ganzes Kapitel“ – 92 Fragen allein in Kapitel II. Die Gruppen stammen aus content/themen.json und sind eine Ergänzung dieser Software. */ const nutzer = starten( mitKapitelIV({ themenLaden: vi.fn(() => Promise.resolve({ meta: { version: 1, stand: '2026-08-29', hinweis: 'Gliederung dieser Software' }, gruppen: [ { id: 'IV.10', kapitel: 'IV', titel: 'Störungen und Versager', fragen: ['IV-72', 'IV-73', 'IV-74'], }, ], }), ), }), ); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); const thema = await screen.findByRole('button', { name: 'Störungen und Versager (3)' }); expect(thema).toBeInTheDocument(); // Die Herkunft steht dabei: Der amtliche Katalog gliedert hier nicht. expect( screen.getByText(/Die Themen sind eine Gliederung dieser Software/u), ).toBeInTheDocument(); }); it('kommt ohne Themendatei aus', async () => { // Dann steht da, was vor 0.22.0 dastand – und nicht etwa nichts. const nutzer = starten(mitKapitelIV()); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); expect(await screen.findByRole('button', { name: 'Ganzes Kapitel IV üben' })).toBeEnabled(); expect(screen.queryByText(/Die Themen sind eine Gliederung/u)).not.toBeInTheDocument(); }); }); describe('Lernstand sichern und übertragen', () => { /* Die Oberflächenseite von D9. Der Kern – Prüfkette, Reihenfolge beim Ersetzen, Sicherheitskopie – ist in `tests/sicherung.test.ts` gegen echte SQLite-Dateien gemessen. Hier geht es um das, was der Nutzer sieht und entscheidet, bevor etwas ersetzt wird. */ const KENNZAHLEN = { profilnamen: ['Olaf'], jeProfil: [ { name: 'Olaf', antworten: 1204, gemerkt: 12, pruefungslaeufe: 3, letzteAntwort: '2026-05-03T21:07:00.000Z', }, ], antworten: 1204, letzteAntwort: '2026-05-03T21:07:00.000Z', gemerkt: 12, offen: 0, fehler: 0, pruefungslaeufe: 3, offenerBogen: false, schemafassung: 7, }; const BRAUCHBAR = { art: 'brauchbar' as const, vorgang: 'v-1', dateiname: 'Waffensachkunde-Lernstand-2026-05-03-210700.wsklernstand', ausDatei: KENNZAHLEN, /* Der Rechner trägt ein anderes Profil als die Datei – genau der Fall, den die Verlustanzeige benennen muss. */ jetzt: { ...KENNZAHLEN, antworten: 42, profilnamen: ['Standard'], jeProfil: [ { name: 'Standard', antworten: 42, gemerkt: 12, pruefungslaeufe: 3, letzteAntwort: '2026-05-03T21:07:00.000Z', }, ], }, }; it('nennt die beiden Wege ungleich – der gefährlichste Fehler ist die Richtung', async () => { starten(); expect( await screen.findByRole('button', { name: 'Sicherung speichern …' }), ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ).toBeInTheDocument(); }); /* Wann zuletzt gesichert wurde, wurde bis 0.22.0 nirgends festgehalten – die Karte konnte es also auch nicht sagen, und eine Erinnerung wäre geraten statt gewusst gewesen. Wer die Karte nie öffnete, hatte nichts, wenn die Datei einmal weg war. */ it('sagt, dass noch nie gesichert wurde, statt zu schweigen', async () => { starten(); /* Der Wortlaut kommt aus derselben Quelle, aus der ihn die Karte nimmt. `sicherungsstandSatz` ist genau dafuer exportiert – bis Fassung 0.24.1 stand der Satz hier abgeschrieben daneben, und der Export hatte keinen einzigen Nutzer ausserhalb seiner eigenen Datei. */ expect(await screen.findByText(sicherungsstandSatz(null))).toBeInTheDocument(); }); it('nennt den Zeitpunkt der letzten Sicherung', async () => { starten( attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, letzteSicherung: '2026-08-12T09:15:00.000Z', }), ), }), ); expect( await screen.findByText(sicherungsstandSatz('2026-08-12T09:15:00.000Z')), ).toBeInTheDocument(); }); it('sagt, dass die selbsttätige Kopie keine Sicherung ersetzt', async () => { /* Die wöchentliche Kopie liegt auf derselben Festplatte. Sie als Sicherung auszugeben wäre die gefährlichere Unwahrheit: Wer sich darauf verlässt, sichert nicht mehr selbst. */ starten(); expect( await screen.findByText(/ersetzt deshalb keine Sicherung, die Sie anderswo aufheben/u), ).toBeInTheDocument(); }); it('nennt den Ordner, in dem die selbsttätigen Kopien liegen', async () => { /* Die Wache, die gefehlt hat. Die Karte erzählte von der Kopie, ohne zu sagen, wo sie liegt – „in seinem eigenen Ordner“. Für jemanden, der sie im Ernstfall braucht, ist das keine Auskunft: Weder eine Liste noch ein Dateidialog führt dorthin, und der Ordnername kam in der ganzen Oberfläche nicht vor. */ starten(); expect(await screen.findByText(/Unterordner/u)).toBeInTheDocument(); expect(screen.getByText('sicherungen')).toBeInTheDocument(); }); it('sagt, dass auch vor den zerstörenden Schritten eine Kopie entsteht', async () => { /* Seit 0.26.6 legen „Neu anfangen“ und das Löschen eines Profils vorher eine Kopie an. Steht das nirgends, verlässt sich niemand darauf – und der Nutzen der Kopie hängt daran, dass man von ihr weiß. */ starten(); expect(await screen.findByText(/unterbleibt der Schritt/u)).toBeInTheDocument(); }); it('öffnet den Ordner der Sicherheitskopien', async () => { /* Die Wache, die gefehlt hat. Bis 0.26.7 führte weder eine Liste noch ein Dateidialog zu den selbsttätigen Kopien – wer eine im Ernstfall brauchte, musste den Pfad kennen. */ const sicherungsordnerZeigen = vi.fn(() => Promise.resolve(true)); const nutzer = starten(attrappe({ sicherungsordnerZeigen })); await nutzer.click( await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }), ); expect(sicherungsordnerZeigen).toHaveBeenCalledWith(); expect(await screen.findByText('Der Ordner ist geöffnet.')).toBeInTheDocument(); }); it('sagt es, wenn es den Ordner noch gar nicht gibt', async () => { /* Er entsteht erst mit der ersten Kopie. So zu tun, als sei etwas geschehen, wäre die schlechtere Antwort. */ const nutzer = starten( attrappe({ sicherungsordnerZeigen: vi.fn(() => Promise.resolve(false)) }), ); await nutzer.click( await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }), ); expect(await screen.findByText(/Den Ordner gibt es noch nicht/u)).toBeInTheDocument(); }); it('nennt den fehlenden Kanal, statt stumm zu bleiben', async () => { const nutzer = starten(ohneKanaele('sicherungsordnerZeigen')); await nutzer.click( await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }), ); expect(await screen.findByText(/sicherung:ordner-zeigen/u)).toBeInTheDocument(); }); it('lässt den Sitzungsumfang einstellen', async () => { /* Die Wache, die gefehlt hat. `SITZUNGSUMFANG = 20` war eine Konstante, und alle sieben Sitzungsstarts reichten sie durch – obwohl der Anwendungskern jede Zahl von 1 bis 1000 annimmt. Wer täglich zehn Minuten hat, bekam dieselben zwanzig Fragen wie jemand mit einer Stunde und ließ die Sitzung halb liegen. */ const einstellungenSchreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)); const nutzer = starten(attrappe({ einstellungenSchreiben })); await nutzer.selectOptions(await screen.findByLabelText('Fragen je Lernsitzung'), '10'); expect(einstellungenSchreiben).toHaveBeenCalledWith( expect.objectContaining({ sitzungsumfang: 10 }), ); }); it('legt die eingestellte Zahl an den Kern weiter', async () => { const lernSitzung = vi.fn(() => Promise.resolve([])); const nutzer = starten( attrappe({ lernSitzung, einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, sitzungsumfang: 8 }), ), }), ); await nutzer.click(await screen.findByRole('button', { name: /Weiterlernen/u })); expect(lernSitzung).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anzahl: 8 }), ); }); it('ersetzt nichts, bevor gefragt wurde', async () => { /* Der Prüfschritt ist folgenlos. Erst nach der Rückfrage darf der Kanal laufen, der wirklich etwas anfasst. */ const sicherungEinspielen = vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })); const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungEinspielen, }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); expect( await screen.findByText(/Diesen Lernstand durch die Sicherung ersetzen\?/u), ).toBeVisible(); expect(sicherungEinspielen).not.toHaveBeenCalled(); }); it('stellt beide Seiten mit denselben Zahlen gegenüber', async () => { /* Der Datei sieht niemand an, von wann sie ist. Ohne die Gegenüberstellung entscheidet man blind. */ const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) })); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); /* „Antworten“ und nicht „beantwortete Fragen“: Die Zahl zählt Zeilen im Protokoll, also auch Wiederholungen derselben Frage. Bis 0.19.0 hieß sie anders und zählte zusätzlich nie aufgeschlagene Prüfungsfragen mit. */ expect(await screen.findByText(/1 Profil: Olaf · 1\.204 Antworten/u)).toBeVisible(); expect(screen.getByText(/1 Profil: Standard · 42 Antworten/u)).toBeVisible(); }); it('lässt die Eingabetaste auf das Harmlose fallen', async () => { /* Wer aus Versehen in der Rückfrage landet, trifft mit der Eingabetaste „behalten“ und nicht „ersetzen“. */ const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) })); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); const knoepfe = screen.getAllByRole('button', { name: /behalten|ersetzen/u }); expect(knoepfe[0]).toHaveAccessibleName('Nein, meinen Lernstand behalten'); }); it('benennt den unterbrochenen Prüfungsbogen als Verlust', async () => { /* Das einzige, was sich nicht in einer Zahl ausdrücken lässt – und deshalb sonst stillschweigend verschwände. */ const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve({ ...BRAUCHBAR, jetzt: { ...BRAUCHBAR.jetzt, offenerBogen: true } }), ), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); /* Der Satz steht jetzt in der Verlustanzeige, zusammen mit den übrigen Verlusten – er ist einer davon, nur eben keine Zahl. */ expect(await screen.findByText(/Ein unterbrochener Prüfungsbogen liegt hier/u)).toBeVisible(); }); /* Zwei Gesamtzahlen nebeneinander genügen nicht: Wer hier ein Profil hat, das die Datei nicht kennt, liest womöglich „mehr Antworten“ und verliert trotzdem sein ganzes Profil. Verglichen wird deshalb je Profil. */ it('benennt ein Profil, das die Datei gar nicht kennt', async () => { const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) })); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); expect( await screen.findByText(/Das Profil „Standard“ kommt in der Datei nicht vor/u), ).toBeVisible(); }); /* Der dritte Weg. Er steht bewusst VOR dem Ersetzen und trägt die harmlosere Beschriftung: Wer nur den Stand vom anderen Gerät braucht, soll hier stehenbleiben und nicht bis zur roten Schaltfläche weiterlesen. */ it('bietet an, ein einzelnes Profil dazuzunehmen', async () => { const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) })); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); expect(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' })).toBeVisible(); expect(screen.getByRole('combobox', { name: 'Profil aus der Datei' })).toHaveDisplayValue( 'Olaf (1204 Antworten)', ); }); it('schickt den Index in die Liste, nie eine Profilnummer', async () => { /* Die Profilnummern der fremden Datei kennt der Renderer nicht, und er soll sie nicht kennen: In einer fremden Datei führte eine Nummer geradewegs in das Profil einer anderen Person. */ const sicherungUebernehmen = vi.fn(() => Promise.resolve({ art: 'uebernommen' as const, profilId: 7, name: 'Olaf (übernommen)', antworten: 1204, bearbeiteteFragen: 480, sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand', katalogstandDerQuelle: null, }), ); const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungUebernehmen, }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' })); expect(sicherungUebernehmen).toHaveBeenCalledWith(BRAUCHBAR.vorgang, 0); expect( await screen.findByText(/Das Profil „Olaf \(übernommen\)“ wurde übernommen/u), ).toBeVisible(); }); it('nennt in der Erfolgsmeldung die Sicherheitskopie und dass nichts ersetzt wurde', async () => { const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'uebernommen' as const, profilId: 7, name: 'Olaf', antworten: 1, bearbeiteteFragen: 1, sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand', katalogstandDerQuelle: null, }), ), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' })); const meldung = await screen.findByText(/wurde übernommen/u); /* Beide Zahlen in einem Satz, der ihr Verhältnis nennt: Bis 0.19.1 stand darüber „39 Antworten“ und darunter „24 bearbeitete Fragen“ – beides richtig, und trotzdem las es sich wie ein Widerspruch. */ expect(meldung).toHaveTextContent(/1 Antwort zu 1 verschiedenen Fragen/u); expect(meldung).toHaveTextContent(/Ihre übrigen Profile sind unverändert/u); expect(meldung).toHaveTextContent(/Lernstand-vor-dem-Uebernehmen-/u); }); it('gibt den Fokus auch zurück, wenn das Einspielen scheitert', async () => { /* Befund der Prüfrunde zu 0.27.2. Der `then`-Zweig von `einspielen` holt den Fokus seit jeher zurück, der `catch`-Zweig nicht — als einziger der vier Wege dieser Karte. Der Modulkopf begründet ausführlich, warum hier kein `disabled` steht: „Ein gesperrtes Element verliert den Fokus, und der fällt auf ``.“ */ const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungEinspielen: vi.fn(() => Promise.reject(new Error('Datei gesperrt'))), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); await nutzer.click(await screen.findByRole('button', { name: /Ja, alles ersetzen/u })); await screen.findByText(/Datei gesperrt/u); expect(document.body).not.toHaveFocus(); expect(screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' })).toHaveFocus(); }); it('gibt den Fokus nach dem Übernehmen an den auslösenden Knopf zurück', async () => { /* Befund der Prüfrunde zu 0.27.2. Der Modulkopf der Karte begründet ausführlich, warum hier kein `disabled` steht: „Ein gesperrtes Element verliert den Fokus, und der fällt auf ``.“ Genau das geschah auf diesem einen Weg trotzdem: `uebernehmen` hängt die Rückfrage samt gedrücktem Knopf aus und holte den Fokus danach in keinem der beiden Zweige zurück. Sichern, Prüfen und Einspielen tun es seit jeher. Wer per Tastatur arbeitet, stand danach auf `` und begann mit dem nächsten Tabulator wieder am Seitenanfang — nach der folgenreichsten Handlung dieser Karte. */ const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'uebernommen' as const, profilId: 7, name: 'Olaf', antworten: 1, bearbeiteteFragen: 1, sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand', katalogstandDerQuelle: null, }), ), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' })); await screen.findByText(/wurde übernommen/u); expect(document.body).not.toHaveFocus(); expect(screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' })).toHaveFocus(); }); it('gibt ihn auch zurück, wenn das Übernehmen scheitert', async () => { /* Die Gegenprobe: Der Fehlerzweig darf nicht der sein, der übrig bleibt. */ const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungUebernehmen: vi.fn(() => Promise.reject(new Error('Datei gesperrt'))), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' })); await screen.findByText(/Datei gesperrt/u); expect(screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' })).toHaveFocus(); }); it('sagt bei gleichem Stand, WAS verglichen wurde – nicht „alles stimmt“', async () => { /* Ein unbedingter Satz über einen Inhalt, der nicht vollständig geprüft wurde, ist genau der Fehler aus docs/stand.md 7.6. Prüfungstermin und Kapitelabwahl werden mitersetzt und sind nicht verglichen. */ const gleich = { ...BRAUCHBAR, jetzt: BRAUCHBAR.ausDatei }; const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(gleich)) })); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); const satz = await screen.findByText(/Verglichen wurden Profile, Antworten/u); expect(satz).toHaveTextContent(/Nicht verglichen wurden Prüfungstermin und Kapitelabwahl/u); }); it('wechselt nach dem Einspielen auf das erste Profil der neuen Datenbank', async () => { /* Nicht verzichtbar: `useLernstand` hält die zuletzt gewählte Nummer in einem Ref, das ein Neuladen überlebt. Steht dort eine Nummer, die es in der eingespielten Datenbank zufällig auch gibt, führte sie geradewegs in das Profil einer anderen Person. */ const bruecke = attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungEinspielen: vi.fn(() => Promise.resolve({ art: 'eingespielt' as const, profilzahl: 1, ersteProfilId: 1, bearbeiteteFragen: 480, einstellungenUebernommen: false, sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand', katalogstandDerQuelle: null, }), ), }); const nutzer = starten(bruecke); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' })); expect(await screen.findByText(/Der Lernstand ist eingelesen/u)).toBeVisible(); /* Die Sicherheitskopie wird beim Namen genannt – sie ist der einzige Weg zurück. */ expect(screen.getByText(/Lernstand-vor-dem-Einspielen-/u)).toBeVisible(); await waitFor(() => { expect(bruecke.lernUebersicht).toHaveBeenCalledTimes(2); }); }); it('schweigt von Einstellungen, wenn die Sicherung keine enthielt', async () => { /* Der Regelfall der Vergangenheit: jede Sicherung vor Fassung 0.27.0. Eine Meldung „übernommen“ wäre dann schlicht unwahr. */ const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungEinspielen: vi.fn(() => Promise.resolve({ art: 'eingespielt' as const, profilzahl: 1, ersteProfilId: 1, bearbeiteteFragen: 12, einstellungenUebernommen: false, sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand', }), ), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' })); expect(await screen.findByText(/Der Lernstand ist eingelesen/u)).toBeVisible(); expect(screen.queryByText(/mit übernommen worden/u)).toBeNull(); }); it('nennt die übernommenen Einstellungen und lässt sie sofort gelten', async () => { /* Der Grund für die ganze Einrichtung: Wer 400 Prozent Anzeigegröße braucht, musste bis 0.26.7 auf dem zweiten Rechner ohne sie anfangen – um sie einzustellen. Geprüft wird beides zusammen, weil nur beides zusammen wahr ist: Die Karte sagt „übernommen“, UND die Oberfläche liest die Einstellungen wirklich noch einmal. Ohne das zweite stünde die Meldung da, während Schrift, Farben und Tasten die alten blieben. */ const lesen = vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), ); const nutzer = starten( attrappe({ einstellungenLesen: lesen, sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)), sicherungEinspielen: vi.fn(() => Promise.resolve({ art: 'eingespielt' as const, profilzahl: 1, ersteProfilId: 1, bearbeiteteFragen: 12, einstellungenUebernommen: true, sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand', }), ), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); const vorEinspielen = lesen.mock.calls.length; await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' })); expect( await screen.findByText(/Ihre Einstellungen für Anzeige, Farben, Vorlesen und Tasten/u), ).toBeVisible(); /* Drei Hooks lesen die Datei getrennt (siehe `einstellungssignal.ts`); geprüft wird, dass überhaupt neu gelesen wird, nicht wie oft. */ await waitFor(() => { expect(lesen.mock.calls.length).toBeGreaterThan(vorEinspielen); }); }); it('sagt nach einer Abweisung, dass nichts verändert wurde', async () => { const nutzer = starten( attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve({ art: 'abgelehnt' as const, grund: 'Diese Datei ist leer oder viel zu klein. Es wurde nichts verändert.', }), ), }), ); await nutzer.click( await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }), ); expect(await screen.findByText(/Es wurde nichts verändert/u)).toBeVisible(); }); }); describe('Was heute ansteht und was heute geschah', () => { /* `pensum.gesamt` ist `neu + fällig`, und `neu` ist eine Rate, die sich nachfüllt. Wer zwanzig neue Fragen bearbeitete, las danach erneut „Heute 20 Fragen“ – ein Satz, der nach getaner Arbeit dasselbe verlangt wie davor. */ const MIT_ARBEIT = { ...UEBERSICHT, heuteBearbeitet: 18 }; it('nennt am unangefangenen Tag die Empfehlung', async () => { starten( attrappe({ lernPlan: vi.fn(() => Promise.resolve({ ...LERNPLAN, pensum: { neu: 14, wiederholung: 6, gesamt: 20, minuten: 8 }, }), ), }), ); expect(await screen.findByText('Heute 20 Fragen, etwa 8 Minuten.')).toBeInTheDocument(); }); it('nennt nach der ersten Antwort die Arbeit und beide Restgrößen', async () => { /* Beide, nicht nur die fällige: Wer einen Prüfungstermin hat, entscheidet über die neuen Fragen, ob der Katalog bis dahin durchläuft. */ starten( attrappe({ lernUebersicht: vi.fn(() => Promise.resolve(MIT_ARBEIT)), lernPlan: vi.fn(() => Promise.resolve({ ...LERNPLAN, pensum: { neu: 5, wiederholung: 6, gesamt: 11, minuten: 5 }, }), ), }), ); expect( await screen.findByText( 'Heute 18 Fragen bearbeitet; offen sind noch 6 zur Wiederholung und 5 neue.', ), ).toBeInTheDocument(); }); it('zeigt nirgends eine Null und nirgends das Wort „Ziel“', async () => { /* Kein Ring, kein Bruch, kein Prozentwert. Wer heute nichts getan hat, liest die Empfehlung – nicht „Heute 0 Fragen bearbeitet“. */ starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); const einstieg = screen.getByRole('region', { name: 'Heute lernen' }); expect(einstieg).not.toHaveTextContent(/Heute 0 Fragen bearbeitet/u); expect(einstieg).not.toHaveTextContent(/Ziel/u); }); it('sagt beim verstrichenen Termin, was Sache ist', async () => { /* Nach dem Termin setzt der Lernplan `neu` auf 0 – nicht weil alles gesehen wäre, sondern weil es nichts mehr zu verteilen gibt. „Auf dem Laufenden“ wäre dann eine Behauptung über den Katalog, die nicht stimmt. */ starten( attrappe({ lernPlan: vi.fn(() => Promise.resolve({ ...LERNPLAN, machbarkeit: 'termin_vorbei' as const, pensum: { neu: 0, wiederholung: 0, gesamt: 0, minuten: 0 }, }), ), }), ); expect(await screen.findByText(/Prüfungstermin ist verstrichen/u)).toBeInTheDocument(); expect(screen.queryByText(/auf dem Laufenden/u)).toBeNull(); }); it('nennt im Lernstand, wann zuletzt gelernt wurde', async () => { /* Die Tatsache statt eines Zählers: Wer nach zwei Wochen zurückkommt, sieht einen gefallenen Reifegrad und erfährt sonst nirgends, warum. */ starten( attrappe({ lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, tageSeitLetzterAntwort: 14 })), }), ); expect(await screen.findByText('Zuletzt gelernt')).toBeInTheDocument(); expect(screen.getByText('vor 14 Tagen')).toBeInTheDocument(); }); it('unterscheidet „noch nie“ von „heute“', async () => { starten(); expect(await screen.findByText('noch nie')).toBeInTheDocument(); }); }); describe('Der Prüfungstag auf dem Startbildschirm', () => { it('sagt, dass keine neuen Fragen mehr dazukommen', async () => { /* Wer heute geprüft wird, will keine Empfehlung, sondern eine Auskunft. Vorher stand hier „Heute 320 Fragen, etwa 133 Minuten“. */ starten( attrappe({ lernPlan: vi.fn(() => Promise.resolve({ ...LERNPLAN, termin: '2026-06-15', tageBisTermin: 0, pensum: { neu: 0, wiederholung: 43, gesamt: 43, minuten: 18 }, }), ), }), ); expect( await screen.findByText( 'Heute ist Ihr Prüfungstermin. Neue Fragen kommen keine mehr dazu; zum Auffrischen stehen 43 Fragen zur Wiederholung bereit.', ), ).toBeInTheDocument(); }); it('meldet am Prüfungstag ohne Fälliges, dass nichts mehr ansteht', async () => { starten( attrappe({ lernPlan: vi.fn(() => Promise.resolve({ ...LERNPLAN, termin: '2026-06-15', tageBisTermin: 0, pensum: { neu: 0, wiederholung: 0, gesamt: 0, minuten: 0 }, }), ), }), ); expect( await screen.findByText('Heute ist Ihr Prüfungstermin. Es steht nichts mehr an.'), ).toBeInTheDocument(); }); }); describe('Binnennavigation auf der langen Seite', () => { /* Der Startbildschirm ist bei 1265 Pixel Breite 8530 Pixel hoch – gemessen am 30.08.2026 an docs/bildschirmfotos/01-startbildschirm-hell.png, im hohen Kontrast 8602. Zum Stand 0.20.0 waren es 8078, vor dem Zusammenlegen der Druckkarten 9032. Wer mit einem Bildschirmleser arbeitet, hat die Überschriftenliste; wer sieht, hatte bisher nur das Rollen – kein Verzeichnis, keine Anker. */ function sprungmenue(): HTMLElement { return screen.getByRole('navigation', { name: 'Weiter unten auf dieser Seite' }); } it('führt jede Karte unterhalb des Menüs auch als Eintrag', async () => { /* Der Punkt dieser Prüfung ist die Vollständigkeit: Eine neu angelegte Karte, die im Verzeichnis fehlt, ist genau der Rückfall in den Zustand vor 0.20.0 – nur unauffälliger. */ starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); const menue = sprungmenue(); const eintraege = within(menue).getAllByRole('link'); const kartenDarunter = [...document.querySelectorAll('main section.karte h2')].filter( (ueberschrift) => (menue.compareDocumentPosition(ueberschrift) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0, ); expect(eintraege).toHaveLength(kartenDarunter.length); }); it('landet mit jedem Eintrag auf einer Kartenüberschrift', async () => { /* Eine Kennung, die es nicht mehr gibt, macht aus dem Eintrag ein Angebot ohne Ziel – und der Klick bliebe folgenlos, ohne dass jemand etwas merkt. Geprüft wird deshalb jeder Eintrag einzeln. */ const nutzer = starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); for (const verweis of within(sprungmenue()).getAllByRole('link')) { const name = verweis.textContent; await nutzer.click(verweis); const ziel = document.activeElement; expect(ziel?.tagName, `„${name}“ trifft keine Überschrift`).toBe('H2'); expect(ziel?.closest('section.karte'), `„${name}“ trifft keine Karte`).not.toBeNull(); } }); it('trägt die Einträge als Verweise, nicht als zweite Knöpfe', async () => { /* Die Namen der Einträge sind dieselben wie die der Schaltflächen in den Karten – „Fragen durchsuchen“ steht seit der Vereinheitlichung an beiden Stellen. Als Schaltfläche wäre jeder Eintrag ein zweiter Knopf desselben Namens; als Verweis ist er eine Sprungmarke und benennt sein Ziel im `href`. */ starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); const menue = sprungmenue(); expect(within(menue).queryAllByRole('button')).toHaveLength(0); expect(within(menue).getByRole('link', { name: 'Fragen durchsuchen' })).toHaveAttribute( 'href', '#suche-titel', ); }); it('springt zur Karte und nimmt den Fokus mit', async () => { /* Reines Rollen wäre für Tastatur und Bildschirmleser wirkungslos – der nächste Tabulatorsprung begänne wieder ganz oben (WCAG 2.4.3). */ const nutzer = starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); await nutzer.click(within(sprungmenue()).getByRole('link', { name: 'Sichern und übertragen' })); expect( screen.getByRole('heading', { name: 'Lernstand sichern und übertragen', level: 2 }), ).toHaveFocus(); }); it('steht unter der Einstieg-Karte, nicht darüber', async () => { /* Die Einstieg-Karte hat ein gemessenes Höhenbudget, damit der Weg ins Lernen im ersten Bildschirm bleibt (e2e/blickfeld.spec.ts). Eine Leiste davor kostete genau dieses Budget. */ starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); const einstieg = screen.getByRole('region', { name: 'Heute lernen' }); const stellung = einstieg.compareDocumentPosition(sprungmenue()); expect(stellung & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); }); it('verkürzt den Tastaturweg in die Verwaltung', async () => { /* Nachgemessen in dieser Attrappe (eine Radiogruppe zählt als ein Halt): Bis zum ersten Bedienelement der Sicherungskarte waren es vor dem Verzeichnis 20 Tabulatorhalte, alle nacheinander. Über das Verzeichnis sind es 13 – fünf Einstiege, dann der achte Eintrag der Leiste –, danach steht der Fokus auf der Überschrift der Karte. Wer die Leiste nicht benutzt und stumpf weitertabbt, kommt auf 27. Das ist der Preis, und er ist bewusst bezahlt – ohne Leiste gab es für sehende Bedienung überhaupt keinen Sprung. Beide Zahlen sind seit 0.27.0 um zwei gewachsen: Die Karte „Gesetze lesen“ bringt einen Eintrag in die Leiste und einen Knopf mit. */ starten(); await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' }); const halte = tabulatorhalte(); const sprung = within(sprungmenue()).getByRole('link', { name: 'Sichern und übertragen' }); const sicherung = document.getElementById('sicherung-titel')?.closest('section'); expect(halte.indexOf(sprung)).toBe(12); expect(halte.findIndex((element) => sicherung?.contains(element) ?? false)).toBe(26); }); }); describe('Drucken – eine Karte statt dreier', () => { /* Lernbericht, Fehlerprotokoll und Fragenliste standen bis 0.21.0 als drei Karten untereinander, jede mit einer eigenen Frage nach der Schriftgröße. Im damaligen Bildschirmfoto nahmen sie zusammen rund 1740 der 9032 Pixel ein; zum Stand 0.20.0 maß dieselbe Seite 8078 und heute 8530. */ it('fragt zuerst nach dem Dokument und zeigt dann nur dieses', async () => { starten(); const karte = await screen.findByRole('region', { name: 'Drucken' }); expect(within(karte).getByRole('radio', { name: 'Lernbericht' })).toBeChecked(); expect(within(karte).getByRole('heading', { name: 'Lernbericht', level: 3 })).toBeVisible(); /* Genau eine Schriftgrößenfrage – nicht drei untereinander. */ expect(screen.getAllByRole('group', { name: 'Schriftgröße des Dokuments' })).toHaveLength(1); expect( screen.queryByRole('button', { name: 'Fehlerprotokoll als PDF speichern' }), ).not.toBeInTheDocument(); }); it('stellt auf das gewählte Dokument um', async () => { const nutzer = starten(); const karte = await screen.findByRole('region', { name: 'Drucken' }); await nutzer.click(within(karte).getByRole('radio', { name: 'Fehlerprotokoll' })); expect(within(karte).getByRole('heading', { name: 'Fehlerprotokoll', level: 3 })).toBeVisible(); expect( within(karte).getByRole('button', { name: 'Fehlerprotokoll als PDF speichern' }), ).toBeVisible(); expect( within(karte).queryByRole('button', { name: 'Lernbericht als PDF speichern' }), ).not.toBeInTheDocument(); }); it('sagt den geänderten Seitenumfang an, statt ihn stumm zu wechseln', async () => { /* Befund der Prüfrunde zu 0.27.2. `shared/druck/umfang.ts` begründet den Satz so: „Der schlimmste gemessene Fall sind 982 Seiten … Wer das ohne Vorwarnung an einen Drucker schickt, hat ein Problem. Die Zahl muss deshalb dastehen, bevor der Speicherdialog aufgeht.“ Dastehen tat sie — angesagt wurde sie nicht: Der Absatz trug keine Live-Region, und die Tabulatorhalte führen von Radiogruppe zu Radiogruppe zur Schaltfläche daran vorbei. Wer die Begründungstiefe umstellte, erfuhr nichts von der Verdopplung. */ /* Die Grundattrappe führt null Fehler; ohne Fehler gibt es keinen Umfangssatz. */ const nutzer = starten( attrappe({ lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, fehler: 40 })), }), ); const karte = await screen.findByRole('region', { name: 'Drucken' }); await nutzer.click(within(karte).getByRole('radio', { name: 'Fehlerprotokoll' })); /* Über die Klasse und nicht über die Rolle: Die Meldungsregion derselben Karte trägt `role="status"` ebenfalls. Zwei Knoten mit derselben Rolle waren hier schon einmal ein Fund der Wachen – deshalb spricht dieser Absatz über `aria-live` und nicht über eine zweite Rolle. */ const region = karte.querySelector('.bericht__umfang'); expect(region, 'Kein Umfangssatz gefunden').not.toBeNull(); expect(region).toHaveAttribute('aria-live', 'polite'); const vorher = region?.textContent; expect(vorher).toMatch(/Seite/u); await nutzer.click(within(karte).getByRole('radio', { name: /Vollständige Begründung/u })); expect(region?.textContent).not.toBe(vorher); expect(region?.textContent).toMatch(/Seite/u); }); it('bietet die Fragenliste nur an, wenn der Katalog dasteht', async () => { /* Die Liste hängt am Katalog. Ohne ihn gäbe es nichts zu drucken – dann steht die Wahl gar nicht erst da, statt in eine gesperrte Schaltfläche zu führen. */ starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) })); const karte = await screen.findByRole('region', { name: 'Drucken' }); expect(within(karte).getByRole('radio', { name: 'Lernbericht' })).toBeInTheDocument(); expect(within(karte).queryByRole('radio', { name: 'Fragenliste' })).not.toBeInTheDocument(); }); }); describe('Startbildschirm – Benennung', () => { it('nennt die Suche überall gleich', async () => { /* Bis 0.20.0 hieß dieselbe Sache „Fragen finden“ (Überschrift), „Fragen durchsuchen“ (Schaltfläche) und „Fragen finden und nachschlagen“ (Hilfe). */ starten(); expect( await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 2 }), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Fragen durchsuchen' })).toBeInTheDocument(); expect(screen.queryByText('Fragen finden')).not.toBeInTheDocument(); }); it('sagt an der Prüfungsschaltfläche, wohin sie führt', async () => { /* Der Knopf führt in die Vorbereitung; gestartet wird erst dort. Vorher hieß er „Prüfungssimulation starten“ und versprach damit den Schritt, den erst die nächste Ansicht tut. */ const nutzer = starten(); const knopf = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); expect( await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten', level: 1 }), ).toBeInTheDocument(); }); }); describe('Gesetze lesen', () => { /* Die Vorschriften lagen seit 0.22.0 im Paket und waren nur über eine Frage erreichbar: Wer wissen wollte, was in § 13 WaffG steht, ohne die passende Frage zu kennen, hatte keinen Weg dorthin. */ const NORMTEXTE: Normtexte = { meta: { version: 1, stand: '2026-09-01', hinweis: 'Prüfstand', gesetzesstand: {} }, gesetze: { WaffG: { bezeichnung: 'Waffengesetz', stand: 'Zuletzt geändert am 20.3.2026', quelle: 'gesetze-im-internet.de', normen: { '§ 2': { titel: 'Grundsätze des Umgangs mit Waffen', istAnlage: false, absaetze: { '1': 'Der Umgang mit Waffen ist nur Berechtigten gestattet.' }, }, '§ 13': { titel: 'Jäger', istAnlage: false, absaetze: { '1': 'Ein Bedürfnis für den Erwerb von Langwaffen wird bei Jägern …' }, }, }, }, StGB: { bezeichnung: 'Strafgesetzbuch', stand: 'Zuletzt geändert am 1.1.2026', quelle: 'gesetze-im-internet.de', normen: { '§ 32': { titel: 'Notwehr', istAnlage: false, absaetze: { '1': 'Wer eine Tat begeht, die durch Notwehr geboten ist …' }, }, }, }, /* Eine Anlage gehörte bis 0.27.2 nicht in diesen Prüfstand – und genau deshalb ist die Verdopplung der Gliederungsnummer niemandem aufgefallen. Der Wortlaut ist wie im echten Bestand gebaut: Der Text beginnt mit seiner eigenen Marke (nachgemessen an `content/normtexte.json`: 231 von 231 markierten Blöcken). */ SprengG: { bezeichnung: 'Sprengstoffgesetz', stand: 'Zuletzt geändert am 5.5.2026', quelle: 'gesetze-im-internet.de', normen: { 'Anlage 1': { titel: '(zu § 1 Abs. 4)', istAnlage: true, bloecke: [ { marke: null, pfad: [], text: 'Vorbemerkung zur Anlage.' }, { marke: 'Abschnitt 1', pfad: [], text: 'Abschnitt 1:\nBegriffe' }, { marke: '1.1', pfad: ['Abschnitt 1'], text: '1.1\nExplosionsgefährliche Stoffe sind feste Stoffe.', }, ], }, }, }, }, }; function mitGesetzen(): ReturnType { /* Die Normtexte liegen in einem Zwischenspeicher des Moduls; ohne das Leeren sähe diese Gruppe, was eine frühere Prüfung geladen hat. */ normtexteZwischenspeicherLeeren(); return attrappe({ normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE)) }); } /** Öffnet die Ansicht und wählt ein Gesetz – der Weg eines Menschen. */ async function inDasGesetz( nutzer: ReturnType, bezeichnung: string, ): Promise { await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' })); await nutzer.click(await screen.findByRole('button', { name: bezeichnung })); } it('führt vom Startbildschirm in die Gesetze und wieder zurück', async () => { const nutzer = starten(mitGesetzen()); await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' })); expect(await screen.findByRole('heading', { name: 'Gesetze lesen', level: 1 })).toBeVisible(); await nutzer.click(screen.getByRole('button', { name: 'Zurück zum Start' })); expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); }); it('nennt zu jedem Gesetz den Änderungsstand', async () => { /* Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute, die morgen falsch sein kann. */ const nutzer = starten(mitGesetzen()); await inDasGesetz(nutzer, 'Waffengesetz'); expect(await screen.findByText(/Zuletzt geändert am 20\.3\.2026/u)).toBeVisible(); expect(screen.getByText(/Amtlicher Wortlaut, unverändert übernommen/u)).toBeVisible(); }); it('zeigt den Wortlaut erst beim Aufklappen', async () => { /* Anlage 1 des WaffG allein hat 29 000 Zeichen; eine Liste, die beim Öffnen alles ausrollt, ist für niemanden zu überblicken. */ const nutzer = starten(mitGesetzen()); await inDasGesetz(nutzer, 'Waffengesetz'); expect(screen.queryByText(/nur Berechtigten gestattet/u)).toBeNull(); await nutzer.click(await screen.findByText(/§ 2 – Grundsätze des Umgangs mit Waffen/u)); expect(await screen.findByText(/nur Berechtigten gestattet/u)).toBeVisible(); }); it('stellt der Gliederungsnummer einer Anlage nicht dieselbe Nummer voran', async () => { /* Befund der Prüfrunde zu 0.27.2. Der Modulkopf der Ansicht sagt zu: „Der Text ist der amtliche, unverändert (§ 5 Abs. 1 UrhG). Er wird nicht gekürzt, nicht zusammengefasst und nicht umsortiert.“ Die Darstellung setzte die Marke aber noch einmal vor einen Text, der bereits mit ihr beginnt — auf dem Bildschirm stand „1.1 1.1 Explosionsgefährliche …“. `Normstelle.tsx` gibt dieselben Blöcke ohne Präfix aus. */ const nutzer = starten(mitGesetzen()); await inDasGesetz(nutzer, 'Sprengstoffgesetz'); await nutzer.click(await screen.findByText(/Anlage 1 – \(zu § 1 Abs\. 4\)/u)); const text = (await screen.findByText(/Explosionsgefährliche Stoffe/u)).textContent; expect(text).toContain('1.1'); expect(text).not.toContain('1.1 1.1'); expect(screen.getByText(/Begriffe/u).textContent).not.toContain('Abschnitt 1 Abschnitt 1'); }); it('findet eine Vorschrift über die Suche und sagt, warum sie trifft', async () => { const nutzer = starten(mitGesetzen()); await inDasGesetz(nutzer, 'Waffengesetz'); await nutzer.type(await screen.findByLabelText('In den Gesetzen suchen'), 'Jäger'); expect(await screen.findByText(/§ 13 – Jäger/u)).toBeVisible(); expect(screen.getByText(/Treffer in der Überschrift/u)).toBeVisible(); expect(screen.queryByText(/§ 2 – Grundsätze/u)).toBeNull(); }); it('sagt in der Auswahl, in welchem Gesetz die Treffer stehen', async () => { /* Sonst müsste der Suchende sieben Gesetze der Reihe nach durchklicken. Die Zahl steht als Text da, nicht als Farbe oder Punkt. */ const nutzer = starten(mitGesetzen()); await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' })); await nutzer.type(await screen.findByLabelText('In den Gesetzen suchen'), 'Notwehr'); expect( await screen.findByRole('button', { name: /Strafgesetzbuch.*1 Treffer/u }), ).toBeVisible(); expect(screen.getByRole('button', { name: /Waffengesetz.*kein Treffer/u })).toBeVisible(); }); it('sagt es, wenn keine Gesetzestexte beiliegen', async () => { /* Eine leere Liste sähe aus wie ein Fehler. Die Grundattrappe liefert `NORMTEXTE_LEER`. */ const nutzer = starten(); await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' })); expect(await screen.findByText(/zurzeit nicht verfügbar/u)).toBeVisible(); }); }); describe('Kapitelwahl – der Lernstand steht an jeder Wahl', () => { /* Der Befund vom 01.09.2026, am Bildschirm gesehen: Die Ampeln standen ausschließlich an den Abschnitten des Kapitels I. Der Grund war keine Auslassung, sondern eine Eigenschaft der Bereichsliste — nur Kapitel I ist amtlich untergliedert, für II bis IV führt sie das Kapitel selbst, und die 29 Themengruppen stehen in einem eigenen Feld, das die Kapitelwahl nicht bekam. Eine Anzeige, die bei drei von vier Kapiteln fehlt, sieht aus wie ein Fehler und war einer. */ async function zurKapitelwahl( bruecke?: ReturnType, ): Promise> { const nutzer = starten(bruecke ?? attrappe({})); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 }); return nutzer; } /** Der Block, in dem dieser Knopf steht. */ function blockVon(knopfname: string | RegExp): HTMLElement { const knopf = screen.getByRole('button', { name: knopfname }); const block = knopf.closest('.kapitelwahl__bereich'); expect(block, `Kein Block um „${String(knopfname)}“`).not.toBeNull(); return block as HTMLElement; } it('nimmt für das ganze Kapitel dessen eigene Zeile, wenn es eine hat', async () => { /* So liegt es bei den Kapiteln II bis IV: Der Katalog gliedert sie nicht, die Bereichsliste führt deshalb das Kapitel selbst. */ await zurKapitelwahl(); const ampel = within(blockVon(/Ganzes Kapitel I üben/u)).getByLabelText( /Prüfungsreife in Kapitel I/u, ); expect(ampel).toHaveAttribute('aria-valuenow', '40'); expect(ampel).toHaveAttribute('aria-valuemax', '300'); }); it('fasst die Abschnitte zusammen, wenn das Kapitel keine eigene Zeile hat', async () => { /* Und so liegt es bei Kapitel I, dem einzigen amtlich untergliederten: Die Bereichsliste kennt `I.1` bis `I.5`, aber kein `I`. Ohne die Zusammenfassung stünde ausgerechnet der erste Knopf der Seite als einziger ohne Lernstand da. Gerechnet wird exakt und nicht ungefähr: 12 von 90 und 40 von 154 sind zusammen 52 von 244 (siehe `bereicheZusammenfassen`). */ await zurKapitelwahl( attrappe({ lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, /* Ohne die Kapitelzeile, dafür mit beiden Abschnitten — so liefert der Anwendungskern es für Kapitel I wirklich. */ bereiche: [ { id: 'I.1', titel: 'Begriffe des Waffenrechts', fragenGesamt: 90, beantwortet: 30, belegt: 12, reifegrad: 12 / 90, stufe: 'zurueck' as const, }, { id: 'I.2', titel: 'Rechte und Pflichten', fragenGesamt: 154, beantwortet: 80, belegt: 40, reifegrad: 40 / 154, stufe: 'zurueck' as const, }, ], }), ), }), ); const ampel = within(blockVon(/Ganzes Kapitel I üben/u)).getByLabelText( /Prüfungsreife in Kapitel I/u, ); expect(ampel).toHaveAttribute('aria-valuenow', '52'); expect(ampel).toHaveAttribute('aria-valuemax', '244'); }); it('zeigt den Stand an jeder Themengruppe', async () => { /* Die 230 Fragen der Kapitel II bis IV haben keinen amtlichen Abschnitt; geübt werden sie über die Themengruppen, und genau dort fehlte der Stand. */ themenZwischenspeicherLeeren(); await zurKapitelwahl( attrappe({ katalogLaden: vi.fn(() => Promise.resolve({ ...KATALOG, kapitel: [...KATALOG.kapitel, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }], }), ), themenLaden: vi.fn(() => Promise.resolve({ meta: { version: 1, stand: '2026-09-01', hinweis: 'Prüfstand' }, gruppen: [ { id: 'II.1', kapitel: 'II', titel: 'Waffenarten', fragen: ['II-01', 'II-02', 'II-03'], }, ], }), ), lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, themengruppen: [ { id: 'II.1', kapitel: 'II', titel: 'Waffenarten', fragenGesamt: 18, beantwortet: 9, belegt: 7, reifegrad: 7 / 18, stufe: 'kippe' as const, }, ], }), ), }), ); const ampel = within(blockVon(/Waffenarten/u)).getByLabelText(/Prüfungsreife in Waffenarten/u); expect(ampel).toHaveAttribute('aria-valuenow', '7'); expect(ampel).toHaveAttribute('aria-valuemax', '18'); }); it('lässt die Ampel weg, solange nichts geladen ist', async () => { /* „Noch nicht geladen“ ist nicht „null Prozent“. */ await zurKapitelwahl(ohneKanaele('lernUebersicht')); expect(screen.queryByLabelText(/Prüfungsreife in/u)).not.toBeInTheDocument(); }); it('legt jede Wahl in eine eigene Zeile der Liste', async () => { /* Der zweite Teil des Befundes: Die Knöpfe standen in einer `flex-wrap`-Reihe, und seit jede Wahl zwei Zeilen Lernstand darunter trägt, zogen sich die Elemente gegenseitig auf die Höhe des höchsten — ein Knopf ohne Ampel stand als hoher leerer Kasten da. Geprüft wird die Struktur, nicht das Aussehen: Jede Wahl ist ein Listeneintrag. */ await zurKapitelwahl(); const block = blockVon(/Ganzes Kapitel I üben/u); expect(block.tagName).toBe('LI'); expect(block.parentElement?.tagName).toBe('UL'); }); }); describe('Reifegrad-Verlauf – er darf nicht das Gegenteil der Ampel behaupten', () => { /* Befund der Prüfrunde zu 0.27.2. Der Modulkopf von `shared/reifeverlauf.ts` sagt zu: „Ein nachgerechneter Verlauf, dessen letzter Punkt nicht die Zahl der Ampel ist, wäre schlimmer als keiner: zwei Zahlen auf einem Bildschirm, die einander widersprechen.“ Genau das trat ein, sobald sich der Lernstand änderte, ohne dass der Startbildschirm dabei verschwand. „Neu anfangen“ ist der eindeutigste Fall: Die Ampel darüber sprang auf null, der Verlauf darunter behauptete weiter, es sei etwas gestiegen. `useReifeverlauf` bietet dafür `neuLaden` an — gerufen hat es niemand. */ const VERLAUF_VORHER = [ { tag: '2026-03-01', reifegrad: 0.02, belegt: 12, beantwortet: 30 }, { tag: '2026-03-02', reifegrad: 0.05, belegt: 29, beantwortet: 60 }, { tag: '2026-03-03', reifegrad: 0.083, belegt: 48, beantwortet: 120 }, ]; const VERLAUF_NACHHER = [ { tag: '2026-03-02', reifegrad: 0, belegt: 0, beantwortet: 0 }, { tag: '2026-03-03', reifegrad: 0, belegt: 0, beantwortet: 0 }, ]; it('rechnet nach „Neu anfangen“ neu', async () => { let aufrufe = 0; const lernVerlauf = vi.fn(() => { aufrufe += 1; return Promise.resolve(aufrufe === 1 ? VERLAUF_VORHER : VERLAUF_NACHHER); }); const nutzer = starten(attrappe({ lernVerlauf })); expect(await screen.findByText(/von 12 auf 48 von 575 Fragen gestiegen/u)).toBeVisible(); await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' })); await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' })); await screen.findByText(/Ihr Lernstand ist zurückgesetzt/u); /* Nicht nur „weg“, sondern „neu gelesen“: Der Satz nennt den Stand nach dem Zurücksetzen. Ein bloßes Ausblenden wäre die zweite Unwahrheit. */ expect(await screen.findByText(/unverändert bei 0 von 575 Fragen/u)).toBeVisible(); expect(screen.queryByText(/Fragen gestiegen/u)).toBeNull(); }); it('holt ihn nicht ohne Anlass ein zweites Mal', async () => { /* Die Gegenprobe: Der Verlauf ist eine Rechnung über das ganze Antwortprotokoll und wird ausdrücklich nicht bei jeder Kleinigkeit geholt (`useReifeverlauf`). Ein Aufruf je Aufbau, nicht mehr. */ const lernVerlauf = vi.fn(() => Promise.resolve(VERLAUF_VORHER)); starten(attrappe({ lernVerlauf })); expect(await screen.findByText(/von 12 auf 48 von 575 Fragen gestiegen/u)).toBeVisible(); expect(lernVerlauf).toHaveBeenCalledTimes(1); }); }); describe('Der eingestellte Sitzungsumfang steht auch dort, wo er angekündigt wird', () => { /* Befund der Prüfrunde zu 0.27.2. Seit 0.26.6 ist der Sitzungsumfang einstellbar; der Changelog sagt es öffentlich zu: „fünf bis fünfzig statt der festen zwanzig“. Die Zahl kam auch überall an, wo eine Sitzung entsteht — nur nicht dort, wo sie **angekündigt** wird. Unter „Weiterlernen“ stand fest „20 Fragen“, in der Kapitelwahl „bis zu 20 Fragen“. Wer fünf eingestellt hatte, las eine Zusage über zwanzig und bekam fünf. */ function mitUmfang(umfang: number): TeilBridge { return attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, sitzungsumfang: umfang, }), ), }); } it('nennt die eingestellte Zahl unter „Weiterlernen“', async () => { starten(mitUmfang(8)); expect(await screen.findByText('8 Fragen, vom Programm zusammengestellt')).toBeVisible(); }); it('nennt ab Werk die zwanzig', async () => { /* Die Gegenprobe: Die Zahl darf nicht dadurch richtig werden, dass gar keine mehr dasteht. */ starten(); expect(await screen.findByText('20 Fragen, vom Programm zusammengestellt')).toBeVisible(); }); it('nennt sie auch in der Kapitelwahl', async () => { const nutzer = starten(mitUmfang(8)); const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u }); await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(knopf); await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 }); expect(screen.getByText(/bis zu 8 Fragen/u)).toBeVisible(); expect(screen.queryByText(/bis zu 20 Fragen/u)).toBeNull(); }); });