/** * Verhaltenstests der Suchansicht. * * Der Suchkern ist in `fragensuche.test.ts` gegen den echten Katalog gemessen. * Hier geht es um das, was die Oberfläche zusagt: Fokusführung, Live-Region, * Leerzustände, den Weg vom Treffer ins Üben und zurück – und um zwei * Zusicherungen, die inhaltlich sind: **welche Antwort richtig ist, steht * nirgends im Baum**, und die Musterantwort erscheint nicht. */ 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 { suchindexZwischenspeicherLeeren } from '../src/renderer/src/hooks/useFragensuche'; import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; import type { Erklaerungen } from '../src/shared/erklaerungen'; import type { Glossar } from '../src/shared/glossar'; import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc'; import type { Frage, Katalog, RichText } from '../src/shared/katalog'; import type { Lernplan } from '../src/shared/lernplan'; import type { FrageStand, Lernuebersicht, Profil } 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; // ─── Katalogattrappe ──────────────────────────────────────────────────── function nurText(text: string): RichText { return { text, segmente: [{ t: text }] }; } const ANSCHEINSWAFFE: Frage = { id: 'I.1-01', amtliche_nummer: '1.01', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 10, frage: nurText('Welcher Gegenstand ist eine Anscheinswaffe?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Ein Sportgewehr'), korrekt: false, bilder: [] }, { label: 'b', inhalt: nurText('Eine Spielzeugpistole'), korrekt: true, bilder: [] }, ], }; /** Eine offene Frage: Ihre Musterantwort darf nirgends erscheinen. */ const NOTWEHR: Frage = { id: 'I.5-01', amtliche_nummer: '5.01', kapitel: 'I', abschnitt: 'I.5', typ: 'freitext', seite: 80, frage: nurText('Was ist Notwehr?'), bilder: [], musterantwort: nurText('Die Verteidigung gegen einen gegenwärtigen rechtswidrigen Angriff.'), }; /** Nur über die Antwortmöglichkeit zu finden – prüft die Rangklasse. */ const BUECHSE: Frage = { id: 'I.1-02', amtliche_nummer: '1.02', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 11, frage: nurText('Welche Langwaffe ist gemeint?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Eine Büchse mit gezogenem Lauf'), korrekt: true, bilder: [] }, { label: 'b', inhalt: nurText('Eine Flinte'), korrekt: false, bilder: [] }, ], }; /** Dreißig Füllfragen, damit die Portionierung greift (Schritt 25). */ const FUELLER: Frage[] = Array.from({ length: 30 }, (_, i) => ({ id: `II-${String(i + 1).padStart(2, '0')}`, amtliche_nummer: String(i + 1).padStart(2, '0'), kapitel: 'II', abschnitt: null, typ: 'mc' as const, seite: 100 + i, frage: nurText(`Technikfrage Nummer ${String(i + 1)} zur Schusswaffe`), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Erste Möglichkeit'), korrekt: true, bilder: [] }, { label: 'b', inhalt: nurText('Zweite Möglichkeit'), korrekt: false, bilder: [] }, ], })); /** Traegt die Katalogwoerter, auf die die Begriffsbruecke bei „Tresor" zeigt. */ const AUFBEWAHRUNG: Frage = { id: 'I.1-03', amtliche_nummer: '1.03', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 12, frage: nurText('Wie ist die Aufbewahrung geregelt?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('In einem Sicherheitsbehältnis'), korrekt: true, bilder: [] }, { label: 'b', inhalt: nurText('Frei zugänglich'), 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: 34, }, kapitel: [ { id: 'I', titel: 'Waffenrecht', abschnitte: [ { id: 'I.1', titel: 'Begriffe' }, { id: 'I.5', titel: 'Notwehr und Notstand' }, ], }, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, ], bilder: [], fragen: [ANSCHEINSWAFFE, BUECHSE, AUFBEWAHRUNG, NOTWEHR, ...FUELLER], }; // ─── Übrige Attrappen ─────────────────────────────────────────────────── const PROFIL: Profil = { id: 1, name: 'Testprofil', pruefungstermin: null, kapitelAusschluss: [], erstelltAm: '2026-01-01T08:00:00.000Z', }; const UEBERSICHT: Lernuebersicht = { fragenGesamt: 34, beantwortet: 0, belegt: 0, reifegrad: 0 / 34, stufe: 'ohne_beleg', deckelnd: [], faellig: 0, gemerkt: 0, offen: 0, fehler: 0, heuteRichtig: 0, heuteFalsch: 0, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, bereiche: [], themengruppen: [], }; const STAND: FrageStand = { frageId: 'I.1-01', 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 bereit.' }, }; const ERGEBNIS: Pruefungsergebnis = { profilId: 'standard', gesamt: 0, richtig: 0, falsch: 0, unbeantwortet: 0, quote: 0, urteil: 'nicht_bestanden', begruendung: '0 Prozent richtig.', verletzteKriterien: [], bereiche: [], fehlerIds: [], dauerMs: 0, zeitAbgelaufen: false, zeitpunkt: '2026-01-01T08:00:00.000Z', }; const LERNPLAN: Lernplan = { termin: null, tageBisTermin: null, gesamtFragen: 34, nieBeantwortet: 34, 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: {}, hinweis: 'Eigener Inhalt.' }, zuFrage: {}, }; const GLOSSAR: Glossar = { meta: { version: 1, stand: '2026-08-21', gesetzesstand: {}, hinweis: 'Eigener Inhalt.' }, eintraege: [], }; 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])), /* Wird von der Suche nie gebraucht: Eine Sitzung mit fester Fragenliste entsteht aus dem geladenen Katalog. Die Attrappe bedient nur den Weg über „Weiterlernen“. */ lernSitzung: vi.fn(() => Promise.resolve([{ frageId: 'I.1-01', 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:/x.pdf', bytes: 1 }), ), /* 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: 'EUPL 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 sucheOeffnen(bruecke: LernAppBridge = attrappe()): Promise { vi.stubGlobal('lernApp', bruecke); const nutzer = userEvent.setup(); render(); await nutzer.click(await screen.findByRole('button', { name: 'Fragen durchsuchen' })); await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); return nutzer; } function feld(): HTMLElement { return screen.getByLabelText('Suchbegriff'); } function trefferliste(): HTMLElement[] { return screen.queryAllByRole('heading', { level: 2 }).filter((h) => h.tabIndex === -1); } afterEach(() => { vi.unstubAllGlobals(); erklaerungenZwischenspeicherLeeren(); glossarZwischenspeicherLeeren(); normtexteZwischenspeicherLeeren(); themenZwischenspeicherLeeren(); suchindexZwischenspeicherLeeren(); }); // ─── Tests ────────────────────────────────────────────────────────────── describe('Suche – Zugang und Aufbau', () => { it('ist vom Startbildschirm aus erreichbar und setzt den Fokus auf die Überschrift', async () => { await sucheOeffnen(); expect(screen.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 })).toHaveFocus(); }); it('führt genau eine h1', async () => { await sucheOeffnen(); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); }); it('bietet genau zwei Bedienelemente vor der Liste: Feld und Bereich', async () => { await sucheOeffnen(); const bereich = screen.getByRole('group', { name: 'Bereich' }); expect(feld()).toBeInTheDocument(); expect(within(bereich).getAllByRole('radio').length).toBeGreaterThan(1); // Keine Kästchen für „wo gesucht wird“ – das wären Tokenizer-Fragen. expect(screen.queryAllByRole('checkbox')).toEqual([]); }); it('nennt die Fragenzahl in jeder Bereichsbeschriftung', async () => { await sucheOeffnen(); expect(screen.getByRole('radio', { name: 'Alle Bereiche (34)' })).toBeInTheDocument(); expect(screen.getByRole('radio', { name: /I\.5 – Notwehr und Notstand \(1\)/u })).toBeVisible(); }); }); describe('Suche – der Fragen-Browser', () => { it('zeigt bei leerem Feld alle Fragen des Bereichs', async () => { await sucheOeffnen(); // 34 Fragen, portioniert auf 25. expect(trefferliste()).toHaveLength(25); }); it('kürzt auf den gewählten Bereich', async () => { const nutzer = await sucheOeffnen(); await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u })); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); expect(screen.getByRole('heading', { name: /Was ist Notwehr/u })).toBeInTheDocument(); }); }); describe('Suche – Finden', () => { it('filtert während des Tippens', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'anscheinswaffe'); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); }); it('findet über Umlaute und ß hinweg', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'buechse'); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); expect(screen.getByRole('heading', { name: /Welche Langwaffe/u })).toBeInTheDocument(); }); it('sagt an einem Antworttreffer, in welcher Möglichkeit er sitzt', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'buechse'); expect(await screen.findByText('Treffer in Antwortmöglichkeit a.')).toBeInTheDocument(); }); it('meldet die Trefferzahl in einer Live-Region – als ganzen Satz', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'anscheinswaffe'); /* Die Region spricht erst nach einer Ruhepause: Eine höfliche Region, die bei jedem Anschlag eine neue Zahl nennt, ist genau das, wovor der Prüfplan zu 4.1.3 warnt. */ const region = await screen.findByText('Eine Frage gefunden.'); expect(region).toHaveAttribute('role', 'status'); }); it('findet eine Frage über ihre amtliche Nummer', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), '5.01'); expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument(); expect(screen.getByText('Über die Fragennummer gefunden.')).toBeInTheDocument(); }); }); describe('Suche – Leerzustände sind Wege, keine Sackgassen', () => { it('bittet bei einem Zeichen um ein weiteres, statt „keine Treffer“ zu melden', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'a'); expect(await screen.findByText('Bitte noch ein Zeichen eingeben.')).toBeInTheDocument(); }); it('benennt das Wort, das im Katalog nicht vorkommt', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'zwergpudel'); expect(await screen.findByText(/„zwergpudel“ kommt im Katalog nicht vor/u)).toBeInTheDocument(); }); it('rechnet vor, was ohne die Bereichseinschränkung zu finden wäre', async () => { const nutzer = await sucheOeffnen(); await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u })); await nutzer.type(feld(), 'anscheinswaffe'); expect( await screen.findByText(/Ohne die Einschränkung.*gäbe es 1 Treffer/u), ).toBeInTheDocument(); await nutzer.click(screen.getByRole('button', { name: 'Einschränkung aufheben' })); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); }); }); describe('Suche – Portionierung', () => { it('lädt auf Knopfdruck nach und setzt den Fokus auf den ersten neuen Treffer', async () => { const nutzer = await sucheOeffnen(); expect(trefferliste()).toHaveLength(25); await nutzer.click(screen.getByRole('button', { name: /Weitere 9 anzeigen \(25 von 34\)/u })); await waitFor(() => { expect(trefferliste()).toHaveLength(34); }); /* Der Fokus geht auf den ersten neu erschienenen Treffer, nicht auf den Knopf: Die neuen Karten stehen im Baum VOR ihm und würden beim Weitertabben sonst vollständig übersprungen. */ expect(trefferliste()[25]).toHaveFocus(); }); }); describe('Suche – vom Treffer ins Üben und zurück', () => { it('startet eine Sitzung mit genau dieser Frage', async () => { /* Eine Sitzung mit fester Fragenliste wird aus dem bereits geladenen Katalog gebaut, ohne den Kanal `lernen:sitzung` – geprüft wird deshalb das Ergebnis und nicht der Aufruf. */ const bruecke = attrappe(); const nutzer = await sucheOeffnen(bruecke); await nutzer.type(feld(), 'anscheinswaffe'); await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' })); expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible(); expect(screen.getByText(/Frage 1 von 1/u)).toBeInTheDocument(); expect(screen.queryByText(/Welche Langwaffe/u)).not.toBeInTheDocument(); expect(bruecke.lernSitzung).not.toHaveBeenCalled(); }); it('führt aus der Auswertung zurück in dieselbe Trefferliste', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'anscheinswaffe'); await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' })); await nutzer.click(await screen.findByRole('button', { name: 'Sitzung beenden' })); const zurueck = await screen.findByRole('button', { name: 'Zurück zur Suche' }); await nutzer.click(zurueck); await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); expect(feld()).toHaveValue('anscheinswaffe'); }); it('deckelt „alle Treffer üben“ auf den Sitzungsumfang', async () => { await sucheOeffnen(); /* Beim Stöbern hat niemand gesucht – dort sind es Fragen, keine Treffer. 34 im Bestand, Sitzungsumfang 20. */ expect( screen.getByRole('button', { name: 'Die ersten 20 von 34 Fragen üben' }), ).toBeInTheDocument(); }); it('spricht bei genau einem Treffer nicht von „Alle 1 Fragen“', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'anscheinswaffe'); expect(await screen.findByRole('button', { name: 'Diese eine Frage üben' })).toBeVisible(); }); }); describe('Suche – verrät nichts', () => { it('zeigt nirgends, welche Antwort richtig ist', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'anscheinswaffe'); await nutzer.click(await screen.findByText(/Antwortmöglichkeiten anzeigen/u)); const inhalt = document.body.textContent; expect(inhalt).toContain('Eine Spielzeugpistole'); // Nichts, was die Lösung ausweist – weder Wort noch Klasse noch Attribut. expect(inhalt).not.toMatch(/richtig|korrekt/iu); expect(document.querySelector('[data-korrekt]')).toBeNull(); }); it('zeigt die Musterantwort einer offenen Frage nicht', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'notwehr'); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); expect(screen.queryByText(/Verteidigung gegen einen gegenwärtigen/u)).not.toBeInTheDocument(); }); it('sagt bei einem Musterantworttreffer, dass sie nicht gezeigt wird', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'rechtswidrigen'); expect( await screen.findByText('Treffer nur in der Musterantwort – sie wird hier nicht gezeigt.'), ).toBeInTheDocument(); }); }); describe('Suche – Barrierefreiheit', () => { it('hat keine axe-Verstöße, auch mit Trefferliste', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'schusswaffe'); await waitFor(() => { expect(trefferliste().length).toBeGreaterThan(0); }); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((v) => v.id)).toEqual([]); }); it('markiert die Fundstelle und benennt sie zusätzlich im Klartext', async () => { /* Die Markierung allein wäre eine Aussage über Farbe (WCAG 1.4.1). Sie wegzulassen hülfe Blinden und verteuerte es für Sehbehinderte. */ const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'anscheinswaffe'); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); const marken = document.querySelectorAll('mark.fundstelle'); expect(marken.length).toBeGreaterThan(0); expect(marken[0]?.textContent.toLowerCase()).toContain('anscheinswaffe'); expect(screen.getByText('Treffer im Fragetext.')).toBeInTheDocument(); }); }); describe('Suche – was die Nachprüfung gefunden hat', () => { /* Jeder Test hier steht für einen bestätigten Befund aus der adversarischen Nachprüfung vor der Veröffentlichung von 0.10.0. Sie stehen zusammen, weil sie eines gemeinsam haben: Die Ansicht sagte etwas anderes, als sie tat – und jeder Lauf war grün dabei. */ it('meldet bei einem Nummerntreffer nicht zugleich „Keine Frage gefunden“', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), '1.01'); expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument(); expect(screen.queryByText('Keine Frage gefunden.')).not.toBeInTheDocument(); // Auch die Live-Region darf das nicht sagen. expect(await screen.findByText('Eine Frage über die Nummer gefunden.')).toBeInTheDocument(); }); it('fordert bei einer einstelligen Fragennummer kein weiteres Zeichen', async () => { /* Eingabe „5“: Die Ansicht zeigte drei Fragen und sagte gleichzeitig „Bitte noch ein Zeichen eingeben.“ – wer hört statt sieht, hielt die Suche für nicht ausgeführt. */ const nutzer = await sucheOeffnen(); await nutzer.type(feld(), '5'); await waitFor(() => { expect(screen.getByText('Über die Fragennummer gefunden:')).toBeInTheDocument(); }); expect(screen.queryByText('Bitte noch ein Zeichen eingeben.')).not.toBeInTheDocument(); }); it('findet auch mit Satzzeichen am Wortrand', async () => { /* „Notwehr!“ fand null Fragen, „Notwehr“ eine – das Satzzeichen wurde stillschweigend mitgesucht. Besonders ärgerlich bei den typografischen Anführungszeichen, die die Anwendung selbst in ihren Hinweisen setzt. */ const nutzer = await sucheOeffnen(); for (const eingabe of ['Notwehr!', '(Notwehr', '„Notwehr“', 'Notwehr;']) { await nutzer.clear(feld()); await nutzer.type(feld(), eingabe); await waitFor(() => { expect(trefferliste(), `„${eingabe}“ findet nichts`).toHaveLength(1); }); } }); it('hält dem Nutzer seine eigene Schreibweise vor, nicht die Kanonform', async () => { const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'Schießstättenübung'); expect( await screen.findByText(/„Schießstättenübung“ kommt im Katalog nicht vor/u), ).toBeInTheDocument(); }); it('verspricht bei der Begriffsbrücke nur, was ein Klick auch liefert', async () => { /* Der schwerste Befund: Der Knopf nannte die Vereinigungsmenge über alle Katalogwörter und setzte beim Klick nur das erste ein. Jetzt trägt jeder Vorschlag seine eigene Zahl – und die muss stimmen. */ const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'Tresor'); const vorschlag = await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u }); const versprochen = Number(/\((\d+)\)/u.exec(vorschlag.textContent)?.[1] ?? '0'); expect(versprochen).toBeGreaterThan(0); await nutzer.click(vorschlag); await waitFor(() => { expect(trefferliste()).toHaveLength(versprochen); }); }); it('lässt den Fokus nach einem Angebotsklick nicht auf fallen', async () => { /* Der Kasten mit dem Knopf verschwindet im selben Render – ohne Fokusführung stünde man danach am Dokumentanfang. */ const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'Tresor'); await nutzer.click(await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u })); await waitFor(() => { expect(feld()).toHaveFocus(); }); expect(document.activeElement).not.toBe(document.body); }); it('sperrt den Weg in die Suche, solange der Katalog fehlt', async () => { /* Sonst führte der Knopf in eine Ansicht, die App.tsx mangels Katalogindex gar nicht rendert: leerer Hauptbereich, keine Überschrift, Fokus auf . */ vi.stubGlobal( 'lernApp', attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }), ); render(); const knopf = await screen.findByRole('button', { name: 'Fragen durchsuchen' }); /* Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein natives `disabled`, dafür `aria-disabled` und der Grund als Text am Knopf. Ein `disabled` Knopf nähme den Fokus mit auf ``. */ await waitFor(() => { expect(knopf).toHaveAttribute('aria-disabled', 'true'); }); expect(knopf).toBeEnabled(); expect(knopf).toHaveAccessibleDescription(/lässt sich nicht darin suchen/u); }); }); describe('Suche – was den Rückweg überlebt', () => { /** * Übt die erste Frage der Trefferliste und beendet die Sitzung. * * Der Rückweg selbst bleibt dem Test überlassen: „Zurück zur Suche“ und * „Zum Start“ stehen am Sitzungsende nebeneinander, und der Unterschied * zwischen beiden ist genau das, was hier geprüft wird. */ async function uebenUndBeenden(nutzer: Nutzer): Promise { await nutzer.click(screen.getAllByRole('button', { name: /^Frage .+ üben$/u })[0]!); await screen.findByRole('button', { name: 'Sitzung beenden' }); await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' })); await screen.findByRole('button', { name: 'Zurück zur Suche' }); } /* Bis Fassung 0.26.0 kam nur der Suchtext zurück. Wer auf einen Bereich eingeschränkt hatte, bekam danach die Liste über dem ganzen Katalog – „dieselbe Trefferliste“, wie der Knopf und das Handbuch sie versprechen, war sie damit nicht. Text und Bereich reisen deshalb als Paar mit dem Sitzungsauftrag (`Suchanfrage` in `lernen/typen.ts`) und nicht als Merkwert daneben. Sie leben genau so lange wie der Vorgang, aus dem sie stammen. */ it('trägt die Bereichseinschränkung über den Rückweg aus einer Sitzung', async () => { const nutzer = await sucheOeffnen(); await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); await nutzer.type(feld(), 'anscheinswaffe'); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); await uebenUndBeenden(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' })); await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); expect(feld()).toHaveValue('anscheinswaffe'); expect(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })).toBeChecked(); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); }); /* Die Wache über die Verfallsregel, und sie ist die wichtigere von beiden: Ohne sie wäre der Kern des Entwurfs unbewacht. Die Anfrage soll den Vorgang NICHT überleben – wer über den Startbildschirm geht, beginnt von vorn. Ein Suchbegriff, der Stunden später wieder dasteht, wäre genau der stille Zustand, den niemand erklären kann. */ it('vergisst die Anfrage auf dem Weg über den Startbildschirm', async () => { const nutzer = await sucheOeffnen(); await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); await nutzer.type(feld(), 'anscheinswaffe'); await waitFor(() => { expect(trefferliste()).toHaveLength(1); }); await uebenUndBeenden(nutzer); await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!); await screen.findByRole('heading', { name: 'Heute lernen' }); await nutzer.click(screen.getByRole('button', { name: 'Fragen durchsuchen' })); await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); expect(feld()).toHaveValue(''); expect(screen.getByRole('radio', { name: /^Alle Bereiche/u })).toBeChecked(); }); it('nennt den Bereich in der Ansage, auch wenn es Treffer gibt', async () => { /* Bis 0.26.0 hieß es schlicht „Eine Frage gefunden.“ – wer eingeschränkt hatte, erfuhr an dieser Stelle nicht, worauf sich die Zahl bezieht. Seit der Rückweg die Einschränkung mitbringt, wiegt das schwerer: Sie kann gesetzt sein, ohne dass man sie selbst gerade gesetzt hat. */ const nutzer = await sucheOeffnen(); await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); await nutzer.type(feld(), 'anscheinswaffe'); expect(await screen.findByText('Eine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument(); }); it('nennt die Einschränkung auch dann, wenn das Wort im ganzen Katalog fehlt', async () => { const nutzer = await sucheOeffnen(); await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); await nutzer.type(feld(), 'zwergpudel'); expect(await screen.findByText('Keine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument(); }); it('bringt die Zahl der nachgeladenen Treffer nicht mit', async () => { /* Sie ist kein gesetzter Wert, sondern eine Rolltiefe in anderer Schreibweise: Die Anwendung nennt sie an keiner Stelle, die beim Betreten im Bild steht – nur in der Knopfbeschriftung hinter allen Karten. Dieselbe Art Wert, die `App.tsx` für die Bildlaufstelle bereits verworfen hat. */ const nutzer = await sucheOeffnen(); await waitFor(() => { expect(trefferliste()).toHaveLength(25); }); await nutzer.click(screen.getByRole('button', { name: /^Weitere \d+ anzeigen \(/u })); await waitFor(() => { expect(trefferliste().length).toBeGreaterThan(25); }); await uebenUndBeenden(nutzer); await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' })); await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); await waitFor(() => { expect(trefferliste()).toHaveLength(25); }); }); }); describe('Suche – die Einstellung „Antwortmöglichkeiten mischen“ gilt auch hier', () => { /* Befund der Prüfrunde zu 0.27.2. `sitzungStarten` in `App.tsx` sagt zu, die Einstellung „hier anzuhängen deckt alle sechs Wege mit fester Fragenliste auf einmal“. Sechs gibt es, aber nur fünf gehen durch `sitzungStarten`: Der Weg aus der Trefferliste baut den Auftrag selbst und ließ `optionenMischen` weg. Wer den Schalter umlegt und aus der Suche heraus übt, bekam die Katalogreihenfolge. Gemessen mit einer festen Zufallsquelle: `mischen` sortiert nach `Math.random()`; eine absteigende Folge kehrt die Reihenfolge um. Ohne die Behebung steht „a)“ vorn, mit ihr „b)“. */ function zufallAbsteigend(): void { let naechster = 1; vi.spyOn(Math, 'random').mockImplementation(() => { naechster -= 0.1; return naechster; }); } function markenReihenfolge(): string[] { return [...document.querySelectorAll('.antwortoption__marke')].map( (knoten) => knoten.textContent, ); } const mitMischen = (): LernAppBridge => attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, optionenMischen: true, }), ), }); it('mischt die Antworten, wenn der Schalter an ist', async () => { zufallAbsteigend(); const nutzer = await sucheOeffnen(mitMischen()); await nutzer.type(feld(), 'anscheinswaffe'); await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' })); expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible(); expect(markenReihenfolge()).toEqual(['b)', 'a)']); }); it('lässt die Katalogreihenfolge stehen, wenn er aus ist', async () => { /* Die Gegenprobe: Der Weg darf nicht dadurch geheilt werden, dass er immer mischt. Ab Werk steht der Schalter aus. */ zufallAbsteigend(); const nutzer = await sucheOeffnen(); await nutzer.type(feld(), 'anscheinswaffe'); await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' })); expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible(); expect(markenReihenfolge()).toEqual(['a)', 'b)']); }); }); describe('Suche – „alle Treffer üben“ folgt dem eingestellten Sitzungsumfang', () => { /* Befund der Prüfrunde zu 0.27.2. Seit 0.26.6 ist der Sitzungsumfang einstellbar; `Suchansicht` rechnete weiter mit der Konstanten `SITZUNGSUMFANG`. Wer fünfzig eingestellt hatte, bekam aus der Trefferliste zwanzig — und der Knopf sagte es auch so, ohne dass sich das aus der Einstellung erklären ließ. */ function mitUmfang(umfang: number): LernAppBridge { return attrappe({ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, sitzungsumfang: umfang, }), ), }); } it('deckelt auf die eingestellte Zahl', async () => { await sucheOeffnen(mitUmfang(5)); expect( await screen.findByRole('button', { name: 'Die ersten 5 von 34 Fragen üben' }), ).toBeVisible(); }); it('legt dann auch wirklich so viele vor', async () => { const nutzer = await sucheOeffnen(mitUmfang(5)); await nutzer.click(await screen.findByRole('button', { name: /Die ersten 5 von 34/u })); expect(await screen.findByText(/Frage 1 von 5/u)).toBeInTheDocument(); }); it('bleibt ab Werk bei zwanzig', async () => { /* Die Gegenprobe: Die Vorgabe darf sich dabei nicht verschieben. */ await sucheOeffnen(); expect( await screen.findByRole('button', { name: 'Die ersten 20 von 34 Fragen üben' }), ).toBeVisible(); }); });