waffensachkunde

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

/ app tests renderer-startbildschirm.test.tsx

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