/* 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
  • 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);