Filipe, mit den drei Knoepfen im Bild: "ich will nur dass wir manager
scouts, dogfather oder spicy sachen eintragen koennen und nicht die
creator. ich will keine daten von denen kriegen, nur wir."
Gebaut am Vormittag, am Nachmittag wieder ausgebaut. Der Weg war
technisch in Ordnung -- und er war der einzige, bei dem ein CREATOR uns
etwas gibt: seine eigene Datenkopie aus der TikTok-App. Genau das soll
nicht sein. Es bleiben die beiden Wir-Wege: die eigene Datei und die
Backstage-Tabelle der Agentur.
VOLLSTAENDIG ENTFERNT, NICHT VERSTECKT:
- beide Schnittstellen (/tiktok-datei und /tiktok-datei/vorschau)
- der Leser workspace-tiktok-datei.js (263 Zeilen)
- Knopf, Fenster, Dateiauswahl, Vorschau, Stilregeln
- der fertige Text, der einen Creator um seine Daten bittet
- der Erklaersatz unter der Knopfreihe
Ein Weg, der nur unsichtbar ist, ist weiterhin ein Weg -- wer die
Adresse kennt, benutzt ihn. Und eine Bitte an einen Creator um seine
Daten soll in diesem Haus nirgends mehr stehen, auch nicht in einem
Fenster, das niemand oeffnet.
GEPRUEFT WIRD JETZT DIE ABWESENHEIT, an drei Stellen: Knopf weg,
Fenster weg, und die Schnittstelle antwortet DOGFATHER mit 404 -- dem
staerksten Zugang, den es gibt. Bekommt er 404, bekommt ihn jeder.
Daneben die Gegenprobe, dass der Backstage-Weg weiterhin mit 200
antwortet; sonst bewiese das 404 nur einen Tippfehler.
pruef-tiktok-datei.mjs (47 Pruefungen) faellt mit dem Weg weg. Die
Zahl sinkt dadurch, und das ist hier richtig: Sie pruefte etwas, das
es nicht mehr gibt. Was bleibt, sind sechs Pruefungen, die das
Fehlen sichern -- pruef-backstage-import 63 -> 66.
NEBENBEI GEDREHT, NICHT GELOESCHT: pruef-leistung-optik behauptete
noch die Regel vom 07.09. ("beim Manager fehlt die Kachel", "die Seite
weist den Creator ab"). Beide Aussagen sind jetzt umgekehrt und messen
zusaetzlich, was vorher niemand gemessen hat: dass die Creatorin auf
der Zahlen-Seite ihre EIGENEN Zahlen sieht und trotzdem keinen
einzigen Knopf zum Eintragen hat -- beides zusammen, denn "keine
Knoepfe" waere auch auf einer leeren Seite wahr. 56 -> 59.
Nicht von mir, nachgemessen gegen den Stand ohne diese Aenderungen:
pruef-struktur meldet dieselben 6 Fehler (crew-index/teamlage nicht
verlinkt, start.css 348 KB, totes CSS .nase, UTC-Datum).
Co-Authored-By: Claude Opus 5 <[email protected]>
660 lines
33 KiB
JavaScript
660 lines
33 KiB
JavaScript
/* ZAHLEN — KANN MAN SIE EINTRAGEN, UND SAGEN SIE ETWAS?
|
||
|
||
pruef-leistung.mjs beweist, dass der Server richtig rechnet. Diese
|
||
Prüfung fragt das, worauf es danach ankommt: Kommt jemand an die
|
||
Zahlen heran, und sagt ihm die Seite etwas, das er ohne sie nicht
|
||
wüsste?
|
||
|
||
Ein Rechenwerk ohne Oberfläche ist kein Modul, sondern ein
|
||
Versprechen. Genau deshalb steht diese Prüfung neben der anderen.
|
||
|
||
GEMESSEN WIRD:
|
||
* Ein Tag wird eingetragen und steht danach in der Wochenkarte.
|
||
* Der VERGLEICH zur Vorwoche erscheint -- die Zahl allein wäre eine
|
||
Behauptung.
|
||
* Die kleine Verlaufslinie wird wirklich GEZEICHNET. Nicht "das
|
||
Element ist da": Ihre Punkte müssen Zahlen sein. Genau hier saß
|
||
ein echter Fehler -- der Server liefert im Verlauf nur drei der
|
||
sieben Größen, für die anderen kam `undefined` an, wurde zu NaN,
|
||
und der Browser zeichnete wortlos nichts.
|
||
* Der Import zeigt ERST die Vorschau. Gegenprobe: vorher darf noch
|
||
nichts in der Datenbank stehen.
|
||
* Ein Creator SIEHT seine Zahlen und kann sie NICHT eintragen.
|
||
* Schriftgrößen und Fingerziele auf dem Handy.
|
||
* Das Dashboard zeigt die Zahlen und die Frühwarnung.
|
||
|
||
GEGENPROBEN durchgehend: Zu jeder Prüfung, die "in Ordnung" sagen
|
||
kann, gehört eine, die beweist, dass sie auch "nicht in Ordnung"
|
||
sagen könnte.
|
||
===================================================================== */
|
||
|
||
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-leistoptik-"));
|
||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||
|
||
import { notbremse } from "./helfer-notbremse.mjs";
|
||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||
await portMussFreiSein(4337, "die Zahlen-Optikpruefung");
|
||
|
||
process.env.PORT = "4337";
|
||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||
|
||
const express = (await import("express")).default;
|
||
const ec = express.response.cookie;
|
||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||
await import("./index.js");
|
||
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
|
||
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
|
||
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
|
||
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
|
||
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
|
||
notbremse(480_000, "pruef-leistung-optik");
|
||
await new Promise((r) => setTimeout(r, 1000));
|
||
const BASIS = "http://127.0.0.1:4337";
|
||
|
||
const PROTOKOLL = join(process.cwd(), "pruef-leistung-optik-lauf.txt");
|
||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||
const melde = (z) => {
|
||
console.log(z);
|
||
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
|
||
};
|
||
let fehler = 0;
|
||
let gemacht = 0;
|
||
const ok = (b, t) => { gemacht++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||
|
||
/* ---- Bestand ------------------------------------------------------------ */
|
||
const { DatabaseSync } = await import("node:sqlite");
|
||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||
for (let i = 0; i < 60; i++) {
|
||
try {
|
||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||
}
|
||
|
||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
const jetzt = new Date().toISOString();
|
||
function anlegen(name, rolle, code) {
|
||
const salt = randomBytes(16).toString("hex");
|
||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||
.run(name, rolle, hash, salt, 32768, jetzt);
|
||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
}
|
||
anlegen("Filipe", "admin", "CODE-DOGI-0001");
|
||
const idMan = anlegen("Marek", "manager", "CODE-MANA-0001");
|
||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idMan, jetzt);
|
||
|
||
/* Ortszeit-Datum. NIE toISOString(): das ist UTC und liegt zwischen
|
||
Mitternacht und zwei Uhr einen Tag daneben -- eine Prüfung, die um
|
||
00:30 rot wird, ohne dass sich etwas geändert hat. */
|
||
const p2 = (n) => String(n).padStart(2, "0");
|
||
const tagVor = (n) => {
|
||
const t = new Date(Date.now() - n * 86400000);
|
||
return `${t.getFullYear()}-${p2(t.getMonth() + 1)}-${p2(t.getDate())}`;
|
||
};
|
||
|
||
/* VORWOCHE bestücken, damit es überhaupt etwas zu vergleichen gibt --
|
||
und damit die Verlaufslinie genug Punkte hat. Die laufende Woche
|
||
bleibt leer: Sie wird gleich über die Oberfläche gefüllt, und das ist
|
||
der eigentliche Gegenstand dieser Prüfung. */
|
||
const setzen = d.prepare(`
|
||
INSERT INTO leistung (creator_id, tag, diamanten, dauer_min, gueltiger_tag,
|
||
zuschauer_avg, verweildauer_s, erfasst, erfasst_von, quelle)
|
||
VALUES (?,?,?,?,1,?,?,?,?, 'hand')`);
|
||
for (let i = 7; i <= 27; i++) {
|
||
/* Absteigend über drei Wochen -- damit auch die Frühwarnung etwas zu
|
||
melden hat und nicht nur "keine Befunde" heißt. */
|
||
setzen.run(idLuna, tagVor(i), 400 + i * 12, 90, 30, 200 + i, jetzt, idMan);
|
||
}
|
||
d.close();
|
||
|
||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
const browser = await pw.chromium.launch();
|
||
|
||
async function alsRolle(rolle, code, wohin = "leistung.html", groesse = { width: 1280, height: 900 }) {
|
||
const ctx = await browser.newContext({ viewport: groesse });
|
||
const seite = await ctx.newPage();
|
||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||
await seite.fill("#code", code);
|
||
await seite.click("button[type=submit]");
|
||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||
await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(900);
|
||
return { ctx, seite };
|
||
}
|
||
|
||
/* =======================================================================
|
||
1. Die Seite ist überhaupt erreichbar — und über die Kachel
|
||
======================================================================= */
|
||
melde("\n=== Der Weg zur Seite ===");
|
||
/* SEIT FILIPES ANWEISUNG SIEHT NUR DOGFATHER DIE ZAHLEN.
|
||
Hier stand "manager", und bis zur Umstellung war das richtig. Seit
|
||
die Kachel und die Seite auf DogFather beschraenkt sind, hat sich
|
||
diese Pruefung selbst ausgesperrt: Sie meldete sieben Fehler, von
|
||
denen keiner ein Mangel war -- sie sah nur eine Seite, die sie nicht
|
||
mehr sehen darf. Gemessen wird jetzt als DogFather; dass der Manager
|
||
NICHT hineinkommt, steht als eigene Gegenprobe darunter. */
|
||
const chef = await alsRolle("admin", "CODE-DOGI-0001", "start.html");
|
||
{
|
||
const kachel = await chef.seite.$$eval(".kachel", (k) =>
|
||
k.map((x) => ({
|
||
name: x.querySelector(".kachel__name")?.textContent.trim(),
|
||
/* Das href sitzt am .kachel__link INNEN, nicht am <li> aussen --
|
||
beim ersten Anlauf stand hier `x.getAttribute("href")` und
|
||
lieferte brav "null". Eine Prüfung, die am falschen Element
|
||
misst, meldet einen Fehler, den es nicht gibt; das ist genauso
|
||
schädlich wie einer, den sie übersieht. */
|
||
ziel: x.querySelector(".kachel__link")?.getAttribute("href"),
|
||
zeichen: !!x.querySelector("svg"),
|
||
})).filter((x) => x.name === "Zahlen"));
|
||
ok(kachel.length === 1, `die Kachel "Zahlen" steht auf der Startseite (${kachel.length}×)`);
|
||
ok(kachel[0]?.ziel === "leistung.html", `sie führt nach "${kachel[0]?.ziel}"`);
|
||
ok(kachel[0]?.zeichen === true, "sie hat ein Zeichen");
|
||
|
||
/* GEGENPROBE zum Selektor: Ein Name, den es nicht gibt, darf NICHT
|
||
gefunden werden -- sonst prüfte der Block oben nichts. */
|
||
const erfunden = await chef.seite.$$eval(".kachel", (k) =>
|
||
k.some((x) => x.querySelector(".kachel__name")?.textContent.trim() === "Zahlenzauber"));
|
||
ok(!erfunden, "eine erfundene Kachel wird nicht gefunden");
|
||
|
||
/* 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);
|