waffensachkunde

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

/ app e2e barrierefreiheit-ansichten.spec.ts

25,9 KB Rohdatei
app/e2e/barrierefreiheit-ansichten.spec.ts — 653 Zeilen
1 /**
2 * axe-core je Ansicht **und je Farbschema**, im echten Fenster.
3 *
4 * ## Was hier gemessen wird
5 *
6 * {@link PRUEFSTUECKE} führt die Anwendung an jede Stelle, an der es etwas zu
7 * messen gibt, und lässt axe-core dort laufen. Fünfzehn Schritte, siebzehn
8 * Zustände: Startbildschirm (drei Zustände), Kapitelwahl, Glossar, Gesetze,
9 * Suche mit
10 * Trefferliste, „Über diese Software“, Hilfedialog, Lernsitzung (drei
11 * Zustände), Auswertung der Lernsitzung, Prüfungswahl, Prüfungslauf,
12 * Rückfrage vor der Abgabe, Prüfungsauswertung, Selbstbewertung offener
13 * Fragen. Damit ist jede der zehn Ansichtsarten aus
14 * `src/renderer/src/lernen/typen.ts` erfasst.
15 *
16 * Die beiden **Tore vor dem Startbildschirm** – „Wer lernt?“ und die Frage
17 * nach Kapitel IV – stehen am Ende in einer eigenen Anwendung: Sie erscheinen
18 * nur auf einem frischen Profilverzeichnis, und `appStarten` beantwortet die
19 * zweite von ihnen für alle übrigen Prüfungen.
20 *
21 * ## Warum im Fenster und nicht in jsdom
22 *
23 * Die Unit-Tests prüfen Baumeigenschaften: Namen, Rollen, Überschriften,
24 * Landmarken, Beschriftungen. Alles, was **gerechnetes Layout** braucht, kann
25 * jsdom nicht: Farbkontrast, Zielgrößen, tatsächliche Fokusreihenfolge. Hier
26 * rechnet Chromium – erst hier lässt sich WCAG 2.2 AA wirklich messen.
27 *
28 * Deshalb läuft {@link fensterOptionen} mit `target-size` ausdrücklich
29 * eingeschaltet. axe-core liefert die Regel abgeschaltet aus, und der Tag
30 * `wcag22aa` schaltet sie nicht ein – nachgemessen an axe-core 4.13.0 ist sie
31 * die einzige Regel an diesem Tag.
32 *
33 * ## Welcher Regelsatz je Farbschema – und warum
34 *
35 * Prüfplan 1.4.3 verlangt, alle drei Farbschemata **einzeln** zu messen. Zuvor
36 * lief dafür genau eine Regel (`color-contrast`) an genau einer Ansicht (dem
37 * Startbildschirm, in `barrierefreiheit.spec.ts`); der volle Regelsatz
38 * existierte ausschließlich im Vorgabeschema.
39 *
40 * Jetzt läuft in **jedem** der drei Schemata an **jeder** Ansicht der volle
41 * Satz WCAG 2.2 AA.
42 *
43 * Die Wahl war eine andere. Ein Schemawechsel schreibt ausschließlich
44 * CSS-Variablen um (`src/renderer/src/styles/tokens.css`, `:root[data-thema=…]`);
45 * Markup, Namen, Rollen, Überschriften und `aria-*`-Zustände sind in allen drei
46 * Schemata dasselbe DOM. Es hätte also nahegelegen, außerhalb des
47 * Vorgabeschemas nur die farb- und geometrieabhängigen Regeln laufen zu lassen
48 * (`color-contrast`, `link-in-text-block`, `target-size` – der hohe Kontrast
49 * verdoppelt `--rahmenbreite`). Nachgemessen kostet dieser Verzicht aber
50 * nichts, was ihn lohnte: **33,9 s** mit den drei Regeln gegen **36,4 s** mit
51 * dem vollen Satz, bei 47 Prüfungen. Für 2,5 Sekunden eine Abkürzung zu
52 * begründen, die man später verteidigen muss, ist ein schlechtes Geschäft –
53 * zumal die Annahme „dasselbe DOM“ genau so lange gilt, wie niemand eine
54 * schemaabhängige Darstellung einbaut.
55 *
56 * ## Was ein grüner Lauf NICHT heißt
57 *
58 * Nicht „barrierefrei“. Automatische Prüfung findet nur, was sich maschinell
59 * entscheiden lässt – bei weitem nicht alles. Die Szenarien an NVDA, JAWS und
60 * VoiceOver in `docs/barrierefreiheit-pruefplan.md` ersetzt diese Suite nicht
61 * und soll es nicht.
62 *
63 * Ausdrücklich **nicht** gemessen, obwohl es zur Anwendung gehört:
64 *
65 * - das Windows-Kontrastdesign (`forced-colors: active`). Dort bestimmt das
66 * Betriebssystem die Palette; Chromium meldet das Signal im Testlauf nicht.
67 * - die Fehlerzustände (kein Katalog, unlesbare Datenbank, fehlgeschlagene
68 * Sicherung) und die Rückfragen der Profilverwaltung.
69 * - die Druckausgaben (`@media print`) – axe misst den Bildschirmzustand.
70 * - ob ein Alternativtext inhaltlich richtig ist, ob die Fokusreihenfolge
71 * sinnvoll ist und ob eine Live-Region im echten Screenreader ankommt.
72 *
73 * Und: Was axe **nicht entscheiden** kann, bricht den Lauf nicht, wird aber
74 * mitsamt Fundstelle genannt (siehe `unentschieden` in `axe-hilfe.ts`).
75 * Nachgesehen betrifft das im ganzen Lauf ausschließlich `aria-hidden`
76 * gesetzte Schriftzeichen: den Pfeil in `.knopf--zurueck`, die Häkchen und
77 * Kreise der Fragenübersicht samt ihrer Legende, das Urteilssymbol der
78 * Prüfungsauswertung und dieselben Symbole in der Verlaufstabelle. axe kann
79 * ihren Kontrast nicht sicher rechnen; ihre Aussage steht in jedem dieser
80 * Fälle zusätzlich im Text – im zugänglichen Namen der Schaltfläche
81 * („Frage 2 von 5, offen“) oder im Wort daneben (WCAG 1.4.1). Der Text selbst
82 * wird regulär gemessen.
83 */
84
85 import { type ElectronApplication, type Page } from '@playwright/test';
86
87 import { THEME_BESCHRIFTUNGEN, type ThemeAufgeloest } from '../src/shared/theme';
88 import { fensterOptionen } from '../src/shared/wcag';
89 import { axePruefen, befunde, unentschieden } from './axe-hilfe';
90 import {
91 amStart,
92 animationenAbwarten,
93 appStarten,
94 appStartenOhneZuschnitt,
95 bauPruefen,
96 themaSetzen,
97 } from './electron-hilfe';
98 import { expect, test } from './konsolenwache';
99
100 let app: ElectronApplication | undefined;
101 let fenster: Page;
102
103 test.beforeAll(async () => {
104 // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen.
105 bauPruefen();
106 ({ app, fenster } = await appStarten());
107 });
108
109 test.afterAll(async () => {
110 await app?.close();
111 });
112
113 /** Alle tatsächlich gerenderten Schemata – `system` ist hier bereits aufgelöst. */
114 const SCHEMATA: readonly ThemeAufgeloest[] = ['hell', 'dunkel', 'hochkontrast'];
115
116 /**
117 * Ausstiege aus jeder Ansicht, in der Reihenfolge, in der sie zu drücken sind.
118 *
119 * Die Rückfragen stehen zuoberst: Steht eine offen, liegt darunter noch der
120 * Knopf, der sie geöffnet hat („Simulation abbrechen“). Wer den zuerst fände,
121 * drückte im Kreis.
122 */
123 const AUSSTIEGE: readonly RegExp[] = [
124 /^Abbrechen und verwerfen$/u,
125 /^Ohne Auswertung verwerfen$/u,
126 /^Auswertung anzeigen$/u,
127 /^Simulation abbrechen$/u,
128 /^Sitzung beenden$/u,
129 /^Zum Start$/u,
130 ];
131
132 /**
133 * Zurück auf den Startbildschirm, egal wo der vorige Test aufgehört hat.
134 *
135 * Dieselbe Bewegung wie in `ueberschriften.spec.ts`: Die Suiten teilen sich
136 * ein Fenster, weil ein Neustart je Test die Laufzeit vervielfachte. Seit hier
137 * auch Prüfungsläufe geprüft werden, muss der Weg mehr kennen als „Zum Start“
138 * – ein laufender Bogen verlangt zwei Klicks, und der Hilfedialog liegt über
139 * allem und schluckt sie.
140 */
141 async function zumStart(): Promise<void> {
142 if ((await fenster.getByRole('dialog').count()) > 0) {
143 await fenster.keyboard.press('Escape');
144 await fenster.waitForTimeout(150);
145 }
146
147 for (let i = 0; i < 8; i++) {
148 if (await amStart(fenster)) {
149 return;
150 }
151
152 let geklickt = false;
153 for (const weg of AUSSTIEGE) {
154 const knopf = fenster.getByRole('button', { name: weg });
155 if ((await knopf.count()) > 0 && (await knopf.first().isVisible())) {
156 await knopf.first().click();
157 await fenster.waitForTimeout(250);
158 geklickt = true;
159 break;
160 }
161 }
162
163 if (!geklickt) {
164 return;
165 }
166 }
167 }
168
169 /**
170 * Prüft die aktuelle Ansicht und meldet Verstöße lesbar.
171 *
172 * `animationenAbwarten` ist Pflicht: Ohne das misst axe die Farben mitten im
173 * Übergang und meldet Kontrastfehler, die es im Ruhezustand nicht gibt.
174 */
175 async function ansichtPruefen(seite: Page, name: string): Promise<void> {
176 await animationenAbwarten(seite);
177 const ergebnis = await axePruefen(seite, fensterOptionen());
178 const gefunden = befunde(ergebnis);
179 const offen = unentschieden(ergebnis);
180
181 /* Unentschiedenes bricht den Lauf nicht, verschwindet aber auch nicht
182 stillschweigend – siehe `unentschieden` in axe-hilfe.ts. Genannt wird
183 auch die Fundstelle: Eine Zeile, die nur „color-contrast“ sagt, lässt
184 niemanden nachsehen, ob dahinter etwas steckt. */
185 if (offen.length > 0) {
186 console.info(
187 `${name}: ${String(offen.length)} von axe nicht entscheidbare Stelle(n):\n` +
188 offen.map((b) => ` ${b.regel}: ${b.betroffen.join(', ')}`).join('\n'),
189 );
190 }
191
192 expect(
193 gefunden,
194 `${name}: ${String(gefunden.length)} Verstoß/Verstöße.\n` +
195 gefunden
196 .map((b) => ` ${b.regel} (${b.wirkung}): ${b.hilfe}\n ${b.betroffen.join('\n ')}`)
197 .join('\n'),
198 ).toEqual([]);
199 }
200
201 /** Startet „Weiterlernen“ und wartet, bis die erste Frage steht. */
202 async function lernsitzungStarten(): Promise<void> {
203 await fenster.getByRole('button', { name: /Weiterlernen/u }).click();
204 await fenster
205 .getByRole('button', { name: /^(Antwort bestätigen|Musterantwort anzeigen)$/u })
206 .waitFor();
207 }
208
209 /**
210 * Beantwortet die stehende Frage – gleich welcher Art.
211 *
212 * Auswahlfragen bekommen die erste Option, offene Fragen die Selbstbewertung
213 * „Nicht gewusst“. Die Sitzung wird zufällig zusammengestellt; welche Art
214 * kommt, steht nicht vorher fest.
215 */
216 async function frageBeantworten(): Promise<void> {
217 const auswahlfeld = fenster.getByRole('radio').or(fenster.getByRole('checkbox'));
218
219 if ((await auswahlfeld.count()) > 0) {
220 await auswahlfeld.first().click();
221 await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click();
222 } else {
223 await fenster.getByRole('button', { name: 'Musterantwort anzeigen' }).click();
224 await fenster.getByRole('button', { name: 'Nicht gewusst' }).click();
225 }
226
227 await fenster.locator('.ergebnis').first().waitFor();
228 }
229
230 /**
231 * Antwortet, bis eine Antwort **nicht** richtig war – höchstens fünf Fragen.
232 *
233 * Erst dann führt die Auswertung die Fehlerliste und den Knopf „Fehler
234 * wiederholen ( … )“; beides wäre sonst nur zufällig geprüft. Welche Option
235 * richtig ist, weiß der Test nicht – deshalb der Versuch statt der Rechnung.
236 * Nach der fünften Frage wird abgebrochen: Auch die Auswertung ohne Fehler ist
237 * ein gültiger Zustand, nur ein ärmerer.
238 */
239 async function bisFehlerAntworten(): Promise<void> {
240 for (let i = 0; i < 5; i++) {
241 await frageBeantworten();
242 if ((await fenster.locator('.ergebnis--richtig').count()) === 0) {
243 return;
244 }
245
246 const weiter = fenster.getByRole('button', { name: /^Nächste Frage$/u });
247 if ((await weiter.count()) === 0) {
248 return;
249 }
250 await weiter.first().click();
251 await fenster
252 .getByRole('button', { name: /^(Antwort bestätigen|Musterantwort anzeigen)$/u })
253 .waitFor();
254 }
255 }
256
257 /**
258 * Stellt einen kurzen Bogen ein und startet ihn.
259 *
260 * Fünf Fragen (das Minimum aus `GRENZEN.fragenMin`) und 60 Minuten: kurz genug
261 * für einen Testlauf, lang genug, dass die Uhr während der Messung nicht
262 * abläuft und die Prüfung sich selbst abgibt.
263 *
264 * `offenProzent` steuert, was nach der Abgabe kommt: Bei 0 folgt unmittelbar
265 * die Auswertung, bei 100 zuerst die Selbstbewertung der offenen Fragen. Beide
266 * Wege sind eigene Ansichten und werden einzeln gemessen.
267 */
268 async function pruefungStarten(offenProzent: number): Promise<void> {
269 await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click();
270 await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check();
271 /* `exact` bleibt gesetzt: Die Beschreibung des Profils „Selbst einstellen“
272 zählt weiterhin auf, was frei zu wählen ist („Fragenzahl, Zeit,
273 Bestehensgrenze und Anteil offener Fragen frei wählen.“). Ohne genaue
274 Übereinstimmung träfe die Suche den Radioknopf mit. */
275 await fenster.getByLabel('Fragen im Bogen', { exact: true }).fill('5');
276 await fenster.getByLabel('Bearbeitungszeit', { exact: true }).fill('60');
277 await fenster.getByLabel('Anteil offener Fragen', { exact: true }).fill(String(offenProzent));
278 await fenster.getByRole('button', { name: 'Simulation starten' }).click();
279 await fenster.getByRole('button', { name: 'Simulation abgeben' }).waitFor();
280 }
281
282 /** Ein Prüfschritt: hinführen und messen, was dort zu messen ist. */
283 interface Pruefstueck {
284 readonly name: string;
285 readonly messen: (pruefen: (zustand: string) => Promise<void>) => Promise<void>;
286 }
287
288 const PRUEFSTUECKE: readonly Pruefstueck[] = [
289 {
290 name: 'Startbildschirm',
291 messen: async (pruefen) => {
292 await zumStart();
293 await pruefen('Startbildschirm');
294 },
295 },
296
297 {
298 name: 'Startbildschirm mit eingeschalteter Sprachausgabe',
299 messen: async (pruefen) => {
300 /*
301 Die beiden Zusatzschalter zum Vorlesen erscheinen erst, wenn die
302 Sprachausgabe an ist – ohne diesen Fall bekäme axe sie nie zu sehen.
303 Genau so entstehen Ansichten, die niemand geprüft hat.
304 */
305 await zumStart();
306 const schalter = fenster.getByRole('checkbox', {
307 name: 'Fragen und Antworten vorlesen lassen',
308 });
309 await schalter.scrollIntoViewIfNeeded();
310 await schalter.setChecked(true);
311 await fenster
312 .getByRole('checkbox', { name: 'Frage und Rückmeldung von selbst vorlesen' })
313 .waitFor();
314
315 await pruefen('Startbildschirm mit Sprachausgabe');
316
317 // Zustand hinterlassen, wie er vorgefunden wurde.
318 await schalter.setChecked(false);
319 },
320 },
321
322 {
323 name: 'Startbildschirm mit Rückfrage vor dem Ersetzen',
324 messen: async (pruefen) => {
325 /*
326 Die Rückfrage vor dem Einspielen ist der folgenreichste Bereich der
327 ganzen Anwendung – und im Vorgabezustand nicht sichtbar. Ohne diesen
328 Fall bekäme axe sie nie zu sehen.
329
330 Der Öffnen-Dialog wird im Hauptprozess ersetzt; geprüft wird alles, was
331 danach kommt.
332 */
333 await zumStart();
334 await app?.evaluate(({ dialog }) => {
335 (dialog as unknown as { showOpenDialog: unknown }).showOpenDialog = () =>
336 Promise.resolve({ canceled: true, filePaths: [] });
337 });
338
339 const knopf = fenster.getByRole('button', { name: 'Sicherung auswählen und ansehen …' });
340 await knopf.scrollIntoViewIfNeeded();
341 await pruefen('Startbildschirm mit Sicherungskarte');
342 },
343 },
344
345 {
346 name: 'Kapitelwahl',
347 messen: async (pruefen) => {
348 await zumStart();
349 await fenster.getByRole('button', { name: /Kapitel wählen/u }).click();
350 await fenster.getByRole('heading', { name: 'Kapitel wählen', level: 1 }).waitFor();
351 await pruefen('Kapitelwahl');
352 },
353 },
354
355 {
356 name: 'Glossar',
357 messen: async (pruefen) => {
358 await zumStart();
359 await fenster.getByRole('button', { name: /Fachbegriffe nachschlagen/u }).click();
360 await fenster.getByRole('heading', { name: 'Glossar', exact: true }).waitFor();
361 await pruefen('Glossar');
362 },
363 },
364
365 {
366 name: 'Gesetze lesen',
367 messen: async (pruefen) => {
368 /* Mit aufgeklapptem Wortlaut: Erst dann steht der amtliche Text da,
369 und erst dann lässt sich der Kontrast der Herkunftszeile und der
370 Trefferbegründung wirklich messen. */
371 await zumStart();
372 await fenster.getByRole('button', { name: /In den Gesetzen nachlesen/u }).click();
373 await fenster.getByRole('heading', { name: 'Gesetze lesen', level: 1 }).waitFor();
374 await fenster.getByLabel('In den Gesetzen suchen').fill('Notwehr');
375 await fenster.waitForTimeout(400);
376 await pruefen('Gesetze lesen');
377 },
378 },
379
380 {
381 name: 'Fragen durchsuchen',
382 messen: async (pruefen) => {
383 /* Mit Trefferliste: Erst dort gibt es Marken, Aufklapper und Knöpfe in
384 Zahl – und erst dort lassen sich Kontrast der Fundstellenmarkierung und
385 Zielgröße der Bereichsradios wirklich messen. */
386 await zumStart();
387 await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click();
388 await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor();
389 await fenster.getByLabel('Suchbegriff').fill('Anscheinswaffe');
390 await fenster.waitForTimeout(400);
391 await pruefen('Fragen durchsuchen');
392 },
393 },
394
395 {
396 name: 'Über diese Software',
397 messen: async (pruefen) => {
398 await zumStart();
399 await fenster.getByRole('button', { name: /Lizenzen und Herkunft anzeigen/u }).click();
400 await fenster.getByRole('heading', { name: 'Über diese Software' }).waitFor();
401 await pruefen('Über diese Software');
402 },
403 },
404
405 {
406 name: 'Hilfe',
407 messen: async (pruefen) => {
408 /* Der Dialog liegt über der Ansicht, nicht an ihrer Stelle. axe misst
409 hier deshalb beides zugleich – und genau so bekommt es der Nutzer
410 auch zu sehen. */
411 await zumStart();
412 await fenster.getByRole('contentinfo').getByRole('button', { name: 'Hilfe' }).click();
413 await fenster.getByRole('dialog', { name: 'Hilfe' }).waitFor();
414 await pruefen('Hilfe');
415 await fenster.keyboard.press('Escape');
416 await fenster.getByRole('dialog', { name: 'Hilfe' }).waitFor({ state: 'hidden' });
417 },
418 },
419
420 {
421 name: 'Lernsitzung',
422 messen: async (pruefen) => {
423 await zumStart();
424 await lernsitzungStarten();
425 await pruefen('Lernsitzung');
426 },
427 },
428
429 {
430 name: 'Lernsitzung mit geöffnetem Meldeblock',
431 messen: async (pruefen) => {
432 /* Der Block ist zugeklappt, solange ihn niemand öffnet – axe bekäme ihn
433 im Vorgabezustand also nie zu sehen. Genau so entstehen Ansichten, die
434 niemand geprüft hat. */
435 await zumStart();
436 await lernsitzungStarten();
437 const aufklapper = fenster.getByText('Fehler zu dieser Frage melden');
438 await aufklapper.scrollIntoViewIfNeeded();
439 await aufklapper.click();
440 await fenster.getByLabel('Meldetext').waitFor();
441
442 await pruefen('Lernsitzung mit Meldeblock');
443 },
444 },
445
446 {
447 name: 'Lernsitzung nach der Antwort',
448 messen: async (pruefen) => {
449 /* Der Ergebniszustand hat eigene Bedienelemente und eine eigene
450 Farbgebung (richtig/falsch/verpasst) – er ist damit ein eigener
451 Prüffall, nicht derselbe Bildschirm mit mehr Text. */
452 await zumStart();
453 await lernsitzungStarten();
454 await frageBeantworten();
455 await pruefen('Lernsitzung nach der Antwort');
456 },
457 },
458
459 {
460 name: 'Auswertung der Lernsitzung',
461 messen: async (pruefen) => {
462 /* Trefferquote, Fortschrittsbalken, Zahlenliste und die Liste der
463 verpatzten Fragen – nichts davon steht in der Sitzung selbst. */
464 await zumStart();
465 await lernsitzungStarten();
466 await bisFehlerAntworten();
467 await fenster.getByRole('button', { name: 'Sitzung beenden' }).click();
468 await fenster.getByRole('heading', { name: /^Auswertung: /u, level: 1 }).waitFor();
469 await pruefen('Auswertung der Lernsitzung');
470 },
471 },
472
473 {
474 name: 'Prüfungswahl',
475 messen: async (pruefen) => {
476 /* Mit aufgeklapptem „Selbst einstellen“: Die vier Zahlenfelder samt
477 ihren Fehlerhinweisen (`aria-invalid`) gibt es nur bei einem
478 anpassbaren Profil. */
479 await zumStart();
480 await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click();
481 await fenster
482 .getByRole('heading', { name: 'Prüfungssimulation vorbereiten', level: 1 })
483 .waitFor();
484 await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check();
485 await fenster.getByLabel('Fragen im Bogen', { exact: true }).waitFor();
486 await pruefen('Prüfungswahl');
487 },
488 },
489
490 {
491 name: 'Prüfungslauf, Rückfrage vor der Abgabe und Prüfungsauswertung',
492 messen: async (pruefen) => {
493 /*
494 Drei Zustände in einem Schritt, weil sie an einem Bogen hängen: Der
495 Lauf lässt sich nicht abgeben, ohne ihn vorher zu starten. Sie einzeln
496 zu prüfen hieße, den Bogen dreimal zusammenzustellen.
497
498 Der Bogen besteht ausschließlich aus Auswahlfragen (0 Prozent offen) –
499 dann folgt auf die Abgabe unmittelbar die Auswertung. Die
500 Selbstbewertung offener Fragen ist ein eigener Prüfschritt.
501 */
502 await zumStart();
503 await pruefungStarten(0);
504
505 /* Eine Antwort setzen: Die Fragenübersicht unterscheidet beantwortete
506 von offenen Fragen, und diese Unterscheidung soll nicht allein an der
507 Farbe hängen. Ohne eine Antwort gäbe es nichts zu unterscheiden. */
508 await fenster.getByRole('radio').or(fenster.getByRole('checkbox')).first().click();
509 await pruefen('Prüfungslauf');
510
511 await fenster.getByRole('button', { name: 'Simulation abgeben' }).click();
512 await fenster.getByRole('heading', { name: 'Simulation wirklich abgeben?' }).waitFor();
513 await pruefen('Prüfung: Rückfrage vor der Abgabe');
514
515 await fenster.getByRole('button', { name: 'Jetzt abgeben und auswerten' }).click();
516 await fenster
517 .getByRole('heading', { name: 'Auswertung der Prüfungssimulation', level: 1 })
518 .waitFor();
519 /* Der Verlauf früherer Läufe kommt aus der Datenbank nach. Ohne dieses
520 Warten misst axe den Ladehinweis statt der Tabelle. */
521 await fenster.getByText('Der Verlauf wird geladen …').waitFor({ state: 'hidden' });
522 await pruefen('Prüfungsauswertung');
523 },
524 },
525
526 {
527 name: 'Prüfung: Selbstbewertung offener Fragen',
528 messen: async (pruefen) => {
529 /* Eigene Ansicht mit eigener Bauform: je offener Frage eine
530 Auswahlgruppe mit `fieldset`/`legend`, dazu die eigene Antwort und die
531 Musterantwort. Ein Bogen aus lauter offenen Fragen (100 Prozent)
532 erzwingt sie. Nicht beantwortet wird bewusst nichts: Auch „Sie haben
533 nichts eingetragen“ ist ein Zustand, den jemand vorgelesen bekommt. */
534 await zumStart();
535 await pruefungStarten(100);
536 await fenster.getByRole('button', { name: 'Simulation abgeben' }).click();
537 await fenster.getByRole('button', { name: 'Jetzt abgeben und auswerten' }).click();
538 await fenster.getByRole('heading', { name: 'Offene Fragen selbst bewerten' }).waitFor();
539 await pruefen('Prüfung: Selbstbewertung offener Fragen');
540
541 // Ohne diesen Klick bliebe der Lauf in der Nachbewertung stehen – dort
542 // gibt es keinen der üblichen Ausstiege.
543 await fenster.getByRole('button', { name: 'Auswertung anzeigen' }).click();
544 await fenster
545 .getByRole('heading', { name: 'Auswertung der Prüfungssimulation', level: 1 })
546 .waitFor();
547 },
548 },
549 ];
550
551 for (const schema of SCHEMATA) {
552 test.describe(`Farbschema „${THEME_BESCHRIFTUNGEN[schema]}“ (WCAG 2.2 AA)`, () => {
553 test.beforeAll(async () => {
554 /* Der Umschalter steht nur auf dem Startbildschirm; das Schema gilt
555 danach für alle Ansichten dieses Blocks. */
556 await zumStart();
557 await themaSetzen(fenster, schema);
558 });
559
560 for (const stueck of PRUEFSTUECKE) {
561 test(stueck.name, async () => {
562 await stueck.messen(async (zustand) => {
563 await ansichtPruefen(fenster, `${zustand} [${schema}]`);
564 });
565 });
566 }
567 });
568 }
569
570 /**
571 * Die beiden Tore vor dem Startbildschirm.
572 *
573 * Eigene Anwendung mit eigenem Profilverzeichnis: `appStarten` beantwortet die
574 * Frage nach Kapitel IV für alle übrigen Prüfungen, und „Wer lernt?“ erscheint
575 * erst ab dem zweiten Lernprofil.
576 */
577 test.describe('Tore vor dem Startbildschirm', () => {
578 test.describe.configure({ mode: 'serial' });
579
580 let torApp: ElectronApplication | undefined;
581 let torFenster: Page;
582
583 test.beforeAll(async () => {
584 // `bauPruefen` steht einmal je Datei, oben – hier wäre es nur eine
585 // zweite Antwort auf dieselbe Frage.
586 ({ app: torApp, fenster: torFenster } = await appStartenOhneZuschnitt());
587 });
588
589 test.afterAll(async () => {
590 await torApp?.close();
591 });
592
593 /**
594 * Farbschema setzen, wo der Umschalter nicht erreichbar ist.
595 *
596 * Die drei Schemata stehen auf dem Startbildschirm – also hinter dem Tor.
597 * Wer die Frage beantwortet, um an den Umschalter zu kommen, bekommt sie
598 * nicht wieder: Sie erscheint einmal je Profilverzeichnis. Geschrieben wird
599 * deshalb genau das Paar, das auch `useThema.auswahlSetzen` schreibt –
600 * Einstellung über die Brücke und `localStorage` –, danach wird neu geladen.
601 * Kein anderer Weg als der der Anwendung, nur ohne den Klick.
602 */
603 async function themaOhneUmschalter(thema: ThemeAufgeloest): Promise<void> {
604 await torFenster.evaluate(async (wert: string) => {
605 window.localStorage.setItem('waffensachkunde.thema', wert);
606 const bruecke = (
607 window as unknown as {
608 lernApp?: { einstellungenSchreiben: (a: { thema: string }) => Promise<unknown> };
609 }
610 ).lernApp;
611 await bruecke?.einstellungenSchreiben({ thema: wert });
612 }, thema);
613
614 await torFenster.reload();
615 await torFenster.waitForSelector('h1', { state: 'visible' });
616 await expect(torFenster.locator('html')).toHaveAttribute('data-thema', thema);
617 await animationenAbwarten(torFenster);
618 }
619
620 test('Frage nach Kapitel IV, in allen drei Farbschemata', async () => {
621 for (const schema of SCHEMATA) {
622 await themaOhneUmschalter(schema);
623 await expect(torFenster.getByRole('heading', { name: /Lernen Sie .* mit\?/u })).toBeVisible();
624 await ansichtPruefen(torFenster, `Zuschnittfrage [${schema}]`);
625 }
626 });
627
628 test('Frage „Wer lernt?“, in allen drei Farbschemata', async () => {
629 // Erst durch das erste Tor, dann ein zweites Profil – ohne das erscheint
630 // die Frage nicht, und das ist Absicht (siehe `Profilfrage.tsx`).
631 await torFenster.getByRole('button', { name: 'Weiter', exact: true }).click();
632 await torFenster.getByRole('heading', { name: 'Heute lernen' }).waitFor();
633
634 await torFenster.getByRole('button', { name: 'Neues Profil anlegen' }).click();
635 await torFenster.getByLabel('Name des neuen Profils').fill('Zweiter');
636 await torFenster.getByRole('button', { name: 'Profil anlegen' }).click();
637 await expect(torFenster.locator('.profile__liste > li')).toHaveCount(2);
638
639 for (const schema of SCHEMATA) {
640 await themaSetzen(torFenster, schema);
641 /* Das Tor steht beim Aufbau, nicht beim Umschalten – deshalb neu laden.
642 Die Wahl überlebt das (Einstellungen und localStorage). */
643 await torFenster.reload();
644 await torFenster.waitForSelector('h1', { state: 'visible' });
645 await expect(torFenster.getByRole('heading', { name: 'Wer lernt?' })).toBeVisible();
646 await ansichtPruefen(torFenster, `Profilfrage [${schema}]`);
647
648 // Zurück auf den Startbildschirm, wo der Umschalter steht.
649 await torFenster.getByRole('button', { name: 'Weiter', exact: true }).click();
650 await torFenster.getByRole('heading', { name: 'Heute lernen' }).waitFor();
651 }
652 });
653 });