Files
dogfather-universe/server/pruef-leistung-optik.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.

Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.

Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.

--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------

1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
   Muster: "([^"]*4231[^"]*)". Das hielt

     { host: "127.0.0.1", port: 4231, path: "/404.html" }

   fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
   einer schliessenden Anfuehrung unterscheiden. Heraus kam

     { host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }

   also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
   alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
   gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
   39/0 vorher, 38/1 nachher.

   Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
   ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
   regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
   regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.

2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
   zweite Durchgang importierte den ersten, um seine Mechanik zu
   benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
   zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
   bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
   zweimal.

--- WAS DAS DAUERHAFT HAELT -----------------------------------------

pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.

Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.

--- NACHGEMESSEN ----------------------------------------------------

Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):

  crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
  anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
  kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
  push-ziel 10/0 · portnummern 8/0

Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 22:51:21 +02:00

660 lines
33 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 { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Zahlen-Optikpruefung");
process.env.PORT = `${PORT}`;
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:${PORT}`;
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");
/* GEDREHT AM 11.09.2026, NICHT GELOESCHT.
Hier stand das Gegenteil: "beim Manager fehlt sie" und "die Seite
laesst ihn nicht herein". Das war richtig nach dem Wunsch vom
07.09. ("nimm die kategorie zahlen bei jedem weg"). Filipe hat ihn
am 11.09. aufgehoben: "jetzt soll jede rolle diese kachel sehen.
spicy, dogfather, manager, scout und creator."
Eine geloeschte Pruefung senkt die Zahl und beweist nichts. Eine
gedrehte haelt fest, dass die Oeffnung eine ENTSCHEIDUNG war --
und schlaegt an, wenn jemand die Sperre versehentlich wieder
einbaut.
BEIDE SCHICHTEN, wie vorher: die Kachel (der Weg) UND die Seite
(die Schranke). Nur eine von beiden zu messen, hat am 10.09. schon
einmal eine dritte, unbemerkte Schicht stehen lassen. */
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"),
`der Manager sieht die Kachel wieder (${beiIhm.length} Kacheln)`);
const versuch = await mgr.seite.evaluate(async (b) =>
(await fetch(b + "/workspace/leistung.html", { redirect: "manual" })).status, BASIS);
ok(versuch === 200, `und die Seite laesst ihn 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");
{
/* ZWEIMAL GEDREHT, UND BEIDE MALE AUF ANSAGE.
Ursprünglich sah Luna ihre eigenen Zahlen (ohne Eintragen-Knopf).
Am 07.09. kam sie gar nicht mehr hinein ("nimm die kategorie
zahlen bei jedem weg"). Seit dem 11.09. gilt wieder die erste
Fassung, und zwar wörtlich: "die creator kriegen dan quasi nur
ihre daten zu sehen und der manager scout dogfather oder spicy
können eintragen."
Gemessen wird deshalb BEIDES -- dass sie hineinkommt UND dass sie
dort nichts eintragen kann. Nur das erste zu prüfen hieße, eine
offene Tür für eine Erlaubnis zu halten. */
const kacheln = await luna.seite.$$eval(".kachel", (k) =>
k.map((x) => x.querySelector(".kachel__name")?.textContent.trim()));
ok(kacheln.length > 2 && kacheln.includes("Zahlen"),
`Luna sieht die Kachel "Zahlen" wieder (${kacheln.length} Kacheln)`);
const rein = await luna.seite.evaluate(async () =>
(await fetch("/workspace/leistung.html", { credentials: "same-origin" })).redirected);
ok(rein === false, "und die Seite laesst sie herein (keine Umleitung)");
/* UND DORT: ihre Zahlen, kein Knopf. Beides zusammen, sonst sagt es
nichts -- "keine Knoepfe" waere auch bei einer leeren Seite wahr. */
await luna.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" });
await luna.seite.waitForTimeout(1600);
const lage = await luna.seite.evaluate(() => {
const h = (id) => document.getElementById(id)?.hidden !== false;
return {
knoepfe: ["import-oeffnen", "netz-oeffnen", "ziele-aendern"].filter((i) => !h(i)),
tagKnoepfe: document.querySelectorAll(".tagzeile__knopf").length,
wahl: document.getElementById("creator-wahl")?.hidden === false,
inhalt: (document.getElementById("tagetabelle")?.textContent || "").length,
};
});
ok(lage.knoepfe.length === 0 && lage.tagKnoepfe === 0,
`sie kann dort nichts eintragen (${lage.knoepfe.join(", ") || "kein Knopf"}, `
+ `${lage.tagKnoepfe} Tag-Knöpfe)`);
ok(lage.wahl === false,
"und hat kein Auswahlfeld für fremde Creator -- sie sieht nur sich");
ok(lage.inhalt > 20,
`ihre eigenen Zahlen stehen aber da (${lage.inhalt} Zeichen)`);
/* 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);