/* 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 ===");
const man = await alsRolle("manager", "CODE-MANA-0001", "start.html");
{
const kachel = await man.seite.$$eval(".kachel", (k) =>
k.map((x) => ({
name: x.querySelector(".kachel__name")?.textContent.trim(),
/* Das href sitzt am .kachel__link INNEN, nicht am
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 man.seite.$$eval(".kachel", (k) =>
k.some((x) => x.querySelector(".kachel__name")?.textContent.trim() === "Zahlenzauber"));
ok(!erfunden, "eine erfundene Kachel wird nicht gefunden");
await man.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" });
await man.seite.waitForTimeout(1100);
const titel = await man.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 man.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 man.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 man.seite.$$(".tagzeile__knopf");
ok(knoepfe.length === 14, `es gibt 14 Tageszeilen mit Knopf (${knoepfe.length})`);
await knoepfe[0].click();
await man.seite.waitForSelector("#tag-dialog[open]", { timeout: 5000 });
await man.seite.fill("#f-diamanten", "1250");
await man.seite.fill("#f-dauer", "120");
await man.seite.fill("#f-verweildauer", "260");
await man.seite.fill("#f-notiz", "PK gegen Mia");
await man.seite.click("#tag-speichern");
await man.seite.waitForTimeout(1400);
const dia = await man.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 man.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 man.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 man.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 man.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 man.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 man.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 man.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 man.seite.reload({ waitUntil: "networkidle" });
await man.seite.waitForTimeout(1200);
}
/* =======================================================================
4. Ziele setzen — aus einem Satz wird ein Fortschritt
======================================================================= */
melde("\n=== Ziele ===");
{
const leerText = await man.seite.$eval("#zielbalken", (e) => e.textContent.trim());
ok(/keine Ziele/i.test(leerText), `ohne Ziel steht ein Satz da: "${leerText.slice(0, 60)}…"`);
await man.seite.click("#ziele-aendern");
await man.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
await man.seite.fill("#z-diamanten", "2500");
await man.seite.fill("#z-tage", "5");
await man.seite.click("#ziele-speichern");
await man.seite.waitForTimeout(1300);
const balken = await man.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 man.seite.click("#ziele-aendern");
await man.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
await man.seite.fill("#z-diamanten", "500");
await man.seite.click("#ziele-speichern");
await man.seite.waitForTimeout(1300);
const voll = await man.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 man.seite.click("#import-oeffnen");
await man.seite.waitForSelector("#import-dialog[open]", { timeout: 5000 });
const gesperrt = await man.seite.$eval("#import-los", (b) => b.disabled);
ok(gesperrt, "der Übernehmen-Knopf ist gesperrt, solange keine Datei da ist");
await man.seite.setInputFiles("#datei", {
name: "export.csv", mimeType: "text/csv", buffer: Buffer.from(csv, "utf8"),
});
await man.seite.waitForSelector("#vorschau:not([hidden])", { timeout: 8000 });
const zahlen = await man.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 man.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 man.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 man.seite.click("#import-los");
await man.seite.waitForTimeout(1600);
const nachTage = await man.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 man.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");
{
const karten = await luna.seite.$$eval(".zahlkarte__wert", (w) => w.map((x) => x.textContent));
ok(karten.length >= 5, `Luna sieht ihre Zahlen (${karten.length} Werte)`);
/* 1250 von Hand + 1480 + 970 aus dem Import = 3700. Die Zahl steht
hier ausgerechnet und nicht abgeschrieben: So fällt auf, wenn der
Import etwas dazuzählt oder verschluckt. */
ok(karten.includes("3.700"),
`darunter ihre Diamanten, 1250+1480+970 (${karten.slice(0, 4).join(", ")})`);
const knoepfe = await luna.seite.$$(".tagzeile__knopf");
ok(knoepfe.length === 0, `sie hat keinen Eintragen-Knopf (${knoepfe.length})`);
const zieleKnopf = await luna.seite.$eval("#ziele-aendern", (b) => b.hidden);
ok(zieleKnopf, "und kann auch keine Ziele setzen");
/* Kein Auswahlfeld: Sie hat nur sich selbst, und eine Liste mit einem
Eintrag ist eine Frage ohne Anlass. */
const wahl = await luna.seite.$eval("#creator-wahl", (e) => e.hidden);
ok(wahl, "es gibt kein Creator-Auswahlfeld für sie");
/* 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 man.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 man.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("manager", "CODE-MANA-0001", "leistung.html",
{ width: 390, height: 844 });
const masse = await handy.seite.$$eval(".tagzeile__knopf", (bs) =>
bs.map((b) => { const r = b.getBoundingClientRect(); return { h: Math.round(r.height), w: Math.round(r.width) }; }));
const zuKlein = masse.filter((m) => m.h < 38 || m.w < 60);
ok(masse.length > 0 && zuKlein.length === 0,
`alle ${masse.length} Eintragen-Knöpfe sind groß genug (${zuKlein.length} zu klein)`);
/* 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 man.seite.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
await man.seite.waitForTimeout(1600);
const zahlen = await man.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 man.seite.$eval("#fruehwarnung", (e) => e.hidden);
ok(!warnung, "die Frühwarnung ist sichtbar");
const signale = await man.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 man.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 man.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 man.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 man.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 man.seite.goto(`${BASIS}/workspace/${wege[0]}`, { waitUntil: "networkidle" });
await man.seite.waitForTimeout(1800);
const offen = await man.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 man.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);