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 — 661 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(): Promise<{ melden: (befehl: Menuebefehl) => void }> {
549 const kern: { melden?: (befehl: Menuebefehl) => void } = {};
550 await anwendungOeffnen(
551 attrappe({
552 menuebefehlBeobachten: vi.fn((rueckruf: (befehl: Menuebefehl) => void) => {
553 kern.melden = rueckruf;
554 return () => undefined;
555 }),
556 }),
557 );
558 if (kern.melden === undefined) {
559 throw new Error('Der Kanal wurde nicht beobachtet.');
560 }
561 return { melden: kern.melden };
562 }
563
564 /*
565 Bis Fassung 0.25.0 führte das Anwendungsmenü zu keiner einzigen der zehn
566 Ansichten. Es ist die einzige Fläche des Fensters, die nicht wegrollt –
567 und der Startbildschirm ist bei 1265 Bildpunkten Breite 8530 hoch. Wer am
568 Sprungmenü der Seite vorbeigerollt war, hatte für die restlichen rund
569 7500 Bildpunkte keinen Weg mehr in eine andere Ansicht.
570 */
571 it('führt in jede der angebotenen Ansichten', async () => {
572 const { melden } = await mitMenue();
573
574 melden('gehe-zu-suche');
575 expect(
576 await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 }),
577 ).toBeInTheDocument();
578
579 melden('gehe-zu-glossar');
580 expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument();
581
582 melden('gehe-zu-start');
583 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
584 });
585
586 /*
587 Aus einer laufenden Sitzung führt kein Menüeintrag heraus. Der Grund
588 steht in `App.tsx` an der Stelle, an der die Zurückleiste dort ausgelassen
589 wird: „Dort ist der Ausstieg eine Entscheidung mit Folgen … und steht bei
590 den übrigen Aktionen, nicht als beiläufiger Pfeil am Rand.“ Ein
591 Menüeintrag ist derselbe beiläufige Weg, nur weiter oben.
592
593 Ausgegraut werden die Einträge nicht – der Kanal ist einseitig, der
594 Hauptprozess erfährt vom Zustand der Oberfläche nichts. Statt
595 stillschweigend nichts zu tun, sagt die Anwendung, warum sie nicht folgt.
596 */
597 it('verlässt eine laufende Lernsitzung nicht und sagt, warum', async () => {
598 const { melden } = await mitMenue();
599
600 const nutzer = userEvent.setup();
601 const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u });
602 /* Der Einstieg ist nie `disabled`, sondern meldet sich über
603 `aria-disabled` (Projektregel, docs/stand.md 7.13). */
604 await waitFor(() => {
605 expect(einstieg).toHaveAttribute('aria-disabled', 'false');
606 });
607 await nutzer.click(einstieg);
608 /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift
609 ist die Fragennummer und hängt am Katalog der Attrappe. */
610 await screen.findByRole('button', { name: 'Sitzung beenden' });
611
612 melden('gehe-zu-glossar');
613
614 /* Die Sitzung steht noch. */
615 expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument();
616 expect(await screen.findByText(/Beenden Sie zuerst die Lernsitzung/u)).toBeInTheDocument();
617 });
618
619 it('sperrt auch die Auswertung und gibt den Weg erst auf dem Startbildschirm frei', async () => {
620 /*
621 Zwei Zusagen in einem Lauf.
622
623 Erstens: Die Auswertung ist ebenso gesperrt wie die Sitzung. Ihr Inhalt
624 steht im Ansichtsobjekt (`bilanz` in `lernen/typen.ts`), und das Menü
625 kennt kein Ziel, das ihn wiederherstellte — ein Sprung verwürfe die
626 Fehlerliste ohne Rückfrage. Diese Sperre fehlte in der ersten Fassung
627 des Menüs und war damit ein Fehler von 0.26.0.
628
629 Zweitens die Gegenprobe: Die Sperre darf nicht hängenbleiben. Vom
630 Startbildschirm aus nimmt der Weg wieder an.
631 */
632 const { melden } = await mitMenue();
633
634 const nutzer = userEvent.setup();
635 const einstieg = await screen.findByRole('button', { name: /^Weiterlernen/u });
636 /* Der Einstieg ist nie `disabled`, sondern meldet sich über
637 `aria-disabled` (Projektregel, docs/stand.md 7.13). */
638 await waitFor(() => {
639 expect(einstieg).toHaveAttribute('aria-disabled', 'false');
640 });
641 await nutzer.click(einstieg);
642 /* „Sitzung beenden“ als Merkmal der laufenden Sitzung: Die Überschrift
643 ist die Fragennummer und hängt am Katalog der Attrappe. */
644 await screen.findByRole('button', { name: 'Sitzung beenden' });
645 await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
646
647 /* In der Auswertung: gesperrt. */
648 melden('gehe-zu-glossar');
649 expect(screen.queryByRole('heading', { name: 'Glossar', level: 1 })).not.toBeInTheDocument();
650 expect(
651 await screen.findByText(/Diese Auswertung lässt sich nicht wieder aufrufen/u),
652 ).toBeInTheDocument();
653
654 /* Auf dem Startbildschirm: wieder frei. */
655 await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
656 await screen.findByRole('heading', { name: 'Heute lernen' });
657
658 melden('gehe-zu-glossar');
659 expect(await screen.findByRole('heading', { name: 'Glossar', level: 1 })).toBeInTheDocument();
660 });
661 });