/** * 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 } 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', }, ], }; 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('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('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('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('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', }), ); 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', }), ), }), ); 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('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, sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand', }), ), }); 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('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 vorher 20 Tabulatorhalte, alle nacheinander. Über das Verzeichnis sind es jetzt 12 – fünf Einstiege, dann der siebte Eintrag der Leiste –, danach steht der Fokus auf der Überschrift der Karte. Wer die Leiste nicht benutzt und stumpf weitertabbt, kommt auf 24: Das Verzeichnis kostet zehn Halte und die zusammengefasste Druckkarte spart sechs. Das ist der Preis, und er ist bewusst bezahlt – ohne Leiste gab es für sehende Bedienung überhaupt keinen Sprung. */ 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(11); expect(halte.findIndex((element) => sicherung?.contains(element) ?? false)).toBe(24); }); }); describe('Drucken – eine Karte statt dreier', () => { /* Lernbericht, Fehlerprotokoll und Fragenliste standen bis 0.20.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('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(); }); });