waffensachkunde

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

/ app tests renderer-suche.test.tsx

35,0 KB Rohdatei
app/tests/renderer-suche.test.tsx — 903 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 themengruppen: [],
170 };
171
172 const STAND: FrageStand = {
173 frageId: 'I.1-01',
174 versuche: 0,
175 richtige: 0,
176 zuletztBeantwortet: null,
177 faelligAb: null,
178 gemerkt: false,
179 letzteBewertung: null,
180 };
181
182 const INFO: AnwendungsInfo = {
183 anwendungsVersion: '0.0.0-attrappe',
184 baukennung: 'abc1234',
185 baustand: '2026-08-22',
186 electronVersion: '43.4.1',
187 chromeVersion: '140.0.0.0',
188 nodeVersion: '22.19.1',
189 plattform: 'win32',
190 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite bereit.' },
191 };
192
193 const ERGEBNIS: Pruefungsergebnis = {
194 profilId: 'standard',
195 gesamt: 0,
196 richtig: 0,
197 falsch: 0,
198 unbeantwortet: 0,
199 quote: 0,
200 urteil: 'nicht_bestanden',
201 begruendung: '0 Prozent richtig.',
202 verletzteKriterien: [],
203 bereiche: [],
204 fehlerIds: [],
205 dauerMs: 0,
206 zeitAbgelaufen: false,
207 zeitpunkt: '2026-01-01T08:00:00.000Z',
208 };
209
210 const LERNPLAN: Lernplan = {
211 termin: null,
212 tageBisTermin: null,
213 gesamtFragen: 34,
214 nieBeantwortet: 34,
215 faellig: 0,
216 zielquote: 0.9,
217 prognoseHeute: 0,
218 prognoseAmTermin: null,
219 pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 },
220 machbarkeit: 'kein_termin',
221 sekundenProFrage: 25,
222 };
223
224 const ERKLAERUNGEN: Erklaerungen = {
225 meta: { version: 1, stand: '2026-08-20', gesetzesstand: {}, hinweis: 'Eigener Inhalt.' },
226 zuFrage: {},
227 };
228
229 const GLOSSAR: Glossar = {
230 meta: { version: 1, stand: '2026-08-21', gesetzesstand: {}, hinweis: 'Eigener Inhalt.' },
231 eintraege: [],
232 };
233
234 function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge {
235 return {
236 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
237 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
238 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
239 hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
240 hilfsmittelBeobachten: vi.fn(() => () => undefined),
241 menuebefehlBeobachten: vi.fn(() => () => undefined),
242 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
243 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
244 einstellungenLesen: vi.fn(() =>
245 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
246 ),
247 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
248 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
249 katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
250 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
251 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
252 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
253 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
254 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
255 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
256 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
257 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
258 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
259 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
260 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
261 /* Wird von der Suche nie gebraucht: Eine Sitzung mit fester Fragenliste
262 entsteht aus dem geladenen Katalog. Die Attrappe bedient nur den Weg
263 über „Weiterlernen“. */
264 lernSitzung: vi.fn(() =>
265 Promise.resolve([{ frageId: 'I.1-01', optionsReihenfolge: ['a', 'b'], gemerkt: false }]),
266 ),
267 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
268 lernMerken: vi.fn(() => Promise.resolve(STAND)),
269 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
270 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
271 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
272 pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })),
273 pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)),
274 pruefungVerlauf: vi.fn(() => Promise.resolve([])),
275 pruefungSichern: vi.fn(() => Promise.resolve(true)),
276 pruefungOffen: vi.fn(() => Promise.resolve(null)),
277 pruefungVerwerfen: vi.fn(() => Promise.resolve()),
278 pruefungLaeuft: vi.fn(() => Promise.resolve()),
279 sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })),
280 fehlerprotokollDrucken: vi.fn(() =>
281 Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }),
282 ),
283 fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })),
284 lernberichtDrucken: vi.fn(() =>
285 Promise.resolve({ gespeichert: true, pfad: 'C:/x.pdf', bytes: 1 }),
286 ),
287 /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt,
288 dass die Brücke den Kanal kennt. */
289 datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })),
290 lizenzenLesen: vi.fn(() =>
291 Promise.resolve({
292 eigene: { kennung: EUPL_KENNUNG, name: 'EUPL 1.2', hinweis: EUPL_HINWEIS, text: null },
293 katalog: {
294 quellenangabe: KATALOG.meta.quellenangabe,
295 quelleUrl: KATALOG.meta.quelle_url,
296 hinweis: KATALOG_HINWEIS,
297 },
298 dritte: [],
299 }),
300 ),
301 ...ueberschreibungen,
302 };
303 }
304
305 // ─── Hilfsgriffe ────────────────────────────────────────────────────────
306
307 async function sucheOeffnen(bruecke: LernAppBridge = attrappe()): Promise<Nutzer> {
308 vi.stubGlobal('lernApp', bruecke);
309 const nutzer = userEvent.setup();
310 render(<App />);
311
312 await nutzer.click(await screen.findByRole('button', { name: 'Fragen durchsuchen' }));
313 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });
314 return nutzer;
315 }
316
317 function feld(): HTMLElement {
318 return screen.getByLabelText('Suchbegriff');
319 }
320
321 function trefferliste(): HTMLElement[] {
322 return screen.queryAllByRole('heading', { level: 2 }).filter((h) => h.tabIndex === -1);
323 }
324
325 afterEach(() => {
326 vi.unstubAllGlobals();
327 erklaerungenZwischenspeicherLeeren();
328 glossarZwischenspeicherLeeren();
329 normtexteZwischenspeicherLeeren();
330 themenZwischenspeicherLeeren();
331 suchindexZwischenspeicherLeeren();
332 });
333
334 // ─── Tests ──────────────────────────────────────────────────────────────
335
336 describe('Suche – Zugang und Aufbau', () => {
337 it('ist vom Startbildschirm aus erreichbar und setzt den Fokus auf die Überschrift', async () => {
338 await sucheOeffnen();
339 expect(screen.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 })).toHaveFocus();
340 });
341
342 it('führt genau eine h1', async () => {
343 await sucheOeffnen();
344 expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
345 });
346
347 it('bietet genau zwei Bedienelemente vor der Liste: Feld und Bereich', async () => {
348 await sucheOeffnen();
349 const bereich = screen.getByRole('group', { name: 'Bereich' });
350 expect(feld()).toBeInTheDocument();
351 expect(within(bereich).getAllByRole('radio').length).toBeGreaterThan(1);
352 // Keine Kästchen für „wo gesucht wird“ – das wären Tokenizer-Fragen.
353 expect(screen.queryAllByRole('checkbox')).toEqual([]);
354 });
355
356 it('nennt die Fragenzahl in jeder Bereichsbeschriftung', async () => {
357 await sucheOeffnen();
358 expect(screen.getByRole('radio', { name: 'Alle Bereiche (34)' })).toBeInTheDocument();
359 expect(screen.getByRole('radio', { name: /I\.5 – Notwehr und Notstand \(1\)/u })).toBeVisible();
360 });
361 });
362
363 describe('Suche – der Fragen-Browser', () => {
364 it('zeigt bei leerem Feld alle Fragen des Bereichs', async () => {
365 await sucheOeffnen();
366 // 34 Fragen, portioniert auf 25.
367 expect(trefferliste()).toHaveLength(25);
368 });
369
370 it('kürzt auf den gewählten Bereich', async () => {
371 const nutzer = await sucheOeffnen();
372 await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u }));
373 await waitFor(() => {
374 expect(trefferliste()).toHaveLength(1);
375 });
376 expect(screen.getByRole('heading', { name: /Was ist Notwehr/u })).toBeInTheDocument();
377 });
378 });
379
380 describe('Suche – Finden', () => {
381 it('filtert während des Tippens', async () => {
382 const nutzer = await sucheOeffnen();
383 await nutzer.type(feld(), 'anscheinswaffe');
384 await waitFor(() => {
385 expect(trefferliste()).toHaveLength(1);
386 });
387 });
388
389 it('findet über Umlaute und ß hinweg', async () => {
390 const nutzer = await sucheOeffnen();
391 await nutzer.type(feld(), 'buechse');
392 await waitFor(() => {
393 expect(trefferliste()).toHaveLength(1);
394 });
395 expect(screen.getByRole('heading', { name: /Welche Langwaffe/u })).toBeInTheDocument();
396 });
397
398 it('sagt an einem Antworttreffer, in welcher Möglichkeit er sitzt', async () => {
399 const nutzer = await sucheOeffnen();
400 await nutzer.type(feld(), 'buechse');
401 expect(await screen.findByText('Treffer in Antwortmöglichkeit a.')).toBeInTheDocument();
402 });
403
404 it('meldet die Trefferzahl in einer Live-Region – als ganzen Satz', async () => {
405 const nutzer = await sucheOeffnen();
406 await nutzer.type(feld(), 'anscheinswaffe');
407
408 /* Die Region spricht erst nach einer Ruhepause: Eine höfliche Region, die
409 bei jedem Anschlag eine neue Zahl nennt, ist genau das, wovor der
410 Prüfplan zu 4.1.3 warnt. */
411 const region = await screen.findByText('Eine Frage gefunden.');
412 expect(region).toHaveAttribute('role', 'status');
413 });
414
415 it('findet eine Frage über ihre amtliche Nummer', async () => {
416 const nutzer = await sucheOeffnen();
417 await nutzer.type(feld(), '5.01');
418 expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument();
419 expect(screen.getByText('Über die Fragennummer gefunden.')).toBeInTheDocument();
420 });
421 });
422
423 describe('Suche – Leerzustände sind Wege, keine Sackgassen', () => {
424 it('bittet bei einem Zeichen um ein weiteres, statt „keine Treffer“ zu melden', async () => {
425 const nutzer = await sucheOeffnen();
426 await nutzer.type(feld(), 'a');
427 expect(await screen.findByText('Bitte noch ein Zeichen eingeben.')).toBeInTheDocument();
428 });
429
430 it('benennt das Wort, das im Katalog nicht vorkommt', async () => {
431 const nutzer = await sucheOeffnen();
432 await nutzer.type(feld(), 'zwergpudel');
433 expect(await screen.findByText(/„zwergpudel“ kommt im Katalog nicht vor/u)).toBeInTheDocument();
434 });
435
436 it('rechnet vor, was ohne die Bereichseinschränkung zu finden wäre', async () => {
437 const nutzer = await sucheOeffnen();
438 await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u }));
439 await nutzer.type(feld(), 'anscheinswaffe');
440
441 expect(
442 await screen.findByText(/Ohne die Einschränkung.*gäbe es 1 Treffer/u),
443 ).toBeInTheDocument();
444 await nutzer.click(screen.getByRole('button', { name: 'Einschränkung aufheben' }));
445 await waitFor(() => {
446 expect(trefferliste()).toHaveLength(1);
447 });
448 });
449 });
450
451 describe('Suche – Portionierung', () => {
452 it('lädt auf Knopfdruck nach und setzt den Fokus auf den ersten neuen Treffer', async () => {
453 const nutzer = await sucheOeffnen();
454 expect(trefferliste()).toHaveLength(25);
455
456 await nutzer.click(screen.getByRole('button', { name: /Weitere 9 anzeigen \(25 von 34\)/u }));
457
458 await waitFor(() => {
459 expect(trefferliste()).toHaveLength(34);
460 });
461 /* Der Fokus geht auf den ersten neu erschienenen Treffer, nicht auf den
462 Knopf: Die neuen Karten stehen im Baum VOR ihm und würden beim
463 Weitertabben sonst vollständig übersprungen. */
464 expect(trefferliste()[25]).toHaveFocus();
465 });
466 });
467
468 describe('Suche – vom Treffer ins Üben und zurück', () => {
469 it('startet eine Sitzung mit genau dieser Frage', async () => {
470 /* Eine Sitzung mit fester Fragenliste wird aus dem bereits geladenen
471 Katalog gebaut, ohne den Kanal `lernen:sitzung` – geprüft wird deshalb
472 das Ergebnis und nicht der Aufruf. */
473 const bruecke = attrappe();
474 const nutzer = await sucheOeffnen(bruecke);
475 await nutzer.type(feld(), 'anscheinswaffe');
476
477 await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));
478
479 expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible();
480 expect(screen.getByText(/Frage 1 von 1/u)).toBeInTheDocument();
481 expect(screen.queryByText(/Welche Langwaffe/u)).not.toBeInTheDocument();
482 expect(bruecke.lernSitzung).not.toHaveBeenCalled();
483 });
484
485 it('führt aus der Auswertung zurück in dieselbe Trefferliste', async () => {
486 const nutzer = await sucheOeffnen();
487 await nutzer.type(feld(), 'anscheinswaffe');
488 await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));
489
490 await nutzer.click(await screen.findByRole('button', { name: 'Sitzung beenden' }));
491 const zurueck = await screen.findByRole('button', { name: 'Zurück zur Suche' });
492 await nutzer.click(zurueck);
493
494 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });
495 expect(feld()).toHaveValue('anscheinswaffe');
496 });
497
498 it('deckelt „alle Treffer üben“ auf den Sitzungsumfang', async () => {
499 await sucheOeffnen();
500 /* Beim Stöbern hat niemand gesucht – dort sind es Fragen, keine Treffer.
501 34 im Bestand, Sitzungsumfang 20. */
502 expect(
503 screen.getByRole('button', { name: 'Die ersten 20 von 34 Fragen üben' }),
504 ).toBeInTheDocument();
505 });
506
507 it('spricht bei genau einem Treffer nicht von „Alle 1 Fragen“', async () => {
508 const nutzer = await sucheOeffnen();
509 await nutzer.type(feld(), 'anscheinswaffe');
510 expect(await screen.findByRole('button', { name: 'Diese eine Frage üben' })).toBeVisible();
511 });
512 });
513
514 describe('Suche – verrät nichts', () => {
515 it('zeigt nirgends, welche Antwort richtig ist', async () => {
516 const nutzer = await sucheOeffnen();
517 await nutzer.type(feld(), 'anscheinswaffe');
518 await nutzer.click(await screen.findByText(/Antwortmöglichkeiten anzeigen/u));
519
520 const inhalt = document.body.textContent;
521 expect(inhalt).toContain('Eine Spielzeugpistole');
522 // Nichts, was die Lösung ausweist – weder Wort noch Klasse noch Attribut.
523 expect(inhalt).not.toMatch(/richtig|korrekt/iu);
524 expect(document.querySelector('[data-korrekt]')).toBeNull();
525 });
526
527 it('zeigt die Musterantwort einer offenen Frage nicht', async () => {
528 const nutzer = await sucheOeffnen();
529 await nutzer.type(feld(), 'notwehr');
530 await waitFor(() => {
531 expect(trefferliste()).toHaveLength(1);
532 });
533 expect(screen.queryByText(/Verteidigung gegen einen gegenwärtigen/u)).not.toBeInTheDocument();
534 });
535
536 it('sagt bei einem Musterantworttreffer, dass sie nicht gezeigt wird', async () => {
537 const nutzer = await sucheOeffnen();
538 await nutzer.type(feld(), 'rechtswidrigen');
539 expect(
540 await screen.findByText('Treffer nur in der Musterantwort – sie wird hier nicht gezeigt.'),
541 ).toBeInTheDocument();
542 });
543 });
544
545 describe('Suche – Barrierefreiheit', () => {
546 it('hat keine axe-Verstöße, auch mit Trefferliste', async () => {
547 const nutzer = await sucheOeffnen();
548 await nutzer.type(feld(), 'schusswaffe');
549 await waitFor(() => {
550 expect(trefferliste().length).toBeGreaterThan(0);
551 });
552
553 const ergebnis = await axe.run(document.body, jsdomOptionen());
554 expect(ergebnis.violations.map((v) => v.id)).toEqual([]);
555 });
556
557 it('markiert die Fundstelle und benennt sie zusätzlich im Klartext', async () => {
558 /* Die Markierung allein wäre eine Aussage über Farbe (WCAG 1.4.1). Sie
559 wegzulassen hülfe Blinden und verteuerte es für Sehbehinderte. */
560 const nutzer = await sucheOeffnen();
561 await nutzer.type(feld(), 'anscheinswaffe');
562 await waitFor(() => {
563 expect(trefferliste()).toHaveLength(1);
564 });
565
566 const marken = document.querySelectorAll('mark.fundstelle');
567 expect(marken.length).toBeGreaterThan(0);
568 expect(marken[0]?.textContent.toLowerCase()).toContain('anscheinswaffe');
569 expect(screen.getByText('Treffer im Fragetext.')).toBeInTheDocument();
570 });
571 });
572
573 describe('Suche – was die Nachprüfung gefunden hat', () => {
574 /*
575 Jeder Test hier steht für einen bestätigten Befund aus der adversarischen
576 Nachprüfung vor der Veröffentlichung von 0.10.0. Sie stehen zusammen,
577 weil sie eines gemeinsam haben: Die Ansicht sagte etwas anderes, als sie
578 tat – und jeder Lauf war grün dabei.
579 */
580
581 it('meldet bei einem Nummerntreffer nicht zugleich „Keine Frage gefunden“', async () => {
582 const nutzer = await sucheOeffnen();
583 await nutzer.type(feld(), '1.01');
584
585 expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument();
586 expect(screen.queryByText('Keine Frage gefunden.')).not.toBeInTheDocument();
587 // Auch die Live-Region darf das nicht sagen.
588 expect(await screen.findByText('Eine Frage über die Nummer gefunden.')).toBeInTheDocument();
589 });
590
591 it('fordert bei einer einstelligen Fragennummer kein weiteres Zeichen', async () => {
592 /* Eingabe „5“: Die Ansicht zeigte drei Fragen und sagte gleichzeitig
593 „Bitte noch ein Zeichen eingeben.“ – wer hört statt sieht, hielt die
594 Suche für nicht ausgeführt. */
595 const nutzer = await sucheOeffnen();
596 await nutzer.type(feld(), '5');
597
598 await waitFor(() => {
599 expect(screen.getByText('Über die Fragennummer gefunden:')).toBeInTheDocument();
600 });
601 expect(screen.queryByText('Bitte noch ein Zeichen eingeben.')).not.toBeInTheDocument();
602 });
603
604 it('findet auch mit Satzzeichen am Wortrand', async () => {
605 /* „Notwehr!“ fand null Fragen, „Notwehr“ eine – das Satzzeichen wurde
606 stillschweigend mitgesucht. Besonders ärgerlich bei den typografischen
607 Anführungszeichen, die die Anwendung selbst in ihren Hinweisen setzt. */
608 const nutzer = await sucheOeffnen();
609 for (const eingabe of ['Notwehr!', '(Notwehr', '„Notwehr“', 'Notwehr;']) {
610 await nutzer.clear(feld());
611 await nutzer.type(feld(), eingabe);
612 await waitFor(() => {
613 expect(trefferliste(), `„${eingabe}“ findet nichts`).toHaveLength(1);
614 });
615 }
616 });
617
618 it('hält dem Nutzer seine eigene Schreibweise vor, nicht die Kanonform', async () => {
619 const nutzer = await sucheOeffnen();
620 await nutzer.type(feld(), 'Schießstättenübung');
621
622 expect(
623 await screen.findByText(/„Schießstättenübung“ kommt im Katalog nicht vor/u),
624 ).toBeInTheDocument();
625 });
626
627 it('verspricht bei der Begriffsbrücke nur, was ein Klick auch liefert', async () => {
628 /*
629 Der schwerste Befund: Der Knopf nannte die Vereinigungsmenge über alle
630 Katalogwörter und setzte beim Klick nur das erste ein. Jetzt trägt jeder
631 Vorschlag seine eigene Zahl – und die muss stimmen.
632 */
633 const nutzer = await sucheOeffnen();
634 await nutzer.type(feld(), 'Tresor');
635
636 const vorschlag = await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u });
637 const versprochen = Number(/\((\d+)\)/u.exec(vorschlag.textContent)?.[1] ?? '0');
638 expect(versprochen).toBeGreaterThan(0);
639
640 await nutzer.click(vorschlag);
641 await waitFor(() => {
642 expect(trefferliste()).toHaveLength(versprochen);
643 });
644 });
645
646 it('lässt den Fokus nach einem Angebotsklick nicht auf <body> fallen', async () => {
647 /* Der Kasten mit dem Knopf verschwindet im selben Render – ohne
648 Fokusführung stünde man danach am Dokumentanfang. */
649 const nutzer = await sucheOeffnen();
650 await nutzer.type(feld(), 'Tresor');
651 await nutzer.click(await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u }));
652
653 await waitFor(() => {
654 expect(feld()).toHaveFocus();
655 });
656 expect(document.activeElement).not.toBe(document.body);
657 });
658
659 it('sperrt den Weg in die Suche, solange der Katalog fehlt', async () => {
660 /* Sonst führte der Knopf in eine Ansicht, die App.tsx mangels
661 Katalogindex gar nicht rendert: leerer Hauptbereich, keine Überschrift,
662 Fokus auf <body>. */
663 vi.stubGlobal(
664 'lernApp',
665 attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }),
666 );
667 render(<App />);
668
669 const knopf = await screen.findByRole('button', { name: 'Fragen durchsuchen' });
670 /* Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein
671 natives `disabled`, dafür `aria-disabled` und der Grund als Text am
672 Knopf. Ein `disabled` Knopf nähme den Fokus mit auf `<body>`. */
673 await waitFor(() => {
674 expect(knopf).toHaveAttribute('aria-disabled', 'true');
675 });
676 expect(knopf).toBeEnabled();
677 expect(knopf).toHaveAccessibleDescription(/lässt sich nicht darin suchen/u);
678 });
679 });
680
681 describe('Suche – was den Rückweg überlebt', () => {
682 /**
683 * Übt die erste Frage der Trefferliste und beendet die Sitzung.
684 *
685 * Der Rückweg selbst bleibt dem Test überlassen: „Zurück zur Suche“ und
686 * „Zum Start“ stehen am Sitzungsende nebeneinander, und der Unterschied
687 * zwischen beiden ist genau das, was hier geprüft wird.
688 */
689 async function uebenUndBeenden(nutzer: Nutzer): Promise<void> {
690 await nutzer.click(screen.getAllByRole('button', { name: /^Frage .+ üben$/u })[0]!);
691 await screen.findByRole('button', { name: 'Sitzung beenden' });
692 await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
693 await screen.findByRole('button', { name: 'Zurück zur Suche' });
694 }
695
696 /*
697 Bis Fassung 0.26.0 kam nur der Suchtext zurück. Wer auf einen Bereich
698 eingeschränkt hatte, bekam danach die Liste über dem ganzen Katalog –
699 „dieselbe Trefferliste“, wie der Knopf und das Handbuch sie versprechen,
700 war sie damit nicht.
701
702 Text und Bereich reisen deshalb als Paar mit dem Sitzungsauftrag
703 (`Suchanfrage` in `lernen/typen.ts`) und nicht als Merkwert daneben. Sie
704 leben genau so lange wie der Vorgang, aus dem sie stammen.
705 */
706 it('trägt die Bereichseinschränkung über den Rückweg aus einer Sitzung', async () => {
707 const nutzer = await sucheOeffnen();
708 await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
709 await nutzer.type(feld(), 'anscheinswaffe');
710 await waitFor(() => {
711 expect(trefferliste()).toHaveLength(1);
712 });
713
714 await uebenUndBeenden(nutzer);
715 await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' }));
716 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });
717
718 expect(feld()).toHaveValue('anscheinswaffe');
719 expect(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })).toBeChecked();
720 await waitFor(() => {
721 expect(trefferliste()).toHaveLength(1);
722 });
723 });
724
725 /*
726 Die Wache über die Verfallsregel, und sie ist die wichtigere von beiden:
727 Ohne sie wäre der Kern des Entwurfs unbewacht. Die Anfrage soll den
728 Vorgang NICHT überleben – wer über den Startbildschirm geht, beginnt von
729 vorn. Ein Suchbegriff, der Stunden später wieder dasteht, wäre genau der
730 stille Zustand, den niemand erklären kann.
731 */
732 it('vergisst die Anfrage auf dem Weg über den Startbildschirm', async () => {
733 const nutzer = await sucheOeffnen();
734 await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
735 await nutzer.type(feld(), 'anscheinswaffe');
736 await waitFor(() => {
737 expect(trefferliste()).toHaveLength(1);
738 });
739
740 await uebenUndBeenden(nutzer);
741 await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
742 await screen.findByRole('heading', { name: 'Heute lernen' });
743 await nutzer.click(screen.getByRole('button', { name: 'Fragen durchsuchen' }));
744 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });
745
746 expect(feld()).toHaveValue('');
747 expect(screen.getByRole('radio', { name: /^Alle Bereiche/u })).toBeChecked();
748 });
749
750 it('nennt den Bereich in der Ansage, auch wenn es Treffer gibt', async () => {
751 /*
752 Bis 0.26.0 hieß es schlicht „Eine Frage gefunden.“ – wer eingeschränkt
753 hatte, erfuhr an dieser Stelle nicht, worauf sich die Zahl bezieht.
754 Seit der Rückweg die Einschränkung mitbringt, wiegt das schwerer: Sie
755 kann gesetzt sein, ohne dass man sie selbst gerade gesetzt hat.
756 */
757 const nutzer = await sucheOeffnen();
758 await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
759 await nutzer.type(feld(), 'anscheinswaffe');
760
761 expect(await screen.findByText('Eine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument();
762 });
763
764 it('nennt die Einschränkung auch dann, wenn das Wort im ganzen Katalog fehlt', async () => {
765 const nutzer = await sucheOeffnen();
766 await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
767 await nutzer.type(feld(), 'zwergpudel');
768
769 expect(await screen.findByText('Keine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument();
770 });
771
772 it('bringt die Zahl der nachgeladenen Treffer nicht mit', async () => {
773 /*
774 Sie ist kein gesetzter Wert, sondern eine Rolltiefe in anderer
775 Schreibweise: Die Anwendung nennt sie an keiner Stelle, die beim
776 Betreten im Bild steht – nur in der Knopfbeschriftung hinter allen
777 Karten. Dieselbe Art Wert, die `App.tsx` für die Bildlaufstelle bereits
778 verworfen hat.
779 */
780 const nutzer = await sucheOeffnen();
781 await waitFor(() => {
782 expect(trefferliste()).toHaveLength(25);
783 });
784 await nutzer.click(screen.getByRole('button', { name: /^Weitere \d+ anzeigen \(/u }));
785 await waitFor(() => {
786 expect(trefferliste().length).toBeGreaterThan(25);
787 });
788
789 await uebenUndBeenden(nutzer);
790 await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' }));
791 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });
792
793 await waitFor(() => {
794 expect(trefferliste()).toHaveLength(25);
795 });
796 });
797 });
798
799 describe('Suche – die Einstellung „Antwortmöglichkeiten mischen“ gilt auch hier', () => {
800 /*
801 Befund der Prüfrunde zu 0.27.2. `sitzungStarten` in `App.tsx` sagt zu, die
802 Einstellung „hier anzuhängen deckt alle sechs Wege mit fester Fragenliste
803 auf einmal“. Sechs gibt es, aber nur fünf gehen durch `sitzungStarten`:
804 Der Weg aus der Trefferliste baut den Auftrag selbst und ließ
805 `optionenMischen` weg. Wer den Schalter umlegt und aus der Suche heraus
806 übt, bekam die Katalogreihenfolge.
807
808 Gemessen mit einer festen Zufallsquelle: `mischen` sortiert nach
809 `Math.random()`; eine absteigende Folge kehrt die Reihenfolge um. Ohne
810 die Behebung steht „a)“ vorn, mit ihr „b)“.
811 */
812 function zufallAbsteigend(): void {
813 let naechster = 1;
814 vi.spyOn(Math, 'random').mockImplementation(() => {
815 naechster -= 0.1;
816 return naechster;
817 });
818 }
819
820 function markenReihenfolge(): string[] {
821 return [...document.querySelectorAll('.antwortoption__marke')].map(
822 (knoten) => knoten.textContent,
823 );
824 }
825
826 const mitMischen = (): LernAppBridge =>
827 attrappe({
828 einstellungenLesen: vi.fn(() =>
829 Promise.resolve({
830 ...EINSTELLUNGEN_STANDARD,
831 zuschnittGefragt: true,
832 optionenMischen: true,
833 }),
834 ),
835 });
836
837 it('mischt die Antworten, wenn der Schalter an ist', async () => {
838 zufallAbsteigend();
839 const nutzer = await sucheOeffnen(mitMischen());
840 await nutzer.type(feld(), 'anscheinswaffe');
841 await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));
842
843 expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible();
844 expect(markenReihenfolge()).toEqual(['b)', 'a)']);
845 });
846
847 it('lässt die Katalogreihenfolge stehen, wenn er aus ist', async () => {
848 /* Die Gegenprobe: Der Weg darf nicht dadurch geheilt werden, dass er
849 immer mischt. Ab Werk steht der Schalter aus. */
850 zufallAbsteigend();
851 const nutzer = await sucheOeffnen();
852 await nutzer.type(feld(), 'anscheinswaffe');
853 await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));
854
855 expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible();
856 expect(markenReihenfolge()).toEqual(['a)', 'b)']);
857 });
858 });
859
860 describe('Suche – „alle Treffer üben“ folgt dem eingestellten Sitzungsumfang', () => {
861 /*
862 Befund der Prüfrunde zu 0.27.2. Seit 0.26.6 ist der Sitzungsumfang
863 einstellbar; `Suchansicht` rechnete weiter mit der Konstanten
864 `SITZUNGSUMFANG`. Wer fünfzig eingestellt hatte, bekam aus der
865 Trefferliste zwanzig — und der Knopf sagte es auch so, ohne dass sich das
866 aus der Einstellung erklären ließ.
867 */
868 function mitUmfang(umfang: number): LernAppBridge {
869 return attrappe({
870 einstellungenLesen: vi.fn(() =>
871 Promise.resolve({
872 ...EINSTELLUNGEN_STANDARD,
873 zuschnittGefragt: true,
874 sitzungsumfang: umfang,
875 }),
876 ),
877 });
878 }
879
880 it('deckelt auf die eingestellte Zahl', async () => {
881 await sucheOeffnen(mitUmfang(5));
882
883 expect(
884 await screen.findByRole('button', { name: 'Die ersten 5 von 34 Fragen üben' }),
885 ).toBeVisible();
886 });
887
888 it('legt dann auch wirklich so viele vor', async () => {
889 const nutzer = await sucheOeffnen(mitUmfang(5));
890 await nutzer.click(await screen.findByRole('button', { name: /Die ersten 5 von 34/u }));
891
892 expect(await screen.findByText(/Frage 1 von 5/u)).toBeInTheDocument();
893 });
894
895 it('bleibt ab Werk bei zwanzig', async () => {
896 /* Die Gegenprobe: Die Vorgabe darf sich dabei nicht verschieben. */
897 await sucheOeffnen();
898
899 expect(
900 await screen.findByRole('button', { name: 'Die ersten 20 von 34 Fragen üben' }),
901 ).toBeVisible();
902 });
903 });