Files
dogfather-universe/server/pruef-leistung-optik.mjs
T
DogFatherGitandClaude Opus 5 4eab64bd20 Eine neue Form fuer jedes Modul -- und der Kalender zeigt nur noch, was einen angeht
Filipe: "DU HAST WIEDER EINE KLEINE AENDERUNG UEBERALL GEMACHT ANSTATT
EINE RIESEN AENDERUNG." Er hatte recht, und der Grund war jedes Mal
derselbe: Ich habe das MATERIAL getauscht (Mattglas, Leuchtschiene,
Verlauf) und die FORM gelassen. Ein abgerundetes Rechteck bleibt ein
abgerundetes Rechteck -- und die Silhouette ist das Einzige, was man aus
fuenf Metern erkennt.

Jetzt ist die Form eine andere: abgeschnittene Ecke oben links
(clip-path, echte Silhouette), ein Kantenlicht in der Kategoriefarbe
darauf, Eckwinkel unten rechts, ein feines Raster statt Koernung.
35 Bauteile auf allen 18 Seiten, in einer eigenen Datei (module.css).

DREI DINGE, DIE DABEI SCHIEFGINGEN UND JETZT ABGESICHERT SIND

1. Die Regeln standen in :where() -- Spezifitaet null, also gewann jede
   aeltere .kachel::before-Regel. Jetzt :is().
2. module.css stand an DRITTER Stelle im Ladeweg. Auf Calls,
   Automationen, Bereich und Dateien hat sie damit gar nichts bewirkt:
   Die Seitendateien setzen dort selbst border-radius und box-shadow und
   kommen spaeter. Genau das ergab wieder "ueberall ein bisschen". Sie
   wird jetzt als LETZTE geladen, geprueft auf allen 18 Seiten.
3. Die Klassenliste steht siebenmal in der Datei (CSS kennt keine
   Variable fuer Selektoren). Eine vergessene Kopie faellt niemandem
   auf -- pruef-css-klassen vergleicht sie deshalb alle, mit Gegenprobe.

DER KALENDER: NUR NOCH, WAS EINEN ANGEHT

Filipe: "calls oder termine soll jeder nur sehen die er selber macht
oder jeden betrifft." Das gilt auch fuer DogFather, und das ist die
eigentliche Aenderung -- er bekam bisher 1=1. "Jeden betrifft" heisst:
ohne Gegenueber und ohne Teilnehmerliste. Wer niemanden eintraegt, meint
alle. Die Rollenfrage entfaellt im Kalender damit vollstaendig.

SECHS PRUEFUNGEN, DIE EINE WELT GEMESSEN HABEN, DIE ES NICHT MEHR GIBT

Alle sechs wurden auf die neue Regel umgeschrieben, keine geloescht --
loeschen haette die Zahl gesenkt und den neuen Weg ungeprueft gelassen:
ics, serien, sicht, spicy, teilnehmer, tagesblick, team.

UND VIER ECHTE FUNDE, DIE DABEI HERAUSFIELEN

* pruef-workspace-seiten war seit dem Bildumbau von heute Frueh auf
  ALLEN 32 Seiten rot: Sie verlangte noch das eine Motiv. Die neue
  Bedingung ist schaerfer als beide alten -- das geladene Bild muss zu
  dem Merkmal passen, das die Seite selbst traegt. Und die Meldung sagt
  jetzt, WELCHE Bedingung gefallen ist.
* pruef-leistung-optik hat sich selbst ausgesperrt (meldete sich als
  Manager an, der die Zahlen seit Filipes Anweisung nicht mehr sehen
  darf) und stuerzte danach ab: 2 Pruefungen statt 56. Der Absturz hat
  verdeckt, dass sie sieben Fingerziele als "zu klein" meldete -- es
  waren die unsichtbaren Knoepfe der zugeklappten Woche, 0x0.
