Files
dogfather-universe/server/pruef-start-ansicht.mjs
T
DogFatherGitandClaude Opus 5 1fe443e59c Handy durchgeprueft, Lichtfarbe je Kachel, haengendes Licht behoben
DAS LICHT BLIEB HAENGEN -- vier Ursachen, jede einzeln behoben:

1. EIN WETTLAUF. Zwischen dem Anmelden eines Bildaufbaus und seinem
   Ablauf konnte der Zeiger die Karte laengst verlassen haben. Dann hatte
   pointerout schon aufgeraeumt, und der Bildaufbau schaltete das Licht
   gleich wieder AN. Der haeufigste Fall und der unauffaelligste.
2. UEBER EINE LUECKE VERLASSEN. Wer eine Karte nicht ueber eine
   Nachbarkarte verliess, sondern ueber den Zwischenraum, loeste kein
   Ereignis aus, das die alte Karte kannte.
3. GESCROLLT, OHNE DIE MAUS ZU BEWEGEN. Die Karte wandert unter dem
   stehenden Zeiger weg -- es kommt gar kein Zeigerereignis. Jetzt wird
   beim Scrollen nachgesehen, ob die beleuchtete Karte noch unter dem
   Zeiger liegt.
4. FENSTER ODER TAB VERLASSEN. Auch dort kommt nichts mehr.

Es gibt jetzt genau EINE beleuchtete Karte -- mehr kann es nicht geben,
es gibt ja nur einen Zeiger. Beim Wechsel geht die alte aus, bevor die
neue angeht.

DIE FARBE GEHOERT ZUR KACHEL. Auf der Wissensseite leuchteten alle sechs
Welten violett, weil die Farbe dort --w heisst und der ganze uebrige
Workspace --ton benutzt. Das Licht griff auf --ton zu, fand nichts und
nahm den Farbton der SEITE. Dieselbe Luecke bei den Aufgaben-Spalten
(--sfarbe), den Kalenderkarten (--afarbe) und den Kalenderzeilen
(--zfarbe). Alle vier setzen jetzt --ton mit.

Nachgewiesen an echten Bildpunkten, nicht am Quelltext: Der Zeiger wird
auf die Aufgaben-Kachel gesetzt (dort muss Rot ueberwiegen: 85 zu 22)
und auf die Kalender-Kachel (dort Blau: 71 zu 13). Ein
Zeichenkettenvergleich haette das nicht gekonnt -- der Browser rechnet
color-mix() aus und schreibt je nach Fassung rgb(), color() oder oklab()
zurueck.

DAS HANDY, komplett durchgeprueft: neue pruef-handy.mjs faehrt alle
fuenfzehn Seiten auf DREI echten Geraetegroessen ab (320 px iPhone SE,
390 px iPhone, 412 px Android) und misst fuenf Dinge, die am Rechner
unsichtbar sind. Gefunden und behoben:

  * ZWEI ECHTE UEBERLAEUFE (startcheck +27 px, automation +33 px). Die
    Seite liess sich waagerecht schieben -- auf einem Telefon der
    schlimmste Fehler. Ursachen: zwoelf Raster mit festem Mindestmass
    (minmax(280px, 1fr) kann nicht schrumpfen -> min(280px, 100%)),
    zwoelf feste Mindestbreiten an Auswahlfeldern, und ein "flex: none"
    an den Stufen-Knoepfen, das jedes Schrumpfen verbot. "width: 100%"
    allein reichte dort nicht.
  * BERUEHRZIELE unter 24 px (WCAG 2.2, Kriterium 2.5.8). Auswahlfelder
    waren 18 bis 22 px hoch. Jetzt 44 px -- die Empfehlung von Apple und
    Google, und der Daumen ist nun einmal breiter als ein Mauszeiger.
    Nebenbei behebt die Schriftgroesse 16 px das Hineinzoomen von iOS.
  * SCHRIFT unter 11,7 px an 52 Stellen. Gesucht wurden sie nicht von
    Hand -- das waere ein Ratespiel gewesen und haette die Haelfte
    uebersehen -- sondern durch Durchsuchen der Stildateien nach
    font-size unter 0,73rem.

ZWEI FEHLER IN DEN EIGENEN PRUEFUNGEN gefunden und behoben: Die
Schriftregel griff zuerst gar nicht (start.css wird VOR den Seitenstilen
geladen, bei gleicher Staerke gewinnt die spaetere -- jetzt mit
body.start qualifiziert), und die Lichtpruefung scrollte 600 px und
stellte nicht zurueck, sodass die folgende Pruefung ins Leere zeigte.

GEGENPROBE gemacht: Mit absichtlich eingebauten Fehlern (8-px-Schrift,
500 px breiter Inhalt) meldet die Handypruefung sofort Rot. Eine
Pruefung, die immer bestaetigt, bestaetigt nichts.

