/** * Verhaltenstests des Hilfefensters. * * Zwei Zusagen stehen hier auf dem Prüfstand: * * 1. **Erreichbarkeit** – WCAG 3.2.6 verlangt konsistente Hilfe: Sie steht in * jeder Ansicht an derselben Stelle. Geprüft wird das quer durch alle * Ansichten, einschließlich Lernsitzung und Prüfungslauf. * 2. **Folgenlosigkeit** – die Hilfe ist ein Dialog und keine Ansicht. Wer sie * mitten in einer Sitzung öffnet, findet danach dieselbe Frage vor. * * ## Was diese Tests NICHT prüfen können * * jsdom 27 kennt `` als Element, aber weder `showModal()` noch * `close()`. Der Dialog läuft hier über den Rückfallweg (das `open`-Attribut * von Hand) und ist damit **nicht modal**: Es gibt keine Fokusfalle und nichts * dahinter wird inert. Beides ist Sache des echten Fensters und wird in * `e2e/hilfe.spec.ts` gemessen. Ein Test, der hier „modal“ behauptete, würde * eine Eigenschaft bescheinigen, die die Umgebung gar nicht hat. */ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import axe from 'axe-core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/renderer/src/App'; import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; import type { Erklaerungen } from '../src/shared/erklaerungen'; import type { Glossar } from '../src/shared/glossar'; import { HILFE_KAPITEL, HILFE_TITEL } from '../src/shared/hilfe'; import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge, type Menuebefehl, } from '../src/shared/ipc'; import type { Katalog, RichText } from '../src/shared/katalog'; import type { Lernplan } from '../src/shared/lernplan'; import type { FrageStand, Lernuebersicht, Profil } from '../src/shared/lernstand'; import { EUPL_HINWEIS, EUPL_KENNUNG, KATALOG_HINWEIS } from '../src/shared/lizenzen'; import type { Pruefungsergebnis } from '../src/shared/pruefung'; import { jsdomOptionen } from '../src/shared/wcag'; import { NORMTEXTE_LEER } from '../src/shared/normtexte'; import { THEMEN_LEER } from '../src/shared/themen'; type Nutzer = ReturnType; // ─── Attrappen ────────────────────────────────────────────────────────── function nurText(text: string): RichText { return { text, segmente: [{ t: text }] }; } const FRAGE_TEXT = 'Welcher Gegenstand ist eine Anscheinswaffe?'; const KATALOG: Katalog = { meta: { titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: '7e5e41fa', fragen_gesamt: 575, }, kapitel: [{ id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }], bilder: [], fragen: [ { id: 'I.1-87', amtliche_nummer: '1.87', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 23, frage: nurText(FRAGE_TEXT), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Sportgewehr'), korrekt: false, bilder: [] }, { label: 'b', inhalt: nurText('Spielzeugpistole'), korrekt: true, bilder: [] }, ], }, ], }; const PROFIL: Profil = { id: 1, name: 'Testprofil', pruefungstermin: null, kapitelAusschluss: [], erstelltAm: '2026-01-01T08:00:00.000Z', }; const UEBERSICHT: Lernuebersicht = { fragenGesamt: 575, beantwortet: 0, belegt: 0, reifegrad: 0 / 575, stufe: 'ohne_beleg', deckelnd: [], faellig: 0, gemerkt: 0, offen: 0, fehler: 0, heuteRichtig: 0, heuteFalsch: 0, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, bereiche: [], }; const STAND: FrageStand = { frageId: 'I.1-87', versuche: 0, richtige: 0, zuletztBeantwortet: null, faelligAb: null, gemerkt: false, letzteBewertung: null, }; const INFO: AnwendungsInfo = { anwendungsVersion: '0.0.0-attrappe', baukennung: 'abc1234', baustand: '2026-08-22', 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 ERGEBNIS: Pruefungsergebnis = { profilId: 'standard', gesamt: 0, richtig: 0, falsch: 0, unbeantwortet: 0, quote: 0, urteil: 'nicht_bestanden', begruendung: '0 Prozent richtig, nötig waren 80 Prozent.', verletzteKriterien: [], bereiche: [], fehlerIds: [], dauerMs: 0, zeitAbgelaufen: false, zeitpunkt: '2026-01-01T08:00:00.000Z', }; const LERNPLAN: Lernplan = { termin: null, tageBisTermin: null, gesamtFragen: 1, nieBeantwortet: 1, faellig: 0, zielquote: 0.9, prognoseHeute: 0, prognoseAmTermin: null, pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 }, machbarkeit: 'kein_termin', sekundenProFrage: 25, }; const ERKLAERUNGEN: Erklaerungen = { meta: { version: 1, stand: '2026-08-20', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.', }, zuFrage: {}, }; const GLOSSAR: Glossar = { meta: { version: 1, stand: '2026-08-21', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt.', }, eintraege: [ { begriff: 'WaffG', art: 'abkuerzung', kurz: 'Waffengesetz.', varianten: ['WaffG'], fundstellen: [], ohneFundstelleGrund: 'Die amtliche Kurzbezeichnung steht nicht im Gesetz selbst.', }, ], }; function attrappe(ueberschreibungen: Partial = {}): LernAppBridge { return { anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)), anzeigegroesseBeobachten: vi.fn(() => () => undefined), hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)), hilfsmittelBeobachten: vi.fn(() => () => undefined), menuebefehlBeobachten: vi.fn(() => () => undefined), /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), ), einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)), katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), katalogBild: vi.fn(() => Promise.resolve('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([{ frageId: 'I.1-87', optionsReihenfolge: ['a', 'b'], gemerkt: false }]), ), 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)), pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })), pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)), pruefungVerlauf: vi.fn(() => Promise.resolve([])), pruefungSichern: vi.fn(() => Promise.resolve(true)), pruefungOffen: vi.fn(() => Promise.resolve(null)), pruefungVerwerfen: vi.fn(() => Promise.resolve()), pruefungLaeuft: vi.fn(() => Promise.resolve()), sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })), fehlerprotokollDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }), ), fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })), lernberichtDrucken: vi.fn(() => Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }), ), /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt, dass die Brücke den Kanal kennt. */ datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })), lizenzenLesen: vi.fn(() => Promise.resolve({ eigene: { kennung: EUPL_KENNUNG, name: 'European Union Public Licence, Version 1.2', hinweis: EUPL_HINWEIS, text: null, }, katalog: { quellenangabe: KATALOG.meta.quellenangabe, quelleUrl: KATALOG.meta.quelle_url, hinweis: KATALOG_HINWEIS, }, dritte: [], }), ), ...ueberschreibungen, }; } // ─── Hilfsgriffe ──────────────────────────────────────────────────────── async function anwendungOeffnen(bruecke: LernAppBridge = attrappe()): Promise { vi.stubGlobal('lernApp', bruecke); const nutzer = userEvent.setup(); render(); await screen.findByRole('button', { name: /Weiterlernen/u }); return nutzer; } /** Die Schaltfläche im Fußbereich – der Weg zur Hilfe in jeder Ansicht. */ function hilfeknopf(): HTMLElement { return screen.getByRole('button', { name: 'Hilfe' }); } /** Der Dialog selbst; er trägt seinen Namen aus der Überschrift. */ function hilfefenster(): HTMLElement { return screen.getByRole('dialog', { name: HILFE_TITEL }); } afterEach(() => { vi.unstubAllGlobals(); erklaerungenZwischenspeicherLeeren(); glossarZwischenspeicherLeeren(); normtexteZwischenspeicherLeeren(); themenZwischenspeicherLeeren(); }); // ─── Tests ────────────────────────────────────────────────────────────── describe('Hilfe – Erreichbarkeit (WCAG 3.2.6)', () => { it('bietet den Weg zur Hilfe schon auf dem Startbildschirm an', async () => { await anwendungOeffnen(); expect(hilfeknopf()).toBeInTheDocument(); }); it('nennt die Taste, mit der sie auch ohne die Schaltfläche aufgeht', async () => { /* Der Menü-Accelerator F1 ist für Sehende im Menü sichtbar. Wer mit einem Bildschirmleser arbeitet, erfährt ihn über aria-keyshortcuts. */ await anwendungOeffnen(); expect(hilfeknopf()).toHaveAttribute('aria-keyshortcuts', 'F1'); expect(hilfeknopf()).toHaveAttribute('aria-haspopup', 'dialog'); }); it('steht in jeder Ansicht an derselben Stelle', async () => { const nutzer = await anwendungOeffnen(); /* Die Kernaussage von 3.2.6 ist die gleiche relative Reihenfolge. Der Fußbereich ist die einzige Landmarke, die es in allen Ansichten gibt – geprüft wird deshalb, dass die Hilfe dort steht und dort ihr erstes Bedienelement ist. */ const wege: readonly (readonly [string, RegExp])[] = [ ['Kapitelwahl', /Kapitel wählen/u], ['Glossar', /Fachbegriffe nachschlagen/u], ['Über diese Software', /Lizenzen und Herkunft anzeigen/u], ['Prüfungssimulation', /Prüfungssimulation vorbereiten/u], ]; for (const [name, knopfname] of wege) { await nutzer.click(await screen.findByRole('button', { name: knopfname })); const fuss = screen.getByRole('contentinfo'); const erstes = within(fuss).getAllByRole('button')[0]; expect(erstes, `${name}: keine Hilfe im Fußbereich`).toHaveAccessibleName('Hilfe'); await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!); } }); it('steht auch in der laufenden Lernsitzung im Fußbereich', async () => { const nutzer = await anwendungOeffnen(); await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u })); await screen.findByText(FRAGE_TEXT); const fuss = screen.getByRole('contentinfo'); expect(within(fuss).getAllByRole('button')[0]).toHaveAccessibleName('Hilfe'); }); }); describe('Hilfe – Inhalt', () => { it('zeigt alle Kapitel mit Überschrift', async () => { const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); const fenster = hilfefenster(); for (const kapitel of HILFE_KAPITEL) { expect( within(fenster).getByRole('heading', { name: kapitel.titel, level: 3 }), `Kapitel fehlt: ${kapitel.id}`, ).toBeInTheDocument(); } }); it('führt ein Inhaltsverzeichnis, dessen Sprungziele es gibt', async () => { const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); const verzeichnis = screen.getByRole('navigation', { name: 'Kapitel dieser Hilfe' }); const verweise = within(verzeichnis).getAllByRole('link'); expect(verweise).toHaveLength(HILFE_KAPITEL.length); for (const verweis of verweise) { const ziel = (verweis.getAttribute('href') ?? '').replace(/^#/u, ''); /* Ein Verzeichniseintrag, der ins Leere zeigt, ist schlimmer als keiner: Er sagt zu, dass es dort weitergeht. */ expect(document.getElementById(ziel), `Sprungziel fehlt: ${ziel}`).not.toBeNull(); } }); it('öffnet von sich aus keine Verbindung nach außen', async () => { /* Dieselbe Zusage wie in „Über diese Software“: kein mailto:, kein tel:, kein https: – die Anwendung startet kein fremdes Programm. */ const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); for (const verweis of within(hilfefenster()).getAllByRole('link')) { expect(verweis.getAttribute('href') ?? '').toMatch(/^#/u); } }); }); describe('Hilfe – Öffnen und Schließen', () => { /* Bis Fassung 0.24.2 landete der Fokus hier auf dem Dialogtitel, weil der Startbildschirm als einzige Ansicht kein zugeordnetes Kapitel hatte. Seit 0.25.0 führt er nach „So kommen Sie durch“ — dem Kapitel, das genau die Frage des Startbildschirms beantwortet. Die Zusage dahinter ist unverändert und die eigentliche: Beim Öffnen steht der Fokus **im Dialog** und auf einer Überschrift, nie im Hintergrund und nie auf `body`. Das Inhaltsverzeichnis bleibt erreichbar; es steht über den Kapiteln (siehe `Hilfedialog.tsx`). */ it('setzt den Fokus beim Öffnen auf die Überschrift des zugeordneten Kapitels', async () => { const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); const kapitel = within(hilfefenster()).getByRole('heading', { name: 'So kommen Sie durch', level: 3, }); expect(kapitel).toHaveFocus(); expect(hilfefenster().contains(document.activeElement)).toBe(true); }); it('führt den Dialogtitel weiterhin als h2', async () => { /* Der Titel bleibt die Überschrift des Dialogs, auch wenn der Fokus tiefer steht – sonst hätte der Dialog keinen zugänglichen Namen. */ const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); expect( within(hilfefenster()).getByRole('heading', { name: HILFE_TITEL, level: 2 }), ).toBeInTheDocument(); }); it('bleibt bei genau einer h1 im Dokument', async () => { /* Jede Ansicht führt genau eine h1, die sagt, wo man ist – ein E2E-Test wacht darüber. Der Dialog ist keine Ansicht; eine zweite h1 würde diese Zusage brechen. Deshalb h2, und deshalb dieser Test. */ const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); }); it('schließt über den Knopf', async () => { const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); await nutzer.click(within(hilfefenster()).getAllByRole('button', { name: 'Schließen' })[0]!); await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); it('schließt mit Esc', async () => { const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); await nutzer.keyboard('{Escape}'); await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); it('räumt seine Überschriften weg, solange es zu ist', async () => { /* Ein geschlossener Dialog ist unsichtbar, aber sein Inhalt stünde trotzdem im Baum – und liefe jedem Überschriftentest der übrigen Ansichten in die Quere. */ await anwendungOeffnen(); expect(screen.queryByRole('heading', { name: HILFE_TITEL })).not.toBeInTheDocument(); }); }); describe('Hilfe – nimmt nichts mit', () => { it('lässt die laufende Lernsitzung stehen', async () => { /* Der eigentliche Grund für einen Dialog statt einer eigenen Ansicht: Wer mitten in der Sitzung wissen will, wie das Merken geht, soll danach dieselbe Frage vorfinden – nicht den Startbildschirm. */ const nutzer = await anwendungOeffnen(); await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u })); await screen.findByText(FRAGE_TEXT); await nutzer.click(hilfeknopf()); expect(hilfefenster()).toBeInTheDocument(); await nutzer.keyboard('{Escape}'); await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); expect(screen.getByText(FRAGE_TEXT)).toBeInTheDocument(); }); }); describe('Hilfe – Menübefehl', () => { it('geht auf, wenn der Anwendungskern „hilfe“ meldet', async () => { /* Das ist der Weg, den F1 und der Menüeintrag „Handbuch“ nehmen: Die Menüleiste liegt im Hauptprozess und erreicht die Ansicht nur so. */ const kern: { melden?: (befehl: Menuebefehl) => void } = {}; await anwendungOeffnen( attrappe({ menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => { kern.melden = rueckruf; return () => undefined; }), }), ); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(kern.melden).toBeDefined(); kern.melden?.('hilfe'); expect(await screen.findByRole('dialog', { name: HILFE_TITEL })).toBeInTheDocument(); }); it('kommt auch ohne diesen Kanal zurecht', async () => { /* Der Anwendungskern entsteht parallel; fehlt der Kanal, bleibt die Hilfe über die Schaltfläche erreichbar. */ const bruecke = attrappe(); Reflect.deleteProperty(bruecke, 'menuebefehlBeobachten'); const nutzer = await anwendungOeffnen(bruecke); await nutzer.click(hilfeknopf()); expect(hilfefenster()).toBeInTheDocument(); }); }); describe('Hilfe – Barrierefreiheit', () => { it('hat im geöffneten Zustand keine axe-Verstöße', async () => { const nutzer = await anwendungOeffnen(); await nutzer.click(hilfeknopf()); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]); }); }); describe('Gehe zu – die Navigation im Anwendungsmenü', () => { /** * Öffnet die Anwendung und gibt den Draht zurück, über den der * Hauptprozess Menübefehle meldet. */ async function mitMenue(): Promise<{ melden: (befehl: Menuebefehl) => void }> { const kern: { melden?: (befehl: Menuebefehl) => void } = {}; await anwendungOeffnen( attrappe({ menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => { kern.melden = rueckruf; return () => undefined; }), }), ); if (kern.melden === undefined) { throw new Error('Der Kanal wurde nicht beobachtet.'); } return { melden: kern.melden }; } /* Bis Fassung 0.25.0 führte das Anwendungsmenü zu keiner einzigen der zehn Ansichten. Es ist die einzige Fläche des Fensters, die nicht wegrollt – und der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 hoch. Wer am Sprungmenü der Seite vorbeigerollt war, hatte für die restlichen rund 7500 Bildpunkte keinen Weg mehr in eine andere Ansicht. */ it('führt in jede der angebotenen Ansichten', async () => { const { melden } = await mitMenue(); melden('gehe-zu-suche'); expect( await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }), ).toBeInTheDocument(); melden('gehe-zu-glossar'); expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument(); melden('gehe-zu-start'); expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); }); /* Aus einer laufenden Sitzung führt kein Menüeintrag heraus. Der Grund steht in `App.tsx` an der Stelle, an der die Zurückleiste dort ausgelassen wird: „Dort ist der Ausstieg eine Entscheidung mit Folgen … und steht bei den übrigen Aktionen, nicht als beiläufiger Pfeil am Rand.“ Ein Menüeintrag ist derselbe beiläufige Weg, nur weiter oben. Ausgegraut werden die Einträge nicht – der Kanal ist einseitig, der Hauptprozess erfährt vom Zustand der Oberfläche nichts. Statt stillschweigend nichts zu tun, sagt die Anwendung, warum sie nicht folgt. */ it('verlässt eine laufende Lernsitzung nicht und sagt, warum', async () => { const { melden } = await mitMenue(); const nutzer = userEvent.setup(); const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u }); /* Der Einstieg ist nie `disabled`, sondern meldet sich über `aria-disabled` (Projektregel, docs/stand.md 7.13). */ await waitFor(() => { expect(einstieg).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(einstieg); /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift ist die Fragennummer und hängt am Katalog der Attrappe. */ await screen.findByRole('button', { name: 'Sitzung beenden' }); melden('gehe-zu-glossar'); /* Die Sitzung steht noch. */ expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument(); expect(await screen.findByText(/Beenden Sie zuerst die Lernsitzung/u)).toBeInTheDocument(); }); it('sperrt auch die Auswertung und gibt den Weg erst auf dem Startbildschirm frei', async () => { /* Zwei Zusagen in einem Lauf. Erstens: Die Auswertung ist ebenso gesperrt wie die Sitzung. Ihr Inhalt steht im Ansichtsobjekt (`bilanz` in `lernen/typen.ts`), und das Menü kennt kein Ziel, das ihn wiederherstellte — ein Sprung verwürfe die Fehlerliste ohne Rückfrage. Diese Sperre fehlte in der ersten Fassung des Menüs und war damit ein Fehler von 0.26.0. Zweitens die Gegenprobe: Die Sperre darf nicht hängenbleiben. Vom Startbildschirm aus nimmt der Weg wieder an. */ const { melden } = await mitMenue(); const nutzer = userEvent.setup(); const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u }); /* Der Einstieg ist nie `disabled`, sondern meldet sich über `aria-disabled` (Projektregel, docs/stand.md 7.13). */ await waitFor(() => { expect(einstieg).toHaveAttribute('aria-disabled', 'false'); }); await nutzer.click(einstieg); /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift ist die Fragennummer und hängt am Katalog der Attrappe. */ await screen.findByRole('button', { name: 'Sitzung beenden' }); await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' })); /* In der Auswertung: gesperrt. */ melden('gehe-zu-glossar'); expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument(); expect( await screen.findByText(/Diese Auswertung lässt sich nicht wieder aufrufen/u), ).toBeInTheDocument(); /* Auf dem Startbildschirm: wieder frei. */ await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!); await screen.findByRole('heading', { name: 'Heute lernen' }); melden('gehe-zu-glossar'); expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument(); }); });