waffensachkunde

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

/ app tests renderer-startbildschirm.test.tsx

113,4 KB Rohdatei
app/tests/renderer-startbildschirm.test.tsx — 2945 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('beginnt keinen Satz mit einem kleingeschriebenen Wort', async () => {
446 /* Befund der Prüfrunde zu 0.27.2. `minutenText` liefert „etwa 10
447 Minuten“ – mitten im Satz richtig, am Satzanfang nicht. Bei
448 „entspannt“ stand es hinter einem Punkt: „Sie liegen gut in der Zeit.
449 etwa 10 Minuten täglich genügen.“ */
450 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
451
452 const satz = await screen.findByText(/Sie liegen gut in der Zeit/u);
453 expect(satz.textContent).not.toMatch(/\.\s+[a-zäöüß]/u);
454 });
455
456 it('überspringt keine Überschriftenebene, wenn die Vorschau dasteht', async () => {
457 /*
458 Befund der Prüfrunde zu 0.27.2. Die Karte „Ihr Lernplan“ führt ein `h2`,
459 die Arbeitslastvorschau darunter ein `h4` – dazwischen fehlt die Ebene 3.
460 Wer mit einem Bildschirmleser nach Überschriften springt, verliert damit
461 die Zuordnung.
462
463 `e2e/ueberschriften.spec.ts` prüft genau diese Regel und sieht den Fall
464 trotzdem nie: Die Vorschau erscheint erst mit einem eingetragenen
465 Prüfungstermin, und der Lauf trägt keinen ein.
466 */
467 starten(
468 attrappe({
469 lernPlan: vi.fn(() =>
470 Promise.resolve({
471 ...LERNPLAN,
472 termin: '2026-06-15',
473 tageBisTermin: 40,
474 vorschau: [3, 0, 5, 2, 0, 1, 4],
475 }),
476 ),
477 }),
478 );
479
480 await screen.findByText('Was in den nächsten Tagen ansteht');
481
482 const ebenen = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((knoten) => ({
483 ebene: Number(knoten.tagName.slice(1)),
484 text: knoten.textContent.trim().slice(0, 40),
485 }));
486
487 let vorige = 1;
488 for (const eintrag of ebenen) {
489 expect(
490 eintrag.ebene,
491 `Sprung auf h${String(eintrag.ebene)} bei „${eintrag.text}“`,
492 ).toBeLessThanOrEqual(vorige + 1);
493 vorige = eintrag.ebene;
494 }
495 });
496
497 it('meldet einen gescheiterten Prüfungstermin als Fehler und behält die Eingabe', async () => {
498 /*
499 Befund der Prüfrunde zu 0.27.2. Misslang das Sichern, erschien die
500 Meldung als „Hinweis“ – mit dem Symbol und der Farbe eines Hinweises,
501 ohne Live-Region und damit ohne Ansage (WCAG 3.3.1). Und das Feld war
502 leer: `uebernehmen` verwarf den Entwurf nach dem Speichern
503 bedingungslos, auch wenn nichts gespeichert wurde. Wer das Datum eben
504 getippt hatte, musste es neu eingeben, ohne zu wissen, warum.
505 */
506 const nutzer = starten(
507 attrappe({
508 profilAktualisieren: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))),
509 }),
510 );
511
512 const feld = await screen.findByLabelText(/Prüfungstermin/u);
513 await nutzer.clear(feld);
514 fireEvent.change(feld, { target: { value: '2026-06-15' } });
515
516 const meldung = await screen.findByText(/Datenbank gesperrt/u);
517 expect(meldung.closest('.hinweis')).toHaveClass('hinweis--fehler');
518 expect(meldung.closest('[role="alert"]')).not.toBeNull();
519 expect(feld).toHaveValue('2026-06-15');
520 });
521
522 it('zeigt die Trefferquote nicht ein zweites Mal', () => {
523 /*
524 Bis Fassung 0.11.0 stand hier ein eigener Balken „Geschätzte
525 Trefferquote“ mit dem Satz „Wenn Sie heute geprüft würden: 62 %
526 richtig“. Seit dem Umbau ist `prognoseHeute` derselbe Wert wie der
527 Reifegrad der Ampel weiter oben – zweimal dieselbe Größe auf einem
528 Bildschirm, einmal als Fragenzahl, einmal als Prozentwert. Genau diese
529 Doppelung war der Befund in docs/stand.md 7.1.
530 */
531 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
532
533 expect(screen.queryByRole('progressbar', { name: 'Geschätzte Trefferquote' })).toBeNull();
534 expect(screen.queryByText(/Wenn Sie heute geprüft würden/u)).toBeNull();
535 });
536
537 it('zeigt den Termin im deutschen Format samt Abstand', async () => {
538 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
539
540 expect(await screen.findByText('15.06.2026 – in 40 Tagen')).toBeInTheDocument();
541 });
542
543 it('beziffert, was Nichtstun bis zur Prüfung kostet – in derselben Einheit wie die Ampel', async () => {
544 /* Fragen, nicht Prozent: Wer zwei Einheiten für dieselbe Größe liest,
545 hält sie für zwei Größen. 41 % der 8 Fragen dieser Attrappe sind 3. */
546 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
547
548 expect(
549 await screen.findByText(/Am Prüfungstag säßen noch etwa 3 von 8 Fragen belegt/u),
550 ).toBeInTheDocument();
551 });
552
553 it('nennt das Tagespensum in Fragen und in Minuten', async () => {
554 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
555
556 expect(
557 await screen.findByText(/13 neue Fragen und 12 zur Wiederholung.*etwa 10 Minuten/su),
558 ).toBeInTheDocument();
559 });
560
561 it('bietet ein beschriftetes Datumsfeld für den Termin', async () => {
562 starten(attrappe({ lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)) }));
563
564 const feld = await screen.findByLabelText('Ihr Prüfungstermin');
565 expect(feld).toHaveAttribute('type', 'date');
566 /* Das Feld steht sofort da, der gespeicherte Termin kommt erst mit dem
567 geladenen Plan – deshalb darauf warten statt sofort zu prüfen. */
568 await waitFor(() => {
569 expect(feld).toHaveValue('2026-06-15');
570 });
571 // Die Formathilfe muss dem Feld zugeordnet sein, nicht nur danebenstehen.
572 expect(feld).toHaveAccessibleDescription(/Format Tag\.Monat\.Jahr/u);
573 });
574
575 it('sichert einen eingetragenen Termin über die Bridge', async () => {
576 const aktualisieren = vi.fn(() => Promise.resolve(PROFIL));
577 const nutzer = starten(attrappe({ profilAktualisieren: aktualisieren }));
578
579 const feld = await screen.findByLabelText('Ihr Prüfungstermin');
580 await nutzer.clear(feld);
581 fireEvent.change(feld, { target: { value: '2026-06-15' } });
582
583 await waitFor(() => {
584 expect(aktualisieren).toHaveBeenCalledWith(1, { pruefungstermin: '2026-06-15' });
585 });
586 });
587
588 it('löscht den Termin, wenn das Feld geleert wird', async () => {
589 const aktualisieren = vi.fn(() => Promise.resolve(PROFIL));
590 starten(
591 attrappe({
592 profilAktualisieren: aktualisieren,
593 lernPlan: vi.fn(() => Promise.resolve(MIT_TERMIN)),
594 }),
595 );
596
597 const feld = await screen.findByLabelText('Ihr Prüfungstermin');
598 // Erst wenn der Termin wirklich im Feld steht, ist das Leeren eine Änderung.
599 await waitFor(() => {
600 expect(feld).toHaveValue('2026-06-15');
601 });
602
603 fireEvent.change(feld, { target: { value: '' } });
604
605 await waitFor(() => {
606 expect(aktualisieren).toHaveBeenCalledWith(1, { pruefungstermin: null });
607 });
608 });
609
610 it('erklärt ohne Termin, was das Programm stattdessen tut', async () => {
611 starten();
612
613 expect(await screen.findByText(/Ohne Prüfungstermin plant das Programm/u)).toBeInTheDocument();
614 });
615
616 it('bleibt bedienbar, wenn der Kern den Kanal nicht anbietet', async () => {
617 starten(ohneKanaele('lernPlan'));
618
619 expect(await screen.findByText(/lernen:plan/u)).toBeInTheDocument();
620 // Der Rest des Startbildschirms steht weiterhin.
621 expect(screen.getByRole('button', { name: /Weiterlernen/u })).toBeInTheDocument();
622 });
623
624 it('erklärt einen Fehler beim Laden, statt leer zu bleiben', async () => {
625 starten(attrappe({ lernPlan: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))) }));
626
627 expect(await screen.findByText(/Datenbank gesperrt/u)).toBeInTheDocument();
628 });
629 });
630
631 describe('Glossar', () => {
632 async function glossarOeffnen(bruecke = attrappe()): Promise<ReturnType<typeof userEvent.setup>> {
633 const nutzer = starten(bruecke);
634 const knopf = await screen.findByRole('button', { name: 'Fachbegriffe nachschlagen' });
635 await nutzer.click(knopf);
636 await screen.findByRole('heading', { name: 'Glossar', level: 1 });
637 return nutzer;
638 }
639
640 it('ist vom Startbildschirm aus erreichbar', async () => {
641 /* WCAG 3.1.3 und 3.1.4 verlangen einen Weg zur Bedeutung. Der Prüfplan
642 verlangt zusätzlich, dass Hilfe an derselben Stelle steht (3.2.6). */
643 await glossarOeffnen();
644
645 expect(screen.getByRole('heading', { name: 'Glossar', level: 1 })).toHaveFocus();
646 });
647
648 it('verstummt, wenn der vorgelesene Eintrag weggefiltert wird', async () => {
649 /*
650 Befund der Prüfrunde zu 0.27.2. Die Sprachausgabe lief hier mit der
651 festen Kennung „glossar“; ihr Aufräumer greift aber nur, wenn diese
652 Kennung wechselt. Wer einen Eintrag vorlesen ließ und danach ins
653 Filterfeld tippte, hörte ihn weiter — und sobald kein Treffer mehr übrig
654 war, stand auf dem Bildschirm keine Schaltfläche mehr, die das anhalten
655 konnte. Vorgelesen werden im Median 596 Zeichen, gesprochen weit über
656 drei Sekunden: genau der Fall von WCAG 1.4.2.
657
658 Der Haken sagt es selbst: „Was gesprochen wird, gehört zu dem, was zu
659 sehen ist; verschwindet das eine, hat das andere zu schweigen.“
660 */
661 let abgebrochen = 0;
662 class AeusserungAttrappe {
663 lang = '';
664 rate = 1;
665 voice: unknown = null;
666 onstart: (() => void) | null = null;
667 onend: (() => void) | null = null;
668 onerror: ((ereignis: { error: string }) => void) | null = null;
669 constructor(public readonly text: string) {}
670 }
671 vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
672 vi.stubGlobal('speechSynthesis', {
673 speak: () => undefined,
674 cancel: () => {
675 abgebrochen += 1;
676 },
677 getVoices: () => [],
678 speaking: false,
679 pending: false,
680 paused: false,
681 });
682
683 const nutzer = await glossarOeffnen(
684 attrappe({
685 einstellungenLesen: vi.fn(() =>
686 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
687 ),
688 }),
689 );
690
691 await nutzer.click((await screen.findAllByRole('button', { name: /vorlesen/u }))[0]!);
692 expect(screen.getAllByRole('button', { name: /Vorlesen beenden/u }).length).toBeGreaterThan(0);
693
694 const vorher = abgebrochen;
695 await nutzer.type(screen.getByLabelText(/Begriff suchen|suchen/u), 'zzz');
696
697 await waitFor(() => {
698 expect(abgebrochen).toBeGreaterThan(vorher);
699 });
700 expect(screen.queryByRole('button', { name: /Vorlesen beenden/u })).toBeNull();
701 });
702
703 it('sagt es, wenn das Glossar leer geladen wurde', async () => {
704 /*
705 Befund der Prüfrunde zu 0.27.2. `glossarLaden` im Hauptprozess fängt
706 eine fehlende, leere oder kaputte `glossar.json` ab und liefert
707 `GLOSSAR_LEER` – der Kanal gelingt, die Oberfläche bekommt ein Objekt.
708 Die Ansicht prüfte aber auf `glossar === null` und zeigte deshalb
709 Suchfeld, „0 Einträge“ und den Satz „Zu dieser Eingabe gibt es keinen
710 Eintrag.“ – dem Lernenden wurde eine Eingabe angelastet, die er nicht
711 gemacht hat.
712
713 Die Nachbarschaft macht es richtig und zeigt, wie: `useNormtexte`
714 führt ein Feld `vorhanden`, und die Gesetzesansicht stützt ihren Hinweis
715 darauf. Die bisherige Wache traf den Fall nie, weil sie den Kanal ganz
716 entfernte – der einzige Weg, der wirklich `null` erzeugt.
717 */
718 const nutzer = starten(
719 attrappe({ glossarLaden: vi.fn(() => Promise.resolve({ ...GLOSSAR, eintraege: [] })) }),
720 );
721 await nutzer.click(await screen.findByRole('button', { name: 'Fachbegriffe nachschlagen' }));
722 await screen.findByRole('heading', { name: 'Glossar', level: 1 });
723
724 expect(screen.getByText(/Das Glossar ist zurzeit nicht verfügbar/u)).toBeInTheDocument();
725 expect(screen.queryByLabelText('Begriff suchen')).toBeNull();
726 });
727
728 it('führt Begriffe und Abkürzungen mit ihrer Bedeutung auf', async () => {
729 await glossarOeffnen();
730
731 expect(screen.getByRole('heading', { name: /^Führen/u, level: 2 })).toBeInTheDocument();
732 expect(screen.getByText(/außerhalb der eigenen Wohnung/u)).toBeInTheDocument();
733 expect(screen.getByText('Waffengesetz.')).toBeInTheDocument();
734 });
735
736 it('kennzeichnet Abkürzungen als solche', async () => {
737 // WCAG 3.1.4: Die aufgelöste Form muss erkennbar eine Auflösung sein.
738 await glossarOeffnen();
739
740 const eintrag = screen.getByRole('heading', { name: /^WaffG/u, level: 2 });
741 expect(eintrag).toHaveTextContent('(Abkürzung)');
742 });
743
744 it('nennt die Fundstelle einer gesetzlichen Bestimmung', async () => {
745 await glossarOeffnen();
746
747 expect(screen.getByText('Anlage 1 Abschnitt 2 Nr. 4 WaffG')).toBeInTheDocument();
748 });
749
750 it('sagt, wo es keine gesetzliche Bestimmung gibt', async () => {
751 await glossarOeffnen();
752
753 expect(
754 screen.getByText(/Kurzbezeichnung eines Gesetzes steht nicht im Gesetz/u),
755 ).toBeInTheDocument();
756 });
757
758 it('filtert auch über die Wortformen', async () => {
759 /* Wer „führt" eingibt, sucht den Eintrag „Führen" – und weiß nicht,
760 unter welchem Stichwort er steht. */
761 const nutzer = await glossarOeffnen();
762
763 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt');
764
765 expect(screen.getByRole('heading', { name: /^Führen/u, level: 2 })).toBeInTheDocument();
766 expect(screen.queryByRole('heading', { name: /^WaffG/u, level: 2 })).not.toBeInTheDocument();
767 });
768
769 it('meldet die Trefferzahl über eine Live-Region', async () => {
770 const nutzer = await glossarOeffnen();
771 const meldung = screen.getByText('2 Einträge');
772 expect(meldung).toHaveAttribute('role', 'status');
773
774 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt');
775
776 /* Seit 0.22.0 mit Ruhepause: Eine höfliche Live-Region, die bei jedem
777 Tastenanschlag eine neue Zahl spricht, mischt einem Screenreader beim
778 Tippen von „führt“ fünf einander unterbrechende Ansagen zwischen die
779 Zeichen-Echos. Die Fragensuche hielt es längst so; das Glossar zog
780 nach (`useVerzoegerteAnsage`). */
781 expect(await screen.findByText('1 von 2 Einträgen')).toBeInTheDocument();
782 });
783
784 it('sagt es, wenn nichts passt', async () => {
785 const nutzer = await glossarOeffnen();
786
787 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'Fahrraddiebstahl');
788
789 expect(screen.getByText('Zu dieser Eingabe gibt es keinen Eintrag.')).toBeInTheDocument();
790 });
791
792 /*
793 „Siehe auch“ war bis 0.22.0 toter Text.
794
795 Wer per Tastatur oder Bildschirmleser von einem Begriff zum verwandten
796 wollte, musste zurück zum Filterfeld, den Begriff abtippen und die Liste
797 neu durchgehen – obwohl beide Einträge in derselben Ansicht stehen und die
798 A-bis-Z-Leiste den Sprungmechanismus längst mitbringt.
799 */
800 it('macht „Siehe auch“ zu einem Sprungziel und nimmt den Fokus mit', async () => {
801 const nutzer = await glossarOeffnen();
802
803 /* Der Eintrag „Führen“ verweist auf „WaffG“. */
804 await nutzer.click(screen.getByRole('button', { name: 'WaffG' }));
805
806 expect(screen.getByRole('heading', { name: /^WaffG/u })).toHaveFocus();
807 });
808
809 it('leert den Filter, wenn das Sprungziel gerade ausgefiltert ist', async () => {
810 /* Sonst liefe der Sprung ins Leere: Der Zieleintrag ist dann gar nicht
811 gerendert, und der Fokus bliebe stehen, ohne dass etwas geschähe. */
812 const nutzer = await glossarOeffnen();
813 const feld = screen.getByLabelText('Begriff suchen');
814 await nutzer.type(feld, 'Führen');
815
816 /* Nur „Führen“ steht noch da – sein Verweisziel „WaffG“ ist ausgefiltert. */
817 expect(screen.queryByRole('heading', { name: /^WaffG/u })).toBeNull();
818
819 await nutzer.click(screen.getByRole('button', { name: 'WaffG' }));
820
821 await waitFor(() => {
822 expect(screen.getByRole('heading', { name: /^WaffG/u })).toHaveFocus();
823 });
824 expect(feld).toHaveValue('');
825 });
826
827 it('zeigt in der Sprungleiste nur Buchstaben, unter denen ein Eintrag steht', async () => {
828 await glossarOeffnen();
829
830 const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
831 // Zwei Einträge, zwei Knöpfe – kein gesperrter Rest des Alphabets.
832 expect(
833 within(leiste)
834 .getAllByRole('button')
835 .map((k) => k.textContent),
836 ).toEqual(['F', 'W']);
837 });
838
839 it('springt zum Buchstaben und setzt den Fokus auf die erste Überschrift', async () => {
840 /* Reines Rollen ohne Fokus wäre für Tastatur und Bildschirmleser
841 wirkungslos – der nächste Tabulatorsprung begänne wieder ganz oben. */
842 const nutzer = await glossarOeffnen();
843 const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
844
845 await nutzer.click(within(leiste).getByRole('button', { name: 'W' }));
846
847 expect(screen.getByRole('heading', { name: /^WaffG/u, level: 2 })).toHaveFocus();
848 });
849
850 it('blendet die Sprungleiste bei aktivem Filter aus', async () => {
851 /* Eine Leiste, deren Knöpfe bei jedem Anschlag kommen und gehen, wäre
852 ein bewegliches Ziel – die gefilterte Liste ist selbst der Sprung. */
853 const nutzer = await glossarOeffnen();
854
855 await nutzer.type(screen.getByLabelText('Begriff suchen'), 'führt');
856 expect(screen.queryByRole('navigation', { name: 'Anfangsbuchstaben' })).not.toBeInTheDocument();
857
858 await nutzer.clear(screen.getByLabelText('Begriff suchen'));
859 expect(screen.getByRole('navigation', { name: 'Anfangsbuchstaben' })).toBeInTheDocument();
860 });
861
862 it('reiht Umlaute beim Grundbuchstaben ein, statt die Gruppe zu zerreißen', async () => {
863 /* Die deutsche Sortierung stellt „Überlassen“ vor „Umgang“. Ein eigener
864 Ü-Knopf zeigte also mitten in die U-Gruppe – deshalb fällt Ü auf U,
865 und der U-Sprung landet auf dem ersten Eintrag der Gruppe. */
866 const eintrag = (begriff: string) => ({
867 begriff,
868 art: 'begriff' as const,
869 kurz: 'Prüfeintrag für die Sprungleiste.',
870 varianten: [],
871 fundstellen: [],
872 });
873 const nutzer = await glossarOeffnen(
874 attrappe({
875 glossarLaden: vi.fn(() =>
876 Promise.resolve({ ...GLOSSAR, eintraege: [eintrag('Umgang'), eintrag('Überlassen')] }),
877 ),
878 }),
879 );
880
881 const leiste = screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
882 expect(within(leiste).queryByRole('button', { name: 'Ü' })).not.toBeInTheDocument();
883
884 await nutzer.click(within(leiste).getByRole('button', { name: 'U' }));
885
886 expect(screen.getByRole('heading', { name: 'Überlassen', level: 2 })).toHaveFocus();
887 });
888
889 it('meldet auch mit Sprungleiste keine axe-Verstöße', async () => {
890 await glossarOeffnen();
891 screen.getByRole('navigation', { name: 'Anfangsbuchstaben' });
892
893 const ergebnis = await axe.run(document.body, jsdomOptionen());
894
895 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
896 });
897
898 it('führt vom Glossar zurück zum Start', async () => {
899 const nutzer = await glossarOeffnen();
900
901 await nutzer.click(
902 within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', {
903 name: 'Zum Start',
904 }),
905 );
906
907 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
908 });
909
910 /*
911 Der Rückweg endet dort, wo er begann.
912
913 Bis Fassung 0.25.0 endete JEDER Rückweg am Seitenanfang: Neun Rückwege
914 laufen über `zumStart`, und der Fokuseffekt in `App.tsx` setzte den Fokus
915 danach ausnahmslos auf `einstiegRef` – auf dem Startbildschirm also auf
916 „Heute lernen“ ganz oben. Der Startbildschirm ist bei 1265 Bildpunkten
917 Breite 8530 Bildpunkte hoch (gemessen an
918 `docs/bildschirmfotos/01-startbildschirm-hell.png`); die Karte „Über diese
919 Software“ steht ganz unten. Wer von dort zurückkam, rollte den Weg noch
920 einmal – mit der Tastatur waren es einundvierzig Tabulatorhalte.
921
922 Geprüft wird der FOKUS und nicht die Bildlaufstelle, und das ist die Sache
923 selbst: Es wurde nie eine Stelle falsch wiederhergestellt, sondern gar
924 keine gemerkt. Der Bildlauf ist die Nebenwirkung des Fokussprungs. jsdom
925 rollt ohnehin nicht; wer hier eine Bildlaufstelle prüfte, prüfte nichts.
926 */
927 it('setzt den Fokus beim Zurückkommen auf die Karte, von der aus man ging', async () => {
928 const nutzer = await glossarOeffnen();
929
930 await nutzer.click(
931 within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', {
932 name: 'Zum Start',
933 }),
934 );
935
936 const karte = await screen.findByRole('heading', { name: 'Glossar', level: 2 });
937 expect(karte).toHaveFocus();
938 /* Und nicht mehr die Einstiegsüberschrift – sonst wäre die Zusage leer. */
939 expect(screen.getByRole('heading', { name: 'Heute lernen' })).not.toHaveFocus();
940 });
941
942 it('führt beim Hinweg unverändert auf die Überschrift der neuen Ansicht', async () => {
943 /* Gegenprobe: Der Hinweg darf sich nicht ändern. Dort ist die
944 Einstiegsüberschrift richtig – der Nutzende ist an einem neuen Ort, und
945 ein Bildschirmleser muss das erfahren. */
946 await glossarOeffnen();
947
948 expect(screen.getByRole('heading', { name: 'Glossar', level: 1 })).toHaveFocus();
949 });
950
951 it('bleibt bedienbar, wenn der Kern das Glossar nicht anbietet', async () => {
952 await glossarOeffnen(ohneKanaele('glossarLaden'));
953
954 expect(screen.getByText(/Glossar ist zurzeit nicht verfügbar/u)).toBeInTheDocument();
955 });
956 });
957
958 describe('Startbildschirm – Wege ins Lernen', () => {
959 it('bietet die fünf Einstiege an', async () => {
960 starten();
961
962 for (const name of [
963 /Weiterlernen/u,
964 /Kapitel wählen/u,
965 /Nur Fehler/u,
966 /Gemerkte Fragen/u,
967 /Offene Fragen/u,
968 ]) {
969 const knopf = await screen.findByRole('button', { name });
970 await waitFor(() => {
971 expect(knopf).toHaveAttribute('aria-disabled', 'false');
972 });
973 /* Kein natives `disabled` – die Projektregel (docs/stand.md 7.13)
974 verlangt einen Knopf, der auch gesperrt den Fokus behält. */
975 expect(knopf).toBeEnabled();
976 }
977 });
978
979 /*
980 Offene Fragen kamen bisher nur zufällig zwischen den Auswahlfragen vor –
981 bei zwanzig Fragen im Mittel dreieinhalb. Sie sind der Teil der Prüfung,
982 den ein Mensch bewertet.
983 */
984 it('startet über „Offene Fragen“ eine Sitzung nur mit auszuformulierenden', async () => {
985 const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG));
986 const nutzer = starten(attrappe({ lernSitzung }));
987
988 const knopf = await screen.findByRole('button', { name: /Offene Fragen/u });
989 await waitFor(() => {
990 expect(knopf).toHaveAttribute('aria-disabled', 'false');
991 });
992 await nutzer.click(knopf);
993
994 expect(lernSitzung).toHaveBeenCalledWith(
995 1,
996 expect.objectContaining({ nurOffene: true, anzahl: 20 }),
997 );
998 });
999
1000 it('nennt an der Schaltfläche, wie viele offene Fragen der Lernumfang hat', async () => {
1001 /* Die Zahl kommt aus der Übersicht und nicht aus dem Katalog: Sie muss
1002 sich mit der Kapitelabwahl ändern, sonst verspräche sie Fragen, die
1003 das Zielprofil gar nicht enthält. */
1004 starten();
1005
1006 expect(await screen.findByRole('button', { name: /Offene Fragen/u })).toHaveTextContent(
1007 /Zum Ausformulieren: \d+ Fragen/u,
1008 );
1009 });
1010
1011 it('zeigt den Lernstand des Abschnitts dort, wo er geübt wird', async () => {
1012 /* Die Wache, die gefehlt hat. Der Lernstand je Bereich stand
1013 ausschließlich in der Lernstandsübersicht des Startbildschirms, und
1014 von dort führte kein Weg ins Üben. Wer wissen wollte, welcher
1015 Abschnitt zurückliegt, musste es sich merken, hierher wechseln und
1016 die Ampel im Kopf behalten – Anzeige und Entscheidung lagen an zwei
1017 verschiedenen Orten. */
1018 const nutzer = starten();
1019
1020 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1021 await waitFor(() => {
1022 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1023 });
1024 await nutzer.click(knopf);
1025
1026 /* Die Ampel steht IM Bereichsblock neben dem Knopf, der ihn übt – nicht
1027 irgendwo auf der Seite. Dieselbe Beschriftung gibt es auch in der
1028 Lernstandsübersicht des Startbildschirms; wer nur nach ihr sucht,
1029 prüft nichts. */
1030 const knopfAbschnitt = await screen.findByRole('button', {
1031 name: 'I.1 – Begriffe des Waffenrechts',
1032 });
1033 const block = knopfAbschnitt.closest('.kapitelwahl__bereich');
1034
1035 expect(block).not.toBeNull();
1036 expect(
1037 within(block as HTMLElement).getByLabelText(/Prüfungsreife in Begriffe des Waffenrechts/u),
1038 ).toBeInTheDocument();
1039 });
1040
1041 it('zeigt keine Ampel, solange der Lernstand nicht geladen ist', async () => {
1042 /* „Noch nicht geladen" ist nicht „null Prozent". Eine Ampel auf Verdacht
1043 wäre schlechter als keine. */
1044 const nutzer = starten(ohneKanaele('lernUebersicht'));
1045
1046 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1047 await waitFor(() => {
1048 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1049 });
1050 await nutzer.click(knopf);
1051
1052 await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 });
1053 const knopfAbschnitt = screen.getByRole('button', {
1054 name: 'I.1 – Begriffe des Waffenrechts',
1055 });
1056 const block = knopfAbschnitt.closest('.kapitelwahl__bereich');
1057
1058 expect(block).not.toBeNull();
1059 expect(
1060 within(block as HTMLElement).queryByLabelText(/Prüfungsreife in/u),
1061 ).not.toBeInTheDocument();
1062 });
1063
1064 it('startet über die Kapitelwahl eine Sitzung für einen Abschnitt', async () => {
1065 const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG));
1066 const nutzer = starten(attrappe({ lernSitzung }));
1067
1068 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1069 await waitFor(() => {
1070 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1071 });
1072 await nutzer.click(knopf);
1073
1074 expect(screen.getByRole('heading', { name: 'Kapitel wählen', level: 1 })).toHaveFocus();
1075
1076 await nutzer.click(screen.getByRole('button', { name: 'I.1 – Begriffe des Waffenrechts' }));
1077
1078 expect(lernSitzung).toHaveBeenCalledWith(
1079 1,
1080 expect.objectContaining({ abschnitte: ['I.1'], anzahl: 20 }),
1081 );
1082 expect(await screen.findByRole('heading', { name: /Frage 1\.01/u })).toBeInTheDocument();
1083 });
1084 });
1085
1086 describe('Startbildschirm – Überschriften und Landmarken', () => {
1087 it('hält die Überschriftenhierarchie ohne Sprünge ein', async () => {
1088 starten();
1089 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
1090
1091 const ebenen = screen
1092 .getAllByRole('heading')
1093 .map((ueberschrift) => Number(ueberschrift.tagName.slice(1)));
1094
1095 expect(ebenen[0]).toBe(1);
1096 expect(ebenen.filter((ebene) => ebene === 1)).toHaveLength(1);
1097 for (let stelle = 1; stelle < ebenen.length; stelle += 1) {
1098 expect(ebenen[stelle]!).toBeLessThanOrEqual(ebenen[stelle - 1]! + 1);
1099 }
1100 });
1101
1102 it('bietet die erwarteten Landmarken', async () => {
1103 starten();
1104 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
1105
1106 expect(screen.getByRole('banner')).toBeInTheDocument();
1107 expect(screen.getByRole('main')).toBeInTheDocument();
1108 expect(screen.getByRole('contentinfo')).toBeInTheDocument();
1109 });
1110 });
1111
1112 describe('Startbildschirm – Tastenkürzel abschaltbar (WCAG 2.1.4)', () => {
1113 it('merkt sich die Abschaltung in den Einstellungen', async () => {
1114 /* Die Wahl geht in `einstellungen.json` statt in den `localStorage`: Bis
1115 0.21.0 stand diese eine Einstellung außerhalb von Einstellungen und
1116 Profil und überlebte keinen Gerätewechsel. */
1117 const schreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD));
1118 const nutzer = starten(attrappe({ einstellungenSchreiben: schreiben }));
1119
1120 const schalter = await screen.findByRole('checkbox', {
1121 name: 'Tastenkürzel in der Lernsitzung verwenden',
1122 });
1123 expect(schalter).toBeChecked();
1124
1125 await nutzer.click(schalter);
1126
1127 expect(schalter).not.toBeChecked();
1128 await waitFor(() => {
1129 expect(schreiben).toHaveBeenCalledWith({ tastenkuerzel: false });
1130 });
1131 expect(window.localStorage.getItem('waffensachkunde.tastenkuerzel')).toBeNull();
1132 });
1133
1134 it('legt die gespeicherte Abschaltung beim Start wieder an', async () => {
1135 starten(
1136 attrappe({
1137 einstellungenLesen: vi.fn(() =>
1138 Promise.resolve({
1139 ...EINSTELLUNGEN_STANDARD,
1140 zuschnittGefragt: true,
1141 tastenkuerzel: false,
1142 }),
1143 ),
1144 }),
1145 );
1146
1147 const schalter = await screen.findByRole('checkbox', {
1148 name: 'Tastenkürzel in der Lernsitzung verwenden',
1149 });
1150 await waitFor(() => {
1151 expect(schalter).not.toBeChecked();
1152 });
1153 });
1154 });
1155
1156 describe('Startbildschirm – Baustand', () => {
1157 /*
1158 Die Versionsnummer allein benennt keinen Stand: Zwischen zwei
1159 Veröffentlichungen entstehen viele Bauten mit derselben Nummer. Wer eine
1160 Rückmeldung gibt, muss sagen können, welchen er vor sich hat.
1161 */
1162 it('nennt Commit-Kürzel und Datum in deutscher Schreibweise', async () => {
1163 starten();
1164
1165 expect(await screen.findByText('abc1234')).toBeInTheDocument();
1166 expect(screen.getByText(/vom 21\.08\.2026/u)).toBeInTheDocument();
1167 });
1168
1169 it('lässt die Zeile weg, wenn beim Bauen kein Git zur Verfügung stand', async () => {
1170 /* Lieber nichts als ein nichtssagendes „unbekannt“ – die Zeile
1171 existiert allein zum Benennen eines Standes. */
1172 starten(
1173 attrappe({
1174 anwendungsInfoLesen: vi.fn(() =>
1175 Promise.resolve({ ...INFO, baukennung: '', baustand: '' }),
1176 ),
1177 }),
1178 );
1179
1180 expect(await screen.findByText('Programmversion')).toBeInTheDocument();
1181 expect(screen.queryByText('Baustand')).not.toBeInTheDocument();
1182 });
1183
1184 it('nennt den Katalogstand und wo man nach einer neueren Fassung sieht', async () => {
1185 /*
1186 Die Anwendung kann nicht wissen, ob das Bundesverwaltungsamt inzwischen
1187 eine neuere Fassung veröffentlicht hat – sie geht nicht ins Netz (siehe
1188 docs/entscheidung-katalog-aktualitaet.md). Was sie kann, ist sagen, wie
1189 alt ihre Inhalte sind. Das Datum stammt aus dem Katalog selbst, nicht aus
1190 einer zweiten Pflege.
1191 */
1192 starten();
1193
1194 expect(await screen.findByText('Fragenkatalog')).toBeInTheDocument();
1195
1196 /* Datum und Quellort gehören in dieselbe Zeile – getrennt geprüft könnten
1197 sie zu zwei verschiedenen Angaben gehören. */
1198 const zeile = screen.getByText('www.bva.bund.de').closest('dd');
1199 expect(zeile).toHaveTextContent('Stand 16.12.2024');
1200 });
1201
1202 it('nennt den Quellort als Text, nicht als Verweis', async () => {
1203 /* Die Anwendung öffnet von sich aus nichts nach außen. Dieselbe
1204 Begründung wie bei der Quelle unter „Über diese Software“ und beim
1205 Rückmeldeweg in shared/kontakt.ts. */
1206 starten();
1207 const ort = await screen.findByText('www.bva.bund.de');
1208
1209 expect(ort.closest('a')).toBeNull();
1210 });
1211
1212 it('sagt es offen, wenn der Katalog nicht geladen ist', async () => {
1213 starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }));
1214
1215 expect(await screen.findByText('Fragenkatalog')).toBeInTheDocument();
1216 expect(screen.getByText('Nicht geladen.')).toBeInTheDocument();
1217 });
1218
1219 it('zeigt ein Datum unverändert, das sich nicht lesen lässt', async () => {
1220 starten(
1221 attrappe({
1222 anwendungsInfoLesen: vi.fn(() => Promise.resolve({ ...INFO, baustand: 'unbekannt' })),
1223 }),
1224 );
1225
1226 expect(await screen.findByText(/vom unbekannt/u)).toBeInTheDocument();
1227 });
1228 });
1229
1230 describe('Startbildschirm – Katalogwechsel', () => {
1231 /*
1232 Erscheint eine neue BVA-Fassung mit geänderter Nummerierung, zeigen
1233 gespeicherte Zeilen zu verschwundenen Fragennummern ins Leere. Der Kern
1234 zählt das beim Öffnen des Lernstands; die Oberfläche muss es sagen – was
1235 geschah, was betroffen ist, und dass nichts gelöscht wurde.
1236 */
1237 it('meldet einen Katalogwechsel mit Zahlen und der Zusage, dass nichts gelöscht wurde', async () => {
1238 starten(
1239 attrappe({
1240 anwendungsInfoLesen: vi.fn(() =>
1241 Promise.resolve({
1242 ...INFO,
1243 katalogwechsel: {
1244 vorher: '2024-12-16',
1245 nachher: '2025-06-01',
1246 verwaisteStaende: 2,
1247 verwaisteAntworten: 5,
1248 },
1249 }),
1250 ),
1251 }),
1252 );
1253
1254 const meldung = await screen.findByText(/Der Fragenkatalog hat die Fassung gewechselt/u);
1255 expect(meldung).toHaveTextContent('bisher Stand 16.12.2024, jetzt Stand 01.06.2025');
1256 expect(meldung).toHaveTextContent(
1257 '5 Einträge im Antwortprotokoll und 2 gespeicherte Fragenstände',
1258 );
1259 expect(meldung).toHaveTextContent('Gelöscht wurde nichts');
1260 });
1261
1262 it('schweigt, wenn der Kern keinen Wechsel meldet', async () => {
1263 /* `null` wie auch ein gänzlich fehlendes Feld heißen: nichts zu sagen.
1264 Ein Dauerhinweis wäre Rauschen – und beim echten Wechsel unglaubwürdig. */
1265 starten(attrappe({ anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)) }));
1266
1267 expect(await screen.findByText('Programmversion')).toBeInTheDocument();
1268 expect(screen.queryByText(/Fassung gewechselt/u)).not.toBeInTheDocument();
1269 });
1270 });
1271
1272 describe('Startbildschirm – fehlende Kanäle', () => {
1273 it('erklärt einen fehlenden Fragenkatalog und sperrt die Einstiege', async () => {
1274 starten(ohneKanaele('katalogLaden'));
1275
1276 expect(
1277 await screen.findByText(/Der Fragenkatalog konnte nicht geladen werden/u),
1278 ).toBeInTheDocument();
1279 expect(screen.getByText(/lässt sich keine Lernsitzung starten/u)).toBeInTheDocument();
1280
1281 /*
1282 Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein
1283 natives `disabled`, dafür `aria-disabled` und ein Verweis auf den Satz,
1284 der den Grund nennt. Ein `disabled` Knopf nähme den Fokus mit auf
1285 `<body>` – und seine Begründung würde nie vorgelesen.
1286 */
1287 const knopf = screen.getByRole('button', { name: /Weiterlernen/u });
1288 expect(knopf).toBeEnabled();
1289 expect(knopf).toHaveAttribute('aria-disabled', 'true');
1290 expect(knopf).toHaveAccessibleDescription(/lässt sich keine Lernsitzung starten/u);
1291 });
1292
1293 it('lässt den gesperrten Einstieg nichts auslösen und den Fokus behalten', async () => {
1294 const lernSitzung = vi.fn(() => Promise.resolve(SITZUNG));
1295 const nutzer = starten(
1296 attrappe({
1297 katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))),
1298 lernSitzung,
1299 }),
1300 );
1301
1302 const knopf = await screen.findByRole('button', { name: /Weiterlernen/u });
1303 await waitFor(() => {
1304 expect(knopf).toHaveAttribute('aria-disabled', 'true');
1305 });
1306 await nutzer.click(knopf);
1307
1308 expect(lernSitzung).not.toHaveBeenCalled();
1309 /* Der Knopf ist noch da und noch anwählbar – das ist der ganze Punkt. */
1310 knopf.focus();
1311 expect(knopf).toHaveFocus();
1312 });
1313
1314 it('meldet einen Fehler des Katalogkanals und bietet einen neuen Versuch an', async () => {
1315 starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('Datei fehlt'))) }));
1316
1317 expect(await screen.findByText(/Datei fehlt/u)).toBeInTheDocument();
1318 expect(screen.getByRole('button', { name: 'Fragenkatalog erneut laden' })).toBeEnabled();
1319 });
1320
1321 it('bleibt ohne Lernprofil bedienbar und übt aus dem Katalog', async () => {
1322 const nutzer = starten(ohneKanaele('profilListe', 'lernUebersicht'));
1323
1324 expect(
1325 await screen.findByText(
1326 /Sie können trotzdem üben; Ihre Antworten werden dabei nicht gespeichert/u,
1327 ),
1328 ).toBeInTheDocument();
1329
1330 const knopf = screen.getByRole('button', { name: /Weiterlernen/u });
1331 await waitFor(() => {
1332 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1333 });
1334 await nutzer.click(knopf);
1335
1336 expect(await screen.findByRole('heading', { name: /, Frage \d/u })).toBeInTheDocument();
1337 expect(screen.getByText(/Ihre Antworten werden nicht gespeichert/u)).toBeInTheDocument();
1338 });
1339
1340 it('zeigt den Lernstandsfehler an, ohne die Oberfläche zu blockieren', async () => {
1341 starten(
1342 attrappe({ lernUebersicht: vi.fn(() => Promise.reject(new Error('Datenbank gesperrt'))) }),
1343 );
1344
1345 expect(
1346 await screen.findByText(/Ihre Prüfungsreife konnte nicht geladen werden/u),
1347 ).toBeInTheDocument();
1348
1349 const knopf = screen.getByRole('button', { name: /Weiterlernen/u });
1350 await waitFor(() => {
1351 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1352 });
1353 });
1354 });
1355
1356 describe('Startbildschirm – Barrierefreiheits-Rauchtest (axe-core)', () => {
1357 it('meldet keine Verstöße gegen WCAG 2.1 A/AA', async () => {
1358 starten();
1359 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
1360
1361 const ergebnis = await axe.run(document.body, jsdomOptionen());
1362
1363 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
1364 });
1365 });
1366
1367 describe('Kapitel dauerhaft abwählen', () => {
1368 /*
1369 Die Oberflächenseite der dauerhaften Abwahl. Der Kern – welche Fragen
1370 danach noch kommen und wie sich Zähler und Prognose ändern – ist in
1371 `tests/lernstand.test.ts` gegen eine echte Datenbank gemessen. Hier geht
1372 es nur um das, was der Nutzer sieht und hört.
1373
1374 Der eigene Katalog statt einer Erweiterung des gemeinsamen: Kapitel IV
1375 hier allgemein einzuhängen änderte die Kapitelwahl und die Wege ins
1376 Lernen für jeden anderen Test in dieser Datei mit.
1377 */
1378 const KATALOG_MIT_IV: Katalog = {
1379 ...KATALOG,
1380 kapitel: [
1381 ...KATALOG.kapitel,
1382 {
1383 id: 'IV',
1384 titel: 'Not- und Seenotsignalmittel',
1385 abschnitte: [{ id: 'IV.1', titel: 'Signalmittel' }],
1386 },
1387 ],
1388 };
1389
1390 function mitKapitelIV(ueberschreibungen: TeilBridge = {}): TeilBridge {
1391 return attrappe({
1392 katalogLaden: vi.fn(() => Promise.resolve(KATALOG_MIT_IV)),
1393 ...ueberschreibungen,
1394 });
1395 }
1396
1397 it('bietet den Schalter unter „Ihr Lernplan“ an, nicht in der Profilverwaltung', async () => {
1398 /* Die Profilverwaltung führt das Register – anlegen, wechseln, umbenennen,
1399 löschen. Der Schalter gehört dorthin, wo seine Wirkung sichtbar wird:
1400 neben den Prüfungstermin. Der Termin sagt, bis wann; die Kapitelwahl
1401 sagt, wie viel. */
1402 starten(mitKapitelIV());
1403
1404 const schalter = await screen.findByRole('checkbox', {
1405 name: 'Kapitel IV – Not- und Seenotsignalmittel mitlernen',
1406 });
1407 expect(schalter).toBeChecked();
1408 expect(screen.getByRole('region', { name: 'Ihr Lernplan' })).toContainElement(schalter);
1409 });
1410
1411 it('bleibt fort, wenn der Katalog das Kapitel gar nicht führt', async () => {
1412 /* Der gemeinsame Katalog dieser Datei hat nur Kapitel I. Ein Schalter für
1413 ein Kapitel, das es nicht gibt, wäre eine leere Zusage. */
1414 starten();
1415 await screen.findByRole('region', { name: 'Ihr Lernplan' });
1416
1417 expect(screen.queryByRole('checkbox', { name: /mitlernen/u })).not.toBeInTheDocument();
1418 });
1419
1420 it('meldet die Abwahl an den Anwendungskern und lädt beide Ansichten neu', async () => {
1421 const bruecke = mitKapitelIV();
1422 const nutzer = starten(bruecke);
1423
1424 await nutzer.click(await screen.findByRole('checkbox', { name: /mitlernen/u }));
1425
1426 await waitFor(() => {
1427 expect(bruecke.profilAktualisieren).toHaveBeenCalledWith(1, { kapitelAusschluss: ['IV'] });
1428 });
1429 /* Lernplan und Lernstand sind entkoppelt und haben getrennte Zähler. Wer
1430 nur einen anstößt, zeigt für eine Weile 575 im Lernstand und 486 im
1431 Plan – untereinander auf demselben Bildschirm. */
1432 await waitFor(() => {
1433 expect(bruecke.lernPlan).toHaveBeenCalledTimes(2);
1434 expect(bruecke.lernUebersicht).toHaveBeenCalledTimes(2);
1435 });
1436 });
1437
1438 it('sagt die Umstellung an – die geänderten Zahlen stehen weiter oben', async () => {
1439 /* Der Schalter steht unter „Ihr Lernplan“, Fortschrittsbalken und
1440 Lernstand stehen im Dokument davor. Wer mit Bildschirmleser arbeitet,
1441 hat sie beim Umlegen längst passiert und merkte sonst nichts. */
1442 const nutzer = starten(mitKapitelIV());
1443
1444 await nutzer.click(await screen.findByRole('checkbox', { name: /mitlernen/u }));
1445
1446 expect(
1447 await screen.findByText(/Kapitel IV ist für dieses Profil abgewählt/u),
1448 ).toBeInTheDocument();
1449 });
1450
1451 it('vermerkt das abgewählte Kapitel in der Kapitelwahl, statt es zu verstecken', async () => {
1452 /* Weder verschwinden noch sperren: Verschwände das Kapitel, fände
1453 niemand den Weg zurück; und gesperrte Bedienelemente nehmen keinen
1454 Fokus – ihre Erklärung würde nie vorgelesen. Üben bleibt möglich, es
1455 zählt nur nicht mit. */
1456 const nutzer = starten(
1457 mitKapitelIV({
1458 profilListe: vi.fn(() => Promise.resolve([{ ...PROFIL, kapitelAusschluss: ['IV'] }])),
1459 }),
1460 );
1461
1462 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1463 await waitFor(() => {
1464 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1465 });
1466 await nutzer.click(knopf);
1467
1468 expect(await screen.findByText(/Für dieses Profil abgewählt/u)).toBeInTheDocument();
1469 expect(screen.getByRole('button', { name: 'Ganzes Kapitel IV üben' })).toBeEnabled();
1470 });
1471
1472 it('bietet für Kapitel ohne amtliche Abschnitte die Themen an', async () => {
1473 /* Der amtliche Katalog gliedert nur Kapitel I in Abschnitte. Für die 230
1474 Fragen der Kapitel II bis IV gab es bis 0.22.0 nur „ganzes Kapitel“ –
1475 92 Fragen allein in Kapitel II. Die Gruppen stammen aus
1476 content/themen.json und sind eine Ergänzung dieser Software. */
1477 const nutzer = starten(
1478 mitKapitelIV({
1479 themenLaden: vi.fn(() =>
1480 Promise.resolve({
1481 meta: { version: 1, stand: '2026-08-29', hinweis: 'Gliederung dieser Software' },
1482 gruppen: [
1483 {
1484 id: 'IV.10',
1485 kapitel: 'IV',
1486 titel: 'Störungen und Versager',
1487 fragen: ['IV-72', 'IV-73', 'IV-74'],
1488 },
1489 ],
1490 }),
1491 ),
1492 }),
1493 );
1494
1495 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1496 await waitFor(() => {
1497 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1498 });
1499 await nutzer.click(knopf);
1500
1501 const thema = await screen.findByRole('button', { name: 'Störungen und Versager (3)' });
1502 expect(thema).toBeInTheDocument();
1503 // Die Herkunft steht dabei: Der amtliche Katalog gliedert hier nicht.
1504 expect(
1505 screen.getByText(/Die Themen sind eine Gliederung dieser Software/u),
1506 ).toBeInTheDocument();
1507 });
1508
1509 it('kommt ohne Themendatei aus', async () => {
1510 // Dann steht da, was vor 0.22.0 dastand – und nicht etwa nichts.
1511 const nutzer = starten(mitKapitelIV());
1512
1513 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
1514 await waitFor(() => {
1515 expect(knopf).toHaveAttribute('aria-disabled', 'false');
1516 });
1517 await nutzer.click(knopf);
1518
1519 expect(await screen.findByRole('button', { name: 'Ganzes Kapitel IV üben' })).toBeEnabled();
1520 expect(screen.queryByText(/Die Themen sind eine Gliederung/u)).not.toBeInTheDocument();
1521 });
1522 });
1523
1524 describe('Lernstand sichern und übertragen', () => {
1525 /*
1526 Die Oberflächenseite von D9. Der Kern – Prüfkette, Reihenfolge beim
1527 Ersetzen, Sicherheitskopie – ist in `tests/sicherung.test.ts` gegen echte
1528 SQLite-Dateien gemessen. Hier geht es um das, was der Nutzer sieht und
1529 entscheidet, bevor etwas ersetzt wird.
1530 */
1531 const KENNZAHLEN = {
1532 profilnamen: ['Olaf'],
1533 jeProfil: [
1534 {
1535 name: 'Olaf',
1536 antworten: 1204,
1537 gemerkt: 12,
1538 pruefungslaeufe: 3,
1539 letzteAntwort: '2026-05-03T21:07:00.000Z',
1540 },
1541 ],
1542 antworten: 1204,
1543 letzteAntwort: '2026-05-03T21:07:00.000Z',
1544 gemerkt: 12,
1545 offen: 0,
1546 fehler: 0,
1547 pruefungslaeufe: 3,
1548 offenerBogen: false,
1549 schemafassung: 7,
1550 };
1551
1552 const BRAUCHBAR = {
1553 art: 'brauchbar' as const,
1554 vorgang: 'v-1',
1555 dateiname: 'Waffensachkunde-Lernstand-2026-05-03-210700.wsklernstand',
1556 ausDatei: KENNZAHLEN,
1557 /* Der Rechner trägt ein anderes Profil als die Datei – genau der Fall,
1558 den die Verlustanzeige benennen muss. */
1559 jetzt: {
1560 ...KENNZAHLEN,
1561 antworten: 42,
1562 profilnamen: ['Standard'],
1563 jeProfil: [
1564 {
1565 name: 'Standard',
1566 antworten: 42,
1567 gemerkt: 12,
1568 pruefungslaeufe: 3,
1569 letzteAntwort: '2026-05-03T21:07:00.000Z',
1570 },
1571 ],
1572 },
1573 };
1574
1575 it('nennt die beiden Wege ungleich – der gefährlichste Fehler ist die Richtung', async () => {
1576 starten();
1577
1578 expect(
1579 await screen.findByRole('button', { name: 'Sicherung speichern …' }),
1580 ).toBeInTheDocument();
1581 expect(
1582 screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1583 ).toBeInTheDocument();
1584 });
1585
1586 /*
1587 Wann zuletzt gesichert wurde, wurde bis 0.22.0 nirgends festgehalten –
1588 die Karte konnte es also auch nicht sagen, und eine Erinnerung wäre
1589 geraten statt gewusst gewesen. Wer die Karte nie öffnete, hatte nichts,
1590 wenn die Datei einmal weg war.
1591 */
1592 it('sagt, dass noch nie gesichert wurde, statt zu schweigen', async () => {
1593 starten();
1594
1595 /* Der Wortlaut kommt aus derselben Quelle, aus der ihn die Karte nimmt.
1596 `sicherungsstandSatz` ist genau dafuer exportiert – bis Fassung 0.24.1
1597 stand der Satz hier abgeschrieben daneben, und der Export hatte keinen
1598 einzigen Nutzer ausserhalb seiner eigenen Datei. */
1599 expect(await screen.findByText(sicherungsstandSatz(null))).toBeInTheDocument();
1600 });
1601
1602 it('nennt den Zeitpunkt der letzten Sicherung', async () => {
1603 starten(
1604 attrappe({
1605 einstellungenLesen: vi.fn(() =>
1606 Promise.resolve({
1607 ...EINSTELLUNGEN_STANDARD,
1608 zuschnittGefragt: true,
1609 letzteSicherung: '2026-08-12T09:15:00.000Z',
1610 }),
1611 ),
1612 }),
1613 );
1614
1615 expect(
1616 await screen.findByText(sicherungsstandSatz('2026-08-12T09:15:00.000Z')),
1617 ).toBeInTheDocument();
1618 });
1619
1620 it('sagt, dass die selbsttätige Kopie keine Sicherung ersetzt', async () => {
1621 /* Die wöchentliche Kopie liegt auf derselben Festplatte. Sie als
1622 Sicherung auszugeben wäre die gefährlichere Unwahrheit: Wer sich
1623 darauf verlässt, sichert nicht mehr selbst. */
1624 starten();
1625
1626 expect(
1627 await screen.findByText(/ersetzt deshalb keine Sicherung, die Sie anderswo aufheben/u),
1628 ).toBeInTheDocument();
1629 });
1630
1631 it('nennt den Ordner, in dem die selbsttätigen Kopien liegen', async () => {
1632 /* Die Wache, die gefehlt hat. Die Karte erzählte von der Kopie, ohne zu
1633 sagen, wo sie liegt – „in seinem eigenen Ordner“. Für jemanden, der
1634 sie im Ernstfall braucht, ist das keine Auskunft: Weder eine Liste
1635 noch ein Dateidialog führt dorthin, und der Ordnername kam in der
1636 ganzen Oberfläche nicht vor. */
1637 starten();
1638
1639 expect(await screen.findByText(/Unterordner/u)).toBeInTheDocument();
1640 expect(screen.getByText('sicherungen')).toBeInTheDocument();
1641 });
1642
1643 it('sagt, dass auch vor den zerstörenden Schritten eine Kopie entsteht', async () => {
1644 /* Seit 0.26.6 legen „Neu anfangen“ und das Löschen eines Profils vorher
1645 eine Kopie an. Steht das nirgends, verlässt sich niemand darauf – und
1646 der Nutzen der Kopie hängt daran, dass man von ihr weiß. */
1647 starten();
1648
1649 expect(await screen.findByText(/unterbleibt der Schritt/u)).toBeInTheDocument();
1650 });
1651
1652 it('öffnet den Ordner der Sicherheitskopien', async () => {
1653 /* Die Wache, die gefehlt hat. Bis 0.26.7 führte weder eine Liste noch
1654 ein Dateidialog zu den selbsttätigen Kopien – wer eine im Ernstfall
1655 brauchte, musste den Pfad kennen. */
1656 const sicherungsordnerZeigen = vi.fn(() => Promise.resolve(true));
1657 const nutzer = starten(attrappe({ sicherungsordnerZeigen }));
1658
1659 await nutzer.click(
1660 await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }),
1661 );
1662
1663 expect(sicherungsordnerZeigen).toHaveBeenCalledWith();
1664 expect(await screen.findByText('Der Ordner ist geöffnet.')).toBeInTheDocument();
1665 });
1666
1667 it('sagt es, wenn es den Ordner noch gar nicht gibt', async () => {
1668 /* Er entsteht erst mit der ersten Kopie. So zu tun, als sei etwas
1669 geschehen, wäre die schlechtere Antwort. */
1670 const nutzer = starten(
1671 attrappe({ sicherungsordnerZeigen: vi.fn(() => Promise.resolve(false)) }),
1672 );
1673
1674 await nutzer.click(
1675 await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }),
1676 );
1677
1678 expect(await screen.findByText(/Den Ordner gibt es noch nicht/u)).toBeInTheDocument();
1679 });
1680
1681 it('nennt den fehlenden Kanal, statt stumm zu bleiben', async () => {
1682 const nutzer = starten(ohneKanaele('sicherungsordnerZeigen'));
1683
1684 await nutzer.click(
1685 await screen.findByRole('button', { name: 'Ordner der Sicherheitskopien öffnen' }),
1686 );
1687
1688 expect(await screen.findByText(/sicherung:ordner-zeigen/u)).toBeInTheDocument();
1689 });
1690
1691 it('lässt den Sitzungsumfang einstellen', async () => {
1692 /* Die Wache, die gefehlt hat. `SITZUNGSUMFANG = 20` war eine Konstante,
1693 und alle sieben Sitzungsstarts reichten sie durch – obwohl der
1694 Anwendungskern jede Zahl von 1 bis 1000 annimmt. Wer täglich zehn
1695 Minuten hat, bekam dieselben zwanzig Fragen wie jemand mit einer
1696 Stunde und ließ die Sitzung halb liegen. */
1697 const einstellungenSchreiben = vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD));
1698 const nutzer = starten(attrappe({ einstellungenSchreiben }));
1699
1700 await nutzer.selectOptions(await screen.findByLabelText('Fragen je Lernsitzung'), '10');
1701
1702 expect(einstellungenSchreiben).toHaveBeenCalledWith(
1703 expect.objectContaining({ sitzungsumfang: 10 }),
1704 );
1705 });
1706
1707 it('legt die eingestellte Zahl an den Kern weiter', async () => {
1708 const lernSitzung = vi.fn(() => Promise.resolve([]));
1709 const nutzer = starten(
1710 attrappe({
1711 lernSitzung,
1712 einstellungenLesen: vi.fn(() =>
1713 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, sitzungsumfang: 8 }),
1714 ),
1715 }),
1716 );
1717
1718 await nutzer.click(await screen.findByRole('button', { name: /Weiterlernen/u }));
1719
1720 expect(lernSitzung).toHaveBeenCalledWith(
1721 expect.anything(),
1722 expect.objectContaining({ anzahl: 8 }),
1723 );
1724 });
1725
1726 it('ersetzt nichts, bevor gefragt wurde', async () => {
1727 /* Der Prüfschritt ist folgenlos. Erst nach der Rückfrage darf der Kanal
1728 laufen, der wirklich etwas anfasst. */
1729 const sicherungEinspielen = vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const }));
1730 const nutzer = starten(
1731 attrappe({
1732 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1733 sicherungEinspielen,
1734 }),
1735 );
1736
1737 await nutzer.click(
1738 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1739 );
1740
1741 expect(
1742 await screen.findByText(/Diesen Lernstand durch die Sicherung ersetzen\?/u),
1743 ).toBeVisible();
1744 expect(sicherungEinspielen).not.toHaveBeenCalled();
1745 });
1746
1747 it('stellt beide Seiten mit denselben Zahlen gegenüber', async () => {
1748 /* Der Datei sieht niemand an, von wann sie ist. Ohne die Gegenüberstellung
1749 entscheidet man blind. */
1750 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1751
1752 await nutzer.click(
1753 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1754 );
1755
1756 /* „Antworten“ und nicht „beantwortete Fragen“: Die Zahl zählt Zeilen im
1757 Protokoll, also auch Wiederholungen derselben Frage. Bis 0.19.0 hieß
1758 sie anders und zählte zusätzlich nie aufgeschlagene Prüfungsfragen mit. */
1759 expect(await screen.findByText(/1 Profil: Olaf · 1\.204 Antworten/u)).toBeVisible();
1760 expect(screen.getByText(/1 Profil: Standard · 42 Antworten/u)).toBeVisible();
1761 });
1762
1763 it('lässt die Eingabetaste auf das Harmlose fallen', async () => {
1764 /* Wer aus Versehen in der Rückfrage landet, trifft mit der Eingabetaste
1765 „behalten“ und nicht „ersetzen“. */
1766 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1767
1768 await nutzer.click(
1769 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1770 );
1771 const knoepfe = screen.getAllByRole('button', { name: /behalten|ersetzen/u });
1772
1773 expect(knoepfe[0]).toHaveAccessibleName('Nein, meinen Lernstand behalten');
1774 });
1775
1776 it('benennt den unterbrochenen Prüfungsbogen als Verlust', async () => {
1777 /* Das einzige, was sich nicht in einer Zahl ausdrücken lässt – und
1778 deshalb sonst stillschweigend verschwände. */
1779 const nutzer = starten(
1780 attrappe({
1781 sicherungPruefen: vi.fn(() =>
1782 Promise.resolve({ ...BRAUCHBAR, jetzt: { ...BRAUCHBAR.jetzt, offenerBogen: true } }),
1783 ),
1784 }),
1785 );
1786
1787 await nutzer.click(
1788 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1789 );
1790
1791 /* Der Satz steht jetzt in der Verlustanzeige, zusammen mit den übrigen
1792 Verlusten – er ist einer davon, nur eben keine Zahl. */
1793 expect(await screen.findByText(/Ein unterbrochener Prüfungsbogen liegt hier/u)).toBeVisible();
1794 });
1795
1796 /*
1797 Zwei Gesamtzahlen nebeneinander genügen nicht: Wer hier ein Profil hat,
1798 das die Datei nicht kennt, liest womöglich „mehr Antworten“ und verliert
1799 trotzdem sein ganzes Profil. Verglichen wird deshalb je Profil.
1800 */
1801 it('benennt ein Profil, das die Datei gar nicht kennt', async () => {
1802 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1803
1804 await nutzer.click(
1805 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1806 );
1807
1808 expect(
1809 await screen.findByText(/Das Profil „Standard“ kommt in der Datei nicht vor/u),
1810 ).toBeVisible();
1811 });
1812
1813 /*
1814 Der dritte Weg. Er steht bewusst VOR dem Ersetzen und trägt die
1815 harmlosere Beschriftung: Wer nur den Stand vom anderen Gerät braucht, soll
1816 hier stehenbleiben und nicht bis zur roten Schaltfläche weiterlesen.
1817 */
1818 it('bietet an, ein einzelnes Profil dazuzunehmen', async () => {
1819 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)) }));
1820
1821 await nutzer.click(
1822 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1823 );
1824
1825 expect(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' })).toBeVisible();
1826 expect(screen.getByRole('combobox', { name: 'Profil aus der Datei' })).toHaveDisplayValue(
1827 'Olaf (1204 Antworten)',
1828 );
1829 });
1830
1831 it('schickt den Index in die Liste, nie eine Profilnummer', async () => {
1832 /* Die Profilnummern der fremden Datei kennt der Renderer nicht, und er
1833 soll sie nicht kennen: In einer fremden Datei führte eine Nummer
1834 geradewegs in das Profil einer anderen Person. */
1835 const sicherungUebernehmen = vi.fn(() =>
1836 Promise.resolve({
1837 art: 'uebernommen' as const,
1838 profilId: 7,
1839 name: 'Olaf (übernommen)',
1840 antworten: 1204,
1841 bearbeiteteFragen: 480,
1842 sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand',
1843 katalogstandDerQuelle: null,
1844 }),
1845 );
1846 const nutzer = starten(
1847 attrappe({
1848 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1849 sicherungUebernehmen,
1850 }),
1851 );
1852
1853 await nutzer.click(
1854 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1855 );
1856 await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' }));
1857
1858 expect(sicherungUebernehmen).toHaveBeenCalledWith(BRAUCHBAR.vorgang, 0);
1859 expect(
1860 await screen.findByText(/Das Profil „Olaf \(übernommen\)“ wurde übernommen/u),
1861 ).toBeVisible();
1862 });
1863
1864 it('nennt in der Erfolgsmeldung die Sicherheitskopie und dass nichts ersetzt wurde', async () => {
1865 const nutzer = starten(
1866 attrappe({
1867 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1868 sicherungUebernehmen: vi.fn(() =>
1869 Promise.resolve({
1870 art: 'uebernommen' as const,
1871 profilId: 7,
1872 name: 'Olaf',
1873 antworten: 1,
1874 bearbeiteteFragen: 1,
1875 sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand',
1876 katalogstandDerQuelle: null,
1877 }),
1878 ),
1879 }),
1880 );
1881
1882 await nutzer.click(
1883 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1884 );
1885 await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' }));
1886
1887 const meldung = await screen.findByText(/wurde übernommen/u);
1888 /* Beide Zahlen in einem Satz, der ihr Verhältnis nennt: Bis 0.19.1 stand
1889 darüber „39 Antworten“ und darunter „24 bearbeitete Fragen“ – beides
1890 richtig, und trotzdem las es sich wie ein Widerspruch. */
1891 expect(meldung).toHaveTextContent(/1 Antwort zu 1 verschiedenen Fragen/u);
1892 expect(meldung).toHaveTextContent(/Ihre übrigen Profile sind unverändert/u);
1893 expect(meldung).toHaveTextContent(/Lernstand-vor-dem-Uebernehmen-/u);
1894 });
1895
1896 it('gibt den Fokus auch zurück, wenn das Einspielen scheitert', async () => {
1897 /*
1898 Befund der Prüfrunde zu 0.27.2. Der `then`-Zweig von `einspielen` holt
1899 den Fokus seit jeher zurück, der `catch`-Zweig nicht — als einziger der
1900 vier Wege dieser Karte. Der Modulkopf begründet ausführlich, warum hier
1901 kein `disabled` steht: „Ein gesperrtes Element verliert den Fokus, und
1902 der fällt auf `<body>`.“
1903 */
1904 const nutzer = starten(
1905 attrappe({
1906 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1907 sicherungEinspielen: vi.fn(() => Promise.reject(new Error('Datei gesperrt'))),
1908 }),
1909 );
1910
1911 await nutzer.click(
1912 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1913 );
1914 await nutzer.click(await screen.findByRole('button', { name: /Ja, alles ersetzen/u }));
1915 await screen.findByText(/Datei gesperrt/u);
1916
1917 expect(document.body).not.toHaveFocus();
1918 expect(screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' })).toHaveFocus();
1919 });
1920
1921 it('gibt den Fokus nach dem Übernehmen an den auslösenden Knopf zurück', async () => {
1922 /*
1923 Befund der Prüfrunde zu 0.27.2. Der Modulkopf der Karte begründet
1924 ausführlich, warum hier kein `disabled` steht: „Ein gesperrtes Element
1925 verliert den Fokus, und der fällt auf `<body>`.“ Genau das geschah auf
1926 diesem einen Weg trotzdem: `uebernehmen` hängt die Rückfrage samt
1927 gedrücktem Knopf aus und holte den Fokus danach in keinem der beiden
1928 Zweige zurück. Sichern, Prüfen und Einspielen tun es seit jeher.
1929
1930 Wer per Tastatur arbeitet, stand danach auf `<body>` und begann mit dem
1931 nächsten Tabulator wieder am Seitenanfang — nach der folgenreichsten
1932 Handlung dieser Karte.
1933 */
1934 const nutzer = starten(
1935 attrappe({
1936 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1937 sicherungUebernehmen: vi.fn(() =>
1938 Promise.resolve({
1939 art: 'uebernommen' as const,
1940 profilId: 7,
1941 name: 'Olaf',
1942 antworten: 1,
1943 bearbeiteteFragen: 1,
1944 sicherheitskopie: 'Lernstand-vor-dem-Uebernehmen-2026-08-23-100000.wsklernstand',
1945 katalogstandDerQuelle: null,
1946 }),
1947 ),
1948 }),
1949 );
1950
1951 await nutzer.click(
1952 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1953 );
1954 await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' }));
1955 await screen.findByText(/wurde übernommen/u);
1956
1957 expect(document.body).not.toHaveFocus();
1958 expect(screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' })).toHaveFocus();
1959 });
1960
1961 it('gibt ihn auch zurück, wenn das Übernehmen scheitert', async () => {
1962 /* Die Gegenprobe: Der Fehlerzweig darf nicht der sein, der übrig bleibt. */
1963 const nutzer = starten(
1964 attrappe({
1965 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
1966 sicherungUebernehmen: vi.fn(() => Promise.reject(new Error('Datei gesperrt'))),
1967 }),
1968 );
1969
1970 await nutzer.click(
1971 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1972 );
1973 await nutzer.click(await screen.findByRole('button', { name: 'Dieses Profil dazunehmen' }));
1974 await screen.findByText(/Datei gesperrt/u);
1975
1976 expect(screen.getByRole('button', { name: 'Sicherung auswählen und ansehen …' })).toHaveFocus();
1977 });
1978
1979 it('sagt bei gleichem Stand, WAS verglichen wurde – nicht „alles stimmt“', async () => {
1980 /* Ein unbedingter Satz über einen Inhalt, der nicht vollständig geprüft
1981 wurde, ist genau der Fehler aus docs/stand.md 7.6. Prüfungstermin und
1982 Kapitelabwahl werden mitersetzt und sind nicht verglichen. */
1983 const gleich = { ...BRAUCHBAR, jetzt: BRAUCHBAR.ausDatei };
1984 const nutzer = starten(attrappe({ sicherungPruefen: vi.fn(() => Promise.resolve(gleich)) }));
1985
1986 await nutzer.click(
1987 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
1988 );
1989
1990 const satz = await screen.findByText(/Verglichen wurden Profile, Antworten/u);
1991 expect(satz).toHaveTextContent(/Nicht verglichen wurden Prüfungstermin und Kapitelabwahl/u);
1992 });
1993
1994 it('wechselt nach dem Einspielen auf das erste Profil der neuen Datenbank', async () => {
1995 /*
1996 Nicht verzichtbar: `useLernstand` hält die zuletzt gewählte Nummer in
1997 einem Ref, das ein Neuladen überlebt. Steht dort eine Nummer, die es in
1998 der eingespielten Datenbank zufällig auch gibt, führte sie geradewegs in
1999 das Profil einer anderen Person.
2000 */
2001 const bruecke = attrappe({
2002 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
2003 sicherungEinspielen: vi.fn(() =>
2004 Promise.resolve({
2005 art: 'eingespielt' as const,
2006 profilzahl: 1,
2007 ersteProfilId: 1,
2008 bearbeiteteFragen: 480,
2009 einstellungenUebernommen: false,
2010 sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand',
2011 katalogstandDerQuelle: null,
2012 }),
2013 ),
2014 });
2015 const nutzer = starten(bruecke);
2016
2017 await nutzer.click(
2018 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
2019 );
2020 await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' }));
2021
2022 expect(await screen.findByText(/Der Lernstand ist eingelesen/u)).toBeVisible();
2023 /* Die Sicherheitskopie wird beim Namen genannt – sie ist der einzige Weg
2024 zurück. */
2025 expect(screen.getByText(/Lernstand-vor-dem-Einspielen-/u)).toBeVisible();
2026 await waitFor(() => {
2027 expect(bruecke.lernUebersicht).toHaveBeenCalledTimes(2);
2028 });
2029 });
2030
2031 it('schweigt von Einstellungen, wenn die Sicherung keine enthielt', async () => {
2032 /* Der Regelfall der Vergangenheit: jede Sicherung vor Fassung 0.27.0.
2033 Eine Meldung „übernommen“ wäre dann schlicht unwahr. */
2034 const nutzer = starten(
2035 attrappe({
2036 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
2037 sicherungEinspielen: vi.fn(() =>
2038 Promise.resolve({
2039 art: 'eingespielt' as const,
2040 profilzahl: 1,
2041 ersteProfilId: 1,
2042 bearbeiteteFragen: 12,
2043 einstellungenUebernommen: false,
2044 sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand',
2045 }),
2046 ),
2047 }),
2048 );
2049
2050 await nutzer.click(
2051 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
2052 );
2053 await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' }));
2054
2055 expect(await screen.findByText(/Der Lernstand ist eingelesen/u)).toBeVisible();
2056 expect(screen.queryByText(/mit übernommen worden/u)).toBeNull();
2057 });
2058
2059 it('nennt die übernommenen Einstellungen und lässt sie sofort gelten', async () => {
2060 /*
2061 Der Grund für die ganze Einrichtung: Wer 400 Prozent Anzeigegröße
2062 braucht, musste bis 0.26.7 auf dem zweiten Rechner ohne sie anfangen –
2063 um sie einzustellen.
2064
2065 Geprüft wird beides zusammen, weil nur beides zusammen wahr ist: Die
2066 Karte sagt „übernommen“, UND die Oberfläche liest die Einstellungen
2067 wirklich noch einmal. Ohne das zweite stünde die Meldung da, während
2068 Schrift, Farben und Tasten die alten blieben.
2069 */
2070 const lesen = vi.fn(() =>
2071 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
2072 );
2073 const nutzer = starten(
2074 attrappe({
2075 einstellungenLesen: lesen,
2076 sicherungPruefen: vi.fn(() => Promise.resolve(BRAUCHBAR)),
2077 sicherungEinspielen: vi.fn(() =>
2078 Promise.resolve({
2079 art: 'eingespielt' as const,
2080 profilzahl: 1,
2081 ersteProfilId: 1,
2082 bearbeiteteFragen: 12,
2083 einstellungenUebernommen: true,
2084 sicherheitskopie: 'Lernstand-vor-dem-Einspielen-2026-08-22-143207.wsklernstand',
2085 }),
2086 ),
2087 }),
2088 );
2089
2090 await nutzer.click(
2091 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
2092 );
2093 const vorEinspielen = lesen.mock.calls.length;
2094 await nutzer.click(await screen.findByRole('button', { name: 'Ja, alles ersetzen' }));
2095
2096 expect(
2097 await screen.findByText(/Ihre Einstellungen für Anzeige, Farben, Vorlesen und Tasten/u),
2098 ).toBeVisible();
2099 /* Drei Hooks lesen die Datei getrennt (siehe `einstellungssignal.ts`);
2100 geprüft wird, dass überhaupt neu gelesen wird, nicht wie oft. */
2101 await waitFor(() => {
2102 expect(lesen.mock.calls.length).toBeGreaterThan(vorEinspielen);
2103 });
2104 });
2105
2106 it('sagt nach einer Abweisung, dass nichts verändert wurde', async () => {
2107 const nutzer = starten(
2108 attrappe({
2109 sicherungPruefen: vi.fn(() =>
2110 Promise.resolve({
2111 art: 'abgelehnt' as const,
2112 grund: 'Diese Datei ist leer oder viel zu klein. Es wurde nichts verändert.',
2113 }),
2114 ),
2115 }),
2116 );
2117
2118 await nutzer.click(
2119 await screen.findByRole('button', { name: 'Sicherung auswählen und ansehen …' }),
2120 );
2121
2122 expect(await screen.findByText(/Es wurde nichts verändert/u)).toBeVisible();
2123 });
2124 });
2125
2126 describe('Was heute ansteht und was heute geschah', () => {
2127 /*
2128 `pensum.gesamt` ist `neu + fällig`, und `neu` ist eine Rate, die sich
2129 nachfüllt. Wer zwanzig neue Fragen bearbeitete, las danach erneut
2130 „Heute 20 Fragen“ – ein Satz, der nach getaner Arbeit dasselbe verlangt
2131 wie davor.
2132 */
2133 const MIT_ARBEIT = { ...UEBERSICHT, heuteBearbeitet: 18 };
2134
2135 it('nennt am unangefangenen Tag die Empfehlung', async () => {
2136 starten(
2137 attrappe({
2138 lernPlan: vi.fn(() =>
2139 Promise.resolve({
2140 ...LERNPLAN,
2141 pensum: { neu: 14, wiederholung: 6, gesamt: 20, minuten: 8 },
2142 }),
2143 ),
2144 }),
2145 );
2146
2147 expect(await screen.findByText('Heute 20 Fragen, etwa 8 Minuten.')).toBeInTheDocument();
2148 });
2149
2150 it('nennt nach der ersten Antwort die Arbeit und beide Restgrößen', async () => {
2151 /* Beide, nicht nur die fällige: Wer einen Prüfungstermin hat, entscheidet
2152 über die neuen Fragen, ob der Katalog bis dahin durchläuft. */
2153 starten(
2154 attrappe({
2155 lernUebersicht: vi.fn(() => Promise.resolve(MIT_ARBEIT)),
2156 lernPlan: vi.fn(() =>
2157 Promise.resolve({
2158 ...LERNPLAN,
2159 pensum: { neu: 5, wiederholung: 6, gesamt: 11, minuten: 5 },
2160 }),
2161 ),
2162 }),
2163 );
2164
2165 expect(
2166 await screen.findByText(
2167 'Heute 18 Fragen bearbeitet; offen sind noch 6 zur Wiederholung und 5 neue.',
2168 ),
2169 ).toBeInTheDocument();
2170 });
2171
2172 it('zeigt nirgends eine Null und nirgends das Wort „Ziel“', async () => {
2173 /* Kein Ring, kein Bruch, kein Prozentwert. Wer heute nichts getan hat,
2174 liest die Empfehlung – nicht „Heute 0 Fragen bearbeitet“. */
2175 starten();
2176 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2177
2178 const einstieg = screen.getByRole('region', { name: 'Heute lernen' });
2179 expect(einstieg).not.toHaveTextContent(/Heute 0 Fragen bearbeitet/u);
2180 expect(einstieg).not.toHaveTextContent(/Ziel/u);
2181 });
2182
2183 it('sagt beim verstrichenen Termin, was Sache ist', async () => {
2184 /*
2185 Nach dem Termin setzt der Lernplan `neu` auf 0 – nicht weil alles
2186 gesehen wäre, sondern weil es nichts mehr zu verteilen gibt. „Auf dem
2187 Laufenden“ wäre dann eine Behauptung über den Katalog, die nicht stimmt.
2188 */
2189 starten(
2190 attrappe({
2191 lernPlan: vi.fn(() =>
2192 Promise.resolve({
2193 ...LERNPLAN,
2194 machbarkeit: 'termin_vorbei' as const,
2195 pensum: { neu: 0, wiederholung: 0, gesamt: 0, minuten: 0 },
2196 }),
2197 ),
2198 }),
2199 );
2200
2201 expect(await screen.findByText(/Prüfungstermin ist verstrichen/u)).toBeInTheDocument();
2202 expect(screen.queryByText(/auf dem Laufenden/u)).toBeNull();
2203 });
2204
2205 it('nennt im Lernstand, wann zuletzt gelernt wurde', async () => {
2206 /* Die Tatsache statt eines Zählers: Wer nach zwei Wochen zurückkommt,
2207 sieht einen gefallenen Reifegrad und erfährt sonst nirgends, warum. */
2208 starten(
2209 attrappe({
2210 lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, tageSeitLetzterAntwort: 14 })),
2211 }),
2212 );
2213
2214 expect(await screen.findByText('Zuletzt gelernt')).toBeInTheDocument();
2215 expect(screen.getByText('vor 14 Tagen')).toBeInTheDocument();
2216 });
2217
2218 it('unterscheidet „noch nie“ von „heute“', async () => {
2219 starten();
2220
2221 expect(await screen.findByText('noch nie')).toBeInTheDocument();
2222 });
2223 });
2224
2225 describe('Der Prüfungstag auf dem Startbildschirm', () => {
2226 it('sagt, dass keine neuen Fragen mehr dazukommen', async () => {
2227 /* Wer heute geprüft wird, will keine Empfehlung, sondern eine Auskunft.
2228 Vorher stand hier „Heute 320 Fragen, etwa 133 Minuten“. */
2229 starten(
2230 attrappe({
2231 lernPlan: vi.fn(() =>
2232 Promise.resolve({
2233 ...LERNPLAN,
2234 termin: '2026-06-15',
2235 tageBisTermin: 0,
2236 pensum: { neu: 0, wiederholung: 43, gesamt: 43, minuten: 18 },
2237 }),
2238 ),
2239 }),
2240 );
2241
2242 expect(
2243 await screen.findByText(
2244 'Heute ist Ihr Prüfungstermin. Neue Fragen kommen keine mehr dazu; zum Auffrischen stehen 43 Fragen zur Wiederholung bereit.',
2245 ),
2246 ).toBeInTheDocument();
2247 });
2248
2249 it('meldet am Prüfungstag ohne Fälliges, dass nichts mehr ansteht', async () => {
2250 starten(
2251 attrappe({
2252 lernPlan: vi.fn(() =>
2253 Promise.resolve({
2254 ...LERNPLAN,
2255 termin: '2026-06-15',
2256 tageBisTermin: 0,
2257 pensum: { neu: 0, wiederholung: 0, gesamt: 0, minuten: 0 },
2258 }),
2259 ),
2260 }),
2261 );
2262
2263 expect(
2264 await screen.findByText('Heute ist Ihr Prüfungstermin. Es steht nichts mehr an.'),
2265 ).toBeInTheDocument();
2266 });
2267 });
2268
2269 describe('Binnennavigation auf der langen Seite', () => {
2270 /*
2271 Der Startbildschirm ist bei 1265 Pixel Breite 8530 Pixel hoch – gemessen
2272 am 30.08.2026 an docs/bildschirmfotos/01-startbildschirm-hell.png, im
2273 hohen Kontrast 8602. Zum Stand 0.20.0 waren es 8078, vor dem
2274 Zusammenlegen der Druckkarten 9032. Wer mit einem Bildschirmleser
2275 arbeitet, hat die Überschriftenliste; wer sieht, hatte bisher nur das
2276 Rollen – kein Verzeichnis, keine Anker.
2277 */
2278
2279 function sprungmenue(): HTMLElement {
2280 return screen.getByRole('navigation', { name: 'Weiter unten auf dieser Seite' });
2281 }
2282
2283 it('führt jede Karte unterhalb des Menüs auch als Eintrag', async () => {
2284 /* Der Punkt dieser Prüfung ist die Vollständigkeit: Eine neu angelegte
2285 Karte, die im Verzeichnis fehlt, ist genau der Rückfall in den
2286 Zustand vor 0.20.0 – nur unauffälliger. */
2287 starten();
2288 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2289
2290 const menue = sprungmenue();
2291 const eintraege = within(menue).getAllByRole('link');
2292 const kartenDarunter = [...document.querySelectorAll('main section.karte h2')].filter(
2293 (ueberschrift) =>
2294 (menue.compareDocumentPosition(ueberschrift) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
2295 );
2296
2297 expect(eintraege).toHaveLength(kartenDarunter.length);
2298 });
2299
2300 it('landet mit jedem Eintrag auf einer Kartenüberschrift', async () => {
2301 /* Eine Kennung, die es nicht mehr gibt, macht aus dem Eintrag ein Angebot
2302 ohne Ziel – und der Klick bliebe folgenlos, ohne dass jemand etwas
2303 merkt. Geprüft wird deshalb jeder Eintrag einzeln. */
2304 const nutzer = starten();
2305 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2306
2307 for (const verweis of within(sprungmenue()).getAllByRole('link')) {
2308 const name = verweis.textContent;
2309 await nutzer.click(verweis);
2310 const ziel = document.activeElement;
2311
2312 expect(ziel?.tagName, `„${name}“ trifft keine Überschrift`).toBe('H2');
2313 expect(ziel?.closest('section.karte'), `„${name}“ trifft keine Karte`).not.toBeNull();
2314 }
2315 });
2316
2317 it('trägt die Einträge als Verweise, nicht als zweite Knöpfe', async () => {
2318 /* Die Namen der Einträge sind dieselben wie die der Schaltflächen in den
2319 Karten – „Fragen durchsuchen“ steht seit der Vereinheitlichung an
2320 beiden Stellen. Als Schaltfläche wäre jeder Eintrag ein zweiter Knopf
2321 desselben Namens; als Verweis ist er eine Sprungmarke und benennt sein
2322 Ziel im `href`. */
2323 starten();
2324 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2325
2326 const menue = sprungmenue();
2327 expect(within(menue).queryAllByRole('button')).toHaveLength(0);
2328 expect(within(menue).getByRole('link', { name: 'Fragen durchsuchen' })).toHaveAttribute(
2329 'href',
2330 '#suche-titel',
2331 );
2332 });
2333
2334 it('springt zur Karte und nimmt den Fokus mit', async () => {
2335 /* Reines Rollen wäre für Tastatur und Bildschirmleser wirkungslos – der
2336 nächste Tabulatorsprung begänne wieder ganz oben (WCAG 2.4.3). */
2337 const nutzer = starten();
2338 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2339
2340 await nutzer.click(within(sprungmenue()).getByRole('link', { name: 'Sichern und übertragen' }));
2341
2342 expect(
2343 screen.getByRole('heading', { name: 'Lernstand sichern und übertragen', level: 2 }),
2344 ).toHaveFocus();
2345 });
2346
2347 it('steht unter der Einstieg-Karte, nicht darüber', async () => {
2348 /* Die Einstieg-Karte hat ein gemessenes Höhenbudget, damit der Weg ins
2349 Lernen im ersten Bildschirm bleibt (e2e/blickfeld.spec.ts). Eine
2350 Leiste davor kostete genau dieses Budget. */
2351 starten();
2352 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2353
2354 const einstieg = screen.getByRole('region', { name: 'Heute lernen' });
2355 const stellung = einstieg.compareDocumentPosition(sprungmenue());
2356
2357 expect(stellung & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
2358 });
2359
2360 it('verkürzt den Tastaturweg in die Verwaltung', async () => {
2361 /*
2362 Nachgemessen in dieser Attrappe (eine Radiogruppe zählt als ein Halt):
2363 Bis zum ersten Bedienelement der Sicherungskarte waren es vor dem
2364 Verzeichnis 20 Tabulatorhalte, alle nacheinander. Über das Verzeichnis
2365 sind es 13 – fünf Einstiege, dann der achte Eintrag der Leiste –,
2366 danach steht der Fokus auf der Überschrift der Karte.
2367
2368 Wer die Leiste nicht benutzt und stumpf weitertabbt, kommt auf 27. Das
2369 ist der Preis, und er ist bewusst bezahlt – ohne Leiste gab es für
2370 sehende Bedienung überhaupt keinen Sprung.
2371
2372 Beide Zahlen sind seit 0.27.0 um zwei gewachsen: Die Karte „Gesetze
2373 lesen“ bringt einen Eintrag in die Leiste und einen Knopf mit.
2374 */
2375 starten();
2376 await screen.findByRole('progressbar', { name: 'Prüfungsreife insgesamt' });
2377
2378 const halte = tabulatorhalte();
2379 const sprung = within(sprungmenue()).getByRole('link', { name: 'Sichern und übertragen' });
2380 const sicherung = document.getElementById('sicherung-titel')?.closest('section');
2381
2382 expect(halte.indexOf(sprung)).toBe(12);
2383 expect(halte.findIndex((element) => sicherung?.contains(element) ?? false)).toBe(26);
2384 });
2385 });
2386
2387 describe('Drucken – eine Karte statt dreier', () => {
2388 /*
2389 Lernbericht, Fehlerprotokoll und Fragenliste standen bis 0.21.0 als drei
2390 Karten untereinander, jede mit einer eigenen Frage nach der Schriftgröße.
2391 Im damaligen Bildschirmfoto nahmen sie zusammen rund 1740 der 9032 Pixel
2392 ein; zum Stand 0.20.0 maß dieselbe Seite 8078 und heute 8530.
2393 */
2394
2395 it('fragt zuerst nach dem Dokument und zeigt dann nur dieses', async () => {
2396 starten();
2397
2398 const karte = await screen.findByRole('region', { name: 'Drucken' });
2399 expect(within(karte).getByRole('radio', { name: 'Lernbericht' })).toBeChecked();
2400 expect(within(karte).getByRole('heading', { name: 'Lernbericht', level: 3 })).toBeVisible();
2401
2402 /* Genau eine Schriftgrößenfrage – nicht drei untereinander. */
2403 expect(screen.getAllByRole('group', { name: 'Schriftgröße des Dokuments' })).toHaveLength(1);
2404 expect(
2405 screen.queryByRole('button', { name: 'Fehlerprotokoll als PDF speichern' }),
2406 ).not.toBeInTheDocument();
2407 });
2408
2409 it('stellt auf das gewählte Dokument um', async () => {
2410 const nutzer = starten();
2411
2412 const karte = await screen.findByRole('region', { name: 'Drucken' });
2413 await nutzer.click(within(karte).getByRole('radio', { name: 'Fehlerprotokoll' }));
2414
2415 expect(within(karte).getByRole('heading', { name: 'Fehlerprotokoll', level: 3 })).toBeVisible();
2416 expect(
2417 within(karte).getByRole('button', { name: 'Fehlerprotokoll als PDF speichern' }),
2418 ).toBeVisible();
2419 expect(
2420 within(karte).queryByRole('button', { name: 'Lernbericht als PDF speichern' }),
2421 ).not.toBeInTheDocument();
2422 });
2423
2424 it('sagt den geänderten Seitenumfang an, statt ihn stumm zu wechseln', async () => {
2425 /*
2426 Befund der Prüfrunde zu 0.27.2. `shared/druck/umfang.ts` begründet den
2427 Satz so: „Der schlimmste gemessene Fall sind 982 Seiten … Wer das ohne
2428 Vorwarnung an einen Drucker schickt, hat ein Problem. Die Zahl muss
2429 deshalb dastehen, bevor der Speicherdialog aufgeht.“ Dastehen tat sie —
2430 angesagt wurde sie nicht: Der Absatz trug keine Live-Region, und die
2431 Tabulatorhalte führen von Radiogruppe zu Radiogruppe zur Schaltfläche
2432 daran vorbei. Wer die Begründungstiefe umstellte, erfuhr nichts von der
2433 Verdopplung.
2434 */
2435 /* Die Grundattrappe führt null Fehler; ohne Fehler gibt es keinen
2436 Umfangssatz. */
2437 const nutzer = starten(
2438 attrappe({
2439 lernUebersicht: vi.fn(() => Promise.resolve({ ...UEBERSICHT, fehler: 40 })),
2440 }),
2441 );
2442
2443 const karte = await screen.findByRole('region', { name: 'Drucken' });
2444 await nutzer.click(within(karte).getByRole('radio', { name: 'Fehlerprotokoll' }));
2445
2446 /* Über die Klasse und nicht über die Rolle: Die Meldungsregion derselben
2447 Karte trägt `role="status"` ebenfalls. Zwei Knoten mit derselben Rolle
2448 waren hier schon einmal ein Fund der Wachen – deshalb spricht dieser
2449 Absatz über `aria-live` und nicht über eine zweite Rolle. */
2450 const region = karte.querySelector('.bericht__umfang');
2451 expect(region, 'Kein Umfangssatz gefunden').not.toBeNull();
2452 expect(region).toHaveAttribute('aria-live', 'polite');
2453
2454 const vorher = region?.textContent;
2455 expect(vorher).toMatch(/Seite/u);
2456
2457 await nutzer.click(within(karte).getByRole('radio', { name: /Vollständige Begründung/u }));
2458
2459 expect(region?.textContent).not.toBe(vorher);
2460 expect(region?.textContent).toMatch(/Seite/u);
2461 });
2462
2463 it('bietet die Fragenliste nur an, wenn der Katalog dasteht', async () => {
2464 /* Die Liste hängt am Katalog. Ohne ihn gäbe es nichts zu drucken – dann
2465 steht die Wahl gar nicht erst da, statt in eine gesperrte Schaltfläche
2466 zu führen. */
2467 starten(attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }));
2468
2469 const karte = await screen.findByRole('region', { name: 'Drucken' });
2470 expect(within(karte).getByRole('radio', { name: 'Lernbericht' })).toBeInTheDocument();
2471 expect(within(karte).queryByRole('radio', { name: 'Fragenliste' })).not.toBeInTheDocument();
2472 });
2473 });
2474
2475 describe('Startbildschirm – Benennung', () => {
2476 it('nennt die Suche überall gleich', async () => {
2477 /* Bis 0.20.0 hieß dieselbe Sache „Fragen finden“ (Überschrift), „Fragen
2478 durchsuchen“ (Schaltfläche) und „Fragen finden und nachschlagen“
2479 (Hilfe). */
2480 starten();
2481
2482 expect(
2483 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 2 }),
2484 ).toBeInTheDocument();
2485 expect(screen.getByRole('button', { name: 'Fragen durchsuchen' })).toBeInTheDocument();
2486 expect(screen.queryByText('Fragen finden')).not.toBeInTheDocument();
2487 });
2488
2489 it('sagt an der Prüfungsschaltfläche, wohin sie führt', async () => {
2490 /* Der Knopf führt in die Vorbereitung; gestartet wird erst dort. Vorher
2491 hieß er „Prüfungssimulation starten“ und versprach damit den Schritt,
2492 den erst die nächste Ansicht tut. */
2493 const nutzer = starten();
2494
2495 const knopf = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
2496 await waitFor(() => {
2497 expect(knopf).toHaveAttribute('aria-disabled', 'false');
2498 });
2499 await nutzer.click(knopf);
2500
2501 expect(
2502 await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten', level: 1 }),
2503 ).toBeInTheDocument();
2504 });
2505 });
2506
2507 describe('Gesetze lesen', () => {
2508 /*
2509 Die Vorschriften lagen seit 0.22.0 im Paket und waren nur über eine
2510 Frage erreichbar: Wer wissen wollte, was in § 13 WaffG steht, ohne die
2511 passende Frage zu kennen, hatte keinen Weg dorthin.
2512 */
2513
2514 const NORMTEXTE: Normtexte = {
2515 meta: { version: 1, stand: '2026-09-01', hinweis: 'Prüfstand', gesetzesstand: {} },
2516 gesetze: {
2517 WaffG: {
2518 bezeichnung: 'Waffengesetz',
2519 stand: 'Zuletzt geändert am 20.3.2026',
2520 quelle: 'gesetze-im-internet.de',
2521 normen: {
2522 '§ 2': {
2523 titel: 'Grundsätze des Umgangs mit Waffen',
2524 istAnlage: false,
2525 absaetze: { '1': 'Der Umgang mit Waffen ist nur Berechtigten gestattet.' },
2526 },
2527 '§ 13': {
2528 titel: 'Jäger',
2529 istAnlage: false,
2530 absaetze: { '1': 'Ein Bedürfnis für den Erwerb von Langwaffen wird bei Jägern …' },
2531 },
2532 },
2533 },
2534 StGB: {
2535 bezeichnung: 'Strafgesetzbuch',
2536 stand: 'Zuletzt geändert am 1.1.2026',
2537 quelle: 'gesetze-im-internet.de',
2538 normen: {
2539 '§ 32': {
2540 titel: 'Notwehr',
2541 istAnlage: false,
2542 absaetze: { '1': 'Wer eine Tat begeht, die durch Notwehr geboten ist …' },
2543 },
2544 },
2545 },
2546 /* Eine Anlage gehörte bis 0.27.2 nicht in diesen Prüfstand – und
2547 genau deshalb ist die Verdopplung der Gliederungsnummer niemandem
2548 aufgefallen. Der Wortlaut ist wie im echten Bestand gebaut: Der
2549 Text beginnt mit seiner eigenen Marke (nachgemessen an
2550 `content/normtexte.json`: 231 von 231 markierten Blöcken). */
2551 SprengG: {
2552 bezeichnung: 'Sprengstoffgesetz',
2553 stand: 'Zuletzt geändert am 5.5.2026',
2554 quelle: 'gesetze-im-internet.de',
2555 normen: {
2556 'Anlage 1': {
2557 titel: '(zu § 1 Abs. 4)',
2558 istAnlage: true,
2559 bloecke: [
2560 { marke: null, pfad: [], text: 'Vorbemerkung zur Anlage.' },
2561 { marke: 'Abschnitt 1', pfad: [], text: 'Abschnitt 1:\nBegriffe' },
2562 {
2563 marke: '1.1',
2564 pfad: ['Abschnitt 1'],
2565 text: '1.1\nExplosionsgefährliche Stoffe sind feste Stoffe.',
2566 },
2567 ],
2568 },
2569 },
2570 },
2571 },
2572 };
2573
2574 function mitGesetzen(): ReturnType<typeof attrappe> {
2575 /* Die Normtexte liegen in einem Zwischenspeicher des Moduls; ohne das
2576 Leeren sähe diese Gruppe, was eine frühere Prüfung geladen hat. */
2577 normtexteZwischenspeicherLeeren();
2578 return attrappe({ normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE)) });
2579 }
2580
2581 /** Öffnet die Ansicht und wählt ein Gesetz – der Weg eines Menschen. */
2582 async function inDasGesetz(
2583 nutzer: ReturnType<typeof starten>,
2584 bezeichnung: string,
2585 ): Promise<void> {
2586 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2587 await nutzer.click(await screen.findByRole('button', { name: bezeichnung }));
2588 }
2589
2590 it('führt vom Startbildschirm in die Gesetze und wieder zurück', async () => {
2591 const nutzer = starten(mitGesetzen());
2592
2593 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2594 expect(await screen.findByRole('heading', { name: 'Gesetze lesen', level: 1 })).toBeVisible();
2595
2596 await nutzer.click(screen.getByRole('button', { name: 'Zurück zum Start' }));
2597 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
2598 });
2599
2600 it('nennt zu jedem Gesetz den Änderungsstand', async () => {
2601 /* Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute,
2602 die morgen falsch sein kann. */
2603 const nutzer = starten(mitGesetzen());
2604 await inDasGesetz(nutzer, 'Waffengesetz');
2605
2606 expect(await screen.findByText(/Zuletzt geändert am 20\.3\.2026/u)).toBeVisible();
2607 expect(screen.getByText(/Amtlicher Wortlaut, unverändert übernommen/u)).toBeVisible();
2608 });
2609
2610 it('zeigt den Wortlaut erst beim Aufklappen', async () => {
2611 /* Anlage 1 des WaffG allein hat 29 000 Zeichen; eine Liste, die beim
2612 Öffnen alles ausrollt, ist für niemanden zu überblicken. */
2613 const nutzer = starten(mitGesetzen());
2614 await inDasGesetz(nutzer, 'Waffengesetz');
2615
2616 expect(screen.queryByText(/nur Berechtigten gestattet/u)).toBeNull();
2617 await nutzer.click(await screen.findByText(/§ 2 – Grundsätze des Umgangs mit Waffen/u));
2618 expect(await screen.findByText(/nur Berechtigten gestattet/u)).toBeVisible();
2619 });
2620
2621 it('stellt der Gliederungsnummer einer Anlage nicht dieselbe Nummer voran', async () => {
2622 /*
2623 Befund der Prüfrunde zu 0.27.2. Der Modulkopf der Ansicht sagt zu: „Der
2624 Text ist der amtliche, unverändert (§ 5 Abs. 1 UrhG). Er wird nicht
2625 gekürzt, nicht zusammengefasst und nicht umsortiert.“ Die Darstellung
2626 setzte die Marke aber noch einmal vor einen Text, der bereits mit ihr
2627 beginnt — auf dem Bildschirm stand „1.1 1.1 Explosionsgefährliche …“.
2628 `Normstelle.tsx` gibt dieselben Blöcke ohne Präfix aus.
2629 */
2630 const nutzer = starten(mitGesetzen());
2631 await inDasGesetz(nutzer, 'Sprengstoffgesetz');
2632
2633 await nutzer.click(await screen.findByText(/Anlage 1 – \(zu § 1 Abs\. 4\)/u));
2634
2635 const text = (await screen.findByText(/Explosionsgefährliche Stoffe/u)).textContent;
2636 expect(text).toContain('1.1');
2637 expect(text).not.toContain('1.1 1.1');
2638 expect(screen.getByText(/Begriffe/u).textContent).not.toContain('Abschnitt 1 Abschnitt 1');
2639 });
2640
2641 it('findet eine Vorschrift über die Suche und sagt, warum sie trifft', async () => {
2642 const nutzer = starten(mitGesetzen());
2643 await inDasGesetz(nutzer, 'Waffengesetz');
2644
2645 await nutzer.type(await screen.findByLabelText('In den Gesetzen suchen'), 'Jäger');
2646
2647 expect(await screen.findByText(/§ 13 – Jäger/u)).toBeVisible();
2648 expect(screen.getByText(/Treffer in der Überschrift/u)).toBeVisible();
2649 expect(screen.queryByText(/§ 2 – Grundsätze/u)).toBeNull();
2650 });
2651
2652 it('sagt in der Auswahl, in welchem Gesetz die Treffer stehen', async () => {
2653 /* Sonst müsste der Suchende sieben Gesetze der Reihe nach durchklicken.
2654 Die Zahl steht als Text da, nicht als Farbe oder Punkt. */
2655 const nutzer = starten(mitGesetzen());
2656 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2657 await nutzer.type(await screen.findByLabelText('In den Gesetzen suchen'), 'Notwehr');
2658
2659 expect(
2660 await screen.findByRole('button', { name: /Strafgesetzbuch.*1 Treffer/u }),
2661 ).toBeVisible();
2662 expect(screen.getByRole('button', { name: /Waffengesetz.*kein Treffer/u })).toBeVisible();
2663 });
2664
2665 it('sagt es, wenn keine Gesetzestexte beiliegen', async () => {
2666 /* Eine leere Liste sähe aus wie ein Fehler. Die Grundattrappe liefert
2667 `NORMTEXTE_LEER`. */
2668 const nutzer = starten();
2669 await nutzer.click(await screen.findByRole('button', { name: 'In den Gesetzen nachlesen' }));
2670
2671 expect(await screen.findByText(/zurzeit nicht verfügbar/u)).toBeVisible();
2672 });
2673 });
2674
2675 describe('Kapitelwahl – der Lernstand steht an jeder Wahl', () => {
2676 /*
2677 Der Befund vom 01.09.2026, am Bildschirm gesehen: Die Ampeln standen
2678 ausschließlich an den Abschnitten des Kapitels I. Der Grund war keine
2679 Auslassung, sondern eine Eigenschaft der Bereichsliste — nur Kapitel I
2680 ist amtlich untergliedert, für II bis IV führt sie das Kapitel selbst,
2681 und die 29 Themengruppen stehen in einem eigenen Feld, das die
2682 Kapitelwahl nicht bekam. Eine Anzeige, die bei drei von vier Kapiteln
2683 fehlt, sieht aus wie ein Fehler und war einer.
2684 */
2685
2686 async function zurKapitelwahl(
2687 bruecke?: ReturnType<typeof attrappe>,
2688 ): Promise<ReturnType<typeof starten>> {
2689 const nutzer = starten(bruecke ?? attrappe({}));
2690 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
2691 await waitFor(() => {
2692 expect(knopf).toHaveAttribute('aria-disabled', 'false');
2693 });
2694 await nutzer.click(knopf);
2695 await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 });
2696 return nutzer;
2697 }
2698
2699 /** Der Block, in dem dieser Knopf steht. */
2700 function blockVon(knopfname: string | RegExp): HTMLElement {
2701 const knopf = screen.getByRole('button', { name: knopfname });
2702 const block = knopf.closest('.kapitelwahl__bereich');
2703 expect(block, `Kein Block um „${String(knopfname)}“`).not.toBeNull();
2704 return block as HTMLElement;
2705 }
2706
2707 it('nimmt für das ganze Kapitel dessen eigene Zeile, wenn es eine hat', async () => {
2708 /* So liegt es bei den Kapiteln II bis IV: Der Katalog gliedert sie nicht,
2709 die Bereichsliste führt deshalb das Kapitel selbst. */
2710 await zurKapitelwahl();
2711
2712 const ampel = within(blockVon(/Ganzes Kapitel I üben/u)).getByLabelText(
2713 /Prüfungsreife in Kapitel I/u,
2714 );
2715 expect(ampel).toHaveAttribute('aria-valuenow', '40');
2716 expect(ampel).toHaveAttribute('aria-valuemax', '300');
2717 });
2718
2719 it('fasst die Abschnitte zusammen, wenn das Kapitel keine eigene Zeile hat', async () => {
2720 /*
2721 Und so liegt es bei Kapitel I, dem einzigen amtlich untergliederten:
2722 Die Bereichsliste kennt `I.1` bis `I.5`, aber kein `I`. Ohne die
2723 Zusammenfassung stünde ausgerechnet der erste Knopf der Seite als
2724 einziger ohne Lernstand da.
2725
2726 Gerechnet wird exakt und nicht ungefähr: 12 von 90 und 40 von 154 sind
2727 zusammen 52 von 244 (siehe `bereicheZusammenfassen`).
2728 */
2729 await zurKapitelwahl(
2730 attrappe({
2731 lernUebersicht: vi.fn(() =>
2732 Promise.resolve({
2733 ...UEBERSICHT,
2734 /* Ohne die Kapitelzeile, dafür mit beiden Abschnitten — so
2735 liefert der Anwendungskern es für Kapitel I wirklich. */
2736 bereiche: [
2737 {
2738 id: 'I.1',
2739 titel: 'Begriffe des Waffenrechts',
2740 fragenGesamt: 90,
2741 beantwortet: 30,
2742 belegt: 12,
2743 reifegrad: 12 / 90,
2744 stufe: 'zurueck' as const,
2745 },
2746 {
2747 id: 'I.2',
2748 titel: 'Rechte und Pflichten',
2749 fragenGesamt: 154,
2750 beantwortet: 80,
2751 belegt: 40,
2752 reifegrad: 40 / 154,
2753 stufe: 'zurueck' as const,
2754 },
2755 ],
2756 }),
2757 ),
2758 }),
2759 );
2760
2761 const ampel = within(blockVon(/Ganzes Kapitel I üben/u)).getByLabelText(
2762 /Prüfungsreife in Kapitel I/u,
2763 );
2764 expect(ampel).toHaveAttribute('aria-valuenow', '52');
2765 expect(ampel).toHaveAttribute('aria-valuemax', '244');
2766 });
2767
2768 it('zeigt den Stand an jeder Themengruppe', async () => {
2769 /* Die 230 Fragen der Kapitel II bis IV haben keinen amtlichen
2770 Abschnitt; geübt werden sie über die Themengruppen, und genau dort
2771 fehlte der Stand. */
2772 themenZwischenspeicherLeeren();
2773 await zurKapitelwahl(
2774 attrappe({
2775 katalogLaden: vi.fn(() =>
2776 Promise.resolve({
2777 ...KATALOG,
2778 kapitel: [...KATALOG.kapitel, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }],
2779 }),
2780 ),
2781 themenLaden: vi.fn(() =>
2782 Promise.resolve({
2783 meta: { version: 1, stand: '2026-09-01', hinweis: 'Prüfstand' },
2784 gruppen: [
2785 {
2786 id: 'II.1',
2787 kapitel: 'II',
2788 titel: 'Waffenarten',
2789 fragen: ['II-01', 'II-02', 'II-03'],
2790 },
2791 ],
2792 }),
2793 ),
2794 lernUebersicht: vi.fn(() =>
2795 Promise.resolve({
2796 ...UEBERSICHT,
2797 themengruppen: [
2798 {
2799 id: 'II.1',
2800 kapitel: 'II',
2801 titel: 'Waffenarten',
2802 fragenGesamt: 18,
2803 beantwortet: 9,
2804 belegt: 7,
2805 reifegrad: 7 / 18,
2806 stufe: 'kippe' as const,
2807 },
2808 ],
2809 }),
2810 ),
2811 }),
2812 );
2813
2814 const ampel = within(blockVon(/Waffenarten/u)).getByLabelText(/Prüfungsreife in Waffenarten/u);
2815 expect(ampel).toHaveAttribute('aria-valuenow', '7');
2816 expect(ampel).toHaveAttribute('aria-valuemax', '18');
2817 });
2818
2819 it('lässt die Ampel weg, solange nichts geladen ist', async () => {
2820 /* „Noch nicht geladen“ ist nicht „null Prozent“. */
2821 await zurKapitelwahl(ohneKanaele('lernUebersicht'));
2822
2823 expect(screen.queryByLabelText(/Prüfungsreife in/u)).not.toBeInTheDocument();
2824 });
2825
2826 it('legt jede Wahl in eine eigene Zeile der Liste', async () => {
2827 /*
2828 Der zweite Teil des Befundes: Die Knöpfe standen in einer
2829 `flex-wrap`-Reihe, und seit jede Wahl zwei Zeilen Lernstand darunter
2830 trägt, zogen sich die Elemente gegenseitig auf die Höhe des höchsten —
2831 ein Knopf ohne Ampel stand als hoher leerer Kasten da. Geprüft wird die
2832 Struktur, nicht das Aussehen: Jede Wahl ist ein Listeneintrag.
2833 */
2834 await zurKapitelwahl();
2835
2836 const block = blockVon(/Ganzes Kapitel I üben/u);
2837 expect(block.tagName).toBe('LI');
2838 expect(block.parentElement?.tagName).toBe('UL');
2839 });
2840 });
2841
2842 describe('Reifegrad-Verlauf – er darf nicht das Gegenteil der Ampel behaupten', () => {
2843 /*
2844 Befund der Prüfrunde zu 0.27.2. Der Modulkopf von `shared/reifeverlauf.ts`
2845 sagt zu: „Ein nachgerechneter Verlauf, dessen letzter Punkt nicht die Zahl
2846 der Ampel ist, wäre schlimmer als keiner: zwei Zahlen auf einem
2847 Bildschirm, die einander widersprechen.“
2848
2849 Genau das trat ein, sobald sich der Lernstand änderte, ohne dass der
2850 Startbildschirm dabei verschwand. „Neu anfangen“ ist der eindeutigste
2851 Fall: Die Ampel darüber sprang auf null, der Verlauf darunter behauptete
2852 weiter, es sei etwas gestiegen. `useReifeverlauf` bietet dafür `neuLaden`
2853 an — gerufen hat es niemand.
2854 */
2855 const VERLAUF_VORHER = [
2856 { tag: '2026-03-01', reifegrad: 0.02, belegt: 12, beantwortet: 30 },
2857 { tag: '2026-03-02', reifegrad: 0.05, belegt: 29, beantwortet: 60 },
2858 { tag: '2026-03-03', reifegrad: 0.083, belegt: 48, beantwortet: 120 },
2859 ];
2860 const VERLAUF_NACHHER = [
2861 { tag: '2026-03-02', reifegrad: 0, belegt: 0, beantwortet: 0 },
2862 { tag: '2026-03-03', reifegrad: 0, belegt: 0, beantwortet: 0 },
2863 ];
2864
2865 it('rechnet nach „Neu anfangen“ neu', async () => {
2866 let aufrufe = 0;
2867 const lernVerlauf = vi.fn(() => {
2868 aufrufe += 1;
2869 return Promise.resolve(aufrufe === 1 ? VERLAUF_VORHER : VERLAUF_NACHHER);
2870 });
2871 const nutzer = starten(attrappe({ lernVerlauf }));
2872
2873 expect(await screen.findByText(/von 12 auf 48 von 575 Fragen gestiegen/u)).toBeVisible();
2874
2875 await nutzer.click(await screen.findByRole('button', { name: 'Lernstand zurücksetzen' }));
2876 await nutzer.click(screen.getByRole('button', { name: 'Ja, Lernstand löschen' }));
2877 await screen.findByText(/Ihr Lernstand ist zurückgesetzt/u);
2878
2879 /* Nicht nur „weg“, sondern „neu gelesen“: Der Satz nennt den Stand nach
2880 dem Zurücksetzen. Ein bloßes Ausblenden wäre die zweite Unwahrheit. */
2881 expect(await screen.findByText(/unverändert bei 0 von 575 Fragen/u)).toBeVisible();
2882 expect(screen.queryByText(/Fragen gestiegen/u)).toBeNull();
2883 });
2884
2885 it('holt ihn nicht ohne Anlass ein zweites Mal', async () => {
2886 /* Die Gegenprobe: Der Verlauf ist eine Rechnung über das ganze
2887 Antwortprotokoll und wird ausdrücklich nicht bei jeder Kleinigkeit
2888 geholt (`useReifeverlauf`). Ein Aufruf je Aufbau, nicht mehr. */
2889 const lernVerlauf = vi.fn(() => Promise.resolve(VERLAUF_VORHER));
2890 starten(attrappe({ lernVerlauf }));
2891
2892 expect(await screen.findByText(/von 12 auf 48 von 575 Fragen gestiegen/u)).toBeVisible();
2893 expect(lernVerlauf).toHaveBeenCalledTimes(1);
2894 });
2895 });
2896
2897 describe('Der eingestellte Sitzungsumfang steht auch dort, wo er angekündigt wird', () => {
2898 /*
2899 Befund der Prüfrunde zu 0.27.2. Seit 0.26.6 ist der Sitzungsumfang
2900 einstellbar; der Changelog sagt es öffentlich zu: „fünf bis fünfzig statt
2901 der festen zwanzig“. Die Zahl kam auch überall an, wo eine Sitzung
2902 entsteht — nur nicht dort, wo sie **angekündigt** wird. Unter
2903 „Weiterlernen“ stand fest „20 Fragen“, in der Kapitelwahl „bis zu 20
2904 Fragen“. Wer fünf eingestellt hatte, las eine Zusage über zwanzig und
2905 bekam fünf.
2906 */
2907 function mitUmfang(umfang: number): TeilBridge {
2908 return attrappe({
2909 einstellungenLesen: vi.fn(() =>
2910 Promise.resolve({
2911 ...EINSTELLUNGEN_STANDARD,
2912 zuschnittGefragt: true,
2913 sitzungsumfang: umfang,
2914 }),
2915 ),
2916 });
2917 }
2918
2919 it('nennt die eingestellte Zahl unter „Weiterlernen“', async () => {
2920 starten(mitUmfang(8));
2921
2922 expect(await screen.findByText('8 Fragen, vom Programm zusammengestellt')).toBeVisible();
2923 });
2924
2925 it('nennt ab Werk die zwanzig', async () => {
2926 /* Die Gegenprobe: Die Zahl darf nicht dadurch richtig werden, dass gar
2927 keine mehr dasteht. */
2928 starten();
2929
2930 expect(await screen.findByText('20 Fragen, vom Programm zusammengestellt')).toBeVisible();
2931 });
2932
2933 it('nennt sie auch in der Kapitelwahl', async () => {
2934 const nutzer = starten(mitUmfang(8));
2935 const knopf = await screen.findByRole('button', { name: /Kapitel wählen/u });
2936 await waitFor(() => {
2937 expect(knopf).toHaveAttribute('aria-disabled', 'false');
2938 });
2939 await nutzer.click(knopf);
2940 await screen.findByRole('heading', { name: 'Kapitel wählen', level: 1 });
2941
2942 expect(screen.getByText(/bis zu 8 Fragen/u)).toBeVisible();
2943 expect(screen.queryByText(/bis zu 20 Fragen/u)).toBeNull();
2944 });
2945 });