/** * Die sieben Bildschirmfotos für den Eintrag im Microsoft Store. * * Ausgabe nach `docs/store-bilder/`. Welche Motive das sind und in welcher * Reihenfolge, gibt `docs/store-eintrag.md` Abschnitt 7.2 vor; die * Bildunterschriften stehen dort in 6.3. Diese Datei erfindet keine Motive, * sie stellt genau die sieben her. * * ## Warum ein eigener Lauf neben `bildschirmfotos.spec.ts` * * Die dreizehn Aufnahmen dort sind Abnahmedokumente: Sie zeigen mit * `fullPage: true` absichtlich die ganze Seite, damit nichts unbesehen * bleibt. Für den Store sind genau diese beiden Eigenschaften untauglich – * gemessen am Stand 0.21.0: * * - Alle dreizehn sind 1265 (die Handbuchaufnahme 1280) Bildpunkte breit * und liegen damit unter der Store-Mindestbreite von 1366. * - `01-startbildschirm-hell.png` ist 1265 × 8078, `11-glossar.png` sogar * 1265 × 40364. Der Store skaliert auf seine Rahmenbreite; bei einem * Verhältnis von 1 : 32 bleibt ein unlesbarer Streifen. * * Beide Zwecke in einer Datei zu mischen verdürbe den einen durch den * anderen (`store-eintrag.md`, 7.1). Die dreizehn bleiben unberührt. * * ## Fenstergröße – gemessen abgewogen, nicht geraten * * Die Store-Mindestgröße ist 1366 × 768. Gewählt sind {@link FENSTER} * CSS-Punkte, und zwar aus einer Abwägung mit einer harten Grenze in der * Mitte: `.seite` in `global.css` deckelt den Inhalt auf `max-width: 62rem`, * also 992 Punkte. Ein breiteres Fenster zeigt deshalb **keinen Inhalt * mehr**, sondern nur mehr leeren Grund – und weil der Store das Bild auf * seine Rahmenbreite herunterrechnet, wird die Schrift dabei kleiner, ohne * dass etwas gewonnen wäre. Ein knapp bemessenes Fenster hält die Schrift * groß, schneidet aber Motive ab, die zusammengehören. * * Deshalb: die Breite nur so weit über die Mindestbreite, dass die Textspalte * den größten Teil des Bildes einnimmt (992 von 1440 Punkten, also 69 * Prozent) und die Grenze mit Sicherheit überschritten ist. * * Die Höhe richtet sich nach dem höchsten der sieben Motive. Nachgemessen bei * 1440 Punkten Breite, jeweils von der oberen bis zur unteren Kante des * Motivs: * * | Motiv | gebraucht | * |---|---| * | 04, Profilwahl und Zeitvorgabe vollständig | 1249 | * | 05, Suchfeld bis zum dritten Treffer | 1152 | * | 03 und 07, Kopf bis zum fünften Weg ins Lernen | 787 | * | 02, Frage samt Rückmeldung und Begründung | 730 | * * {@link FENSTER} setzt deshalb 1275: die 1249 des höchsten Motivs, 10 Punkte * Luft darüber und 16 darunter – beide Kanten liegen damit im Zwischenraum * zwischen zwei Blöcken und schneiden keinen an. Das Bild wird dadurch höher * als das übliche 16 : 10, und das ist der Preis dafür, dass kein Motiv aus * 6.2 angeschnitten wird: Ein Zuschnitt mitten in die Zeitstufen hinein wäre * das schlechtere Bild, und ein Verkleinern der Anzeigegröße machte genau die * Schrift kleiner, um die es hier geht. Alle sieben behalten dasselbe Format; * ein Karussell aus unterschiedlich hohen Bildern springt. * * ## Warum die Aufnahme über CDP läuft und nicht über `page.screenshot()` * * Nachgemessen an dieser Anwendung, alles bei 1440 × 900: * * | Weg | devicePixelRatio | Aufnahme | * |---|---|---| * | `page.screenshot()` | 1 | 1440 × 900 | * | `page.screenshot({ scale: 'device' })` | 1 | 1440 × 900 | * | Start mit `--force-device-scale-factor=2` | 1 (wirkungslos) | 1440 × 900 | * | CDP `Emulation.setDeviceMetricsOverride` = 2 | 2 | 1440 × 900 | * | dazu CDP `Page.captureScreenshot` | 2 | 2880 × 1800 | * * Nur der letzte Weg liefert Gerätepunkte. Wichtig ist die Gegenprobe: Die * CSS-Maße bleiben dabei unverändert – die `h1` misst in beiden Fällen * 928 Punkte bei 38 px Schriftgröße. Der Faktor verdoppelt also die * Abtastung und **nicht** die Textgröße; das Bild zeigt denselben Ausschnitt, * nur feiner aufgelöst. Genau das ist im Store etwas wert, weil er ohnehin * herunterrechnet. * * Gewählt ist trotzdem nicht 2, sondern {@link GERAETEFAKTOR} = 1,5. Der * Grund ist die obere Grenze: Microsoft nennt für Bildmaterial im Store * 3840 × 2160 Punkte. Mit Faktor 2 wäre die Aufnahme 2880 × 2550 und läge in * der Höhe darüber; mit 1,5 sind es 2160 × 1913 – anderthalbfache Abtastung * und mit Abstand innerhalb dessen, was der Store annimmt. 1,5 ist zudem eine * der üblichen Windows-Skalierungen und wird von Chromium sauber gezeichnet. * * ## Ausschnitt statt ganzer Seite – und an Elementen festgemacht * * Aufgenommen wird der sichtbare Ausschnitt, so wie `ausschnittAblegen()` in * `bildschirmfotos.spec.ts` (dritter Weg wäre einer zu viel). Wohin gerollt * wird, entscheidet aber kein fester Pixelwert, sondern ein Element: * {@link ausschnittAn} setzt eine Marke an den oberen Rand. Und was auf dem * Bild zu sehen sein muss, prüft {@link imBildErwarten} nach der Aufnahme * nach – verrutscht ein Motiv bei der nächsten Layoutänderung aus dem * Ausschnitt, fällt dieser Lauf durch, statt ein halbes Bild abzulegen. * * ## Und die untere Kante gehört zwischen zwei Zeilen * * Ein Bild kann die Größenprüfung bestehen und trotzdem untauglich sein. Beim * ersten Lauf endeten vier der sieben mitten in einer Textzeile – zu sehen war * eine Reihe halber Buchstaben, quer über das Bild. Die Höhe steht fest, also * regelt {@link unterkanteAufZeilengrenze} das über die Rollhöhe: Sie sucht im * erlaubten Spielraum die nächste Zeilengrenze und legt die Kante dorthin. * {@link keineAngeschnitteneZeile} prüft danach beide waagerechten Kanten * nach, und zwar in {@link ablegen} – also ausnahmslos vor jeder Aufnahme, * auch vor denen, die heute von selbst sauber enden. * * ## Keine stillen Wachen * * In `bildschirmfotos.spec.ts` blieb Aufnahme 05 zeitweise still aus, weil * ihre Bedingung eine Auswahlfrage voraussetzte und manchmal eine offene kam; * die veraltete Datei blieb als vermeintlich aktuelle liegen. Hier gibt es * kein `if (sichtbar)` um eine Aufnahme herum. Jedes Motiv wird angesteuert, * jede Voraussetzung wird zugesichert, und jede Datei wird nach dem Schreiben * gegen {@link MINDESTMASS} gemessen. Ein Bild, das der Store abwiese, fällt * hier auf und nicht erst beim Hochladen. */ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { type CDPSession, type ElectronApplication, type Locator, type Page, } from '@playwright/test'; import { animationenAbwarten, appStarten, bauPruefen, projektWurzel, themaSetzen, } from './electron-hilfe'; import { expect, test } from './konsolenwache'; const ZIEL = join(projektWurzel, '..', 'docs', 'store-bilder'); /** * Fenstergröße in CSS-Punkten – Begründung und Messwerte im Dateikopf. * * Die Höhe ist am höchsten Motiv gemessen (Prüfungssimulation: 1249 Punkte * von der Profilwahl bis zum Ende der Zeitvorgabe); die übrigen sechs kommen * mit weniger aus und behalten dasselbe Format. */ const FENSTER = { breite: 1440, hoehe: 1275 } as const; /** * Abtastung der Aufnahme; vervielfacht die Bildpunkte, nicht die Schrift. * * 1,5 statt 2, damit die Aufnahme mit 2160 × 1913 unter den 3840 × 2160 * bleibt, die Microsoft für Bildmaterial im Store nennt. Begründung im * Dateikopf. */ const GERAETEFAKTOR = 1.5; /** Was der Microsoft Store für Windows-Bildschirmfotos mindestens verlangt. */ const MINDESTMASS = { breite: 1366, hoehe: 768 } as const; /** * Toleranz beim Vergleich von Kantenlagen, in CSS-Punkten. * * Zeilenkästen kommen mit Nachkommastellen; ein Kasten, der rechnerisch * 0,2 Punkte in die Bildkante ragt, ist im Bild nichts. Erst darüber beginnt * das, was jemand als angeschnittene Schrift sieht. */ const ZEILENTOLERANZ = 0.5; /** * Sicherheitsabstand, mit dem eine Bildkante neben eine Zeile gelegt wird. * * Die Aufnahme schneidet bei ganzen Gerätepunkten, die Zeilenkästen liegen auf * Bruchteilen. Ein Punkt Abstand von der Zeilengrenze kostet nichts und hält * die Kante auch nach dem Runden sicher zwischen zwei Zeilen. */ const KANTENPUFFER = 1; /** * Wie weit die Aufnahme im hohen Kontrast von der hellen abweichen darf. * * Bild 03 und 07 sollen denselben Ausschnitt zeigen; die dickeren Rahmen des * Kontrastschemas verschieben das Layout aber um ein paar Punkte. Der Wert ist * gemessen: Mit 12 fand sich keine Zeilengrenze – die Überschrift, die dort * hineinragt, ist rund 30 Punkte hoch, und wer sie ganz zeigen oder ganz * ausblenden will, braucht die halbe Zeilenhöhe. 20 Punkte sind 1,6 Prozent * der Fensterhöhe; im Vergleich der beiden Bilder ist das nichts. */ const FEINJUSTIERUNG = 20; /** * Die Frage, die auf den Bildern 01 und 02 steht: 1.37 („erwerben“). * * Bewusst gewählt und nicht gezogen. Die Lernsitzung stellt sonst eine * **zufällige** Frage vor – für ein Werbebild ist das untauglich, weil weder * Länge noch Fragetyp noch Aussehen vorhersehbar sind. * * Warum diese und keine andere: * * - Kurz und in einer Zeile lesbar: 69 Zeichen, drei Antwortmöglichkeiten * von je einer Zeile. Ein langer Fragetext mit sechs Optionen füllte das * Bild mit Text, den im Store niemand liest. * - Genau eine Antwort ist richtig – die Rückmeldung bleibt eindeutig. * - Die falsche Antwort a) „Abschluss eines Kaufvertrages“ ist die * naheliegende. Genau daran zeigt Bild 02, wozu die Begründung da ist. * - Sie trägt die Aussage des Store-Textes: „‚Erwerben‘ hat mit Kaufen * nichts zu tun.“ Die Erklärung dazu nennt zwei Fundstellen (Anlage 1 * Abschnitt 2 Nr. 1 und § 20 Abs. 1 WaffG) – auch das ist im Bild zu * sehen und belegt die Zusage aus der Beschreibung. * - Sie zeigt kein Prüfzeichen. Bilder im Katalog sind amtliche Zeichen; * sie gehören nicht auf ein Werbebild, das ohne sie auskommt. * * ## Wie sie angesteuert wird – und warum nicht über ihre Nummer * * Die Suche findet sie auch über die amtliche Nummer, und der Knopf am * Treffer führte unmittelbar ins Üben. Nachgemessen sähe das Bild dann aber * so aus: „Frage 1.37 · **Frage 1 von 1**“, mit vollständig gefülltem * Fortschrittsbalken. Das ist eine Sitzung, die zu Ende ist, bevor sie * anfängt – und die Bildunterschrift aus 6.3 („Die Zeile darüber sagt, wie * viele Fragen noch kommen“) wäre daneben. * * Deshalb der Sammelknopf über {@link FRAGE.suchtext}: 10 Treffer, die Frage * 1.37 an erster Stelle. Auf dem Bild steht dann „Suche: erwerben Schusswaffe * · Frage 1 von 10“ – eine Sitzung im Gang, und nebenbei der Beleg, dass von * jedem Suchtreffer ein Weg ins Üben führt. Dass 1.37 vorn steht, sichert der * Test zu; ändert sich die Rangfolge, fällt er durch, statt eine andere Frage * abzubilden. */ const FRAGE = { nummer: '1.37', suchtext: 'erwerben Schusswaffe', treffer: 10, falscheOption: 'a', } as const; /** * Der Suchbegriff für Bild 05. * * Verlangt sind drei sichtbare Treffer; der Test prüft das nach. „Waffenschein“ * steht in 35 Fragen (nachgemessen an der Trefferzahl der Ansicht), und die * ersten drei sind kurze, für sich verständliche Fragen – „Wie lange gilt der * Kleine Waffenschein?“ und Ähnliches. * * Nicht genommen: „Notwehr“. Der erste Treffer ist dort die einzige * Lückentextfrage des Katalogs, und in der Trefferliste stehen ihre Lücken * naturgemäß leer: „Notwehr ist diejenige , die ist, um …“. Wer das im Store * sieht, hält es für einen Textfehler der Anwendung. */ const SUCHBEGRIFF = 'Waffenschein'; /** * Die sieben Dateinamen in der Reihenfolge aus Abschnitt 6.2. * * Die führende Nummer ist der Platz im Store: Hochgeladen wird der Reihe * nach, und der Store zeigt die ersten Bilder groß. Aufgenommen werden 03 und * 07 trotzdem zusammen – siehe dort. */ const BILDER = [ '01-frage-mit-antwortmoeglichkeiten', '02-rueckmeldung-mit-begruendung', '03-startbildschirm', '04-pruefungssimulation-vorbereiten', '05-fragen-durchsuchen', '06-glossar', '07-startbildschirm-hoher-kontrast', ] as const; let app: ElectronApplication; let fenster: Page; let cdp: CDPSession; /** Was am Ende als Beleg ausgegeben wird: je Bild die gemessenen Maße. */ const gemessen: string[] = []; test.beforeAll(async () => { // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen. bauPruefen(); mkdirSync(ZIEL, { recursive: true }); ({ app, fenster } = await appStarten()); await fenster.setViewportSize({ width: FENSTER.breite, height: FENSTER.hoehe }); cdp = await app.context().newCDPSession(fenster); await cdp.send('Emulation.setDeviceMetricsOverride', { width: FENSTER.breite, height: FENSTER.hoehe, deviceScaleFactor: GERAETEFAKTOR, mobile: false, }); /* Gegenprobe zur Tabelle im Dateikopf: Die Abtastung steigt, das Layout bleibt. Stimmt das nicht mehr, wäre die Begründung dort hinfällig. */ const abtastung = await fenster.evaluate(() => ({ verhaeltnis: window.devicePixelRatio, breite: window.innerWidth, hoehe: window.innerHeight, })); expect(abtastung.verhaeltnis).toBeCloseTo(GERAETEFAKTOR, 2); expect(abtastung.breite).toBe(FENSTER.breite); expect(abtastung.hoehe).toBe(FENSTER.hoehe); }); test.afterAll(async () => { /* Die Maße gehören in die Ausgabe des Laufs: Sie sind der Beleg, dass jedes Bild über der Store-Mindestgröße liegt. */ console.info(`\nStore-Bilder in ${ZIEL}:\n${gemessen.join('\n')}\n`); await app.close(); }); /** Breite und Höhe aus dem IHDR-Kopf einer PNG-Datei. */ function pngMasse(pfad: string): { breite: number; hoehe: number } { const puffer = readFileSync(pfad); return { breite: puffer.readUInt32BE(16), hoehe: puffer.readUInt32BE(20) }; } /** Ein Zeilenkasten im Fenster: die Fläche, die eine gesetzte Textzeile einnimmt. */ interface Zeile { readonly oben: number; readonly unten: number; readonly text: string; } /** * Alle sichtbaren Textzeilen, in Fensterkoordinaten. * * Gemessen wird an Textknoten, nicht an Elementen: Ein Absatz ist ein Element, * aber sieben Zeilen – und angeschnitten wird immer eine Zeile. * `Range.getClientRects()` gibt genau die Zeilenkästen zurück, einen je * Umbruch. Ihre Höhe ist die der Inline-Box (schriftgrößenabhängig), nicht die * des Zeilenabstands; zwischen zwei Zeilen bleibt deshalb eine echte Lücke, * und in die gehört eine Bildkante. * * Ausgelassen wird, was niemand sieht – und das ist mehr, als es zunächst * scheint. Gemessen am ersten Lauf mit dieser Prüfung: Bild 01 fiel durch, * weil die untere Kante angeblich „Nicht der Antrieb entscheidet, sondern Lauf * und Zweckbestimmung“ teilte. Im Bild ist dort leerer Grund. Der Satz steht * im zugeklappten Aufklapper „3 Fachbegriffe in dieser Frage“: Chromium legt * den Inhalt eines geschlossenen `
` sehr wohl aus – zu sehen ist er * nicht, Zeilenkästen hat er trotzdem. * * Ausgelassen werden deshalb: leerer Text, Kästen ohne Ausdehnung, alles unter * `.nur-screenreader` (Text für Bildschirmleser, den die Anzeige aus dem Bild * schiebt), alles im geschlossenen Teil eines Aufklappers, alles, was * `checkVisibility` verwirft (display, visibility, Deckkraft, * `content-visibility`) – und alles weit außerhalb des Fensters. */ async function zeilenImFenster(): Promise { return fenster.evaluate(() => { const RAND = 200; const zeilen: { oben: number; unten: number; text: string }[] = []; const lauf = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); for (let knoten = lauf.nextNode(); knoten !== null; knoten = lauf.nextNode()) { const text = (knoten.textContent ?? '').trim(); if (text === '') { continue; } const eltern = knoten.parentElement; if (eltern === null) { continue; } if (eltern.closest('.nur-screenreader') !== null) { continue; } if ( !eltern.checkVisibility({ contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true, }) ) { continue; } /* Der zugeklappte Teil eines Aufklappers – die Zusammenfassung selbst ist sichtbar und bleibt drin. */ if (eltern.closest('details:not([open])') !== null && eltern.closest('summary') === null) { continue; } const bereich = document.createRange(); bereich.selectNodeContents(knoten); for (const kasten of Array.from(bereich.getClientRects())) { if (kasten.height < 2 || kasten.width < 2) { continue; } if (kasten.bottom < -RAND || kasten.top > window.innerHeight + RAND) { continue; } zeilen.push({ oben: kasten.top, unten: kasten.bottom, text }); } } return zeilen; }); } /** Die Zeilen, die eine waagerechte Kante bei `hoehe` mittendurch teilt. */ function zeilenAnDerKante(zeilen: readonly Zeile[], hoehe: number): Zeile[] { return zeilen.filter( (zeile) => zeile.oben + ZEILENTOLERANZ < hoehe && hoehe < zeile.unten - ZEILENTOLERANZ, ); } /** Für Fehlermeldungen: der Anfang des Textes, dem die Kante die Hälfte nimmt. */ function auszug(zeile: Zeile): string { return zeile.text.length > 60 ? `${zeile.text.slice(0, 60)}…` : zeile.text; } /** * Rollt so weit, dass die untere Bildkante keine Textzeile durchschneidet. * * ## Warum es diese Funktion gibt * * Aus einem gemessenen Befund: Beim ersten Lauf endeten vier der sieben Bilder * mitten in einer Zeile – 02 in „…die Waffenbesitzkarte beantragen oder die * Eintragung in eine vorhandene“, 03 in der Überschrift „Prüfungssimulation * vorbereiten“, 05 in „Antwortmöglichkeiten anzeigen (3)“, 06 in „sich * nehmen.“ Zu sehen war jedes Mal eine Reihe halber Buchstaben. Die * Größenprüfung bestanden alle vier; im Store wäre das schlimmer als kein * Bild. * * Die Bildhöhe steht fest – alle sieben behalten dasselbe Format –, also ist * die einzige Stellschraube, wo gerollt wird. {@link ausschnittAn} richtet die * **obere** Kante an einem Element aus; diese Funktion legt danach die * **untere** in die Lücke zwischen zwei Zeilen. * * Gewählt wird die kleinste Verschiebung, die das leistet, und nur innerhalb * des angegebenen Spielraums: Was oben aus dem Bild rutschen darf, weiß nur * das Motiv. Findet sich im Spielraum keine Zeilengrenze, fällt der Lauf durch * – lieber kein Bild als ein halbes. * * @param spielraum `runter` verschiebt den Ausschnitt nach unten (oben geht * Luft verloren), `hoch` nach oben (unten geht Inhalt verloren). Beides in * CSS-Punkten. * @returns die angewandte Verschiebung – für Bild 07, das dieselbe braucht wie * Bild 03. */ async function unterkanteAufZeilengrenze(spielraum: { runter: number; hoch?: number; }): Promise { const hoch = spielraum.hoch ?? 0; const zeilen = await zeilenImFenster(); const getroffen = zeilenAnDerKante(zeilen, FENSTER.hoehe)[0]; if (getroffen === undefined) { return 0; } /* Die Kandidaten sind die Zeilengrenzen selbst: knapp unter eine Zeile (dann steht sie ganz im Bild) oder knapp über sie (dann ist sie ganz draußen). Genommen wird die nächstgelegene, die keine andere Zeile anschneidet – Zeilen mehrerer Spalten liegen nicht auf einer Höhe. */ const kandidaten = zeilen .flatMap((zeile) => [zeile.unten + KANTENPUFFER, zeile.oben - KANTENPUFFER]) .map((grenze) => grenze - FENSTER.hoehe) .filter((versatz) => versatz >= -hoch && versatz <= spielraum.runter) .filter((versatz) => zeilenAnDerKante(zeilen, FENSTER.hoehe + versatz).length === 0) .sort((a, b) => Math.abs(a) - Math.abs(b)); const versatz = kandidaten[0]; expect( versatz, `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.`, ).toBeDefined(); if (versatz === undefined) { return 0; } await fensterRollen(versatz); return versatz; } /** Verschiebt den Ausschnitt um `versatz` Punkte nach unten (negativ: nach oben). */ async function fensterRollen(versatz: number): Promise { await fenster.evaluate((punkte) => { window.scrollBy(0, punkte); }, versatz); await animationenAbwarten(fenster); } /** * Sichert zu, dass keine Bildkante mitten durch eine Zeile geht. * * Läuft in {@link ablegen} und damit ausnahmslos vor jeder der sieben * Aufnahmen. Geprüft werden beide waagerechten Kanten; die senkrechten liegen * links und rechts im leeren Grund, weil der Inhalt auf 62rem gedeckelt ist. */ async function keineAngeschnitteneZeile(name: string): Promise { const zeilen = await zeilenImFenster(); for (const [kante, wo] of [ [0, 'obere'], [FENSTER.hoehe, 'untere'], ] as const) { const getroffen = zeilenAnDerKante(zeilen, kante); expect( getroffen.map(auszug), `${name}.png: Die ${wo} Bildkante teilt diese Zeile mittendurch – im Store steht dort eine Reihe halber Buchstaben.`, ).toEqual([]); } } /** * Nimmt den sichtbaren Ausschnitt auf und misst das Ergebnis nach. * * Die Messung ist kein Beiwerk: Ein Bild unter der Mindestgröße weist der * Store beim Hochladen ab – und das fiele sonst erst dort auf. */ async function ablegen(name: string): Promise { await animationenAbwarten(fenster); await keineAngeschnitteneZeile(name); const antwort = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false, }); const pfad = join(ZIEL, `${name}.png`); writeFileSync(pfad, Buffer.from(antwort.data, 'base64')); const masse = pngMasse(pfad); expect( masse.breite, `${name}.png ist ${String(masse.breite)} Punkte breit – der Store verlangt mindestens ${String(MINDESTMASS.breite)}.`, ).toBeGreaterThanOrEqual(MINDESTMASS.breite); expect( masse.hoehe, `${name}.png ist ${String(masse.hoehe)} Punkte hoch – der Store verlangt mindestens ${String(MINDESTMASS.hoehe)}.`, ).toBeGreaterThanOrEqual(MINDESTMASS.hoehe); gemessen.push(`${name}.png ${String(masse.breite)} × ${String(masse.hoehe)}`); } /** An den Anfang der Seite rollen – für die Motive, die oben beginnen. */ async function nachOben(): Promise { await fenster.evaluate(() => { window.scrollTo(0, 0); }); await animationenAbwarten(fenster); } /** * Rollt so, dass `marke` mit `abstand` Punkten Luft oben im Bild steht. * * An einem Element festgemacht statt an einer Pixelzahl: Wächst der Kopf der * Ansicht um eine Zeile, wandert der Ausschnitt mit, statt zu verrutschen. */ async function ausschnittAn(marke: Locator, abstand = 24): Promise { await marke.scrollIntoViewIfNeeded(); await marke.evaluate((element, luft) => { const ziel = element.getBoundingClientRect().top + window.scrollY - luft; window.scrollTo(0, Math.max(0, ziel)); }, abstand); await animationenAbwarten(fenster); } /** * Sichert zu, dass etwas vollständig im aufgenommenen Ausschnitt liegt. * * `boundingBox()` rechnet in Fensterkoordinaten, also nach dem Rollen. Was * hier durchfällt, ist genau der Fall, der ein halbes Werbebild ergäbe. */ async function imBildErwarten(marke: Locator, was: string): Promise { await expect(marke, `${was}: nicht sichtbar`).toBeVisible(); const kasten = await marke.boundingBox(); expect(kasten, `${was}: keine Ausdehnung messbar`).not.toBeNull(); if (kasten === null) { return; } expect( Math.round(kasten.y), `${was} beginnt bei ${String(Math.round(kasten.y))} px und steht damit über dem Bildausschnitt.`, ).toBeGreaterThanOrEqual(0); expect( Math.round(kasten.y + kasten.height), `${was} endet bei ${String(Math.round(kasten.y + kasten.height))} px, das Bild ist ${String(FENSTER.hoehe)} px hoch.`, ).toBeLessThanOrEqual(FENSTER.hoehe); } /** * Sichert zu, dass ein Nachbarblock ganz **über** dem Ausschnitt bleibt. * * Der Gegenpart zu {@link imBildErwarten}, und aus einem gemessenen Anlass: * Beim ersten Lauf lag die obere Bildkante vier Punkte im Hinweiskasten über * den Profilen. Zu sehen war davon ein oranger Strich – nichts, was jemand * lesen kann, aber genug, damit das Bild aussieht wie versehentlich * beschnitten. Die Kante gehört in den Zwischenraum zweier Blöcke, und das * ist prüfbar. */ async function ueberDemBildErwarten(marke: Locator, was: string): Promise { const kasten = await marke.boundingBox(); expect(kasten, `${was}: keine Ausdehnung messbar`).not.toBeNull(); if (kasten === null) { return; } expect( Math.round(kasten.y + kasten.height), `${was} ragt bis ${String(Math.round(kasten.y + kasten.height))} px ins Bild und wird dort angeschnitten.`, ).toBeLessThanOrEqual(0); } /** Zurück zum Startbildschirm – aus jeder der hier besuchten Ansichten. */ async function zumStart(): Promise { const start = fenster.getByRole('heading', { name: 'Heute lernen', exact: true }); const wege = [/sitzung beenden/iu, /zum start/iu]; for (let versuch = 0; versuch < 4; versuch++) { if (await start.isVisible().catch(() => false)) { await nachOben(); return; } for (const weg of wege) { const knopf = fenster.getByRole('button', { name: weg }).first(); if (await knopf.isVisible().catch(() => false)) { await knopf.click(); await fenster.waitForTimeout(300); break; } } } await start.waitFor({ timeout: 5000 }); await nachOben(); } /** * Startet über die Suche die Sitzung, die auf den Bildern 01 und 02 steht. * * Ohne Absicherung mit `if`: Findet die Suche die Frage nicht oder steht sie * nicht mehr an erster Stelle, ist das ein Befund und kein Grund, still ein * altes Bild liegen zu lassen. */ async function sitzungAusSucheStarten(): Promise { await zumStart(); await fenster .getByRole('button', { name: /Fragen durchsuchen/iu }) .first() .click(); await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); await fenster.getByLabel('Suchbegriff').fill(FRAGE.suchtext); await fenster.waitForTimeout(600); const ersterTreffer = fenster.locator('.suche__treffer > li').first(); await expect( ersterTreffer.locator('.treffer__einordnung'), `Frage ${FRAGE.nummer} steht nicht mehr an erster Stelle der Treffer zu „${FRAGE.suchtext}“.`, ).toContainText(`Frage ${FRAGE.nummer}`); await fenster .getByRole('button', { name: `Alle ${String(FRAGE.treffer)} Fragen üben`, exact: true }) .click(); await fenster.waitForTimeout(700); await expect( fenster.locator('.frage__nummer'), 'Die Sitzung zeigt eine andere Frage als bestellt.', ).toHaveText(`Frage ${FRAGE.nummer}`); await expect( fenster.locator('.fortschritt__text'), 'Die Fortschrittszeile nennt nicht die erwartete Sitzung.', ).toHaveText(`Suche: ${FRAGE.suchtext} · Frage 1 von ${String(FRAGE.treffer)}`); } /** * Beantwortet Fragen einer laufenden Sitzung, bis `anzahl` erreicht ist. * * Für den Lernstand hinter Bild 03 und 07 (siehe dort). Beide Fragearten * werden bedient; endet die Sitzung vorher, wirft die Schleife – eine halb * gefüllte Auskunft wäre schlechter als ein roter Lauf. * * `merken` legt die ersten Fragen zusätzlich auf die Merkliste. Grund ist der * Startbildschirm: Ohne sie steht dort „Ihre Merkliste: 0 Fragen“, und eine * Null wirbt für nichts. Zwei und nicht eine, weil die Beschriftung fest * „Fragen“ sagt („1 Fragen“ wäre auf dem Werbebild ein Grammatikfehler). */ async function fragenBeantworten(anzahl: number, merken = 0): Promise { for (let n = 0; n < anzahl; n++) { if (n < merken) { await fenster.getByRole('button', { name: /Frage merken/u }).click(); } const auswahlfeld = fenster.getByRole('radio').or(fenster.getByRole('checkbox')); if ((await auswahlfeld.count()) > 0) { await auswahlfeld.first().check(); } await fenster .getByRole('button', { name: /^(Antwort bestätigen|Musterantwort anzeigen)$/u }) .first() .click(); await fenster.waitForTimeout(200); // Offene Fragen wollen zuerst die Selbstbewertung. const bewertung = fenster.getByRole('button', { name: 'Gewusst', exact: true }); if ((await bewertung.count()) > 0) { await bewertung.first().click(); await fenster.waitForTimeout(200); } const weiter = fenster.getByRole('button', { name: /^(Nächste Frage|Sitzung auswerten)$/u }); await expect( weiter, `Nach Frage ${String(n + 1)} steht kein Weiterknopf – die Sitzung ist unerwartet zu Ende.`, ).toHaveCount(1); await weiter.first().click(); await fenster.waitForTimeout(250); } } test('01 und 02: eine Frage und die Rückmeldung mit Begründung', async () => { await sitzungAusSucheStarten(); const frage = fenster.locator('.frage__text'); const optionen = fenster.locator('.antwortoption'); await expect( optionen, 'Die Frage hat nicht die erwarteten drei Antwortmöglichkeiten.', ).toHaveCount(3); await nachOben(); await imBildErwarten(fenster.locator('.fortschritt'), 'Die Fortschrittszeile'); await imBildErwarten(frage, 'Der Fragetext'); await imBildErwarten(optionen.last(), 'Die letzte Antwortmöglichkeit'); await imBildErwarten( fenster.getByRole('button', { name: 'Antwort bestätigen' }), 'Die Schaltfläche „Antwort bestätigen“', ); await ablegen('01-frage-mit-antwortmoeglichkeiten'); /* Bewusst die falsche Antwort: Bild 02 soll die Begründung zeigen, und die steht nur bei einer falschen Antwort von selbst offen (Lernsitzung.tsx, `offen={ergebnis.art !== 'richtig'}`). */ await fenster.getByRole('radio', { name: new RegExp(`^${FRAGE.falscheOption}\\)`, 'u') }).check(); await fenster.getByRole('button', { name: 'Antwort bestätigen' }).click(); await fenster.waitForTimeout(500); const ergebnis = fenster.locator('.ergebnis__titel'); await expect(ergebnis, 'Die Rückmeldung meldet nicht „Falsch“.').toHaveText(/Falsch/u); const begruendung = fenster.getByRole('heading', { name: 'Warum das so ist' }); const kurzfassung = fenster.locator('.erklaerung__kurz'); /* Bild 02 beginnt eine Zeile tiefer als Bild 01: nicht an der Fortschrittszeile, sondern am Kopf der Frage. Zwei gemessene Gründe. Erstens der Platz. Die Begründung ist der Grund für dieses Bild, und die Fortschrittszeile kostet 48 Punkte, die ihr fehlen. Ihre Bildunterschrift (6.3, `05-lernsitzung-feedback`) nennt sie auch gar nicht – sie spricht von der Kennzeichnung der Antworten und von der Begründung darunter. Zweitens ein Widerspruch, der sonst im Bild steht: Auf Bild 01 heißt es „Frage 1 von 10“, nach der falschen Antwort „Frage 1 von 11“ – die Frage wird in derselben Sitzung wieder eingereiht (`useSitzung`, `MAX_WIEDEREINREIHUNGEN`). Inhaltlich richtig, im unmittelbaren Vergleich zweier Werbebilder aber ein Zahlensprung, den jeder sieht und niemand erklärt bekommt. */ await ausschnittAn(fenster.locator('.frage__kopf'), 28); await unterkanteAufZeilengrenze({ runter: 45, hoch: 12 }); await ueberDemBildErwarten(fenster.locator('.fortschritt'), 'Die Fortschrittszeile'); await imBildErwarten(frage, 'Der Fragetext'); await imBildErwarten(optionen.last(), 'Die letzte Antwortmöglichkeit mit ihrer Kennzeichnung'); await imBildErwarten(ergebnis, 'Die Überschrift der Rückmeldung'); await imBildErwarten(begruendung, 'Der Kasten „Warum das so ist“'); await imBildErwarten(kurzfassung, 'Die Kurzfassung der Begründung'); await ablegen('02-rueckmeldung-mit-begruendung'); }); test('Lernstand für den Startbildschirm', async () => { /* Ein frischer Lernstand zeigt auf dem Startbildschirm nur Nullen: „Heute 20 Fragen, etwa 15 Minuten“ und sonst nichts Gelebtes. Mit ein paar beantworteten Fragen nennt die Einstiegskarte stattdessen, was heute schon geschehen ist und was offen blieb (`Einstieg.tsx`, `pensumSatz`) – das ist dieselbe Ansicht, nur in Benutzung. Die Prüfungsreife bleibt dabei bei null, und das ist Absicht: Sie zählt eine Frage erst, wenn sie nach mindestens einem Tag Abstand noch einmal richtig beantwortet wurde (`shared/reife.ts`). Ein Lauf an einem Tag kann das nicht herstellen, und ein vorgerückter Lernstand wäre für ein Werbebild eine Erfindung. Der Satz, der stattdessen dasteht – „Wiedererkennen ist kein Erinnern“ – ist genau der, mit dem die Store-Beschreibung wirbt. */ await zumStart(); await fenster .getByRole('button', { name: /Weiterlernen/iu }) .first() .click(); await fenster.waitForTimeout(700); await fragenBeantworten(8, 2); await zumStart(); const pensum = fenster.locator('.einstieg__pensum'); await expect(pensum, 'Die Einstiegskarte nennt die geleistete Arbeit nicht.').toHaveText( /bearbeitet/u, ); await expect( fenster.getByRole('button', { name: /Gemerkte Fragen/u }).first(), 'Die Merkliste ist leer geblieben.', ).toContainText('2 Fragen'); console.info(`Einstiegskarte: ${await pensum.innerText()}`); }); /** Was auf beiden Startbildschirm-Aufnahmen zu sehen sein muss. */ async function startbildschirmPruefen(): Promise { await imBildErwarten( fenster.getByRole('heading', { name: 'Waffensachkunde – Lernsoftware', level: 1 }), 'Der Titel der Anwendung', ); await imBildErwarten( fenster.getByRole('heading', { name: 'Heute lernen', exact: true }), 'Die Überschrift „Heute lernen“', ); await imBildErwarten(fenster.locator('.einstieg .reifeampel'), 'Die Prüfungsreife-Anzeige'); await imBildErwarten(fenster.locator('.einstieg__pensum'), 'Das Tagespensum'); await imBildErwarten( fenster.getByRole('button', { name: /Offene Fragen/u }).first(), 'Der fünfte Weg ins Lernen', ); } test('03 und 07: der Startbildschirm hell und im hohen Kontrast', async () => { /* Beide Aufnahmen unmittelbar nacheinander, obwohl sie im Store an Platz 3 und 7 stehen. Der Grund steht in `store-eintrag.md` 7.2: Platz 7 trägt nur, wenn er dieselbe Ansicht zeigt wie Platz 3. „Dieselbe“ heißt auch dieselben Zahlen – und die Zeile „offen sind noch … zur Wiederholung“ rechnet gegen die Uhr. Nachgemessen: Zwischen den beiden Aufnahmen lagen zuvor drei andere Ansichten, und die Zeile nannte einmal 8 und einmal 5. Zwei Bilder, die denselben Bildschirm zeigen sollen und sich in einer Zahl unterscheiden, sind ein Fehler, den niemand sucht und jeder sieht. */ await zumStart(); await nachOben(); /* Der Kopf der Seite gibt die obere Kante vor, die untere darf sich bewegen: Über der Überschrift stehen 54 Punkte Luft, davon sind 40 entbehrlich. Ohne diesen Spielraum endete das Bild in der halben Überschrift „Prüfungssimulation vorbereiten“ des nächsten Blocks; mit ihm rückt die Kante darunter, und die Überschrift steht ganz im Bild (gemessen genügten dafür 10 Punkte). */ const versatz = await unterkanteAufZeilengrenze({ runter: 40 }); await startbildschirmPruefen(); const pensumHell = await fenster.locator('.einstieg__pensum').innerText(); await ablegen('03-startbildschirm'); await themaSetzen(fenster, 'hochkontrast'); await nachOben(); /* Derselbe Versatz, nicht ein neu gesuchter: „dieselbe Ansicht“ heißt auch derselbe Ausschnitt. Im hohen Kontrast sind die Rahmen aber dicker, und die Zeile, die im hellen Schema gerade unter der Kante lag, rutscht dabei hinein – gemessen fiel `07` mit „Prüfungssimulation vorbereiten“ durch. Deshalb danach eine Feinjustierung mit engem Spielraum: höchstens {@link FEINJUSTIERUNG} Punkte, gemessen genügten 14. Ein Unterschied von gut einem Prozent der Bildhöhe, den im Vergleich niemand sieht – anders als eine Reihe halber Buchstaben. */ await fensterRollen(versatz); const nachjustiert = await unterkanteAufZeilengrenze({ runter: FEINJUSTIERUNG, hoch: FEINJUSTIERUNG, }); console.info( `Startbildschirm: Versatz ${String(Math.round(versatz))} Punkte, im hohen Kontrast nachjustiert um ${String(Math.round(nachjustiert))}.`, ); await startbildschirmPruefen(); await expect( fenster.locator('.einstieg__pensum'), 'Die beiden Startbildschirme nennen verschiedene Zahlen und taugen nicht zum Vergleich.', ).toHaveText(pensumHell); await ablegen('07-startbildschirm-hoher-kontrast'); // Das helle Schema zurücksetzen – die übrigen Motive sind hell. await themaSetzen(fenster, 'hell'); await nachOben(); }); test('04: die Prüfungssimulation vorbereiten', async () => { await zumStart(); await fenster .getByRole('button', { name: /Prüfungssimulation vorbereiten/iu }) .first() .click(); await fenster .getByRole('heading', { name: 'Prüfungssimulation vorbereiten', level: 1 }) .waitFor(); const profile = fenster.locator('.profilwahl__option'); await expect(profile, 'Es stehen nicht die erwarteten fünf Profile zur Wahl.').toHaveCount(5); const zeitstufen = fenster.locator('.zeitwahl__option'); await expect(zeitstufen, 'Es stehen nicht die erwarteten vier Zeitstufen zur Wahl.').toHaveCount( 4, ); /* Zehn Punkte Luft, nicht die üblichen 24: Zwischen dem Hinweiskasten und der Profilwahl liegen nachgemessen 20 Punkte. Mit 24 läge die Bildkante im Kasten, und oben stünde ein angeschnittener oranger Streifen. */ await ausschnittAn(fenster.locator('.profilwahl'), 10); await ueberDemBildErwarten(fenster.locator('.hinweis').first(), 'Der Hinweis über den Profilen'); await imBildErwarten(fenster.locator('.profilwahl'), 'Die Wahl des Prüfungsprofils'); await imBildErwarten(fenster.locator('.zeitwahl'), 'Die Wahl der Zeitvorgabe'); await ablegen('04-pruefungssimulation-vorbereiten'); await zumStart(); }); test('05: Fragen durchsuchen mit Treffern', async () => { await zumStart(); await fenster .getByRole('button', { name: /Fragen durchsuchen/iu }) .first() .click(); await fenster.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 }).waitFor(); await fenster.getByLabel('Suchbegriff').fill(SUCHBEGRIFF); await fenster.waitForTimeout(600); /* Kein Nummerntreffer bei diesem Wort – sonst stünde über der Liste eine zweite `ol` mit derselben Klasse, und „der dritte Treffer“ meinte etwas anderes als das, was das Motiv verlangt. */ await expect( fenster.locator('.suche__nummern'), `„${SUCHBEGRIFF}“ wird zusätzlich als Fragennummer gelesen – das Motiv wäre ein anderes.`, ).toHaveCount(0); const treffer = fenster.locator('.suche__treffer > li'); const anzahl = await treffer.count(); expect( anzahl, `„${SUCHBEGRIFF}“ liefert nur ${String(anzahl)} Treffer; das Motiv verlangt mindestens drei.`, ).toBeGreaterThanOrEqual(3); await ausschnittAn(fenster.locator('.suche__eingabe')); /* Der dritte Treffer passt nicht ganz ins Bild – seine Karte ist 227 Punkte hoch, und ab dem Suchfeld gerechnet fehlen rund 100. Das Motiv aus 6.2 verlangt „Suchfeld bis zum dritten Treffer“, und genau so steht es da: Der dritte zeigt Nummer, Kapitel und Fragetext, dann endet das Bild. Was es nicht tun darf, ist mitten in einer Zeile zu enden – beim ersten Lauf war das „Antwortmöglichkeiten anzeigen (3)“. */ await unterkanteAufZeilengrenze({ runter: 30 }); await imBildErwarten(fenster.getByLabel('Suchbegriff'), 'Das Suchfeld'); await imBildErwarten(fenster.locator('.suche__anzahl'), 'Die Trefferzahl'); await imBildErwarten(treffer.nth(2).locator('.treffer__frage'), 'Der dritte Treffer'); await ablegen('05-fragen-durchsuchen'); await zumStart(); }); test('06: das Glossar', async () => { await zumStart(); await fenster .getByRole('button', { name: /Fachbegriffe nachschlagen/iu }) .first() .click(); await fenster.getByRole('heading', { name: 'Glossar', level: 1 }).waitFor(); await nachOben(); /* Über dem Knopf „Zum Start“ stehen 49 Punkte Luft; 30 davon sind der Spielraum, mit dem die untere Kante aus dem zweiten Glossareintrag herauskommt (beim ersten Lauf endete das Bild in „sich nehmen.“). */ await unterkanteAufZeilengrenze({ runter: 30 }); await imBildErwarten(fenster.getByLabel('Begriff suchen'), 'Das Suchfeld des Glossars'); await imBildErwarten(fenster.locator('.glossar__anzahl'), 'Die Zahl der Einträge'); await imBildErwarten( fenster.getByRole('navigation', { name: 'Anfangsbuchstaben' }), 'Die A-bis-Z-Leiste', ); await imBildErwarten( fenster.locator('.glossar__liste').getByRole('heading', { level: 2 }).first(), 'Der erste Glossareintrag', ); await ablegen('06-glossar'); await zumStart(); }); test('Alle sieben Bilder sind entstanden', () => { /* Der Abschluss über den ganzen Lauf: Sieben Motive nennt Abschnitt 6.2, sieben Dateien müssen es sein. Bliebe eines aus – etwa weil eine Aufnahme still übersprungen wurde –, fiele das sonst erst beim Hochladen auf. Geprüft wird gegen die Namen, nicht gegen die Anzahl: „welches fehlt“ ist die Auskunft, die weiterhilft. */ const fehlend = BILDER.filter( (name) => !gemessen.some((zeile) => zeile.startsWith(`${name}.png`)), ); expect( fehlend, 'Diese Motive fehlen. Der Lauf muss vollständig durchlaufen – mit `--grep` entstehen nur einzelne Bilder.', ).toEqual([]); });