waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Das Hilfefenster im echten Fenster. |
| 3 | * |
| 4 | * ## Warum diese Suite nötig ist |
| 5 | * |
| 6 | * Die Unit-Tests in `tests/renderer-hilfe.test.tsx` prüfen Inhalt, Aufbau und |
| 7 | * Erreichbarkeit – aber nicht das, was die Hilfe überhaupt erst zu einem |
| 8 | * Dialog macht. jsdom 27 kennt `<dialog>` als Element, hat aber weder |
| 9 | * `showModal()` noch `close()`; dort läuft der Dialog über einen Rückfallweg |
| 10 | * und ist **nicht modal**. Fokusfalle, Inertheit des Hintergrunds und die |
| 11 | * Rückgabe des Fokus gibt es nur in Chromium – also nur hier. |
| 12 | * |
| 13 | * Dazu kommt der Weg über die Menüleiste: Sie liegt im Hauptprozess und ist |
| 14 | * unter jsdom gar nicht vorhanden. |
| 15 | */ |
| 16 | |
| 17 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 18 | |
| 19 | import { HILFE_KAPITEL, HILFE_TITEL } from '../src/shared/hilfe'; |
| 20 | import { amStart, animationenAbwarten, appStarten, bauPruefen } from './electron-hilfe'; |
| 21 | import { expect, test } from './konsolenwache'; |
| 22 | |
| 23 | let app: ElectronApplication | undefined; |
| 24 | let fenster: Page; |
| 25 | |
| 26 | test.beforeAll(async () => { |
| 27 | bauPruefen(); |
| 28 | ({ app, fenster } = await appStarten()); |
| 29 | }); |
| 30 | |
| 31 | test.afterAll(async () => { |
| 32 | await app?.close(); |
| 33 | }); |
| 34 | |
| 35 | /** Der Weg zur Hilfe: die erste Schaltfläche im Fußbereich. */ |
| 36 | function hilfeknopf() { |
| 37 | return fenster.getByRole('contentinfo').getByRole('button', { name: 'Hilfe', exact: true }); |
| 38 | } |
| 39 | |
| 40 | function hilfefenster() { |
| 41 | return fenster.getByRole('dialog', { name: HILFE_TITEL }); |
| 42 | } |
| 43 | |
| 44 | async function hilfeOeffnen(): Promise<void> { |
| 45 | await hilfeknopf().click(); |
| 46 | await hilfefenster().waitFor(); |
| 47 | await animationenAbwarten(fenster); |
| 48 | } |
| 49 | |
| 50 | async function hilfeSchliessen(): Promise<void> { |
| 51 | if ((await hilfefenster().count()) > 0) { |
| 52 | await fenster.keyboard.press('Escape'); |
| 53 | await hilfefenster().waitFor({ state: 'hidden' }); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /** Zurück auf den Startbildschirm, egal wo der vorige Test aufgehört hat. */ |
| 58 | async function zumStart(): Promise<void> { |
| 59 | await hilfeSchliessen(); |
| 60 | for (let i = 0; i < 4; i++) { |
| 61 | if (await amStart(fenster)) { |
| 62 | return; |
| 63 | } |
| 64 | const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); |
| 65 | if ((await zurueck.count()) === 0) { |
| 66 | return; |
| 67 | } |
| 68 | await zurueck.first().click(); |
| 69 | await fenster.waitForTimeout(250); |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | test.describe('Hilfe – ein wirklich modaler Dialog', () => { |
| 74 | test('öffnet sich modal und legt sich über die Ansicht', async () => { |
| 75 | await zumStart(); |
| 76 | await hilfeOeffnen(); |
| 77 | |
| 78 | /* `:modal` trifft nur auf einen über showModal() geöffneten Dialog zu. |
| 79 | Ein `<dialog open>` ohne Modalität fiele hier durch – und genau das |
| 80 | ist der Unterschied, den jsdom nicht abbilden kann. */ |
| 81 | const istModal = await fenster.evaluate( |
| 82 | () => document.querySelector('dialog')?.matches(':modal') ?? false, |
| 83 | ); |
| 84 | expect(istModal, 'Der Dialog ist nicht modal geöffnet.').toBe(true); |
| 85 | |
| 86 | await hilfeSchliessen(); |
| 87 | }); |
| 88 | |
| 89 | test('hält den Fokus im Dialog fest', async () => { |
| 90 | await zumStart(); |
| 91 | await hilfeOeffnen(); |
| 92 | |
| 93 | /* |
| 94 | Ohne Fokusfalle wandert der Tabulator hinter den Dialog und bedient |
| 95 | Elemente, die optisch verdeckt sind. Zwanzig Schritte reichen: Das |
| 96 | Inhaltsverzeichnis hat zwölf Verweise, dazu zwei Schaltflächen – nach |
| 97 | einem vollen Umlauf wäre der Fokus draußen, wenn er es könnte. Die Zahl |
| 98 | stand bis 0.24.2 bei „zehn“ und war schon damals falsch; sie ist keine |
| 99 | Zusicherung, sondern die Begründung dafür, dass zwanzig Schritte reichen. |
| 100 | */ |
| 101 | for (let schritt = 0; schritt < 20; schritt++) { |
| 102 | await fenster.keyboard.press('Tab'); |
| 103 | const drinnen = await fenster.evaluate(() => { |
| 104 | return document.querySelector('dialog')?.contains(document.activeElement) ?? false; |
| 105 | }); |
| 106 | expect( |
| 107 | drinnen, |
| 108 | `Der Fokus ist nach ${String(schritt + 1)} Tabs aus dem Dialog gefallen.`, |
| 109 | ).toBe(true); |
| 110 | } |
| 111 | |
| 112 | await hilfeSchliessen(); |
| 113 | }); |
| 114 | |
| 115 | test('gibt den Fokus beim Schließen an die Schaltfläche zurück', async () => { |
| 116 | await zumStart(); |
| 117 | await hilfeOeffnen(); |
| 118 | await fenster.keyboard.press('Escape'); |
| 119 | await hilfefenster().waitFor({ state: 'hidden' }); |
| 120 | |
| 121 | /* Wer die Hilfe schließt, soll dort weitermachen, wo er war – nicht am |
| 122 | Anfang des Dokuments (WCAG 2.4.3, Fokusreihenfolge). */ |
| 123 | const zurueckAufKnopf = await fenster.evaluate( |
| 124 | () => document.activeElement?.textContent.trim() === 'Hilfe', |
| 125 | ); |
| 126 | expect(zurueckAufKnopf, 'Der Fokus steht nach dem Schließen nicht auf „Hilfe“.').toBe(true); |
| 127 | }); |
| 128 | |
| 129 | test('zeigt alle Kapitel und springt aus dem Verzeichnis dorthin', async () => { |
| 130 | await zumStart(); |
| 131 | await hilfeOeffnen(); |
| 132 | |
| 133 | for (const kapitel of HILFE_KAPITEL) { |
| 134 | await expect( |
| 135 | hilfefenster().getByRole('heading', { name: kapitel.titel, level: 3 }), |
| 136 | ).toBeVisible(); |
| 137 | } |
| 138 | |
| 139 | const letztes = HILFE_KAPITEL[HILFE_KAPITEL.length - 1]; |
| 140 | await fenster.getByRole('link', { name: letztes?.titel ?? '' }).click(); |
| 141 | |
| 142 | /* Der Sprung soll den Fokus mitnehmen, nicht nur den Bildausschnitt: |
| 143 | Wer nicht sieht, wohin gescrollt wurde, merkt sonst nichts davon. */ |
| 144 | const beimKapitel = await fenster.evaluate( |
| 145 | (id) => document.activeElement?.id === id, |
| 146 | `hilfe-${letztes?.id ?? ''}`, |
| 147 | ); |
| 148 | expect(beimKapitel, 'Der Sprung aus dem Verzeichnis nimmt den Fokus nicht mit.').toBe(true); |
| 149 | |
| 150 | await hilfeSchliessen(); |
| 151 | }); |
| 152 | }); |
| 153 | |
| 154 | test.describe('Hilfe – Erreichbarkeit (WCAG 3.2.6)', () => { |
| 155 | test('steht in jeder Ansicht als erste Schaltfläche im Fußbereich', async () => { |
| 156 | const wege: readonly (readonly [string, RegExp])[] = [ |
| 157 | ['Kapitelwahl', /Kapitel wählen/u], |
| 158 | ['Glossar', /Fachbegriffe nachschlagen/u], |
| 159 | ['Über diese Software', /Lizenzen und Herkunft anzeigen/u], |
| 160 | ['Prüfungswahl', /Prüfungssimulation vorbereiten/u], |
| 161 | ]; |
| 162 | |
| 163 | for (const [name, knopfname] of wege) { |
| 164 | await zumStart(); |
| 165 | await fenster.getByRole('button', { name: knopfname }).click(); |
| 166 | await fenster.waitForTimeout(300); |
| 167 | |
| 168 | const erste = await fenster |
| 169 | .getByRole('contentinfo') |
| 170 | .getByRole('button') |
| 171 | .first() |
| 172 | .textContent(); |
| 173 | expect(erste?.trim(), `${name}: Die Hilfe steht nicht zuoberst im Fußbereich.`).toBe('Hilfe'); |
| 174 | } |
| 175 | |
| 176 | await zumStart(); |
| 177 | }); |
| 178 | |
| 179 | test('geht über den Menüeintrag „Handbuch“ auf', async () => { |
| 180 | await zumStart(); |
| 181 | |
| 182 | /* Derselbe Weg, den F1 nimmt: Der Accelerator hängt an diesem |
| 183 | Menüeintrag. Ihn hier auszulösen prüft die ganze Kette – Menü im |
| 184 | Hauptprozess, IPC-Meldung, Dialog in der Oberfläche. */ |
| 185 | const gefunden = await app?.evaluate(({ Menu }) => { |
| 186 | const menue = Menu.getApplicationMenu(); |
| 187 | const hilfe = menue?.items.find((eintrag) => eintrag.label.replace('&', '') === 'Hilfe'); |
| 188 | const handbuch = hilfe?.submenu?.items.find((eintrag) => eintrag.label === 'Handbuch'); |
| 189 | handbuch?.click(); |
| 190 | return handbuch !== undefined; |
| 191 | }); |
| 192 | |
| 193 | expect(gefunden, 'Im Hilfemenü steht kein Eintrag „Handbuch“.').toBe(true); |
| 194 | await expect(hilfefenster()).toBeVisible(); |
| 195 | |
| 196 | await hilfeSchliessen(); |
| 197 | }); |
| 198 | }); |
| 199 | |
| 200 | test.describe('Hilfe – nimmt nichts mit', () => { |
| 201 | test('lässt den laufenden Prüfungsbogen stehen und die Uhr laufen', async () => { |
| 202 | /* |
| 203 | Der eigentliche Grund für einen Dialog statt einer eigenen Ansicht. |
| 204 | Und zugleich die Probe auf eine Zusage im Handbuch selbst: Die |
| 205 | Prüfungsuhr läuft weiter – sie anzuhalten würde die Simulation |
| 206 | verfälschen. |
| 207 | |
| 208 | Der Lauf ist bewusst der kleinstmögliche (fünf Fragen, eine Minute): |
| 209 | Die Restzeitanzeige nennt bis 59 Sekunden hinab jede Sekunde einzeln, |
| 210 | darüber nur volle Minuten. Mit dem Standardprofil stünde nach zwei |
| 211 | Sekunden immer noch „2 Stunden“ da, und der Test bewiese nichts. |
| 212 | */ |
| 213 | await zumStart(); |
| 214 | await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click(); |
| 215 | await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check(); |
| 216 | await fenster.getByLabel('Fragen im Bogen').fill('5'); |
| 217 | await fenster.getByLabel('Bearbeitungszeit').fill('1'); |
| 218 | await fenster.getByRole('button', { name: 'Simulation starten' }).click(); |
| 219 | await fenster.getByRole('button', { name: 'Simulation abbrechen' }).waitFor(); |
| 220 | |
| 221 | const restzeit = fenster.locator('.restzeit__wert'); |
| 222 | const vorher = (await restzeit.textContent())?.trim() ?? ''; |
| 223 | expect(vorher.length, 'Es wurde keine Restzeit angezeigt.').toBeGreaterThan(0); |
| 224 | |
| 225 | await hilfeOeffnen(); |
| 226 | await fenster.waitForTimeout(2500); |
| 227 | await hilfeSchliessen(); |
| 228 | |
| 229 | await expect(fenster.getByRole('button', { name: 'Simulation abbrechen' })).toBeVisible(); |
| 230 | const nachher = (await restzeit.textContent())?.trim() ?? ''; |
| 231 | expect(nachher, 'Die Prüfungsuhr stand still, während die Hilfe offen war.').not.toBe(vorher); |
| 232 | |
| 233 | // Aufräumen: Ein laufender Bogen ließe das Fenster beim Schließen |
| 234 | // nachfragen und brächte den nächsten Lauf durcheinander. |
| 235 | await fenster.getByRole('button', { name: 'Simulation abbrechen' }).click(); |
| 236 | await fenster.getByRole('button', { name: 'Abbrechen und verwerfen' }).click(); |
| 237 | await fenster.waitForTimeout(400); |
| 238 | await zumStart(); |
| 239 | }); |
| 240 | |
| 241 | /* |
| 242 | Die Kehrseite der vorigen Prüfung, und die gefährlichere. |
| 243 | |
| 244 | `showModal()` macht den Hintergrund inert – für Zeiger und für |
| 245 | Bildschirmleser. Für Tastendrücke gilt das nicht: Sie steigen bis zum |
| 246 | `window` auf, und dort hängt der Zuhörer der Zeichenkürzel. Bis Fassung |
| 247 | 0.24.1 bestätigte deshalb ein Druck auf die Eingabetaste bei offener |
| 248 | Hilfe die – womöglich leere – Antwort der dahinterliegenden Frage. Die |
| 249 | Frage zählte als falsch, und die Wiedervorlage rechnete damit weiter. |
| 250 | |
| 251 | Gemessen wird an der Frage selbst: Steht nach dem Schließen der Hilfe |
| 252 | immer noch die Antwortmöglichkeit statt der Rückmeldung, ist nichts |
| 253 | bestätigt worden. |
| 254 | */ |
| 255 | test('lässt die Tastenkürzel der Lernsitzung nicht durch den Dialog wirken', async () => { |
| 256 | /* |
| 257 | Der Weg über die Suche, und das ist der Punkt: Eine Auswahlfrage muss |
| 258 | es sein — bei einer offenen Frage steht der Fokus im Textfeld, und dort |
| 259 | greifen die Kürzel ohnehin nicht. |
| 260 | |
| 261 | Bis Fassung 0.25.0 ging dieser Test über „Weiterlernen“ und verlangte, |
| 262 | dass die Sitzung mit einer Auswahlfrage **beginnt**. Die Reihenfolge ist |
| 263 | gemischt, und 104 der 575 Fragen sind offen — der Test hing damit am |
| 264 | Zufall und fiel im Gate-Lauf zu 0.25.0 aus genau diesem Grund. Über die |
| 265 | amtliche Nummer ist die Frage festgelegt: 1.02 kommt im Katalog genau |
| 266 | einmal vor und hat drei Antwortmöglichkeiten. |
| 267 | */ |
| 268 | await zumStart(); |
| 269 | await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); |
| 270 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 271 | await fenster.getByRole('radio', { name: /Alle Bereiche/u }).check(); |
| 272 | await fenster.getByRole('searchbox').fill('1.02'); |
| 273 | await fenster |
| 274 | .getByRole('button', { name: /^Frage 1\.02 üben$/u }) |
| 275 | .first() |
| 276 | .click(); |
| 277 | |
| 278 | const optionen = fenster |
| 279 | .getByRole('group') |
| 280 | .getByRole('checkbox') |
| 281 | .or(fenster.getByRole('radio')); |
| 282 | await optionen.first().waitFor(); |
| 283 | const vorher = await optionen.count(); |
| 284 | expect(vorher, 'Die Sitzung begann nicht mit einer Auswahlfrage.').toBe(3); |
| 285 | |
| 286 | await hilfeOeffnen(); |
| 287 | await fenster.keyboard.press('Enter'); |
| 288 | await fenster.keyboard.press('1'); |
| 289 | await fenster.keyboard.press('m'); |
| 290 | await fenster.waitForTimeout(200); |
| 291 | |
| 292 | /* Der Dialog steht noch – keine der Tasten hat ihn geschlossen. */ |
| 293 | await expect(hilfefenster()).toBeVisible(); |
| 294 | await hilfeSchliessen(); |
| 295 | |
| 296 | /* Und die Frage ist unberührt: Die Antwortmöglichkeiten stehen weiterhin |
| 297 | zur Wahl, und keine ist ausgewählt. */ |
| 298 | await expect(optionen.first()).toBeVisible(); |
| 299 | expect(await optionen.count()).toBe(vorher); |
| 300 | for (let i = 0; i < vorher; i++) { |
| 301 | await expect(optionen.nth(i)).not.toBeChecked(); |
| 302 | } |
| 303 | await expect(fenster.getByRole('button', { name: /^(Weiter|Nächste Frage)/u })).toHaveCount(0); |
| 304 | |
| 305 | await zumStart(); |
| 306 | }); |
| 307 | }); |