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 | 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 | }); |