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, 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 | }); |