waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Was ohne Scrollen erreichbar sein muss. |
| 3 | * |
| 4 | * Anlass ist eine Rückmeldung aus der Anwendung heraus: „Was mir nicht |
| 5 | * gefällt, dass ich zum Lernen erstmal scrollen muss." Nachgemessen bei |
| 6 | * 1063 Pixeln sichtbarer Höhe: |
| 7 | * |
| 8 | * - Der Weg ins Lernen begann bei 2659 px – zweieinhalb Bildschirme |
| 9 | * Statusbericht vor der ersten Frage. |
| 10 | * - Nach dem Bestätigen begann die Rückmeldung bei rund 950 px und war |
| 11 | * damit praktisch unsichtbar; „Nächste Frage" stand bei 2115 px. |
| 12 | * |
| 13 | * Solche Regressionen fallen in keinem anderen Test auf: Alles ist da, |
| 14 | * alles ist bedienbar, alles ist zugänglich – es steht nur zu weit unten. |
| 15 | * Deshalb misst dieser Lauf Positionen statt Vorhandensein. |
| 16 | * |
| 17 | * Gemessen wird bei 1999 × 1125 Fenstergröße. Kleinere Fenster brauchen |
| 18 | * zwangsläufig mehr Scrollen; die Zusage gilt der üblichen Arbeitsgröße. |
| 19 | */ |
| 20 | |
| 21 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 22 | |
| 23 | import { amStart, appStarten, bauPruefen } from './electron-hilfe'; |
| 24 | import { expect, test } from './konsolenwache'; |
| 25 | |
| 26 | let app: ElectronApplication | undefined; |
| 27 | let fenster: Page; |
| 28 | |
| 29 | test.beforeAll(async () => { |
| 30 | // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. |
| 31 | bauPruefen(); |
| 32 | ({ app, fenster } = await appStarten()); |
| 33 | await app.evaluate(({ BrowserWindow }) => { |
| 34 | BrowserWindow.getAllWindows()[0]?.setSize(1999, 1125); |
| 35 | }); |
| 36 | await fenster.waitForTimeout(300); |
| 37 | }); |
| 38 | |
| 39 | test.afterAll(async () => { |
| 40 | await app?.close(); |
| 41 | }); |
| 42 | |
| 43 | async function sichtbareHoehe(): Promise<number> { |
| 44 | return fenster.evaluate(() => window.innerHeight); |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Startet eine frische Sitzung und blättert bis zu einer Auswahlfrage. |
| 49 | * |
| 50 | * Jeder Test holt sich seine eigene Sitzung: Sonst hängt das Ergebnis davon |
| 51 | * ab, wo der vorige aufgehört hat – und ein Durchlauf, der zufällig auf der |
| 52 | * letzten Frage endet, landet in der Auswertung statt in der Sitzung. |
| 53 | */ |
| 54 | async function sitzungMitAuswahlfrage(): Promise<void> { |
| 55 | /* Zurück zum Start – in Schritten: „Sitzung beenden“ führt zur Auswertung, |
| 56 | erst von dort geht es „Zum Start“. */ |
| 57 | for (let i = 0; i < 4; i++) { |
| 58 | if (await amStart(fenster)) { |
| 59 | break; |
| 60 | } |
| 61 | const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); |
| 62 | if ((await zurueck.count()) === 0) { |
| 63 | break; |
| 64 | } |
| 65 | await zurueck.first().click(); |
| 66 | await fenster.waitForTimeout(250); |
| 67 | } |
| 68 | |
| 69 | await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); |
| 70 | await fenster.getByRole('heading', { level: 1, name: /, Frage \d/u }).waitFor(); |
| 71 | |
| 72 | for (let i = 0; i < 15; i++) { |
| 73 | if ((await fenster.getByRole('radio').count()) > 0) { |
| 74 | return; |
| 75 | } |
| 76 | await fenster |
| 77 | .getByRole('button', { name: /Musterantwort anzeigen|^Antwort bestätigen$/u }) |
| 78 | .first() |
| 79 | .click(); |
| 80 | const bewertung = fenster.getByRole('button', { name: /^(Gewusst|Sicher gewusst)$/u }); |
| 81 | if ((await bewertung.count()) > 0) { |
| 82 | await bewertung.first().click(); |
| 83 | } |
| 84 | const weiter = fenster.getByRole('button', { name: /^Nächste Frage$/u }); |
| 85 | if ((await weiter.count()) === 0) { |
| 86 | /* Ende der Sitzung erreicht, ohne eine Auswahlfrage zu treffen – neu |
| 87 | beginnen statt in der Auswertung stehen zu bleiben. */ |
| 88 | await sitzungMitAuswahlfrage(); |
| 89 | return; |
| 90 | } |
| 91 | await weiter.first().click(); |
| 92 | await fenster.waitForTimeout(150); |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | test.describe('Ohne Scrollen erreichbar', () => { |
| 97 | test('der Weg ins Lernen steht im ersten Bildschirm', async () => { |
| 98 | const hoehe = await sichtbareHoehe(); |
| 99 | const knopf = fenster.getByRole('button', { name: /Weiterlernen/u }); |
| 100 | await expect(knopf).toBeVisible(); |
| 101 | |
| 102 | const unten = await fenster.evaluate(() => { |
| 103 | const b = [...document.querySelectorAll('button')].find((e) => |
| 104 | e.textContent.includes('Weiterlernen'), |
| 105 | ); |
| 106 | return b === undefined |
| 107 | ? Number.POSITIVE_INFINITY |
| 108 | : Math.round(b.getBoundingClientRect().bottom); |
| 109 | }); |
| 110 | |
| 111 | expect( |
| 112 | unten, |
| 113 | `„Weiterlernen" endet bei ${String(unten)} px, sichtbar sind ${String(hoehe)} px.`, |
| 114 | ).toBeLessThanOrEqual(hoehe); |
| 115 | }); |
| 116 | |
| 117 | test('alle fünf Einstiege stehen im ersten Bildschirm', async () => { |
| 118 | const hoehe = await sichtbareHoehe(); |
| 119 | |
| 120 | /* Der fünfte Knopf („Offene Fragen", seit 0.17.0) kostet keine Zeile: |
| 121 | Das Raster stellt bei dieser Breite drei Spalten, vier Knöpfe standen |
| 122 | also längst in zwei Reihen. Geprüft wird das hier und nicht gerechnet. */ |
| 123 | for (const name of ['Kapitel wählen', 'Nur Fehler', 'Gemerkte Fragen', 'Offene Fragen']) { |
| 124 | const unten = await fenster.evaluate((gesucht) => { |
| 125 | const b = [...document.querySelectorAll('button')].find((e) => |
| 126 | e.textContent.includes(gesucht), |
| 127 | ); |
| 128 | return b === undefined |
| 129 | ? Number.POSITIVE_INFINITY |
| 130 | : Math.round(b.getBoundingClientRect().bottom); |
| 131 | }, name); |
| 132 | |
| 133 | expect(unten, `„${name}" endet bei ${String(unten)} px`).toBeLessThanOrEqual(hoehe); |
| 134 | } |
| 135 | }); |
| 136 | |
| 137 | test('die Rückmeldung erscheint ohne Scrollen', async () => { |
| 138 | await sitzungMitAuswahlfrage(); |
| 139 | await fenster.getByRole('radio').first().click(); |
| 140 | await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click(); |
| 141 | await fenster.waitForTimeout(250); |
| 142 | await fenster.evaluate(() => { |
| 143 | window.scrollTo(0, 0); |
| 144 | }); |
| 145 | |
| 146 | const hoehe = await sichtbareHoehe(); |
| 147 | |
| 148 | /* |
| 149 | Die Zusage ist genau: Wer bestätigt hat, liest Urteil UND Lösungssatz, |
| 150 | ohne die Hand zur Maus zu nehmen. Gemessen wird deshalb die Unterkante |
| 151 | des Lösungssatzes, nicht die Oberkante des Kastens – eine Überschrift |
| 152 | am unteren Rand wäre keine Antwort. |
| 153 | */ |
| 154 | const unten = await fenster.evaluate(() => { |
| 155 | const satz = document.querySelector('.ergebnis p'); |
| 156 | return satz === null |
| 157 | ? Number.POSITIVE_INFINITY |
| 158 | : Math.round(satz.getBoundingClientRect().bottom); |
| 159 | }); |
| 160 | |
| 161 | expect( |
| 162 | unten, |
| 163 | `Der Lösungssatz endet bei ${String(unten)} px, sichtbar sind ${String(hoehe)} px.`, |
| 164 | ).toBeLessThanOrEqual(hoehe); |
| 165 | }); |
| 166 | |
| 167 | test('die Frage ist die oberste Überschrift der Sitzung', async () => { |
| 168 | /* |
| 169 | Der Name der Anwendung steht in der Titelleiste des Fensters – ihn im |
| 170 | Inhalt zu wiederholen kostete 167 Pixel und lenkte vom Stoff ab. Die |
| 171 | oberste Überschrift benennt jetzt, worum es in dieser Ansicht geht: |
| 172 | die Frage. Wer per Überschrift navigiert, landet damit an der Sache. |
| 173 | */ |
| 174 | await sitzungMitAuswahlfrage(); |
| 175 | |
| 176 | await expect(fenster.locator('.kopfbereich')).toHaveCount(0); |
| 177 | |
| 178 | const ueberschriften = fenster.getByRole('heading', { level: 1 }); |
| 179 | await expect(ueberschriften).toHaveCount(1); |
| 180 | await expect(ueberschriften).toHaveClass(/frage__text/u); |
| 181 | }); |
| 182 | |
| 183 | test('die Frage steht weit oben', async () => { |
| 184 | /* |
| 185 | Vorher begann der Fragetext bei 299 Pixeln, in der ursprünglichen |
| 186 | Fassung sogar bei 495. |
| 187 | |
| 188 | Gemessen wird der Moment des Ankommens. Jede neue Frage holt den Fokus |
| 189 | auf ihre Überschrift (`Lernsitzung.tsx`); erst dann steht die Seite so, |
| 190 | wie ein Lernender sie vorfindet. Diese Prüfung holte sich früher weder |
| 191 | eine eigene Sitzung noch wartete sie darauf – sie maß, wo die vorige |
| 192 | Prüfung den Rollbalken hatte stehen lassen. Nachgemessen über vier |
| 193 | Fenstergrößen: Lag der Fokus auf der Überschrift, stand sie bei 166 px |
| 194 | und die Seite war ungerollt; hing er noch auf „Nächste Frage", lag sie |
| 195 | bei 4 px, bei kleinem Fenster bei −363 px. Das war eine Aussage über |
| 196 | den Testablauf, nicht über die Anwendung. |
| 197 | |
| 198 | Die untere Schranke ist deshalb 0 und nicht 1: Eine Überschrift genau |
| 199 | an der Oberkante ist der beste Fall, kein Fehler. Unter 0 wäre sie |
| 200 | angeschnitten. |
| 201 | */ |
| 202 | await sitzungMitAuswahlfrage(); |
| 203 | await expect |
| 204 | .poll(() => fenster.evaluate(() => document.activeElement?.className ?? '')) |
| 205 | .toContain('frage__text'); |
| 206 | |
| 207 | const oben = await fenster.evaluate(() => { |
| 208 | const ueberschrift = document.querySelector('.frage__text'); |
| 209 | return ueberschrift === null ? null : Math.round(ueberschrift.getBoundingClientRect().top); |
| 210 | }); |
| 211 | |
| 212 | if (oben === null) { |
| 213 | throw new Error('Die Sitzung zeigt keine Frageüberschrift.'); |
| 214 | } |
| 215 | expect(oben, `Der Fragetext beginnt bei ${String(oben)} px.`).toBeLessThan(220); |
| 216 | expect( |
| 217 | oben, |
| 218 | `Der Fragetext steht über dem Sichtfeld (${String(oben)} px).`, |
| 219 | ).toBeGreaterThanOrEqual(0); |
| 220 | }); |
| 221 | }); |