waffensachkunde

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

/ app e2e barrierefreiheit-ansichten.spec.ts

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