waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app e2e store-bilder.spec.ts
| 1 | /** |
| 2 | * Die sieben Bildschirmfotos für den Eintrag im Microsoft Store. |
| 3 | * |
| 4 | * Ausgabe nach `docs/store-bilder/`. Welche Motive das sind und in welcher |
| 5 | * Reihenfolge, gibt `docs/store-eintrag.md` Abschnitt 7.2 vor; die |
| 6 | * Bildunterschriften stehen dort in 6.3. Diese Datei erfindet keine Motive, |
| 7 | * sie stellt genau die sieben her. |
| 8 | * |
| 9 | * ## Warum ein eigener Lauf neben `bildschirmfotos.spec.ts` |
| 10 | * |
| 11 | * Die dreizehn Aufnahmen dort sind Abnahmedokumente: Sie zeigen mit |
| 12 | * `fullPage: true` absichtlich die ganze Seite, damit nichts unbesehen |
| 13 | * bleibt. Für den Store sind genau diese beiden Eigenschaften untauglich – |
| 14 | * gemessen am Stand 0.21.0: |
| 15 | * |
| 16 | * - Alle dreizehn sind 1265 (die Handbuchaufnahme 1280) Bildpunkte breit |
| 17 | * und liegen damit unter der Store-Mindestbreite von 1366. |
| 18 | * - `01-startbildschirm-hell.png` ist 1265 × 8078, `11-glossar.png` sogar |
| 19 | * 1265 × 40364. Der Store skaliert auf seine Rahmenbreite; bei einem |
| 20 | * Verhältnis von 1 : 32 bleibt ein unlesbarer Streifen. |
| 21 | * |
| 22 | * Beide Zwecke in einer Datei zu mischen verdürbe den einen durch den |
| 23 | * anderen (`store-eintrag.md`, 7.1). Die dreizehn bleiben unberührt. |
| 24 | * |
| 25 | * ## Fenstergröße – gemessen abgewogen, nicht geraten |
| 26 | * |
| 27 | * Die Store-Mindestgröße ist 1366 × 768. Gewählt sind {@link FENSTER} |
| 28 | * CSS-Punkte, und zwar aus einer Abwägung mit einer harten Grenze in der |
| 29 | * Mitte: `.seite` in `global.css` deckelt den Inhalt auf `max-width: 62rem`, |
| 30 | * also 992 Punkte. Ein breiteres Fenster zeigt deshalb **keinen Inhalt |
| 31 | * mehr**, sondern nur mehr leeren Grund – und weil der Store das Bild auf |
| 32 | * seine Rahmenbreite herunterrechnet, wird die Schrift dabei kleiner, ohne |
| 33 | * dass etwas gewonnen wäre. Ein knapp bemessenes Fenster hält die Schrift |
| 34 | * groß, schneidet aber Motive ab, die zusammengehören. |
| 35 | * |
| 36 | * Deshalb: die Breite nur so weit über die Mindestbreite, dass die Textspalte |
| 37 | * den größten Teil des Bildes einnimmt (992 von 1440 Punkten, also 69 |
| 38 | * Prozent) und die Grenze mit Sicherheit überschritten ist. |
| 39 | * |
| 40 | * Die Höhe richtet sich nach dem höchsten der sieben Motive. Nachgemessen bei |
| 41 | * 1440 Punkten Breite, jeweils von der oberen bis zur unteren Kante des |
| 42 | * Motivs: |
| 43 | * |
| 44 | * | Motiv | gebraucht | |
| 45 | * |---|---| |
| 46 | * | 04, Profilwahl und Zeitvorgabe vollständig | 1249 | |
| 47 | * | 05, Suchfeld bis zum dritten Treffer | 1152 | |
| 48 | * | 03 und 07, Kopf bis zum fünften Weg ins Lernen | 787 | |
| 49 | * | 02, Frage samt Rückmeldung und Begründung | 730 | |
| 50 | * |
| 51 | * {@link FENSTER} setzt deshalb 1275: die 1249 des höchsten Motivs, 10 Punkte |
| 52 | * Luft darüber und 16 darunter – beide Kanten liegen damit im Zwischenraum |
| 53 | * zwischen zwei Blöcken und schneiden keinen an. Das Bild wird dadurch höher |
| 54 | * als das übliche 16 : 10, und das ist der Preis dafür, dass kein Motiv aus |
| 55 | * 6.2 angeschnitten wird: Ein Zuschnitt mitten in die Zeitstufen hinein wäre |
| 56 | * das schlechtere Bild, und ein Verkleinern der Anzeigegröße machte genau die |
| 57 | * Schrift kleiner, um die es hier geht. Alle sieben behalten dasselbe Format; |
| 58 | * ein Karussell aus unterschiedlich hohen Bildern springt. |
| 59 | * |
| 60 | * ## Warum die Aufnahme über CDP läuft und nicht über `page.screenshot()` |
| 61 | * |
| 62 | * Nachgemessen an dieser Anwendung, alles bei 1440 × 900: |
| 63 | * |
| 64 | * | Weg | devicePixelRatio | Aufnahme | |
| 65 | * |---|---|---| |
| 66 | * | `page.screenshot()` | 1 | 1440 × 900 | |
| 67 | * | `page.screenshot({ scale: 'device' })` | 1 | 1440 × 900 | |
| 68 | * | Start mit `--force-device-scale-factor=2` | 1 (wirkungslos) | 1440 × 900 | |
| 69 | * | CDP `Emulation.setDeviceMetricsOverride` = 2 | 2 | 1440 × 900 | |
| 70 | * | dazu CDP `Page.captureScreenshot` | 2 | 2880 × 1800 | |
| 71 | * |
| 72 | * Nur der letzte Weg liefert Gerätepunkte. Wichtig ist die Gegenprobe: Die |
| 73 | * CSS-Maße bleiben dabei unverändert – die `h1` misst in beiden Fällen |
| 74 | * 928 Punkte bei 38 px Schriftgröße. Der Faktor verdoppelt also die |
| 75 | * Abtastung und **nicht** die Textgröße; das Bild zeigt denselben Ausschnitt, |
| 76 | * nur feiner aufgelöst. Genau das ist im Store etwas wert, weil er ohnehin |
| 77 | * herunterrechnet. |
| 78 | * |
| 79 | * Gewählt ist trotzdem nicht 2, sondern {@link GERAETEFAKTOR} = 1,5. Der |
| 80 | * Grund ist die obere Grenze: Microsoft nennt für Bildmaterial im Store |
| 81 | * 3840 × 2160 Punkte. Mit Faktor 2 wäre die Aufnahme 2880 × 2550 und läge in |
| 82 | * der Höhe darüber; mit 1,5 sind es 2160 × 1913 – anderthalbfache Abtastung |
| 83 | * und mit Abstand innerhalb dessen, was der Store annimmt. 1,5 ist zudem eine |
| 84 | * der üblichen Windows-Skalierungen und wird von Chromium sauber gezeichnet. |
| 85 | * |
| 86 | * ## Ausschnitt statt ganzer Seite – und an Elementen festgemacht |
| 87 | * |
| 88 | * Aufgenommen wird der sichtbare Ausschnitt, so wie `ausschnittAblegen()` in |
| 89 | * `bildschirmfotos.spec.ts` (dritter Weg wäre einer zu viel). Wohin gerollt |
| 90 | * wird, entscheidet aber kein fester Pixelwert, sondern ein Element: |
| 91 | * {@link ausschnittAn} setzt eine Marke an den oberen Rand. Und was auf dem |
| 92 | * Bild zu sehen sein muss, prüft {@link imBildErwarten} nach der Aufnahme |
| 93 | * nach – verrutscht ein Motiv bei der nächsten Layoutänderung aus dem |
| 94 | * Ausschnitt, fällt dieser Lauf durch, statt ein halbes Bild abzulegen. |
| 95 | * |
| 96 | * ## Und die untere Kante gehört zwischen zwei Zeilen |
| 97 | * |
| 98 | * Ein Bild kann die Größenprüfung bestehen und trotzdem untauglich sein. Beim |
| 99 | * ersten Lauf endeten vier der sieben mitten in einer Textzeile – zu sehen war |
| 100 | * eine Reihe halber Buchstaben, quer über das Bild. Die Höhe steht fest, also |
| 101 | * regelt {@link unterkanteAufZeilengrenze} das über die Rollhöhe: Sie sucht im |
| 102 | * erlaubten Spielraum die nächste Zeilengrenze und legt die Kante dorthin. |
| 103 | * {@link keineAngeschnitteneZeile} prüft danach beide waagerechten Kanten |
| 104 | * nach, und zwar in {@link ablegen} – also ausnahmslos vor jeder Aufnahme, |
| 105 | * auch vor denen, die heute von selbst sauber enden. |
| 106 | * |
| 107 | * ## Keine stillen Wachen |
| 108 | * |
| 109 | * In `bildschirmfotos.spec.ts` blieb Aufnahme 05 zeitweise still aus, weil |
| 110 | * ihre Bedingung eine Auswahlfrage voraussetzte und manchmal eine offene kam; |
| 111 | * die veraltete Datei blieb als vermeintlich aktuelle liegen. Hier gibt es |
| 112 | * kein `if (sichtbar)` um eine Aufnahme herum. Jedes Motiv wird angesteuert, |
| 113 | * jede Voraussetzung wird zugesichert, und jede Datei wird nach dem Schreiben |
| 114 | * gegen {@link MINDESTMASS} gemessen. Ein Bild, das der Store abwiese, fällt |
| 115 | * hier auf und nicht erst beim Hochladen. |
| 116 | */ |
| 117 | import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; |
| 118 | import { join } from 'node:path'; |
| 119 | |
| 120 | import { |
| 121 | type CDPSession, |
| 122 | type ElectronApplication, |
| 123 | type Locator, |
| 124 | type Page, |
| 125 | } from '@playwright/test'; |
| 126 | |
| 127 | import { |
| 128 | animationenAbwarten, |
| 129 | appStarten, |
| 130 | bauPruefen, |
| 131 | projektWurzel, |
| 132 | themaSetzen, |
| 133 | } from './electron-hilfe'; |
| 134 | import { expect, test } from './konsolenwache'; |
| 135 | |
| 136 | const ZIEL = join(projektWurzel, '..', 'docs', 'store-bilder'); |
| 137 | |
| 138 | /** |
| 139 | * Fenstergröße in CSS-Punkten – Begründung und Messwerte im Dateikopf. |
| 140 | * |
| 141 | * Die Höhe ist am höchsten Motiv gemessen (Prüfungssimulation: 1249 Punkte |
| 142 | * von der Profilwahl bis zum Ende der Zeitvorgabe); die übrigen sechs kommen |
| 143 | * mit weniger aus und behalten dasselbe Format. |
| 144 | */ |
| 145 | const FENSTER = { breite: 1440, hoehe: 1275 } as const; |
| 146 | |
| 147 | /** |
| 148 | * Abtastung der Aufnahme; vervielfacht die Bildpunkte, nicht die Schrift. |
| 149 | * |
| 150 | * 1,5 statt 2, damit die Aufnahme mit 2160 × 1913 unter den 3840 × 2160 |
| 151 | * bleibt, die Microsoft für Bildmaterial im Store nennt. Begründung im |
| 152 | * Dateikopf. |
| 153 | */ |
| 154 | const GERAETEFAKTOR = 1.5; |
| 155 | |
| 156 | /** Was der Microsoft Store für Windows-Bildschirmfotos mindestens verlangt. */ |
| 157 | const MINDESTMASS = { breite: 1366, hoehe: 768 } as const; |
| 158 | |
| 159 | /** |
| 160 | * Toleranz beim Vergleich von Kantenlagen, in CSS-Punkten. |
| 161 | * |
| 162 | * Zeilenkästen kommen mit Nachkommastellen; ein Kasten, der rechnerisch |
| 163 | * 0,2 Punkte in die Bildkante ragt, ist im Bild nichts. Erst darüber beginnt |
| 164 | * das, was jemand als angeschnittene Schrift sieht. |
| 165 | */ |
| 166 | const ZEILENTOLERANZ = 0.5; |
| 167 | |
| 168 | /** |
| 169 | * Sicherheitsabstand, mit dem eine Bildkante neben eine Zeile gelegt wird. |
| 170 | * |
| 171 | * Die Aufnahme schneidet bei ganzen Gerätepunkten, die Zeilenkästen liegen auf |
| 172 | * Bruchteilen. Ein Punkt Abstand von der Zeilengrenze kostet nichts und hält |
| 173 | * die Kante auch nach dem Runden sicher zwischen zwei Zeilen. |
| 174 | */ |
| 175 | const KANTENPUFFER = 1; |
| 176 | |
| 177 | /** |
| 178 | * Wie weit die Aufnahme im hohen Kontrast von der hellen abweichen darf. |
| 179 | * |
| 180 | * Bild 03 und 07 sollen denselben Ausschnitt zeigen; die dickeren Rahmen des |
| 181 | * Kontrastschemas verschieben das Layout aber um ein paar Punkte. Der Wert ist |
| 182 | * gemessen: Mit 12 fand sich keine Zeilengrenze – die Überschrift, die dort |
| 183 | * hineinragt, ist rund 30 Punkte hoch, und wer sie ganz zeigen oder ganz |
| 184 | * ausblenden will, braucht die halbe Zeilenhöhe. 20 Punkte sind 1,6 Prozent |
| 185 | * der Fensterhöhe; im Vergleich der beiden Bilder ist das nichts. |
| 186 | */ |
| 187 | const FEINJUSTIERUNG = 20; |
| 188 | |
| 189 | /** |
| 190 | * Die Frage, die auf den Bildern 01 und 02 steht: 1.37 („erwerben“). |
| 191 | * |
| 192 | * Bewusst gewählt und nicht gezogen. Die Lernsitzung stellt sonst eine |
| 193 | * **zufällige** Frage vor – für ein Werbebild ist das untauglich, weil weder |
| 194 | * Länge noch Fragetyp noch Aussehen vorhersehbar sind. |
| 195 | * |
| 196 | * Warum diese und keine andere: |
| 197 | * |
| 198 | * - Kurz und in einer Zeile lesbar: 69 Zeichen, drei Antwortmöglichkeiten |
| 199 | * von je einer Zeile. Ein langer Fragetext mit sechs Optionen füllte das |
| 200 | * Bild mit Text, den im Store niemand liest. |
| 201 | * - Genau eine Antwort ist richtig – die Rückmeldung bleibt eindeutig. |
| 202 | * - Die falsche Antwort a) „Abschluss eines Kaufvertrages“ ist die |
| 203 | * naheliegende. Genau daran zeigt Bild 02, wozu die Begründung da ist. |
| 204 | * - Sie trägt die Aussage des Store-Textes: „‚Erwerben‘ hat mit Kaufen |
| 205 | * nichts zu tun.“ Die Erklärung dazu nennt zwei Fundstellen (Anlage 1 |
| 206 | * Abschnitt 2 Nr. 1 und § 20 Abs. 1 WaffG) – auch das ist im Bild zu |
| 207 | * sehen und belegt die Zusage aus der Beschreibung. |
| 208 | * - Sie zeigt kein Prüfzeichen. Bilder im Katalog sind amtliche Zeichen; |
| 209 | * sie gehören nicht auf ein Werbebild, das ohne sie auskommt. |
| 210 | * |
| 211 | * ## Wie sie angesteuert wird – und warum nicht über ihre Nummer |
| 212 | * |
| 213 | * Die Suche findet sie auch über die amtliche Nummer, und der Knopf am |
| 214 | * Treffer führte unmittelbar ins Üben. Nachgemessen sähe das Bild dann aber |
| 215 | * so aus: „Frage 1.37 · **Frage 1 von 1**“, mit vollständig gefülltem |
| 216 | * Fortschrittsbalken. Das ist eine Sitzung, die zu Ende ist, bevor sie |
| 217 | * anfängt – und die Bildunterschrift aus 6.3 („Die Zeile darüber sagt, wie |
| 218 | * viele Fragen noch kommen“) wäre daneben. |
| 219 | * |
| 220 | * Deshalb der Sammelknopf über {@link FRAGE.suchtext}: 10 Treffer, die Frage |
| 221 | * 1.37 an erster Stelle. Auf dem Bild steht dann „Suche: erwerben Schusswaffe |
| 222 | * · Frage 1 von 10“ – eine Sitzung im Gang, und nebenbei der Beleg, dass von |
| 223 | * jedem Suchtreffer ein Weg ins Üben führt. Dass 1.37 vorn steht, sichert der |
| 224 | * Test zu; ändert sich die Rangfolge, fällt er durch, statt eine andere Frage |
| 225 | * abzubilden. |
| 226 | */ |
| 227 | const FRAGE = { |
| 228 | nummer: '1.37', |
| 229 | suchtext: 'erwerben Schusswaffe', |
| 230 | treffer: 10, |
| 231 | falscheOption: 'a', |
| 232 | } as const; |
| 233 | |
| 234 | /** |
| 235 | * Der Suchbegriff für Bild 05. |
| 236 | * |
| 237 | * Verlangt sind drei sichtbare Treffer; der Test prüft das nach. „Waffenschein“ |
| 238 | * steht in 35 Fragen (nachgemessen an der Trefferzahl der Ansicht), und die |
| 239 | * ersten drei sind kurze, für sich verständliche Fragen – „Wie lange gilt der |
| 240 | * Kleine Waffenschein?“ und Ähnliches. |
| 241 | * |
| 242 | * Nicht genommen: „Notwehr“. Der erste Treffer ist dort die einzige |
| 243 | * Lückentextfrage des Katalogs, und in der Trefferliste stehen ihre Lücken |
| 244 | * naturgemäß leer: „Notwehr ist diejenige , die ist, um …“. Wer das im Store |
| 245 | * sieht, hält es für einen Textfehler der Anwendung. |
| 246 | */ |
| 247 | const SUCHBEGRIFF = 'Waffenschein'; |
| 248 | |
| 249 | /** |
| 250 | * Die sieben Dateinamen in der Reihenfolge aus Abschnitt 6.2. |
| 251 | * |
| 252 | * Die führende Nummer ist der Platz im Store: Hochgeladen wird der Reihe |
| 253 | * nach, und der Store zeigt die ersten Bilder groß. Aufgenommen werden 03 und |
| 254 | * 07 trotzdem zusammen – siehe dort. |
| 255 | */ |
| 256 | const BILDER = [ |
| 257 | '01-frage-mit-antwortmoeglichkeiten', |
| 258 | '02-rueckmeldung-mit-begruendung', |
| 259 | '03-startbildschirm', |
| 260 | '04-pruefungssimulation-vorbereiten', |
| 261 | '05-fragen-durchsuchen', |
| 262 | '06-glossar', |
| 263 | '07-startbildschirm-hoher-kontrast', |
| 264 | ] as const; |
| 265 | |
| 266 | let app: ElectronApplication; |
| 267 | let fenster: Page; |
| 268 | let cdp: CDPSession; |
| 269 | |
| 270 | /** Was am Ende als Beleg ausgegeben wird: je Bild die gemessenen Maße. */ |
| 271 | const gemessen: string[] = []; |
| 272 | |
| 273 | test.beforeAll(async () => { |
| 274 | // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. |
| 275 | bauPruefen(); |
| 276 | mkdirSync(ZIEL, { recursive: true }); |
| 277 | ({ app, fenster } = await appStarten()); |
| 278 | await fenster.setViewportSize({ width: FENSTER.breite, height: FENSTER.hoehe }); |
| 279 | |
| 280 | cdp = await app.context().newCDPSession(fenster); |
| 281 | await cdp.send('Emulation.setDeviceMetricsOverride', { |
| 282 | width: FENSTER.breite, |
| 283 | height: FENSTER.hoehe, |
| 284 | deviceScaleFactor: GERAETEFAKTOR, |
| 285 | mobile: false, |
| 286 | }); |
| 287 | |
| 288 | /* Gegenprobe zur Tabelle im Dateikopf: Die Abtastung steigt, das Layout |
| 289 | bleibt. Stimmt das nicht mehr, wäre die Begründung dort hinfällig. */ |
| 290 | const abtastung = await fenster.evaluate(() => ({ |
| 291 | verhaeltnis: window.devicePixelRatio, |
| 292 | breite: window.innerWidth, |
| 293 | hoehe: window.innerHeight, |
| 294 | })); |
| 295 | expect(abtastung.verhaeltnis).toBeCloseTo(GERAETEFAKTOR, 2); |
| 296 | expect(abtastung.breite).toBe(FENSTER.breite); |
| 297 | expect(abtastung.hoehe).toBe(FENSTER.hoehe); |
| 298 | }); |
| 299 | |
| 300 | test.afterAll(async () => { |
| 301 | /* Die Maße gehören in die Ausgabe des Laufs: Sie sind der Beleg, dass jedes |
| 302 | Bild über der Store-Mindestgröße liegt. */ |
| 303 | console.info(`\nStore-Bilder in ${ZIEL}:\n${gemessen.join('\n')}\n`); |
| 304 | await app.close(); |
| 305 | }); |
| 306 | |
| 307 | /** Breite und Höhe aus dem IHDR-Kopf einer PNG-Datei. */ |
| 308 | function pngMasse(pfad: string): { breite: number; hoehe: number } { |
| 309 | const puffer = readFileSync(pfad); |
| 310 | return { breite: puffer.readUInt32BE(16), hoehe: puffer.readUInt32BE(20) }; |
| 311 | } |
| 312 | |
| 313 | /** Ein Zeilenkasten im Fenster: die Fläche, die eine gesetzte Textzeile einnimmt. */ |
| 314 | interface Zeile { |
| 315 | readonly oben: number; |
| 316 | readonly unten: number; |
| 317 | readonly text: string; |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * Alle sichtbaren Textzeilen, in Fensterkoordinaten. |
| 322 | * |
| 323 | * Gemessen wird an Textknoten, nicht an Elementen: Ein Absatz ist ein Element, |
| 324 | * aber sieben Zeilen – und angeschnitten wird immer eine Zeile. |
| 325 | * `Range.getClientRects()` gibt genau die Zeilenkästen zurück, einen je |
| 326 | * Umbruch. Ihre Höhe ist die der Inline-Box (schriftgrößenabhängig), nicht die |
| 327 | * des Zeilenabstands; zwischen zwei Zeilen bleibt deshalb eine echte Lücke, |
| 328 | * und in die gehört eine Bildkante. |
| 329 | * |
| 330 | * Ausgelassen wird, was niemand sieht – und das ist mehr, als es zunächst |
| 331 | * scheint. Gemessen am ersten Lauf mit dieser Prüfung: Bild 01 fiel durch, |
| 332 | * weil die untere Kante angeblich „Nicht der Antrieb entscheidet, sondern Lauf |
| 333 | * und Zweckbestimmung“ teilte. Im Bild ist dort leerer Grund. Der Satz steht |
| 334 | * im zugeklappten Aufklapper „3 Fachbegriffe in dieser Frage“: Chromium legt |
| 335 | * den Inhalt eines geschlossenen `<details>` sehr wohl aus – zu sehen ist er |
| 336 | * nicht, Zeilenkästen hat er trotzdem. |
| 337 | * |
| 338 | * Ausgelassen werden deshalb: leerer Text, Kästen ohne Ausdehnung, alles unter |
| 339 | * `.nur-screenreader` (Text für Bildschirmleser, den die Anzeige aus dem Bild |
| 340 | * schiebt), alles im geschlossenen Teil eines Aufklappers, alles, was |
| 341 | * `checkVisibility` verwirft (display, visibility, Deckkraft, |
| 342 | * `content-visibility`) – und alles weit außerhalb des Fensters. |
| 343 | */ |
| 344 | async function zeilenImFenster(): Promise<Zeile[]> { |
| 345 | return fenster.evaluate(() => { |
| 346 | const RAND = 200; |
| 347 | const zeilen: { oben: number; unten: number; text: string }[] = []; |
| 348 | const lauf = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); |
| 349 | |
| 350 | for (let knoten = lauf.nextNode(); knoten !== null; knoten = lauf.nextNode()) { |
| 351 | const text = (knoten.textContent ?? '').trim(); |
| 352 | if (text === '') { |
| 353 | continue; |
| 354 | } |
| 355 | const eltern = knoten.parentElement; |
| 356 | if (eltern === null) { |
| 357 | continue; |
| 358 | } |
| 359 | if (eltern.closest('.nur-screenreader') !== null) { |
| 360 | continue; |
| 361 | } |
| 362 | if ( |
| 363 | !eltern.checkVisibility({ |
| 364 | contentVisibilityAuto: true, |
| 365 | opacityProperty: true, |
| 366 | visibilityProperty: true, |
| 367 | }) |
| 368 | ) { |
| 369 | continue; |
| 370 | } |
| 371 | /* Der zugeklappte Teil eines Aufklappers – die Zusammenfassung selbst |
| 372 | ist sichtbar und bleibt drin. */ |
| 373 | if (eltern.closest('details:not([open])') !== null && eltern.closest('summary') === null) { |
| 374 | continue; |
| 375 | } |
| 376 | |
| 377 | const bereich = document.createRange(); |
| 378 | bereich.selectNodeContents(knoten); |
| 379 | for (const kasten of Array.from(bereich.getClientRects())) { |
| 380 | if (kasten.height < 2 || kasten.width < 2) { |
| 381 | continue; |
| 382 | } |
| 383 | if (kasten.bottom < -RAND || kasten.top > window.innerHeight + RAND) { |
| 384 | continue; |
| 385 | } |
| 386 | zeilen.push({ oben: kasten.top, unten: kasten.bottom, text }); |
| 387 | } |
| 388 | } |
| 389 | return zeilen; |
| 390 | }); |
| 391 | } |
| 392 | |
| 393 | /** Die Zeilen, die eine waagerechte Kante bei `hoehe` mittendurch teilt. */ |
| 394 | function zeilenAnDerKante(zeilen: readonly Zeile[], hoehe: number): Zeile[] { |
| 395 | return zeilen.filter( |
| 396 | (zeile) => zeile.oben + ZEILENTOLERANZ < hoehe && hoehe < zeile.unten - ZEILENTOLERANZ, |
| 397 | ); |
| 398 | } |
| 399 | |
| 400 | /** Für Fehlermeldungen: der Anfang des Textes, dem die Kante die Hälfte nimmt. */ |
| 401 | function auszug(zeile: Zeile): string { |
| 402 | return zeile.text.length > 60 ? `${zeile.text.slice(0, 60)}…` : zeile.text; |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * Rollt so weit, dass die untere Bildkante keine Textzeile durchschneidet. |
| 407 | * |
| 408 | * ## Warum es diese Funktion gibt |
| 409 | * |
| 410 | * Aus einem gemessenen Befund: Beim ersten Lauf endeten vier der sieben Bilder |
| 411 | * mitten in einer Zeile – 02 in „…die Waffenbesitzkarte beantragen oder die |
| 412 | * Eintragung in eine vorhandene“, 03 in der Überschrift „Prüfungssimulation |
| 413 | * vorbereiten“, 05 in „Antwortmöglichkeiten anzeigen (3)“, 06 in „sich |
| 414 | * nehmen.“ Zu sehen war jedes Mal eine Reihe halber Buchstaben. Die |
| 415 | * Größenprüfung bestanden alle vier; im Store wäre das schlimmer als kein |
| 416 | * Bild. |
| 417 | * |
| 418 | * Die Bildhöhe steht fest – alle sieben behalten dasselbe Format –, also ist |
| 419 | * die einzige Stellschraube, wo gerollt wird. {@link ausschnittAn} richtet die |
| 420 | * **obere** Kante an einem Element aus; diese Funktion legt danach die |
| 421 | * **untere** in die Lücke zwischen zwei Zeilen. |
| 422 | * |
| 423 | * Gewählt wird die kleinste Verschiebung, die das leistet, und nur innerhalb |
| 424 | * des angegebenen Spielraums: Was oben aus dem Bild rutschen darf, weiß nur |
| 425 | * das Motiv. Findet sich im Spielraum keine Zeilengrenze, fällt der Lauf durch |
| 426 | * – lieber kein Bild als ein halbes. |
| 427 | * |
| 428 | * @param spielraum `runter` verschiebt den Ausschnitt nach unten (oben geht |
| 429 | * Luft verloren), `hoch` nach oben (unten geht Inhalt verloren). Beides in |
| 430 | * CSS-Punkten. |
| 431 | * @returns die angewandte Verschiebung – für Bild 07, das dieselbe braucht wie |
| 432 | * Bild 03. |
| 433 | */ |
| 434 | async function unterkanteAufZeilengrenze(spielraum: { |
| 435 | runter: number; |
| 436 | hoch?: number; |
| 437 | }): Promise<number> { |
| 438 | const hoch = spielraum.hoch ?? 0; |
| 439 | const zeilen = await zeilenImFenster(); |
| 440 | const getroffen = zeilenAnDerKante(zeilen, FENSTER.hoehe)[0]; |
| 441 | if (getroffen === undefined) { |
| 442 | return 0; |
| 443 | } |
| 444 | |
| 445 | /* Die Kandidaten sind die Zeilengrenzen selbst: knapp unter eine Zeile |
| 446 | (dann steht sie ganz im Bild) oder knapp über sie (dann ist sie ganz |
| 447 | draußen). Genommen wird die nächstgelegene, die keine andere Zeile |
| 448 | anschneidet – Zeilen mehrerer Spalten liegen nicht auf einer Höhe. */ |
| 449 | const kandidaten = zeilen |
| 450 | .flatMap((zeile) => [zeile.unten + KANTENPUFFER, zeile.oben - KANTENPUFFER]) |
| 451 | .map((grenze) => grenze - FENSTER.hoehe) |
| 452 | .filter((versatz) => versatz >= -hoch && versatz <= spielraum.runter) |
| 453 | .filter((versatz) => zeilenAnDerKante(zeilen, FENSTER.hoehe + versatz).length === 0) |
| 454 | .sort((a, b) => Math.abs(a) - Math.abs(b)); |
| 455 | |
| 456 | const versatz = kandidaten[0]; |
| 457 | expect( |
| 458 | versatz, |
| 459 | `Die untere Bildkante teilt „${auszug(getroffen)}“. Im Spielraum von ${String(hoch)} Punkten nach oben und ${String(spielraum.runter)} nach unten liegt keine Zeilengrenze – der Ausschnitt braucht eine andere Marke.`, |
| 460 | ).toBeDefined(); |
| 461 | if (versatz === undefined) { |
| 462 | return 0; |
| 463 | } |
| 464 | |
| 465 | await fensterRollen(versatz); |
| 466 | return versatz; |
| 467 | } |
| 468 | |
| 469 | /** Verschiebt den Ausschnitt um `versatz` Punkte nach unten (negativ: nach oben). */ |
| 470 | async function fensterRollen(versatz: number): Promise<void> { |
| 471 | await fenster.evaluate((punkte) => { |
| 472 | window.scrollBy(0, punkte); |
| 473 | }, versatz); |
| 474 | await animationenAbwarten(fenster); |
| 475 | } |
| 476 | |
| 477 | /** |
| 478 | * Sichert zu, dass keine Bildkante mitten durch eine Zeile geht. |
| 479 | * |
| 480 | * Läuft in {@link ablegen} und damit ausnahmslos vor jeder der sieben |
| 481 | * Aufnahmen. Geprüft werden beide waagerechten Kanten; die senkrechten liegen |
| 482 | * links und rechts im leeren Grund, weil der Inhalt auf 62rem gedeckelt ist. |
| 483 | */ |
| 484 | async function keineAngeschnitteneZeile(name: string): Promise<void> { |
| 485 | const zeilen = await zeilenImFenster(); |
| 486 | for (const [kante, wo] of [ |
| 487 | [0, 'obere'], |
| 488 | [FENSTER.hoehe, 'untere'], |
| 489 | ] as const) { |
| 490 | const getroffen = zeilenAnDerKante(zeilen, kante); |
| 491 | expect( |
| 492 | getroffen.map(auszug), |
| 493 | `${name}.png: Die ${wo} Bildkante teilt diese Zeile mittendurch – im Store steht dort eine Reihe halber Buchstaben.`, |
| 494 | ).toEqual([]); |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | /** |
| 499 | * Nimmt den sichtbaren Ausschnitt auf und misst das Ergebnis nach. |
| 500 | * |
| 501 | * Die Messung ist kein Beiwerk: Ein Bild unter der Mindestgröße weist der |
| 502 | * Store beim Hochladen ab – und das fiele sonst erst dort auf. |
| 503 | */ |
| 504 | async function ablegen(name: string): Promise<void> { |
| 505 | await animationenAbwarten(fenster); |
| 506 | await keineAngeschnitteneZeile(name); |
| 507 | |
| 508 | const antwort = await cdp.send('Page.captureScreenshot', { |
| 509 | format: 'png', |
| 510 | captureBeyondViewport: false, |
| 511 | }); |
| 512 | const pfad = join(ZIEL, `${name}.png`); |
| 513 | writeFileSync(pfad, Buffer.from(antwort.data, 'base64')); |
| 514 | |
| 515 | const masse = pngMasse(pfad); |
| 516 | expect( |
| 517 | masse.breite, |
| 518 | `${name}.png ist ${String(masse.breite)} Punkte breit – der Store verlangt mindestens ${String(MINDESTMASS.breite)}.`, |
| 519 | ).toBeGreaterThanOrEqual(MINDESTMASS.breite); |
| 520 | expect( |
| 521 | masse.hoehe, |
| 522 | `${name}.png ist ${String(masse.hoehe)} Punkte hoch – der Store verlangt mindestens ${String(MINDESTMASS.hoehe)}.`, |
| 523 | ).toBeGreaterThanOrEqual(MINDESTMASS.hoehe); |
| 524 | |
| 525 | gemessen.push(`${name}.png ${String(masse.breite)} × ${String(masse.hoehe)}`); |
| 526 | } |
| 527 | |
| 528 | /** An den Anfang der Seite rollen – für die Motive, die oben beginnen. */ |
| 529 | async function nachOben(): Promise<void> { |
| 530 | await fenster.evaluate(() => { |
| 531 | window.scrollTo(0, 0); |
| 532 | }); |
| 533 | await animationenAbwarten(fenster); |
| 534 | } |
| 535 | |
| 536 | /** |
| 537 | * Rollt so, dass `marke` mit `abstand` Punkten Luft oben im Bild steht. |
| 538 | * |
| 539 | * An einem Element festgemacht statt an einer Pixelzahl: Wächst der Kopf der |
| 540 | * Ansicht um eine Zeile, wandert der Ausschnitt mit, statt zu verrutschen. |
| 541 | */ |
| 542 | async function ausschnittAn(marke: Locator, abstand = 24): Promise<void> { |
| 543 | await marke.scrollIntoViewIfNeeded(); |
| 544 | await marke.evaluate((element, luft) => { |
| 545 | const ziel = element.getBoundingClientRect().top + window.scrollY - luft; |
| 546 | window.scrollTo(0, Math.max(0, ziel)); |
| 547 | }, abstand); |
| 548 | await animationenAbwarten(fenster); |
| 549 | } |
| 550 | |
| 551 | /** |
| 552 | * Sichert zu, dass etwas vollständig im aufgenommenen Ausschnitt liegt. |
| 553 | * |
| 554 | * `boundingBox()` rechnet in Fensterkoordinaten, also nach dem Rollen. Was |
| 555 | * hier durchfällt, ist genau der Fall, der ein halbes Werbebild ergäbe. |
| 556 | */ |
| 557 | async function imBildErwarten(marke: Locator, was: string): Promise<void> { |
| 558 | await expect(marke, `${was}: nicht sichtbar`).toBeVisible(); |
| 559 | const kasten = await marke.boundingBox(); |
| 560 | expect(kasten, `${was}: keine Ausdehnung messbar`).not.toBeNull(); |
| 561 | if (kasten === null) { |
| 562 | return; |
| 563 | } |
| 564 | expect( |
| 565 | Math.round(kasten.y), |
| 566 | `${was} beginnt bei ${String(Math.round(kasten.y))} px und steht damit über dem Bildausschnitt.`, |
| 567 | ).toBeGreaterThanOrEqual(0); |
| 568 | expect( |
| 569 | Math.round(kasten.y + kasten.height), |
| 570 | `${was} endet bei ${String(Math.round(kasten.y + kasten.height))} px, das Bild ist ${String(FENSTER.hoehe)} px hoch.`, |
| 571 | ).toBeLessThanOrEqual(FENSTER.hoehe); |
| 572 | } |
| 573 | |
| 574 | /** |
| 575 | * Sichert zu, dass ein Nachbarblock ganz **über** dem Ausschnitt bleibt. |
| 576 | * |
| 577 | * Der Gegenpart zu {@link imBildErwarten}, und aus einem gemessenen Anlass: |
| 578 | * Beim ersten Lauf lag die obere Bildkante vier Punkte im Hinweiskasten über |
| 579 | * den Profilen. Zu sehen war davon ein oranger Strich – nichts, was jemand |
| 580 | * lesen kann, aber genug, damit das Bild aussieht wie versehentlich |
| 581 | * beschnitten. Die Kante gehört in den Zwischenraum zweier Blöcke, und das |
| 582 | * ist prüfbar. |
| 583 | */ |
| 584 | async function ueberDemBildErwarten(marke: Locator, was: string): Promise<void> { |
| 585 | const kasten = await marke.boundingBox(); |
| 586 | expect(kasten, `${was}: keine Ausdehnung messbar`).not.toBeNull(); |
| 587 | if (kasten === null) { |
| 588 | return; |
| 589 | } |
| 590 | expect( |
| 591 | Math.round(kasten.y + kasten.height), |
| 592 | `${was} ragt bis ${String(Math.round(kasten.y + kasten.height))} px ins Bild und wird dort angeschnitten.`, |
| 593 | ).toBeLessThanOrEqual(0); |
| 594 | } |
| 595 | |
| 596 | /** Zurück zum Startbildschirm – aus jeder der hier besuchten Ansichten. */ |
| 597 | async function zumStart(): Promise<void> { |
| 598 | const start = fenster.getByRole('heading', { name: 'Heute lernen', exact: true }); |
| 599 | const wege = [/sitzung beenden/iu, /zum start/iu]; |
| 600 | |
| 601 | for (let versuch = 0; versuch < 4; versuch++) { |
| 602 | if (await start.isVisible().catch(() => false)) { |
| 603 | await nachOben(); |
| 604 | return; |
| 605 | } |
| 606 | for (const weg of wege) { |
| 607 | const knopf = fenster.getByRole('button', { name: weg }).first(); |
| 608 | if (await knopf.isVisible().catch(() => false)) { |
| 609 | await knopf.click(); |
| 610 | await fenster.waitForTimeout(300); |
| 611 | break; |
| 612 | } |
| 613 | } |
| 614 | } |
| 615 | await start.waitFor({ timeout: 5000 }); |
| 616 | await nachOben(); |
| 617 | } |
| 618 | |
| 619 | /** |
| 620 | * Startet über die Suche die Sitzung, die auf den Bildern 01 und 02 steht. |
| 621 | * |
| 622 | * Ohne Absicherung mit `if`: Findet die Suche die Frage nicht oder steht sie |
| 623 | * nicht mehr an erster Stelle, ist das ein Befund und kein Grund, still ein |
| 624 | * altes Bild liegen zu lassen. |
| 625 | */ |
| 626 | async function sitzungAusSucheStarten(): Promise<void> { |
| 627 | await zumStart(); |
| 628 | await fenster |
| 629 | .getByRole('button', { name: /Fragen durchsuchen/iu }) |
| 630 | .first() |
| 631 | .click(); |
| 632 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 633 | await fenster.getByLabel('Suchbegriff').fill(FRAGE.suchtext); |
| 634 | await fenster.waitForTimeout(600); |
| 635 | |
| 636 | const ersterTreffer = fenster.locator('.suche__treffer > li').first(); |
| 637 | await expect( |
| 638 | ersterTreffer.locator('.treffer__einordnung'), |
| 639 | `Frage ${FRAGE.nummer} steht nicht mehr an erster Stelle der Treffer zu „${FRAGE.suchtext}“.`, |
| 640 | ).toContainText(`Frage ${FRAGE.nummer}`); |
| 641 | |
| 642 | await fenster |
| 643 | .getByRole('button', { name: `Alle ${String(FRAGE.treffer)} Fragen üben`, exact: true }) |
| 644 | .click(); |
| 645 | await fenster.waitForTimeout(700); |
| 646 | |
| 647 | await expect( |
| 648 | fenster.locator('.frage__nummer'), |
| 649 | 'Die Sitzung zeigt eine andere Frage als bestellt.', |
| 650 | ).toHaveText(`Frage ${FRAGE.nummer}`); |
| 651 | await expect( |
| 652 | fenster.locator('.fortschritt__text'), |
| 653 | 'Die Fortschrittszeile nennt nicht die erwartete Sitzung.', |
| 654 | ).toHaveText(`Suche: ${FRAGE.suchtext} · Frage 1 von ${String(FRAGE.treffer)}`); |
| 655 | } |
| 656 | |
| 657 | /** |
| 658 | * Beantwortet Fragen einer laufenden Sitzung, bis `anzahl` erreicht ist. |
| 659 | * |
| 660 | * Für den Lernstand hinter Bild 03 und 07 (siehe dort). Beide Fragearten |
| 661 | * werden bedient; endet die Sitzung vorher, wirft die Schleife – eine halb |
| 662 | * gefüllte Auskunft wäre schlechter als ein roter Lauf. |
| 663 | * |
| 664 | * `merken` legt die ersten Fragen zusätzlich auf die Merkliste. Grund ist der |
| 665 | * Startbildschirm: Ohne sie steht dort „Ihre Merkliste: 0 Fragen“, und eine |
| 666 | * Null wirbt für nichts. Zwei und nicht eine, weil die Beschriftung fest |
| 667 | * „Fragen“ sagt („1 Fragen“ wäre auf dem Werbebild ein Grammatikfehler). |
| 668 | */ |
| 669 | async function fragenBeantworten(anzahl: number, merken = 0): Promise<void> { |
| 670 | for (let n = 0; n < anzahl; n++) { |
| 671 | if (n < merken) { |
| 672 | await fenster.getByRole('button', { name: /Frage merken/u }).click(); |
| 673 | } |
| 674 | |
| 675 | const auswahlfeld = fenster.getByRole('radio').or(fenster.getByRole('checkbox')); |
| 676 | if ((await auswahlfeld.count()) > 0) { |
| 677 | await auswahlfeld.first().check(); |
| 678 | } |
| 679 | |
| 680 | await fenster |
| 681 | .getByRole('button', { name: /^(Antwort bestätigen|Musterantwort anzeigen)$/u }) |
| 682 | .first() |
| 683 | .click(); |
| 684 | await fenster.waitForTimeout(200); |
| 685 | |
| 686 | // Offene Fragen wollen zuerst die Selbstbewertung. |
| 687 | const bewertung = fenster.getByRole('button', { name: 'Gewusst', exact: true }); |
| 688 | if ((await bewertung.count()) > 0) { |
| 689 | await bewertung.first().click(); |
| 690 | await fenster.waitForTimeout(200); |
| 691 | } |
| 692 | |
| 693 | const weiter = fenster.getByRole('button', { name: /^(Nächste Frage|Sitzung auswerten)$/u }); |
| 694 | await expect( |
| 695 | weiter, |
| 696 | `Nach Frage ${String(n + 1)} steht kein Weiterknopf – die Sitzung ist unerwartet zu Ende.`, |
| 697 | ).toHaveCount(1); |
| 698 | await weiter.first().click(); |
| 699 | await fenster.waitForTimeout(250); |
| 700 | } |
| 701 | } |
| 702 | |
| 703 | test('01 und 02: eine Frage und die Rückmeldung mit Begründung', async () => { |
| 704 | await sitzungAusSucheStarten(); |
| 705 | |
| 706 | const frage = fenster.locator('.frage__text'); |
| 707 | const optionen = fenster.locator('.antwortoption'); |
| 708 | await expect( |
| 709 | optionen, |
| 710 | 'Die Frage hat nicht die erwarteten drei Antwortmöglichkeiten.', |
| 711 | ).toHaveCount(3); |
| 712 | |
| 713 | await nachOben(); |
| 714 | await imBildErwarten(fenster.locator('.fortschritt'), 'Die Fortschrittszeile'); |
| 715 | await imBildErwarten(frage, 'Der Fragetext'); |
| 716 | await imBildErwarten(optionen.last(), 'Die letzte Antwortmöglichkeit'); |
| 717 | await imBildErwarten( |
| 718 | fenster.getByRole('button', { name: 'Antwort bestätigen' }), |
| 719 | 'Die Schaltfläche „Antwort bestätigen“', |
| 720 | ); |
| 721 | await ablegen('01-frage-mit-antwortmoeglichkeiten'); |
| 722 | |
| 723 | /* Bewusst die falsche Antwort: Bild 02 soll die Begründung zeigen, und die |
| 724 | steht nur bei einer falschen Antwort von selbst offen (Lernsitzung.tsx, |
| 725 | `offen={ergebnis.art !== 'richtig'}`). */ |
| 726 | await fenster.getByRole('radio', { name: new RegExp(`^${FRAGE.falscheOption}\\)`, 'u') }).check(); |
| 727 | await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click(); |
| 728 | await fenster.waitForTimeout(500); |
| 729 | |
| 730 | const ergebnis = fenster.locator('.ergebnis__titel'); |
| 731 | await expect(ergebnis, 'Die Rückmeldung meldet nicht „Falsch“.').toHaveText(/Falsch/u); |
| 732 | |
| 733 | const begruendung = fenster.getByRole('heading', { name: 'Warum das so ist' }); |
| 734 | const kurzfassung = fenster.locator('.erklaerung__kurz'); |
| 735 | |
| 736 | /* |
| 737 | Bild 02 beginnt eine Zeile tiefer als Bild 01: nicht an der |
| 738 | Fortschrittszeile, sondern am Kopf der Frage. Zwei gemessene Gründe. |
| 739 | |
| 740 | Erstens der Platz. Die Begründung ist der Grund für dieses Bild, und die |
| 741 | Fortschrittszeile kostet 48 Punkte, die ihr fehlen. Ihre Bildunterschrift |
| 742 | (6.3, `05-lernsitzung-feedback`) nennt sie auch gar nicht – sie spricht von |
| 743 | der Kennzeichnung der Antworten und von der Begründung darunter. |
| 744 | |
| 745 | Zweitens ein Widerspruch, der sonst im Bild steht: Auf Bild 01 heißt es |
| 746 | „Frage 1 von 10“, nach der falschen Antwort „Frage 1 von 11“ – die Frage |
| 747 | wird in derselben Sitzung wieder eingereiht (`useSitzung`, |
| 748 | `MAX_WIEDEREINREIHUNGEN`). Inhaltlich richtig, im unmittelbaren Vergleich |
| 749 | zweier Werbebilder aber ein Zahlensprung, den jeder sieht und niemand |
| 750 | erklärt bekommt. |
| 751 | */ |
| 752 | await ausschnittAn(fenster.locator('.frage__kopf'), 28); |
| 753 | await unterkanteAufZeilengrenze({ runter: 45, hoch: 12 }); |
| 754 | await ueberDemBildErwarten(fenster.locator('.fortschritt'), 'Die Fortschrittszeile'); |
| 755 | await imBildErwarten(frage, 'Der Fragetext'); |
| 756 | await imBildErwarten(optionen.last(), 'Die letzte Antwortmöglichkeit mit ihrer Kennzeichnung'); |
| 757 | await imBildErwarten(ergebnis, 'Die Überschrift der Rückmeldung'); |
| 758 | await imBildErwarten(begruendung, 'Der Kasten „Warum das so ist“'); |
| 759 | await imBildErwarten(kurzfassung, 'Die Kurzfassung der Begründung'); |
| 760 | await ablegen('02-rueckmeldung-mit-begruendung'); |
| 761 | }); |
| 762 | |
| 763 | test('Lernstand für den Startbildschirm', async () => { |
| 764 | /* |
| 765 | Ein frischer Lernstand zeigt auf dem Startbildschirm nur Nullen: „Heute 20 |
| 766 | Fragen, etwa 15 Minuten“ und sonst nichts Gelebtes. Mit ein paar |
| 767 | beantworteten Fragen nennt die Einstiegskarte stattdessen, was heute schon |
| 768 | geschehen ist und was offen blieb (`Einstieg.tsx`, `pensumSatz`) – das ist |
| 769 | dieselbe Ansicht, nur in Benutzung. |
| 770 | Die Prüfungsreife bleibt dabei bei null, und das ist Absicht: Sie zählt |
| 771 | eine Frage erst, wenn sie nach mindestens einem Tag Abstand noch einmal |
| 772 | richtig beantwortet wurde (`shared/reife.ts`). Ein Lauf an einem Tag kann |
| 773 | das nicht herstellen, und ein vorgerückter Lernstand wäre für ein Werbebild |
| 774 | eine Erfindung. Der Satz, der stattdessen dasteht – „Wiedererkennen ist |
| 775 | kein Erinnern“ – ist genau der, mit dem die Store-Beschreibung wirbt. |
| 776 | */ |
| 777 | await zumStart(); |
| 778 | await fenster |
| 779 | .getByRole('button', { name: /Weiterlernen/iu }) |
| 780 | .first() |
| 781 | .click(); |
| 782 | await fenster.waitForTimeout(700); |
| 783 | await fragenBeantworten(8, 2); |
| 784 | await zumStart(); |
| 785 | |
| 786 | const pensum = fenster.locator('.einstieg__pensum'); |
| 787 | await expect(pensum, 'Die Einstiegskarte nennt die geleistete Arbeit nicht.').toHaveText( |
| 788 | /bearbeitet/u, |
| 789 | ); |
| 790 | await expect( |
| 791 | fenster.getByRole('button', { name: /Gemerkte Fragen/u }).first(), |
| 792 | 'Die Merkliste ist leer geblieben.', |
| 793 | ).toContainText('2 Fragen'); |
| 794 | console.info(`Einstiegskarte: ${await pensum.innerText()}`); |
| 795 | }); |
| 796 | |
| 797 | /** Was auf beiden Startbildschirm-Aufnahmen zu sehen sein muss. */ |
| 798 | async function startbildschirmPruefen(): Promise<void> { |
| 799 | await imBildErwarten( |
| 800 | fenster.getByRole('heading', { name: 'Waffensachkunde – Lernsoftware', level: 1 }), |
| 801 | 'Der Titel der Anwendung', |
| 802 | ); |
| 803 | await imBildErwarten( |
| 804 | fenster.getByRole('heading', { name: 'Heute lernen', exact: true }), |
| 805 | 'Die Überschrift „Heute lernen“', |
| 806 | ); |
| 807 | await imBildErwarten(fenster.locator('.einstieg .reifeampel'), 'Die Prüfungsreife-Anzeige'); |
| 808 | await imBildErwarten(fenster.locator('.einstieg__pensum'), 'Das Tagespensum'); |
| 809 | await imBildErwarten( |
| 810 | fenster.getByRole('button', { name: /Offene Fragen/u }).first(), |
| 811 | 'Der fünfte Weg ins Lernen', |
| 812 | ); |
| 813 | } |
| 814 | |
| 815 | test('03 und 07: der Startbildschirm hell und im hohen Kontrast', async () => { |
| 816 | /* |
| 817 | Beide Aufnahmen unmittelbar nacheinander, obwohl sie im Store an Platz 3 |
| 818 | und 7 stehen. Der Grund steht in `store-eintrag.md` 7.2: Platz 7 trägt nur, |
| 819 | wenn er dieselbe Ansicht zeigt wie Platz 3. „Dieselbe“ heißt auch dieselben |
| 820 | Zahlen – und die Zeile „offen sind noch … zur Wiederholung“ rechnet gegen |
| 821 | die Uhr. Nachgemessen: Zwischen den beiden Aufnahmen lagen zuvor drei |
| 822 | andere Ansichten, und die Zeile nannte einmal 8 und einmal 5. Zwei Bilder, |
| 823 | die denselben Bildschirm zeigen sollen und sich in einer Zahl |
| 824 | unterscheiden, sind ein Fehler, den niemand sucht und jeder sieht. |
| 825 | */ |
| 826 | await zumStart(); |
| 827 | await nachOben(); |
| 828 | /* |
| 829 | Der Kopf der Seite gibt die obere Kante vor, die untere darf sich bewegen: |
| 830 | Über der Überschrift stehen 54 Punkte Luft, davon sind 40 entbehrlich. Ohne |
| 831 | diesen Spielraum endete das Bild in der halben Überschrift |
| 832 | „Prüfungssimulation vorbereiten“ des nächsten Blocks; mit ihm rückt die |
| 833 | Kante darunter, und die Überschrift steht ganz im Bild (gemessen genügten |
| 834 | dafür 10 Punkte). |
| 835 | */ |
| 836 | const versatz = await unterkanteAufZeilengrenze({ runter: 40 }); |
| 837 | await startbildschirmPruefen(); |
| 838 | const pensumHell = await fenster.locator('.einstieg__pensum').innerText(); |
| 839 | await ablegen('03-startbildschirm'); |
| 840 | |
| 841 | await themaSetzen(fenster, 'hochkontrast'); |
| 842 | await nachOben(); |
| 843 | /* |
| 844 | Derselbe Versatz, nicht ein neu gesuchter: „dieselbe Ansicht“ heißt auch |
| 845 | derselbe Ausschnitt. Im hohen Kontrast sind die Rahmen aber dicker, und die |
| 846 | Zeile, die im hellen Schema gerade unter der Kante lag, rutscht dabei |
| 847 | hinein – gemessen fiel `07` mit „Prüfungssimulation vorbereiten“ durch. |
| 848 | Deshalb danach eine Feinjustierung mit engem Spielraum: höchstens |
| 849 | {@link FEINJUSTIERUNG} Punkte, gemessen genügten 14. Ein Unterschied von |
| 850 | gut einem Prozent der Bildhöhe, den im Vergleich niemand sieht – anders als |
| 851 | eine Reihe halber Buchstaben. |
| 852 | */ |
| 853 | await fensterRollen(versatz); |
| 854 | const nachjustiert = await unterkanteAufZeilengrenze({ |
| 855 | runter: FEINJUSTIERUNG, |
| 856 | hoch: FEINJUSTIERUNG, |
| 857 | }); |
| 858 | console.info( |
| 859 | `Startbildschirm: Versatz ${String(Math.round(versatz))} Punkte, im hohen Kontrast nachjustiert um ${String(Math.round(nachjustiert))}.`, |
| 860 | ); |
| 861 | await startbildschirmPruefen(); |
| 862 | await expect( |
| 863 | fenster.locator('.einstieg__pensum'), |
| 864 | 'Die beiden Startbildschirme nennen verschiedene Zahlen und taugen nicht zum Vergleich.', |
| 865 | ).toHaveText(pensumHell); |
| 866 | await ablegen('07-startbildschirm-hoher-kontrast'); |
| 867 | |
| 868 | // Das helle Schema zurücksetzen – die übrigen Motive sind hell. |
| 869 | await themaSetzen(fenster, 'hell'); |
| 870 | await nachOben(); |
| 871 | }); |
| 872 | |
| 873 | test('04: die Prüfungssimulation vorbereiten', async () => { |
| 874 | await zumStart(); |
| 875 | await fenster |
| 876 | .getByRole('button', { name: /Prüfungssimulation vorbereiten/iu }) |
| 877 | .first() |
| 878 | .click(); |
| 879 | await fenster |
| 880 | .getByRole('heading', { name: 'Prüfungssimulation vorbereiten', level: 1 }) |
| 881 | .waitFor(); |
| 882 | |
| 883 | const profile = fenster.locator('.profilwahl__option'); |
| 884 | await expect(profile, 'Es stehen nicht die erwarteten fünf Profile zur Wahl.').toHaveCount(5); |
| 885 | const zeitstufen = fenster.locator('.zeitwahl__option'); |
| 886 | await expect(zeitstufen, 'Es stehen nicht die erwarteten vier Zeitstufen zur Wahl.').toHaveCount( |
| 887 | 4, |
| 888 | ); |
| 889 | |
| 890 | /* |
| 891 | Zehn Punkte Luft, nicht die üblichen 24: Zwischen dem Hinweiskasten und |
| 892 | der Profilwahl liegen nachgemessen 20 Punkte. Mit 24 läge die Bildkante |
| 893 | im Kasten, und oben stünde ein angeschnittener oranger Streifen. |
| 894 | */ |
| 895 | await ausschnittAn(fenster.locator('.profilwahl'), 10); |
| 896 | await ueberDemBildErwarten(fenster.locator('.hinweis').first(), 'Der Hinweis über den Profilen'); |
| 897 | await imBildErwarten(fenster.locator('.profilwahl'), 'Die Wahl des Prüfungsprofils'); |
| 898 | await imBildErwarten(fenster.locator('.zeitwahl'), 'Die Wahl der Zeitvorgabe'); |
| 899 | await ablegen('04-pruefungssimulation-vorbereiten'); |
| 900 | |
| 901 | await zumStart(); |
| 902 | }); |
| 903 | |
| 904 | test('05: Fragen durchsuchen mit Treffern', async () => { |
| 905 | await zumStart(); |
| 906 | await fenster |
| 907 | .getByRole('button', { name: /Fragen durchsuchen/iu }) |
| 908 | .first() |
| 909 | .click(); |
| 910 | await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); |
| 911 | await fenster.getByLabel('Suchbegriff').fill(SUCHBEGRIFF); |
| 912 | await fenster.waitForTimeout(600); |
| 913 | |
| 914 | /* Kein Nummerntreffer bei diesem Wort – sonst stünde über der Liste eine |
| 915 | zweite `ol` mit derselben Klasse, und „der dritte Treffer“ meinte etwas |
| 916 | anderes als das, was das Motiv verlangt. */ |
| 917 | await expect( |
| 918 | fenster.locator('.suche__nummern'), |
| 919 | `„${SUCHBEGRIFF}“ wird zusätzlich als Fragennummer gelesen – das Motiv wäre ein anderes.`, |
| 920 | ).toHaveCount(0); |
| 921 | |
| 922 | const treffer = fenster.locator('.suche__treffer > li'); |
| 923 | const anzahl = await treffer.count(); |
| 924 | expect( |
| 925 | anzahl, |
| 926 | `„${SUCHBEGRIFF}“ liefert nur ${String(anzahl)} Treffer; das Motiv verlangt mindestens drei.`, |
| 927 | ).toBeGreaterThanOrEqual(3); |
| 928 | |
| 929 | await ausschnittAn(fenster.locator('.suche__eingabe')); |
| 930 | /* |
| 931 | Der dritte Treffer passt nicht ganz ins Bild – seine Karte ist 227 Punkte |
| 932 | hoch, und ab dem Suchfeld gerechnet fehlen rund 100. Das Motiv aus 6.2 |
| 933 | verlangt „Suchfeld bis zum dritten Treffer“, und genau so steht es da: Der |
| 934 | dritte zeigt Nummer, Kapitel und Fragetext, dann endet das Bild. Was es |
| 935 | nicht tun darf, ist mitten in einer Zeile zu enden – beim ersten Lauf war |
| 936 | das „Antwortmöglichkeiten anzeigen (3)“. |
| 937 | */ |
| 938 | await unterkanteAufZeilengrenze({ runter: 30 }); |
| 939 | await imBildErwarten(fenster.getByLabel('Suchbegriff'), 'Das Suchfeld'); |
| 940 | await imBildErwarten(fenster.locator('.suche__anzahl'), 'Die Trefferzahl'); |
| 941 | await imBildErwarten(treffer.nth(2).locator('.treffer__frage'), 'Der dritte Treffer'); |
| 942 | await ablegen('05-fragen-durchsuchen'); |
| 943 | |
| 944 | await zumStart(); |
| 945 | }); |
| 946 | |
| 947 | test('06: das Glossar', async () => { |
| 948 | await zumStart(); |
| 949 | await fenster |
| 950 | .getByRole('button', { name: /Fachbegriffe nachschlagen/iu }) |
| 951 | .first() |
| 952 | .click(); |
| 953 | await fenster.getByRole('heading', { name: 'Glossar', level: 1 }).waitFor(); |
| 954 | |
| 955 | await nachOben(); |
| 956 | /* Über dem Knopf „Zum Start“ stehen 49 Punkte Luft; 30 davon sind der |
| 957 | Spielraum, mit dem die untere Kante aus dem zweiten Glossareintrag |
| 958 | herauskommt (beim ersten Lauf endete das Bild in „sich nehmen.“). */ |
| 959 | await unterkanteAufZeilengrenze({ runter: 30 }); |
| 960 | await imBildErwarten(fenster.getByLabel('Begriff suchen'), 'Das Suchfeld des Glossars'); |
| 961 | await imBildErwarten(fenster.locator('.glossar__anzahl'), 'Die Zahl der Einträge'); |
| 962 | await imBildErwarten( |
| 963 | fenster.getByRole('navigation', { name: 'Anfangsbuchstaben' }), |
| 964 | 'Die A-bis-Z-Leiste', |
| 965 | ); |
| 966 | await imBildErwarten( |
| 967 | fenster.locator('.glossar__liste').getByRole('heading', { level: 2 }).first(), |
| 968 | 'Der erste Glossareintrag', |
| 969 | ); |
| 970 | await ablegen('06-glossar'); |
| 971 | |
| 972 | await zumStart(); |
| 973 | }); |
| 974 | |
| 975 | test('Alle sieben Bilder sind entstanden', () => { |
| 976 | /* Der Abschluss über den ganzen Lauf: Sieben Motive nennt Abschnitt 6.2, |
| 977 | sieben Dateien müssen es sein. Bliebe eines aus – etwa weil eine Aufnahme |
| 978 | still übersprungen wurde –, fiele das sonst erst beim Hochladen auf. |
| 979 | Geprüft wird gegen die Namen, nicht gegen die Anzahl: „welches fehlt“ ist |
| 980 | die Auskunft, die weiterhilft. */ |
| 981 | const fehlend = BILDER.filter( |
| 982 | (name) => !gemessen.some((zeile) => zeile.startsWith(`${name}.png`)), |
| 983 | ); |
| 984 | expect( |
| 985 | fehlend, |
| 986 | 'Diese Motive fehlen. Der Lauf muss vollständig durchlaufen – mit `--grep` entstehen nur einzelne Bilder.', |
| 987 | ).toEqual([]); |
| 988 | }); |