waffensachkunde

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

/ app e2e hilfe.spec.ts

11,9 KB Rohdatei
app/e2e/hilfe.spec.ts — 307 Zeilen
1 /**
2 * Das Hilfefenster im echten Fenster.
3 *
4 * ## Warum diese Suite nötig ist
5 *
6 * Die Unit-Tests in `tests/renderer-hilfe.test.tsx` prüfen Inhalt, Aufbau und
7 * Erreichbarkeit – aber nicht das, was die Hilfe überhaupt erst zu einem
8 * Dialog macht. jsdom 27 kennt `<dialog>` als Element, hat aber weder
9 * `showModal()` noch `close()`; dort läuft der Dialog über einen Rückfallweg
10 * und ist **nicht modal**. Fokusfalle, Inertheit des Hintergrunds und die
11 * Rückgabe des Fokus gibt es nur in Chromium – also nur hier.
12 *
13 * Dazu kommt der Weg über die Menüleiste: Sie liegt im Hauptprozess und ist
14 * unter jsdom gar nicht vorhanden.
15 */
16
17 import { type ElectronApplication, type Page } from '@playwright/test';
18
19 import { HILFE_KAPITEL, HILFE_TITEL } from '../src/shared/hilfe';
20 import { amStart, animationenAbwarten, appStarten, bauPruefen } from './electron-hilfe';
21 import { expect, test } from './konsolenwache';
22
23 let app: ElectronApplication | undefined;
24 let fenster: Page;
25
26 test.beforeAll(async () => {
27 bauPruefen();
28 ({ app, fenster } = await appStarten());
29 });
30
31 test.afterAll(async () => {
32 await app?.close();
33 });
34
35 /** Der Weg zur Hilfe: die erste Schaltfläche im Fußbereich. */
36 function hilfeknopf() {
37 return fenster.getByRole('contentinfo').getByRole('button', { name: 'Hilfe', exact: true });
38 }
39
40 function hilfefenster() {
41 return fenster.getByRole('dialog', { name: HILFE_TITEL });
42 }
43
44 async function hilfeOeffnen(): Promise<void> {
45 await hilfeknopf().click();
46 await hilfefenster().waitFor();
47 await animationenAbwarten(fenster);
48 }
49
50 async function hilfeSchliessen(): Promise<void> {
51 if ((await hilfefenster().count()) > 0) {
52 await fenster.keyboard.press('Escape');
53 await hilfefenster().waitFor({ state: 'hidden' });
54 }
55 }
56
57 /** Zurück auf den Startbildschirm, egal wo der vorige Test aufgehört hat. */
58 async function zumStart(): Promise<void> {
59 await hilfeSchliessen();
60 for (let i = 0; i < 4; i++) {
61 if (await amStart(fenster)) {
62 return;
63 }
64 const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u });
65 if ((await zurueck.count()) === 0) {
66 return;
67 }
68 await zurueck.first().click();
69 await fenster.waitForTimeout(250);
70 }
71 }
72
73 test.describe('Hilfe – ein wirklich modaler Dialog', () => {
74 test('öffnet sich modal und legt sich über die Ansicht', async () => {
75 await zumStart();
76 await hilfeOeffnen();
77
78 /* `:modal` trifft nur auf einen über showModal() geöffneten Dialog zu.
79 Ein `<dialog open>` ohne Modalität fiele hier durch – und genau das
80 ist der Unterschied, den jsdom nicht abbilden kann. */
81 const istModal = await fenster.evaluate(
82 () => document.querySelector('dialog')?.matches(':modal') ?? false,
83 );
84 expect(istModal, 'Der Dialog ist nicht modal geöffnet.').toBe(true);
85
86 await hilfeSchliessen();
87 });
88
89 test('hält den Fokus im Dialog fest', async () => {
90 await zumStart();
91 await hilfeOeffnen();
92
93 /*
94 Ohne Fokusfalle wandert der Tabulator hinter den Dialog und bedient
95 Elemente, die optisch verdeckt sind. Zwanzig Schritte reichen: Das
96 Inhaltsverzeichnis hat zwölf Verweise, dazu zwei Schaltflächen – nach
97 einem vollen Umlauf wäre der Fokus draußen, wenn er es könnte. Die Zahl
98 stand bis 0.24.2 bei „zehn“ und war schon damals falsch; sie ist keine
99 Zusicherung, sondern die Begründung dafür, dass zwanzig Schritte reichen.
100 */
101 for (let schritt = 0; schritt < 20; schritt++) {
102 await fenster.keyboard.press('Tab');
103 const drinnen = await fenster.evaluate(() => {
104 return document.querySelector('dialog')?.contains(document.activeElement) ?? false;
105 });
106 expect(
107 drinnen,
108 `Der Fokus ist nach ${String(schritt + 1)} Tabs aus dem Dialog gefallen.`,
109 ).toBe(true);
110 }
111
112 await hilfeSchliessen();
113 });
114
115 test('gibt den Fokus beim Schließen an die Schaltfläche zurück', async () => {
116 await zumStart();
117 await hilfeOeffnen();
118 await fenster.keyboard.press('Escape');
119 await hilfefenster().waitFor({ state: 'hidden' });
120
121 /* Wer die Hilfe schließt, soll dort weitermachen, wo er war – nicht am
122 Anfang des Dokuments (WCAG 2.4.3, Fokusreihenfolge). */
123 const zurueckAufKnopf = await fenster.evaluate(
124 () => document.activeElement?.textContent.trim() === 'Hilfe',
125 );
126 expect(zurueckAufKnopf, 'Der Fokus steht nach dem Schließen nicht auf „Hilfe“.').toBe(true);
127 });
128
129 test('zeigt alle Kapitel und springt aus dem Verzeichnis dorthin', async () => {
130 await zumStart();
131 await hilfeOeffnen();
132
133 for (const kapitel of HILFE_KAPITEL) {
134 await expect(
135 hilfefenster().getByRole('heading', { name: kapitel.titel, level: 3 }),
136 ).toBeVisible();
137 }
138
139 const letztes = HILFE_KAPITEL[HILFE_KAPITEL.length - 1];
140 await fenster.getByRole('link', { name: letztes?.titel ?? '' }).click();
141
142 /* Der Sprung soll den Fokus mitnehmen, nicht nur den Bildausschnitt:
143 Wer nicht sieht, wohin gescrollt wurde, merkt sonst nichts davon. */
144 const beimKapitel = await fenster.evaluate(
145 (id) => document.activeElement?.id === id,
146 `hilfe-${letztes?.id ?? ''}`,
147 );
148 expect(beimKapitel, 'Der Sprung aus dem Verzeichnis nimmt den Fokus nicht mit.').toBe(true);
149
150 await hilfeSchliessen();
151 });
152 });
153
154 test.describe('Hilfe – Erreichbarkeit (WCAG 3.2.6)', () => {
155 test('steht in jeder Ansicht als erste Schaltfläche im Fußbereich', async () => {
156 const wege: readonly (readonly [string, RegExp])[] = [
157 ['Kapitelwahl', /Kapitel wählen/u],
158 ['Glossar', /Fachbegriffe nachschlagen/u],
159 ['Über diese Software', /Lizenzen und Herkunft anzeigen/u],
160 ['Prüfungswahl', /Prüfungssimulation vorbereiten/u],
161 ];
162
163 for (const [name, knopfname] of wege) {
164 await zumStart();
165 await fenster.getByRole('button', { name: knopfname }).click();
166 await fenster.waitForTimeout(300);
167
168 const erste = await fenster
169 .getByRole('contentinfo')
170 .getByRole('button')
171 .first()
172 .textContent();
173 expect(erste?.trim(), `${name}: Die Hilfe steht nicht zuoberst im Fußbereich.`).toBe('Hilfe');
174 }
175
176 await zumStart();
177 });
178
179 test('geht über den Menüeintrag „Handbuch“ auf', async () => {
180 await zumStart();
181
182 /* Derselbe Weg, den F1 nimmt: Der Accelerator hängt an diesem
183 Menüeintrag. Ihn hier auszulösen prüft die ganze Kette – Menü im
184 Hauptprozess, IPC-Meldung, Dialog in der Oberfläche. */
185 const gefunden = await app?.evaluate(({ Menu }) => {
186 const menue = Menu.getApplicationMenu();
187 const hilfe = menue?.items.find((eintrag) => eintrag.label.replace('&', '') === 'Hilfe');
188 const handbuch = hilfe?.submenu?.items.find((eintrag) => eintrag.label === 'Handbuch');
189 handbuch?.click();
190 return handbuch !== undefined;
191 });
192
193 expect(gefunden, 'Im Hilfemenü steht kein Eintrag „Handbuch“.').toBe(true);
194 await expect(hilfefenster()).toBeVisible();
195
196 await hilfeSchliessen();
197 });
198 });
199
200 test.describe('Hilfe – nimmt nichts mit', () => {
201 test('lässt den laufenden Prüfungsbogen stehen und die Uhr laufen', async () => {
202 /*
203 Der eigentliche Grund für einen Dialog statt einer eigenen Ansicht.
204 Und zugleich die Probe auf eine Zusage im Handbuch selbst: Die
205 Prüfungsuhr läuft weiter – sie anzuhalten würde die Simulation
206 verfälschen.
207
208 Der Lauf ist bewusst der kleinstmögliche (fünf Fragen, eine Minute):
209 Die Restzeitanzeige nennt bis 59 Sekunden hinab jede Sekunde einzeln,
210 darüber nur volle Minuten. Mit dem Standardprofil stünde nach zwei
211 Sekunden immer noch „2 Stunden“ da, und der Test bewiese nichts.
212 */
213 await zumStart();
214 await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click();
215 await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check();
216 await fenster.getByLabel('Fragen im Bogen').fill('5');
217 await fenster.getByLabel('Bearbeitungszeit').fill('1');
218 await fenster.getByRole('button', { name: 'Simulation starten' }).click();
219 await fenster.getByRole('button', { name: 'Simulation abbrechen' }).waitFor();
220
221 const restzeit = fenster.locator('.restzeit__wert');
222 const vorher = (await restzeit.textContent())?.trim() ?? '';
223 expect(vorher.length, 'Es wurde keine Restzeit angezeigt.').toBeGreaterThan(0);
224
225 await hilfeOeffnen();
226 await fenster.waitForTimeout(2500);
227 await hilfeSchliessen();
228
229 await expect(fenster.getByRole('button', { name: 'Simulation abbrechen' })).toBeVisible();
230 const nachher = (await restzeit.textContent())?.trim() ?? '';
231 expect(nachher, 'Die Prüfungsuhr stand still, während die Hilfe offen war.').not.toBe(vorher);
232
233 // Aufräumen: Ein laufender Bogen ließe das Fenster beim Schließen
234 // nachfragen und brächte den nächsten Lauf durcheinander.
235 await fenster.getByRole('button', { name: 'Simulation abbrechen' }).click();
236 await fenster.getByRole('button', { name: 'Abbrechen und verwerfen' }).click();
237 await fenster.waitForTimeout(400);
238 await zumStart();
239 });
240
241 /*
242 Die Kehrseite der vorigen Prüfung, und die gefährlichere.
243
244 `showModal()` macht den Hintergrund inert – für Zeiger und für
245 Bildschirmleser. Für Tastendrücke gilt das nicht: Sie steigen bis zum
246 `window` auf, und dort hängt der Zuhörer der Zeichenkürzel. Bis Fassung
247 0.24.1 bestätigte deshalb ein Druck auf die Eingabetaste bei offener
248 Hilfe die – womöglich leere – Antwort der dahinterliegenden Frage. Die
249 Frage zählte als falsch, und die Wiedervorlage rechnete damit weiter.
250
251 Gemessen wird an der Frage selbst: Steht nach dem Schließen der Hilfe
252 immer noch die Antwortmöglichkeit statt der Rückmeldung, ist nichts
253 bestätigt worden.
254 */
255 test('lässt die Tastenkürzel der Lernsitzung nicht durch den Dialog wirken', async () => {
256 /*
257 Der Weg über die Suche, und das ist der Punkt: Eine Auswahlfrage muss
258 es sein — bei einer offenen Frage steht der Fokus im Textfeld, und dort
259 greifen die Kürzel ohnehin nicht.
260
261 Bis Fassung 0.25.0 ging dieser Test über „Weiterlernen“ und verlangte,
262 dass die Sitzung mit einer Auswahlfrage **beginnt**. Die Reihenfolge ist
263 gemischt, und 104 der 575 Fragen sind offen — der Test hing damit am
264 Zufall und fiel im Gate-Lauf zu 0.25.0 aus genau diesem Grund. Über die
265 amtliche Nummer ist die Frage festgelegt: 1.02 kommt im Katalog genau
266 einmal vor und hat drei Antwortmöglichkeiten.
267 */
268 await zumStart();
269 await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click();
270 await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor();
271 await fenster.getByRole('radio', { name: /Alle Bereiche/u }).check();
272 await fenster.getByRole('searchbox').fill('1.02');
273 await fenster
274 .getByRole('button', { name: /^Frage 1\.02 üben$/u })
275 .first()
276 .click();
277
278 const optionen = fenster
279 .getByRole('group')
280 .getByRole('checkbox')
281 .or(fenster.getByRole('radio'));
282 await optionen.first().waitFor();
283 const vorher = await optionen.count();
284 expect(vorher, 'Die Sitzung begann nicht mit einer Auswahlfrage.').toBe(3);
285
286 await hilfeOeffnen();
287 await fenster.keyboard.press('Enter');
288 await fenster.keyboard.press('1');
289 await fenster.keyboard.press('m');
290 await fenster.waitForTimeout(200);
291
292 /* Der Dialog steht noch – keine der Tasten hat ihn geschlossen. */
293 await expect(hilfefenster()).toBeVisible();
294 await hilfeSchliessen();
295
296 /* Und die Frage ist unberührt: Die Antwortmöglichkeiten stehen weiterhin
297 zur Wahl, und keine ist ausgewählt. */
298 await expect(optionen.first()).toBeVisible();
299 expect(await optionen.count()).toBe(vorher);
300 for (let i = 0; i < vorher; i++) {
301 await expect(optionen.nth(i)).not.toBeChecked();
302 }
303 await expect(fenster.getByRole('button', { name: /^(Weiter|Nächste Frage)/u })).toHaveCount(0);
304
305 await zumStart();
306 });
307 });