Files
dogfather-universe/server/pruef-start-ansicht.mjs
T
DogFatherGitandClaude Opus 5 0ffb9e8781 Die Kategorien in Filipes Reihenfolge -- und das Band jetzt ueberall
Vier Meldungen von Filipe, drei davon erledigt.

1. "DIE DOGFATHER ROLLE SIEHT DIE CREATOR NICHT MEHR" -- NICHTS KAPUTT.
   Nachgestellt mit frischer Datenbank und fuenf Creator: DogFather
   sieht auf allen sechs Seiten mit Creator-Umschalter alle fuenf. In
   der SICHT VON MIESMUSCHEL dagegen steht auf leistung.html und
   profil.html genau einer -- ihr Name. Genau das zeigt sein
   Bildschirmfoto. Er ist noch in der fremden Sicht von gestern.

   MEINE SCHULD, NICHT SEINE. Gestern habe ich das Hinweis-Band
   ausdruecklich nur aufs Handy gelegt, mit der Begruendung, am Rechner
   stehe der Name ja im Umschalter und zwei Anzeigen fuer dieselbe
   Sache seien eine zu viel. Einen Tag spaeter ist er am RECHNER darauf
   hereingefallen, mit sichtbarem Namen im Umschalter UND goldenem
   Rahmen. Damit ist die Begruendung widerlegt -- nicht durch ein
   Argument, sondern durch den Fall. Das Band steht ab jetzt ueberall.

   NEBENBEFUND, NICHT ANGEFASST: Die fremde Sicht greift nur auf der
   Haelfte der Seiten. leistung und profil folgen ihr, bereich, content,
   report und startcheck zeigen weiter alle Creator. Halb umgesetzt ist
   schlechter als gar nicht -- das gehoert entschieden, nicht nebenbei
   geaendert.

2. "RUND UM DAS TEAM UEBER TAEGLICH" -- verschoben, mitsamt dem Absatz,
   der die alte Stelle begruendet hat.

3. "TEAM DOGI UND ENTWICKLUNG GANZ UNTEN, NUR DOGFATHER UND VANVAN".
   `gruppeNach: "Täglich"` -> `"Team & System"`, der letzten Gruppe der
   Liste. Als NAME und nicht als Position: Eine Zahl waere beim
   naechsten Umsortieren still falsch, und still falsch hiesse hier,
   dass privates Material wieder nach oben rutscht.

   DIE SICHTBARKEIT WAR SCHON RICHTIG -- nachgesehen statt angenommen:
   Auf der Workspace-Adresse bekommt die Kacheln nur `admin`. VanVan
   traegt die Rolle `hand` und kann sich dort gar nicht anmelden
   (sitzungPasstZurAdresse weist Team-Dogi-Rollen ab); sie sieht
   dieselben Kacheln auf der crew-Adresse ueber HAND_BEREICHE. Die
   Modis sehen sie nicht -- Entwicklung und Talente stehen nicht in
   MODI_BEREICHE. Am Livesystem geprueft: genau ein admin, eine hand.

   Gemessen kommt fuer DogFather heraus:
     Rund um das Team | Taeglich | Rund um den Creator | Team & System
     | Team Dogi | Entwicklung & Nachwuchs
   Spicy Media sieht dieselbe Folge ohne die letzten beiden, Manager
   und Creator wie bisher.

UND EINE PRUEFUNG, DIE DAS FALSCHE GEMESSEN HAT
pruef-start-ansicht wurde durch die neue Reihenfolge rot -- ohne dass
eine Kachel kleiner geworden waere. Sie las
`querySelector(".kachel__zeichen")`, also die ERSTE Kachel der Seite.
Solange "Taeglich" oben stand, war das zufaellig die grosse
Dashboard-Kachel. Die Pruefung hat damit nie belegt, was ihr Kommentar
behauptet ("die Kacheln sollen spuerbar groesser sein"), sondern nur
"die erste ist die grosse".

Jetzt misst sie die grosse Kachel ausdruecklich UND die kleinste aller
Kacheln, mit eigenen Untergrenzen. Das ist strenger als vorher: Vorher
konnte jede Kachel ausser der ersten beliebig schrumpfen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 11:04:39 +02:00

