waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app e2e anzeigegroesse.spec.ts
| 1 | /** |
| 2 | * Die Anzeigegröße im echten Fenster – und der Umbruch, den sie erzwingt. |
| 3 | * |
| 4 | * Der Zoom hängt an den `webContents`, nicht am Dokument – prüfbar ist er |
| 5 | * deshalb nur am laufenden Fenster. Die reine Rechnerei (welche Stufe folgt |
| 6 | * auf welche, wie werden unbrauchbare Werte bereinigt) prüft |
| 7 | * `tests/ansicht.test.ts`. |
| 8 | * |
| 9 | * Der zweite Teil dieser Datei misst WCAG 2.2 – 1.4.10 (Reflow) an dem Punkt, |
| 10 | * an dem das Kriterium ihn festmacht: 400 Prozent Vergrößerung auf einem |
| 11 | * 1280 Pixel breiten Fenster, also 320 CSS-Pixel Inhaltsbreite. |
| 12 | */ |
| 13 | |
| 14 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 15 | |
| 16 | import { amStart, appStarten, bauPruefen } from './electron-hilfe'; |
| 17 | import { expect, test } from './konsolenwache'; |
| 18 | |
| 19 | let app: ElectronApplication | undefined; |
| 20 | let fenster: Page; |
| 21 | |
| 22 | test.beforeAll(async () => { |
| 23 | // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. |
| 24 | bauPruefen(); |
| 25 | ({ app, fenster } = await appStarten()); |
| 26 | }); |
| 27 | |
| 28 | test.afterAll(async () => { |
| 29 | await app?.close(); |
| 30 | }); |
| 31 | |
| 32 | async function faktor(): Promise<number> { |
| 33 | return ( |
| 34 | (await app?.evaluate(({ BrowserWindow }) => |
| 35 | Number(BrowserWindow.getAllWindows()[0]?.webContents.getZoomFactor().toFixed(3)), |
| 36 | )) ?? 0 |
| 37 | ); |
| 38 | } |
| 39 | |
| 40 | test.describe('Anzeigegröße', () => { |
| 41 | test('beginnt bei 100 Prozent', async () => { |
| 42 | await expect(fenster.getByText('Aktuell: 100 Prozent')).toBeVisible(); |
| 43 | expect(await faktor()).toBeCloseTo(1, 2); |
| 44 | }); |
| 45 | |
| 46 | test('vergrößert die ganze Anwendung, nicht nur den Text', async () => { |
| 47 | /* Der Zoomfaktor wirkt auf alles im Fenster – auch auf die siebzehn |
| 48 | Prüfzeichen, bei denen das Bild die Antwort ist. Genau deshalb Zoom |
| 49 | und nicht bloß eine größere Schrift. */ |
| 50 | await fenster.getByRole('button', { name: /Größer/u }).click(); |
| 51 | await expect(fenster.getByText('Aktuell: 110 Prozent')).toBeVisible(); |
| 52 | expect(await faktor()).toBeCloseTo(1.1, 2); |
| 53 | }); |
| 54 | |
| 55 | test('reicht bis 400 Prozent (WCAG 1.4.4 und 1.4.10)', async () => { |
| 56 | /* |
| 57 | 1.4.4 verlangt 200 Prozent. Die Leiter geht weiter bis 400, weil 1.4.10 |
| 58 | dort misst: 400 Prozent auf 1280 Pixeln Fensterbreite sind die 320 |
| 59 | CSS-Pixel, bei denen der Inhalt ohne seitliches Rollen benutzbar sein |
| 60 | muss. Endete die Leiter bei 200, wäre dieser Fall mit Bordmitteln nicht |
| 61 | einmal herstellbar. |
| 62 | |
| 63 | Nach jedem Klick auf die neue Zahl warten. Blind hintereinander zu |
| 64 | klicken läuft in eine Wettlaufsituation: Playwright prüft, ob der Knopf |
| 65 | bedienbar ist, React schaltet ihn zwischendurch ab. |
| 66 | */ |
| 67 | const knopf = fenster.getByRole('button', { name: /Größer/u }); |
| 68 | const stand = fenster.locator('.anzeigegroesse__stand'); |
| 69 | for (const stufe of [125, 150, 175, 200, 250, 300, 400]) { |
| 70 | await knopf.click(); |
| 71 | await expect(stand).toHaveText(`Aktuell: ${String(stufe)} Prozent`); |
| 72 | } |
| 73 | |
| 74 | await expect(fenster.getByText('Aktuell: 400 Prozent')).toBeVisible(); |
| 75 | expect(await faktor()).toBeCloseTo(4, 2); |
| 76 | await expect(fenster.getByRole('button', { name: /Größer/u })).toBeDisabled(); |
| 77 | }); |
| 78 | |
| 79 | test('bleibt bei 400 Prozent bedienbar', async () => { |
| 80 | /* Bei vierfacher Größe muss sich alles noch erreichen lassen – sonst wäre |
| 81 | die Vergrößerung wertlos. Geprüft wird an der Hauptschaltfläche. */ |
| 82 | const weiterlernen = fenster.getByRole('button', { name: /Weiterlernen/u }); |
| 83 | |
| 84 | await expect(weiterlernen).toBeVisible(); |
| 85 | await weiterlernen.scrollIntoViewIfNeeded(); |
| 86 | await expect(weiterlernen).toBeEnabled(); |
| 87 | }); |
| 88 | |
| 89 | test('kehrt auf Normalgröße zurück', async () => { |
| 90 | await fenster.getByRole('button', { name: 'Normalgröße' }).click(); |
| 91 | |
| 92 | await expect(fenster.getByText('Aktuell: 100 Prozent')).toBeVisible(); |
| 93 | expect(await faktor()).toBeCloseTo(1, 2); |
| 94 | }); |
| 95 | |
| 96 | test('überlebt einen Neustart der Anwendung', async () => { |
| 97 | await fenster.getByRole('button', { name: /Größer/u }).click(); |
| 98 | await fenster.getByRole('button', { name: /Größer/u }).click(); |
| 99 | await expect(fenster.getByText('Aktuell: 125 Prozent')).toBeVisible(); |
| 100 | |
| 101 | /* Neu laden ersetzt keinen echten Neustart, prüft aber denselben Weg: |
| 102 | Der Hauptprozess legt die gespeicherte Größe auf das frisch geladene |
| 103 | Dokument. Ohne das blitzte die Anwendung beim Start klein auf. */ |
| 104 | await fenster.reload(); |
| 105 | await fenster.getByRole('heading', { level: 1 }).waitFor(); |
| 106 | |
| 107 | expect(await faktor()).toBeCloseTo(1.25, 2); |
| 108 | await expect(fenster.getByText('Aktuell: 125 Prozent')).toBeVisible(); |
| 109 | |
| 110 | await fenster.getByRole('button', { name: 'Normalgröße' }).click(); |
| 111 | }); |
| 112 | |
| 113 | /* |
| 114 | Ganz am Ende, und das ist Absicht: Die Tests darueber haengen aneinander - |
| 115 | „kehrt auf Normalgroesse zurueck" verlaesst sich darauf, dass der vorige |
| 116 | Test bei der obersten Stufe endet, sonst ist der Knopf gesperrt. Dazwischen |
| 117 | eingeschoben brach dieser Block die Kette. Er stellt am Anfang jedes |
| 118 | Tests selbst her, was er braucht. |
| 119 | */ |
| 120 | test.describe('über die Tastatur', () => { |
| 121 | /* |
| 122 | Gemeldet und nachgemessen: `Strg` und `+` taten unter Windows nichts. |
| 123 | Electron liest den Beschleunigernamen `Plus` als die umgeschaltete Form |
| 124 | von `=` und meldet damit faktisch `Strg+Umschalt+=` beim Betriebssystem |
| 125 | an – richtig für eine amerikanische Tastatur, falsch für eine deutsche, |
| 126 | auf der `+` eine eigene Taste ohne Umschalt ist. |
| 127 | |
| 128 | Abgefangen wird deshalb im Renderer (`hooks/useZoomtasten.ts`), und zwar |
| 129 | über das Zeichen, das die Tastatur tatsächlich liefert. Damit ist es |
| 130 | auch prüfbar: Ein Menü-Beschleuniger läuft über die Beschleunigertabelle |
| 131 | des Betriebssystems und wird von keinem Prüflauf erreicht – nachgemessen |
| 132 | ebenso wie `before-input-event` im Hauptprozess, das für Tasten aus |
| 133 | einem Prüflauf überhaupt nichts meldet. |
| 134 | */ |
| 135 | test.beforeEach(async () => { |
| 136 | await fenster.keyboard.press('Control+0'); |
| 137 | await fenster.waitForTimeout(200); |
| 138 | }); |
| 139 | |
| 140 | test('vergrößert über alle drei Schreibweisen des Pluszeichens', async () => { |
| 141 | // Die deutsche Plustaste: dieselbe Taste wie `=` auf US-Layout. |
| 142 | await fenster.keyboard.press('Control+='); |
| 143 | await fenster.waitForTimeout(200); |
| 144 | expect(await faktor(), 'Strg und + (deutsche Tastatur)').toBeCloseTo(1.1, 2); |
| 145 | |
| 146 | // Die amerikanische Schreibweise, mit Umschalt. |
| 147 | await fenster.keyboard.press('Control+Shift+='); |
| 148 | await fenster.waitForTimeout(200); |
| 149 | expect(await faktor(), 'Strg, Umschalt und +').toBeCloseTo(1.25, 2); |
| 150 | |
| 151 | // Der Ziffernblock. |
| 152 | await fenster.keyboard.press('Control+NumpadAdd'); |
| 153 | await fenster.waitForTimeout(200); |
| 154 | expect(await faktor(), 'Strg und + auf dem Ziffernblock').toBeCloseTo(1.5, 2); |
| 155 | }); |
| 156 | |
| 157 | test('verkleinert und setzt zurück', async () => { |
| 158 | await fenster.keyboard.press('Control+='); |
| 159 | await fenster.keyboard.press('Control+='); |
| 160 | await fenster.waitForTimeout(250); |
| 161 | expect(await faktor()).toBeCloseTo(1.25, 2); |
| 162 | |
| 163 | await fenster.keyboard.press('Control+-'); |
| 164 | await fenster.waitForTimeout(200); |
| 165 | expect(await faktor()).toBeCloseTo(1.1, 2); |
| 166 | |
| 167 | await fenster.keyboard.press('Control+0'); |
| 168 | await fenster.waitForTimeout(200); |
| 169 | expect(await faktor()).toBeCloseTo(1, 2); |
| 170 | }); |
| 171 | |
| 172 | test('wirkt auch, wenn der Fokus in einem Textfeld steht', async () => { |
| 173 | /* Wer eine offene Frage tippt und die Schrift zu klein findet, soll |
| 174 | nicht erst irgendwohin klicken müssen. */ |
| 175 | const feld = fenster.getByLabel('Ihr Prüfungstermin'); |
| 176 | await feld.click(); |
| 177 | await fenster.keyboard.press('Control+='); |
| 178 | await fenster.waitForTimeout(200); |
| 179 | expect(await faktor()).toBeCloseTo(1.1, 2); |
| 180 | await fenster.keyboard.press('Control+0'); |
| 181 | await fenster.waitForTimeout(200); |
| 182 | }); |
| 183 | |
| 184 | test('schluckt keine fremde Tastenkombination', async () => { |
| 185 | /* Der Zuhörer liegt am Fenster und sieht jede Taste mit Strg. Er darf |
| 186 | nur die drei eigenen abfangen – sonst bräche er Kopieren, Einfügen |
| 187 | und alles Weitere. */ |
| 188 | const vorher = await faktor(); |
| 189 | for (const taste of ['Control+a', 'Control+c', 'Control+v', 'Control+1', 'Control+9']) { |
| 190 | await fenster.keyboard.press(taste); |
| 191 | } |
| 192 | await fenster.waitForTimeout(250); |
| 193 | expect(await faktor()).toBeCloseTo(vorher, 2); |
| 194 | }); |
| 195 | }); |
| 196 | }); |
| 197 | |
| 198 | /* ===================================================================== |
| 199 | Reflow – WCAG 2.2, 1.4.10 |
| 200 | ===================================================================== */ |
| 201 | |
| 202 | /** Was eine Messung zurückgibt. */ |
| 203 | interface Reflowmass { |
| 204 | /** Sichtbare Breite des Dokuments in CSS-Pixeln, ohne Rollbalken. */ |
| 205 | readonly innen: number; |
| 206 | /** Tatsächliche Breite des Dokuments; darf `innen` nicht überschreiten. */ |
| 207 | readonly doku: number; |
| 208 | /** Elemente, deren Inhalt über den eigenen Kasten hinausragt. */ |
| 209 | readonly ueberlauf: readonly string[]; |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Misst den waagerechten und den senkrechten Überlauf der stehenden Ansicht. |
| 214 | * |
| 215 | * ## Was gegenüber der ersten Fassung schärfer geworden ist |
| 216 | * |
| 217 | * Die Vorgängerin dieser Messung war zu großzügig, und zwar an vier Stellen |
| 218 | * zugleich. Nachgezählt: |
| 219 | * |
| 220 | * 1. **Der Filter warf zu viel weg.** `kasten.width <= 1 || kasten.height <= 1` |
| 221 | * verwarf jedes Element, das in *einer* Richtung dünn ist – eine Trennlinie, |
| 222 | * ein Fortschrittsbalken, ein Rahmenstreifen. Gemeint war der Klammergriff |
| 223 | * für Screenreader-Texte (`.nur-screenreader`), und der ist in **beiden** |
| 224 | * Richtungen 1 Pixel groß. Jetzt steht dort `&&`. |
| 225 | * 2. **Die Toleranz war fast 2 Pixel.** Verglichen wurde `scrollWidth` (ohne |
| 226 | * Rahmen) gegen `Math.ceil(kasten.width) + 1` (mit Rahmen). Bei den hier |
| 227 | * üblichen 2 Pixel starken Rahmen ließ das rund 4 Pixel Überstand |
| 228 | * durchgehen. Jetzt stehen `scrollWidth` und `clientWidth` gegeneinander – |
| 229 | * beide ohne Rahmen – mit einer Toleranz von 1 Pixel für die Rundung: Beide |
| 230 | * Werte sind ganzzahlig, ein Layout mit Bruchteilen kann sie um eins |
| 231 | * auseinanderfallen lassen. |
| 232 | * 3. **Der Bezug war zu weit.** `window.innerWidth` zählt den senkrechten |
| 233 | * Rollbalken mit; die Seite durfte also um dessen Breite zu breit sein. |
| 234 | * Gemessen wird jetzt gegen `documentElement.clientWidth`. |
| 235 | * 4. **Senkrecht wurde gar nicht gemessen.** Abgeschnittener Inhalt in einem |
| 236 | * Kasten mit fester Höhe fiel nicht auf. Jetzt fällt er auf – ausgenommen |
| 237 | * die Seite selbst, denn senkrechtes Rollen erlaubt 1.4.10 ausdrücklich. |
| 238 | * |
| 239 | * Ausgenommen bleiben drei Fälle, jeder mit Grund: |
| 240 | * |
| 241 | * - **Absichtliche Rollbereiche** (`overflow-x`/`-y: auto` oder `scroll`). |
| 242 | * Genau dort liegt die Verlaufstabelle, und für Inhalte, die eine |
| 243 | * zweidimensionale Anordnung brauchen, lässt 1.4.10 das zu. |
| 244 | * - **Textfelder und Auswahllisten.** Ein `<input>` rollt seinen eigenen Wert |
| 245 | * von Haus aus; das ist keine verlorene Information. |
| 246 | * - **Unsichtbares** – Kästen ohne Fläche und `visibility: hidden`. |
| 247 | */ |
| 248 | async function reflowMessen(seite: Page): Promise<Reflowmass> { |
| 249 | return seite.evaluate(() => { |
| 250 | const wurzel = document.documentElement; |
| 251 | const ueberlauf: string[] = []; |
| 252 | |
| 253 | for (const element of [...document.querySelectorAll('*')]) { |
| 254 | const kasten = element.getBoundingClientRect(); |
| 255 | |
| 256 | if (kasten.width === 0 || kasten.height === 0) { |
| 257 | continue; |
| 258 | } |
| 259 | if (kasten.width <= 1 && kasten.height <= 1) { |
| 260 | continue; |
| 261 | } |
| 262 | if (element.tagName === 'INPUT' || element.tagName === 'SELECT') { |
| 263 | continue; |
| 264 | } |
| 265 | |
| 266 | const stil = getComputedStyle(element); |
| 267 | if (stil.visibility === 'hidden') { |
| 268 | continue; |
| 269 | } |
| 270 | |
| 271 | const istSeite = element === wurzel || element === document.body; |
| 272 | const rolltWaagerecht = stil.overflowX === 'auto' || stil.overflowX === 'scroll'; |
| 273 | const rolltSenkrecht = istSeite || stil.overflowY === 'auto' || stil.overflowY === 'scroll'; |
| 274 | |
| 275 | const klasse = element.getAttribute('class'); |
| 276 | const name = element.tagName + (klasse === null ? '' : `.${klasse.slice(0, 40)}`); |
| 277 | |
| 278 | const zuBreit = element.scrollWidth - element.clientWidth; |
| 279 | if (!rolltWaagerecht && zuBreit > 1) { |
| 280 | ueberlauf.push(`${name}: ${String(zuBreit)} px zu breit`); |
| 281 | } |
| 282 | |
| 283 | const zuHoch = element.scrollHeight - element.clientHeight; |
| 284 | if (!rolltSenkrecht && zuHoch > 1) { |
| 285 | ueberlauf.push(`${name}: ${String(zuHoch)} px zu hoch`); |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | return { |
| 290 | innen: wurzel.clientWidth, |
| 291 | doku: wurzel.scrollWidth, |
| 292 | // Zwölf reichen zum Suchen; hundert Zeilen im Fehlerbericht helfen nicht. |
| 293 | ueberlauf: ueberlauf.slice(0, 12), |
| 294 | }; |
| 295 | }); |
| 296 | } |
| 297 | |
| 298 | /** Setzt die Anzeigegröße über denselben Weg, den auch die Oberfläche geht. */ |
| 299 | async function anzeigegroesseSetzen(prozent: number): Promise<void> { |
| 300 | await fenster.evaluate(async (wert: number) => { |
| 301 | const bruecke = ( |
| 302 | window as unknown as { |
| 303 | lernApp?: { anzeigegroesseSetzen: (p: number) => Promise<number> }; |
| 304 | } |
| 305 | ).lernApp; |
| 306 | await bruecke?.anzeigegroesseSetzen(wert); |
| 307 | }, prozent); |
| 308 | await fenster.waitForTimeout(250); |
| 309 | } |
| 310 | |
| 311 | test.describe('Reflow bei 320 CSS-Pixeln (WCAG 1.4.10)', () => { |
| 312 | /* |
| 313 | Eigener Block am Ende, aus demselben Grund wie der Tastaturblock: Er |
| 314 | verstellt Fenstergröße und Anzeigegröße und stellt beides danach wieder |
| 315 | her. Dazwischen eingeschoben zerrisse er die Kette der Tests darüber. |
| 316 | |
| 317 | 1280 Pixel Inhaltsbreite mal 400 Prozent ergibt genau die 320 CSS-Pixel, |
| 318 | an denen das Kriterium gemessen wird. `setContentSize` und nicht |
| 319 | `setSize`: Der Fensterrahmen zählt nicht zum Inhalt, und um den geht es. |
| 320 | */ |
| 321 | const URSPRUNG = { breite: 1180, hoehe: 820 }; |
| 322 | |
| 323 | test.beforeAll(async () => { |
| 324 | await app?.evaluate(({ BrowserWindow }) => { |
| 325 | BrowserWindow.getAllWindows()[0]?.setContentSize(1280, 1024); |
| 326 | }); |
| 327 | await fenster.waitForTimeout(300); |
| 328 | }); |
| 329 | |
| 330 | test.afterAll(async () => { |
| 331 | await anzeigegroesseSetzen(100); |
| 332 | await app?.evaluate(({ BrowserWindow }, groesse) => { |
| 333 | BrowserWindow.getAllWindows()[0]?.setContentSize(groesse.breite, groesse.hoehe); |
| 334 | }, URSPRUNG); |
| 335 | }); |
| 336 | |
| 337 | /** Zurück auf den Startbildschirm, egal wo der vorige Test aufgehört hat. */ |
| 338 | async function zumStart(): Promise<void> { |
| 339 | if ((await fenster.getByRole('dialog').count()) > 0) { |
| 340 | await fenster.keyboard.press('Escape'); |
| 341 | await fenster.waitForTimeout(150); |
| 342 | } |
| 343 | |
| 344 | const ausstiege = [ |
| 345 | /^Ohne Auswertung verwerfen$/u, |
| 346 | /^Auswertung anzeigen$/u, |
| 347 | /^Simulation abbrechen$/u, |
| 348 | /^Sitzung beenden$/u, |
| 349 | /^Zum Start$/u, |
| 350 | ]; |
| 351 | |
| 352 | for (let i = 0; i < 8; i++) { |
| 353 | if (await amStart(fenster)) { |
| 354 | return; |
| 355 | } |
| 356 | |
| 357 | let geklickt = false; |
| 358 | for (const weg of ausstiege) { |
| 359 | const knopf = fenster.getByRole('button', { name: weg }); |
| 360 | if ((await knopf.count()) > 0 && (await knopf.first().isVisible())) { |
| 361 | await knopf.first().click(); |
| 362 | await fenster.waitForTimeout(250); |
| 363 | geklickt = true; |
| 364 | break; |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | if (!geklickt) { |
| 369 | return; |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * Die Ansichten, die der Prüfplan als Härtefälle führt (Zeile H8). |
| 376 | * |
| 377 | * Hingeführt wird bei 100 Prozent: Auf 320 CSS-Pixeln liegt zwischen zwei |
| 378 | * Schaltflächen ein halber Bildschirm, und jeder Klick würde zur |
| 379 | * Rollübung. Gemessen wird danach – der Zustand ist derselbe. |
| 380 | */ |
| 381 | const ANSICHTEN: readonly { readonly name: string; readonly hin: () => Promise<void> }[] = [ |
| 382 | { |
| 383 | name: 'Startbildschirm mit Lernstand', |
| 384 | hin: async () => { |
| 385 | await zumStart(); |
| 386 | }, |
| 387 | }, |
| 388 | { |
| 389 | name: 'Hilfe', |
| 390 | hin: async () => { |
| 391 | await zumStart(); |
| 392 | await fenster.getByRole('contentinfo').getByRole('button', { name: 'Hilfe' }).click(); |
| 393 | await fenster.getByRole('dialog', { name: 'Hilfe' }).waitFor(); |
| 394 | }, |
| 395 | }, |
| 396 | { |
| 397 | name: 'Trefferliste der Suche', |
| 398 | hin: async () => { |
| 399 | /* Der Suchbegriff ist mit Bedacht gewählt: „waffenbesitzkarten- |
| 400 | pflichtigen" ist mit 29 Zeichen das längste Wort im amtlichen |
| 401 | Katalog. Damit steht der harte Fall des Prüfplans – ein langer |
| 402 | amtlicher Text – zuverlässig in der Liste und nicht nur zufällig. */ |
| 403 | await zumStart(); |
| 404 | await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); |
| 405 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 406 | await fenster.getByLabel('Suchbegriff').fill('waffenbesitzkartenpflichtigen'); |
| 407 | await fenster.waitForTimeout(500); |
| 408 | }, |
| 409 | }, |
| 410 | { |
| 411 | name: 'Lernsitzung nach der Antwort', |
| 412 | hin: async () => { |
| 413 | await zumStart(); |
| 414 | await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); |
| 415 | await fenster |
| 416 | .getByRole('button', { name: /^(Antwort bestätigen|Musterantwort anzeigen)$/u }) |
| 417 | .waitFor(); |
| 418 | |
| 419 | const auswahlfeld = fenster.getByRole('radio').or(fenster.getByRole('checkbox')); |
| 420 | if ((await auswahlfeld.count()) > 0) { |
| 421 | await auswahlfeld.first().click(); |
| 422 | await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click(); |
| 423 | } else { |
| 424 | await fenster.getByRole('button', { name: 'Musterantwort anzeigen' }).click(); |
| 425 | await fenster.getByRole('button', { name: 'Nicht gewusst' }).click(); |
| 426 | } |
| 427 | await fenster.locator('.ergebnis').first().waitFor(); |
| 428 | }, |
| 429 | }, |
| 430 | { |
| 431 | name: 'Prüfungslauf mit Fragenübersicht', |
| 432 | hin: async () => { |
| 433 | await zumStart(); |
| 434 | await fenster.getByRole('button', { name: /Prüfungssimulation vorbereiten/u }).click(); |
| 435 | await fenster.getByRole('radio', { name: /Selbst einstellen/u }).check(); |
| 436 | await fenster.getByLabel('Fragen im Bogen', { exact: true }).fill('40'); |
| 437 | await fenster.getByLabel('Bearbeitungszeit', { exact: true }).fill('60'); |
| 438 | await fenster.getByLabel('Anteil offener Fragen', { exact: true }).fill('0'); |
| 439 | await fenster.getByRole('button', { name: 'Simulation starten' }).click(); |
| 440 | await fenster.getByRole('button', { name: 'Simulation abgeben' }).waitFor(); |
| 441 | /* Eine Antwort setzen: Erst dann führt die Übersicht beide Zustände |
| 442 | nebeneinander, und erst dann ist ihr Raster wirklich gefüllt. */ |
| 443 | await fenster.getByRole('radio').or(fenster.getByRole('checkbox')).first().click(); |
| 444 | }, |
| 445 | }, |
| 446 | { |
| 447 | name: 'Prüfungsauswertung mit Verlaufstabelle', |
| 448 | hin: async () => { |
| 449 | /* Kein `zumStart()`: Dieser Schritt gibt den Bogen ab, den der vorige |
| 450 | gestartet hat. Ihn eigens noch einmal zusammenzustellen kostete |
| 451 | einen zweiten Durchlauf durch die Prüfungswahl, und die Auswertung |
| 452 | bekäme dieselben Zahlen. */ |
| 453 | await fenster.getByRole('button', { name: 'Simulation abgeben' }).click(); |
| 454 | await fenster.getByRole('button', { name: 'Jetzt abgeben und auswerten' }).click(); |
| 455 | await fenster |
| 456 | .getByRole('heading', { name: 'Auswertung der Prüfungssimulation', level: 1 }) |
| 457 | .waitFor(); |
| 458 | /* Der Verlauf kommt aus der Datenbank nach. Ohne dieses Warten misst |
| 459 | der Lauf den Ladehinweis statt der Tabelle. */ |
| 460 | await fenster.getByText('Der Verlauf wird geladen …').waitFor({ state: 'hidden' }); |
| 461 | }, |
| 462 | }, |
| 463 | ]; |
| 464 | |
| 465 | for (const ansicht of ANSICHTEN) { |
| 466 | test(ansicht.name, async () => { |
| 467 | await anzeigegroesseSetzen(100); |
| 468 | await ansicht.hin(); |
| 469 | await anzeigegroesseSetzen(400); |
| 470 | |
| 471 | const mass = await reflowMessen(fenster); |
| 472 | |
| 473 | expect( |
| 474 | mass.innen, |
| 475 | `Gemessen wird bei ${String(mass.innen)} CSS-Pixeln – das ist nicht der Prüffall.`, |
| 476 | ).toBeLessThanOrEqual(320); |
| 477 | expect( |
| 478 | mass.innen, |
| 479 | `Nur ${String(mass.innen)} CSS-Pixel: Die Messung wäre strenger als das Kriterium.`, |
| 480 | ).toBeGreaterThan(300); |
| 481 | |
| 482 | expect( |
| 483 | mass.doku, |
| 484 | `${ansicht.name}: Die Seite ist ${String(mass.doku)} px breit, sichtbar sind ` + |
| 485 | `${String(mass.innen)} px – seitliches Rollen für Fließtext.`, |
| 486 | ).toBeLessThanOrEqual(mass.innen); |
| 487 | |
| 488 | expect(mass.ueberlauf, `${ansicht.name}: Inhalt ragt über seinen Kasten hinaus.`).toEqual([]); |
| 489 | }); |
| 490 | } |
| 491 | |
| 492 | test('die Messung fängt einen absichtlich erzeugten Überlauf', async () => { |
| 493 | /* |
| 494 | Gegenprobe zur Gegenprobe. |
| 495 | |
| 496 | Eine Messung, die immer grün ist, sagt nichts. Hier bekommt die Seite |
| 497 | nacheinander drei Fehler eingesetzt, die sie in Wirklichkeit nicht hat – |
| 498 | einen Kasten zwei Pixel breiter als sein Elternteil, ein unteilbares |
| 499 | langes Wort und einen abgeschnittenen Kasten. Jeder muss auffallen, |
| 500 | danach muss die Seite wieder sauber sein. |
| 501 | |
| 502 | Die zwei Pixel sind der Punkt: Die frühere Messung ließ durch ihren |
| 503 | Bezug auf `getBoundingClientRect().width` plus Rundung rund vier Pixel |
| 504 | durchgehen und hätte diesen Fall verschwiegen. |
| 505 | */ |
| 506 | await anzeigegroesseSetzen(100); |
| 507 | await zumStart(); |
| 508 | await anzeigegroesseSetzen(400); |
| 509 | |
| 510 | const vorher = await reflowMessen(fenster); |
| 511 | expect(vorher.ueberlauf, 'Die Ansicht ist vor der Gegenprobe nicht sauber.').toEqual([]); |
| 512 | |
| 513 | for (const fall of ['zweiPixel', 'langesWort', 'abgeschnitten'] as const) { |
| 514 | await fenster.evaluate((art: string) => { |
| 515 | const wirt = document.querySelector('.karte'); |
| 516 | if (wirt === null) { |
| 517 | throw new Error('Keine Karte gefunden, an der sich messen ließe.'); |
| 518 | } |
| 519 | const stoerer = document.createElement('div'); |
| 520 | stoerer.id = 'gegenprobe'; |
| 521 | |
| 522 | if (art === 'zweiPixel') { |
| 523 | stoerer.style.inlineSize = `${String(wirt.clientWidth + 2)}px`; |
| 524 | stoerer.style.blockSize = '20px'; |
| 525 | } else if (art === 'langesWort') { |
| 526 | stoerer.style.whiteSpace = 'nowrap'; |
| 527 | stoerer.textContent = 'Deaktivierungsdurchfuehrungsverordnungsaenderungsgesetz'; |
| 528 | } else { |
| 529 | stoerer.style.blockSize = '20px'; |
| 530 | stoerer.style.overflow = 'hidden'; |
| 531 | stoerer.textContent = 'Eine Zeile, zwei Zeilen, drei Zeilen, vier Zeilen, fuenf Zeilen.'; |
| 532 | } |
| 533 | |
| 534 | wirt.appendChild(stoerer); |
| 535 | }, fall); |
| 536 | |
| 537 | const mitFehler = await reflowMessen(fenster); |
| 538 | expect( |
| 539 | mitFehler.ueberlauf.length, |
| 540 | `Die Messung übersieht den Fall „${fall}".`, |
| 541 | ).toBeGreaterThan(0); |
| 542 | |
| 543 | await fenster.evaluate(() => { |
| 544 | document.getElementById('gegenprobe')?.remove(); |
| 545 | }); |
| 546 | } |
| 547 | |
| 548 | const nachher = await reflowMessen(fenster); |
| 549 | expect(nachher.ueberlauf, 'Die Gegenprobe hat etwas hinterlassen.').toEqual([]); |
| 550 | expect(nachher.doku).toBeLessThanOrEqual(nachher.innen); |
| 551 | }); |
| 552 | |
| 553 | test('das Fenster lässt sich bis auf 320 CSS-Pixel schmal ziehen', async () => { |
| 554 | /* |
| 555 | Der zweite Weg zu 320 Pixeln, unabhängig vom Zoom: das Fenster selbst. |
| 556 | |
| 557 | Bis Fassung 0.20.0 stand `minWidth` auf 720 – zusammen mit einer Leiter |
| 558 | bis 200 Prozent war der schmalste erreichbare Bereich 360 CSS-Pixel, der |
| 559 | Prüffall also nicht herstellbar. Geprüft wird an der eingestellten |
| 560 | Mindestgröße und nicht an der gemessenen Breite: `minWidth` meint die |
| 561 | Fenster-, nicht die Inhaltsbreite, und wie viel der Rahmen davon |
| 562 | abzieht, entscheidet das Betriebssystem. |
| 563 | */ |
| 564 | const minBreite = |
| 565 | (await app?.evaluate( |
| 566 | ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.getMinimumSize()[0] ?? 0, |
| 567 | )) ?? 0; |
| 568 | |
| 569 | expect(minBreite, 'Das Fenster lässt sich nicht schmal genug ziehen.').toBeLessThanOrEqual(400); |
| 570 | expect( |
| 571 | minBreite, |
| 572 | 'Unter 320 Pixeln gibt es keine Zusage mehr – so schmal soll es nicht gehen.', |
| 573 | ).toBeGreaterThanOrEqual(320); |
| 574 | |
| 575 | // Und die Ansicht trägt auch bei dieser Breite, bei 100 Prozent Anzeige. |
| 576 | await anzeigegroesseSetzen(100); |
| 577 | await zumStart(); |
| 578 | await app?.evaluate(({ BrowserWindow }) => { |
| 579 | BrowserWindow.getAllWindows()[0]?.setSize(200, 800); |
| 580 | }); |
| 581 | await fenster.waitForTimeout(300); |
| 582 | |
| 583 | const mass = await reflowMessen(fenster); |
| 584 | expect(mass.doku, 'Am schmalsten Fenster rollt die Seite seitlich.').toBeLessThanOrEqual( |
| 585 | mass.innen, |
| 586 | ); |
| 587 | expect(mass.ueberlauf, 'Am schmalsten Fenster ragt Inhalt aus seinem Kasten.').toEqual([]); |
| 588 | |
| 589 | await app?.evaluate(({ BrowserWindow }) => { |
| 590 | BrowserWindow.getAllWindows()[0]?.setContentSize(1280, 1024); |
| 591 | }); |
| 592 | await fenster.waitForTimeout(300); |
| 593 | }); |
| 594 | }); |