waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests renderer-suche.test.tsx

31,2 KB Rohdatei
app/tests/renderer-suche.test.tsx — 796 Zeilen
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 });