waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app e2e barrierefreiheit-ansichten.spec.ts
| 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 | }); |