waffensachkunde

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

/ app tests renderer-hilfe.test.tsx

24,6 KB Rohdatei
app/tests/renderer-hilfe.test.tsx — 660 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 };
116
117 const STAND: FrageStand = {
118 frageId: 'I.1-87',
119 versuche: 0,
120 richtige: 0,
121 zuletztBeantwortet: null,
122 faelligAb: null,
123 gemerkt: false,
124 letzteBewertung: null,
125 };
126
127 const INFO: AnwendungsInfo = {
128 anwendungsVersion: '0.0.0-attrappe',
129 baukennung: 'abc1234',
130 baustand: '2026-08-22',
131 electronVersion: '43.4.1',
132 chromeVersion: '140.0.0.0',
133 nodeVersion: '22.19.1',
134 plattform: 'win32',
135 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
136 };
137
138 const ERGEBNIS: Pruefungsergebnis = {
139 profilId: 'standard',
140 gesamt: 0,
141 richtig: 0,
142 falsch: 0,
143 unbeantwortet: 0,
144 quote: 0,
145 urteil: 'nicht_bestanden',
146 begruendung: '0 Prozent richtig, nötig waren 80 Prozent.',
147 verletzteKriterien: [],
148 bereiche: [],
149 fehlerIds: [],
150 dauerMs: 0,
151 zeitAbgelaufen: false,
152 zeitpunkt: '2026-01-01T08:00:00.000Z',
153 };
154
155 const LERNPLAN: Lernplan = {
156 termin: null,
157 tageBisTermin: null,
158 gesamtFragen: 1,
159 nieBeantwortet: 1,
160 faellig: 0,
161 zielquote: 0.9,
162 prognoseHeute: 0,
163 prognoseAmTermin: null,
164 pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 },
165 machbarkeit: 'kein_termin',
166 sekundenProFrage: 25,
167 };
168
169 const ERKLAERUNGEN: Erklaerungen = {
170 meta: {
171 version: 1,
172 stand: '2026-08-20',
173 gesetzesstand: { WaffG: 'Pruefstand' },
174 hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.',
175 },
176 zuFrage: {},
177 };
178
179 const GLOSSAR: Glossar = {
180 meta: {
181 version: 1,
182 stand: '2026-08-21',
183 gesetzesstand: { WaffG: 'Pruefstand' },
184 hinweis: 'Eigener redaktioneller Inhalt.',
185 },
186 eintraege: [
187 {
188 begriff: 'WaffG',
189 art: 'abkuerzung',
190 kurz: 'Waffengesetz.',
191 varianten: ['WaffG'],
192 fundstellen: [],
193 ohneFundstelleGrund: 'Die amtliche Kurzbezeichnung steht nicht im Gesetz selbst.',
194 },
195 ],
196 };
197
198 function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge {
199 return {
200 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
201 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
202 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
203 hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
204 hilfsmittelBeobachten: vi.fn(() => () => undefined),
205 menuebefehlBeobachten: vi.fn(() => () => undefined),
206 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
207 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
208 einstellungenLesen: vi.fn(() =>
209 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
210 ),
211 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
212 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
213 katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
214 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
215 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
216 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
217 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
218 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
219 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
220 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
221 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
222 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
223 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
224 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
225 lernSitzung: vi.fn(() =>
226 Promise.resolve([{ frageId: 'I.1-87', optionsReihenfolge: ['a', 'b'], gemerkt: false }]),
227 ),
228 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
229 lernMerken: vi.fn(() => Promise.resolve(STAND)),
230 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
231 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
232 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
233 pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })),
234 pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)),
235 pruefungVerlauf: vi.fn(() => Promise.resolve([])),
236 pruefungSichern: vi.fn(() => Promise.resolve(true)),
237 pruefungOffen: vi.fn(() => Promise.resolve(null)),
238 pruefungVerwerfen: vi.fn(() => Promise.resolve()),
239 pruefungLaeuft: vi.fn(() => Promise.resolve()),
240 sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })),
241 fehlerprotokollDrucken: vi.fn(() =>
242 Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }),
243 ),
244 fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })),
245 lernberichtDrucken: vi.fn(() =>
246 Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }),
247 ),
248 /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt,
249 dass die Brücke den Kanal kennt. */
250 datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })),
251 lizenzenLesen: vi.fn(() =>
252 Promise.resolve({
253 eigene: {
254 kennung: EUPL_KENNUNG,
255 name: 'European Union Public Licence, Version 1.2',
256 hinweis: EUPL_HINWEIS,
257 text: null,
258 },
259 katalog: {
260 quellenangabe: KATALOG.meta.quellenangabe,
261 quelleUrl: KATALOG.meta.quelle_url,
262 hinweis: KATALOG_HINWEIS,
263 },
264 dritte: [],
265 }),
266 ),
267 ...ueberschreibungen,
268 };
269 }
270
271 // ─── Hilfsgriffe ────────────────────────────────────────────────────────
272
273 async function anwendungOeffnen(bruecke: LernAppBridge = attrappe()): Promise<Nutzer> {
274 vi.stubGlobal('lernApp', bruecke);
275 const nutzer = userEvent.setup();
276 render(<App />);
277 await screen.findByRole('button', { name: /Weiterlernen/u });
278 return nutzer;
279 }
280
281 /** Die Schaltfläche im Fußbereich – der Weg zur Hilfe in jeder Ansicht. */
282 function hilfeknopf(): HTMLElement {
283 return screen.getByRole('button', { name: 'Hilfe' });
284 }
285
286 /** Der Dialog selbst; er trägt seinen Namen aus der Überschrift. */
287 function hilfefenster(): HTMLElement {
288 return screen.getByRole('dialog', { name: HILFE_TITEL });
289 }
290
291 afterEach(() => {
292 vi.unstubAllGlobals();
293 erklaerungenZwischenspeicherLeeren();
294 glossarZwischenspeicherLeeren();
295 normtexteZwischenspeicherLeeren();
296 themenZwischenspeicherLeeren();
297 });
298
299 // ─── Tests ──────────────────────────────────────────────────────────────
300
301 describe('Hilfe – Erreichbarkeit (WCAG 3.2.6)', () => {
302 it('bietet den Weg zur Hilfe schon auf dem Startbildschirm an', async () => {
303 await anwendungOeffnen();
304 expect(hilfeknopf()).toBeInTheDocument();
305 });
306
307 it('nennt die Taste, mit der sie auch ohne die Schaltfläche aufgeht', async () => {
308 /* Der Menü-Accelerator F1 ist für Sehende im Menü sichtbar. Wer mit
309 einem Bildschirmleser arbeitet, erfährt ihn über aria-keyshortcuts. */
310 await anwendungOeffnen();
311
312 expect(hilfeknopf()).toHaveAttribute('aria-keyshortcuts', 'F1');
313 expect(hilfeknopf()).toHaveAttribute('aria-haspopup', 'dialog');
314 });
315
316 it('steht in jeder Ansicht an derselben Stelle', async () => {
317 const nutzer = await anwendungOeffnen();
318
319 /*
320 Die Kernaussage von 3.2.6 ist die gleiche relative Reihenfolge. Der
321 Fußbereich ist die einzige Landmarke, die es in allen Ansichten gibt –
322 geprüft wird deshalb, dass die Hilfe dort steht und dort ihr erstes
323 Bedienelement ist.
324 */
325 const wege: readonly (readonly [string, RegExp])[] = [
326 ['Kapitelwahl', /Kapitel wählen/u],
327 ['Glossar', /Fachbegriffe nachschlagen/u],
328 ['Über diese Software', /Lizenzen und Herkunft anzeigen/u],
329 ['Prüfungssimulation', /Prüfungssimulation vorbereiten/u],
330 ];
331
332 for (const [name, knopfname] of wege) {
333 await nutzer.click(await screen.findByRole('button', { name: knopfname }));
334
335 const fuss = screen.getByRole('contentinfo');
336 const erstes = within(fuss).getAllByRole('button')[0];
337 expect(erstes, `${name}: keine Hilfe im Fußbereich`).toHaveAccessibleName('Hilfe');
338
339 await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
340 }
341 });
342
343 it('steht auch in der laufenden Lernsitzung im Fußbereich', async () => {
344 const nutzer = await anwendungOeffnen();
345 await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u }));
346 await screen.findByText(FRAGE_TEXT);
347
348 const fuss = screen.getByRole('contentinfo');
349 expect(within(fuss).getAllByRole('button')[0]).toHaveAccessibleName('Hilfe');
350 });
351 });
352
353 describe('Hilfe – Inhalt', () => {
354 it('zeigt alle Kapitel mit Überschrift', async () => {
355 const nutzer = await anwendungOeffnen();
356 await nutzer.click(hilfeknopf());
357
358 const fenster = hilfefenster();
359 for (const kapitel of HILFE_KAPITEL) {
360 expect(
361 within(fenster).getByRole('heading', { name: kapitel.titel, level: 3 }),
362 `Kapitel fehlt: ${kapitel.id}`,
363 ).toBeInTheDocument();
364 }
365 });
366
367 it('führt ein Inhaltsverzeichnis, dessen Sprungziele es gibt', async () => {
368 const nutzer = await anwendungOeffnen();
369 await nutzer.click(hilfeknopf());
370
371 const verzeichnis = screen.getByRole('navigation', { name: 'Kapitel dieser Hilfe' });
372 const verweise = within(verzeichnis).getAllByRole('link');
373 expect(verweise).toHaveLength(HILFE_KAPITEL.length);
374
375 for (const verweis of verweise) {
376 const ziel = (verweis.getAttribute('href') ?? '').replace(/^#/u, '');
377 /* Ein Verzeichniseintrag, der ins Leere zeigt, ist schlimmer als
378 keiner: Er sagt zu, dass es dort weitergeht. */
379 expect(document.getElementById(ziel), `Sprungziel fehlt: ${ziel}`).not.toBeNull();
380 }
381 });
382
383 it('öffnet von sich aus keine Verbindung nach außen', async () => {
384 /* Dieselbe Zusage wie in „Über diese Software“: kein mailto:, kein tel:,
385 kein https: – die Anwendung startet kein fremdes Programm. */
386 const nutzer = await anwendungOeffnen();
387 await nutzer.click(hilfeknopf());
388
389 for (const verweis of within(hilfefenster()).getAllByRole('link')) {
390 expect(verweis.getAttribute('href') ?? '').toMatch(/^#/u);
391 }
392 });
393 });
394
395 describe('Hilfe – Öffnen und Schließen', () => {
396 /*
397 Bis Fassung 0.24.2 landete der Fokus hier auf dem Dialogtitel, weil der
398 Startbildschirm als einzige Ansicht kein zugeordnetes Kapitel hatte.
399 Seit 0.25.0 führt er nach „So kommen Sie durch“ — dem Kapitel, das genau
400 die Frage des Startbildschirms beantwortet.
401
402 Die Zusage dahinter ist unverändert und die eigentliche: Beim Öffnen
403 steht der Fokus **im Dialog** und auf einer Überschrift, nie im
404 Hintergrund und nie auf `body`. Das Inhaltsverzeichnis bleibt erreichbar;
405 es steht über den Kapiteln (siehe `Hilfedialog.tsx`).
406 */
407 it('setzt den Fokus beim Öffnen auf die Überschrift des zugeordneten Kapitels', async () => {
408 const nutzer = await anwendungOeffnen();
409 await nutzer.click(hilfeknopf());
410
411 const kapitel = within(hilfefenster()).getByRole('heading', {
412 name: 'So kommen Sie durch',
413 level: 3,
414 });
415 expect(kapitel).toHaveFocus();
416 expect(hilfefenster().contains(document.activeElement)).toBe(true);
417 });
418
419 it('führt den Dialogtitel weiterhin als h2', async () => {
420 /* Der Titel bleibt die Überschrift des Dialogs, auch wenn der Fokus
421 tiefer steht – sonst hätte der Dialog keinen zugänglichen Namen. */
422 const nutzer = await anwendungOeffnen();
423 await nutzer.click(hilfeknopf());
424
425 expect(
426 within(hilfefenster()).getByRole('heading', { name: HILFE_TITEL, level: 2 }),
427 ).toBeInTheDocument();
428 });
429
430 it('bleibt bei genau einer h1 im Dokument', async () => {
431 /*
432 Jede Ansicht führt genau eine h1, die sagt, wo man ist – ein
433 E2E-Test wacht darüber. Der Dialog ist keine Ansicht; eine zweite h1
434 würde diese Zusage brechen. Deshalb h2, und deshalb dieser Test.
435 */
436 const nutzer = await anwendungOeffnen();
437 await nutzer.click(hilfeknopf());
438
439 expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
440 });
441
442 it('schließt über den Knopf', async () => {
443 const nutzer = await anwendungOeffnen();
444 await nutzer.click(hilfeknopf());
445
446 await nutzer.click(within(hilfefenster()).getAllByRole('button', { name: 'Schließen' })[0]!);
447
448 await waitFor(() => {
449 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
450 });
451 });
452
453 it('schließt mit Esc', async () => {
454 const nutzer = await anwendungOeffnen();
455 await nutzer.click(hilfeknopf());
456
457 await nutzer.keyboard('{Escape}');
458
459 await waitFor(() => {
460 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
461 });
462 });
463
464 it('räumt seine Überschriften weg, solange es zu ist', async () => {
465 /* Ein geschlossener Dialog ist unsichtbar, aber sein Inhalt stünde
466 trotzdem im Baum – und liefe jedem Überschriftentest der übrigen
467 Ansichten in die Quere. */
468 await anwendungOeffnen();
469 expect(screen.queryByRole('heading', { name: HILFE_TITEL })).not.toBeInTheDocument();
470 });
471 });
472
473 describe('Hilfe – nimmt nichts mit', () => {
474 it('lässt die laufende Lernsitzung stehen', async () => {
475 /*
476 Der eigentliche Grund für einen Dialog statt einer eigenen Ansicht:
477 Wer mitten in der Sitzung wissen will, wie das Merken geht, soll
478 danach dieselbe Frage vorfinden – nicht den Startbildschirm.
479 */
480 const nutzer = await anwendungOeffnen();
481 await nutzer.click(screen.getByRole('button', { name: /Weiterlernen/u }));
482 await screen.findByText(FRAGE_TEXT);
483
484 await nutzer.click(hilfeknopf());
485 expect(hilfefenster()).toBeInTheDocument();
486
487 await nutzer.keyboard('{Escape}');
488 await waitFor(() => {
489 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
490 });
491
492 expect(screen.getByText(FRAGE_TEXT)).toBeInTheDocument();
493 });
494 });
495
496 describe('Hilfe – Menübefehl', () => {
497 it('geht auf, wenn der Anwendungskern „hilfe“ meldet', async () => {
498 /* Das ist der Weg, den F1 und der Menüeintrag „Handbuch“ nehmen: Die
499 Menüleiste liegt im Hauptprozess und erreicht die Ansicht nur so. */
500 const kern: { melden?: (befehl: Menuebefehl) => void } = {};
501
502 await anwendungOeffnen(
503 attrappe({
504 menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => {
505 kern.melden = rueckruf;
506 return () => undefined;
507 }),
508 }),
509 );
510
511 expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
512 expect(kern.melden).toBeDefined();
513
514 kern.melden?.('hilfe');
515
516 expect(await screen.findByRole('dialog', { name: HILFE_TITEL })).toBeInTheDocument();
517 });
518
519 it('kommt auch ohne diesen Kanal zurecht', async () => {
520 /* Der Anwendungskern entsteht parallel; fehlt der Kanal, bleibt die
521 Hilfe über die Schaltfläche erreichbar. */
522 const bruecke = attrappe();
523 Reflect.deleteProperty(bruecke, 'menuebefehlBeobachten');
524
525 const nutzer = await anwendungOeffnen(bruecke);
526 await nutzer.click(hilfeknopf());
527
528 expect(hilfefenster()).toBeInTheDocument();
529 });
530 });
531
532 describe('Hilfe – Barrierefreiheit', () => {
533 it('hat im geöffneten Zustand keine axe-Verstöße', async () => {
534 const nutzer = await anwendungOeffnen();
535 await nutzer.click(hilfeknopf());
536
537 const ergebnis = await axe.run(document.body, jsdomOptionen());
538 expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]);
539 });
540 });
541
542 describe('Gehe zu – die Navigation im Anwendungsmenü', () => {
543 /**
544 * Öffnet die Anwendung und gibt den Draht zurück, über den der
545 * Hauptprozess Menübefehle meldet.
546 */
547 async function mitMenue(): Promise<{ melden: (befehl: Menuebefehl) => void }> {
548 const kern: { melden?: (befehl: Menuebefehl) => void } = {};
549 await anwendungOeffnen(
550 attrappe({
551 menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => {
552 kern.melden = rueckruf;
553 return () => undefined;
554 }),
555 }),
556 );
557 if (kern.melden === undefined) {
558 throw new Error('Der Kanal wurde nicht beobachtet.');
559 }
560 return { melden: kern.melden };
561 }
562
563 /*
564 Bis Fassung 0.25.0 führte das Anwendungsmenü zu keiner einzigen der zehn
565 Ansichten. Es ist die einzige Fläche des Fensters, die nicht wegrollt –
566 und der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 hoch. Wer am
567 Sprungmenü der Seite vorbeigerollt war, hatte für die restlichen rund
568 7500 Bildpunkte keinen Weg mehr in eine andere Ansicht.
569 */
570 it('führt in jede der angebotenen Ansichten', async () => {
571 const { melden } = await mitMenue();
572
573 melden('gehe-zu-suche');
574 expect(
575 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }),
576 ).toBeInTheDocument();
577
578 melden('gehe-zu-glossar');
579 expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument();
580
581 melden('gehe-zu-start');
582 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
583 });
584
585 /*
586 Aus einer laufenden Sitzung führt kein Menüeintrag heraus. Der Grund
587 steht in `App.tsx` an der Stelle, an der die Zurückleiste dort ausgelassen
588 wird: „Dort ist der Ausstieg eine Entscheidung mit Folgen … und steht bei
589 den übrigen Aktionen, nicht als beiläufiger Pfeil am Rand.“ Ein
590 Menüeintrag ist derselbe beiläufige Weg, nur weiter oben.
591
592 Ausgegraut werden die Einträge nicht – der Kanal ist einseitig, der
593 Hauptprozess erfährt vom Zustand der Oberfläche nichts. Statt
594 stillschweigend nichts zu tun, sagt die Anwendung, warum sie nicht folgt.
595 */
596 it('verlässt eine laufende Lernsitzung nicht und sagt, warum', async () => {
597 const { melden } = await mitMenue();
598
599 const nutzer = userEvent.setup();
600 const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u });
601 /* Der Einstieg ist nie `disabled`, sondern meldet sich über
602 `aria-disabled` (Projektregel, docs/stand.md 7.13). */
603 await waitFor(() => {
604 expect(einstieg).toHaveAttribute('aria-disabled', 'false');
605 });
606 await nutzer.click(einstieg);
607 /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift
608 ist die Fragennummer und hängt am Katalog der Attrappe. */
609 await screen.findByRole('button', { name: 'Sitzung beenden' });
610
611 melden('gehe-zu-glossar');
612
613 /* Die Sitzung steht noch. */
614 expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument();
615 expect(await screen.findByText(/Beenden Sie zuerst die Lernsitzung/u)).toBeInTheDocument();
616 });
617
618 it('sperrt auch die Auswertung und gibt den Weg erst auf dem Startbildschirm frei', async () => {
619 /*
620 Zwei Zusagen in einem Lauf.
621
622 Erstens: Die Auswertung ist ebenso gesperrt wie die Sitzung. Ihr Inhalt
623 steht im Ansichtsobjekt (`bilanz` in `lernen/typen.ts`), und das Menü
624 kennt kein Ziel, das ihn wiederherstellte — ein Sprung verwürfe die
625 Fehlerliste ohne Rückfrage. Diese Sperre fehlte in der ersten Fassung
626 des Menüs und war damit ein Fehler von 0.26.0.
627
628 Zweitens die Gegenprobe: Die Sperre darf nicht hängenbleiben. Vom
629 Startbildschirm aus nimmt der Weg wieder an.
630 */
631 const { melden } = await mitMenue();
632
633 const nutzer = userEvent.setup();
634 const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u });
635 /* Der Einstieg ist nie `disabled`, sondern meldet sich über
636 `aria-disabled` (Projektregel, docs/stand.md 7.13). */
637 await waitFor(() => {
638 expect(einstieg).toHaveAttribute('aria-disabled', 'false');
639 });
640 await nutzer.click(einstieg);
641 /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift
642 ist die Fragennummer und hängt am Katalog der Attrappe. */
643 await screen.findByRole('button', { name: 'Sitzung beenden' });
644 await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
645
646 /* In der Auswertung: gesperrt. */
647 melden('gehe-zu-glossar');
648 expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument();
649 expect(
650 await screen.findByText(/Diese Auswertung lässt sich nicht wieder aufrufen/u),
651 ).toBeInTheDocument();
652
653 /* Auf dem Startbildschirm: wieder frei. */
654 await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
655 await screen.findByRole('heading', { name: 'Heute lernen' });
656
657 melden('gehe-zu-glossar');
658 expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument();
659 });
660 });