waffensachkunde

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

/ app tests renderer-startbildschirm.test.tsx

95,4 KB Rohdatei
app/tests/renderer-startbildschirm.test.tsx — 2516 Zeilen
1 /**
2 * Verhaltenstests des Startbildschirms.
3 *
4 * Schwerpunkt: Prüfungsreife verständlich anzeigen, die vier Wege ins Lernen
5 * anbieten – und dabei auch dann tragfähig bleiben, wenn der Anwendungskern
6 * einen Kanal noch nicht bereitstellt.
7 */
8
9 import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
10 import userEvent from '@testing-library/user-event';
11 import axe from 'axe-core';
12 import { jsdomOptionen } from '../src/shared/wcag';
13 import { afterEach, describe, expect, it, vi } from 'vitest';
14
15 import { App } from '../src/renderer/src/App';
16 import { sicherungsstandSatz } from '../src/renderer/src/lernen/zeitsaetze';
17 import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
18 import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar';
19 import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte';
20 import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen';
21 import type { Frage, Katalog, RichText } from '../src/shared/katalog';
22 import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc';
23 import type { Erklaerungen } from '../src/shared/erklaerungen';
24 import type { Glossar } from '../src/shared/glossar';
25 import type { Lernplan } from '../src/shared/lernplan';
26 import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand';
27 import { NORMTEXTE_LEER, type Normtexte } from '../src/shared/normtexte';
28 import { THEMEN_LEER } from '../src/shared/themen';
29
30 /** Bridge-Attrappe, aus der sich einzelne Kanäle entfernen lassen. */
31 type TeilBridge = { -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] };
32
33 function nurText(text: string): RichText {
34 return { text, segmente: [{ t: text }] };
35 }
36
37 const FRAGE_A: Frage = {
38 id: 'I.1-01',
39 amtliche_nummer: '1.01',
40 kapitel: 'I',
41 abschnitt: 'I.1',
42 typ: 'mc',
43 seite: 6,
44 frage: nurText('Was regelt das Waffengesetz?'),
45 bilder: [],
46 optionen: [
47 {
48 label: 'a',
49 inhalt: nurText('Den Umgang mit Waffen und Munition.'),
50 korrekt: true,
51 bilder: [],
52 },
53 { label: 'b', inhalt: nurText('Den Handel mit Kraftfahrzeugen.'), korrekt: false, bilder: [] },
54 ],
55 };
56
57 const FRAGE_B: Frage = {
58 id: 'I.2-04',
59 amtliche_nummer: '2.04',
60 kapitel: 'I',
61 abschnitt: 'I.2',
62 typ: 'mc',
63 seite: 30,
64 frage: nurText('Wer darf eine Waffe führen?'),
65 bilder: [],
66 optionen: [
67 { label: 'a', inhalt: nurText('Wer einen Waffenschein besitzt.'), korrekt: true, bilder: [] },
68 { label: 'b', inhalt: nurText('Jede volljährige Person.'), korrekt: false, bilder: [] },
69 ],
70 };
71
72 const KATALOG: Katalog = {
73 meta: {
74 titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)',
75 herausgeber: 'Bundesverwaltungsamt',
76 stand: '2024-12-16',
77 quellenangabe: 'Amtlicher Fragenkatalog des Bundesverwaltungsamtes, Stand 16.12.2024.',
78 quelle_url: 'https://www.bva.bund.de/',
79 quelldatei_sha256: '7e5e41fa',
80 fragen_gesamt: 2,
81 },
82 kapitel: [
83 {
84 id: 'I',
85 titel: 'Waffenrecht und sonstige Rechtsvorschriften',
86 abschnitte: [
87 { id: 'I.1', titel: 'Begriffe des Waffenrechts' },
88 { id: 'I.2', titel: 'Rechte und Pflichten' },
89 ],
90 },
91 ],
92 bilder: [],
93 fragen: [FRAGE_A, FRAGE_B],
94 };
95
96 const PROFIL: Profil = {
97 id: 1,
98 name: 'Testprofil',
99 pruefungstermin: null,
100 kapitelAusschluss: [],
101 erstelltAm: '2026-01-01T08:00:00.000Z',
102 };
103
104 const UEBERSICHT: Lernuebersicht = {
105 fragenGesamt: 575,
106 beantwortet: 120,
107 belegt: 48,
108 reifegrad: 48 / 575,
109 stufe: 'zurueck',
110 deckelnd: [],
111 faellig: 12,
112 gemerkt: 5,
113 offen: 0,
114 fehler: 0,
115 heuteRichtig: 8,
116 heuteFalsch: 2,
117 heuteBearbeitet: 0,
118 tageSeitLetzterAntwort: null,
119 bereiche: [
120 {
121 id: 'I',
122 titel: 'Waffenrecht und sonstige Rechtsvorschriften',
123 fragenGesamt: 300,
124 beantwortet: 100,
125 belegt: 40,
126 reifegrad: 40 / 300,
127 stufe: 'zurueck',
128 },
129 {
130 id: 'I.1',
131 titel: 'Begriffe des Waffenrechts',
132 fragenGesamt: 90,
133 beantwortet: 30,
134 belegt: 12,
135 reifegrad: 12 / 90,
136 stufe: 'zurueck',
137 },
138 ],
139 themengruppen: [],
140 };
141
142 const STAND: FrageStand = {
143 frageId: 'I.1-01',
144 versuche: 1,
145 richtige: 1,
146 zuletztBeantwortet: '2026-01-01T08:00:00.000Z',
147 faelligAb: null,
148 gemerkt: false,
149 letzteBewertung: 'gut',
150 };
151
152 const INFO: AnwendungsInfo = {
153 anwendungsVersion: '0.0.0-attrappe',
154 baukennung: 'abc1234',
155 baustand: '2026-08-21',
156 electronVersion: '43.4.1',
157 chromeVersion: '140.0.0.0',
158 nodeVersion: '22.19.1',
159 plattform: 'win32',
160 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
161 };
162
163 const SITZUNG: SitzungsFrage[] = [
164 { frageId: 'I.1-01', optionsReihenfolge: ['a', 'b'], gemerkt: false },
165 ];
166
167 /** Lernplan für die Attrappe – ohne Termin, damit nichts vom Datum abhängt. */
168 const LERNPLAN: Lernplan = {
169 termin: null,
170 tageBisTermin: null,
171 gesamtFragen: 8,
172 nieBeantwortet: 6,
173 faellig: 1,
174 zielquote: 0.9,
175 prognoseHeute: 0.25,
176 prognoseAmTermin: null,
177 pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 },
178 machbarkeit: 'kein_termin',
179 sekundenProFrage: 25,
180 };
181
182 /** Erklaerungen fuer die Attrappe – ein Eintrag genuegt fuer die Anzeige. */
183 const ERKLAERUNGEN: Erklaerungen = {
184 meta: {
185 version: 1,
186 stand: '2026-08-20',
187 gesetzesstand: { WaffG: 'Pruefstand' },
188 hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.',
189 },
190 zuFrage: {
191 'I.1-01': {
192 kurz: 'Das Waffengesetz regelt den Umgang mit Waffen und Munition.',
193 text: 'Massgeblich ist § 1 Abs. 1 WaffG: Gegenstand ist der Umgang, Zweck die oeffentliche Sicherheit und Ordnung.',
194 fundstellen: [{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }],
195 merksatz: 'Gegenstand: der Umgang. Zweck: die Sicherheit.',
196 },
197 },
198 };
199
200 /** Glossar fuer die Attrappe – zwei Eintraege genuegen fuer die Anzeige. */
201 const GLOSSAR: Glossar = {
202 meta: {
203 version: 1,
204 stand: '2026-08-21',
205 gesetzesstand: { WaffG: 'Pruefstand' },
206 hinweis: 'Eigener redaktioneller Inhalt.',
207 },
208 eintraege: [
209 {
210 begriff: 'Führen',
211 art: 'begriff',
212 kurz: 'Die tatsächliche Gewalt über eine Waffe außerhalb der eigenen Wohnung ausüben.',
213 varianten: ['führt', 'führen', 'geführt'],
214 fundstellen: [{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 2 Nr. 4' }],
215 siehe: ['WaffG'],
216 },
217 {
218 begriff: 'WaffG',
219 art: 'abkuerzung',
220 kurz: 'Waffengesetz.',
221 varianten: ['WaffG', 'Waffengesetzes', 'Waffengesetz'],
222 fundstellen: [],
223 ohneFundstelleGrund:
224 'Die amtliche Kurzbezeichnung eines Gesetzes steht nicht im Gesetz selbst.',
225 },
226 ],
227 };
228
229 function attrappe(ueberschreibungen: TeilBridge = {}): TeilBridge {
230 return {
231 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
232 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
233 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
234 einstellungenLesen: vi.fn(() =>
235 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
236 ),
237 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
238 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
239 katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
240 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
241 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
242 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
243 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
244 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
245 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
246 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
247 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
248 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
249 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
250 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
251 lernSitzung: vi.fn(() => Promise.resolve(SITZUNG)),
252 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
253 lernMerken: vi.fn(() => Promise.resolve(STAND)),
254 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
255 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
256 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
257 ...ueberschreibungen,
258 };
259 }
260
261 /** Bildet den Zustand nach, in dem der Anwendungskern einen Kanal noch nicht anbietet. */
262 function ohneKanaele(...entfernt: readonly (keyof LernAppBridge)[]): TeilBridge {
263 const bruecke = attrappe();
264 for (const kanal of entfernt) {
265 Reflect.deleteProperty(bruecke, kanal);
266 }
267 return bruecke;
268 }
269
270 function starten(bruecke: TeilBridge = attrappe()): ReturnType<typeof userEvent.setup> {
271 vi.stubGlobal('lernApp', bruecke);
272 const nutzer = userEvent.setup();
273 render(<App />);
274 return nutzer;
275 }
276
277 /**
278 * Alle Halte des Tabulators in Dokumentreihenfolge.
279 *
280 * Eine Radiogruppe ist genau **ein** Halt – der Tabulator springt sie als
281 * Ganzes an, innerhalb wird mit den Pfeiltasten gewählt. Wer sie einzeln
282 * zählte, rechnete die Wege künstlich lang.
283 */
284 function tabulatorhalte(): HTMLElement[] {
285 const kandidaten = [
286 ...document.querySelectorAll<HTMLElement>(
287 'main a[href], main button, main input, main select, main textarea, main summary, main [tabindex]',
288 ),
289 ].filter((element) => {
290 if (element.getAttribute('tabindex') === '-1') {
291 return false;
292 }
293 return !(element as { disabled?: boolean }).disabled;
294 });
295
296 const gesehen = new Set<string>();
297 return kandidaten.filter((element) => {
298 if (!(element instanceof HTMLInputElement) || element.type !== 'radio') {
299 return true;
300 }
301 if (gesehen.has(element.name)) {
302 return false;
303 }
304 gesehen.add(element.name);
305 return true;
306 });
307 }
308
309 afterEach(() => {
310 vi.unstubAllGlobals();
311 /* Die Erklaerungen liegen in einem Modul-weiten Zwischenspeicher – ohne
312 Leeren wuerde der naechste Test die Antwort des vorigen sehen. */
313 erklaerungenZwischenspeicherLeeren();
314 glossarZwischenspeicherLeeren();
315 normtexteZwischenspeicherLeeren();
316 themenZwischenspeicherLeeren();
317 });
318
319 describe('Startbildschirm – Prüfungsreife', () => {
320 it('nennt die Stufe als Wort und den Stand als nachprüfbare Zahl', async () => {
321 /* Die Farbe trägt nichts, was nicht auch als Text dasteht (WCAG 1.4.1).
322 Und die Zahl steht vor der Auslegung: „48 von 575 Fragen sitzen belegt“
323 ist nachprüfbar, „noch nicht so weit“ ist eine Auslegung davon. */
324 starten();
325
326 const balken = await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
327 expect(within(balken.closest('.reifeampel')!).getByText('Noch nicht so weit')).toBeVisible();
328
329 expect(balken).toHaveAttribute('aria-valuenow', '48');
330 expect(balken).toHaveAttribute('aria-valuemin', '0');
331 expect(balken).toHaveAttribute('aria-valuemax', '575');
332 expect(balken.getAttribute('aria-valuetext')).toContain('48 von 575 Fragen sitzen belegt.');
333 });
334
335 it('verspricht in der Ampel keine Bestehenswahrscheinlichkeit', async () => {
336 /* Eine Angabe wie „neun von zehn Läufen“ setzte voraus, dass alle Fragen
337 dieselbe Trefferchance haben. Das Gegenteil ist der Fall. */
338 starten();
339 const balken = await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
340
341 expect(balken.getAttribute('aria-valuetext')).not.toMatch(/Prozent|von zehn/u);
342 });
343
344 it('erklärt aufklappbar, was „belegt“ heißt', async () => {
345 /* Die Erläuterung steht in der Lernstandskarte, nicht auf dem Einstieg:
346 Dort kostet jede Zeile Blickfeld (e2e/blickfeld.spec.ts). */
347 starten();
348
349 const erklaerung = await screen.findByText('Was heißt „belegt“?');
350 expect(erklaerung).toBeInTheDocument();
351 expect(screen.getByText(/nach mindestens einem Tag Abstand/u)).toBeInTheDocument();
352 });
353
354 it('zeigt den Reifegrad-Verlauf als Satz und als Tabelle', async () => {
355 /*
356 Die Linie ist `aria-hidden` und trägt nichts allein (WCAG 1.4.1). Wer
357 sie nicht sehen kann, muss dieselbe Auskunft bekommen – deshalb steht
358 sie hier im Satz und in einer echten Tabelle mit Spaltenköpfen.
359 */
360 const nutzer = starten(
361 attrappe({
362 lernVerlauf: vi.fn(() =>
363 Promise.resolve([
364 { tag: '2026-03-01', reifegrad: 0.02, belegt: 12, beantwortet: 30 },
365 { tag: '2026-03-02', reifegrad: 0.05, belegt: 29, beantwortet: 60 },
366 { tag: '2026-03-03', reifegrad: 0.083, belegt: 48, beantwortet: 120 },
367 ]),
368 ),
369 }),
370 );
371
372 expect(await screen.findByText(/von 12 auf 48 von 575 Fragen gestiegen/u)).toBeVisible();
373 expect(screen.getByText(/plus 36/u)).toBeVisible();
374
375 /* Die Tabelle steht eingeklappt – achtundzwanzig Zeilen übernähmen sonst
376 den Startbildschirm. Aufgeklappt wird sie so, wie ein Mensch es tut. */
377 await nutzer.click(screen.getByText('Die Zahlen Tag für Tag'));
378
379 const tabelle = await screen.findByRole('table', { name: 'Reifegrad Tag für Tag' });
380 expect(within(tabelle).getAllByRole('row')).toHaveLength(4);
381 expect(within(tabelle).getByRole('columnheader', { name: 'Belegte Fragen' })).toBeVisible();
382 expect(within(tabelle).getByRole('rowheader', { name: '3. März' })).toBeVisible();
383 });
384
385 it('lässt den Verlauf ganz weg, wenn die Fassung ihn nicht kennt', async () => {
386 /* Eine leere Liste hieße „nichts gelernt“ und wäre eine Aussage. Ein
387 fehlender Kanal heißt „diese Fassung kann das nicht“ und darf keine
388 werden. */
389 starten();
390 /* Abgewartet, bis der Startbildschirm steht – sonst prüfte die
391 Abwesenheit nur, dass noch gar nichts da ist. */
392 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
393
394 expect(screen.queryByRole('table', { name: 'Reifegrad Tag für Tag' })).toBeNull();
395 expect(screen.queryByText(/Fragen gestiegen/u)).toBeNull();
396 });
397
398 it('führt jedes Kapitel und jeden Abschnitt einzeln auf', async () => {
399 starten();
400
401 const liste = await screen.findByRole('list', { name: 'Nach Kapiteln und Abschnitten' });
402 const eintraege = within(liste).getAllByRole('listitem');
403 expect(eintraege).toHaveLength(2);
404 expect(
405 within(liste).getByRole('progressbar', {
406 name: 'Prüfungsreife in Waffenrecht und sonstige Rechtsvorschriften',
407 }),
408 ).toHaveAttribute('aria-valuenow', '40');
409 expect(
410 within(liste).getByRole('progressbar', {
411 name: 'Prüfungsreife in Begriffe des Waffenrechts',
412 }),
413 ).toHaveAttribute('aria-valuenow', '12');
414 });
415
416 it('zeigt die weiteren Kennzahlen im Klartext', async () => {
417 starten();
418
419 expect(await screen.findByText('120 von 575 Fragen')).toBeInTheDocument();
420 expect(screen.getByText('12 Fragen')).toBeInTheDocument();
421 expect(screen.getByText('8 richtig, 2 falsch')).toBeInTheDocument();
422 });
423 });
424
425 describe('Startbildschirm – Lernplan', () => {
426 /** Plan mit Termin; die Vorgabe-Attrappe hat bewusst keinen. */
427 const MIT_TERMIN: Lernplan = {
428 ...LERNPLAN,
429 termin: '2026-06-15',
430 tageBisTermin: 40,
431 nieBeantwortet: 455,
432 faellig: 12,
433 prognoseHeute: 0.62,
434 prognoseAmTermin: 0.41,
435 pensum: { neu: 13, wiederholung: 12, gesamt: 25, minuten: 10 },
436 machbarkeit: 'entspannt',
437 };
438
439 it('nennt die Einschätzung als ganzen Satz, nicht als Ampelwort', async () => {
440 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
441
442 expect(await screen.findByText(/Sie liegen gut in der Zeit/u)).toBeInTheDocument();
443 });
444
445 it('zeigt die Trefferquote nicht ein zweites Mal', () => {
446 /*
447 Bis Fassung 0.11.0 stand hier ein eigener Balken „Geschätzte
448 Trefferquote“ mit dem Satz „Wenn Sie heute geprüft würden: 62 %
449 richtig“. Seit dem Umbau ist `prognoseHeute` derselbe Wert wie der
450 Reifegrad der Ampel weiter oben – zweimal dieselbe Größe auf einem
451 Bildschirm, einmal als Fragenzahl, einmal als Prozentwert. Genau diese
452 Doppelung war der Befund in docs/stand.md 7.1.
453 */
454 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
455
456 expect(screen.queryByRole('progressbar', { name: 'Geschätzte Trefferquote' })).toBeNull();
457 expect(screen.queryByText(/Wenn Sie heute geprüft würden/u)).toBeNull();
458 });
459
460 it('zeigt den Termin im deutschen Format samt Abstand', async () => {
461 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
462
463 expect(await screen.findByText('15.06.2026 – in 40 Tagen')).toBeInTheDocument();
464 });
465
466 it('beziffert, was Nichtstun bis zur Prüfung kostet – in derselben Einheit wie die Ampel', async () => {
467 /* Fragen, nicht Prozent: Wer zwei Einheiten für dieselbe Größe liest,
468 hält sie für zwei Größen. 41 % der 8 Fragen dieser Attrappe sind 3. */
469 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
470
471 expect(
472 await screen.findByText(/Am Prüfungstag säßen noch etwa 3 von 8 Fragen belegt/u),
473 ).toBeInTheDocument();
474 });
475
476 it('nennt das Tagespensum in Fragen und in Minuten', async () => {
477 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
478
479 expect(
480 await screen.findByText(/13 neue Fragen und 12 zur Wiederholung.*etwa 10 Minuten/su),
481 ).toBeInTheDocument();
482 });
483
484 it('bietet ein beschriftetes Datumsfeld für den Termin', async () => {
485 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
486
487 const feld = await screen.findByLabelText('Ihr Prüfungstermin');
488 expect(feld).toHaveAttribute('type', 'date');
489 /* Das Feld steht sofort da, der gespeicherte Termin kommt erst mit dem
490 geladenen Plan – deshalb darauf warten statt sofort zu prüfen. */
491 await waitFor(() => {
492 expect(feld).toHaveValue('2026-06-15');
493 });
494 // Die Formathilfe muss dem Feld zugeordnet sein, nicht nur danebenstehen.
495 expect(feld).toHaveAccessibleDescription(/Format Tag\.Monat\.Jahr/u);
496 });
497
498 it('sichert einen eingetragenen Termin über die Bridge', async () => {
499 const aktualisieren = vi.fn(() => Promise.resolve(PROFIL));
500 const nutzer = starten(attrappe({ profilAktualisieren: aktualisieren }));
501
502 const feld = await screen.findByLabelText('Ihr Prüfungstermin');
503 await nutzer.clear(feld);
504 fireEvent.change(feld, { target: { value: '2026-06-15' } });
505
506 await waitFor(() => {
507 expect(aktualisieren).toHaveBeenCalledWith(1, { pruefungstermin: '2026-06-15' });
508 });
509 });
510
511 it('löscht den Termin, wenn das Feld geleert wird', async () => {
512 const aktualisieren = vi.fn(() => Promise.resolve(PROFIL));
513 starten(
514 attrappe({
515 profilAktualisieren: aktualisieren,
516 lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)),
517 }),
518 );
519
520 const feld = await screen.findByLabelText('Ihr Prüfungstermin');
521 // Erst wenn der Termin wirklich im Feld steht, ist das Leeren eine Änderung.
522 await waitFor(() => {
523 expect(feld).toHaveValue('2026-06-15');
524 });
525
526 fireEvent.change(feld, { target: { value: '' } });
527
528 await waitFor(() => {
529 expect(aktualisieren).toHaveBeenCalledWith(1, { pruefungstermin: null });
530 });
531 });
532
533 it('erklärt ohne Termin, was das Programm stattdessen tut', async () => {
534 starten();
535
536 expect(await screen.findByText(/Ohne Prüfungstermin plant das Programm/u)).toBeInTheDocument();
537 });
538
539 it('bleibt bedienbar, wenn der Kern den Kanal nicht anbietet', async () => {
540 starten(ohneKanaele('lernPlan'));
541
542 expect(await screen.findByText(/lernen:plan/u)).toBeInTheDocument();
543 // Der Rest des Startbildschirms steht weiterhin.
544 expect(screen.getByRole('button', { name: /Weiterlernen/u })).toBeInTheDocument();
545 });
546
547 it('erklärt einen Fehler beim Laden, statt leer zu bleiben', async () => {
548 starten(attrappe({ lernPlan: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))) }));
549
550 expect(await screen.findByText(/Datenbank gesperrt/u)).toBeInTheDocument();
551 });
552 });
553
554 describe('Glossar', () => {
555 async function glossarOeffnen(bruecke = attrappe()): Promise<ReturnType<typeof userEvent.setup>> {
556 const nutzer = starten(bruecke);
557 const knopf = await screen.findByRole('button', { name: 'Fachbegriffe nachschlagen' });
558 await nutzer.click(knopf);
559 await screen.findByRole('heading', { name: 'Glossar', level: 1 });
560 return nutzer;
561 }
562
563 it('ist vom Startbildschirm aus erreichbar', async () => {
564 /* WCAG 3.1.3 und 3.1.4 verlangen einen Weg zur Bedeutung. Der Prüfplan
565 verlangt zusätzlich, dass Hilfe an derselben Stelle steht (3.2.6). */
566 await glossarOeffnen();
567
568 expect(screen.getByRole('heading', { name: 'Glossar', level: 1 })).toHaveFocus();
569 });
570
571 it('führt Begriffe und Abkürzungen mit ihrer Bedeutung auf', async () => {
572 await glossarOeffnen();
573
574 expect(screen.getByRole('heading', { name: /^Führen/u, level: 2 })).toBeInTheDocument();
575 expect(screen.getByText(/außerhalb der eigenen Wohnung/u)).toBeInTheDocument();
576 expect(screen.getByText('Waffengesetz.')).toBeInTheDocument();
577 });
578
579 it('kennzeichnet Abkürzungen als solche', async () => {
580 // WCAG 3.1.4: Die aufgelöste Form muss erkennbar eine Auflösung sein.
581 await glossarOeffnen();
582
583 const eintrag = screen.getByRole('heading', { name: /^WaffG/u, level: 2 });
584 expect(eintrag).toHaveTextContent('(Abkürzung)');
585 });
586
587 it('nennt die Fundstelle einer gesetzlichen Bestimmung', async () => {
588 await glossarOeffnen();
589
590 expect(screen.getByText('Anlage 1 Abschnitt 2 Nr. 4 WaffG')).toBeInTheDocument();
591 });
592
593 it('sagt, wo es keine gesetzliche Bestimmung gibt', async () => {
594 await glossarOeffnen();
595
596 expect(
597 screen.getByText(/Kurzbezeichnung eines Gesetzes steht nicht im Gesetz/u),
598 ).toBeInTheDocument();
599 });
600
601 it('filtert auch über die Wortformen', async () => {
602 /* Wer „führt" eingibt, sucht den Eintrag „Führen" – und weiß nicht,
603 unter welchem Stichwort er steht. */
604 const nutzer = await glossarOeffnen();
605
606 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt');
607
608 expect(screen.getByRole('heading', { name: /^Führen/u, level: 2 })).toBeInTheDocument();
609 expect(screen.queryByRole('heading', { name: /^WaffG/u, level: 2 })).not.toBeInTheDocument();
610 });
611
612 it('meldet die Trefferzahl über eine Live-Region', async () => {
613 const nutzer = await glossarOeffnen();
614 const meldung = screen.getByText('2 Einträge');
615 expect(meldung).toHaveAttribute('role', 'status');
616
617 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt');
618
619 /* Seit 0.22.0 mit Ruhepause: Eine höfliche Live-Region, die bei jedem
620 Tastenanschlag eine neue Zahl spricht, mischt einem Screenreader beim
621 Tippen von „führt“ fünf einander unterbrechende Ansagen zwischen die
622 Zeichen-Echos. Die Fragensuche hielt es längst so; das Glossar zog
623 nach (`useVerzoegerteAnsage`). */
624 expect(await screen.findByText('1 von 2 Einträgen')).toBeInTheDocument();
625 });
626
627 it('sagt es, wenn nichts passt', async () => {
628 const nutzer = await glossarOeffnen();
629
630 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'Fahrraddiebstahl');
631
632 expect(screen.getByText('Zu dieser Eingabe gibt es keinen Eintrag.')).toBeInTheDocument();
633 });
634
635 /*
636 „Siehe auch“ war bis 0.22.0 toter Text.
637
638 Wer per Tastatur oder Bildschirmleser von einem Begriff zum verwandten
639 wollte, musste zurück zum Filterfeld, den Begriff abtippen und die Liste
640 neu durchgehen – obwohl beide Einträge in derselben Ansicht stehen und die
641 A-bis-Z-Leiste den Sprungmechanismus längst mitbringt.
642 */
643 it('macht „Siehe auch“ zu einem Sprungziel und nimmt den Fokus mit', async () => {
644 const nutzer = await glossarOeffnen();
645
646 /* Der Eintrag „Führen“ verweist auf „WaffG“. */
647 await nutzer.click(screen.getByRole('button', { name: 'WaffG' }));
648
649 expect(screen.getByRole('heading', { name: /^WaffG/u })).toHaveFocus();
650 });
651
652 it('leert den Filter, wenn das Sprungziel gerade ausgefiltert ist', async () => {
653 /* Sonst liefe der Sprung ins Leere: Der Zieleintrag ist dann gar nicht
654 gerendert, und der Fokus bliebe stehen, ohne dass etwas geschähe. */
655 const nutzer = await glossarOeffnen();
656 const feld = screen.getByLabelText('Begriff suchen');
657 await nutzer.type(feld, 'Führen');
658
659 /* Nur „Führen“ steht noch da – sein Verweisziel „WaffG“ ist ausgefiltert. */
660 expect(screen.queryByRole('heading', { name: /^WaffG/u })).toBeNull();
661
662 await nutzer.click(screen.getByRole('button', { name: 'WaffG' }));
663
664 await waitFor(() => {
665 expect(screen.getByRole('heading', { name: /^WaffG/u })).toHaveFocus();
666 });
667 expect(feld).toHaveValue('');
668 });
669
670 it('zeigt in der Sprungleiste nur Buchstaben, unter denen ein Eintrag steht', async () => {
671 await glossarOeffnen();
672
673 const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
674 // Zwei Einträge, zwei Knöpfe – kein gesperrter Rest des Alphabets.
675 expect(
676 within(leiste)
677 .getAllByRole('button')
678 .map((k) => k.textContent),
679 ).toEqual(['F', 'W']);
680 });
681
682 it('springt zum Buchstaben und setzt den Fokus auf die erste Überschrift', async () => {
683 /* Reines Rollen ohne Fokus wäre für Tastatur und Bildschirmleser
684 wirkungslos – der nächste Tabulatorsprung begänne wieder ganz oben. */
685 const nutzer = await glossarOeffnen();
686 const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
687
688 await nutzer.click(within(leiste).getByRole('button', { name: 'W' }));
689
690 expect(screen.getByRole('heading', { name: /^WaffG/u, level: 2 })).toHaveFocus();
691 });
692
693 it('blendet die Sprungleiste bei aktivem Filter aus', async () => {
694 /* Eine Leiste, deren Knöpfe bei jedem Anschlag kommen und gehen, wäre
695 ein bewegliches Ziel – die gefilterte Liste ist selbst der Sprung. */
696 const nutzer = await glossarOeffnen();
697
698 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt');
699 expect(screen.queryByRole('navigation', { name: 'Anfangsbuchstaben' })).not.toBeInTheDocument();
700
701 await nutzer.clear(screen.getByLabelText('Begriff suchen'));
702 expect(screen.getByRole('navigation', { name: 'Anfangsbuchstaben' })).toBeInTheDocument();
703 });
704
705 it('reiht Umlaute beim Grundbuchstaben ein, statt die Gruppe zu zerreißen', async () => {
706 /* Die deutsche Sortierung stellt „Überlassen“ vor „Umgang“. Ein eigener
707 Ü-Knopf zeigte also mitten in die U-Gruppe – deshalb fällt Ü auf U,
708 und der U-Sprung landet auf dem ersten Eintrag der Gruppe. */
709 const eintrag = (begriff: string) => ({
710 begriff,
711 art: 'begriff' as const,
712 kurz: 'Prüfeintrag für die Sprungleiste.',
713 varianten: [],
714 fundstellen: [],
715 });
716 const nutzer = await glossarOeffnen(
717 attrappe({
718 glossarLaden: vi.fn(() =>
719 Promise.resolve({ ...GLOSSAR, eintraege: [eintrag('Umgang'), eintrag('Überlassen')] }),
720 ),
721 }),
722 );
723
724 const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
725 expect(within(leiste).queryByRole('button', { name: 'Ü' })).not.toBeInTheDocument();
726
727 await nutzer.click(within(leiste).getByRole('button', { name: 'U' }));
728
729 expect(screen.getByRole('heading', { name: 'Überlassen', level: 2 })).toHaveFocus();
730 });
731
732 it('meldet auch mit Sprungleiste keine axe-Verstöße', async () => {
733 await glossarOeffnen();
734 screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
735
736 const ergebnis = await axe.run(document.body, jsdomOptionen());
737
738 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
739 });
740
741 it('führt vom Glossar zurück zum Start', async () => {
742 const nutzer = await glossarOeffnen();
743
744 await nutzer.click(
745 within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', {
746 name: 'Zum Start',
747 }),
748 );
749
750 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
751 });
752
753 /*
754 Der Rückweg endet dort, wo er begann.
755
756 Bis Fassung 0.25.0 endete JEDER Rückweg am Seitenanfang: Neun Rückwege
757 laufen über `zumStart`, und der Fokuseffekt in `App.tsx` setzte den Fokus
758 danach ausnahmslos auf `einstiegRef` – auf dem Startbildschirm also auf
759 „Heute lernen“ ganz oben. Der Startbildschirm ist bei 1265 Bildpunkten
760 Breite 8530 Bildpunkte hoch (gemessen an
761 `docs/bildschirmfotos/01-startbildschirm-hell.png`); die Karte „Über diese
762 Software“ steht ganz unten. Wer von dort zurückkam, rollte den Weg noch
763 einmal – mit der Tastatur waren es einundvierzig Tabulatorhalte.
764
765 Geprüft wird der FOKUS und nicht die Bildlaufstelle, und das ist die Sache
766 selbst: Es wurde nie eine Stelle falsch wiederhergestellt, sondern gar
767 keine gemerkt. Der Bildlauf ist die Nebenwirkung des Fokussprungs. jsdom
768 rollt ohnehin nicht; wer hier eine Bildlaufstelle prüfte, prüfte nichts.
769 */
770 it('setzt den Fokus beim Zurückkommen auf die Karte, von der aus man ging', async () => {
771 const nutzer = await glossarOeffnen();
772
773 await nutzer.click(
774 within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', {
775 name: 'Zum Start',
776 }),
777 );
778
779 const karte = await screen.findByRole('heading', { name: 'Glossar', level: 2 });
780 expect(karte).toHaveFocus();
781 /* Und nicht mehr die Einstiegsüberschrift – sonst wäre die Zusage leer. */
782 expect(screen.getByRole('heading', { name: 'Heute lernen' })).not.toHaveFocus();
783 });
784
785 it('führt beim Hinweg unverändert auf die Überschrift der neuen Ansicht', async () => {
786 /* Gegenprobe: Der Hinweg darf sich nicht ändern. Dort ist die
787 Einstiegsüberschrift richtig – der Nutzende ist an einem neuen Ort, und
788 ein Bildschirmleser muss das erfahren. */
789 await glossarOeffnen();
790
791 expect(screen.getByRole('heading', { name: 'Glossar', level: 1 })).toHaveFocus();
792 });
793
794 it('bleibt bedienbar, wenn der Kern das Glossar nicht anbietet', async () => {
795 await glossarOeffnen(ohneKanaele('glossarLaden'));
796
797 expect(screen.getByText(/Glossar ist zurzeit nicht verfügbar/u)).toBeInTheDocument();
798 });
799 });
800
801 describe('Startbildschirm – Wege ins Lernen', () => {
802 it('bietet die fünf Einstiege an', async () => {
803 starten();
804
805 for (const name of [
806 /Weiterlernen/u,
807 /Kapitel wählen/u,
808 /Nur Fehler/u,
809 /Gemerkte Fragen/u,
810 /Offene Fragen/u,
811 ]) {
812 const knopf = await screen.findByRole('button', { name });
813 await waitFor(() => {
814 expect(knopf).toHaveAttribute('aria-disabled', 'false');
815 });
816 /* Kein natives `disabled` – die Projektregel (docs/stand.md 7.13)
817 verlangt einen Knopf, der auch gesperrt den Fokus behält. */
818 expect(knopf).toBeEnabled();
819 }
820 });
821
822 /*
823 Offene Fragen kamen bisher nur zufällig zwischen den Auswahlfragen vor –
824 bei zwanzig Fragen im Mittel dreieinhalb. Sie sind der Teil der Prüfung,
825 den ein Mensch bewertet.
826 */
827 it('startet über „Offene Fragen“ eine Sitzung nur mit auszuformulierenden', async () => {
828 const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG));
829 const nutzer = starten(attrappe({ lernSitzung }));
830
831 const knopf = await screen.findByRole('button', { name: /Offene Fragen/u });
832 await waitFor(() => {
833 expect(knopf).toHaveAttribute('aria-disabled', 'false');
834 });
835 await nutzer.click(knopf);
836
837 expect(lernSitzung).toHaveBeenCalledWith(
838 1,
839 expect.objectContaining({ nurOffene: true, anzahl: 20 }),
840 );
841 });
842
843 it('nennt an der Schaltfläche, wie viele offene Fragen der Lernumfang hat', async () => {
844 /* Die Zahl kommt aus der Übersicht und nicht aus dem Katalog: Sie muss
845 sich mit der Kapitelabwahl ändern, sonst verspräche sie Fragen, die
846 das Zielprofil gar nicht enthält. */
847 starten();
848
849 expect(await screen.findByRole('button', { name: /Offene Fragen/u })).toHaveTextContent(
850 /Zum Ausformulieren: \d+ Fragen/u,
851 );
852 });
853
854 it('zeigt den Lernstand des Abschnitts dort, wo er geübt wird', async () => {
855 /* Die Wache, die gefehlt hat. Der Lernstand je Bereich stand
856 ausschließlich in der Lernstandsübersicht des Startbildschirms, und
857 von dort führte kein Weg ins Üben. Wer wissen wollte, welcher
858 Abschnitt zurückliegt, musste es sich merken, hierher wechseln und
859 die Ampel im Kopf behalten – Anzeige und Entscheidung lagen an zwei
860 verschiedenen Orten. */
861 const nutzer = starten();
862
863 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
864 await waitFor(() => {
865 expect(knopf).toHaveAttribute('aria-disabled', 'false');
866 });
867 await nutzer.click(knopf);
868
869 /* Die Ampel steht IM Bereichsblock neben dem Knopf, der ihn übt – nicht
870 irgendwo auf der Seite. Dieselbe Beschriftung gibt es auch in der
871 Lernstandsübersicht des Startbildschirms; wer nur nach ihr sucht,
872 prüft nichts. */
873 const knopfAbschnitt = await screen.findByRole('button', {
874 name: 'I.1 – Begriffe des Waffenrechts',
875 });
876 const block = knopfAbschnitt.closest('.kapitelwahl__bereich');
877
878 expect(block).not.toBeNull();
879 expect(
880 within(block as HTMLElement).getByLabelText(/Prüfungsreife in Begriffe des Waffenrechts/u),
881 ).toBeInTheDocument();
882 });
883
884 it('zeigt keine Ampel, solange der Lernstand nicht geladen ist', async () => {
885 /* „Noch nicht geladen" ist nicht „null Prozent". Eine Ampel auf Verdacht
886 wäre schlechter als keine. */
887 const nutzer = starten(ohneKanaele('lernUebersicht'));
888
889 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
890 await waitFor(() => {
891 expect(knopf).toHaveAttribute('aria-disabled', 'false');
892 });
893 await nutzer.click(knopf);
894
895 await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 });
896 const knopfAbschnitt = screen.getByRole('button', {
897 name: 'I.1 – Begriffe des Waffenrechts',
898 });
899 const block = knopfAbschnitt.closest('.kapitelwahl__bereich');
900
901 expect(block).not.toBeNull();
902 expect(
903 within(block as HTMLElement).queryByLabelText(/Prüfungsreife in/u),
904 ).not.toBeInTheDocument();
905 });
906
907 it('startet über die Kapitelwahl eine Sitzung für einen Abschnitt', async () => {
908 const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG));
909 const nutzer = starten(attrappe({ lernSitzung }));
910
911 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
912 await waitFor(() => {
913 expect(knopf).toHaveAttribute('aria-disabled', 'false');
914 });
915 await nutzer.click(knopf);
916
917 expect(screen.getByRole('heading', { name: 'Kapitel wählen', level: 1 })).toHaveFocus();
918
919 await nutzer.click(screen.getByRole('button', { name: 'I.1 – Begriffe des Waffenrechts' }));
920
921 expect(lernSitzung).toHaveBeenCalledWith(
922 1,
923 expect.objectContaining({ abschnitte: ['I.1'], anzahl: 20 }),
924 );
925 expect(await screen.findByRole('heading', { name: /Frage 1\.01/u })).toBeInTheDocument();
926 });
927 });
928
929 describe('Startbildschirm – Überschriften und Landmarken', () => {
930 it('hält die Überschriftenhierarchie ohne Sprünge ein', async () => {
931 starten();
932 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
933
934 const ebenen = screen
935 .getAllByRole('heading')
936 .map((ueberschrift) => Number(ueberschrift.tagName.slice(1)));
937
938 expect(ebenen[0]).toBe(1);
939 expect(ebenen.filter((ebene) => ebene === 1)).toHaveLength(1);
940 for (let stelle = 1; stelle < ebenen.length; stelle += 1) {
941 expect(ebenen[stelle]!).toBeLessThanOrEqual(ebenen[stelle - 1]! + 1);
942 }
943 });
944
945 it('bietet die erwarteten Landmarken', async () => {
946 starten();
947 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
948
949 expect(screen.getByRole('banner')).toBeInTheDocument();
950 expect(screen.getByRole('main')).toBeInTheDocument();
951 expect(screen.getByRole('contentinfo')).toBeInTheDocument();
952 });
953 });
954
955 describe('Startbildschirm – Tastenkürzel abschaltbar (WCAG 2.1.4)', () => {
956 it('merkt sich die Abschaltung in den Einstellungen', async () => {
957 /* Die Wahl geht in `einstellungen.json` statt in den `localStorage`: Bis
958 0.21.0 stand diese eine Einstellung außerhalb von Einstellungen und
959 Profil und überlebte keinen Gerätewechsel. */
960 const schreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD));
961 const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben }));
962
963 const schalter = await screen.findByRole('checkbox', {
964 name: 'Tastenkürzel in der Lernsitzung verwenden',
965 });
966 expect(schalter).toBeChecked();
967
968 await nutzer.click(schalter);
969
970 expect(schalter).not.toBeChecked();
971 await waitFor(() => {
972 expect(schreiben).toHaveBeenCalledWith({ tastenkuerzel: false });
973 });
974 expect(window.localStorage.getItem('waffensachkunde.tastenkuerzel')).toBeNull();
975 });
976
977 it('legt die gespeicherte Abschaltung beim Start wieder an', async () => {
978 starten(
979 attrappe({
980 einstellungenLesen: vi.fn(() =>
981 Promise.resolve({
982 ...EINSTELLUNGEN_STANDARD,
983 zuschnittGefragt: true,
984 tastenkuerzel: false,
985 }),
986 ),
987 }),
988 );
989
990 const schalter = await screen.findByRole('checkbox', {
991 name: 'Tastenkürzel in der Lernsitzung verwenden',
992 });
993 await waitFor(() => {
994 expect(schalter).not.toBeChecked();
995 });
996 });
997 });
998
999 describe('Startbildschirm – Baustand', () => {
1000 /*
1001 Die Versionsnummer allein benennt keinen Stand: Zwischen zwei
1002 Veröffentlichungen entstehen viele Bauten mit derselben Nummer. Wer eine
1003 Rückmeldung gibt, muss sagen können, welchen er vor sich hat.
1004 */
1005 it('nennt Commit-Kürzel und Datum in deutscher Schreibweise', async () => {
1006 starten();
1007
1008 expect(await screen.findByText('abc1234')).toBeInTheDocument();
1009 expect(screen.getByText(/vom 21\.08\.2026/u)).toBeInTheDocument();
1010 });
1011
1012 it('lässt die Zeile weg, wenn beim Bauen kein Git zur Verfügung stand', async () => {
1013 /* Lieber nichts als ein nichtssagendes „unbekannt“ – die Zeile
1014 existiert allein zum Benennen eines Standes. */
1015 starten(
1016 attrappe({
1017 anwendungsInfoLesen: vi.fn(() =>
1018 Promise.resolve({ ...INFO, baukennung: '', baustand: '' }),
1019 ),
1020 }),
1021 );
1022
1023 expect(await screen.findByText('Programmversion')).toBeInTheDocument();
1024 expect(screen.queryByText('Baustand')).not.toBeInTheDocument();
1025 });
1026
1027 it('nennt den Katalogstand und wo man nach einer neueren Fassung sieht', async () => {
1028 /*
1029 Die Anwendung kann nicht wissen, ob das Bundesverwaltungsamt inzwischen
1030 eine neuere Fassung veröffentlicht hat – sie geht nicht ins Netz (siehe
1031 docs/entscheidung-katalog-aktualitaet.md). Was sie kann, ist sagen, wie
1032 alt ihre Inhalte sind. Das Datum stammt aus dem Katalog selbst, nicht aus
1033 einer zweiten Pflege.
1034 */
1035 starten();
1036
1037 expect(await screen.findByText('Fragenkatalog')).toBeInTheDocument();
1038
1039 /* Datum und Quellort gehören in dieselbe Zeile – getrennt geprüft könnten
1040 sie zu zwei verschiedenen Angaben gehören. */
1041 const zeile = screen.getByText('www.bva.bund.de').closest('dd');
1042 expect(zeile).toHaveTextContent('Stand 16.12.2024');
1043 });
1044
1045 it('nennt den Quellort als Text, nicht als Verweis', async () => {
1046 /* Die Anwendung öffnet von sich aus nichts nach außen. Dieselbe
1047 Begründung wie bei der Quelle unter „Über diese Software“ und beim
1048 Rückmeldeweg in shared/kontakt.ts. */
1049 starten();
1050 const ort = await screen.findByText('www.bva.bund.de');
1051
1052 expect(ort.closest('a')).toBeNull();
1053 });
1054
1055 it('sagt es offen, wenn der Katalog nicht geladen ist', async () => {
1056 starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }));
1057
1058 expect(await screen.findByText('Fragenkatalog')).toBeInTheDocument();
1059 expect(screen.getByText('Nicht geladen.')).toBeInTheDocument();
1060 });
1061
1062 it('zeigt ein Datum unverändert, das sich nicht lesen lässt', async () => {
1063 starten(
1064 attrappe({
1065 anwendungsInfoLesen: vi.fn(() => Promise.resolve({ ...INFO, baustand: 'unbekannt' })),
1066 }),
1067 );
1068
1069 expect(await screen.findByText(/vom unbekannt/u)).toBeInTheDocument();
1070 });
1071 });
1072
1073 describe('Startbildschirm – Katalogwechsel', () => {
1074 /*
1075 Erscheint eine neue BVA-Fassung mit geänderter Nummerierung, zeigen
1076 gespeicherte Zeilen zu verschwundenen Fragennummern ins Leere. Der Kern
1077 zählt das beim Öffnen des Lernstands; die Oberfläche muss es sagen – was
1078 geschah, was betroffen ist, und dass nichts gelöscht wurde.
1079 */
1080 it('meldet einen Katalogwechsel mit Zahlen und der Zusage, dass nichts gelöscht wurde', async () => {
1081 starten(
1082 attrappe({
1083 anwendungsInfoLesen: vi.fn(() =>
1084 Promise.resolve({
1085 ...INFO,
1086 katalogwechsel: {
1087 vorher: '2024-12-16',
1088 nachher: '2025-06-01',
1089 verwaisteStaende: 2,
1090 verwaisteAntworten: 5,
1091 },
1092 }),
1093 ),
1094 }),
1095 );
1096
1097 const meldung = await screen.findByText(/Der Fragenkatalog hat die Fassung gewechselt/u);
1098 expect(meldung).toHaveTextContent('bisher Stand 16.12.2024, jetzt Stand 01.06.2025');
1099 expect(meldung).toHaveTextContent(
1100 '5 Einträge im Antwortprotokoll und 2 gespeicherte Fragenstände',
1101 );
1102 expect(meldung).toHaveTextContent('Gelöscht wurde nichts');
1103 });
1104
1105 it('schweigt, wenn der Kern keinen Wechsel meldet', async () => {
1106 /* `null` wie auch ein gänzlich fehlendes Feld heißen: nichts zu sagen.
1107 Ein Dauerhinweis wäre Rauschen – und beim echten Wechsel unglaubwürdig. */
1108 starten(attrappe({ anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)) }));
1109
1110 expect(await screen.findByText('Programmversion')).toBeInTheDocument();
1111 expect(screen.queryByText(/Fassung gewechselt/u)).not.toBeInTheDocument();
1112 });
1113 });
1114
1115 describe('Startbildschirm – fehlende Kanäle', () => {
1116 it('erklärt einen fehlenden Fragenkatalog und sperrt die Einstiege', async () => {
1117 starten(ohneKanaele('katalogLaden'));
1118
1119 expect(
1120 await screen.findByText(/Der Fragenkatalog konnte nicht geladen werden/u),
1121 ).toBeInTheDocument();
1122 expect(screen.getByText(/lässt sich keine Lernsitzung starten/u)).toBeInTheDocument();
1123
1124 /*
1125 Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein
1126 natives `disabled`, dafür `aria-disabled` und ein Verweis auf den Satz,
1127 der den Grund nennt. Ein `disabled` Knopf nähme den Fokus mit auf
1128 `<body>` – und seine Begründung würde nie vorgelesen.
1129 */
1130 const knopf = screen.getByRole('button', { name: /Weiterlernen/u });
1131 expect(knopf).toBeEnabled();
1132 expect(knopf).toHaveAttribute('aria-disabled', 'true');
1133 expect(knopf).toHaveAccessibleDescription(/lässt sich keine Lernsitzung starten/u);
1134 });
1135
1136 it('lässt den gesperrten Einstieg nichts auslösen und den Fokus behalten', async () => {
1137 const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG));
1138 const nutzer = starten(
1139 attrappe({
1140 katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))),
1141 lernSitzung,
1142 }),
1143 );
1144
1145 const knopf = await screen.findByRole('button', { name: /Weiterlernen/u });
1146 await waitFor(() => {
1147 expect(knopf).toHaveAttribute('aria-disabled', 'true');
1148 });
1149 await nutzer.click(knopf);
1150
1151 expect(lernSitzung).not.toHaveBeenCalled();
1152 /* Der Knopf ist noch da und noch anwählbar – das ist der ganze Punkt. */
1153 knopf.focus();
1154 expect(knopf).toHaveFocus();
1155 });
1156
1157 it('meldet einen Fehler des Katalogkanals und bietet einen neuen Versuch an', async () => {
1158 starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('Datei fehlt'))) }));
1159
1160 expect(await screen.findByText(/Datei fehlt/u)).toBeInTheDocument();
1161 expect(screen.getByRole('button', { name: 'Fragenkatalog erneut laden' })).toBeEnabled();
1162 });
1163
1164 it('bleibt ohne Lernprofil bedienbar und übt aus dem Katalog', async () => {
1165 const nutzer = starten(ohneKanaele('profilListe', 'lernUebersicht'));
1166
1167 expect(
1168 await screen.findByText(
1169 /Sie können trotzdem üben; Ihre Antworten werden dabei nicht gespeichert/u,
1170 ),
1171 ).toBeInTheDocument();
1172
1173 const knopf = screen.getByRole('button', { name: /Weiterlernen/u });
1174 await waitFor(() => {
1175 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1176 });
1177 await nutzer.click(knopf);
1178
1179 expect(await screen.findByRole('heading', { name: /, Frage \d/u })).toBeInTheDocument();
1180 expect(screen.getByText(/Ihre Antworten werden nicht gespeichert/u)).toBeInTheDocument();
1181 });
1182
1183 it('zeigt den Lernstandsfehler an, ohne die Oberfläche zu blockieren', async () => {
1184 starten(
1185 attrappe({ lernUebersicht: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))) }),
1186 );
1187
1188 expect(
1189 await screen.findByText(/Ihre Prüfungsreife konnte nicht geladen werden/u),
1190 ).toBeInTheDocument();
1191
1192 const knopf = screen.getByRole('button', { name: /Weiterlernen/u });
1193 await waitFor(() => {
1194 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1195 });
1196 });
1197 });
1198
1199 describe('Startbildschirm – Barrierefreiheits-Rauchtest (axe-core)', () => {
1200 it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => {
1201 starten();
1202 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
1203
1204 const ergebnis = await axe.run(document.body, jsdomOptionen());
1205
1206 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
1207 });
1208 });
1209
1210 describe('Kapitel dauerhaft abwählen', () => {
1211 /*
1212 Die Oberflächenseite der dauerhaften Abwahl. Der Kern – welche Fragen
1213 danach noch kommen und wie sich Zähler und Prognose ändern – ist in
1214 `tests/lernstand.test.ts` gegen eine echte Datenbank gemessen. Hier geht
1215 es nur um das, was der Nutzer sieht und hört.
1216
1217 Der eigene Katalog statt einer Erweiterung des gemeinsamen: Kapitel IV
1218 hier allgemein einzuhängen änderte die Kapitelwahl und die Wege ins
1219 Lernen für jeden anderen Test in dieser Datei mit.
1220 */
1221 const KATALOG_MIT_IV: Katalog = {
1222 ...KATALOG,
1223 kapitel: [
1224 ...KATALOG.kapitel,
1225 {
1226 id: 'IV',
1227 titel: 'Not- und Seenotsignalmittel',
1228 abschnitte: [{ id: 'IV.1', titel: 'Signalmittel' }],
1229 },
1230 ],
1231 };
1232
1233 function mitKapitelIV(ueberschreibungen: TeilBridge = {}): TeilBridge {
1234 return attrappe({
1235 katalogLaden: vi.fn(() => Promise.resolve(KATALOG_MIT_IV)),
1236 ...ueberschreibungen,
1237 });
1238 }
1239
1240 it('bietet den Schalter unter „Ihr Lernplan“ an, nicht in der Profilverwaltung', async () => {
1241 /* Die Profilverwaltung führt das Register – anlegen, wechseln, umbenennen,
1242 löschen. Der Schalter gehört dorthin, wo seine Wirkung sichtbar wird:
1243 neben den Prüfungstermin. Der Termin sagt, bis wann; die Kapitelwahl
1244 sagt, wie viel. */
1245 starten(mitKapitelIV());
1246
1247 const schalter = await screen.findByRole('checkbox', {
1248 name: 'Kapitel IV – Not- und Seenotsignalmittel mitlernen',
1249 });
1250 expect(schalter).toBeChecked();
1251 expect(screen.getByRole('region', { name: 'Ihr Lernplan' })).toContainElement(schalter);
1252 });
1253
1254 it('bleibt fort, wenn der Katalog das Kapitel gar nicht führt', async () => {
1255 /* Der gemeinsame Katalog dieser Datei hat nur Kapitel I. Ein Schalter für
1256 ein Kapitel, das es nicht gibt, wäre eine leere Zusage. */
1257 starten();
1258 await screen.findByRole('region', { name: 'Ihr Lernplan' });
1259
1260 expect(screen.queryByRole('checkbox', { name: /mitlernen/u })).not.toBeInTheDocument();
1261 });
1262
1263 it('meldet die Abwahl an den Anwendungskern und lädt beide Ansichten neu', async () => {
1264 const bruecke = mitKapitelIV();
1265 const nutzer = starten(bruecke);
1266
1267 await nutzer.click(await screen.findByRole('checkbox', { name: /mitlernen/u }));
1268
1269 await waitFor(() => {
1270 expect(bruecke.profilAktualisieren).toHaveBeenCalledWith(1, { kapitelAusschluss: ['IV'] });
1271 });
1272 /* Lernplan und Lernstand sind entkoppelt und haben getrennte Zähler. Wer
1273 nur einen anstößt, zeigt für eine Weile 575 im Lernstand und 486 im
1274 Plan – untereinander auf demselben Bildschirm. */
1275 await waitFor(() => {
1276 expect(bruecke.lernPlan).toHaveBeenCalledTimes(2);
1277 expect(bruecke.lernUebersicht).toHaveBeenCalledTimes(2);
1278 });
1279 });
1280
1281 it('sagt die Umstellung an – die geänderten Zahlen stehen weiter oben', async () => {
1282 /* Der Schalter steht unter „Ihr Lernplan“, Fortschrittsbalken und
1283 Lernstand stehen im Dokument davor. Wer mit Bildschirmleser arbeitet,
1284 hat sie beim Umlegen längst passiert und merkte sonst nichts. */
1285 const nutzer = starten(mitKapitelIV());
1286
1287 await nutzer.click(await screen.findByRole('checkbox', { name: /mitlernen/u }));
1288
1289 expect(
1290 await screen.findByText(/Kapitel IV ist für dieses Profil abgewählt/u),
1291 ).toBeInTheDocument();
1292 });
1293
1294 it('vermerkt das abgewählte Kapitel in der Kapitelwahl, statt es zu verstecken', async () => {
1295 /* Weder verschwinden noch sperren: Verschwände das Kapitel, fände
1296 niemand den Weg zurück; und gesperrte Bedienelemente nehmen keinen
1297 Fokus – ihre Erklärung würde nie vorgelesen. Üben bleibt möglich, es
1298 zählt nur nicht mit. */
1299 const nutzer = starten(
1300 mitKapitelIV({
1301 profilListe: vi.fn(() => Promise.resolve([{ ...PROFIL, kapitelAusschluss: ['IV'] }])),
1302 }),
1303 );
1304
1305 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1306 await waitFor(() => {
1307 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1308 });
1309 await nutzer.click(knopf);
1310
1311 expect(await screen.findByText(/Für dieses Profil abgewählt/u)).toBeInTheDocument();
1312 expect(screen.getByRole('button', { name: 'Ganzes Kapitel IV üben' })).toBeEnabled();
1313 });
1314
1315 it('bietet für Kapitel ohne amtliche Abschnitte die Themen an', async () => {
1316 /* Der amtliche Katalog gliedert nur Kapitel I in Abschnitte. Für die 230
1317 Fragen der Kapitel II bis IV gab es bis 0.22.0 nur „ganzes Kapitel“ –
1318 92 Fragen allein in Kapitel II. Die Gruppen stammen aus
1319 content/themen.json und sind eine Ergänzung dieser Software. */
1320 const nutzer = starten(
1321 mitKapitelIV({
1322 themenLaden: vi.fn(() =>
1323 Promise.resolve({
1324 meta: { version: 1, stand: '2026-08-29', hinweis: 'Gliederung dieser Software' },
1325 gruppen: [
1326 {
1327 id: 'IV.10',
1328 kapitel: 'IV',
1329 titel: 'Störungen und Versager',
1330 fragen: ['IV-72', 'IV-73', 'IV-74'],
1331 },
1332 ],
1333 }),
1334 ),
1335 }),
1336 );
1337
1338 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1339 await waitFor(() => {
1340 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1341 });
1342 await nutzer.click(knopf);
1343
1344 const thema = await screen.findByRole('button', { name: 'Störungen und Versager (3)' });
1345 expect(thema).toBeInTheDocument();
1346 // Die Herkunft steht dabei: Der amtliche Katalog gliedert hier nicht.
1347 expect(
1348 screen.getByText(/Die Themen sind eine Gliederung dieser Software/u),
1349 ).toBeInTheDocument();
1350 });
1351
1352 it('kommt ohne Themendatei aus', async () => {
1353 // Dann steht da, was vor 0.22.0 dastand – und nicht etwa nichts.
1354 const nutzer = starten(mitKapitelIV());
1355
1356 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1357 await waitFor(() => {
1358 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1359 });
1360 await nutzer.click(knopf);
1361
1362 expect(await screen.findByRole('button', { name: 'Ganzes Kapitel IV üben' })).toBeEnabled();
1363 expect(screen.queryByText(/Die Themen sind eine Gliederung/u)).not.toBeInTheDocument();
1364 });
1365 });
1366
1367 describe('Lernstand sichern und übertragen', () => {
1368 /*
1369 Die Oberflächenseite von D9. Der Kern – Prüfkette, Reihenfolge beim
1370 Ersetzen, Sicherheitskopie – ist in `tests/sicherung.test.ts` gegen echte
1371 SQLite-Dateien gemessen. Hier geht es um das, was der Nutzer sieht und
1372 entscheidet, bevor etwas ersetzt wird.
1373 */
1374 const KENNZAHLEN = {
1375 profilnamen: ['Olaf'],
1376 jeProfil: [
1377 {
1378 name: 'Olaf',
1379 antworten: 1204,
1380 gemerkt: 12,
1381 pruefungslaeufe: 3,
1382 letzteAntwort: '2026-05-03T21:07:00.000Z',
1383 },
1384 ],
1385 antworten: 1204,
1386 letzteAntwort: '2026-05-03T21:07:00.000Z',
1387 gemerkt: 12,
1388 offen: 0,
1389 fehler: 0,
1390 pruefungslaeufe: 3,
1391 offenerBogen: false,
1392 schemafassung: 7,
1393 };
1394
1395 const BRAUCHBAR = {
1396 art: 'brauchbar' as const,
1397 vorgang: 'v-1',
1398 dateiname: 'Waffensachkunde-Lernstand-2026-05-03-210700.wsklernstand',
1399 ausDatei: KENNZAHLEN,
1400 /* Der Rechner trägt ein anderes Profil als die Datei – genau der Fall,
1401 den die Verlustanzeige benennen muss. */
1402 jetzt: {
1403 ...KENNZAHLEN,
1404 antworten: 42,
1405 profilnamen: ['Standard'],
1406 jeProfil: [
1407 {
1408 name: 'Standard',
1409 antworten: 42,
1410 gemerkt: 12,
1411 pruefungslaeufe: 3,
1412 letzteAntwort: '2026-05-03T21:07:00.000Z',
1413 },
1414 ],
1415 },
1416 };
1417
1418 it('nennt die beiden Wege ungleich – der gefährlichste Fehler ist die Richtung', async () => {
1419 starten();
1420
1421 expect(
1422 await screen.findByRole('button', { name: 'Sicherung speichern …' }),
1423 ).toBeInTheDocument();
1424 expect(
1425 screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1426 ).toBeInTheDocument();
1427 });
1428
1429 /*
1430 Wann zuletzt gesichert wurde, wurde bis 0.22.0 nirgends festgehalten –
1431 die Karte konnte es also auch nicht sagen, und eine Erinnerung wäre
1432 geraten statt gewusst gewesen. Wer die Karte nie öffnete, hatte nichts,
1433 wenn die Datei einmal weg war.
1434 */
1435 it('sagt, dass noch nie gesichert wurde, statt zu schweigen', async () => {
1436 starten();
1437
1438 /* Der Wortlaut kommt aus derselben Quelle, aus der ihn die Karte nimmt.
1439 `sicherungsstandSatz` ist genau dafuer exportiert – bis Fassung 0.24.1
1440 stand der Satz hier abgeschrieben daneben, und der Export hatte keinen
1441 einzigen Nutzer ausserhalb seiner eigenen Datei. */
1442 expect(await screen.findByText(sicherungsstandSatz(null))).toBeInTheDocument();
1443 });
1444
1445 it('nennt den Zeitpunkt der letzten Sicherung', async () => {
1446 starten(
1447 attrappe({
1448 einstellungenLesen: vi.fn(() =>
1449 Promise.resolve({
1450 ...EINSTELLUNGEN_STANDARD,
1451 zuschnittGefragt: true,
1452 letzteSicherung: '2026-08-12T09:15:00.000Z',
1453 }),
1454 ),
1455 }),
1456 );
1457
1458 expect(
1459 await screen.findByText(sicherungsstandSatz('2026-08-12T09:15:00.000Z')),
1460 ).toBeInTheDocument();
1461 });
1462
1463 it('sagt, dass die selbsttätige Kopie keine Sicherung ersetzt', async () => {
1464 /* Die wöchentliche Kopie liegt auf derselben Festplatte. Sie als
1465 Sicherung auszugeben wäre die gefährlichere Unwahrheit: Wer sich
1466 darauf verlässt, sichert nicht mehr selbst. */
1467 starten();
1468
1469 expect(
1470 await screen.findByText(/ersetzt deshalb keine Sicherung, die Sie anderswo aufheben/u),
1471 ).toBeInTheDocument();
1472 });
1473
1474 it('nennt den Ordner, in dem die selbsttätigen Kopien liegen', async () => {
1475 /* Die Wache, die gefehlt hat. Die Karte erzählte von der Kopie, ohne zu
1476 sagen, wo sie liegt – „in seinem eigenen Ordner“. Für jemanden, der
1477 sie im Ernstfall braucht, ist das keine Auskunft: Weder eine Liste
1478 noch ein Dateidialog führt dorthin, und der Ordnername kam in der
1479 ganzen Oberfläche nicht vor. */
1480 starten();
1481
1482 expect(await screen.findByText(/Unterordner/u)).toBeInTheDocument();
1483 expect(screen.getByText('sicherungen')).toBeInTheDocument();
1484 });
1485
1486 it('sagt, dass auch vor den zerstörenden Schritten eine Kopie entsteht', async () => {
1487 /* Seit 0.26.6 legen „Neu anfangen“ und das Löschen eines Profils vorher
1488 eine Kopie an. Steht das nirgends, verlässt sich niemand darauf – und
1489 der Nutzen der Kopie hängt daran, dass man von ihr weiß. */
1490 starten();
1491
1492 expect(await screen.findByText(/unterbleibt der Schritt/u)).toBeInTheDocument();
1493 });
1494
1495 it('öffnet den Ordner der Sicherheitskopien', async () => {
1496 /* Die Wache, die gefehlt hat. Bis 0.26.7 führte weder eine Liste noch
1497 ein Dateidialog zu den selbsttätigen Kopien – wer eine im Ernstfall
1498 brauchte, musste den Pfad kennen. */
1499 const sicherungsordnerZeigen = vi.fn(() => Promise.resolve(true));
1500 const nutzer = starten(attrappe({ sicherungsordnerZeigen }));
1501
1502 await nutzer.click(
1503 await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }),
1504 );
1505
1506 expect(sicherungsordnerZeigen).toHaveBeenCalledWith();
1507 expect(await screen.findByText('Der Ordner ist geöffnet.')).toBeInTheDocument();
1508 });
1509
1510 it('sagt es, wenn es den Ordner noch gar nicht gibt', async () => {
1511 /* Er entsteht erst mit der ersten Kopie. So zu tun, als sei etwas
1512 geschehen, wäre die schlechtere Antwort. */
1513 const nutzer = starten(
1514 attrappe({ sicherungsordnerZeigen: vi.fn(() => Promise.resolve(false)) }),
1515 );
1516
1517 await nutzer.click(
1518 await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }),
1519 );
1520
1521 expect(await screen.findByText(/Den Ordner gibt es noch nicht/u)).toBeInTheDocument();
1522 });
1523
1524 it('nennt den fehlenden Kanal, statt stumm zu bleiben', async () => {
1525 const nutzer = starten(ohneKanaele('sicherungsordnerZeigen'));
1526
1527 await nutzer.click(
1528 await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }),
1529 );
1530
1531 expect(await screen.findByText(/sicherung:ordner-zeigen/u)).toBeInTheDocument();
1532 });
1533
1534 it('lässt den Sitzungsumfang einstellen', async () => {
1535 /* Die Wache, die gefehlt hat. `SITZUNGSUMFANG = 20` war eine Konstante,
1536 und alle sieben Sitzungsstarts reichten sie durch – obwohl der
1537 Anwendungskern jede Zahl von 1 bis 1000 annimmt. Wer täglich zehn
1538 Minuten hat, bekam dieselben zwanzig Fragen wie jemand mit einer
1539 Stunde und ließ die Sitzung halb liegen. */
1540 const einstellungenSchreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD));
1541 const nutzer = starten(attrappe({ einstellungenSchreiben }));
1542
1543 await nutzer.selectOptions(await screen.findByLabelText('Fragen je Lernsitzung'), '10');
1544
1545 expect(einstellungenSchreiben).toHaveBeenCalledWith(
1546 expect.objectContaining({ sitzungsumfang: 10 }),
1547 );
1548 });
1549
1550 it('legt die eingestellte Zahl an den Kern weiter', async () => {
1551 const lernSitzung = vi.fn(() => Promise.resolve([]));
1552 const nutzer = starten(
1553 attrappe({
1554 lernSitzung,
1555 einstellungenLesen: vi.fn(() =>
1556 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, sitzungsumfang: 8 }),
1557 ),
1558 }),
1559 );
1560
1561 await nutzer.click(await screen.findByRole('button', { name: /Weiterlernen/u }));
1562
1563 expect(lernSitzung).toHaveBeenCalledWith(
1564 expect.anything(),
1565 expect.objectContaining({ anzahl: 8 }),
1566 );
1567 });
1568
1569 it('ersetzt nichts, bevor gefragt wurde', async () => {
1570 /* Der Prüfschritt ist folgenlos. Erst nach der Rückfrage darf der Kanal
1571 laufen, der wirklich etwas anfasst. */
1572 const sicherungEinspielen = vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const }));
1573 const nutzer = starten(
1574 attrappe({
1575 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1576 sicherungEinspielen,
1577 }),
1578 );
1579
1580 await nutzer.click(
1581 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1582 );
1583
1584 expect(
1585 await screen.findByText(/Diesen Lernstand durch die Sicherung ersetzen\?/u),
1586 ).toBeVisible();
1587 expect(sicherungEinspielen).not.toHaveBeenCalled();
1588 });
1589
1590 it('stellt beide Seiten mit denselben Zahlen gegenüber', async () => {
1591 /* Der Datei sieht niemand an, von wann sie ist. Ohne die Gegenüberstellung
1592 entscheidet man blind. */
1593 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1594
1595 await nutzer.click(
1596 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1597 );
1598
1599 /* „Antworten“ und nicht „beantwortete Fragen“: Die Zahl zählt Zeilen im
1600 Protokoll, also auch Wiederholungen derselben Frage. Bis 0.19.0 hieß
1601 sie anders und zählte zusätzlich nie aufgeschlagene Prüfungsfragen mit. */
1602 expect(await screen.findByText(/1 Profil: Olaf · 1\.204 Antworten/u)).toBeVisible();
1603 expect(screen.getByText(/1 Profil: Standard · 42 Antworten/u)).toBeVisible();
1604 });
1605
1606 it('lässt die Eingabetaste auf das Harmlose fallen', async () => {
1607 /* Wer aus Versehen in der Rückfrage landet, trifft mit der Eingabetaste
1608 „behalten“ und nicht „ersetzen“. */
1609 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1610
1611 await nutzer.click(
1612 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1613 );
1614 const knoepfe = screen.getAllByRole('button', { name: /behalten|ersetzen/u });
1615
1616 expect(knoepfe[0]).toHaveAccessibleName('Nein, meinen Lernstand behalten');
1617 });
1618
1619 it('benennt den unterbrochenen Prüfungsbogen als Verlust', async () => {
1620 /* Das einzige, was sich nicht in einer Zahl ausdrücken lässt – und
1621 deshalb sonst stillschweigend verschwände. */
1622 const nutzer = starten(
1623 attrappe({
1624 sicherungPruefen: vi.fn(() =>
1625 Promise.resolve({ ...BRAUCHBAR, jetzt: { ...BRAUCHBAR.jetzt, offenerBogen: true } }),
1626 ),
1627 }),
1628 );
1629
1630 await nutzer.click(
1631 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1632 );
1633
1634 /* Der Satz steht jetzt in der Verlustanzeige, zusammen mit den übrigen
1635 Verlusten – er ist einer davon, nur eben keine Zahl. */
1636 expect(await screen.findByText(/Ein unterbrochener Prüfungsbogen liegt hier/u)).toBeVisible();
1637 });
1638
1639 /*
1640 Zwei Gesamtzahlen nebeneinander genügen nicht: Wer hier ein Profil hat,
1641 das die Datei nicht kennt, liest womöglich „mehr Antworten“ und verliert
1642 trotzdem sein ganzes Profil. Verglichen wird deshalb je Profil.
1643 */
1644 it('benennt ein Profil, das die Datei gar nicht kennt', async () => {
1645 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1646
1647 await nutzer.click(
1648 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1649 );
1650
1651 expect(
1652 await screen.findByText(/Das Profil „Standard“ kommt in der Datei nicht vor/u),
1653 ).toBeVisible();
1654 });
1655
1656 /*
1657 Der dritte Weg. Er steht bewusst VOR dem Ersetzen und trägt die
1658 harmlosere Beschriftung: Wer nur den Stand vom anderen Gerät braucht, soll
1659 hier stehenbleiben und nicht bis zur roten Schaltfläche weiterlesen.
1660 */
1661 it('bietet an, ein einzelnes Profil dazuzunehmen', async () => {
1662 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1663
1664 await nutzer.click(
1665 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1666 );
1667
1668 expect(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' })).toBeVisible();
1669 expect(screen.getByRole('combobox', { name: 'Profil aus der Datei' })).toHaveDisplayValue(
1670 'Olaf (1204 Antworten)',
1671 );
1672 });
1673
1674 it('schickt den Index in die Liste, nie eine Profilnummer', async () => {
1675 /* Die Profilnummern der fremden Datei kennt der Renderer nicht, und er
1676 soll sie nicht kennen: In einer fremden Datei führte eine Nummer
1677 geradewegs in das Profil einer anderen Person. */
1678 const sicherungUebernehmen = vi.fn(() =>
1679 Promise.resolve({
1680 art: 'uebernommen' as const,
1681 profilId: 7,
1682 name: 'Olaf (übernommen)',
1683 antworten: 1204,
1684 bearbeiteteFragen: 480,
1685 sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand',
1686 katalogstandDerQuelle: null,
1687 }),
1688 );
1689 const nutzer = starten(
1690 attrappe({
1691 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1692 sicherungUebernehmen,
1693 }),
1694 );
1695
1696 await nutzer.click(
1697 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1698 );
1699 await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' }));
1700
1701 expect(sicherungUebernehmen).toHaveBeenCalledWith(BRAUCHBAR.vorgang, 0);
1702 expect(
1703 await screen.findByText(/Das Profil „Olaf \(übernommen\)“ wurde übernommen/u),
1704 ).toBeVisible();
1705 });
1706
1707 it('nennt in der Erfolgsmeldung die Sicherheitskopie und dass nichts ersetzt wurde', async () => {
1708 const nutzer = starten(
1709 attrappe({
1710 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1711 sicherungUebernehmen: vi.fn(() =>
1712 Promise.resolve({
1713 art: 'uebernommen' as const,
1714 profilId: 7,
1715 name: 'Olaf',
1716 antworten: 1,
1717 bearbeiteteFragen: 1,
1718 sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand',
1719 katalogstandDerQuelle: null,
1720 }),
1721 ),
1722 }),
1723 );
1724
1725 await nutzer.click(
1726 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1727 );
1728 await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' }));
1729
1730 const meldung = await screen.findByText(/wurde übernommen/u);
1731 /* Beide Zahlen in einem Satz, der ihr Verhältnis nennt: Bis 0.19.1 stand
1732 darüber „39 Antworten“ und darunter „24 bearbeitete Fragen“ – beides
1733 richtig, und trotzdem las es sich wie ein Widerspruch. */
1734 expect(meldung).toHaveTextContent(/1 Antwort zu 1 verschiedenen Fragen/u);
1735 expect(meldung).toHaveTextContent(/Ihre übrigen Profile sind unverändert/u);
1736 expect(meldung).toHaveTextContent(/Lernstand-vor-dem-Uebernehmen-/u);
1737 });
1738
1739 it('sagt bei gleichem Stand, WAS verglichen wurde – nicht „alles stimmt“', async () => {
1740 /* Ein unbedingter Satz über einen Inhalt, der nicht vollständig geprüft
1741 wurde, ist genau der Fehler aus docs/stand.md 7.6. Prüfungstermin und
1742 Kapitelabwahl werden mitersetzt und sind nicht verglichen. */
1743 const gleich = { ...BRAUCHBAR, jetzt: BRAUCHBAR.ausDatei };
1744 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(gleich)) }));
1745
1746 await nutzer.click(
1747 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1748 );
1749
1750 const satz = await screen.findByText(/Verglichen wurden Profile, Antworten/u);
1751 expect(satz).toHaveTextContent(/Nicht verglichen wurden Prüfungstermin und Kapitelabwahl/u);
1752 });
1753
1754 it('wechselt nach dem Einspielen auf das erste Profil der neuen Datenbank', async () => {
1755 /*
1756 Nicht verzichtbar: `useLernstand` hält die zuletzt gewählte Nummer in
1757 einem Ref, das ein Neuladen überlebt. Steht dort eine Nummer, die es in
1758 der eingespielten Datenbank zufällig auch gibt, führte sie geradewegs in
1759 das Profil einer anderen Person.
1760 */
1761 const bruecke = attrappe({
1762 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1763 sicherungEinspielen: vi.fn(() =>
1764 Promise.resolve({
1765 art: 'eingespielt' as const,
1766 profilzahl: 1,
1767 ersteProfilId: 1,
1768 bearbeiteteFragen: 480,
1769 einstellungenUebernommen: false,
1770 sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand',
1771 katalogstandDerQuelle: null,
1772 }),
1773 ),
1774 });
1775 const nutzer = starten(bruecke);
1776
1777 await nutzer.click(
1778 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1779 );
1780 await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' }));
1781
1782 expect(await screen.findByText(/Der Lernstand ist eingelesen/u)).toBeVisible();
1783 /* Die Sicherheitskopie wird beim Namen genannt – sie ist der einzige Weg
1784 zurück. */
1785 expect(screen.getByText(/Lernstand-vor-dem-Einspielen-/u)).toBeVisible();
1786 await waitFor(() => {
1787 expect(bruecke.lernUebersicht).toHaveBeenCalledTimes(2);
1788 });
1789 });
1790
1791 it('schweigt von Einstellungen, wenn die Sicherung keine enthielt', async () => {
1792 /* Der Regelfall der Vergangenheit: jede Sicherung vor Fassung 0.27.0.
1793 Eine Meldung „übernommen“ wäre dann schlicht unwahr. */
1794 const nutzer = starten(
1795 attrappe({
1796 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1797 sicherungEinspielen: vi.fn(() =>
1798 Promise.resolve({
1799 art: 'eingespielt' as const,
1800 profilzahl: 1,
1801 ersteProfilId: 1,
1802 bearbeiteteFragen: 12,
1803 einstellungenUebernommen: false,
1804 sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand',
1805 }),
1806 ),
1807 }),
1808 );
1809
1810 await nutzer.click(
1811 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1812 );
1813 await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' }));
1814
1815 expect(await screen.findByText(/Der Lernstand ist eingelesen/u)).toBeVisible();
1816 expect(screen.queryByText(/mit übernommen worden/u)).toBeNull();
1817 });
1818
1819 it('nennt die übernommenen Einstellungen und lässt sie sofort gelten', async () => {
1820 /*
1821 Der Grund für die ganze Einrichtung: Wer 400 Prozent Anzeigegröße
1822 braucht, musste bis 0.26.7 auf dem zweiten Rechner ohne sie anfangen –
1823 um sie einzustellen.
1824
1825 Geprüft wird beides zusammen, weil nur beides zusammen wahr ist: Die
1826 Karte sagt „übernommen“, UND die Oberfläche liest die Einstellungen
1827 wirklich noch einmal. Ohne das zweite stünde die Meldung da, während
1828 Schrift, Farben und Tasten die alten blieben.
1829 */
1830 const lesen = vi.fn(() =>
1831 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
1832 );
1833 const nutzer = starten(
1834 attrappe({
1835 einstellungenLesen: lesen,
1836 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1837 sicherungEinspielen: vi.fn(() =>
1838 Promise.resolve({
1839 art: 'eingespielt' as const,
1840 profilzahl: 1,
1841 ersteProfilId: 1,
1842 bearbeiteteFragen: 12,
1843 einstellungenUebernommen: true,
1844 sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand',
1845 }),
1846 ),
1847 }),
1848 );
1849
1850 await nutzer.click(
1851 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1852 );
1853 const vorEinspielen = lesen.mock.calls.length;
1854 await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' }));
1855
1856 expect(
1857 await screen.findByText(/Ihre Einstellungen für Anzeige, Farben, Vorlesen und Tasten/u),
1858 ).toBeVisible();
1859 /* Drei Hooks lesen die Datei getrennt (siehe `einstellungssignal.ts`);
1860 geprüft wird, dass überhaupt neu gelesen wird, nicht wie oft. */
1861 await waitFor(() => {
1862 expect(lesen.mock.calls.length).toBeGreaterThan(vorEinspielen);
1863 });
1864 });
1865
1866 it('sagt nach einer Abweisung, dass nichts verändert wurde', async () => {
1867 const nutzer = starten(
1868 attrappe({
1869 sicherungPruefen: vi.fn(() =>
1870 Promise.resolve({
1871 art: 'abgelehnt' as const,
1872 grund: 'Diese Datei ist leer oder viel zu klein. Es wurde nichts verändert.',
1873 }),
1874 ),
1875 }),
1876 );
1877
1878 await nutzer.click(
1879 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1880 );
1881
1882 expect(await screen.findByText(/Es wurde nichts verändert/u)).toBeVisible();
1883 });
1884 });
1885
1886 describe('Was heute ansteht und was heute geschah', () => {
1887 /*
1888 `pensum.gesamt` ist `neu + fällig`, und `neu` ist eine Rate, die sich
1889 nachfüllt. Wer zwanzig neue Fragen bearbeitete, las danach erneut
1890 „Heute 20 Fragen“ – ein Satz, der nach getaner Arbeit dasselbe verlangt
1891 wie davor.
1892 */
1893 const MIT_ARBEIT = { ...UEBERSICHT, heuteBearbeitet: 18 };
1894
1895 it('nennt am unangefangenen Tag die Empfehlung', async () => {
1896 starten(
1897 attrappe({
1898 lernPlan: vi.fn(() =>
1899 Promise.resolve({
1900 ...LERNPLAN,
1901 pensum: { neu: 14, wiederholung: 6, gesamt: 20, minuten: 8 },
1902 }),
1903 ),
1904 }),
1905 );
1906
1907 expect(await screen.findByText('Heute 20 Fragen, etwa 8 Minuten.')).toBeInTheDocument();
1908 });
1909
1910 it('nennt nach der ersten Antwort die Arbeit und beide Restgrößen', async () => {
1911 /* Beide, nicht nur die fällige: Wer einen Prüfungstermin hat, entscheidet
1912 über die neuen Fragen, ob der Katalog bis dahin durchläuft. */
1913 starten(
1914 attrappe({
1915 lernUebersicht: vi.fn(() => Promise.resolve(MIT_ARBEIT)),
1916 lernPlan: vi.fn(() =>
1917 Promise.resolve({
1918 ...LERNPLAN,
1919 pensum: { neu: 5, wiederholung: 6, gesamt: 11, minuten: 5 },
1920 }),
1921 ),
1922 }),
1923 );
1924
1925 expect(
1926 await screen.findByText(
1927 'Heute 18 Fragen bearbeitet; offen sind noch 6 zur Wiederholung und 5 neue.',
1928 ),
1929 ).toBeInTheDocument();
1930 });
1931
1932 it('zeigt nirgends eine Null und nirgends das Wort „Ziel“', async () => {
1933 /* Kein Ring, kein Bruch, kein Prozentwert. Wer heute nichts getan hat,
1934 liest die Empfehlung – nicht „Heute 0 Fragen bearbeitet“. */
1935 starten();
1936 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
1937
1938 const einstieg = screen.getByRole('region', { name: 'Heute lernen' });
1939 expect(einstieg).not.toHaveTextContent(/Heute 0 Fragen bearbeitet/u);
1940 expect(einstieg).not.toHaveTextContent(/Ziel/u);
1941 });
1942
1943 it('sagt beim verstrichenen Termin, was Sache ist', async () => {
1944 /*
1945 Nach dem Termin setzt der Lernplan `neu` auf 0 – nicht weil alles
1946 gesehen wäre, sondern weil es nichts mehr zu verteilen gibt. „Auf dem
1947 Laufenden“ wäre dann eine Behauptung über den Katalog, die nicht stimmt.
1948 */
1949 starten(
1950 attrappe({
1951 lernPlan: vi.fn(() =>
1952 Promise.resolve({
1953 ...LERNPLAN,
1954 machbarkeit: 'termin_vorbei' as const,
1955 pensum: { neu: 0, wiederholung: 0, gesamt: 0, minuten: 0 },
1956 }),
1957 ),
1958 }),
1959 );
1960
1961 expect(await screen.findByText(/Prüfungstermin ist verstrichen/u)).toBeInTheDocument();
1962 expect(screen.queryByText(/auf dem Laufenden/u)).toBeNull();
1963 });
1964
1965 it('nennt im Lernstand, wann zuletzt gelernt wurde', async () => {
1966 /* Die Tatsache statt eines Zählers: Wer nach zwei Wochen zurückkommt,
1967 sieht einen gefallenen Reifegrad und erfährt sonst nirgends, warum. */
1968 starten(
1969 attrappe({
1970 lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, tageSeitLetzterAntwort: 14 })),
1971 }),
1972 );
1973
1974 expect(await screen.findByText('Zuletzt gelernt')).toBeInTheDocument();
1975 expect(screen.getByText('vor 14 Tagen')).toBeInTheDocument();
1976 });
1977
1978 it('unterscheidet „noch nie“ von „heute“', async () => {
1979 starten();
1980
1981 expect(await screen.findByText('noch nie')).toBeInTheDocument();
1982 });
1983 });
1984
1985 describe('Der Prüfungstag auf dem Startbildschirm', () => {
1986 it('sagt, dass keine neuen Fragen mehr dazukommen', async () => {
1987 /* Wer heute geprüft wird, will keine Empfehlung, sondern eine Auskunft.
1988 Vorher stand hier „Heute 320 Fragen, etwa 133 Minuten“. */
1989 starten(
1990 attrappe({
1991 lernPlan: vi.fn(() =>
1992 Promise.resolve({
1993 ...LERNPLAN,
1994 termin: '2026-06-15',
1995 tageBisTermin: 0,
1996 pensum: { neu: 0, wiederholung: 43, gesamt: 43, minuten: 18 },
1997 }),
1998 ),
1999 }),
2000 );
2001
2002 expect(
2003 await screen.findByText(
2004 'Heute ist Ihr Prüfungstermin. Neue Fragen kommen keine mehr dazu; zum Auffrischen stehen 43 Fragen zur Wiederholung bereit.',
2005 ),
2006 ).toBeInTheDocument();
2007 });
2008
2009 it('meldet am Prüfungstag ohne Fälliges, dass nichts mehr ansteht', async () => {
2010 starten(
2011 attrappe({
2012 lernPlan: vi.fn(() =>
2013 Promise.resolve({
2014 ...LERNPLAN,
2015 termin: '2026-06-15',
2016 tageBisTermin: 0,
2017 pensum: { neu: 0, wiederholung: 0, gesamt: 0, minuten: 0 },
2018 }),
2019 ),
2020 }),
2021 );
2022
2023 expect(
2024 await screen.findByText('Heute ist Ihr Prüfungstermin. Es steht nichts mehr an.'),
2025 ).toBeInTheDocument();
2026 });
2027 });
2028
2029 describe('Binnennavigation auf der langen Seite', () => {
2030 /*
2031 Der Startbildschirm ist bei 1265 Pixel Breite 8530 Pixel hoch – gemessen
2032 am 30.08.2026 an docs/bildschirmfotos/01-startbildschirm-hell.png, im
2033 hohen Kontrast 8602. Zum Stand 0.20.0 waren es 8078, vor dem
2034 Zusammenlegen der Druckkarten 9032. Wer mit einem Bildschirmleser
2035 arbeitet, hat die Überschriftenliste; wer sieht, hatte bisher nur das
2036 Rollen – kein Verzeichnis, keine Anker.
2037 */
2038
2039 function sprungmenue(): HTMLElement {
2040 return screen.getByRole('navigation', { name: 'Weiter unten auf dieser Seite' });
2041 }
2042
2043 it('führt jede Karte unterhalb des Menüs auch als Eintrag', async () => {
2044 /* Der Punkt dieser Prüfung ist die Vollständigkeit: Eine neu angelegte
2045 Karte, die im Verzeichnis fehlt, ist genau der Rückfall in den
2046 Zustand vor 0.20.0 – nur unauffälliger. */
2047 starten();
2048 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2049
2050 const menue = sprungmenue();
2051 const eintraege = within(menue).getAllByRole('link');
2052 const kartenDarunter = [...document.querySelectorAll('main section.karte h2')].filter(
2053 (ueberschrift) =>
2054 (menue.compareDocumentPosition(ueberschrift) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
2055 );
2056
2057 expect(eintraege).toHaveLength(kartenDarunter.length);
2058 });
2059
2060 it('landet mit jedem Eintrag auf einer Kartenüberschrift', async () => {
2061 /* Eine Kennung, die es nicht mehr gibt, macht aus dem Eintrag ein Angebot
2062 ohne Ziel – und der Klick bliebe folgenlos, ohne dass jemand etwas
2063 merkt. Geprüft wird deshalb jeder Eintrag einzeln. */
2064 const nutzer = starten();
2065 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2066
2067 for (const verweis of within(sprungmenue()).getAllByRole('link')) {
2068 const name = verweis.textContent;
2069 await nutzer.click(verweis);
2070 const ziel = document.activeElement;
2071
2072 expect(ziel?.tagName, `„${name}“ trifft keine Überschrift`).toBe('H2');
2073 expect(ziel?.closest('section.karte'), `„${name}“ trifft keine Karte`).not.toBeNull();
2074 }
2075 });
2076
2077 it('trägt die Einträge als Verweise, nicht als zweite Knöpfe', async () => {
2078 /* Die Namen der Einträge sind dieselben wie die der Schaltflächen in den
2079 Karten – „Fragen durchsuchen“ steht seit der Vereinheitlichung an
2080 beiden Stellen. Als Schaltfläche wäre jeder Eintrag ein zweiter Knopf
2081 desselben Namens; als Verweis ist er eine Sprungmarke und benennt sein
2082 Ziel im `href`. */
2083 starten();
2084 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2085
2086 const menue = sprungmenue();
2087 expect(within(menue).queryAllByRole('button')).toHaveLength(0);
2088 expect(within(menue).getByRole('link', { name: 'Fragen durchsuchen' })).toHaveAttribute(
2089 'href',
2090 '#suche-titel',
2091 );
2092 });
2093
2094 it('springt zur Karte und nimmt den Fokus mit', async () => {
2095 /* Reines Rollen wäre für Tastatur und Bildschirmleser wirkungslos – der
2096 nächste Tabulatorsprung begänne wieder ganz oben (WCAG 2.4.3). */
2097 const nutzer = starten();
2098 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2099
2100 await nutzer.click(within(sprungmenue()).getByRole('link', { name: 'Sichern und übertragen' }));
2101
2102 expect(
2103 screen.getByRole('heading', { name: 'Lernstand sichern und übertragen', level: 2 }),
2104 ).toHaveFocus();
2105 });
2106
2107 it('steht unter der Einstieg-Karte, nicht darüber', async () => {
2108 /* Die Einstieg-Karte hat ein gemessenes Höhenbudget, damit der Weg ins
2109 Lernen im ersten Bildschirm bleibt (e2e/blickfeld.spec.ts). Eine
2110 Leiste davor kostete genau dieses Budget. */
2111 starten();
2112 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2113
2114 const einstieg = screen.getByRole('region', { name: 'Heute lernen' });
2115 const stellung = einstieg.compareDocumentPosition(sprungmenue());
2116
2117 expect(stellung & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
2118 });
2119
2120 it('verkürzt den Tastaturweg in die Verwaltung', async () => {
2121 /*
2122 Nachgemessen in dieser Attrappe (eine Radiogruppe zählt als ein Halt):
2123 Bis zum ersten Bedienelement der Sicherungskarte waren es vor dem
2124 Verzeichnis 20 Tabulatorhalte, alle nacheinander. Über das Verzeichnis
2125 sind es 13 – fünf Einstiege, dann der achte Eintrag der Leiste –,
2126 danach steht der Fokus auf der Überschrift der Karte.
2127
2128 Wer die Leiste nicht benutzt und stumpf weitertabbt, kommt auf 27. Das
2129 ist der Preis, und er ist bewusst bezahlt – ohne Leiste gab es für
2130 sehende Bedienung überhaupt keinen Sprung.
2131
2132 Beide Zahlen sind seit 0.27.0 um zwei gewachsen: Die Karte „Gesetze
2133 lesen“ bringt einen Eintrag in die Leiste und einen Knopf mit.
2134 */
2135 starten();
2136 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2137
2138 const halte = tabulatorhalte();
2139 const sprung = within(sprungmenue()).getByRole('link', { name: 'Sichern und übertragen' });
2140 const sicherung = document.getElementById('sicherung-titel')?.closest('section');
2141
2142 expect(halte.indexOf(sprung)).toBe(12);
2143 expect(halte.findIndex((element) => sicherung?.contains(element) ?? false)).toBe(26);
2144 });
2145 });
2146
2147 describe('Drucken – eine Karte statt dreier', () => {
2148 /*
2149 Lernbericht, Fehlerprotokoll und Fragenliste standen bis 0.20.0 als drei
2150 Karten untereinander, jede mit einer eigenen Frage nach der Schriftgröße.
2151 Im damaligen Bildschirmfoto nahmen sie zusammen rund 1740 der 9032 Pixel
2152 ein; zum Stand 0.20.0 maß dieselbe Seite 8078 und heute 8530.
2153 */
2154
2155 it('fragt zuerst nach dem Dokument und zeigt dann nur dieses', async () => {
2156 starten();
2157
2158 const karte = await screen.findByRole('region', { name: 'Drucken' });
2159 expect(within(karte).getByRole('radio', { name: 'Lernbericht' })).toBeChecked();
2160 expect(within(karte).getByRole('heading', { name: 'Lernbericht', level: 3 })).toBeVisible();
2161
2162 /* Genau eine Schriftgrößenfrage – nicht drei untereinander. */
2163 expect(screen.getAllByRole('group', { name: 'Schriftgröße des Dokuments' })).toHaveLength(1);
2164 expect(
2165 screen.queryByRole('button', { name: 'Fehlerprotokoll als PDF speichern' }),
2166 ).not.toBeInTheDocument();
2167 });
2168
2169 it('stellt auf das gewählte Dokument um', async () => {
2170 const nutzer = starten();
2171
2172 const karte = await screen.findByRole('region', { name: 'Drucken' });
2173 await nutzer.click(within(karte).getByRole('radio', { name: 'Fehlerprotokoll' }));
2174
2175 expect(within(karte).getByRole('heading', { name: 'Fehlerprotokoll', level: 3 })).toBeVisible();
2176 expect(
2177 within(karte).getByRole('button', { name: 'Fehlerprotokoll als PDF speichern' }),
2178 ).toBeVisible();
2179 expect(
2180 within(karte).queryByRole('button', { name: 'Lernbericht als PDF speichern' }),
2181 ).not.toBeInTheDocument();
2182 });
2183
2184 it('bietet die Fragenliste nur an, wenn der Katalog dasteht', async () => {
2185 /* Die Liste hängt am Katalog. Ohne ihn gäbe es nichts zu drucken – dann
2186 steht die Wahl gar nicht erst da, statt in eine gesperrte Schaltfläche
2187 zu führen. */
2188 starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }));
2189
2190 const karte = await screen.findByRole('region', { name: 'Drucken' });
2191 expect(within(karte).getByRole('radio', { name: 'Lernbericht' })).toBeInTheDocument();
2192 expect(within(karte).queryByRole('radio', { name: 'Fragenliste' })).not.toBeInTheDocument();
2193 });
2194 });
2195
2196 describe('Startbildschirm – Benennung', () => {
2197 it('nennt die Suche überall gleich', async () => {
2198 /* Bis 0.20.0 hieß dieselbe Sache „Fragen finden“ (Überschrift), „Fragen
2199 durchsuchen“ (Schaltfläche) und „Fragen finden und nachschlagen“
2200 (Hilfe). */
2201 starten();
2202
2203 expect(
2204 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 2 }),
2205 ).toBeInTheDocument();
2206 expect(screen.getByRole('button', { name: 'Fragen durchsuchen' })).toBeInTheDocument();
2207 expect(screen.queryByText('Fragen finden')).not.toBeInTheDocument();
2208 });
2209
2210 it('sagt an der Prüfungsschaltfläche, wohin sie führt', async () => {
2211 /* Der Knopf führt in die Vorbereitung; gestartet wird erst dort. Vorher
2212 hieß er „Prüfungssimulation starten“ und versprach damit den Schritt,
2213 den erst die nächste Ansicht tut. */
2214 const nutzer = starten();
2215
2216 const knopf = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
2217 await waitFor(() => {
2218 expect(knopf).toHaveAttribute('aria-disabled', 'false');
2219 });
2220 await nutzer.click(knopf);
2221
2222 expect(
2223 await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten', level: 1 }),
2224 ).toBeInTheDocument();
2225 });
2226 });
2227
2228 describe('Gesetze lesen', () => {
2229 /*
2230 Die Vorschriften lagen seit 0.22.0 im Paket und waren nur über eine
2231 Frage erreichbar: Wer wissen wollte, was in § 13 WaffG steht, ohne die
2232 passende Frage zu kennen, hatte keinen Weg dorthin.
2233 */
2234
2235 const NORMTEXTE: Normtexte = {
2236 meta: { version: 1, stand: '2026-09-01', hinweis: 'Prüfstand', gesetzesstand: {} },
2237 gesetze: {
2238 WaffG: {
2239 bezeichnung: 'Waffengesetz',
2240 stand: 'Zuletzt geändert am 20.3.2026',
2241 quelle: 'gesetze-im-internet.de',
2242 normen: {
2243 '§ 2': {
2244 titel: 'Grundsätze des Umgangs mit Waffen',
2245 istAnlage: false,
2246 absaetze: { '1': 'Der Umgang mit Waffen ist nur Berechtigten gestattet.' },
2247 },
2248 '§ 13': {
2249 titel: 'Jäger',
2250 istAnlage: false,
2251 absaetze: { '1': 'Ein Bedürfnis für den Erwerb von Langwaffen wird bei Jägern …' },
2252 },
2253 },
2254 },
2255 StGB: {
2256 bezeichnung: 'Strafgesetzbuch',
2257 stand: 'Zuletzt geändert am 1.1.2026',
2258 quelle: 'gesetze-im-internet.de',
2259 normen: {
2260 '§ 32': {
2261 titel: 'Notwehr',
2262 istAnlage: false,
2263 absaetze: { '1': 'Wer eine Tat begeht, die durch Notwehr geboten ist …' },
2264 },
2265 },
2266 },
2267 },
2268 };
2269
2270 function mitGesetzen(): ReturnType<typeof attrappe> {
2271 /* Die Normtexte liegen in einem Zwischenspeicher des Moduls; ohne das
2272 Leeren sähe diese Gruppe, was eine frühere Prüfung geladen hat. */
2273 normtexteZwischenspeicherLeeren();
2274 return attrappe({ normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE)) });
2275 }
2276
2277 /** Öffnet die Ansicht und wählt ein Gesetz – der Weg eines Menschen. */
2278 async function inDasGesetz(
2279 nutzer: ReturnType<typeof starten>,
2280 bezeichnung: string,
2281 ): Promise<void> {
2282 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2283 await nutzer.click(await screen.findByRole('button', { name: bezeichnung }));
2284 }
2285
2286 it('führt vom Startbildschirm in die Gesetze und wieder zurück', async () => {
2287 const nutzer = starten(mitGesetzen());
2288
2289 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2290 expect(await screen.findByRole('heading', { name: 'Gesetze lesen', level: 1 })).toBeVisible();
2291
2292 await nutzer.click(screen.getByRole('button', { name: 'Zurück zum Start' }));
2293 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
2294 });
2295
2296 it('nennt zu jedem Gesetz den Änderungsstand', async () => {
2297 /* Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute,
2298 die morgen falsch sein kann. */
2299 const nutzer = starten(mitGesetzen());
2300 await inDasGesetz(nutzer, 'Waffengesetz');
2301
2302 expect(await screen.findByText(/Zuletzt geändert am 20\.3\.2026/u)).toBeVisible();
2303 expect(screen.getByText(/Amtlicher Wortlaut, unverändert übernommen/u)).toBeVisible();
2304 });
2305
2306 it('zeigt den Wortlaut erst beim Aufklappen', async () => {
2307 /* Anlage 1 des WaffG allein hat 29 000 Zeichen; eine Liste, die beim
2308 Öffnen alles ausrollt, ist für niemanden zu überblicken. */
2309 const nutzer = starten(mitGesetzen());
2310 await inDasGesetz(nutzer, 'Waffengesetz');
2311
2312 expect(screen.queryByText(/nur Berechtigten gestattet/u)).toBeNull();
2313 await nutzer.click(await screen.findByText(/§ 2 – Grundsätze des Umgangs mit Waffen/u));
2314 expect(await screen.findByText(/nur Berechtigten gestattet/u)).toBeVisible();
2315 });
2316
2317 it('findet eine Vorschrift über die Suche und sagt, warum sie trifft', async () => {
2318 const nutzer = starten(mitGesetzen());
2319 await inDasGesetz(nutzer, 'Waffengesetz');
2320
2321 await nutzer.type(await screen.findByLabelText('In den Gesetzen suchen'), 'Jäger');
2322
2323 expect(await screen.findByText(/§ 13 – Jäger/u)).toBeVisible();
2324 expect(screen.getByText(/Treffer in der Überschrift/u)).toBeVisible();
2325 expect(screen.queryByText(/§ 2 – Grundsätze/u)).toBeNull();
2326 });
2327
2328 it('sagt in der Auswahl, in welchem Gesetz die Treffer stehen', async () => {
2329 /* Sonst müsste der Suchende sieben Gesetze der Reihe nach durchklicken.
2330 Die Zahl steht als Text da, nicht als Farbe oder Punkt. */
2331 const nutzer = starten(mitGesetzen());
2332 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2333 await nutzer.type(await screen.findByLabelText('In den Gesetzen suchen'), 'Notwehr');
2334
2335 expect(
2336 await screen.findByRole('button', { name: /Strafgesetzbuch.*1 Treffer/u }),
2337 ).toBeVisible();
2338 expect(screen.getByRole('button', { name: /Waffengesetz.*kein Treffer/u })).toBeVisible();
2339 });
2340
2341 it('sagt es, wenn keine Gesetzestexte beiliegen', async () => {
2342 /* Eine leere Liste sähe aus wie ein Fehler. Die Grundattrappe liefert
2343 `NORMTEXTE_LEER`. */
2344 const nutzer = starten();
2345 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2346
2347 expect(await screen.findByText(/zurzeit nicht verfügbar/u)).toBeVisible();
2348 });
2349 });
2350
2351 describe('Kapitelwahl – der Lernstand steht an jeder Wahl', () => {
2352 /*
2353 Der Befund vom 01.09.2026, am Bildschirm gesehen: Die Ampeln standen
2354 ausschließlich an den Abschnitten des Kapitels I. Der Grund war keine
2355 Auslassung, sondern eine Eigenschaft der Bereichsliste — nur Kapitel I
2356 ist amtlich untergliedert, für II bis IV führt sie das Kapitel selbst,
2357 und die 29 Themengruppen stehen in einem eigenen Feld, das die
2358 Kapitelwahl nicht bekam. Eine Anzeige, die bei drei von vier Kapiteln
2359 fehlt, sieht aus wie ein Fehler und war einer.
2360 */
2361
2362 async function zurKapitelwahl(
2363 bruecke?: ReturnType<typeof attrappe>,
2364 ): Promise<ReturnType<typeof starten>> {
2365 const nutzer = starten(bruecke ?? attrappe({}));
2366 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
2367 await waitFor(() => {
2368 expect(knopf).toHaveAttribute('aria-disabled', 'false');
2369 });
2370 await nutzer.click(knopf);
2371 await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 });
2372 return nutzer;
2373 }
2374
2375 /** Der Block, in dem dieser Knopf steht. */
2376 function blockVon(knopfname: string | RegExp): HTMLElement {
2377 const knopf = screen.getByRole('button', { name: knopfname });
2378 const block = knopf.closest('.kapitelwahl__bereich');
2379 expect(block, `Kein Block um „${String(knopfname)}“`).not.toBeNull();
2380 return block as HTMLElement;
2381 }
2382
2383 it('nimmt für das ganze Kapitel dessen eigene Zeile, wenn es eine hat', async () => {
2384 /* So liegt es bei den Kapiteln II bis IV: Der Katalog gliedert sie nicht,
2385 die Bereichsliste führt deshalb das Kapitel selbst. */
2386 await zurKapitelwahl();
2387
2388 const ampel = within(blockVon(/Ganzes Kapitel I üben/u)).getByLabelText(
2389 /Prüfungsreife in Kapitel I/u,
2390 );
2391 expect(ampel).toHaveAttribute('aria-valuenow', '40');
2392 expect(ampel).toHaveAttribute('aria-valuemax', '300');
2393 });
2394
2395 it('fasst die Abschnitte zusammen, wenn das Kapitel keine eigene Zeile hat', async () => {
2396 /*
2397 Und so liegt es bei Kapitel I, dem einzigen amtlich untergliederten:
2398 Die Bereichsliste kennt `I.1` bis `I.5`, aber kein `I`. Ohne die
2399 Zusammenfassung stünde ausgerechnet der erste Knopf der Seite als
2400 einziger ohne Lernstand da.
2401
2402 Gerechnet wird exakt und nicht ungefähr: 12 von 90 und 40 von 154 sind
2403 zusammen 52 von 244 (siehe `bereicheZusammenfassen`).
2404 */
2405 await zurKapitelwahl(
2406 attrappe({
2407 lernUebersicht: vi.fn(() =>
2408 Promise.resolve({
2409 ...UEBERSICHT,
2410 /* Ohne die Kapitelzeile, dafür mit beiden Abschnitten — so
2411 liefert der Anwendungskern es für Kapitel I wirklich. */
2412 bereiche: [
2413 {
2414 id: 'I.1',
2415 titel: 'Begriffe des Waffenrechts',
2416 fragenGesamt: 90,
2417 beantwortet: 30,
2418 belegt: 12,
2419 reifegrad: 12 / 90,
2420 stufe: 'zurueck' as const,
2421 },
2422 {
2423 id: 'I.2',
2424 titel: 'Rechte und Pflichten',
2425 fragenGesamt: 154,
2426 beantwortet: 80,
2427 belegt: 40,
2428 reifegrad: 40 / 154,
2429 stufe: 'zurueck' as const,
2430 },
2431 ],
2432 }),
2433 ),
2434 }),
2435 );
2436
2437 const ampel = within(blockVon(/Ganzes Kapitel I üben/u)).getByLabelText(
2438 /Prüfungsreife in Kapitel I/u,
2439 );
2440 expect(ampel).toHaveAttribute('aria-valuenow', '52');
2441 expect(ampel).toHaveAttribute('aria-valuemax', '244');
2442 });
2443
2444 it('zeigt den Stand an jeder Themengruppe', async () => {
2445 /* Die 230 Fragen der Kapitel II bis IV haben keinen amtlichen
2446 Abschnitt; geübt werden sie über die Themengruppen, und genau dort
2447 fehlte der Stand. */
2448 themenZwischenspeicherLeeren();
2449 await zurKapitelwahl(
2450 attrappe({
2451 katalogLaden: vi.fn(() =>
2452 Promise.resolve({
2453 ...KATALOG,
2454 kapitel: [...KATALOG.kapitel, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }],
2455 }),
2456 ),
2457 themenLaden: vi.fn(() =>
2458 Promise.resolve({
2459 meta: { version: 1, stand: '2026-09-01', hinweis: 'Prüfstand' },
2460 gruppen: [
2461 {
2462 id: 'II.1',
2463 kapitel: 'II',
2464 titel: 'Waffenarten',
2465 fragen: ['II-01', 'II-02', 'II-03'],
2466 },
2467 ],
2468 }),
2469 ),
2470 lernUebersicht: vi.fn(() =>
2471 Promise.resolve({
2472 ...UEBERSICHT,
2473 themengruppen: [
2474 {
2475 id: 'II.1',
2476 kapitel: 'II',
2477 titel: 'Waffenarten',
2478 fragenGesamt: 18,
2479 beantwortet: 9,
2480 belegt: 7,
2481 reifegrad: 7 / 18,
2482 stufe: 'kippe' as const,
2483 },
2484 ],
2485 }),
2486 ),
2487 }),
2488 );
2489
2490 const ampel = within(blockVon(/Waffenarten/u)).getByLabelText(/Prüfungsreife in Waffenarten/u);
2491 expect(ampel).toHaveAttribute('aria-valuenow', '7');
2492 expect(ampel).toHaveAttribute('aria-valuemax', '18');
2493 });
2494
2495 it('lässt die Ampel weg, solange nichts geladen ist', async () => {
2496 /* „Noch nicht geladen“ ist nicht „null Prozent“. */
2497 await zurKapitelwahl(ohneKanaele('lernUebersicht'));
2498
2499 expect(screen.queryByLabelText(/Prüfungsreife in/u)).not.toBeInTheDocument();
2500 });
2501
2502 it('legt jede Wahl in eine eigene Zeile der Liste', async () => {
2503 /*
2504 Der zweite Teil des Befundes: Die Knöpfe standen in einer
2505 `flex-wrap`-Reihe, und seit jede Wahl zwei Zeilen Lernstand darunter
2506 trägt, zogen sich die Elemente gegenseitig auf die Höhe des höchsten —
2507 ein Knopf ohne Ampel stand als hoher leerer Kasten da. Geprüft wird die
2508 Struktur, nicht das Aussehen: Jede Wahl ist ein Listeneintrag.
2509 */
2510 await zurKapitelwahl();
2511
2512 const block = blockVon(/Ganzes Kapitel I üben/u);
2513 expect(block.tagName).toBe('LI');
2514 expect(block.parentElement?.tagName).toBe('UL');
2515 });
2516 });