waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app e2e store-bilder.spec.ts

41,1 KB Rohdatei
app/e2e/store-bilder.spec.ts — 988 Zeilen
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 });