Alle achtzehn Pruefungen laufen gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-01 14:48:48 +02:00

616 lines
33 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Browserpruefung der Startseite: Gruppen, Zeichen, Farbtoene, Zahlen auf
den Kacheln -- und was jede Rolle davon zu sehen bekommt. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-start-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4186";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4186";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.();
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const heute = jetzt.slice(0, 10);
const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idAnna = anlegen("Tili", "creator", "CODE-ANNA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt);
/* Arbeit an mehreren Stellen -- damit auf den Kacheln wirklich Zahlen
stehen und man sieht, ob sie an der richtigen landen. */
for (const [status, frist] of [["offen", gestern], ["offen", gestern], ["offen", heute],
["arbeit", null], ["review", null]]) {
d.prepare("INSERT INTO aufgaben (titel, status, creator_id, frist, erstellt, erstellt_von) VALUES ('Test',?,?,?,?,?)")
.run(status, idAnna, frist, jetzt, idDogi);
}
d.prepare("INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Heute',?,?,0,?,?)")
.run(heute + "T18:00", idAnna, jetzt, idDogi);
d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('technik','problem','Ton weg',?,'hoch','offen',?,?,?)")
.run(heute, idAnna, jetzt, idDogi);
d.close();
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
async function anmelden(kontext, rolle, code) {
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.waitForTimeout(900);
return seite;
}
const lesen = (seite) => seite.evaluate(() => ({
gruss: document.getElementById("gruss")?.textContent,
/* Die Begrüßung ist ein Kopf aus vier Teilen geworden: Datum mit
Kalenderwoche, ein Wort zum Tag, die Plakette in Rollenfarbe und
ein Satz zur Lage. Jeder Teil wird einzeln nachgesehen -- "sieht
gut aus" ist kein Nachweis. */
datum: document.getElementById("datum")?.textContent || "",
grussWort: document.querySelector(".gruss__wort")?.textContent || "",
grussName: document.querySelector(".gruss__name")?.textContent || "",
plakette: document.getElementById("plakette")?.textContent || "",
plaketteRolle: document.getElementById("plakette")?.dataset.rolle || "",
plaketteRund: getComputedStyle(document.getElementById("plakette") || document.body).borderRadius,
marker: document.getElementById("tagesmarker")?.hidden === false
? document.getElementById("tagesmarker").textContent : null,
lage: document.getElementById("lage")?.hidden === false
? document.getElementById("lage").textContent : null,
lageStufe: document.getElementById("lage")?.dataset.stufe || null,
/* Der Schriftzug oben links, aufgeteilt in Rang. */
markeHaupt: document.querySelector(".kopfleiste .marke__haupt")?.textContent || "",
markeLeise: document.querySelector(".kopfleiste .marke__leise")?.textContent || "",
markeRauten: document.querySelectorAll(".kopfleiste .marke__raute").length,
gruppenNamen: [...document.querySelectorAll(".gruppe__name")].map((g) => g.textContent),
gruppen: [...document.querySelectorAll(".gruppe")].map((g) => ({
name: g.querySelector(".gruppe__name")?.textContent,
kacheln: g.querySelectorAll(".kachel").length,
})),
kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({
name: k.querySelector(".kachel__name")?.textContent,
unter: k.querySelector(".kachel__unter")?.textContent,
ton: k.dataset.ton,
farbe: getComputedStyle(k.querySelector(".kachel__svg")).stroke,
pfade: k.querySelectorAll(".kachel__svg path").length,
zahl: k.querySelector(".kachel__zahl")?.textContent || null,
warn: k.querySelector(".kachel__zahl")?.dataset.warn === "ja",
ziel: k.querySelector(".kachel__link")?.getAttribute("href"),
})),
/* Abgeschnittener Text ist der haeufigste stille Fehler in einer
Kachel -- er sieht auf den ersten Blick nach Absicht aus. Gemessen
wird die tatsaechliche Textbreite gegen die verfuegbare. */
abgeschnitten: [...document.querySelectorAll(".kachel__name, .kachel__unter")]
.filter((e) => e.scrollWidth > e.clientWidth + 1)
.map((e) => e.textContent),
hinweise: [...document.querySelectorAll("#dran .dran__punkt")]
.filter((z) => z.offsetParent !== null).length,
hinweiseGesamt: document.querySelectorAll("#dran .dran__punkt").length,
/* Jede Hinweiszeile traegt Zeichen und Farbe der Kachel, zu der sie
fuehrt. Geprueft wird beides an den echten Bildpunkten, nicht an der
Absicht im Quelltext. */
dranZeilen: [...document.querySelectorAll("#dran .dran__punkt")].map((z) => ({
ton: z.dataset.ton || null,
stufe: z.dataset.stufe,
pfade: z.querySelectorAll(".dran__svg path").length,
strich: getComputedStyle(z.querySelector(".dran__svg")).stroke,
zahl: z.querySelector(".dran__zahl")?.textContent || null,
zahlFarbe: getComputedStyle(z.querySelector(".dran__zahl")).color,
text: z.querySelector(".dran__text")?.textContent || "",
label: z.querySelector(".dran__link")?.getAttribute("aria-label") || "",
})),
zeichenGroesse: Math.round(document.querySelector(".kachel__zeichen")?.getBoundingClientRect().width || 0),
wasserzeichen: document.querySelectorAll(".kachel__wasserzeichen").length,
wasserzeichenDeckung: getComputedStyle(document.querySelector(".kachel__wasserzeichen") || document.body).opacity,
wasserzeichenSchwach: Number(getComputedStyle(
document.querySelector(".kachel__wasserzeichen") || document.body).opacity) <= 0.15,
namenGroesse: Math.round(parseFloat(getComputedStyle(document.querySelector(".kachel__name")).fontSize) || 0),
kachelHoehe: Math.round(document.querySelectorAll(".kachel")[1]?.getBoundingClientRect().height || 0),
zahlenLeer: !document.getElementById("zahlen-leer")?.hidden,
zahlen: [...document.querySelectorAll(".zahl")].length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
ueberstehend: [...document.querySelectorAll("main *")]
.filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1)
.map((e) => String(e.className).slice(0, 40)).slice(0, 4),
}));
for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
console.log(`\n=== DogFather · ${wie}`);
const kontext = await browser.newContext({ viewport: { width: breite, height: 1200 } });
const konsole = [];
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const r = await lesen(seite);
console.log(" Gruß: ", r.gruss);
console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · "));
console.log(" Zahlen: ", r.kacheln.filter((k) => k.zahl).map((k) => `${k.name}=${k.zahl}${k.warn ? "!" : ""}`).join(" · "));
ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`);
ok(r.kacheln.length === 17, `siebzehn Kacheln (${r.kacheln.length})`);
ok(/^(Guten Morgen|Guten Mittag|Guten Tag|Guten Abend|Späten Abend|Noch wach), Filipe$/
.test(r.gruss || ""), `Begrüßung nach Tageszeit ("${r.gruss}")`);
/* Der Name muss eigenständig ausgezeichnet sein -- er ist das Wichtigste
an der Zeile und wird kräftiger gesetzt als der Gruß davor. */
ok(r.grussName === "Filipe", `der Name steht für sich (${r.grussName})`);
ok(r.grussWort.trim().endsWith(","), `und das Grußwort davor (${r.grussWort.trim()})`);
/* Datumszeile: Wochentag, Datum, Kalenderwoche. Die KW wird selbst
gerechnet (ISO 8601) -- eine falsche KW wäre schlimmer als keine. */
console.log(" Datum: ", r.datum, r.marker ? `· ${r.marker}` : "");
/* Kein \w im Monatsnamen: "März" enthält einen Umlaut, den \w ohne
Unicode-Schalter nicht kennt. Diese Prüfung wäre im März stillschweigend
rot geworden -- ein Fehler, der sieben Monate lang unsichtbar wartet. */
ok(/^(Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag), \d{1,2}\. [A-Za-zÄÖÜäöü]+ · KW \d{1,2}$/
.test(r.datum), `Datum mit Kalenderwoche ("${r.datum}")`);
const kw = Number((r.datum.match(/KW (\d+)/) || [])[1]);
ok(kw >= 1 && kw <= 53, `die Kalenderwoche ist plausibel (${kw})`);
/* An Tagen mit Charakter steht ein Wort dabei -- an den übrigen nicht.
Beides ist richtig, aber es muss zum Wochentag passen. */
const tag = r.datum.split(",")[0];
const erwartet = { Montag: "Neue Woche", Freitag: "Endspurt",
Samstag: "Wochenende", Sonntag: "Wochenende" }[tag] || null;
ok(r.marker === erwartet,
`das Wort zum Tag passt zum ${tag} (${r.marker || "keins"}, erwartet ${erwartet || "keins"})`);
/* Die Plakette: Anfangsbuchstabe, rund, in der Farbe der Rolle. */
ok(r.plakette === "F", `die Plakette trägt den Anfangsbuchstaben (${r.plakette})`);
ok(r.plaketteRolle === "admin", `und die Rolle für ihre Farbe (${r.plaketteRolle})`);
ok(/50%|9999px/.test(r.plaketteRund), `und ist rund (${r.plaketteRund})`);
/* Der Satz zur Lage kommt aus denselben Hinweisen wie die Liste
darunter -- keine zweite Zählung. Hier liegt Überfälliges vor,
also muss er das sagen und rot sein. */
console.log(" Lage: ", r.lage);
ok(r.lageStufe === "warnung", `die Lage meldet Überfälliges (${r.lageStufe})`);
ok(/^\d+ Sachen? (ist|sind) überfällig/.test(r.lage || ""), `und sagt es im Klartext ("${r.lage}")`);
/* Sie muss zu den Hinweisen passen, sonst stünden zwei Wahrheiten
übereinander auf einem Bildschirm. */
const dringend = r.dranZeilen.filter((z) => z.stufe === "warnung")
.reduce((s, z) => s + Number(z.zahl), 0);
ok(Number((r.lage.match(/^\d+/) || [])[0]) === dringend,
`und stimmt mit den Hinweisen überein (${r.lage.match(/^\d+/)} = ${dringend})`);
/* Der Schriftzug oben links hat jetzt einen Rang statt einer Farbe.
Die geschützten Leerzeichen aus dem Quelltext bleiben erhalten (der
Schriftzug darf nicht umbrechen) -- beim Vergleich werden sie
deshalb gleichgesetzt, sonst prüft man Unsichtbares. */
const ohneNbsp = (t) => t.replace(/ /g, " ");
ok(ohneNbsp(r.markeHaupt) === "Dogfather Universe", `der Name steht vorn ("${r.markeHaupt}")`);
ok(ohneNbsp(r.markeLeise) === "Creator Workspace", `der Ort dahinter ("${r.markeLeise}")`);
ok(r.markeRauten === 1, `und dazwischen genau eine Raute (${r.markeRauten})`);
/* Der geschützte Leerraum muss WIRKLICH noch da sein -- ginge er beim
Aufteilen verloren, bräche "Creator Workspace" auf dem Handy um. */
ok(r.markeHaupt.includes(" ") && r.markeLeise.includes(" "),
"und der Schriftzug kann weiterhin nicht umbrechen");
/* Jede Kachel muss ein Zeichen haben -- eine ohne waere ein leeres
Kaestchen und faellt sofort auf. */
/* Das Wasserzeichen: dasselbe Zeichen noch einmal, riesig und schwach
im Hintergrund. Es macht jede Kachel unverwechselbar -- fehlt es,
sehen alle wieder gleich aus. */
ok(r.wasserzeichen === r.kacheln.length,
`jede Kachel trägt ihr Wasserzeichen (${r.wasserzeichen} von ${r.kacheln.length})`);
ok(r.wasserzeichenSchwach, `und es ist schwach genug, um nicht zu stören (${r.wasserzeichenDeckung})`);
ok(r.kacheln.every((k) => k.pfade > 0),
`jede Kachel hat ein Zeichen (${r.kacheln.filter((k) => !k.pfade).map((k) => k.name).join(", ") || "alle"})`);
ok(r.kacheln.every((k) => k.unter && k.unter.length > 4), "jede Kachel sagt, was drin ist");
ok(r.kacheln.every((k) => k.ziel && k.ziel !== "#"), "jede Kachel führt irgendwohin");
/* Acht verschiedene Farben, und nirgends steht "ÖFFNEN" mehr. */
const toene = new Set(r.kacheln.map((k) => k.farbe));
/* JEDE Kachel ihre eigene Farbe -- siebzehn verschiedene, keine
doppelt. (Der Creator sieht "Mein Profil" statt "Creator-Profile";
das sind zwei Namen fuer denselben Bereich und teilen sich einen
Ton -- deshalb wird hier gegen die Zahl der KACHELN geprueft.) */
ok(toene.size === r.kacheln.length,
`jede Kachel hat ihre eigene Farbe (${toene.size} Farben auf ${r.kacheln.length} Kacheln)`);
const oeffnen = await seite.evaluate(() => document.body.textContent.includes("ÖFFNEN"));
ok(!oeffnen, "das Wort ÖFFNEN kommt nicht mehr vor");
/* Die Zahlen müssen auf der RICHTIGEN Kachel stehen. */
const auf = (n) => r.kacheln.find((k) => k.name === n);
ok(auf("Aufgaben")?.zahl === "4",
`Aufgaben trägt 4 (2 überfällig + 1 heute + 1 Review) (${auf("Aufgaben")?.zahl})`);
ok(auf("Aufgaben")?.warn === true, "und ist als Warnung markiert");
ok(auf("Kalender")?.zahl === "1", `Kalender trägt 1 (${auf("Kalender")?.zahl})`);
ok(auf("LIVE-Analyse")?.zahl === "1", `der dringende Bereichspunkt (${auf("LIVE-Analyse")?.zahl})`);
ok(!auf("Wissens-Bibliothek")?.zahl, "wo nichts liegt, steht keine Zahl");
/* Die Kacheln sollen spürbar größer sein als vorher (Zeichenfeld 38px,
Name .93rem). Gemessen statt geglaubt -- "sieht größer aus" ist kein
Nachweis. */
ok(r.zeichenGroesse >= 46, `Zeichenfeld mindestens 46px (${r.zeichenGroesse}px)`);
ok(r.namenGroesse >= 16, `Name mindestens 16px (${r.namenGroesse}px)`);
ok(r.kachelHoehe >= 80, `Kachel mindestens 80px hoch (${r.kachelHoehe}px)`);
ok(r.abgeschnitten.length === 0,
`kein Text wird abgeschnitten (${r.abgeschnitten.join(" | ") || "keiner"})`);
ok(r.hinweise <= 4, `höchstens vier Hinweise sichtbar (${r.hinweise} von ${r.hinweiseGesamt})`);
/* --- Die Hinweiszeilen ------------------------------------------------
Eine Zeile ohne Zeichen oder ohne Zahl waere ein halbes Bauteil und
faellt beim Ueberfliegen sofort auf. */
ok(r.dranZeilen.length > 0, `es liegen Hinweise vor (${r.dranZeilen.length})`);
ok(r.dranZeilen.every((z) => z.pfade > 0),
`jede Hinweiszeile trägt ihr Zeichen (${r.dranZeilen.filter((z) => !z.pfade).length} ohne)`);
ok(r.dranZeilen.every((z) => /^[1-9]\d*$/.test(z.zahl || "")),
`jede Hinweiszeile stellt ihre Zahl voran (${r.dranZeilen.map((z) => z.zahl).join(",")})`);
/* Die Zahl steht vorn -- im Satz darf sie nicht noch einmal stehen. */
ok(r.dranZeilen.every((z) => !/^\d/.test(z.text)),
`und keine Zahl steht doppelt (${r.dranZeilen.map((z) => z.text).find((t) => /^\d/.test(t)) || "keine"})`);
/* Vorleseprogramme bekommen den ganzen Satz, nicht "2" und "Aufgaben sind
überfällig" als zwei Bruchstuecke. */
ok(r.dranZeilen.every((z) => z.label.includes(z.zahl) && z.label.length > 10),
"und der ganze Satz steht für Vorleseprogramme dran");
/* Farbe folgt dem Bereich, nicht dem Bauteil: Der Ton der Zeile muss
der Ton einer wirklich vorhandenen Kachel sein. */
const kachelToene = new Set(r.kacheln.map((k) => k.ton));
ok(r.dranZeilen.every((z) => z.ton && kachelToene.has(z.ton)),
`jede Zeile trägt den Farbton ihrer Kachel (${r.dranZeilen.map((z) => z.ton).join(",")})`);
/* Und zwar wirklich DIESELBE Farbe, an echten Bildpunkten gemessen --
"hat einen Ton" reicht nicht, sonst faellt nicht auf, wenn alle
Zeilen denselben tragen. */
const kachelFarbe = new Map(r.kacheln.map((k) => [k.ton, k.farbe]));
const falsch = r.dranZeilen.filter((z) => z.stufe !== "warnung")
.filter((z) => z.zahlFarbe !== kachelFarbe.get(z.ton));
ok(falsch.length === 0,
`und die Farbe ist die der Kachel (${falsch.map((z) => `${z.ton}: ${z.zahlFarbe} statt ${kachelFarbe.get(z.ton)}`).join(" | ") || "alle"})`);
/* Ueberfaellig schlaegt Bereichsfarbe -- dort muss Rot stehen, sonst
geht die Dringlichkeit in siebzehn Farben unter. */
const warnZeilen = r.dranZeilen.filter((z) => z.stufe === "warnung");
ok(warnZeilen.length > 0 && warnZeilen.every((z) => z.zahlFarbe === "rgb(255, 154, 162)"),
`überfällige Zeilen sind rot (${warnZeilen.length} Stück, ${warnZeilen[0]?.zahlFarbe})`);
/* Und die uebrigen eben NICHT rot -- sonst waere die Regel wertlos. */
ok(r.dranZeilen.filter((z) => z.stufe !== "warnung")
.every((z) => z.zahlFarbe !== "rgb(255, 154, 162)"),
"und nur die");
ok(r.zahlen === 6, `die Aufgabenzahlen stehen (${r.zahlen})`);
ok(!r.zahlenLeer, "und nicht der Leer-Satz");
ok(r.ueber <= 0, `nichts steht über (${r.ueber}px, ${r.ueberstehend.join(", ")})`);
ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
await seite.screenshot({ path: breite === 1440 ? "pruef-start-computer.png" : "pruef-start-handy.png", fullPage: true });
await kontext.close();
}
console.log("\n=== Creator");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "creator", "CODE-ANNA-0001");
const r = await lesen(seite);
console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · "));
const namen = r.kacheln.map((k) => k.name);
ok(namen.includes("Mein Profil"), "sieht sein eigenes Profil");
ok(!namen.includes("Creator-Profile"), "und nicht die Liste aller Creator");
ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung");
ok(!namen.includes("Scout-Pipeline"), "keine Scout-Pipeline");
ok(!namen.includes("Automationen"), "keine Automationen");
ok(r.gruppen.length === 3, `trotzdem drei Gruppen (${r.gruppen.length})`);
ok(r.kacheln.every((k) => k.pfade > 0), "alle Zeichen da");
/* Von "Team & System" bleibt für ihn genau eine Kachel übrig. Eine
Überschrift, die Team und System verspricht und nur die Bibliothek
zeigt, verspricht etwas Falsches -- deshalb heißt sie hier "Wissen". */
ok(r.gruppenNamen.includes("Wissen"), `die Gruppe heißt "Wissen" (${r.gruppenNamen.join(" · ")})`);
ok(!r.gruppenNamen.includes("Team & System"), "und nicht mehr \"Team & System\"");
/* Seine Begrüßung: eigene Rollenfarbe, und da nichts anliegt, der
ruhige Satz statt eines erfundenen Alarms. */
ok(r.plaketteRolle === "creator", `die Plakette kennt seine Rolle (${r.plaketteRolle})`);
/* Tili hat selbst überfällige Aufgaben -- die Lage MUSS das sagen.
Entscheidend ist nicht, welche Stufe herauskommt, sondern dass sie
zu dem passt, was direkt darunter steht. Zwei verschiedene Zahlen
auf einem Bildschirm wären der eigentliche Fehler. */
const cDringend = r.dranZeilen.filter((z) => z.stufe === "warnung")
.reduce((s, z) => s + Number(z.zahl), 0);
ok(cDringend > 0 ? r.lageStufe === "warnung" : r.lageStufe !== "warnung",
`die Lage passt zu seinen Hinweisen (${r.lageStufe}: "${r.lage}")`);
ok(cDringend === 0 || Number((r.lage.match(/^\d+/) || [])[0]) === cDringend,
`und zur Zahl darunter (${cDringend})`);
await seite.screenshot({ path: "pruef-start-creator.png", fullPage: true });
await kontext.close();
}
console.log("\n=== Scout");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "scout", "CODE-SCOU-0001");
const r = await lesen(seite);
const namen = r.kacheln.map((k) => k.name);
ok(namen.includes("Scout-Pipeline"), "sieht seine Pipeline");
ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung");
ok(!namen.includes("Automationen"), "keine Automationen");
ok(namen.includes("Creator-Profile"), "aber die Creator-Profile");
await kontext.close();
}
/* Der Satz "Nichts offen" ist ein Kasten mit display:flex geworden. Damit
ist er staerker als das hidden des Browsers -- ohne eine eigene Regel
stuende er IMMER da, auch wenn Arbeit offen ist. Genau das wird hier
gemessen, in beide Richtungen. */
console.log("\n=== Der Satz 'Nichts offen' erscheint nur, wenn nichts offen ist");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const r = await seite.evaluate(() => {
const k = document.getElementById("zahlen-leer");
const zu = getComputedStyle(k).display;
k.hidden = false;
const auf = getComputedStyle(k).display;
const haken = k.querySelectorAll("svg path").length;
k.hidden = true;
return { zu, auf, haken };
});
ok(r.zu === "none", `versteckt heisst versteckt (${r.zu})`);
ok(r.auf !== "none", `und sichtbar heisst sichtbar (${r.auf})`);
ok(r.haken > 0, `der Haken ist da (${r.haken})`);
await kontext.close();
}
console.log("\n=== Weniger Bewegung: keine Animation");
{
const kontext = await browser.newContext({
viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const bewegung = await seite.evaluate(() => {
const k = document.querySelector(".kachel");
const s = getComputedStyle(k);
return { animation: s.animationName, sichtbar: s.opacity };
});
ok(bewegung.animation === "none", `keine Animation (${bewegung.animation})`);
ok(Number(bewegung.sichtbar) === 1, `und trotzdem voll sichtbar (${bewegung.sichtbar})`);
await kontext.close();
}
console.log("\n=== Das Licht folgt dem Zeiger");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
const kachel = seite.locator(".kachel").nth(1);
const kasten = await kachel.boundingBox();
const ruhe = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)");
/* Seit dem 01.09.2026 gibt es zum Fleck auch einen RAND, der auf der
Kante mitwandert. Er entsteht aus einem Verlauf, von dem eine Maske
nur den Saum stehen laesst -- geht die Maske verloren, waere die
ganze Karte eingefaerbt und der Text unlesbar. Deshalb wird hier
nicht "sieht gut aus" geprueft, sondern an ECHTEN Bildpunkten
gemessen, ob die Kante heller ist als die Mitte. */
{
await seite.mouse.move(kasten.x + kasten.width * 0.5, kasten.y + kasten.height * 0.5);
await seite.waitForTimeout(450);
const bild = await seite.screenshot({ type: "png" });
const r = await seite.evaluate(async ({ b64, kasten }) => {
const bild = new Image();
bild.src = "data:image/png;base64," + b64;
await bild.decode();
const c = document.createElement("canvas");
c.width = bild.width; c.height = bild.height;
const g = c.getContext("2d");
g.drawImage(bild, 0, 0);
const skala = bild.width / window.innerWidth;
const hell = (x, y) => {
const p = g.getImageData(Math.round(x * skala), Math.round(y * skala), 1, 1).data;
return 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
};
/* Der Zeiger steht in der Mitte. Gemessen wird die Kante direkt
darueber (dort muss der Rand leuchten) gegen dieselbe Kante an
der weit entfernten Ecke (dort nicht). */
return {
licht: !!document.querySelector(".kachel .licht"),
kanteNah: hell(kasten.x + kasten.width * 0.5, kasten.y + 1.5),
kanteFern: hell(kasten.x + 3, kasten.y + kasten.height - 2),
};
}, { b64: bild.toString("base64"), kasten });
ok(r.licht, "die Karten haben ein Licht-Element bekommen");
/* Die nahe Kante muss spuerbar heller sein als die ferne. Ohne die
Maske waere die ganze Karte gleich hell -- der Unterschied ist
genau der Nachweis, dass der Rand wirklich der Maus folgt. */
ok(r.kanteNah > r.kanteFern + 8,
`der Rand leuchtet dort, wo der Zeiger ist `
+ `(nah ${r.kanteNah.toFixed(0)} gegen fern ${r.kanteFern.toFixed(0)})`);
await seite.mouse.move(5, 5);
await seite.waitForTimeout(400);
}
/* --- Bleibt das Licht haengen? -----------------------------------------
Beanstandet am 01.09.2026: "teilweise bleibt das Licht haengen".
Es gab dafuer vier Wege, und jeder einzelne wird hier durchgespielt.
Alle vier haben dasselbe Merkmal: Der Zeiger ist weg, aber die Karte
leuchtet weiter. */
{
const anzahlAn = () => seite.evaluate(() =>
document.querySelectorAll('[data-licht="an"]').length);
/* 1. Ueber die Luecke zwischen zwei Kacheln hinaus. */
const k1 = await seite.locator(".kachel").nth(1).boundingBox();
await seite.mouse.move(k1.x + k1.width / 2, k1.y + k1.height / 2);
await seite.waitForTimeout(300);
ok(await anzahlAn() === 1, `beim Ueberfahren leuchtet genau eine Kachel (${await anzahlAn()})`);
await seite.mouse.move(k1.x + k1.width / 2, k1.y - 30); // in die Luecke darueber
await seite.waitForTimeout(350);
ok(await anzahlAn() === 0, `in der Luecke geht es aus (${await anzahlAn()})`);
/* 2. Direkt von einer Kachel auf die naechste -- es darf nie mehr
als eine gleichzeitig leuchten. */
const k2 = await seite.locator(".kachel").nth(2).boundingBox();
await seite.mouse.move(k1.x + k1.width / 2, k1.y + k1.height / 2);
await seite.waitForTimeout(250);
await seite.mouse.move(k2.x + k2.width / 2, k2.y + k2.height / 2);
await seite.waitForTimeout(300);
ok(await anzahlAn() === 1, `beim Wechsel leuchtet weiterhin nur eine (${await anzahlAn()})`);
/* 3. Gescrollt, ohne die Maus zu bewegen. Die Karte wandert unter
dem stehenden Zeiger weg -- es kommt KEIN Zeigerereignis. Das war
der haeufigste Fall. */
await seite.mouse.wheel(0, 600);
await seite.waitForTimeout(450);
ok(await anzahlAn() === 0, `nach dem Scrollen ist es aus (${await anzahlAn()})`);
/* WIEDER HOCH. Ohne das lagen die Kacheln fuer alle folgenden
Pruefungen 600 px weiter oben -- die naechste Pruefung bewegte den
Zeiger dann ins Leere und meldete einen Fehler, den es nicht gab. */
await seite.mouse.wheel(0, -600);
await seite.waitForTimeout(400);
/* 4. Der Wettlauf: schnell hinein und sofort wieder hinaus. Der
angemeldete Bildaufbau lief frueher NACH dem Verlassen und
schaltete das Licht wieder an -- so blieb es haengen. */
await seite.mouse.move(0, 0);
await seite.waitForTimeout(200);
const k3 = await seite.locator(".kachel").nth(0).boundingBox();
if (k3) {
await seite.mouse.move(k3.x + 20, k3.y + 20);
await seite.mouse.move(k3.x - 60, k3.y - 60); // im selben Wimpernschlag wieder raus
await seite.waitForTimeout(500);
ok(await anzahlAn() === 0, `hinein und sofort wieder hinaus: aus (${await anzahlAn()})`);
}
}
/* --- Die Farbe des Lichts gehoert zur Kachel ---------------------------
Beanstandet: Es leuchtete in der Farbe der SEITE statt in der der
Kachel. Geprueft wird, dass zwei verschieden gefaerbte Kacheln auch
verschieden gefaerbtes Licht bekommen. */
{
const farben = [];
for (const nr of [1, 2, 3]) {
const b = await seite.locator(".kachel").nth(nr).boundingBox();
if (!b) continue;
await seite.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
await seite.waitForTimeout(300);
farben.push(await seite.evaluate((n) => {
const k = document.querySelectorAll(".kachel")[n];
const l = k.querySelector(".licht");
return {
ton: getComputedStyle(k).getPropertyValue("--ton").trim(),
licht: l ? getComputedStyle(l, "::before").backgroundImage.slice(0, 90) : null,
};
}, nr));
}
const toene = new Set(farben.map((f) => f.ton));
ok(toene.size === farben.length,
`jede Kachel hat ihren eigenen Ton (${[...toene].join(", ")})`);
/* Die Farbe im Verlauf NICHT als Text vergleichen: Der Browser
rechnet color-mix() aus und schreibt das Ergebnis je nach Fassung
als rgb(), color(srgb ...) oder oklab() zurueck. Ein
Zeichenkettenvergleich schlaegt dann fehl, obwohl die Farbe
stimmt -- genau das ist beim ersten Lauf passiert.
Verglichen werden stattdessen die Lichter UNTEREINANDER: Drei
verschieden gefaerbte Kacheln muessen drei verschiedene Verlaeufe
ergeben. Kaeme die Farbe weiterhin von der SEITE (der Fehler, den
Filipe gesehen hat), waeren alle drei gleich. */
const lichter = new Set(farben.map((f) => f.licht));
ok(lichter.size === farben.length,
`und das Licht ist je Kachel ein anderes (${lichter.size} von ${farben.length})`);
/* Zusaetzlich an echten Bildpunkten: Das Licht der Aufgaben-Kachel
(orange) muss mehr Rot als Blau haben, das der Kalender-Kachel
(tuerkis) mehr Blau als Rot. Das kann keine Zeichenkette
vortaeuschen. */
for (const [name, pruefung] of [["Aufgaben", (p) => p[0] > p[2]],
["Kalender", (p) => p[2] > p[0]]]) {
const b = await seite.evaluate((n) => {
const k = [...document.querySelectorAll(".kachel")]
.find((x) => x.querySelector(".kachel__name")?.textContent === n);
if (!k) return null;
const r = k.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}, name);
if (!b) continue;
await seite.mouse.move(b.x, b.y);
await seite.waitForTimeout(350);
const bild = await seite.screenshot({ type: "png" });
const p = await seite.evaluate(async ({ b64, x, y }) => {
const i = new Image(); i.src = "data:image/png;base64," + b64; await i.decode();
const c = document.createElement("canvas");
c.width = i.width; c.height = i.height;
const g = c.getContext("2d"); g.drawImage(i, 0, 0);
const s = i.width / window.innerWidth;
return [...g.getImageData(Math.round(x * s), Math.round(y * s), 1, 1).data];
}, { b64: bild.toString("base64"), x: b.x, y: b.y });
ok(pruefung(p), `das Licht auf "${name}" hat die richtige Farbe `
+ `(rot ${p[0]}, grün ${p[1]}, blau ${p[2]})`);
}
await seite.mouse.move(2, 2);
await seite.waitForTimeout(300);
}
ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`);
await seite.mouse.move(kasten.x + kasten.width * 0.2, kasten.y + kasten.height * 0.3);
await seite.waitForTimeout(250);
const links = await kachel.evaluate((k) => k.style.getPropertyValue("--mx"));
await seite.mouse.move(kasten.x + kasten.width * 0.85, kasten.y + kasten.height * 0.6);
await seite.waitForTimeout(250);
const rechts = await kachel.evaluate((k) => k.style.getPropertyValue("--mx"));
console.log(` links: ${links} · rechts: ${rechts}`);
ok(parseFloat(links) < 40, `links steht der Fleck links (${links})`);
ok(parseFloat(rechts) > 60, `rechts steht er rechts (${rechts})`);
/* Beim Verlassen zurück in die Ruhelage. Ohne das blieben die Kacheln
nach einer Weile alle unterschiedlich beleuchtet stehen, ohne dass
jemand wüsste, warum. */
await seite.mouse.move(kasten.x - 250, kasten.y - 250);
await seite.waitForTimeout(250);
const danach = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)");
ok(danach === "(Ruhelage)", `nach dem Verlassen wieder in Ruhe (${danach})`);
await kontext.close();
}
console.log("\n=== Ohne feinen Zeiger bleibt es aus");
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001");
await seite.touchscreen.tap(200, 700).catch(() => {});
await seite.waitForTimeout(200);
const gesetzt = await seite.evaluate(() =>
[...document.querySelectorAll(".kachel")].some((k) => k.style.getPropertyValue("--mx")));
ok(!gesetzt, "auf dem Handy wird nichts gesetzt");
await kontext.close();
}
await browser.close();
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler === 0 ? 0 : 1);