waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app e2e aufklappmarke.spec.ts
| 1 | /** |
| 2 | * Jeder Aufklapper zeigt sein Dreieck. |
| 3 | * |
| 4 | * ## Warum das im echten Fenster gemessen wird |
| 5 | * |
| 6 | * Ein `<summary>` ohne Marke sieht aus wie eine gewöhnliche Zeile: Es sagt |
| 7 | * niemandem, dass sich dahinter etwas aufklappen lässt. Wer die Maus nicht |
| 8 | * darüberführt, findet den Inhalt nie. Chromium malt die Marke nur, solange |
| 9 | * das Element `display: list-item` behält – ein `display: flex` (oder `grid`, |
| 10 | * `inline-flex`, `block`) auf dem `<summary>` schaltet sie stillschweigend ab. |
| 11 | * |
| 12 | * Genau das war in `styles/lernen.css` bei `.sitzung__kuerzel summary` und in |
| 13 | * `styles/ueber.css` bei `.lizenzblock__schalter` passiert. Aufgefallen ist es |
| 14 | * erst im Bild: „Tastenkürzel dieser Ansicht“ stand ohne Dreieck neben zwei |
| 15 | * Nachbarn, die eines hatten. Die Warnung davor stand zu diesem Zeitpunkt |
| 16 | * bereits im Kommentar vor `.erklaerung--klappbar` in derselben Datei. |
| 17 | * |
| 18 | * Unter jsdom ist die Frage nicht zu beantworten: Dort gibt es keine |
| 19 | * CSS-Kaskade, `getComputedStyle` liefert für ein `<summary>` weder das |
| 20 | * `list-item` der UA-Vorlage noch das `flex` aus dem Stilblatt. Erst Chromium |
| 21 | * rechnet – deshalb steht diese Wache hier und nicht in `tests/`. |
| 22 | * |
| 23 | * ## Was gemessen wird |
| 24 | * |
| 25 | * Für jedes sichtbare `<summary>` der Ansicht: `display` muss `list-item` |
| 26 | * sein, der Markentyp darf nicht `none` sein und der Inhalt des `::marker` |
| 27 | * darf nicht abgeschaltet sein. Alle drei Wege führen sonst zum selben |
| 28 | * Ergebnis – ein Aufklapper, dem man das Aufklappen nicht ansieht. |
| 29 | */ |
| 30 | |
| 31 | import { type ElectronApplication, type Page } from '@playwright/test'; |
| 32 | |
| 33 | import { amStart, appStarten, bauPruefen } from './electron-hilfe'; |
| 34 | import { expect, test } from './konsolenwache'; |
| 35 | |
| 36 | let app: ElectronApplication | undefined; |
| 37 | let fenster: Page; |
| 38 | |
| 39 | test.beforeAll(async () => { |
| 40 | // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. |
| 41 | bauPruefen(); |
| 42 | ({ app, fenster } = await appStarten()); |
| 43 | }); |
| 44 | |
| 45 | test.afterAll(async () => { |
| 46 | await app?.close(); |
| 47 | }); |
| 48 | |
| 49 | // Electron-Fenster, ein Profil: die Ansichten werden nacheinander angefahren. |
| 50 | test.describe.configure({ mode: 'serial' }); |
| 51 | |
| 52 | /** Ein `<summary>`, dem die Aufklappmarke fehlt – samt Grund. */ |
| 53 | interface Befund { |
| 54 | readonly text: string; |
| 55 | readonly klasse: string; |
| 56 | readonly display: string; |
| 57 | readonly markentyp: string; |
| 58 | readonly markeninhalt: string; |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Alle sichtbaren `<summary>` der laufenden Ansicht ohne Aufklappmarke. |
| 63 | * |
| 64 | * Zugeklappte `<details>` zählen mit: Ihr `<summary>` steht sichtbar da, und |
| 65 | * gerade dort braucht es die Marke am dringendsten. Ausgenommen sind nur |
| 66 | * wirklich verborgene Elemente – die malt Chromium ohnehin nicht. |
| 67 | */ |
| 68 | async function ohneMarke(): Promise<Befund[]> { |
| 69 | return fenster.evaluate(() => { |
| 70 | const sichtbare = [...document.querySelectorAll('summary')].filter( |
| 71 | (summary) => |
| 72 | summary.getClientRects().length > 0 && getComputedStyle(summary).visibility !== 'hidden', |
| 73 | ); |
| 74 | |
| 75 | return sichtbare |
| 76 | .map((summary) => { |
| 77 | const stil = getComputedStyle(summary); |
| 78 | return { |
| 79 | text: summary.textContent.trim().slice(0, 60), |
| 80 | klasse: summary.getAttribute('class') ?? '(ohne Klasse)', |
| 81 | display: stil.display, |
| 82 | markentyp: stil.listStyleType, |
| 83 | markeninhalt: getComputedStyle(summary, '::marker').content, |
| 84 | }; |
| 85 | }) |
| 86 | .filter( |
| 87 | (b) => |
| 88 | /* Alle drei Wege enden beim selben Bild: kein Dreieck. */ |
| 89 | b.display !== 'list-item' || b.markentyp === 'none' || b.markeninhalt === 'none', |
| 90 | ); |
| 91 | }); |
| 92 | } |
| 93 | |
| 94 | /** Meldet die Fundstellen so, dass die Ursache ohne Nachsuchen dasteht. */ |
| 95 | async function markenPruefen(ansicht: string): Promise<void> { |
| 96 | const befunde = await ohneMarke(); |
| 97 | const liste = befunde |
| 98 | .map( |
| 99 | (b) => |
| 100 | `„${b.text}“ (${b.klasse}): display=${b.display}, ` + |
| 101 | `list-style-type=${b.markentyp}, ::marker content=${b.markeninhalt}`, |
| 102 | ) |
| 103 | .join('\n '); |
| 104 | |
| 105 | expect( |
| 106 | befunde, |
| 107 | `${ansicht}: ${String(befunde.length)} Aufklapper ohne Dreieck.\n ${liste}\n` + |
| 108 | 'Ein `display` ausser `list-item` auf einem <summary> schaltet die Marke ab.', |
| 109 | ).toEqual([]); |
| 110 | } |
| 111 | |
| 112 | /** Zurück zum Startbildschirm, egal wo der vorige Test aufgehört hat. */ |
| 113 | async function zumStart(): Promise<void> { |
| 114 | for (let i = 0; i < 4; i++) { |
| 115 | if (await amStart(fenster)) { |
| 116 | return; |
| 117 | } |
| 118 | const zurueck = fenster.getByRole('button', { name: /^(Zum Start|Sitzung beenden)$/u }); |
| 119 | if ((await zurueck.count()) === 0) { |
| 120 | return; |
| 121 | } |
| 122 | await zurueck.first().click(); |
| 123 | await fenster.waitForTimeout(250); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | test.describe('Aufklappmarke je Ansicht', () => { |
| 128 | test('Startbildschirm', async () => { |
| 129 | await zumStart(); |
| 130 | await markenPruefen('Startbildschirm'); |
| 131 | }); |
| 132 | |
| 133 | test('Lernsitzung', async () => { |
| 134 | /* Die Ansicht des ursprünglichen Befundes: Hier stehen „Tastenkürzel |
| 135 | dieser Ansicht“ und „Fehler zu dieser Frage melden“ nebeneinander. |
| 136 | Die Tastenkürzel sind ab Werk eingeschaltet – ohne Zutun sichtbar. */ |
| 137 | await zumStart(); |
| 138 | await fenster.getByRole('button', { name: /Weiterlernen/u }).click(); |
| 139 | await fenster.getByRole('heading', { level: 1, name: /, Frage \d/u }).waitFor(); |
| 140 | |
| 141 | await expect(fenster.getByText('Tastenkürzel dieser Ansicht')).toBeVisible(); |
| 142 | await markenPruefen('Lernsitzung'); |
| 143 | }); |
| 144 | |
| 145 | test('Lernsitzung nach der Antwort', async () => { |
| 146 | /* Erst nach der Antwort erscheinen „Warum das so ist“ und – bei Fragen |
| 147 | mit Bild – „Was dieses Zeichen bedeutet“. Gesucht ist irgendeine |
| 148 | Auswahlfrage: Die Sitzung wird zufällig zusammengestellt. */ |
| 149 | const auswahlfeld = fenster.getByRole('radio').or(fenster.getByRole('checkbox')); |
| 150 | |
| 151 | for (let i = 0; i < 20; i++) { |
| 152 | if ((await auswahlfeld.count()) > 0) { |
| 153 | break; |
| 154 | } |
| 155 | await fenster |
| 156 | .getByRole('button', { name: /Musterantwort anzeigen|^Antwort bestätigen$/u }) |
| 157 | .first() |
| 158 | .click(); |
| 159 | const bewerten = fenster.getByRole('button', { name: /^(Gewusst|Sicher gewusst)$/u }); |
| 160 | if ((await bewerten.count()) > 0) { |
| 161 | await bewerten.first().click(); |
| 162 | } |
| 163 | const weiter = fenster.getByRole('button', { name: /^Nächste Frage$/u }); |
| 164 | if ((await weiter.count()) === 0) { |
| 165 | break; |
| 166 | } |
| 167 | await weiter.first().click(); |
| 168 | await fenster.waitForTimeout(150); |
| 169 | } |
| 170 | |
| 171 | await auswahlfeld.first().click(); |
| 172 | await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click(); |
| 173 | await fenster.waitForTimeout(300); |
| 174 | |
| 175 | await markenPruefen('Lernsitzung nach der Antwort'); |
| 176 | }); |
| 177 | |
| 178 | test('Über diese Software', async () => { |
| 179 | /* Zweite Fundstelle desselben Fehlers: `.lizenzblock__schalter`. */ |
| 180 | await zumStart(); |
| 181 | await fenster.getByRole('button', { name: /Lizenzen und Herkunft anzeigen/u }).click(); |
| 182 | await fenster.getByRole('heading', { name: 'Über diese Software' }).waitFor(); |
| 183 | |
| 184 | await expect(fenster.locator('summary.lizenzblock__schalter').first()).toBeVisible(); |
| 185 | await markenPruefen('Über diese Software'); |
| 186 | }); |
| 187 | |
| 188 | test('Fragen durchsuchen', async () => { |
| 189 | /* Mit Trefferliste, nicht leer: Erst dann steht in jeder Trefferkarte der |
| 190 | Aufklapper „Antwortmöglichkeiten anzeigen“. */ |
| 191 | await zumStart(); |
| 192 | await fenster.getByRole('button', { name: /Fragen durchsuchen/u }).click(); |
| 193 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 194 | await fenster.getByLabel('Suchbegriff').fill('Anscheinswaffe'); |
| 195 | await fenster.waitForTimeout(400); |
| 196 | |
| 197 | await markenPruefen('Fragen durchsuchen'); |
| 198 | await zumStart(); |
| 199 | }); |
| 200 | |
| 201 | test('Startbildschirm mit Lernstand', async () => { |
| 202 | /* Die Lernstandsübersicht steht auf dem Startbildschirm, erscheint dort |
| 203 | aber erst, wenn etwas beantwortet ist – der erste Test dieser Datei |
| 204 | läuft auf einem frischen Profil und sieht sie noch nicht. Ihre beiden |
| 205 | Aufklapper („Was heißt ‚belegt‘?“, „Nach Kapiteln und Abschnitten“) |
| 206 | kommen deshalb erst hier unter die Lupe. */ |
| 207 | await zumStart(); |
| 208 | |
| 209 | await expect(fenster.getByText('Was heißt „belegt“?')).toBeVisible(); |
| 210 | await markenPruefen('Startbildschirm mit Lernstand'); |
| 211 | }); |
| 212 | }); |