* personen.html beim Manager: Der Erklaersatz ("Codes, Sperren und das
  Protokoll bleiben bei DogFather") kam nie an -- das Element gab es
  nicht, und das && davor hat den Fehlgriff verschluckt. Die Liste stand
  ausserdem dauerhaft auf aria-busy="true": fuer einen Screenreader lud
  die Seite fuer immer.
* pruef-schranke hielt personen.html noch fuer DogFather-only. Die Seite
  wechselt jetzt die Erwartung, statt aus der Pruefung zu verschwinden:
  Seite auf fuer die Leitung, Verwaltungsdaten dahinter weiterhin zu.

Die Anmeldeseite wurde nicht angefasst (nur ihr Versionsstempel).

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

622 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ZAHLEN — KANN MAN SIE EINTRAGEN, UND SAGEN SIE ETWAS?
pruef-leistung.mjs beweist, dass der Server richtig rechnet. Diese
Prüfung fragt das, worauf es danach ankommt: Kommt jemand an die
Zahlen heran, und sagt ihm die Seite etwas, das er ohne sie nicht
wüsste?
Ein Rechenwerk ohne Oberfläche ist kein Modul, sondern ein
Versprechen. Genau deshalb steht diese Prüfung neben der anderen.
GEMESSEN WIRD:
* Ein Tag wird eingetragen und steht danach in der Wochenkarte.
* Der VERGLEICH zur Vorwoche erscheint -- die Zahl allein wäre eine
Behauptung.
* Die kleine Verlaufslinie wird wirklich GEZEICHNET. Nicht "das
Element ist da": Ihre Punkte müssen Zahlen sein. Genau hier saß
ein echter Fehler -- der Server liefert im Verlauf nur drei der
sieben Größen, für die anderen kam `undefined` an, wurde zu NaN,
und der Browser zeichnete wortlos nichts.
* Der Import zeigt ERST die Vorschau. Gegenprobe: vorher darf noch
nichts in der Datenbank stehen.
* Ein Creator SIEHT seine Zahlen und kann sie NICHT eintragen.
* Schriftgrößen und Fingerziele auf dem Handy.
* Das Dashboard zeigt die Zahlen und die Frühwarnung.
GEGENPROBEN durchgehend: Zu jeder Prüfung, die "in Ordnung" sagen
kann, gehört eine, die beweist, dass sie auch "nicht in Ordnung"
sagen könnte.
===================================================================== */
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-leistoptik-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4337, "die Zahlen-Optikpruefung");
process.env.PORT = "4337";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
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");
/* 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-leistung-optik");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4337";
const PROTOKOLL = join(process.cwd(), "pruef-leistung-optik-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => {
console.log(z);
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
};
let fehler = 0;
let gemacht = 0;
const ok = (b, t) => { gemacht++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
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;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idMan = anlegen("Marek", "manager", "CODE-MANA-0001");
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idMan, jetzt);
/* Ortszeit-Datum. NIE toISOString(): das ist UTC und liegt zwischen
Mitternacht und zwei Uhr einen Tag daneben -- eine Prüfung, die um
00:30 rot wird, ohne dass sich etwas geändert hat. */
const p2 = (n) => String(n).padStart(2, "0");
const tagVor = (n) => {
const t = new Date(Date.now() - n * 86400000);
return `${t.getFullYear()}-${p2(t.getMonth() + 1)}-${p2(t.getDate())}`;
};
/* VORWOCHE bestücken, damit es überhaupt etwas zu vergleichen gibt --
und damit die Verlaufslinie genug Punkte hat. Die laufende Woche
bleibt leer: Sie wird gleich über die Oberfläche gefüllt, und das ist
der eigentliche Gegenstand dieser Prüfung. */
const setzen = d.prepare(`
INSERT INTO leistung (creator_id, tag, diamanten, dauer_min, gueltiger_tag,
zuschauer_avg, verweildauer_s, erfasst, erfasst_von, quelle)
VALUES (?,?,?,?,1,?,?,?,?, 'hand')`);
for (let i = 7; i <= 27; i++) {
/* Absteigend über drei Wochen -- damit auch die Frühwarnung etwas zu
melden hat und nicht nur "keine Befunde" heißt. */
setzen.run(idLuna, tagVor(i), 400 + i * 12, 90, 30, 200 + i, jetzt, idMan);
}
d.close();
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
async function alsRolle(rolle, code, wohin = "leistung.html", groesse = { width: 1280, height: 900 }) {
const ctx = await browser.newContext({ viewport: groesse });
const seite = await ctx.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
return { ctx, seite };
}
/* =======================================================================
1. Die Seite ist überhaupt erreichbar — und über die Kachel
======================================================================= */
melde("\n=== Der Weg zur Seite ===");
/* SEIT FILIPES ANWEISUNG SIEHT NUR DOGFATHER DIE ZAHLEN.
Hier stand "manager", und bis zur Umstellung war das richtig. Seit
die Kachel und die Seite auf DogFather beschraenkt sind, hat sich
diese Pruefung selbst ausgesperrt: Sie meldete sieben Fehler, von
denen keiner ein Mangel war -- sie sah nur eine Seite, die sie nicht
mehr sehen darf. Gemessen wird jetzt als DogFather; dass der Manager
NICHT hineinkommt, steht als eigene Gegenprobe darunter. */
const chef = await alsRolle("admin", "CODE-DOGI-0001", "start.html");
{
const kachel = await chef.seite.$$eval(".kachel", (k) =>
k.map((x) => ({
name: x.querySelector(".kachel__name")?.textContent.trim(),
/* Das href sitzt am .kachel__link INNEN, nicht am <li> aussen --
beim ersten Anlauf stand hier `x.getAttribute("href")` und
lieferte brav "null". Eine Prüfung, die am falschen Element
misst, meldet einen Fehler, den es nicht gibt; das ist genauso
schädlich wie einer, den sie übersieht. */
ziel: x.querySelector(".kachel__link")?.getAttribute("href"),
zeichen: !!x.querySelector("svg"),
})).filter((x) => x.name === "Zahlen"));
ok(kachel.length === 1, `die Kachel "Zahlen" steht auf der Startseite (${kachel.length}×)`);
ok(kachel[0]?.ziel === "leistung.html", `sie führt nach "${kachel[0]?.ziel}"`);
ok(kachel[0]?.zeichen === true, "sie hat ein Zeichen");
/* GEGENPROBE zum Selektor: Ein Name, den es nicht gibt, darf NICHT
gefunden werden -- sonst prüfte der Block oben nichts. */
const erfunden = await chef.seite.$$eval(".kachel", (k) =>
k.some((x) => x.querySelector(".kachel__name")?.textContent.trim() === "Zahlenzauber"));
ok(!erfunden, "eine erfundene Kachel wird nicht gefunden");
/* GEGENPROBE ZUR REGEL SELBST: Der Manager darf sie NICHT sehen und
NICHT hineinkommen. Ohne diese beiden Zeilen bewiese der Block
oben nur, dass DogFather etwas sieht -- nicht, dass sonst niemand
es sieht. Und genau das war die Anweisung. */
const mgr = await alsRolle("manager", "CODE-MANA-0001", "start.html");
const beiIhm = await mgr.seite.$$eval(".kachel", (k) =>
k.map((x) => x.querySelector(".kachel__name")?.textContent.trim()));
ok(beiIhm.length > 3 && !beiIhm.includes("Zahlen"),
`beim Manager fehlt sie (${beiIhm.length} Kacheln, keine davon "Zahlen")`);
const versuch = await mgr.seite.evaluate(async (b) =>
(await fetch(b + "/workspace/leistung.html", { redirect: "manual" })).status, BASIS);
ok(versuch !== 200, `und die Seite selbst laesst ihn nicht herein (HTTP ${versuch})`);
await mgr.ctx.close();
await chef.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" });
await chef.seite.waitForTimeout(1100);
const titel = await chef.seite.$eval(".titel", (e) => e.textContent.trim());
ok(titel.length > 0, `die Seite ist offen: "${titel}"`);
}
/* =======================================================================
2. Einen Tag eintragen — und er steht danach in der Wochenkarte
======================================================================= */
melde("\n=== Einen Tag eintragen ===");
{
const vorher = await chef.seite.$$eval(".zahlkarte__wert", (w) => w.map((x) => x.textContent));
ok(vorher.length >= 5,
`die Wochenkarten stehen da (${vorher.length} Werte) — sonst prüft alles Folgende nichts`);
/* Die laufende Woche ist leer: Diamanten müssen 0 sein. Steht hier
schon etwas, ist der Bestand oben falsch aufgebaut und die Prüfung
misst etwas anderes als sie behauptet. */
const diaVorher = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
return t?.querySelector(".zahlkarte__wert")?.textContent;
});
ok(diaVorher === "0", `vor dem Eintragen stehen ${diaVorher} Diamanten da`);
/* Der erste "eintragen"-Knopf ist der von HEUTE (die Liste läuft
rückwärts). */
const knoepfe = await chef.seite.$$(".tagzeile__knopf");
ok(knoepfe.length === 14, `es gibt 14 Tageszeilen mit Knopf (${knoepfe.length})`);
await knoepfe[0].click();
await chef.seite.waitForSelector("#tag-dialog[open]", { timeout: 5000 });
await chef.seite.fill("#f-diamanten", "1250");
await chef.seite.fill("#f-dauer", "120");
await chef.seite.fill("#f-verweildauer", "260");
await chef.seite.fill("#f-notiz", "PK gegen Mia");
await chef.seite.click("#tag-speichern");
await chef.seite.waitForTimeout(1400);
const dia = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
return t?.querySelector(".zahlkarte__wert")?.textContent;
});
ok(dia === "1.250", `die Karte zeigt jetzt "${dia}" (mit Tausenderpunkt)`);
/* DER VERGLEICH. Ohne ihn ist die Zahl eine Behauptung -- das ist die
Regel, wegen der dieses Modul überhaupt so gebaut ist. */
const wandel = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
const w = t?.querySelector(".zahlkarte__wandel");
return { text: w?.textContent || "", richtung: w?.dataset.richtung || "" };
});
ok(/Vorwoche/.test(wandel.text) && /%/.test(wandel.text),
`daneben steht der Vergleich: "${wandel.text}"`);
/* DAS VORZEICHEN MUSS ZUR RICHTUNG PASSEN, und die Richtung färbt.
Nicht fest auf "hoch" prüfen: Die Vorwoche ist im Bestand oben
absichtlich stärker, es MUSS also ein Minus sein -- eine Prüfung,
die "hoch" erwartet, prüft dann nur noch, ob ich die Testdaten
richtig erinnere. Diese hier prüft den Zusammenhang selbst und
bleibt gültig, wenn sich der Bestand ändert. */
const p = Number((wandel.text.match(/(-?\d+)\s*%/) || [])[1]);
const soll = p > 0 ? "hoch" : p < 0 ? "runter" : "gleich";
ok(Number.isFinite(p) && wandel.richtung === soll,
`${p} % ist als "${wandel.richtung}" gekennzeichnet (erwartet "${soll}")`);
/* Und die Farbe muss der Richtung folgen -- sonst steht ein Minus in
Grün da und niemand merkt es. */
const farbe = await chef.seite.$$eval(".zahlkarte", (k) => {
const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
return getComputedStyle(t.querySelector(".zahlkarte__wandel")).color;
});
const [r, g] = (farbe.match(/\d+/g) || []).map(Number);
ok(p < 0 ? r > g : g >= r,
`und die Farbe passt dazu (${farbe})`);
/* Die Notiz ist der Kontext, ohne den eine Zahl in die Irre führt. */
const notiz = await chef.seite.$$eval(".tagzeile__notiz", (n) => n.map((x) => x.textContent));
ok(notiz.includes("PK gegen Mia"), `die Notiz steht in der Tageszeile (${notiz.length} Notizen)`);
/* GEGENPROBE: Ein Wert, der nie eingetragen wurde, darf nicht
dastehen. */
const erfunden = await chef.seite.$$eval(".zahlkarte__wert", (w) =>
w.some((x) => x.textContent === "9.999"));
ok(!erfunden, "ein nie eingetragener Wert erscheint nicht");
}
/* =======================================================================
3. Die Verlaufslinie wird WIRKLICH gezeichnet
======================================================================= */
melde("\n=== Die kleine Verlaufslinie ===");
{
/* "Das Element ist da" reicht NICHT. Genau hier saß ein echter
Fehler: Der Server liefert im Verlauf nur drei der sieben Größen;
für alle anderen kam undefined an, wurde zu Number(undefined) = NaN
und der Browser zeichnete wortlos gar nichts. Ein SVG mit
points="NaN,NaN" ist im DOM vorhanden und auf dem Bildschirm
unsichtbar. Deshalb werden die PUNKTE gemessen. */
const linien = await chef.seite.$$eval(".zahlkarte__linie polyline", (ls) =>
ls.map((l) => l.getAttribute("points") || ""));
ok(linien.length >= 3, `${linien.length} Verlaufslinien sind gezeichnet`);
const kaputt = linien.filter((p) => !p || /NaN|undefined|Infinity/.test(p));
ok(linien.length > 0 && kaputt.length === 0,
`keine Linie hat unbrauchbare Punkte (${kaputt.length} von ${linien.length})`);
/* Und sie muss WACHSEN: mindestens drei Punktepaare, sonst ist es
keine Linie, sondern ein Strich. */
const zuKurz = linien.filter((p) => p.trim().split(/\s+/).length < 3);
ok(linien.length > 0 && zuKurz.length === 0,
`jede Linie hat mindestens drei Punkte (${zuKurz.length} zu kurz)`);
/* Auch der Endpunkt-Kreis. */
const kreise = await chef.seite.$$eval(".zahlkarte__linie circle", (cs) =>
cs.map((c) => `${c.getAttribute("cx")},${c.getAttribute("cy")}`));
ok(kreise.length === linien.length && !kreise.some((k) => /NaN/.test(k)),
`jede Linie hat einen Endpunkt (${kreise.length})`);
/* GEGENPROBE zum Selektor: Nach dem Entfernen darf nichts mehr
gefunden werden. */
const nachher = await chef.seite.evaluate(() => {
document.querySelectorAll(".zahlkarte__linie").forEach((e) => e.remove());
return document.querySelectorAll(".zahlkarte__linie polyline").length;
});
ok(nachher === 0, "der Selektor trifft wirklich die Linien (nach dem Entfernen 0)");
await chef.seite.reload({ waitUntil: "networkidle" });
await chef.seite.waitForTimeout(1200);
}
/* =======================================================================
4. Ziele setzen — aus einem Satz wird ein Fortschritt
======================================================================= */
melde("\n=== Ziele ===");
{
const leerText = await chef.seite.$eval("#zielbalken", (e) => e.textContent.trim());
ok(/keine Ziele/i.test(leerText), `ohne Ziel steht ein Satz da: "${leerText.slice(0, 60)}…"`);
await chef.seite.click("#ziele-aendern");
await chef.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
await chef.seite.fill("#z-diamanten", "2500");
await chef.seite.fill("#z-tage", "5");
await chef.seite.click("#ziele-speichern");
await chef.seite.waitForTimeout(1300);
const balken = await chef.seite.$$eval(".zielbalken__zeile", (zs) =>
zs.map((z) => ({
name: z.querySelector(".zielbalken__name")?.textContent,
zahl: z.querySelector(".zielbalken__zahl")?.textContent,
breite: z.querySelector(".zielbalken__fuell")?.style.width,
})));
ok(balken.length === 2, `zwei Ziele stehen als Balken da (${balken.length})`);
const dia = balken.find((b) => b.name === "Diamanten");
ok(dia?.zahl === "1.250 von 2.500", `der Stand steht daneben: "${dia?.zahl}"`);
ok(dia?.breite === "50%", `der Balken ist zur Hälfte gefüllt (${dia?.breite})`);
/* KEIN BALKEN ÜBER 100 % -- er hinge sonst aus dem Kasten. */
await chef.seite.click("#ziele-aendern");
await chef.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
await chef.seite.fill("#z-diamanten", "500");
await chef.seite.click("#ziele-speichern");
await chef.seite.waitForTimeout(1300);
const voll = await chef.seite.$$eval(".zielbalken__zeile", (zs) => {
const z = zs.find((x) => x.querySelector(".zielbalken__name")?.textContent === "Diamanten");
return {
breite: z?.querySelector(".zielbalken__fuell")?.style.width,
voll: z?.querySelector(".zielbalken__fuell")?.dataset.voll,
text: z?.querySelector(".zielbalken__anteil")?.textContent,
};
});
ok(voll.breite === "100%", `bei 250 % bleibt der Balken bei ${voll.breite}`);
ok(voll.voll === "ja", "und er ist als erreicht gekennzeichnet");
ok(/250/.test(voll.text || ""), `der wahre Wert steht trotzdem da: "${voll.text}"`);
}
/* =======================================================================
5. Import — ERST die Vorschau, dann das Schreiben
======================================================================= */
melde("\n=== Aus Datei einlesen ===");
{
const csv = [
"Datum;Diamanten;Dauer (Minuten);Ø Zuschauer",
`${tagVor(3)};1.480;95;312`,
`${tagVor(2)};970;80;255`,
"kein-datum;500;60;100",
].join("\n");
await chef.seite.click("#import-oeffnen");
await chef.seite.waitForSelector("#import-dialog[open]", { timeout: 5000 });
const gesperrt = await chef.seite.$eval("#import-los", (b) => b.disabled);
ok(gesperrt, "der Übernehmen-Knopf ist gesperrt, solange keine Datei da ist");
await chef.seite.setInputFiles("#datei", {
name: "export.csv", mimeType: "text/csv", buffer: Buffer.from(csv, "utf8"),
});
await chef.seite.waitForSelector("#vorschau:not([hidden])", { timeout: 8000 });
const zahlen = await chef.seite.$$eval(".import-zahl", (zs) =>
zs.map((z) => `${z.querySelector(".import-zahl__wert")?.textContent} ${z.querySelector(".import-zahl__name")?.textContent}`));
ok(zahlen.some((z) => /^2 neu/.test(z)), `die Vorschau zählt: ${zahlen.join(" · ")}`);
ok(zahlen.some((z) => /^1 unlesbar/.test(z)), "die unlesbare Zeile wird gemeldet, nicht verschluckt");
const spalten = await chef.seite.$eval(".import-spalten", (e) => e.textContent);
ok(/Diamanten/.test(spalten) && /LIVE-Dauer/.test(spalten),
`die erkannten Spalten stehen da: "${spalten}"`);
/* DIE GEGENPROBE, auf die es ankommt: Bis hierher darf NICHTS
geschrieben worden sein. Eine Vorschau, die schon schreibt, ist
keine Vorschau. */
const vorAntwort = await fetch(`${BASIS}/workspace/api/leistung/${idLuna}/tage?tage=7`, {
headers: { cookie: (await chef.ctx.cookies()).map((c) => `${c.name}=${c.value}`).join("; ") },
});
const vorTage = (await vorAntwort.json()).tage || [];
const schonDa = vorTage.some((t) => t.tag === tagVor(3));
ok(!schonDa, "vor dem Bestätigen steht noch nichts in der Datenbank");
await chef.seite.click("#import-los");
await chef.seite.waitForTimeout(1600);
const nachTage = await chef.seite.$$eval(".tagzeile", (zs) =>
zs.filter((z) => z.dataset.quelle === "import").length);
ok(nachTage === 2, `nach dem Bestätigen sind 2 Zeilen aus dem Import da (${nachTage})`);
/* DER TAUSENDERPUNKT. "1.480" darf nicht als 1,48 ankommen -- Faktor
tausend daneben, und die Zahl sieht trotzdem plausibel aus. */
const werte = await chef.seite.$$eval(".tagzeile", (zs) =>
zs.filter((z) => z.dataset.quelle === "import")
.map((z) => z.querySelector(".tagzeile__wertzahl")?.textContent));
ok(werte.includes("1.480"), `"1.480" ist als 1480 angekommen (${werte.join(", ")})`);
ok(!werte.includes("1,48") && !werte.includes("1"),
"und nicht als 1,48 — der Tausenderpunkt wurde richtig gelesen");
}
/* =======================================================================
6. Der Creator sieht seine Zahlen — und darf sie nicht eintragen
======================================================================= */
melde("\n=== Was der Creator sieht ===");
const luna = await alsRolle("creator", "CODE-CREA-0001", "start.html");
{
/* HIER STAND DAS GEGENTEIL, UND ES WAR RICHTIG -- bis Filipe die
Regel geändert hat: "nimm die kategorie zahlen bei jedem weg, nur
die rolle dogfather soll die sehen." Vorher sah Luna ihre eigenen
Zahlen (nur ohne Eintragen-Knopf), jetzt kommt sie gar nicht mehr
hinein. Die Prüfung misst deshalb nicht mehr, WAS sie dort sieht,
sondern DASS sie nicht hineinkommt -- und dass die Schranke am
Server sitzt, nicht bloß in der Oberfläche. */
const keineKachel = await luna.seite.$$eval(".kachel", (k) =>
k.map((x) => x.querySelector(".kachel__name")?.textContent.trim()));
ok(keineKachel.length > 2 && !keineKachel.includes("Zahlen"),
`bei Luna fehlt die Kachel "Zahlen" (${keineKachel.length} Kacheln)`);
const rein = await luna.seite.evaluate(async () =>
(await fetch("/workspace/leistung.html", { credentials: "same-origin" })).redirected);
ok(rein === true, "und die Seite selbst weist sie ab (Umleitung)");
/* DIE SCHRANKE SITZT AM SERVER, nicht in der Oberfläche. Ein
versteckter Knopf ist keine Sicherung. */
const antwort = await luna.seite.evaluate(async (tag) => {
const a = await fetch(`/workspace/api/leistung/3/${tag}`, {
method: "PUT", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ diamanten: 99999 }),
});
return a.status;
}, tagVor(1));
ok(antwort === 403, `von Hand gebaut lehnt der Server ab (HTTP ${antwort})`);
}
/* =======================================================================
7. Lesbarkeit und Fingerziele
======================================================================= */
melde("\n=== Lesbar und bedienbar ===");
{
/* Kleiner als 12 px liest niemand auf einem Bildschirm, an dem er
jeden Tag sitzt. Zweimal ist mir das schon durchgerutscht (10,56
und 10,88 px) -- deshalb wird es gemessen, nicht angesehen. */
const klein = await chef.seite.$$eval(
".zahlkarte *, .zielbalken *, .tagzeile *, .import-vorschau *",
(es) => es
.filter((e) => e.textContent.trim() && !e.children.length)
.map((e) => ({ t: e.textContent.trim().slice(0, 28), px: parseFloat(getComputedStyle(e).fontSize) }))
.filter((x) => x.px < 12));
ok(klein.length === 0,
`keine Schrift unter 12 px (${klein.length} gefunden: ${klein.slice(0, 3).map((k) => `${k.t} ${k.px}px`).join(" | ")})`);
/* Die ZAHL der geprüften Elemente gehört in die Bedingung, nicht nur
in den Meldetext. Eine leere Auswahl wäre sonst grün, ohne etwas
angesehen zu haben. */
const gezaehlt = await chef.seite.$$eval(
".zahlkarte *, .zielbalken *, .tagzeile *",
(es) => es.filter((e) => e.textContent.trim() && !e.children.length).length);
ok(gezaehlt > 40, `und es wurden wirklich Elemente gemessen (${gezaehlt})`);
/* HANDY: Der Eintragen-Knopf muss mit dem Daumen zu treffen sein. */
const handy = await alsRolle("admin", "CODE-DOGI-0001", "leistung.html",
{ width: 390, height: 844 });
/* NUR SICHTBARE KNÖPFE. Die Seite zeigt zwei Wochen, und die zweite
ist zugeklappt -- ihre Knöpfe messen 0×0. Genau die meldete die
Prüfung als "zu klein": ein Fingerziel, das niemand sehen kann,
ist kein zu kleines Fingerziel, sondern gar keins. Sieben
angebliche Mängel, kein einziger echter. Deshalb wird die Zahl der
wirklich sichtbaren mitgeführt und in die Bedingung genommen --
wäre plötzlich keiner mehr sichtbar, muss das auffallen und darf
nicht als "alle groß genug" durchgehen. */
const masse = await handy.seite.$$eval(".tagzeile__knopf", (bs) => bs
.filter((b) => b.checkVisibility ? b.checkVisibility() : b.offsetParent !== null)
.map((b) => { const r = b.getBoundingClientRect(); return { h: Math.round(r.height), w: Math.round(r.width) }; })
.filter((m) => m.w > 0 && m.h > 0));
const zuKlein = masse.filter((m) => m.h < 38 || m.w < 60);
/* Die Maße der Ausreißer gehören in die Meldung. "7 zu klein" sagt
nur, DASS etwas nicht stimmt; "38×44" sagt, WAS -- und erspart
einen zweiten Lauf, nur um nachzusehen. */
ok(masse.length >= 7 && zuKlein.length === 0,
`alle ${masse.length} Eintragen-Knöpfe sind groß genug (${zuKlein.length} zu klein`
+ `${zuKlein.length ? ": " + zuKlein.slice(0, 4).map((m) => `${m.w}×${m.h}`).join(", ") : ""})`);
/* NICHTS DARF SEITLICH HERAUSHÄNGEN. Waagerechtes Scrollen auf dem
Handy ist der sicherste Weg, eine Seite unbenutzbar zu machen. */
const breite = await handy.seite.evaluate(() => ({
doc: document.documentElement.scrollWidth,
fenster: window.innerWidth,
}));
ok(breite.doc <= breite.fenster + 1,
`nichts hängt seitlich heraus (${breite.doc} px auf ${breite.fenster} px)`);
/* Und die Wochenkarte bleibt einspaltig -- ein vierstelliger Wert
bricht sonst um, und eine umgebrochene Zahl ist keine Zahl mehr. */
const spalten = await handy.seite.$eval("#zahlkarten",
(e) => getComputedStyle(e).gridTemplateColumns.split(" ").length);
ok(spalten === 1, `die Wochenkarten stehen untereinander (${spalten} Spalte)`);
const wertHoehe = await handy.seite.$$eval(".zahlkarte__wert", (ws) =>
ws.map((w) => Math.round(w.getBoundingClientRect().height)));
const umbruch = wertHoehe.filter((h) => h > 42);
ok(wertHoehe.length > 0 && umbruch.length === 0,
`keine Zahl bricht um (${umbruch.length} von ${wertHoehe.length})`);
await handy.ctx.close();
}
/* =======================================================================
8. Das Dashboard: Zahlen in der Karte und die Frühwarnung
======================================================================= */
melde("\n=== Dashboard ===");
{
await chef.seite.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
await chef.seite.waitForTimeout(1600);
const zahlen = await chef.seite.$$eval(".kzahlen", (zs) =>
zs.map((z) => ({
ziel: z.getAttribute("href"),
felder: [...z.querySelectorAll(".kzahlen__feld")].map((f) =>
`${f.querySelector(".kzahlen__name")?.textContent}=${f.querySelector(".kzahlen__wert")?.textContent}`),
})));
ok(zahlen.length === 1, `jede Creator-Karte trägt ihre Zahlen (${zahlen.length})`);
ok(zahlen[0]?.felder.some((f) => /^Diamanten=3\.700$/.test(f)),
`die Diamanten stehen dort: ${zahlen[0]?.felder.join(" · ")}`);
ok(/leistung\.html\?creator=/.test(zahlen[0]?.ziel || ""),
`und führen zur Zahlenseite ("${zahlen[0]?.ziel}")`);
/* DIE FRÜHWARNUNG. Der Bestand oben fällt über drei Wochen ab und
hat diese Woche weniger LIVE-Tage -- da MUSS etwas stehen. */
const warnung = await chef.seite.$eval("#fruehwarnung", (e) => e.hidden);
ok(!warnung, "die Frühwarnung ist sichtbar");
const signale = await chef.seite.$$eval(".warnsignal__text", (ts) => ts.map((t) => t.textContent));
ok(signale.length > 0, `${signale.length} Signale: ${signale.join(" | ").slice(0, 140)}`);
/* KEINE PUNKTZAHL. Genau das war die Entscheidung: sechs benannte
Beobachtungen statt eines Werts, dem man nicht widersprechen kann. */
const punktzahl = await chef.seite.evaluate(() =>
/risiko|score|punkte?zahl/i.test(document.getElementById("fruehwarnung")?.textContent || ""));
ok(!punktzahl, "und nirgends steht eine Risiko-Punktzahl");
/* Der Weg zum Gespräch. Ein Hinweis ohne Weg dahin ist nur ein
schlechtes Gewissen. */
const wege = await chef.seite.$$eval(".warnkarte__weg", (ws) => ws.map((w) => w.getAttribute("href")));
ok(wege.length > 0 && wege.every((w) => /^chat\.html\?mit=\d+$/.test(w)),
`jede Warnkarte führt ins Gespräch (${wege.join(", ")})`);
/* Auch hier die Schriftgrößen -- die Dashboard-Zeile ist eine eigene
Fläche mit eigenem CSS. Beim ersten Lauf stand .kzahlen__name auf
.66 rem = 10,56 px; genau derselbe Wert, der mir schon zweimal
durchgerutscht ist, weil ihn niemand misst. */
const kleinDash = await chef.seite.$$eval(".kzahlen *, .warnkarte *", (es) => es
.filter((e) => e.textContent.trim() && !e.children.length)
.map((e) => ({ t: e.textContent.trim().slice(0, 24), px: parseFloat(getComputedStyle(e).fontSize) }))
.filter((x) => x.px < 12));
const gezaehltDash = await chef.seite.$$eval(".kzahlen *, .warnkarte *",
(es) => es.filter((e) => e.textContent.trim() && !e.children.length).length);
ok(gezaehltDash > 8 && kleinDash.length === 0,
`im Dashboard keine Schrift unter 12 px (${gezaehltDash} gemessen, ${kleinDash.length} zu klein`
+ `${kleinDash.length ? ": " + kleinDash.slice(0, 3).map((k) => `${k.t} ${k.px}px`).join(" | ") : ""})`);
/* Und dieser Weg muss WIRKLICH funktionieren. ?mit= war bis zum
06.09.2026 gar nicht vorgesehen -- ein Link, der auf einer leeren
Gesprächsliste endet, ist schlimmer als keiner. */
await chef.seite.goto(`${BASIS}/workspace/${wege[0]}`, { waitUntil: "networkidle" });
await chef.seite.waitForTimeout(1800);
const offen = await chef.seite.$eval("#verlauf-titel", (e) => e.textContent.trim());
ok(offen === "Luna", `der Link öffnet das Gespräch mit "${offen}"`);
}
/* =======================================================================
9. Ein Creator sieht die Frühwarnung NIE
======================================================================= */
melde("\n=== Was der Creator NICHT sieht ===");
{
await luna.seite.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
await luna.seite.waitForTimeout(1600);
const versteckt = await luna.seite.$eval("#fruehwarnung", (e) => e.hidden);
ok(versteckt, "die Frühwarnung bleibt bei ihr verborgen");
/* Auch von Hand abgefragt kommt nichts. Ein verstecktes Element ist
keine Sicherung. */
const roh = await luna.seite.evaluate(async () => {
const a = await fetch("/workspace/api/fruehwarnung", { credentials: "same-origin" });
return a.ok ? await a.json() : { fehler: a.status };
});
ok(Array.isArray(roh.creator) && roh.creator.length === 0 && roh.gilt_nicht === true,
`die Schnittstelle liefert ihr nichts (${JSON.stringify(roh).slice(0, 70)})`);
}
await luna.ctx.close();
await chef.ctx.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${gemacht} Pruefungen`);
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
/* process.exit() ist hier richtig: Diese Prüfung startet einen
Express-Server, der von selbst weiterläuft (offene Verbindungen,
Zeitgeber für die Sicherung). Mit `exitCode` allein wartet Node
darauf, dass all das endet -- der Lauf liefe in die Zeitüberschreitung,
obwohl alle Prüfungen längst durch sind. Die Browser sind zu, bevor
diese Zeile läuft. */
process.exit(fehler ? 1 : 0);