waffensachkunde

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

/ app e2e barrierefreiheit-ansichten.spec.ts

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