976 lines
53 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 }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
notbremse(480_000, "pruef-start-ansicht");
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?.();
/* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des
Benutzers; toISOString() liefert dagegen UTC und damit zwischen
Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese
Pruefung fehl, ohne dass etwas kaputt war. */
const tagText = (d = new Date()) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
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 = tagText();
const gestern = tagText(new Date(Date.now() - 86400_000));
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") || "",
})),
/* DIE GROSSE KACHEL AUSDRUECKLICH, NICHT DIE ERSTE (11.09.2026).
Hier stand `querySelector(".kachel__zeichen")` -- also die erste
Kachel der Seite, welche auch immer das gerade ist. Solange
"Taeglich" oben stand, war das zufaellig die grosse Dashboard-Kachel
mit 46 px. Seit "Rund um das Team" davor steht, ist es eine normale
mit 40 px, und die Pruefung wurde rot -- ohne dass an einer Kachel
irgendetwas kleiner geworden waere.
Die Pruefung hat also nie gemessen, was ihr Kommentar behauptet
("die Kacheln sollen spuerbar groesser sein"), sondern "die erste
ist die grosse". Jetzt werden BEIDE Groessen gemessen: die grosse
Kachel an ihrem Mass und die kleinste aller Kacheln an ihrem. */
zeichenGroesse: Math.round(document.querySelector('[data-gross="ja"] .kachel__zeichen')
?.getBoundingClientRect().width || 0),
zeichenKleinstes: Math.round(Math.min(...[...document.querySelectorAll(".kachel__zeichen")]
.map((e) => e.getBoundingClientRect().width), Infinity)),
namenKleinstes: Math.round(Math.min(...[...document.querySelectorAll(".kachel__name")]
.map((e) => parseFloat(getComputedStyle(e).fontSize) || 0), Infinity)),
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('[data-gross="ja"] .kachel__name')
|| 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(" · "));
/* VIER, nicht drei (berichtigt 10.09.2026).
Die Erwartung stand auf drei, seit b45de94 eine vierte Gruppe
hinzugefuegt hat ("Rund um das Team -- die Arbeitslage von Managern
und Scouts"). Die Anwendung war richtig, die Zahl hier alt -- und
diese Pruefung damit rot, ohne dass es jemandem aufgefallen ist.
Gefunden hat sie nicht ein Gesamtlauf, sondern die Frage "was kann
an meiner Aenderung kaputtgehen?" -- und danach die Gegenprobe auf
dem unveraenderten Stand, die zeigte: schon vorher rot, nicht von
mir.
GEPRUEFT WERDEN JETZT DIE NAMEN, nicht nur die Anzahl. Vier Gruppen
koennten auch drei richtige und eine doppelte sein, und dann stuende
hier wieder eine gruene Zahl ueber einem falschen Bildschirm. */
/* NACHTRAG 10.09.2026: FUENF -- "Team Dogi" an zweiter Stelle.
Filipe musste die Kacheln seines Teams zwischen den uebrigen suchen.
Sie haben jetzt eine eigene Gruppe, und die steht bewusst NICHT am
Ende: `gruppeNach` in server/workspace.js schiebt sie direkt hinter
die taegliche Arbeit. Die REIHENFOLGE ist hier deshalb die eigentliche
Aussage -- eine Gruppe, die ans Ende rutscht, ist genau der Fehler,
den diese Zeile finden soll, und auf dem Bildschirm faellt er kaum auf.
Die Gruppe kommt vom Server (`bereiche_zusatz`), nicht aus
bereiche.js -- sie steht in keiner Datei, die jeder herunterladen kann.
Die Kachelzahl blieb bei 24: der Eingang ist umgezogen, das Ideen-Board
aus bereiche.js hierher gewandert, keine Kachel ist neu. */
/* NACHTRAG 11.09.2026: SECHS -- "Entwicklung & Nachwuchs" an dritter
Stelle.
Sie steht dort nicht zufaellig: `gruppeNach: "Team Dogi"` setzt sie
direkt hinter das Team. Was ueber Menschen aufgeschrieben wird,
gehoert neben das Team und nicht zwischen die taeglichen
Werkzeuge -- und schon gar nicht ans Ende, wo es niemand ansieht.
Die Kachelzahl steigt von 25 auf 27; beide Kacheln sind neu
(Entwicklung, Talente), keine ist umgezogen. */
/* NEUE REIHENFOLGE AM 11.09.2026 (Filipe, zwei Wuensche an einem Tag).
1. "ich will dass die kategorie [Rund um das Team] ueber der
kategorie ist [Taeglich]" -- wer die Leitung hat, fragt zuerst,
wo es im Team klemmt.
2. "diese zwei kategorien sollen ... ganz unten sein ... goldene
regel das sind private informationen von meiner community."
Der Absatz darueber sagte bis heute das Gegenteil ("und schon gar
nicht ans Ende, wo es niemand ansieht"). Das war meine Begruendung,
nicht seine Entscheidung -- und bei privaten Dingen wiegt "faellt
niemandem ins Auge" schwerer als "wird oft angesehen". */
const ERWARTETE_GRUPPEN = ["Rund um das Team", "Täglich", "Rund um den Creator",
"Team & System", "Team Dogi", "Entwicklung & Nachwuchs"];
ok(JSON.stringify(r.gruppen.map((g) => g.name)) === JSON.stringify(ERWARTETE_GRUPPEN),
`sechs Gruppen in dieser Reihenfolge (${r.gruppen.map((g) => g.name).join(" · ")})`);
/* WIE VIELE KACHELN? Nicht abgeschrieben, sondern aus bereiche.js
gezaehlt (06.09.2026).
Hier stand `=== 18`, mit einem Kommentar, der erklaerte, warum es
genau achtzehn sind. Am 06.09. kamen Chat, Zahlen und Agentur dazu
-- und die Pruefung meldete drei Tage lang einen Fehler, den es
nicht gab. Eine Pruefung, die bei jeder neuen Kachel rot wird,
erzieht dazu, ihr Rotwerden zu ignorieren; genau dann uebersieht
man das Mal, an dem WIRKLICH eine Kachel fehlt.
Gezaehlt wird deshalb, was diese Rolle laut bereiche.js sehen
SOLL. Fehlt eine auf dem Bildschirm, faellt es weiterhin auf --
und eine neue kostet keinen Handgriff mehr. */
/* NACHTRAG 10.09.2026: Es gibt eine ZWEITE Quelle.
Seit dem 09.09. haengt der Server einzelnen Rollen Kacheln an, die
gar nicht in bereiche.js stehen (`bereiche_zusatz`) -- gerade damit
sie nicht in einer Datei stehen, die jeder herunterladen kann. Die
Zaehlung oben kannte nur die Datei und meldete deshalb "alle 23
Kacheln stehen auf der Seite (24)": ein Fehler, den es nicht gab.
Das ist genau derselbe Fall wie am 06.09., nur eine Ebene weiter --
und dieselbe Antwort: nicht die Zahl anpassen, sondern die zweite
Quelle mitzaehlen. Gefragt wird jetzt BEIDE. */
const sollKacheln = await seite.evaluate(async () => {
const ausDatei = window.Bereiche.GRUPPEN
.flatMap((g) => g.bereiche).filter((b) => b.rollen.includes("admin")).length;
const a = await fetch("/workspace/api/ich", { credentials: "same-origin" });
const ich = a.ok ? await a.json() : {};
return ausDatei + (ich.bereiche_zusatz || []).length;
});
ok(sollKacheln > 15, `Datei und Server nennen zusammen ${sollKacheln} Kacheln fuer DogFather`);
ok(r.kacheln.length === sollKacheln,
`alle ${sollKacheln} Kacheln stehen auf der Seite (${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, " ");
/* "Spicy & Dogi" seit dem 08.09.2026 (Wunsch von Filipe): links die
Chili von Spicy Media, rechts der Husky von Dogi, und der Name
dazwischen in genau dieser Reihenfolge. */
ok(ohneNbsp(r.markeHaupt) === "Spicy & Dogi", `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, `grosse Kachel: Zeichenfeld mindestens 46px (${r.zeichenGroesse}px)`);
ok(r.namenGroesse >= 16, `grosse Kachel: Name mindestens 16px (${r.namenGroesse}px)`);
/* UND DIE KLEINSTE KACHEL HAT AUCH EIN MASS (11.09.2026).
Das ist der Teil, den die Pruefung bisher gar nicht hatte: Sie sah
immer nur EINE Kachel an. Die Untergrenzen sind die heute
gemessenen Werte -- sie sagen also "nicht kleiner als jetzt". Eine
ausgedachte Zahl waere hier falsch: Sie wuerde entweder sofort rot
oder nie. */
ok(r.zeichenKleinstes >= 40,
`kleinste Kachel: Zeichenfeld mindestens 40px (${r.zeichenKleinstes}px)`);
ok(r.namenKleinstes >= 12,
`kleinste Kachel: Name mindestens 12px (${r.namenKleinstes}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");
/* SIEBEN seit dem 08.09.2026: "Abgebrochen" ist als eigene Kategorie
dazugekommen (screen12). Vorher zaehlten abgebrochene Aufgaben
unsichtbar bei "Ueberfaellig" und "Heute faellig" mit.
Die Zahl steht bewusst hier in der Bedingung und nicht nur im
Meldetext -- eine Kategorie, die still verschwindet, faellt sonst
niemandem auf. */
ok(r.zahlen === 7, `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);
/* ERST INS BILD ROLLEN, DANN MESSEN -- und zwar VOR JEDER Benutzung.
Hier stand `const kasten = await kachel.boundingBox()` ein einziges
Mal, ganz am Anfang, ohne vorher zu rollen. Am 08.09.2026 hat das
drei Pruefungen dieses Blocks umgeworfen, ohne dass am Licht
irgendetwas kaputt war: Die Begruessungskachel wuchs beim Umbau zur
Zentrale von 244 auf 404 px, die zweite Kachel rutschte damit von
y=971 auf y=1131, und ihre Mitte lag bei 1207 -- ausserhalb eines
1200 px hohen Fensters. Dorthin kann kein Zeiger fahren, also
entstand kein Licht.
Verraten hat es die MISCHUNG aus gruen und rot: "links" (30 % der
Hoehe) bestand, "rechts" (60 %) nicht. Eine Kachel, die nur zur
Haelfte getroffen wird, ist nicht kaputt -- sie haengt halb aus dem
Bild. Genau dieser Fehler steht zwanzig Zeilen weiter unten schon
einmal beschrieben, fuer den 07.09.2026. Dort wurde er an der
einzelnen Stelle geflickt, hier nicht.
Deshalb jetzt nicht noch einmal eine feste Annahme, sondern eine
Funktion: Sie holt die Kachel MITTIG ins Bild und misst danach.
`block: "center"` statt `scrollIntoViewIfNeeded()` mit Absicht --
letzteres rollt nur so weit, dass die Kante gerade eben anliegt,
und dann liegt der 60-%-Punkt wieder am Rand. Wer die Kachel spaeter
noch hoeher macht, merkt davon nichts mehr. */
const kachelHolen = async () => {
await kachel.evaluate((k) => k.scrollIntoView({ block: "center", behavior: "instant" }));
await seite.waitForTimeout(150);
const k = await kachel.boundingBox();
/* Die Anzahl gehoert in die Bedingung, nicht in den Meldetext: Eine
Kachel, die trotz Rollen nicht ganz ins Fenster passt, wuerde die
naechsten Messungen still verfaelschen. Dann lieber hier lautstark
scheitern als drei Zeilen weiter unten raetselhaft. */
const fenster = await seite.evaluate(() => window.innerHeight);
if (k.y < 0 || k.y + k.height > fenster) {
throw new Error(`Die Kachel passt auch nach dem Rollen nicht ins Fenster `
+ `(y=${Math.round(k.y)}, Hoehe=${Math.round(k.height)}, Fenster=${fenster}). `
+ `Ist sie groesser geworden?`);
}
return k;
};
const kasten = await kachelHolen();
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.
Auch hier ueber `kachelHolen()` statt ueber ein blankes
`boundingBox()`: Die Mitte dieser Kachel lag am 08.09.2026
ausserhalb des Fensters, der Zeiger kam nie an, und die Pruefung
meldete "leuchtet genau eine Kachel (0)" -- als waere das Licht
kaputt. Es war nur niemand da, der es haette ausloesen koennen. */
const k1 = await kachelHolen();
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.
ERST INS BILD ROLLEN, DANN MESSEN, DANN ZIELEN. Diese Datei
schreibt denselben Satz zwanzig Zeilen weiter unten schon
einmal, und trotzdem stand er hier nicht: Am 07.09.2026 wurde
die Begruessungskachel hoeher, Kachel 3 rutschte auf y = 1134 bei
einem 1200 px hohen Fenster -- ihre Mitte lag bei 1210, also
UNTER dem Bildrand. Der Zeiger landete im Nichts, keine Kachel
leuchtete, und die Pruefung meldete einen Fehler am Licht,
obwohl das Licht in Ordnung war.
Der Kasten wird NACH dem Rollen neu gelesen. Ein vor dem Rollen
gemessener Kasten ist danach falsch -- das waere derselbe Fehler
mit einem Zwischenschritt mehr. */
await seite.locator(".kachel").nth(2).scrollIntoViewIfNeeded();
await seite.waitForTimeout(250);
const k2 = await seite.locator(".kachel").nth(2).boundingBox();
const k1neu = await seite.locator(".kachel").nth(1).boundingBox();
await seite.mouse.move(k1neu.x + k1neu.width / 2, k1neu.y + k1neu.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]) {
/* Auch hier: erst ins Bild rollen, dann den Kasten lesen. Eine
Kachel unter dem Bildrand bekommt keinen Zeiger, also auch
kein Licht-Element -- und `licht: null` fuer zwei von drei
Kacheln sah aus wie "zwei Kacheln leuchten gleich". Ein Fehler,
der etwas ganz anderes meldet, als er ist. */
await seite.locator(".kachel").nth(nr).scrollIntoViewIfNeeded();
await seite.waitForTimeout(200);
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));
}
/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Waeren keine Kacheln
messbar, waere `farben` leer -- und "0 verschiedene Toene bei 0
Kacheln" ist immer wahr. Grün, ohne etwas angesehen zu haben. */
ok(farben.length === 3, `drei Kacheln wirklich gemessen (${farben.length})`);
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 kann keine Zeichenkette
vortaeuschen.
GEMESSEN GEGEN DIE EIGENE FARBE DER KACHEL, nicht gegen eine
Farbe aus dem Gedaechtnis (06.09.2026). Hier stand vorher: "das
Licht der Aufgaben-Kachel (orange) muss mehr Rot als Blau haben,
das der Kalender-Kachel (tuerkis) mehr Blau als Rot." Beide
Klammern waren Wissen von aussen -- und als die 21 Farbtoene neu
gerechnet wurden, war der Kalender rosa statt tuerkis. Die
Pruefung meldete einen Fehler, den es nicht gab.
Jetzt wird der Ton der Kachel selbst ausgelesen und verlangt,
dass das gemessene Licht dieselbe Reihenfolge der Kanaele hat
(welcher ist am staerksten, welcher am schwaechsten). Das ist
genau die Aussage, um die es geht -- "das Licht traegt die Farbe
DIESER Kachel" -- und sie bleibt wahr, egal wie die Palette
gerechnet wird. */
for (const name of ["Aufgaben", "Kalender", "Agentur"]) {
/* ERST INS BILD ROLLEN. Die Agentur-Kachel liegt weit unten; ihr
Mittelpunkt lag ausserhalb des Fensters, und der Bildpunkt dort
war schwarz (0/0/0). Die Pruefung meldete daraufhin einen
Fehler, der nur bedeutete: "ausserhalb des Bildes gemessen".
Ein Wert von genau null ist fast nie ein Messergebnis. */
await seite.evaluate((n) => {
[...document.querySelectorAll(".kachel")]
.find((x) => x.querySelector(".kachel__name")?.textContent === n)
?.scrollIntoView({ block: "center", behavior: "instant" });
}, name);
await seite.waitForTimeout(250);
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();
/* Der Ton der Kachel, in Zahlen. getComputedStyle liefert ihn
als "rgb(a, b, c)". */
const ton = getComputedStyle(k).getPropertyValue("--ton").trim();
const zahlen = (ton.match(/\d+/g) || []).map(Number);
const hexTreffer = /^#([0-9a-f]{6})$/i.exec(ton);
const soll = hexTreffer
? [0, 2, 4].map((i) => parseInt(hexTreffer[1].slice(i, i + 2), 16))
: zahlen.slice(0, 3);
/* WO DIE FARBE LIEGT, HAT SICH AM 07.09.2026 GEAENDERT.
Bis dahin lag ein Farbschleier ueber der ganzen Kachel, und
die Mitte war der richtige Messpunkt. Die neue Form traegt
die Farbe auf der KANTE -- die Flaeche ist mit Absicht
neutral. An der alten Stelle gemessen kam deshalb Grau
heraus, und die Pruefung meldete einen Fehler, den es nicht
gab.
Gemessen wird jetzt das obere Kantenlicht: ein schmales Band
von 4 px Hoehe ueber dem linken Drittel, beginnend hinter
der Schraege. Nicht EIN Bildpunkt -- eine 1,6 px dicke Linie
auf einer 1-zu-1-Aufloesung trifft man mit einem Punkt nur
mit Glueck. Aus dem Band wird der farbigste Punkt genommen. */
return { x: r.x + r.width / 2, y: r.y + r.height / 2, soll, ton,
band: { x: r.x + 22, y: r.y, breite: Math.max(40, r.width / 3), hoehe: 4 } };
}, name);
if (!b || b.soll.length !== 3) {
ok(false, `"${name}": Farbton nicht lesbar (${b?.ton})`);
continue;
}
await seite.mouse.move(b.x, b.y);
await seite.waitForTimeout(350);
const bild = await seite.screenshot({ type: "png" });
const gemessen = await seite.evaluate(async ({ b64, band }) => {
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;
const d = g.getImageData(Math.round(band.x * s), Math.round(band.y * s),
Math.round(band.breite * s), Math.round(band.hoehe * s)).data;
/* Der farbigste Punkt des Bandes: groesster Abstand zwischen
staerkstem und schwaechstem Kanal. Ein grauer Streifen hat
dort fast null -- daran erkennt man auch, wenn gar kein
Licht da ist. */
let best = [0, 0, 0], spanne = -1, punkte = 0;
for (let k = 0; k + 3 < d.length; k += 4) {
punkte++;
const v = [d[k], d[k + 1], d[k + 2]];
const sp = Math.max(...v) - Math.min(...v);
if (sp > spanne) { spanne = sp; best = v; }
}
return { punkte, spanne, farbe: best };
}, { b64: bild.toString("base64"), band: b.band });
const p = gemessen.farbe;
/* DIE ANZAHL DER GEMESSENEN PUNKTE GEHOERT IN DIE BEDINGUNG.
Ein leeres Band haette sonst "Reihenfolge stimmt" gemeldet --
0 gegen 0 ist immer gleich. */
ok(gemessen.punkte > 50 && gemessen.spanne >= 6,
`"${name}": auf der Oberkante liegt ueberhaupt Farbe `
+ `(${gemessen.punkte} Punkte, Spanne ${gemessen.spanne})`);
/* DER FARBWINKEL, NICHT DIE REIHENFOLGE DER KANAELE (08.09.2026).
Hier stand ein Vergleich der Kanal-REIHENFOLGE: Welcher Kanal
ist der staerkste, welcher der zweite. Der Gedanke war richtig
-- absolute Werte taugen nicht, weil das Licht stark
abgedunkelt auf dunklem Grund liegt.
Er hat aber eine Voraussetzung, die niemand aufgeschrieben
hatte: Die Kanaele muessen deutlich verschieden sein. Seit die
Kachelfarben am 08.09.2026 neu gerechnet wurden, trifft das
nicht mehr ueberall zu. "Aufgaben" ist jetzt Tuerkis (#18bfa3)
mit G=191 und B=163 -- nach Abdunklung gemessen 63 gegen 65.
ZWEI Stufen von 255. Die Reihenfolge kippt dort durch
Rundung, und die Pruefung meldete einen Fehler, wo keiner war.
Der Farbwinkel misst dasselbe, nur ohne diese Voraussetzung:
Er sagt, WELCHE Farbe es ist, unabhaengig von Helligkeit und
Saettigung -- genau die Frage. 40 Grad Toleranz, weil das Licht
sich mit dem dunklen Untergrund mischt; verwechselbare Toene
der Palette liegen weiter auseinander (der kleinste Abstand
zweier Kachelfarben betraegt nach der Neuberechnung 0,097 in
OKLab, das sind hier gut 60 Grad).
Die Gegenprobe unten beweist, dass sie weiterhin anschlaegt. */
const winkel = (v) => {
const [r, g, bl] = v.map((x) => x / 255);
const max = Math.max(r, g, bl), min = Math.min(r, g, bl), d = max - min;
if (d < 0.004) return null; // grau, kein Ton
let h;
if (max === r) h = ((g - bl) / d) % 6;
else if (max === g) h = (bl - r) / d + 2;
else h = (r - g) / d + 4;
return ((h * 60) % 360 + 360) % 360;
};
const abstand = (a, c) => {
if (a === null || c === null) return 999;
const d = Math.abs(a - c);
return d > 180 ? 360 - d : d;
};
const sollWinkel = winkel(b.soll), istWinkel = winkel(p);
const ab = abstand(sollWinkel, istWinkel);
ok(ab <= 40,
`das Licht auf "${name}" traegt den Ton der Kachel `
+ `(Farbwinkel ${sollWinkel === null ? "grau" : Math.round(sollWinkel) + "°"} `
+ `gegen ${istWinkel === null ? "grau" : Math.round(istWinkel) + "°"}, `
+ `Abstand ${Math.round(ab)}° von 40 erlaubt) `
+ `(${b.ton}: ${b.soll.join("/")} → gemessen ${p[0]}/${p[1]}/${p[2]})`);
/* GEGENPROBE, einmal je Kachel: Eine Pruefung, die immer
bestaetigt, bestaetigt nichts. Der gemessene Ton wird gegen
eine ABSICHTLICH falsche Farbe gehalten -- den Gegenton auf dem
Farbkreis. Schlaegt das nicht an, misst die Pruefung nichts.
Der Gegenton ist der haerteste Fall: Er ist so weit entfernt,
wie zwei Farben ueberhaupt sein koennen. Wuerde die Toleranz
einmal versehentlich auf 180 Grad hochgesetzt, faellt es hier
sofort auf. */
const falsch = sollWinkel === null ? 180 : (sollWinkel + 180) % 360;
ok(abstand(falsch, istWinkel) > 40,
` und sie erkennt den Gegenton als falsch `
+ `(${Math.round(falsch)}° gegen gemessen `
+ `${istWinkel === null ? "grau" : Math.round(istWinkel) + "°"})`);
}
await seite.mouse.move(2, 2);
/* UND WIEDER GANZ NACH OBEN. Der Abschnitt oben rollt zu den
Kacheln; was danach kommt, misst am oberen Rand. Ohne diese Zeile
meldeten die folgenden Pruefungen "der Fleck steht nicht links" --
er stand sehr wohl, nur ausserhalb des Bildausschnitts. Eine
Pruefung, die den Zustand hinterlaesst, den sie vorgefunden hat,
ist die einzige, der die naechste glauben kann. */
await seite.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" }));
await seite.waitForTimeout(300);
}
ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`);
/* NEU GEMESSEN, nicht der Kasten von oben. Der Block darueber endet
mit `window.scrollTo({ top: 0 })` -- ab dieser Zeile steht die Seite
also wieder ganz oben, und jede Koordinate von vorhin zeigt um die
gerollte Strecke daneben. Genau daran ist "rechts" gescheitert. */
const kastenJetzt = await kachelHolen();
await seite.mouse.move(kastenJetzt.x + kastenJetzt.width * 0.2,
kastenJetzt.y + kastenJetzt.height * 0.3);
await seite.waitForTimeout(250);
const links = await kachel.evaluate((k) => k.style.getPropertyValue("--mx"));
await seite.mouse.move(kastenJetzt.x + kastenJetzt.width * 0.85,
kastenJetzt.y + kastenJetzt.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(kastenJetzt.x - 250, kastenJetzt.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);