waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests renderer-suche.test.tsx
| 1 | /** |
| 2 | * Verhaltenstests der Suchansicht. |
| 3 | * |
| 4 | * Der Suchkern ist in `fragensuche.test.ts` gegen den echten Katalog gemessen. |
| 5 | * Hier geht es um das, was die Oberfläche zusagt: Fokusführung, Live-Region, |
| 6 | * Leerzustände, den Weg vom Treffer ins Üben und zurück – und um zwei |
| 7 | * Zusicherungen, die inhaltlich sind: **welche Antwort richtig ist, steht |
| 8 | * nirgends im Baum**, und die Musterantwort erscheint nicht. |
| 9 | */ |
| 10 | |
| 11 | import { render, screen, waitFor, within } from '@testing-library/react'; |
| 12 | import userEvent from '@testing-library/user-event'; |
| 13 | import axe from 'axe-core'; |
| 14 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 15 | |
| 16 | import { App } from '../src/renderer/src/App'; |
| 17 | import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; |
| 18 | import { suchindexZwischenspeicherLeeren } from '../src/renderer/src/hooks/useFragensuche'; |
| 19 | import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar'; |
| 20 | import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; |
| 21 | import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen'; |
| 22 | import type { Erklaerungen } from '../src/shared/erklaerungen'; |
| 23 | import type { Glossar } from '../src/shared/glossar'; |
| 24 | import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc'; |
| 25 | import type { Frage, Katalog, RichText } from '../src/shared/katalog'; |
| 26 | import type { Lernplan } from '../src/shared/lernplan'; |
| 27 | import type { FrageStand, Lernuebersicht, Profil } from '../src/shared/lernstand'; |
| 28 | import { EUPL_HINWEIS, EUPL_KENNUNG, KATALOG_HINWEIS } from '../src/shared/lizenzen'; |
| 29 | import type { Pruefungsergebnis } from '../src/shared/pruefung'; |
| 30 | import { jsdomOptionen } from '../src/shared/wcag'; |
| 31 | import { NORMTEXTE_LEER } from '../src/shared/normtexte'; |
| 32 | import { THEMEN_LEER } from '../src/shared/themen'; |
| 33 | |
| 34 | type Nutzer = ReturnType<typeof userEvent.setup>; |
| 35 | |
| 36 | // ─── Katalogattrappe ──────────────────────────────────────────────────── |
| 37 | |
| 38 | function nurText(text: string): RichText { |
| 39 | return { text, segmente: [{ t: text }] }; |
| 40 | } |
| 41 | |
| 42 | const ANSCHEINSWAFFE: Frage = { |
| 43 | id: 'I.1-01', |
| 44 | amtliche_nummer: '1.01', |
| 45 | kapitel: 'I', |
| 46 | abschnitt: 'I.1', |
| 47 | typ: 'mc', |
| 48 | seite: 10, |
| 49 | frage: nurText('Welcher Gegenstand ist eine Anscheinswaffe?'), |
| 50 | bilder: [], |
| 51 | optionen: [ |
| 52 | { label: 'a', inhalt: nurText('Ein Sportgewehr'), korrekt: false, bilder: [] }, |
| 53 | { label: 'b', inhalt: nurText('Eine Spielzeugpistole'), korrekt: true, bilder: [] }, |
| 54 | ], |
| 55 | }; |
| 56 | |
| 57 | /** Eine offene Frage: Ihre Musterantwort darf nirgends erscheinen. */ |
| 58 | const NOTWEHR: Frage = { |
| 59 | id: 'I.5-01', |
| 60 | amtliche_nummer: '5.01', |
| 61 | kapitel: 'I', |
| 62 | abschnitt: 'I.5', |
| 63 | typ: 'freitext', |
| 64 | seite: 80, |
| 65 | frage: nurText('Was ist Notwehr?'), |
| 66 | bilder: [], |
| 67 | musterantwort: nurText('Die Verteidigung gegen einen gegenwärtigen rechtswidrigen Angriff.'), |
| 68 | }; |
| 69 | |
| 70 | /** Nur über die Antwortmöglichkeit zu finden – prüft die Rangklasse. */ |
| 71 | const BUECHSE: Frage = { |
| 72 | id: 'I.1-02', |
| 73 | amtliche_nummer: '1.02', |
| 74 | kapitel: 'I', |
| 75 | abschnitt: 'I.1', |
| 76 | typ: 'mc', |
| 77 | seite: 11, |
| 78 | frage: nurText('Welche Langwaffe ist gemeint?'), |
| 79 | bilder: [], |
| 80 | optionen: [ |
| 81 | { label: 'a', inhalt: nurText('Eine Büchse mit gezogenem Lauf'), korrekt: true, bilder: [] }, |
| 82 | { label: 'b', inhalt: nurText('Eine Flinte'), korrekt: false, bilder: [] }, |
| 83 | ], |
| 84 | }; |
| 85 | |
| 86 | /** Dreißig Füllfragen, damit die Portionierung greift (Schritt 25). */ |
| 87 | const FUELLER: Frage[] = Array.from({ length: 30 }, (_, i) => ({ |
| 88 | id: `II-${String(i + 1).padStart(2, '0')}`, |
| 89 | amtliche_nummer: String(i + 1).padStart(2, '0'), |
| 90 | kapitel: 'II', |
| 91 | abschnitt: null, |
| 92 | typ: 'mc' as const, |
| 93 | seite: 100 + i, |
| 94 | frage: nurText(`Technikfrage Nummer ${String(i + 1)} zur Schusswaffe`), |
| 95 | bilder: [], |
| 96 | optionen: [ |
| 97 | { label: 'a', inhalt: nurText('Erste Möglichkeit'), korrekt: true, bilder: [] }, |
| 98 | { label: 'b', inhalt: nurText('Zweite Möglichkeit'), korrekt: false, bilder: [] }, |
| 99 | ], |
| 100 | })); |
| 101 | |
| 102 | /** Traegt die Katalogwoerter, auf die die Begriffsbruecke bei „Tresor" zeigt. */ |
| 103 | const AUFBEWAHRUNG: Frage = { |
| 104 | id: 'I.1-03', |
| 105 | amtliche_nummer: '1.03', |
| 106 | kapitel: 'I', |
| 107 | abschnitt: 'I.1', |
| 108 | typ: 'mc', |
| 109 | seite: 12, |
| 110 | frage: nurText('Wie ist die Aufbewahrung geregelt?'), |
| 111 | bilder: [], |
| 112 | optionen: [ |
| 113 | { label: 'a', inhalt: nurText('In einem Sicherheitsbehältnis'), korrekt: true, bilder: [] }, |
| 114 | { label: 'b', inhalt: nurText('Frei zugänglich'), korrekt: false, bilder: [] }, |
| 115 | ], |
| 116 | }; |
| 117 | |
| 118 | const KATALOG: Katalog = { |
| 119 | meta: { |
| 120 | titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', |
| 121 | herausgeber: 'Bundesverwaltungsamt', |
| 122 | stand: '2024-12-16', |
| 123 | quellenangabe: 'Amtlicher Fragenkatalog des Bundesverwaltungsamtes, Stand 16.12.2024.', |
| 124 | quelle_url: 'https://www.bva.bund.de/', |
| 125 | quelldatei_sha256: '7e5e41fa', |
| 126 | fragen_gesamt: 34, |
| 127 | }, |
| 128 | kapitel: [ |
| 129 | { |
| 130 | id: 'I', |
| 131 | titel: 'Waffenrecht', |
| 132 | abschnitte: [ |
| 133 | { id: 'I.1', titel: 'Begriffe' }, |
| 134 | { id: 'I.5', titel: 'Notwehr und Notstand' }, |
| 135 | ], |
| 136 | }, |
| 137 | { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, |
| 138 | ], |
| 139 | bilder: [], |
| 140 | fragen: [ANSCHEINSWAFFE, BUECHSE, AUFBEWAHRUNG, NOTWEHR, ...FUELLER], |
| 141 | }; |
| 142 | |
| 143 | // ─── Übrige Attrappen ─────────────────────────────────────────────────── |
| 144 | |
| 145 | const PROFIL: Profil = { |
| 146 | id: 1, |
| 147 | name: 'Testprofil', |
| 148 | pruefungstermin: null, |
| 149 | kapitelAusschluss: [], |
| 150 | erstelltAm: '2026-01-01T08:00:00.000Z', |
| 151 | }; |
| 152 | |
| 153 | const UEBERSICHT: Lernuebersicht = { |
| 154 | fragenGesamt: 34, |
| 155 | beantwortet: 0, |
| 156 | belegt: 0, |
| 157 | reifegrad: 0 / 34, |
| 158 | stufe: 'ohne_beleg', |
| 159 | deckelnd: [], |
| 160 | faellig: 0, |
| 161 | gemerkt: 0, |
| 162 | offen: 0, |
| 163 | fehler: 0, |
| 164 | heuteRichtig: 0, |
| 165 | heuteFalsch: 0, |
| 166 | heuteBearbeitet: 0, |
| 167 | tageSeitLetzterAntwort: null, |
| 168 | bereiche: [], |
| 169 | }; |
| 170 | |
| 171 | const STAND: FrageStand = { |
| 172 | frageId: 'I.1-01', |
| 173 | versuche: 0, |
| 174 | richtige: 0, |
| 175 | zuletztBeantwortet: null, |
| 176 | faelligAb: null, |
| 177 | gemerkt: false, |
| 178 | letzteBewertung: null, |
| 179 | }; |
| 180 | |
| 181 | const INFO: AnwendungsInfo = { |
| 182 | anwendungsVersion: '0.0.0-attrappe', |
| 183 | baukennung: 'abc1234', |
| 184 | baustand: '2026-08-22', |
| 185 | electronVersion: '43.4.1', |
| 186 | chromeVersion: '140.0.0.0', |
| 187 | nodeVersion: '22.19.1', |
| 188 | plattform: 'win32', |
| 189 | datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite bereit.' }, |
| 190 | }; |
| 191 | |
| 192 | const ERGEBNIS: Pruefungsergebnis = { |
| 193 | profilId: 'standard', |
| 194 | gesamt: 0, |
| 195 | richtig: 0, |
| 196 | falsch: 0, |
| 197 | unbeantwortet: 0, |
| 198 | quote: 0, |
| 199 | urteil: 'nicht_bestanden', |
| 200 | begruendung: '0 Prozent richtig.', |
| 201 | verletzteKriterien: [], |
| 202 | bereiche: [], |
| 203 | fehlerIds: [], |
| 204 | dauerMs: 0, |
| 205 | zeitAbgelaufen: false, |
| 206 | zeitpunkt: '2026-01-01T08:00:00.000Z', |
| 207 | }; |
| 208 | |
| 209 | const LERNPLAN: Lernplan = { |
| 210 | termin: null, |
| 211 | tageBisTermin: null, |
| 212 | gesamtFragen: 34, |
| 213 | nieBeantwortet: 34, |
| 214 | faellig: 0, |
| 215 | zielquote: 0.9, |
| 216 | prognoseHeute: 0, |
| 217 | prognoseAmTermin: null, |
| 218 | pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 }, |
| 219 | machbarkeit: 'kein_termin', |
| 220 | sekundenProFrage: 25, |
| 221 | }; |
| 222 | |
| 223 | const ERKLAERUNGEN: Erklaerungen = { |
| 224 | meta: { version: 1, stand: '2026-08-20', gesetzesstand: {}, hinweis: 'Eigener Inhalt.' }, |
| 225 | zuFrage: {}, |
| 226 | }; |
| 227 | |
| 228 | const GLOSSAR: Glossar = { |
| 229 | meta: { version: 1, stand: '2026-08-21', gesetzesstand: {}, hinweis: 'Eigener Inhalt.' }, |
| 230 | eintraege: [], |
| 231 | }; |
| 232 | |
| 233 | function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge { |
| 234 | return { |
| 235 | anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), |
| 236 | anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)), |
| 237 | anzeigegroesseBeobachten: vi.fn(() => () => undefined), |
| 238 | hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)), |
| 239 | hilfsmittelBeobachten: vi.fn(() => () => undefined), |
| 240 | menuebefehlBeobachten: vi.fn(() => () => undefined), |
| 241 | /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: |
| 242 | Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ |
| 243 | einstellungenLesen: vi.fn(() => |
| 244 | Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), |
| 245 | ), |
| 246 | einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)), |
| 247 | katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), |
| 248 | katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')), |
| 249 | erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)), |
| 250 | glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)), |
| 251 | /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0. |
| 252 | Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext; |
| 253 | den prueft tests/normstelle.test.tsx an eigenen Faellen. */ |
| 254 | normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)), |
| 255 | themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)), |
| 256 | profilListe: vi.fn(() => Promise.resolve([PROFIL])), |
| 257 | profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)), |
| 258 | profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)), |
| 259 | profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])), |
| 260 | /* Wird von der Suche nie gebraucht: Eine Sitzung mit fester Fragenliste |
| 261 | entsteht aus dem geladenen Katalog. Die Attrappe bedient nur den Weg |
| 262 | über „Weiterlernen“. */ |
| 263 | lernSitzung: vi.fn(() => |
| 264 | Promise.resolve([{ frageId: 'I.1-01', optionsReihenfolge: ['a', 'b'], gemerkt: false }]), |
| 265 | ), |
| 266 | lernAntworten: vi.fn(() => Promise.resolve(STAND)), |
| 267 | lernMerken: vi.fn(() => Promise.resolve(STAND)), |
| 268 | lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 269 | lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), |
| 270 | lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), |
| 271 | pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })), |
| 272 | pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)), |
| 273 | pruefungVerlauf: vi.fn(() => Promise.resolve([])), |
| 274 | pruefungSichern: vi.fn(() => Promise.resolve(true)), |
| 275 | pruefungOffen: vi.fn(() => Promise.resolve(null)), |
| 276 | pruefungVerwerfen: vi.fn(() => Promise.resolve()), |
| 277 | pruefungLaeuft: vi.fn(() => Promise.resolve()), |
| 278 | sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })), |
| 279 | fehlerprotokollDrucken: vi.fn(() => |
| 280 | Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }), |
| 281 | ), |
| 282 | fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })), |
| 283 | lernberichtDrucken: vi.fn(() => |
| 284 | Promise.resolve({ gespeichert: true, pfad: 'C:/x.pdf', bytes: 1 }), |
| 285 | ), |
| 286 | /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt, |
| 287 | dass die Brücke den Kanal kennt. */ |
| 288 | datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })), |
| 289 | lizenzenLesen: vi.fn(() => |
| 290 | Promise.resolve({ |
| 291 | eigene: { kennung: EUPL_KENNUNG, name: 'EUPL 1.2', hinweis: EUPL_HINWEIS, text: null }, |
| 292 | katalog: { |
| 293 | quellenangabe: KATALOG.meta.quellenangabe, |
| 294 | quelleUrl: KATALOG.meta.quelle_url, |
| 295 | hinweis: KATALOG_HINWEIS, |
| 296 | }, |
| 297 | dritte: [], |
| 298 | }), |
| 299 | ), |
| 300 | ...ueberschreibungen, |
| 301 | }; |
| 302 | } |
| 303 | |
| 304 | // ─── Hilfsgriffe ──────────────────────────────────────────────────────── |
| 305 | |
| 306 | async function sucheOeffnen(bruecke: LernAppBridge = attrappe()): Promise<Nutzer> { |
| 307 | vi.stubGlobal('lernApp', bruecke); |
| 308 | const nutzer = userEvent.setup(); |
| 309 | render(<App />); |
| 310 | |
| 311 | await nutzer.click(await screen.findByRole('button', { name: 'Fragen durchsuchen' })); |
| 312 | await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); |
| 313 | return nutzer; |
| 314 | } |
| 315 | |
| 316 | function feld(): HTMLElement { |
| 317 | return screen.getByLabelText('Suchbegriff'); |
| 318 | } |
| 319 | |
| 320 | function trefferliste(): HTMLElement[] { |
| 321 | return screen.queryAllByRole('heading', { level: 2 }).filter((h) => h.tabIndex === -1); |
| 322 | } |
| 323 | |
| 324 | afterEach(() => { |
| 325 | vi.unstubAllGlobals(); |
| 326 | erklaerungenZwischenspeicherLeeren(); |
| 327 | glossarZwischenspeicherLeeren(); |
| 328 | normtexteZwischenspeicherLeeren(); |
| 329 | themenZwischenspeicherLeeren(); |
| 330 | suchindexZwischenspeicherLeeren(); |
| 331 | }); |
| 332 | |
| 333 | // ─── Tests ────────────────────────────────────────────────────────────── |
| 334 | |
| 335 | describe('Suche – Zugang und Aufbau', () => { |
| 336 | it('ist vom Startbildschirm aus erreichbar und setzt den Fokus auf die Überschrift', async () => { |
| 337 | await sucheOeffnen(); |
| 338 | expect(screen.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 })).toHaveFocus(); |
| 339 | }); |
| 340 | |
| 341 | it('führt genau eine h1', async () => { |
| 342 | await sucheOeffnen(); |
| 343 | expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); |
| 344 | }); |
| 345 | |
| 346 | it('bietet genau zwei Bedienelemente vor der Liste: Feld und Bereich', async () => { |
| 347 | await sucheOeffnen(); |
| 348 | const bereich = screen.getByRole('group', { name: 'Bereich' }); |
| 349 | expect(feld()).toBeInTheDocument(); |
| 350 | expect(within(bereich).getAllByRole('radio').length).toBeGreaterThan(1); |
| 351 | // Keine Kästchen für „wo gesucht wird“ – das wären Tokenizer-Fragen. |
| 352 | expect(screen.queryAllByRole('checkbox')).toEqual([]); |
| 353 | }); |
| 354 | |
| 355 | it('nennt die Fragenzahl in jeder Bereichsbeschriftung', async () => { |
| 356 | await sucheOeffnen(); |
| 357 | expect(screen.getByRole('radio', { name: 'Alle Bereiche (34)' })).toBeInTheDocument(); |
| 358 | expect(screen.getByRole('radio', { name: /I\.5 – Notwehr und Notstand \(1\)/u })).toBeVisible(); |
| 359 | }); |
| 360 | }); |
| 361 | |
| 362 | describe('Suche – der Fragen-Browser', () => { |
| 363 | it('zeigt bei leerem Feld alle Fragen des Bereichs', async () => { |
| 364 | await sucheOeffnen(); |
| 365 | // 34 Fragen, portioniert auf 25. |
| 366 | expect(trefferliste()).toHaveLength(25); |
| 367 | }); |
| 368 | |
| 369 | it('kürzt auf den gewählten Bereich', async () => { |
| 370 | const nutzer = await sucheOeffnen(); |
| 371 | await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u })); |
| 372 | await waitFor(() => { |
| 373 | expect(trefferliste()).toHaveLength(1); |
| 374 | }); |
| 375 | expect(screen.getByRole('heading', { name: /Was ist Notwehr/u })).toBeInTheDocument(); |
| 376 | }); |
| 377 | }); |
| 378 | |
| 379 | describe('Suche – Finden', () => { |
| 380 | it('filtert während des Tippens', async () => { |
| 381 | const nutzer = await sucheOeffnen(); |
| 382 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 383 | await waitFor(() => { |
| 384 | expect(trefferliste()).toHaveLength(1); |
| 385 | }); |
| 386 | }); |
| 387 | |
| 388 | it('findet über Umlaute und ß hinweg', async () => { |
| 389 | const nutzer = await sucheOeffnen(); |
| 390 | await nutzer.type(feld(), 'buechse'); |
| 391 | await waitFor(() => { |
| 392 | expect(trefferliste()).toHaveLength(1); |
| 393 | }); |
| 394 | expect(screen.getByRole('heading', { name: /Welche Langwaffe/u })).toBeInTheDocument(); |
| 395 | }); |
| 396 | |
| 397 | it('sagt an einem Antworttreffer, in welcher Möglichkeit er sitzt', async () => { |
| 398 | const nutzer = await sucheOeffnen(); |
| 399 | await nutzer.type(feld(), 'buechse'); |
| 400 | expect(await screen.findByText('Treffer in Antwortmöglichkeit a.')).toBeInTheDocument(); |
| 401 | }); |
| 402 | |
| 403 | it('meldet die Trefferzahl in einer Live-Region – als ganzen Satz', async () => { |
| 404 | const nutzer = await sucheOeffnen(); |
| 405 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 406 | |
| 407 | /* Die Region spricht erst nach einer Ruhepause: Eine höfliche Region, die |
| 408 | bei jedem Anschlag eine neue Zahl nennt, ist genau das, wovor der |
| 409 | Prüfplan zu 4.1.3 warnt. */ |
| 410 | const region = await screen.findByText('Eine Frage gefunden.'); |
| 411 | expect(region).toHaveAttribute('role', 'status'); |
| 412 | }); |
| 413 | |
| 414 | it('findet eine Frage über ihre amtliche Nummer', async () => { |
| 415 | const nutzer = await sucheOeffnen(); |
| 416 | await nutzer.type(feld(), '5.01'); |
| 417 | expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument(); |
| 418 | expect(screen.getByText('Über die Fragennummer gefunden.')).toBeInTheDocument(); |
| 419 | }); |
| 420 | }); |
| 421 | |
| 422 | describe('Suche – Leerzustände sind Wege, keine Sackgassen', () => { |
| 423 | it('bittet bei einem Zeichen um ein weiteres, statt „keine Treffer“ zu melden', async () => { |
| 424 | const nutzer = await sucheOeffnen(); |
| 425 | await nutzer.type(feld(), 'a'); |
| 426 | expect(await screen.findByText('Bitte noch ein Zeichen eingeben.')).toBeInTheDocument(); |
| 427 | }); |
| 428 | |
| 429 | it('benennt das Wort, das im Katalog nicht vorkommt', async () => { |
| 430 | const nutzer = await sucheOeffnen(); |
| 431 | await nutzer.type(feld(), 'zwergpudel'); |
| 432 | expect(await screen.findByText(/„zwergpudel“ kommt im Katalog nicht vor/u)).toBeInTheDocument(); |
| 433 | }); |
| 434 | |
| 435 | it('rechnet vor, was ohne die Bereichseinschränkung zu finden wäre', async () => { |
| 436 | const nutzer = await sucheOeffnen(); |
| 437 | await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u })); |
| 438 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 439 | |
| 440 | expect( |
| 441 | await screen.findByText(/Ohne die Einschränkung.*gäbe es 1 Treffer/u), |
| 442 | ).toBeInTheDocument(); |
| 443 | await nutzer.click(screen.getByRole('button', { name: 'Einschränkung aufheben' })); |
| 444 | await waitFor(() => { |
| 445 | expect(trefferliste()).toHaveLength(1); |
| 446 | }); |
| 447 | }); |
| 448 | }); |
| 449 | |
| 450 | describe('Suche – Portionierung', () => { |
| 451 | it('lädt auf Knopfdruck nach und setzt den Fokus auf den ersten neuen Treffer', async () => { |
| 452 | const nutzer = await sucheOeffnen(); |
| 453 | expect(trefferliste()).toHaveLength(25); |
| 454 | |
| 455 | await nutzer.click(screen.getByRole('button', { name: /Weitere 9 anzeigen \(25 von 34\)/u })); |
| 456 | |
| 457 | await waitFor(() => { |
| 458 | expect(trefferliste()).toHaveLength(34); |
| 459 | }); |
| 460 | /* Der Fokus geht auf den ersten neu erschienenen Treffer, nicht auf den |
| 461 | Knopf: Die neuen Karten stehen im Baum VOR ihm und würden beim |
| 462 | Weitertabben sonst vollständig übersprungen. */ |
| 463 | expect(trefferliste()[25]).toHaveFocus(); |
| 464 | }); |
| 465 | }); |
| 466 | |
| 467 | describe('Suche – vom Treffer ins Üben und zurück', () => { |
| 468 | it('startet eine Sitzung mit genau dieser Frage', async () => { |
| 469 | /* Eine Sitzung mit fester Fragenliste wird aus dem bereits geladenen |
| 470 | Katalog gebaut, ohne den Kanal `lernen:sitzung` – geprüft wird deshalb |
| 471 | das Ergebnis und nicht der Aufruf. */ |
| 472 | const bruecke = attrappe(); |
| 473 | const nutzer = await sucheOeffnen(bruecke); |
| 474 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 475 | |
| 476 | await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' })); |
| 477 | |
| 478 | expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible(); |
| 479 | expect(screen.getByText(/Frage 1 von 1/u)).toBeInTheDocument(); |
| 480 | expect(screen.queryByText(/Welche Langwaffe/u)).not.toBeInTheDocument(); |
| 481 | expect(bruecke.lernSitzung).not.toHaveBeenCalled(); |
| 482 | }); |
| 483 | |
| 484 | it('führt aus der Auswertung zurück in dieselbe Trefferliste', async () => { |
| 485 | const nutzer = await sucheOeffnen(); |
| 486 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 487 | await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' })); |
| 488 | |
| 489 | await nutzer.click(await screen.findByRole('button', { name: 'Sitzung beenden' })); |
| 490 | const zurueck = await screen.findByRole('button', { name: 'Zurück zur Suche' }); |
| 491 | await nutzer.click(zurueck); |
| 492 | |
| 493 | await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); |
| 494 | expect(feld()).toHaveValue('anscheinswaffe'); |
| 495 | }); |
| 496 | |
| 497 | it('deckelt „alle Treffer üben“ auf den Sitzungsumfang', async () => { |
| 498 | await sucheOeffnen(); |
| 499 | /* Beim Stöbern hat niemand gesucht – dort sind es Fragen, keine Treffer. |
| 500 | 34 im Bestand, Sitzungsumfang 20. */ |
| 501 | expect( |
| 502 | screen.getByRole('button', { name: 'Die ersten 20 von 34 Fragen üben' }), |
| 503 | ).toBeInTheDocument(); |
| 504 | }); |
| 505 | |
| 506 | it('spricht bei genau einem Treffer nicht von „Alle 1 Fragen“', async () => { |
| 507 | const nutzer = await sucheOeffnen(); |
| 508 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 509 | expect(await screen.findByRole('button', { name: 'Diese eine Frage üben' })).toBeVisible(); |
| 510 | }); |
| 511 | }); |
| 512 | |
| 513 | describe('Suche – verrät nichts', () => { |
| 514 | it('zeigt nirgends, welche Antwort richtig ist', async () => { |
| 515 | const nutzer = await sucheOeffnen(); |
| 516 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 517 | await nutzer.click(await screen.findByText(/Antwortmöglichkeiten anzeigen/u)); |
| 518 | |
| 519 | const inhalt = document.body.textContent; |
| 520 | expect(inhalt).toContain('Eine Spielzeugpistole'); |
| 521 | // Nichts, was die Lösung ausweist – weder Wort noch Klasse noch Attribut. |
| 522 | expect(inhalt).not.toMatch(/richtig|korrekt/iu); |
| 523 | expect(document.querySelector('[data-korrekt]')).toBeNull(); |
| 524 | }); |
| 525 | |
| 526 | it('zeigt die Musterantwort einer offenen Frage nicht', async () => { |
| 527 | const nutzer = await sucheOeffnen(); |
| 528 | await nutzer.type(feld(), 'notwehr'); |
| 529 | await waitFor(() => { |
| 530 | expect(trefferliste()).toHaveLength(1); |
| 531 | }); |
| 532 | expect(screen.queryByText(/Verteidigung gegen einen gegenwärtigen/u)).not.toBeInTheDocument(); |
| 533 | }); |
| 534 | |
| 535 | it('sagt bei einem Musterantworttreffer, dass sie nicht gezeigt wird', async () => { |
| 536 | const nutzer = await sucheOeffnen(); |
| 537 | await nutzer.type(feld(), 'rechtswidrigen'); |
| 538 | expect( |
| 539 | await screen.findByText('Treffer nur in der Musterantwort – sie wird hier nicht gezeigt.'), |
| 540 | ).toBeInTheDocument(); |
| 541 | }); |
| 542 | }); |
| 543 | |
| 544 | describe('Suche – Barrierefreiheit', () => { |
| 545 | it('hat keine axe-Verstöße, auch mit Trefferliste', async () => { |
| 546 | const nutzer = await sucheOeffnen(); |
| 547 | await nutzer.type(feld(), 'schusswaffe'); |
| 548 | await waitFor(() => { |
| 549 | expect(trefferliste().length).toBeGreaterThan(0); |
| 550 | }); |
| 551 | |
| 552 | const ergebnis = await axe.run(document.body, jsdomOptionen()); |
| 553 | expect(ergebnis.violations.map((v) => v.id)).toEqual([]); |
| 554 | }); |
| 555 | |
| 556 | it('markiert die Fundstelle und benennt sie zusätzlich im Klartext', async () => { |
| 557 | /* Die Markierung allein wäre eine Aussage über Farbe (WCAG 1.4.1). Sie |
| 558 | wegzulassen hülfe Blinden und verteuerte es für Sehbehinderte. */ |
| 559 | const nutzer = await sucheOeffnen(); |
| 560 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 561 | await waitFor(() => { |
| 562 | expect(trefferliste()).toHaveLength(1); |
| 563 | }); |
| 564 | |
| 565 | const marken = document.querySelectorAll('mark.fundstelle'); |
| 566 | expect(marken.length).toBeGreaterThan(0); |
| 567 | expect(marken[0]?.textContent.toLowerCase()).toContain('anscheinswaffe'); |
| 568 | expect(screen.getByText('Treffer im Fragetext.')).toBeInTheDocument(); |
| 569 | }); |
| 570 | }); |
| 571 | |
| 572 | describe('Suche – was die Nachprüfung gefunden hat', () => { |
| 573 | /* |
| 574 | Jeder Test hier steht für einen bestätigten Befund aus der adversarischen |
| 575 | Nachprüfung vor der Veröffentlichung von 0.10.0. Sie stehen zusammen, |
| 576 | weil sie eines gemeinsam haben: Die Ansicht sagte etwas anderes, als sie |
| 577 | tat – und jeder Lauf war grün dabei. |
| 578 | */ |
| 579 | |
| 580 | it('meldet bei einem Nummerntreffer nicht zugleich „Keine Frage gefunden“', async () => { |
| 581 | const nutzer = await sucheOeffnen(); |
| 582 | await nutzer.type(feld(), '1.01'); |
| 583 | |
| 584 | expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument(); |
| 585 | expect(screen.queryByText('Keine Frage gefunden.')).not.toBeInTheDocument(); |
| 586 | // Auch die Live-Region darf das nicht sagen. |
| 587 | expect(await screen.findByText('Eine Frage über die Nummer gefunden.')).toBeInTheDocument(); |
| 588 | }); |
| 589 | |
| 590 | it('fordert bei einer einstelligen Fragennummer kein weiteres Zeichen', async () => { |
| 591 | /* Eingabe „5“: Die Ansicht zeigte drei Fragen und sagte gleichzeitig |
| 592 | „Bitte noch ein Zeichen eingeben.“ – wer hört statt sieht, hielt die |
| 593 | Suche für nicht ausgeführt. */ |
| 594 | const nutzer = await sucheOeffnen(); |
| 595 | await nutzer.type(feld(), '5'); |
| 596 | |
| 597 | await waitFor(() => { |
| 598 | expect(screen.getByText('Über die Fragennummer gefunden:')).toBeInTheDocument(); |
| 599 | }); |
| 600 | expect(screen.queryByText('Bitte noch ein Zeichen eingeben.')).not.toBeInTheDocument(); |
| 601 | }); |
| 602 | |
| 603 | it('findet auch mit Satzzeichen am Wortrand', async () => { |
| 604 | /* „Notwehr!“ fand null Fragen, „Notwehr“ eine – das Satzzeichen wurde |
| 605 | stillschweigend mitgesucht. Besonders ärgerlich bei den typografischen |
| 606 | Anführungszeichen, die die Anwendung selbst in ihren Hinweisen setzt. */ |
| 607 | const nutzer = await sucheOeffnen(); |
| 608 | for (const eingabe of ['Notwehr!', '(Notwehr', '„Notwehr“', 'Notwehr;']) { |
| 609 | await nutzer.clear(feld()); |
| 610 | await nutzer.type(feld(), eingabe); |
| 611 | await waitFor(() => { |
| 612 | expect(trefferliste(), `„${eingabe}“ findet nichts`).toHaveLength(1); |
| 613 | }); |
| 614 | } |
| 615 | }); |
| 616 | |
| 617 | it('hält dem Nutzer seine eigene Schreibweise vor, nicht die Kanonform', async () => { |
| 618 | const nutzer = await sucheOeffnen(); |
| 619 | await nutzer.type(feld(), 'Schießstättenübung'); |
| 620 | |
| 621 | expect( |
| 622 | await screen.findByText(/„Schießstättenübung“ kommt im Katalog nicht vor/u), |
| 623 | ).toBeInTheDocument(); |
| 624 | }); |
| 625 | |
| 626 | it('verspricht bei der Begriffsbrücke nur, was ein Klick auch liefert', async () => { |
| 627 | /* |
| 628 | Der schwerste Befund: Der Knopf nannte die Vereinigungsmenge über alle |
| 629 | Katalogwörter und setzte beim Klick nur das erste ein. Jetzt trägt jeder |
| 630 | Vorschlag seine eigene Zahl – und die muss stimmen. |
| 631 | */ |
| 632 | const nutzer = await sucheOeffnen(); |
| 633 | await nutzer.type(feld(), 'Tresor'); |
| 634 | |
| 635 | const vorschlag = await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u }); |
| 636 | const versprochen = Number(/\((\d+)\)/u.exec(vorschlag.textContent)?.[1] ?? '0'); |
| 637 | expect(versprochen).toBeGreaterThan(0); |
| 638 | |
| 639 | await nutzer.click(vorschlag); |
| 640 | await waitFor(() => { |
| 641 | expect(trefferliste()).toHaveLength(versprochen); |
| 642 | }); |
| 643 | }); |
| 644 | |
| 645 | it('lässt den Fokus nach einem Angebotsklick nicht auf <body> fallen', async () => { |
| 646 | /* Der Kasten mit dem Knopf verschwindet im selben Render – ohne |
| 647 | Fokusführung stünde man danach am Dokumentanfang. */ |
| 648 | const nutzer = await sucheOeffnen(); |
| 649 | await nutzer.type(feld(), 'Tresor'); |
| 650 | await nutzer.click(await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u })); |
| 651 | |
| 652 | await waitFor(() => { |
| 653 | expect(feld()).toHaveFocus(); |
| 654 | }); |
| 655 | expect(document.activeElement).not.toBe(document.body); |
| 656 | }); |
| 657 | |
| 658 | it('sperrt den Weg in die Suche, solange der Katalog fehlt', async () => { |
| 659 | /* Sonst führte der Knopf in eine Ansicht, die App.tsx mangels |
| 660 | Katalogindex gar nicht rendert: leerer Hauptbereich, keine Überschrift, |
| 661 | Fokus auf <body>. */ |
| 662 | vi.stubGlobal( |
| 663 | 'lernApp', |
| 664 | attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }), |
| 665 | ); |
| 666 | render(<App />); |
| 667 | |
| 668 | const knopf = await screen.findByRole('button', { name: 'Fragen durchsuchen' }); |
| 669 | /* Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein |
| 670 | natives `disabled`, dafür `aria-disabled` und der Grund als Text am |
| 671 | Knopf. Ein `disabled` Knopf nähme den Fokus mit auf `<body>`. */ |
| 672 | await waitFor(() => { |
| 673 | expect(knopf).toHaveAttribute('aria-disabled', 'true'); |
| 674 | }); |
| 675 | expect(knopf).toBeEnabled(); |
| 676 | expect(knopf).toHaveAccessibleDescription(/lässt sich nicht darin suchen/u); |
| 677 | }); |
| 678 | }); |
| 679 | |
| 680 | describe('Suche – was den Rückweg überlebt', () => { |
| 681 | /** |
| 682 | * Übt die erste Frage der Trefferliste und beendet die Sitzung. |
| 683 | * |
| 684 | * Der Rückweg selbst bleibt dem Test überlassen: „Zurück zur Suche“ und |
| 685 | * „Zum Start“ stehen am Sitzungsende nebeneinander, und der Unterschied |
| 686 | * zwischen beiden ist genau das, was hier geprüft wird. |
| 687 | */ |
| 688 | async function uebenUndBeenden(nutzer: Nutzer): Promise<void> { |
| 689 | await nutzer.click(screen.getAllByRole('button', { name: /^Frage .+ üben$/u })[0]!); |
| 690 | await screen.findByRole('button', { name: 'Sitzung beenden' }); |
| 691 | await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' })); |
| 692 | await screen.findByRole('button', { name: 'Zurück zur Suche' }); |
| 693 | } |
| 694 | |
| 695 | /* |
| 696 | Bis Fassung 0.26.0 kam nur der Suchtext zurück. Wer auf einen Bereich |
| 697 | eingeschränkt hatte, bekam danach die Liste über dem ganzen Katalog – |
| 698 | „dieselbe Trefferliste“, wie der Knopf und das Handbuch sie versprechen, |
| 699 | war sie damit nicht. |
| 700 | |
| 701 | Text und Bereich reisen deshalb als Paar mit dem Sitzungsauftrag |
| 702 | (`Suchanfrage` in `lernen/typen.ts`) und nicht als Merkwert daneben. Sie |
| 703 | leben genau so lange wie der Vorgang, aus dem sie stammen. |
| 704 | */ |
| 705 | it('trägt die Bereichseinschränkung über den Rückweg aus einer Sitzung', async () => { |
| 706 | const nutzer = await sucheOeffnen(); |
| 707 | await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); |
| 708 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 709 | await waitFor(() => { |
| 710 | expect(trefferliste()).toHaveLength(1); |
| 711 | }); |
| 712 | |
| 713 | await uebenUndBeenden(nutzer); |
| 714 | await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' })); |
| 715 | await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); |
| 716 | |
| 717 | expect(feld()).toHaveValue('anscheinswaffe'); |
| 718 | expect(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })).toBeChecked(); |
| 719 | await waitFor(() => { |
| 720 | expect(trefferliste()).toHaveLength(1); |
| 721 | }); |
| 722 | }); |
| 723 | |
| 724 | /* |
| 725 | Die Wache über die Verfallsregel, und sie ist die wichtigere von beiden: |
| 726 | Ohne sie wäre der Kern des Entwurfs unbewacht. Die Anfrage soll den |
| 727 | Vorgang NICHT überleben – wer über den Startbildschirm geht, beginnt von |
| 728 | vorn. Ein Suchbegriff, der Stunden später wieder dasteht, wäre genau der |
| 729 | stille Zustand, den niemand erklären kann. |
| 730 | */ |
| 731 | it('vergisst die Anfrage auf dem Weg über den Startbildschirm', async () => { |
| 732 | const nutzer = await sucheOeffnen(); |
| 733 | await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); |
| 734 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 735 | await waitFor(() => { |
| 736 | expect(trefferliste()).toHaveLength(1); |
| 737 | }); |
| 738 | |
| 739 | await uebenUndBeenden(nutzer); |
| 740 | await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!); |
| 741 | await screen.findByRole('heading', { name: 'Heute lernen' }); |
| 742 | await nutzer.click(screen.getByRole('button', { name: 'Fragen durchsuchen' })); |
| 743 | await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); |
| 744 | |
| 745 | expect(feld()).toHaveValue(''); |
| 746 | expect(screen.getByRole('radio', { name: /^Alle Bereiche/u })).toBeChecked(); |
| 747 | }); |
| 748 | |
| 749 | it('nennt den Bereich in der Ansage, auch wenn es Treffer gibt', async () => { |
| 750 | /* |
| 751 | Bis 0.26.0 hieß es schlicht „Eine Frage gefunden.“ – wer eingeschränkt |
| 752 | hatte, erfuhr an dieser Stelle nicht, worauf sich die Zahl bezieht. |
| 753 | Seit der Rückweg die Einschränkung mitbringt, wiegt das schwerer: Sie |
| 754 | kann gesetzt sein, ohne dass man sie selbst gerade gesetzt hat. |
| 755 | */ |
| 756 | const nutzer = await sucheOeffnen(); |
| 757 | await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); |
| 758 | await nutzer.type(feld(), 'anscheinswaffe'); |
| 759 | |
| 760 | expect(await screen.findByText('Eine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument(); |
| 761 | }); |
| 762 | |
| 763 | it('nennt die Einschränkung auch dann, wenn das Wort im ganzen Katalog fehlt', async () => { |
| 764 | const nutzer = await sucheOeffnen(); |
| 765 | await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })); |
| 766 | await nutzer.type(feld(), 'zwergpudel'); |
| 767 | |
| 768 | expect(await screen.findByText('Keine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument(); |
| 769 | }); |
| 770 | |
| 771 | it('bringt die Zahl der nachgeladenen Treffer nicht mit', async () => { |
| 772 | /* |
| 773 | Sie ist kein gesetzter Wert, sondern eine Rolltiefe in anderer |
| 774 | Schreibweise: Die Anwendung nennt sie an keiner Stelle, die beim |
| 775 | Betreten im Bild steht – nur in der Knopfbeschriftung hinter allen |
| 776 | Karten. Dieselbe Art Wert, die `App.tsx` für die Bildlaufstelle bereits |
| 777 | verworfen hat. |
| 778 | */ |
| 779 | const nutzer = await sucheOeffnen(); |
| 780 | await waitFor(() => { |
| 781 | expect(trefferliste()).toHaveLength(25); |
| 782 | }); |
| 783 | await nutzer.click(screen.getByRole('button', { name: /^Weitere \d+ anzeigen \(/u })); |
| 784 | await waitFor(() => { |
| 785 | expect(trefferliste().length).toBeGreaterThan(25); |
| 786 | }); |
| 787 | |
| 788 | await uebenUndBeenden(nutzer); |
| 789 | await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' })); |
| 790 | await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }); |
| 791 | |
| 792 | await waitFor(() => { |
| 793 | expect(trefferliste()).toHaveLength(25); |
| 794 | }); |
| 795 | }); |
| 796 | }); |