waffensachkunde

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

/ app tests renderer-hilfe.test.tsx

27,7 KB Rohdatei
app/tests/renderer-hilfe.test.tsx — 733 Zeilen
1 /**
2 * Verhaltenstests des Hilfefensters.
3 *
4 * Zwei Zusagen stehen hier auf dem Prüfstand:
5 *
6 * 1. **Erreichbarkeit** – WCAG 3.2.6 verlangt konsistente Hilfe: Sie steht in
7 * jeder Ansicht an derselben Stelle. Geprüft wird das quer durch alle
8 * Ansichten, einschließlich Lernsitzung und Prüfungslauf.
9 * 2. **Folgenlosigkeit** – die Hilfe ist ein Dialog und keine Ansicht. Wer sie
10 * mitten in einer Sitzung öffnet, findet danach dieselbe Frage vor.
11 *
12 * ## Was diese Tests NICHT prüfen können
13 *
14 * jsdom 27 kennt `<dialog>` als Element, aber weder `showModal()` noch
15 * `close()`. Der Dialog läuft hier über den Rückfallweg (das `open`-Attribut
16 * von Hand) und ist damit **nicht modal**: Es gibt keine Fokusfalle und nichts
17 * dahinter wird inert. Beides ist Sache des echten Fensters und wird in
18 * `e2e/hilfe.spec.ts` gemessen. Ein Test, der hier „modal“ behauptete, würde
19 * eine Eigenschaft bescheinigen, die die Umgebung gar nicht hat.
20 */
21
22 import { render, screen, waitFor, within } from '@testing-library/react';
23 import userEvent from '@testing-library/user-event';
24 import axe from 'axe-core';
25 import { afterEach, describe, expect, it, vi } from 'vitest';
26
27 import { App } from '../src/renderer/src/App';
28 import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
29 import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar';
30 import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte';
31 import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen';
32 import type { Erklaerungen } from '../src/shared/erklaerungen';
33 import type { Glossar } from '../src/shared/glossar';
34 import { HILFE_KAPITEL, HILFE_TITEL } from '../src/shared/hilfe';
35 import {
36 EINSTELLUNGEN_STANDARD,
37 type AnwendungsInfo,
38 type LernAppBridge,
39 type Menuebefehl,
40 } from '../src/shared/ipc';
41 import type { Katalog, RichText } from '../src/shared/katalog';
42 import type { Lernplan } from '../src/shared/lernplan';
43 import type { FrageStand, Lernuebersicht, Profil } from '../src/shared/lernstand';
44 import { EUPL_HINWEIS, EUPL_KENNUNG, KATALOG_HINWEIS } from '../src/shared/lizenzen';
45 import type { Pruefungsergebnis } from '../src/shared/pruefung';
46 import { jsdomOptionen } from '../src/shared/wcag';
47 import { NORMTEXTE_LEER } from '../src/shared/normtexte';
48 import { THEMEN_LEER } from '../src/shared/themen';
49
50 type Nutzer = ReturnType<typeof userEvent.setup>;
51
52 // ─── Attrappen ──────────────────────────────────────────────────────────
53
54 function nurText(text: string): RichText {
55 return { text, segmente: [{ t: text }] };
56 }
57
58 const FRAGE_TEXT = 'Welcher Gegenstand ist eine Anscheinswaffe?';
59
60 const KATALOG: Katalog = {
61 meta: {
62 titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)',
63 herausgeber: 'Bundesverwaltungsamt',
64 stand: '2024-12-16',
65 quellenangabe:
66 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.',
67 quelle_url: 'https://www.bva.bund.de/',
68 quelldatei_sha256: '7e5e41fa',
69 fragen_gesamt: 575,
70 },
71 kapitel: [{ id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }],
72 bilder: [],
73 fragen: [
74 {
75 id: 'I.1-87',
76 amtliche_nummer: '1.87',
77 kapitel: 'I',
78 abschnitt: 'I.1',
79 typ: 'mc',
80 seite: 23,
81 frage: nurText(FRAGE_TEXT),
82 bilder: [],
83 optionen: [
84 { label: 'a', inhalt: nurText('Sportgewehr'), korrekt: false, bilder: [] },
85 { label: 'b', inhalt: nurText('Spielzeugpistole'), korrekt: true, bilder: [] },
86 ],
87 },
88 ],
89 };
90
91 const PROFIL: Profil = {
92 id: 1,
93 name: 'Testprofil',
94 pruefungstermin: null,
95 kapitelAusschluss: [],
96 erstelltAm: '2026-01-01T08:00:00.000Z',
97 };
98
99 const UEBERSICHT: Lernuebersicht = {
100 fragenGesamt: 575,
101 beantwortet: 0,
102 belegt: 0,
103 reifegrad: 0 / 575,
104 stufe: 'ohne_beleg',
105 deckelnd: [],
106 faellig: 0,
107 gemerkt: 0,
108 offen: 0,
109 fehler: 0,
110 heuteRichtig: 0,
111 heuteFalsch: 0,
112 heuteBearbeitet: 0,
113 tageSeitLetzterAntwort: null,
114 bereiche: [],
115 themengruppen: [],
116 };
117
118 const STAND: FrageStand = {
119 frageId: 'I.1-87',
120 versuche: 0,
121 richtige: 0,
122 zuletztBeantwortet: null,
123 faelligAb: null,
124 gemerkt: false,
125 letzteBewertung: null,
126 };
127
128 const INFO: AnwendungsInfo = {
129 anwendungsVersion: '0.0.0-attrappe',
130 baukennung: 'abc1234',
131 baustand: '2026-08-22',
132 electronVersion: '43.4.1',
133 chromeVersion: '140.0.0.0',
134 nodeVersion: '22.19.1',
135 plattform: 'win32',
136 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
137 };
138
139 const ERGEBNIS: Pruefungsergebnis = {
140 profilId: 'standard',
141 gesamt: 0,
142 richtig: 0,
143 falsch: 0,
144 unbeantwortet: 0,
145 quote: 0,
146 urteil: 'nicht_bestanden',
147 begruendung: '0 Prozent richtig, nötig waren 80 Prozent.',
148 verletzteKriterien: [],
149 bereiche: [],
150 fehlerIds: [],
151 dauerMs: 0,
152 zeitAbgelaufen: false,
153 zeitpunkt: '2026-01-01T08:00:00.000Z',
154 };
155
156 const LERNPLAN: Lernplan = {
157 termin: null,
158 tageBisTermin: null,
159 gesamtFragen: 1,
160 nieBeantwortet: 1,
161 faellig: 0,
162 zielquote: 0.9,
163 prognoseHeute: 0,
164 prognoseAmTermin: null,
165 pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 },
166 machbarkeit: 'kein_termin',
167 sekundenProFrage: 25,
168 };
169
170 const ERKLAERUNGEN: Erklaerungen = {
171 meta: {
172 version: 1,
173 stand: '2026-08-20',
174 gesetzesstand: { WaffG: 'Pruefstand' },
175 hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.',
176 },
177 zuFrage: {},
178 };
179
180 const GLOSSAR: Glossar = {
181 meta: {
182 version: 1,
183 stand: '2026-08-21',
184 gesetzesstand: { WaffG: 'Pruefstand' },
185 hinweis: 'Eigener redaktioneller Inhalt.',
186 },
187 eintraege: [
188 {
189 begriff: 'WaffG',
190 art: 'abkuerzung',
191 kurz: 'Waffengesetz.',
192 varianten: ['WaffG'],
193 fundstellen: [],
194 ohneFundstelleGrund: 'Die amtliche Kurzbezeichnung steht nicht im Gesetz selbst.',
195 },
196 ],
197 };
198
199 function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge {
200 return {
201 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
202 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
203 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
204 hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
205 hilfsmittelBeobachten: vi.fn(() => () => undefined),
206 menuebefehlBeobachten: vi.fn(() => () => undefined),
207 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
208 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
209 einstellungenLesen: vi.fn(() =>
210 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
211 ),
212 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
213 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
214 katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
215 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
216 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
217 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
218 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
219 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
220 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
221 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
222 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
223 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
224 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
225 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
226 lernSitzung: vi.fn(() =>
227 Promise.resolve([{ frageId: 'I.1-87', optionsReihenfolge: ['a', 'b'], gemerkt: false }]),
228 ),
229 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
230 lernMerken: vi.fn(() => Promise.resolve(STAND)),
231 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
232 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
233 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
234 pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })),
235 pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)),
236 pruefungVerlauf: vi.fn(() => Promise.resolve([])),
237 pruefungSichern: vi.fn(() => Promise.resolve(true)),
238 pruefungOffen: vi.fn(() => Promise.resolve(null)),
239 pruefungVerwerfen: vi.fn(() => Promise.resolve()),
240 pruefungLaeuft: vi.fn(() => Promise.resolve()),
241 sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })),
242 fehlerprotokollDrucken: vi.fn(() =>
243 Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }),
244 ),
245 fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })),
246 lernberichtDrucken: vi.fn(() =>
247 Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }),
248 ),
249 /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt,
250 dass die Brücke den Kanal kennt. */
251 datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })),
252 lizenzenLesen: vi.fn(() =>
253 Promise.resolve({
254 eigene: {
255 kennung: EUPL_KENNUNG,
256 name: 'European Union Public Licence, Version 1.2',
257 hinweis: EUPL_HINWEIS,
258 text: null,
259 },
260 katalog: {
261 quellenangabe: KATALOG.meta.quellenangabe,
262 quelleUrl: KATALOG.meta.quelle_url,
263 hinweis: KATALOG_HINWEIS,
264 },
265 dritte: [],
266 }),
267 ),
268 ...ueberschreibungen,
269 };
270 }
271
272 // ─── Hilfsgriffe ────────────────────────────────────────────────────────
273
274 async function anwendungOeffnen(bruecke: LernAppBridge = attrappe()): Promise<Nutzer> {
275 vi.stubGlobal('lernApp', bruecke);
276 const nutzer = userEvent.setup();
277 render(<App />);
278 await screen.findByRole('button', { name: /Weiterlernen/u });
279 return nutzer;
280 }
281
282 /** Die Schaltfläche im Fußbereich – der Weg zur Hilfe in jeder Ansicht. */
283 function hilfeknopf(): HTMLElement {
284 return screen.getByRole('button', { name: 'Hilfe' });
285 }
286
287 /** Der Dialog selbst; er trägt seinen Namen aus der Überschrift. */
288 function hilfefenster(): HTMLElement {
289 return screen.getByRole('dialog', { name: HILFE_TITEL });
290 }
291
292 afterEach(() => {
293 vi.unstubAllGlobals();
294 erklaerungenZwischenspeicherLeeren();
295 glossarZwischenspeicherLeeren();
296 normtexteZwischenspeicherLeeren();
297 themenZwischenspeicherLeeren();
298 });
299
300 // ─── Tests ──────────────────────────────────────────────────────────────
301
302 describe('Hilfe – Erreichbarkeit (WCAG 3.2.6)', () => {
303 it('bietet den Weg zur Hilfe schon auf dem Startbildschirm an', async () => {
304 await anwendungOeffnen();
305 expect(hilfeknopf()).toBeInTheDocument();
306 });
307
308 it('nennt die Taste, mit der sie auch ohne die Schaltfläche aufgeht', async () => {
309 /* Der Menü-Accelerator F1 ist für Sehende im Menü sichtbar. Wer mit
310 einem Bildschirmleser arbeitet, erfährt ihn über aria-keyshortcuts. */
311 await anwendungOeffnen();
312
313 expect(hilfeknopf()).toHaveAttribute('aria-keyshortcuts', 'F1');
314 expect(hilfeknopf()).toHaveAttribute('aria-haspopup', 'dialog');
315 });
316
317 it('steht in jeder Ansicht an derselben Stelle', async () => {
318 const nutzer = await anwendungOeffnen();
319
320 /*
321 Die Kernaussage von 3.2.6 ist die gleiche relative Reihenfolge. Der
322 Fußbereich ist die einzige Landmarke, die es in allen Ansichten gibt –
323 geprüft wird deshalb, dass die Hilfe dort steht und dort ihr erstes
324 Bedienelement ist.
325 */
326 const wege: readonly (readonly [string, RegExp])[] = [
327 ['Kapitelwahl', /Kapitel wählen/u],
328 ['Glossar', /Fachbegriffe nachschlagen/u],
329 ['Über diese Software', /Lizenzen und Herkunft anzeigen/u],
330 ['Prüfungssimulation', /Prüfungssimulation vorbereiten/u],
331 ];
332
333 for (const [name, knopfname] of wege) {
334 await nutzer.click(await screen.findByRole('button', { name: knopfname }));
335
336 const fuss = screen.getByRole('contentinfo');
337 const erstes = within(fuss).getAllByRole('button')[0];
338 expect(erstes, `${name}: keine Hilfe im Fußbereich`).toHaveAccessibleName('Hilfe');
339
340 await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
341 }
342 });
343
344 it('steht auch in der laufenden Lernsitzung im Fußbereich', async () => {
345 const nutzer = await anwendungOeffnen();
346 await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u }));
347 await screen.findByText(FRAGE_TEXT);
348
349 const fuss = screen.getByRole('contentinfo');
350 expect(within(fuss).getAllByRole('button')[0]).toHaveAccessibleName('Hilfe');
351 });
352 });
353
354 describe('Hilfe – Inhalt', () => {
355 it('zeigt alle Kapitel mit Überschrift', async () => {
356 const nutzer = await anwendungOeffnen();
357 await nutzer.click(hilfeknopf());
358
359 const fenster = hilfefenster();
360 for (const kapitel of HILFE_KAPITEL) {
361 expect(
362 within(fenster).getByRole('heading', { name: kapitel.titel, level: 3 }),
363 `Kapitel fehlt: ${kapitel.id}`,
364 ).toBeInTheDocument();
365 }
366 });
367
368 it('führt ein Inhaltsverzeichnis, dessen Sprungziele es gibt', async () => {
369 const nutzer = await anwendungOeffnen();
370 await nutzer.click(hilfeknopf());
371
372 const verzeichnis = screen.getByRole('navigation', { name: 'Kapitel dieser Hilfe' });
373 const verweise = within(verzeichnis).getAllByRole('link');
374 expect(verweise).toHaveLength(HILFE_KAPITEL.length);
375
376 for (const verweis of verweise) {
377 const ziel = (verweis.getAttribute('href') ?? '').replace(/^#/u, '');
378 /* Ein Verzeichniseintrag, der ins Leere zeigt, ist schlimmer als
379 keiner: Er sagt zu, dass es dort weitergeht. */
380 expect(document.getElementById(ziel), `Sprungziel fehlt: ${ziel}`).not.toBeNull();
381 }
382 });
383
384 it('öffnet von sich aus keine Verbindung nach außen', async () => {
385 /* Dieselbe Zusage wie in „Über diese Software“: kein mailto:, kein tel:,
386 kein https: – die Anwendung startet kein fremdes Programm. */
387 const nutzer = await anwendungOeffnen();
388 await nutzer.click(hilfeknopf());
389
390 for (const verweis of within(hilfefenster()).getAllByRole('link')) {
391 expect(verweis.getAttribute('href') ?? '').toMatch(/^#/u);
392 }
393 });
394 });
395
396 describe('Hilfe – Öffnen und Schließen', () => {
397 /*
398 Bis Fassung 0.24.2 landete der Fokus hier auf dem Dialogtitel, weil der
399 Startbildschirm als einzige Ansicht kein zugeordnetes Kapitel hatte.
400 Seit 0.25.0 führt er nach „So kommen Sie durch“ — dem Kapitel, das genau
401 die Frage des Startbildschirms beantwortet.
402
403 Die Zusage dahinter ist unverändert und die eigentliche: Beim Öffnen
404 steht der Fokus **im Dialog** und auf einer Überschrift, nie im
405 Hintergrund und nie auf `body`. Das Inhaltsverzeichnis bleibt erreichbar;
406 es steht über den Kapiteln (siehe `Hilfedialog.tsx`).
407 */
408 it('setzt den Fokus beim Öffnen auf die Überschrift des zugeordneten Kapitels', async () => {
409 const nutzer = await anwendungOeffnen();
410 await nutzer.click(hilfeknopf());
411
412 const kapitel = within(hilfefenster()).getByRole('heading', {
413 name: 'So kommen Sie durch',
414 level: 3,
415 });
416 expect(kapitel).toHaveFocus();
417 expect(hilfefenster().contains(document.activeElement)).toBe(true);
418 });
419
420 it('führt den Dialogtitel weiterhin als h2', async () => {
421 /* Der Titel bleibt die Überschrift des Dialogs, auch wenn der Fokus
422 tiefer steht – sonst hätte der Dialog keinen zugänglichen Namen. */
423 const nutzer = await anwendungOeffnen();
424 await nutzer.click(hilfeknopf());
425
426 expect(
427 within(hilfefenster()).getByRole('heading', { name: HILFE_TITEL, level: 2 }),
428 ).toBeInTheDocument();
429 });
430
431 it('bleibt bei genau einer h1 im Dokument', async () => {
432 /*
433 Jede Ansicht führt genau eine h1, die sagt, wo man ist – ein
434 E2E-Test wacht darüber. Der Dialog ist keine Ansicht; eine zweite h1
435 würde diese Zusage brechen. Deshalb h2, und deshalb dieser Test.
436 */
437 const nutzer = await anwendungOeffnen();
438 await nutzer.click(hilfeknopf());
439
440 expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
441 });
442
443 it('schließt über den Knopf', async () => {
444 const nutzer = await anwendungOeffnen();
445 await nutzer.click(hilfeknopf());
446
447 await nutzer.click(within(hilfefenster()).getAllByRole('button', { name: 'Schließen' })[0]!);
448
449 await waitFor(() => {
450 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
451 });
452 });
453
454 it('schließt mit Esc', async () => {
455 const nutzer = await anwendungOeffnen();
456 await nutzer.click(hilfeknopf());
457
458 await nutzer.keyboard('{Escape}');
459
460 await waitFor(() => {
461 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
462 });
463 });
464
465 it('räumt seine Überschriften weg, solange es zu ist', async () => {
466 /* Ein geschlossener Dialog ist unsichtbar, aber sein Inhalt stünde
467 trotzdem im Baum – und liefe jedem Überschriftentest der übrigen
468 Ansichten in die Quere. */
469 await anwendungOeffnen();
470 expect(screen.queryByRole('heading', { name: HILFE_TITEL })).not.toBeInTheDocument();
471 });
472 });
473
474 describe('Hilfe – nimmt nichts mit', () => {
475 it('lässt die laufende Lernsitzung stehen', async () => {
476 /*
477 Der eigentliche Grund für einen Dialog statt einer eigenen Ansicht:
478 Wer mitten in der Sitzung wissen will, wie das Merken geht, soll
479 danach dieselbe Frage vorfinden – nicht den Startbildschirm.
480 */
481 const nutzer = await anwendungOeffnen();
482 await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u }));
483 await screen.findByText(FRAGE_TEXT);
484
485 await nutzer.click(hilfeknopf());
486 expect(hilfefenster()).toBeInTheDocument();
487
488 await nutzer.keyboard('{Escape}');
489 await waitFor(() => {
490 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
491 });
492
493 expect(screen.getByText(FRAGE_TEXT)).toBeInTheDocument();
494 });
495 });
496
497 describe('Hilfe – Menübefehl', () => {
498 it('geht auf, wenn der Anwendungskern „hilfe“ meldet', async () => {
499 /* Das ist der Weg, den F1 und der Menüeintrag „Handbuch“ nehmen: Die
500 Menüleiste liegt im Hauptprozess und erreicht die Ansicht nur so. */
501 const kern: { melden?: (befehl: Menuebefehl) => void } = {};
502
503 await anwendungOeffnen(
504 attrappe({
505 menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => {
506 kern.melden = rueckruf;
507 return () => undefined;
508 }),
509 }),
510 );
511
512 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
513 expect(kern.melden).toBeDefined();
514
515 kern.melden?.('hilfe');
516
517 expect(await screen.findByRole('dialog', { name: HILFE_TITEL })).toBeInTheDocument();
518 });
519
520 it('kommt auch ohne diesen Kanal zurecht', async () => {
521 /* Der Anwendungskern entsteht parallel; fehlt der Kanal, bleibt die
522 Hilfe über die Schaltfläche erreichbar. */
523 const bruecke = attrappe();
524 Reflect.deleteProperty(bruecke, 'menuebefehlBeobachten');
525
526 const nutzer = await anwendungOeffnen(bruecke);
527 await nutzer.click(hilfeknopf());
528
529 expect(hilfefenster()).toBeInTheDocument();
530 });
531 });
532
533 describe('Hilfe – Barrierefreiheit', () => {
534 it('hat im geöffneten Zustand keine axe-Verstöße', async () => {
535 const nutzer = await anwendungOeffnen();
536 await nutzer.click(hilfeknopf());
537
538 const ergebnis = await axe.run(document.body, jsdomOptionen());
539 expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]);
540 });
541 });
542
543 describe('Gehe zu – die Navigation im Anwendungsmenü', () => {
544 /**
545 * Öffnet die Anwendung und gibt den Draht zurück, über den der
546 * Hauptprozess Menübefehle meldet.
547 */
548 async function mitMenue(
549 weitere: Partial<LernAppBridge> = {},
550 ): Promise<{ melden: (befehl: Menuebefehl) => void }> {
551 const kern: { melden?: (befehl: Menuebefehl) => void } = {};
552 await anwendungOeffnen(
553 attrappe({
554 menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => {
555 kern.melden = rueckruf;
556 return () => undefined;
557 }),
558 ...weitere,
559 }),
560 );
561 if (kern.melden === undefined) {
562 throw new Error('Der Kanal wurde nicht beobachtet.');
563 }
564 return { melden: kern.melden };
565 }
566
567 /*
568 Bis Fassung 0.25.0 führte das Anwendungsmenü zu keiner einzigen der zehn
569 Ansichten. Es ist die einzige Fläche des Fensters, die nicht wegrollt –
570 und der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 hoch. Wer am
571 Sprungmenü der Seite vorbeigerollt war, hatte für die restlichen rund
572 7500 Bildpunkte keinen Weg mehr in eine andere Ansicht.
573 */
574 it('führt in jede der angebotenen Ansichten', async () => {
575 const { melden } = await mitMenue();
576
577 melden('gehe-zu-suche');
578 expect(
579 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }),
580 ).toBeInTheDocument();
581
582 melden('gehe-zu-glossar');
583 expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument();
584
585 melden('gehe-zu-start');
586 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
587 });
588
589 /*
590 Aus einer laufenden Sitzung führt kein Menüeintrag heraus. Der Grund
591 steht in `App.tsx` an der Stelle, an der die Zurückleiste dort ausgelassen
592 wird: „Dort ist der Ausstieg eine Entscheidung mit Folgen … und steht bei
593 den übrigen Aktionen, nicht als beiläufiger Pfeil am Rand.“ Ein
594 Menüeintrag ist derselbe beiläufige Weg, nur weiter oben.
595
596 Ausgegraut werden die Einträge nicht – der Kanal ist einseitig, der
597 Hauptprozess erfährt vom Zustand der Oberfläche nichts. Statt
598 stillschweigend nichts zu tun, sagt die Anwendung, warum sie nicht folgt.
599 */
600 it('verlässt eine laufende Lernsitzung nicht und sagt, warum', async () => {
601 const { melden } = await mitMenue();
602
603 const nutzer = userEvent.setup();
604 const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u });
605 /* Der Einstieg ist nie `disabled`, sondern meldet sich über
606 `aria-disabled` (Projektregel, docs/stand.md 7.13). */
607 await waitFor(() => {
608 expect(einstieg).toHaveAttribute('aria-disabled', 'false');
609 });
610 await nutzer.click(einstieg);
611 /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift
612 ist die Fragennummer und hängt am Katalog der Attrappe. */
613 await screen.findByRole('button', { name: 'Sitzung beenden' });
614
615 melden('gehe-zu-glossar');
616
617 /* Die Sitzung steht noch. */
618 expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument();
619 expect(await screen.findByText(/Beenden Sie zuerst die Lernsitzung/u)).toBeInTheDocument();
620 });
621
622 it('führt ohne Fragenkatalog nicht in eine leere Ansicht', async () => {
623 /*
624 Befund der Prüfrunde zu 0.27.2. Drei Ansichten hängen an
625 `katalogIndex !== null` und rendern ohne ihn gar nichts; zugleich
626 entfällt in jeder Ansicht außer dem Startbildschirm der Seitenkopf. Über
627 das Menü – seit 0.25.0 der zweite vollwertige Navigationsweg – landete
628 man damit in einem leeren Hauptbereich: kein `h1` im ganzen Dokument,
629 kein Fokusanker, keine Auskunft. Der Startbildschirm sperrt seine Knöpfe
630 genau dafür und begründet die Sperre am Knopf.
631 */
632 const { melden } = await mitMenue({
633 katalogLaden: vi.fn(() => Promise.reject(new Error('Katalog kaputt'))),
634 });
635
636 melden('gehe-zu-suche');
637
638 await screen.findByText(/Der Fragenkatalog ist nicht geladen/u);
639
640 expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
641 expect(document.body).not.toHaveFocus();
642 });
643
644 it('spricht die Farbschema-Ansage nicht ein zweites Mal', async () => {
645 /*
646 Befund der Prüfrunde zu 0.27.2. Die Live-Region der Anwendung hatte zwei
647 Quellen und einen Rückfall: `menueansage === "" ? ansage : menueansage`.
648 Die Ansage des Farbschemas wird aber nie zurückgenommen — sie steht
649 nach der ersten Umschaltung für immer da. Sobald ein Menübefehl seine
650 Meldung wieder leerte, wechselte der Inhalt der Region von der
651 Menümeldung zurück auf „Dunkles Farbschema …“, und ein
652 Bildschirmleser sprach sie ein zweites Mal, ohne dass jemand etwas am
653 Farbschema getan hätte.
654 */
655 const { melden } = await mitMenue();
656 const nutzer = userEvent.setup();
657
658 await nutzer.click(await screen.findByRole('radio', { name: /Dunkel/u }));
659
660 /* Die Live-Region der Anwendung – die eine, in der beide Quellen landen. */
661 const region = await waitFor(() => {
662 const treffer = screen
663 .getAllByRole('status')
664 .find((knoten) => knoten.textContent.includes('Darstellung umgestellt'));
665 expect(treffer).toBeDefined();
666 return treffer!;
667 });
668
669 /* Ein Menübefehl, der nicht ausgeführt wird: Die Region trägt seine
670 Begründung. */
671 const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u });
672 await waitFor(() => {
673 expect(einstieg).toHaveAttribute('aria-disabled', 'false');
674 });
675 await nutzer.click(einstieg);
676 await screen.findByRole('button', { name: 'Sitzung beenden' });
677 melden('gehe-zu-glossar');
678 await screen.findByText(/Beenden Sie zuerst die Lernsitzung/u);
679
680 /* Und einer, der ausgeführt wird: Danach steht dort nichts mehr — und
681 ganz sicher nicht wieder die Ansage von vorhin. */
682 await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
683 await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
684 await screen.findByRole('heading', { name: 'Heute lernen' });
685 melden('gehe-zu-glossar');
686 await screen.findByRole('heading', { name: 'Glossar', level: 1 });
687
688 expect(region.textContent).toBe('');
689 });
690
691 it('sperrt auch die Auswertung und gibt den Weg erst auf dem Startbildschirm frei', async () => {
692 /*
693 Zwei Zusagen in einem Lauf.
694
695 Erstens: Die Auswertung ist ebenso gesperrt wie die Sitzung. Ihr Inhalt
696 steht im Ansichtsobjekt (`bilanz` in `lernen/typen.ts`), und das Menü
697 kennt kein Ziel, das ihn wiederherstellte — ein Sprung verwürfe die
698 Fehlerliste ohne Rückfrage. Diese Sperre fehlte in der ersten Fassung
699 des Menüs und war damit ein Fehler von 0.26.0.
700
701 Zweitens die Gegenprobe: Die Sperre darf nicht hängenbleiben. Vom
702 Startbildschirm aus nimmt der Weg wieder an.
703 */
704 const { melden } = await mitMenue();
705
706 const nutzer = userEvent.setup();
707 const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u });
708 /* Der Einstieg ist nie `disabled`, sondern meldet sich über
709 `aria-disabled` (Projektregel, docs/stand.md 7.13). */
710 await waitFor(() => {
711 expect(einstieg).toHaveAttribute('aria-disabled', 'false');
712 });
713 await nutzer.click(einstieg);
714 /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift
715 ist die Fragennummer und hängt am Katalog der Attrappe. */
716 await screen.findByRole('button', { name: 'Sitzung beenden' });
717 await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
718
719 /* In der Auswertung: gesperrt. */
720 melden('gehe-zu-glossar');
721 expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument();
722 expect(
723 await screen.findByText(/Diese Auswertung lässt sich nicht wieder aufrufen/u),
724 ).toBeInTheDocument();
725
726 /* Auf dem Startbildschirm: wieder frei. */
727 await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
728 await screen.findByRole('heading', { name: 'Heute lernen' });
729
730 melden('gehe-zu-glossar');
731 expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument();
732 });
733 });