Files
dogfather-universe/server/pruef-backstage-import.mjs
T
DogFatherGitandClaude Opus 5 e82bf45f0d Ein Zeitraum geht jetzt durch -- als Zeitraum, nicht als Tag
Filipe: "verbesser das, also ich will dass das auch so geht, mach dass
es funktioniert, ich will es so einfach und perfekt wie moeglich. also
sieh zu dass die excel auch so durch geht."

Backstage gibt eine Ausgabe fuer einen ZEITRAUM heraus --
"2026-09-01 ~ 2026-09-13", eine Zeile je Creator, alle Zahlen
aufsummiert. Am Vormittag wurde so eine Zeile abgewiesen. Jetzt geht
sie durch.

DREI WEGE WAEREN MOEGLICH GEWESEN, ZWEI DAVON WAEREN
ZAHLENFAELSCHUNG:
  * auf den ersten Tag schreiben -> dreizehn Tage auf einem Tag; die
    Zahl steht da, sie ist gross, sie sieht richtig aus, und niemand
    kann spaeter sagen, was darin steckt;
  * gleichmaessig verteilen -> erfundene Tage, die es nie gab.
Der dritte ist dieser: den Zeitraum ALS Zeitraum ablegen. Was drinsteht,
ist dann wahr -- und was er nicht sagt (welcher Tag wie lief),
behauptet er auch nicht.

EIGENE TABELLE UND NICHT EIN FELD IN `leistung`: Dort ist der
Schluessel (creator, tag). Ein Zeitraum ab dem 1. September wuerde mit
dem ECHTEN 1. September zusammenstossen, und eine der beiden Zahlen
waere weg. Getrennt kann keines das andere ueberschreiben -- und die
Wochenzahlen bleiben, was sie sind: aus Tagen gerechnet.

Der Schluessel ist (creator, von, bis): Dieselbe Datei zweimal
einzulesen ersetzt denselben Zeitraum, statt ihn zu verdoppeln.

AUF DER SEITE steht ein eigener Block zwischen Woche und Tageszeilen:
die Spanne vorn, die Zahl der Tage als Marke daneben, die Werte
darunter. Dazu EINE Umrechnung, und nur diese: "Ø 4.129 Diamanten pro
Tag" -- als Durchschnitt bezeichnet, nirgends gespeichert. Wer eine
Woche vergleichen will, braucht sie; wer sie fuer einen echten Tag
haelt, hat das Wort nicht gelesen. Darueber steht woertlich, dass diese
Zahlen nicht in die Wochenzahlen eingehen.

MIT DER ECHTEN DATEI GEMESSEN: alle 8 Zeilen gehen durch, 8 als
Zeitraum, 0 abgelehnt. Spanne 01.09.-11.09. = 11 Tage, Diamanten
53.679, Dauer 5171 Minuten.

pruef-backstage-import 86 -> 106. Die Pruefung, auf die es ankommt, ist
nicht "es wird gespeichert", sondern "es wird NICHT in die Woche
gemischt": kein Tag kommt hinzu, die Wochenzahl bleibt Ziffer fuer
Ziffer dieselbe. Dazu die Gegenprobe, dass ein Zeitraum von EINEM Tag
weiterhin ein Tag bleibt -- sonst hiesse alles andere nur, dass jetzt
alles als Zeitraum abgelegt wird.

DREI PRUEFUNGEN GEDREHT, KEINE GELOESCHT: Zwei behaupteten noch die
Ablehnung vom Vormittag. Und tagAusZelle() hat jetzt DREI Ausgaenge
statt zwei (Tag, Zeitraum, Grund) -- geprueft wird, dass nie zwei davon
gleichzeitig kommen. Gaebe es zwei, entschiede jeder Aufrufer selbst,
was Vorrang hat, und der zweite entschiede anders als der erste. Genau
daran ist heute frueh der Zeitraum-Schutz gescheitert.

ZWEI EIGENE MESSFEHLER, beide von der Pruefung gefunden: Ich verlangte
"kein einziger Tag in der Spanne" und uebersah, dass weiter oben schon
ein Tag geschrieben worden war, der hineinfaellt -- gemessen wird jetzt
die VERAENDERUNG. Und ich mass den Zeitraum-Block, waehrend ein anderer
Creator geoeffnet war: keine Frage an die Seite, sondern an die falsche
Person.

Nebenbei zum zweiten Mal heute: eine Versalzeile mit 10,88 px. Auf
.72rem angehoben, bevor pruef-css-klassen sie findet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 16:23:16 +02:00

995 lines
47 KiB
JavaScript

/* =====================================================================
DER BACKSTAGE-IMPORT — eine Tabelle, alle Creator auf einmal.
Filipe (10.09.2026): "wie sehr wir diese seite hier mit dem backstage
verbinden könnten von tiktok. ohne probleme und ganz schnell."
Die Recherche ergab: Backstage hat keine Schnittstelle und keinen
Export. Was es hat, ist eine Tabelle im Browser. Also: markieren,
kopieren, einfügen -- und der Workspace ordnet die Zeilen den
Creators zu.
DIE ZUORDNUNG IST DIE GANZE ARBEIT, und sie ist auch die ganze
Gefahr: Eine falsch zugeordnete Zahl ist schlimmer als eine
fehlende, weil die fehlende auffällt. Deshalb prüft diese Datei vor
allem, was NICHT passiert:
- eine unbekannte Person wird gemeldet, nicht geraten
- eine Zeile ohne Zahlen wird übersprungen, nicht als Null
geschrieben (sie würde sonst einen echten Tag überschreiben)
- ein Datum in der Zukunft wird abgelehnt
- ein Scout kommt gar nicht erst an diesen Weg
Aufruf: node server/pruef-backstage-import.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { notbremse } from "./helfer-notbremse.mjs";
notbremse(240_000, "pruef-backstage-import");
const HIER = dirname(fileURLToPath(import.meta.url));
const { xlsxBauen } = await import("./helfer-xlsx-bauen.mjs");
const PORT = 4389;
const BASIS = `http://127.0.0.1:${PORT}`;
const ordner = mkdtempSync(join(tmpdir(), "ws-backstage-"));
const DB = join(ordner, "workspace.db");
const jetzt = new Date().toISOString();
const heute = new Date().toISOString().slice(0, 10);
const gestern = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const morgen = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
let gemacht = 0, fehler = 0;
const ok = (b, text) => {
gemacht++;
if (b) console.log(` ok ${text}`);
else { fehler++; console.log(` FEHL ${text}`); }
};
let kind = null;
async function starten() {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let aus = "";
kind.stdout.on("data", (d) => { aus += d; });
kind.stderr.on("data", (d) => { aus += d; });
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
if (a.status < 500) return; } catch { /* noch nicht oben */ }
}
throw new Error("Server startete nicht:\n" + aus.slice(-1500));
}
await starten();
kind.kill();
await new Promise((r) => setTimeout(r, 600));
let idNova, idKiro, idLumi, idSam, idMira;
{
const d = new DatabaseSync(DB);
const anlegen = (name, rolle, code, tiktok) => {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 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, tiktok) VALUES (?,?,?,?,?,1,?,?)")
.run(name, rolle, hash, salz, 32768, jetzt, tiktok ?? null);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
idSam = anlegen("Sam", "scout", "CODE-SAM-00001");
/* Seit dem 11.09.2026 kommen auch Manager und Spicy Media auf diese
Seite. Beide sehen andere Creator als ein Scout -- Spicy Media
alle (siehtAlles), ein Manager nur seine eigenen und die seiner
Scouts. Ohne beide im Bestand waere das ungeprueft. */
idMira = anlegen("Mira", "manager", "CODE-MIRA-0001");
anlegen("Sasha", "spicy", "CODE-SASHA-0001");
/* Nova hat ein Handle, Kiro NICHT -- er wird ueber den Namen
gefunden. Lumi hat ein Handle mit anderer Schreibweise als in der
Tabelle: Genau daran zeigt sich, ob normalisiert wird. */
idNova = anlegen("Nova", "creator", "CODE-NOVA-0001", "nova_live");
idKiro = anlegen("Kiro", "creator", "CODE-KIRO-0001", null);
idLumi = anlegen("Lumi", "creator", "CODE-LUMI-0001", "LumiStreamt");
/* Sam betreut Nova. Ohne diese Zeile haette der Scout gar keinen
Creator -- die Seite waere leer, und eine Pruefung auf "der Scout
sieht den Backstage-Satz NICHT" waere gruen, weil er ueberhaupt
nichts sieht. Das ist der Unterschied zwischen einem Beweis und
einem Zufall. */
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idNova, idSam, jetzt);
/* Und Sam gehoert zu Mira -- damit der Manager ueber seinen Scout
an Nova kommt. Sonst saehe er eine leere Seite, und die Pruefung
unten wuerde messen, dass nichts da ist, statt dass es stimmt. */
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)")
.run(idSam, idMira, jetzt);
d.close();
}
await starten();
const anmelden = async (rolle, code) => {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }) });
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
return keks;
};
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksScout = await anmelden("scout", "CODE-SAM-00001");
const ruf = async (weg, keks, koerper) => {
const a = await fetch(BASIS + weg, { method: "POST",
headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS },
body: JSON.stringify(koerper) });
let daten = null; try { daten = await a.json(); } catch { /* leer erlaubt */ }
return { status: a.status, daten };
};
/* EINE TABELLE, WIE SIE AUS DEM BROWSER KOMMT: mit TABULATOREN.
Genau das ist der Fall, den der alte Zerleger nicht kannte -- er
suchte nur Komma und Semikolon. */
const TABELLE = [
"Creator\tHandle\tDiamanten\tLIVE-Dauer (Minuten)\tØ Zuschauer\tPeak Zuschauer\tNeue Follower",
`Nova\t@Nova_Live\t12.480\t184\t42\t310\t23`,
`Kiro\t\t3.900\t95\t18\t74\t6`,
`Lumi\t@lumistreamt\t22.010\t240\t88\t512\t41`,
`Fremde Person\t@niemand_hier\t500\t30\t5\t12\t1`,
].join("\n");
/* =======================================================================
1. Die Vorschau ordnet zu -- und raet nicht
======================================================================= */
console.log("=== Die Vorschau ===");
{
const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: TABELLE, tag: gestern });
ok(status === 200, `die Tabelle wird gelesen (HTTP ${status})`);
/* DER TABULATOR IST DER PUNKT: Ohne ihn stuende alles in Spalte 1
und es waere nichts erkannt worden. */
ok(daten?.zugeordnet === 3, `drei von vier Zeilen zugeordnet (${daten?.zugeordnet})`);
ok(daten?.unbekannt?.length === 1 && /niemand_hier/.test(daten.unbekannt[0]),
`die vierte wird beim Namen genannt (${(daten?.unbekannt || []).join(", ")})`);
ok(daten?.neu === 3 && daten?.ersetzt === 0, `drei neu, keine ersetzt (${daten?.neu}/${daten?.ersetzt})`);
const sp = Object.keys(daten?.spalten || {});
ok(sp.includes("diamanten") && sp.includes("dauer_min") && sp.includes("zuschauer_avg")
&& sp.includes("zuschauer_max") && sp.includes("follower_neu"),
`fuenf Zahlenspalten erkannt (${sp.join(", ")})`);
/* "12.480" ist deutsch geschrieben und muss 12480 sein, nicht 12,48. */
const nova = (daten?.beispiele || []).find((b) => b.name === "Nova");
ok(nova?.diamanten === 12480, `"12.480" wurde zu 12480 (${nova?.diamanten})`);
}
/* =======================================================================
2. Ohne Datum in der Tabelle UND ohne Vorgabe: eine Frage, kein Unsinn
======================================================================= */
console.log("\n=== Wenn der Tag fehlt ===");
{
const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: TABELLE });
ok(status === 400 && daten?.braucht_tag === true,
`es wird nach dem Tag gefragt statt geraten (HTTP ${status})`);
}
/* =======================================================================
3. Uebernehmen -- und was dabei NICHT passiert
======================================================================= */
console.log("\n=== Übernehmen ===");
{
const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import",
keksDogi, { text: TABELLE, tag: gestern });
ok(status === 200, `die Übernahme läuft (HTTP ${status})`);
ok(daten?.geschrieben === 3, `drei Zeilen geschrieben (${daten?.geschrieben})`);
const d = new DatabaseSync(DB);
const z = d.prepare("SELECT creator_id, diamanten, dauer_min, gueltiger_tag, quelle FROM leistung WHERE tag = ? ORDER BY creator_id").all(gestern);
ok(z.length === 3, `drei Zeilen stehen in der Datenbank (${z.length})`);
ok(z.every((x) => x.quelle === "import"),
"alle mit Quelle 'import' -- von Hand getippte sind unterscheidbar");
ok(z.every((x) => x.gueltiger_tag === 1),
"alle als gültiger Tag gezählt, weil LIVE-Dauer vorhanden ist");
const lumi = z.find((x) => x.creator_id === idLumi);
ok(lumi?.diamanten === 22010,
`Lumi wurde über "@lumistreamt" gegen "LumiStreamt" gefunden (${lumi?.diamanten})`);
const kiro = z.find((x) => x.creator_id === idKiro);
ok(kiro?.diamanten === 3900, `Kiro ohne Handle über den Namen (${kiro?.diamanten})`);
/* DIE FREMDE PERSON DARF NICHT ENTSTANDEN SEIN. Ein Import, der
nebenbei Leute anlegt, waere die schlimmste Variante von
"hilfreich". */
const wieViele = d.prepare("SELECT COUNT(*) AS n FROM personen WHERE rolle = 'creator'").get().n;
ok(wieViele === 3, `es wurde niemand neu angelegt (${wieViele} Creator)`);
d.close();
}
/* =======================================================================
4. Noch einmal dieselbe Tabelle: ersetzen statt verdoppeln
======================================================================= */
console.log("\n=== Dieselbe Tabelle ein zweites Mal ===");
{
const { daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: TABELLE, tag: gestern });
ok(daten?.ersetzt === 3 && daten?.neu === 0,
`jetzt drei ersetzt, keine neu (${daten?.neu}/${daten?.ersetzt})`);
const { status } = await ruf("/workspace/api/leistung/netzwerk-import",
keksDogi, { text: TABELLE, tag: gestern });
ok(status === 200, "und sie laeuft anstandslos durch");
const d = new DatabaseSync(DB);
const n = d.prepare("SELECT COUNT(*) AS n FROM leistung WHERE tag = ?").get(gestern).n;
ok(n === 3, `es sind immer noch drei Zeilen, keine sechs (${n})`);
d.close();
}
/* =======================================================================
5. Eine von Hand geschriebene Notiz ueberlebt
======================================================================= */
console.log("\n=== Was von Hand kam, bleibt ===");
{
const d = new DatabaseSync(DB);
d.prepare("UPDATE leistung SET notiz = ? WHERE creator_id = ? AND tag = ?")
.run("Technikprobleme ab 22 Uhr", idNova, gestern);
d.close();
await ruf("/workspace/api/leistung/netzwerk-import", keksDogi, { text: TABELLE, tag: gestern });
const d2 = new DatabaseSync(DB);
const n = d2.prepare("SELECT notiz FROM leistung WHERE creator_id = ? AND tag = ?")
.get(idNova, gestern);
ok(n?.notiz === "Technikprobleme ab 22 Uhr",
`die Notiz steht nach dem Import noch da ("${n?.notiz}")`);
d2.close();
}
/* =======================================================================
6. Gegenproben -- was abgelehnt wird
======================================================================= */
console.log("\n=== Gegenproben ===");
{
const { status } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: TABELLE, tag: morgen });
/* Ein Tag in der Zukunft laesst die Zeilen durchfallen -- die
Vorschau antwortet zwar 200, ordnet aber nichts zu. */
const { daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: TABELLE, tag: morgen });
ok(status === 200 && daten?.zugeordnet === 0 && daten?.probleme_gesamt >= 3,
`ein Datum in der Zukunft wird abgelehnt (${daten?.zugeordnet} zugeordnet, `
+ `${daten?.probleme_gesamt} Probleme)`);
}
{
const ohneZahlen = ["Creator\tHandle\tDiamanten", "Nova\t@Nova_Live\t"].join("\n");
const { daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: ohneZahlen, tag: gestern });
ok(daten?.zugeordnet === 0,
"eine Zeile ganz ohne Zahl wird uebersprungen statt als Null geschrieben");
}
{
const ohneNamen = ["Datum\tDiamanten", `${gestern}\t100`].join("\n");
const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: ohneNamen });
ok(status === 400 && /Name oder Handle/.test(daten?.fehler || ""),
`ohne Personenspalte wird es abgelehnt ("${daten?.fehler}")`);
}
{
const { status } = await ruf("/workspace/api/leistung/netzwerk-import",
keksScout, { text: TABELLE, tag: gestern });
ok(status === 403, `ein Scout kommt an diesen Weg nicht (HTTP ${status})`);
}
{
/* GEGENPROBE ZUR GEGENPROBE: Das Richtige geht weiterhin durch.
Ohne sie hiesse "alles wird abgelehnt" auch "alles wird
abgelehnt". */
const { status } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: TABELLE, tag: heute });
ok(status === 200, `heute als Tag geht dagegen durch (HTTP ${status})`);
}
/* DIE SCHNITTSTELLE ZUR TIKTOK-DATEI GIBT ES NICHT MEHR (11.09.2026).
Filipe: "ich will keine daten von denen kriegen, nur wir."
Ein ausgebauter Knopf ist keine Entfernung -- die Schnittstelle
waere weiterhin erreichbar, und wer die Adresse kennt, koennte
weiter eine Creator-Datei hochladen. Gemessen wird deshalb hier,
mit DogFathers Zugang, also dem staerksten, den es gibt: Bekommt
ER 404, bekommt ihn jeder.
GEGENPROBE DANEBEN: Der Backstage-Weg antwortet weiterhin. Sonst
bewiese das 404 nur, dass ich mich vertippt habe. */
for (const weg of ["/workspace/api/leistung/1/tiktok-datei",
"/workspace/api/leistung/1/tiktok-datei/vorschau"]) {
const { status } = await ruf(weg, keksDogi, { inhalt: {} });
ok(status === 404, `${weg.split("/").slice(-2).join("/")} gibt es nicht mehr (HTTP ${status})`);
}
{
const { status } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: TABELLE, tag: gestern });
ok(status === 200,
`Gegenprobe: der Backstage-Weg antwortet weiterhin (HTTP ${status})`);
}
/* WER SIEHT WEN -- direkt an der Schnittstelle gemessen, bevor der
Browser ins Spiel kommt. Wird der Auswahlliste auf der Seite etwas
fehlen, steht hier, ob es schon hier fehlte. */
for (const [rolle, code, wer] of [
["manager", "CODE-MIRA-0001", "über seinen Scout"],
["spicy", "CODE-SASHA-0001", "alle"],
]) {
const keks = await anmelden(rolle, code);
const a = await fetch(BASIS + "/workspace/api/leistung", { headers: { Cookie: keks } });
const d = await a.json().catch(() => ({}));
const namen = (d.creator || []).map((c) => c.name).join(", ");
ok(a.status === 200 && namen.length > 0,
`${rolle} bekommt Creator (${wer}): ${namen || `LEER, HTTP ${a.status}`}`);
}
/* Vorbedingung fuer die Gegenprobe weiter unten: Sam MUSS Nova sehen.
Sonst waere "der Scout sieht den Backstage-Satz nicht" nur die
Auskunft, dass seine Seite leer ist. */
{
const a = await fetch(BASIS + "/workspace/api/leistung", { headers: { Cookie: keksScout } });
const d = await a.json().catch(() => ({}));
const namen = (d.creator || []).map((c) => c.name).join(", ");
ok(/Nova/.test(namen),
`der Scout sieht seinen Creator in der Liste (${namen || "leer"})`);
}
{
/* ---- EIN ZEITRAUM GEHT JETZT DURCH (14.09.2026) ------------------
Filipe: "ich will dass das auch so geht, mach dass es
funktioniert ... sieh zu dass die excel auch so durch geht."
Am Vormittag wurde eine Zeile mit "2026-09-01 ~ 2026-09-13"
abgewiesen -- richtig, denn auf einen Tag geschrieben waeren es
dreizehn Tage auf einmal gewesen. Jetzt geht sie durch, aber als
ZEITRAUM: eigene Tabelle, eigener Block auf der Seite, und die
Wochenzahlen bleiben unberuehrt.
DAS IST DIE PRUEFUNG, AUF DIE ES ANKOMMT: nicht "es wird
gespeichert", sondern "es wird NICHT in die Woche gemischt". Eine
Summe aus dreizehn Tagen in einer Wochenzahl waere genau der
stille Fehler, den das Ganze vermeiden soll. */
const spanneText = [
"Datenzeitraum\tCreator\tDiamanten\tLIVE-Dauer\tGültige LIVE-Gehen-Tage",
"2026-09-01 ~ 2026-09-13\t@Nova_Live\t53.679\t86Std. 10Min. 52Sek.\t11",
].join("\n");
{
const v = await ruf("/workspace/api/leistung/netzwerk-import/vorschau",
keksDogi, { text: spanneText });
ok(v.status === 200, `die Vorschau nimmt den Zeitraum an (HTTP ${v.status})`);
ok(v.daten?.zeitraeume === 1,
`sie zaehlt ihn als Zeitraum, nicht als Tag (${v.daten?.zeitraeume})`);
ok(v.daten?.spanne?.tage === 13,
`und nennt die Zahl der Tage (${v.daten?.spanne?.tage})`);
ok((v.daten?.probleme || []).length === 0,
`keine Absage mehr (${(v.daten?.probleme || []).length} Probleme)`);
}
/* Die Woche VOR dem Import -- als Vergleichspunkt. Ohne ihn hiesse
"die Woche stimmt" nur, dass irgendetwas dasteht. */
const wocheVor = await (await fetch(BASIS + `/workspace/api/leistung/${idNova}`,
{ headers: { Cookie: keksDogi } })).json();
const diaVor = wocheVor?.woche?.diamanten?.wert ?? null;
/* WIE VIELE TAGESZEILEN ES SCHON GIBT. Der erste Entwurf verlangte
hier NULL -- und uebersah, dass die Backstage-Tabelle weiter oben
bereits einen Tag geschrieben hat, der in dieselbe Spanne faellt.
Die Pruefung war rot, der Code richtig. Gemessen wird deshalb die
VERAENDERUNG, nicht der Absolutwert. */
const tageVor = (() => {
const d0 = new DatabaseSync(DB, { readOnly: true });
const n = d0.prepare(
"SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ? AND tag >= '2026-09-01' AND tag <= '2026-09-13'")
.get(idNova).n;
d0.close();
return n;
})();
{
const s = await ruf("/workspace/api/leistung/netzwerk-import",
keksDogi, { text: spanneText });
ok(s.status === 200, `der Zeitraum wird uebernommen (HTTP ${s.status})`);
ok(s.daten?.zeitraeume === 1 && s.daten?.geschrieben === 0,
`als Zeitraum geschrieben, nicht als Tag (${s.daten?.zeitraeume} / ${s.daten?.geschrieben})`);
}
{
const d2 = new DatabaseSync(DB, { readOnly: true });
const z = d2.prepare(
"SELECT * FROM leistung_zeitraum WHERE creator_id = ?").get(idNova);
const tage = d2.prepare(
"SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ? AND tag >= '2026-09-01' AND tag <= '2026-09-13'")
.get(idNova).n;
d2.close();
ok(z && z.von === "2026-09-01" && z.bis === "2026-09-13" && z.tage === 13,
`er steht in der Datenbank (${z?.von} bis ${z?.bis}, ${z?.tage} Tage)`);
ok(z?.diamanten === 53679, `mit den Diamanten (${z?.diamanten})`);
ok(z?.dauer_min === 5171, `und der Dauer in Minuten (${z?.dauer_min})`);
/* DER WICHTIGSTE HAKEN: In der TAGES-Tabelle steht davon nichts. */
ok(tage === tageVor,
`und KEIN einziger Tag kam hinzu (${tageVor} -> ${tage} Tageszeilen)`);
}
{
/* Und die Wochenzahl ist unveraendert -- der Zeitraum mischt sich
nicht hinein. */
const nach = await (await fetch(BASIS + `/workspace/api/leistung/${idNova}`,
{ headers: { Cookie: keksDogi } })).json();
ok((nach?.woche?.diamanten?.wert ?? null) === diaVor,
`die Wochenzahl bleibt unveraendert (${diaVor} -> ${nach?.woche?.diamanten?.wert})`);
ok(Array.isArray(nach?.zeitraeume) && nach.zeitraeume.length === 1,
`aber der Zeitraum wird mitgeliefert (${(nach?.zeitraeume || []).length})`);
}
{
/* DIESELBE DATEI ZWEIMAL ersetzt denselben Zeitraum, statt ihn zu
verdoppeln -- sonst stuenden nach dem zweiten Einlesen zwei
gleiche Karten da. */
await ruf("/workspace/api/leistung/netzwerk-import", keksDogi, { text: spanneText });
const d3 = new DatabaseSync(DB, { readOnly: true });
const n = d3.prepare(
"SELECT COUNT(*) AS n FROM leistung_zeitraum WHERE creator_id = ?").get(idNova).n;
d3.close();
ok(n === 1, `zweimal einlesen ergibt EINEN Zeitraum (${n})`);
}
{
/* GEGENPROBE: Ein Zeitraum von EINEM Tag ist kein Zeitraum -- er
geht weiterhin in die Tagestabelle. Ohne sie hiesse alles oben
nur, dass jetzt alles als Zeitraum abgelegt wird. */
const einTag = [
"Datenzeitraum\tCreator\tDiamanten",
`${gestern} ~ ${gestern}\t@Nova_Live\t1.234`,
].join("\n");
const s = await ruf("/workspace/api/leistung/netzwerk-import", keksDogi, { text: einTag });
ok(s.daten?.geschrieben === 1 && (s.daten?.zeitraeume ?? 0) === 0,
`ein Ein-Tages-Zeitraum bleibt ein Tag (${s.daten?.geschrieben} Tag, `
+ `${s.daten?.zeitraeume} Zeitraeume)`);
}
}
/* =======================================================================
7. Und die Seite selbst
======================================================================= */
console.log("\n=== Die Seite ===");
{
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
const seite = await ctx.newPage();
const skriptfehler = [];
seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140)));
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(BASIS + "/workspace/leistung.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1800);
ok(skriptfehler.length === 0, `kein Skriptfehler (${skriptfehler.join(" | ") || "keiner"})`);
const sichtbar = await seite.$eval("#netz-oeffnen", (e) => !e.hidden).catch(() => false);
ok(sichtbar === true, "DogFather sieht den Knopf „Backstage-Tabelle einfügen\"");
/* Einfuegen, Vorschau abwarten, Meldung lesen -- der ganze Weg. */
await seite.click("#netz-oeffnen");
await seite.waitForTimeout(400);
await seite.fill("#netz-tag", gestern);
await seite.fill("#netz-text", TABELLE);
await seite.dispatchEvent("#netz-text", "change");
await seite.waitForTimeout(1200);
const vorschau = await seite.$eval("#netz-vorschau", (e) => e.textContent).catch(() => "");
ok(/3 von 4 Zeilen zugeordnet/.test(vorschau),
`die Vorschau nennt die Zahl (${vorschau.slice(0, 60)}…)`);
ok(/niemand_hier/.test(vorschau), "und den Namen, der nicht zugeordnet wurde");
const losFrei = await seite.$eval("#netz-los", (e) => !e.disabled);
ok(losFrei === true, "der Übernehmen-Knopf ist danach frei");
/* ---- DIE ERKLÄRUNG MUSS DA SEIN (11.09.2026) --------------------
Filipe: "ich brauch auch eine erklärung immer dabei wo die scouts
und manager das finden." Ein Text, den niemand prüft, verschwindet
beim nächsten Umbau -- und niemandem fällt es auf, weil ein
fehlender Satz nichts kaputtmacht. */
const schritte = await seite.$$eval("#netz-dialog .anleitung li", (ls) => ls.length);
ok(schritte >= 5, `die Backstage-Anleitung hat ${schritte} Schritte`);
const anleitung = await seite.$eval("#netz-dialog", (e) => e.textContent);
ok(/Überschriftenzeile/.test(anleitung),
"und sagt, dass die Überschriftenzeile mitmarkiert werden muss");
ok(/Steckbrief/.test(anleitung),
"und woher die Zuordnung kommt (TikTok-Name im Steckbrief)");
/* ---- ARBEIT ZUERST, ANLEITUNG DAHINTER (11.09.2026) -------------
Filipe: "perfektionnier auch diese kachel die sieht so scheisse
aus." Fuenf lange Schritte standen VOR dem Einfuegefeld.
DIE ZEILEN OBEN WUERDEN AUCH GRUEN BLEIBEN, WENN DIE ANLEITUNG
WIEDER NACH VORN RUTSCHT -- `textContent` liest den ganzen Dialog
und kennt keine Reihenfolge. Deshalb wird hier die POSITION
gemessen, in Pixeln, an der laufenden Seite.
Und die eine Angabe, ohne die es gar nicht geht (die
Ueberschriftenzeile), muss SICHTBAR sein, ohne dass jemand
aufklappt. Sie hinter einen Klick zu legen hiesse, den
haeufigsten Fehlgriff zu verstecken. */
const bau = await seite.evaluate(() => {
const o = (id) => document.getElementById(id)?.getBoundingClientRect().top ?? -1;
const hilfe = document.getElementById("netz-hilfe");
const sichtbar = [...document.querySelectorAll("#netz-form > *")]
.filter((e) => e !== hilfe && !e.hidden)
.map((e) => e.textContent).join(" ");
return {
feld: o("netz-text"), hilfe: o("netz-hilfe"),
offen: hilfe?.open === true,
sichtbar,
zeileHoch: document.getElementById("netz-hilfe")?.offsetHeight ?? 0,
};
});
ok(bau.feld > 0 && bau.hilfe > bau.feld,
`das Einfügefeld steht VOR der Anleitung (${Math.round(bau.feld)} px zu `
+ `${Math.round(bau.hilfe)} px)`);
ok(bau.offen === false, "die Anleitung ist zugeklappt -- sie steht im Weg, wer sie kennt");
ok(/Überschriftenzeile/.test(bau.sichtbar),
"trotzdem steht die Überschriftenzeile schon oben, ohne Aufklappen");
ok(bau.zeileHoch > 0 && bau.zeileHoch < 90,
`zugeklappt nimmt sie kaum Platz (${bau.zeileHoch} px)`);
/* Und sie laesst sich wirklich oeffnen -- ein Aufklapper, der nicht
aufklappt, versteckt die Anleitung endgueltig. */
await seite.click("#netz-hilfe > summary");
await seite.waitForTimeout(300);
const auf = await seite.evaluate(() => {
const h = document.getElementById("netz-hilfe");
const ol = document.querySelector("#netz-hilfe .anleitung");
return { offen: h?.open === true, hoch: ol?.offsetHeight ?? 0 };
});
ok(auf.offen === true && auf.hoch > 80,
`aufgeklappt stehen die Schritte da (${auf.hoch} px)`);
await seite.click("#netz-hilfe > summary");
await seite.waitForTimeout(200);
if (process.env.SCHIRM) {
await seite.locator("#netz-dialog").screenshot(
{ path: join(HIER, "pruef-netz-zu.png") });
await seite.click("#netz-hilfe > summary");
await seite.waitForTimeout(300);
await seite.locator("#netz-dialog").screenshot(
{ path: join(HIER, "pruef-netz-auf.png") });
await seite.click("#netz-hilfe > summary");
await seite.waitForTimeout(200);
/* Und der Zustand, den man beim OEFFNEN sieht -- genau der, ueber
den Filipe gestolpert ist. */
await seite.evaluate(() => {
document.getElementById("netz-text").value = "";
document.getElementById("netz-vorschau").hidden = true;
document.getElementById("netz-tag").value = "";
});
await seite.waitForTimeout(200);
await seite.locator("#netz-dialog").screenshot(
{ path: join(HIER, "pruef-netz-leer.png") });
}
/* ---- DIE BACKSTAGE-AUSGABE ALS DATEI (14.09.2026) ---------------
Filipes echte Ausgabe ("Creator:innendaten …xlsx") enthaelt ALLE
Creator auf einmal -- sie gehoert hierher und nicht in den
Einzel-Import. Nachgebaut wird ihre Form, inklusive der Titelzeile,
die "Creator:innen verwalten" ueber die Tabelle setzt. */
const netzDatei = xlsxBauen([
["Exportiert am :2026-09-11 13:10:35"],
["Datenzeitraum", "Creator*innen-Anmeldename", "Diamanten", "LIVE-Dauer"],
[`${gestern} ~ ${gestern}`, "nova_live", "12.480", "3Std. 4Min."],
[`${gestern} ~ ${gestern}`, "Kiro", "3.900", "1Std. 35Min."],
]);
await seite.setInputFiles("#netz-datei", {
name: "Creator_innendaten.xlsx",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
buffer: netzDatei,
});
await seite.waitForTimeout(1800);
const ausDatei = await seite.evaluate(() => ({
feld: document.getElementById("netz-text")?.value || "",
fehler: document.getElementById("netz-fehler")?.textContent || "",
vorschau: document.getElementById("netz-vorschau")?.hidden,
text: document.getElementById("netz-vorschau")?.textContent || "",
}));
ok(ausDatei.fehler === "", `die Datei wird angenommen ("${ausDatei.fehler.slice(0, 40)}")`);
ok(ausDatei.feld.includes("nova_live") && ausDatei.feld.includes("Datenzeitraum"),
"das Einfuegefeld ist danach gefuellt -- ab da derselbe Weg wie beim Einfuegen");
ok(!ausDatei.feld.startsWith("Exportiert am"),
"die Titelzeile ist weg, die Ueberschrift steht oben");
ok(ausDatei.vorschau === false && /zugeordnet/.test(ausDatei.text),
`und die Vorschau erscheint von selbst ("${ausDatei.text.slice(0, 45)}…")`);
/* DER TEUERSTE FALL, gemessen statt vermutet: Ein ZEITRAUM ueber
mehrere Tage darf NICHT auf einen Tag geschrieben werden. Genau das
steht in Filipes echter Datei (2026-09-01 ~ 2026-09-11), und genau
das haette `datumLesen` stillschweigend auf den 1. September
gebucht -- elf Tage Diamanten, plausibel und falsch. */
const zeitraumDatei = xlsxBauen([
["Datenzeitraum", "Creator*innen-Anmeldename", "Diamanten"],
["2026-09-01 ~ 2026-09-11", "nova_live", "53.679"],
]);
await seite.setInputFiles("#netz-datei", {
name: "zeitraum.xlsx",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
buffer: zeitraumDatei,
});
await seite.waitForTimeout(1800);
const spanne = await seite.evaluate(() =>
document.getElementById("netz-vorschau")?.textContent || "");
/* GEDREHT AM 14.09.2026 NACHMITTAG, NICHT GELOESCHT.
Hier stand: "ein Zeitraum ueber mehrere Tage wird abgelehnt und
BEGRUENDET" -- richtig, bis Filipe sagte: "ich will dass das auch
so geht." Jetzt geht die Zeile durch, und die Vorschau sagt, dass
es ein ZEITRAUM ist. Eine geloeschte Pruefung senkt die Zahl und
beweist nichts; eine gedrehte haelt fest, dass die Aenderung eine
Entscheidung war. */
ok(/1 von 1 Zeilen zugeordnet/.test(spanne),
`ein Zeitraum ueber mehrere Tage geht jetzt durch ("${spanne.slice(0, 60)}…")`);
ok(!/mehrere Tage auf einmal/.test(spanne),
"und wird nicht mehr abgewiesen");
if (process.env.SCHIRM) {
await seite.locator("#netz-dialog").screenshot(
{ path: join(HIER, "pruef-netz-zeitraum.png") });
}
/* ---- UND WIE ES DANACH AUSSIEHT (14.09.2026) --------------------
Der Block mit den Zeitraeumen ist die eigentliche Antwort auf
"sieh zu dass die excel auch so durch geht": Die Zahlen sind da,
und man sieht auf einen Blick, worauf sie sich beziehen. */
await seite.click("#netz-los");
await seite.waitForTimeout(1800);
/* AUF DEN RICHTIGEN CREATOR SCHALTEN. Der erste Entwurf mass sofort
und fand nichts -- geoeffnet war Kiro, der Zeitraum gehoert Nova.
Das war kein Befund, sondern eine Frage an die falsche Person. */
await seite.selectOption("#creator", String(idNova)).catch(() => {});
await seite.waitForTimeout(1600);
const werOffen = await seite.evaluate(() =>
document.getElementById("creator")?.selectedOptions?.[0]?.textContent || "");
ok(/Nova/.test(werOffen), `Nova ist geoeffnet ("${werOffen.slice(0, 30)}")`);
const spannenBild = await seite.evaluate(() => ({
sichtbar: document.getElementById("spannen-block")?.hidden === false,
karten: document.querySelectorAll(".spanne").length,
zeit: document.querySelector(".spanne__zeit")?.textContent || "",
tage: document.querySelector(".spanne__tage")?.textContent || "",
schnitt: document.querySelector(".spanne__schnitt")?.textContent || "",
satz: document.querySelector(".spannen__satz")?.textContent || "",
}));
ok(spannenBild.sichtbar && spannenBild.karten >= 1,
`der Zeitraum-Block steht auf der Seite (${spannenBild.karten} Karten)`);
ok(spannenBild.zeit.includes("01.09.") && spannenBild.zeit.includes("13.09."),
`mit der Spanne davor ("${spannenBild.zeit}")`);
ok(/Tage/.test(spannenBild.tage),
`und der Zahl der Tage ("${spannenBild.tage}")`);
ok(/Ø/.test(spannenBild.schnitt) && /pro Tag/.test(spannenBild.schnitt),
`der Schnitt steht als Durchschnitt da, nicht als Tageswert ("${spannenBild.schnitt}")`);
ok(/nicht für einen Tag/.test(spannenBild.satz),
"und darueber steht, dass es kein Tageswert ist");
if (process.env.SCHIRM) {
await seite.locator("#spannen-block").screenshot(
{ path: join(HIER, "pruef-spannen.png") });
}
await seite.click("#netz-zu").catch(() => {});
await seite.waitForTimeout(300);
/* Gemessen wird SATZ FUER SATZ, nicht der Block. Ein Block, der
als Ganzes erscheint, erklaert einem Scout auch den Knopf, den er
nicht hat -- das ist keine Hilfe, sondern eine Absage. */
const satz = (id) => seite.$eval(id, (e) => (e.hidden ? "" : e.textContent))
.catch(() => "");
const netzSatz = await satz("#quelle-netz");
ok(/Backstage/.test(netzSatz),
"unter den Knöpfen steht dauerhaft, woher die Zahlen kommen");
/* ---- EINE EXCEL-DATEI WIRD GELESEN (14.09.2026) -----------------
Filipe: "mach doch bitte so dass man alle dateien hoch laden könne
auch excel dateien. gib gas und krieg das hin."
HIER STAND DAS GEGENTEIL, und es war richtig: Am 11.09. bekam eine
.xlsx eine Absage mit Wegweiser ("nimm den Knopf daneben"). Seit
heute wird sie gelesen -- die Pruefung wurde deshalb GEDREHT, nicht
geloescht.
GEBAUT WIRD EINE ECHTE DATEI, kein Stueck Text mit ZIP-Anfang. Der
erste Entwurf schickte zehn Bytes "PK…" und war gruen, weil der
Server "ZIP-Ende fehlt" meldete -- das haette auch bei kaputtem
Leser gestimmt. */
/* EINE Person, ZWEI Tage -- dafuer ist dieser Weg da. Der erste
Entwurf hatte hier zwei verschiedene Creator; dass die neue Sperre
ihn abgewiesen hat, war ihr erster echter Treffer. */
const vortag = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
const xlsxDatei = xlsxBauen([
["Datum", "Creator", "Diamanten", "LIVE-Dauer"],
[gestern, "@Nova_Live", "12.480", "3Std. 4Min."],
[vortag, "@Nova_Live", "3.900", "1Std. 35Min."],
]);
await seite.click("#import-oeffnen");
await seite.waitForTimeout(300);
await seite.setInputFiles("#datei", {
name: "backstage.xlsx",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
buffer: xlsxDatei,
});
await seite.waitForTimeout(1500);
const xlsx = await seite.evaluate(() => ({
fehler: document.getElementById("import-fehler")?.textContent || "",
vorschau: document.getElementById("vorschau")?.hidden,
text: document.getElementById("vorschau")?.textContent || "",
}));
ok(xlsx.fehler === "",
`die Excel-Datei wird ohne Absage gelesen ("${xlsx.fehler.slice(0, 50)}")`);
ok(xlsx.vorschau === false, "und es entsteht eine Vorschau");
ok(xlsx.text.includes("Diamanten") && xlsx.text.includes("LIVE-Dauer"),
`die Vorschau nennt die erkannten Spalten ("${xlsx.text.slice(0, 55)}…")`);
/* ---- EINE DATEI MIT ALLEN CREATORN GEHOERT WOANDERS HIN ---------
(14.09.2026) Genau das ist Filipe passiert: Er hat seine
Backstage-Ausgabe -- eine Tabelle mit allen Creator:innen -- hier
hochgeladen. Dieser Weg schreibt auf EINEN Creator. Herausgekommen
ist eine Zeile mit lauter Nullen auf einem Tag, den er nicht mehr
sah; eine Fehlermeldung gab es nicht.
"Es passiert nichts Sichtbares" ist die schlechteste aller
Antworten. Jetzt sagt der Weg, WAS los ist und WOHIN die Datei
gehoert. */
const vieleLeute = xlsxBauen([
["Datenzeitraum", "Creator*innen-Anmeldename", "Diamanten"],
[gestern, "nova_live", "12.480"],
[gestern, "kiro_on", "3.900"],
[gestern, "lumistreamt", "22.010"],
]);
await seite.setInputFiles("#datei", {
name: "alle.xlsx",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
buffer: vieleLeute,
});
await seite.waitForTimeout(1400);
const viele = await seite.evaluate(() => ({
fehler: document.getElementById("import-fehler")?.textContent || "",
vorschau: document.getElementById("vorschau")?.hidden,
}));
ok(viele.fehler.includes("3 verschiedene Creator"),
`die Datei mit drei Creatorn wird erkannt ("${viele.fehler.slice(0, 55)}…")`);
ok(viele.fehler.includes("Backstage-Tabelle"),
"und der richtige Knopf wird genannt");
ok(viele.vorschau === true, "es entsteht KEINE Vorschau -- geschrieben wird nichts");
/* GEGENPROBE: EIN Creator in der Datei geht weiterhin durch. Sonst
hiesse das oben nur, dass jede Datei mit Namensspalte abgewiesen
wird. */
await seite.setInputFiles("#datei", {
name: "einer.xlsx",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
buffer: xlsxBauen([
["Datenzeitraum", "Creator*innen-Anmeldename", "Diamanten"],
[gestern, "nova_live", "12.480"],
]),
});
await seite.waitForTimeout(1400);
const einer = await seite.evaluate(() => ({
fehler: document.getElementById("import-fehler")?.textContent || "",
vorschau: document.getElementById("vorschau")?.hidden,
}));
ok(einer.vorschau === false && einer.fehler === "",
`eine Datei mit EINEM Creator kommt durch ("${einer.fehler.slice(0, 40)}")`);
/* DIE ALTE .xls IST ETWAS ANDERES -- kein ZIP, sondern BIFF. Sie
bekommt weiterhin eine Absage, und die muss WEITERHELFEN. */
await seite.setInputFiles("#datei", {
name: "alt.xls",
mimeType: "application/vnd.ms-excel",
buffer: Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0, 0, 0]),
});
await seite.waitForTimeout(700);
const altText = await seite.evaluate(() =>
document.getElementById("import-fehler")?.textContent || "");
ok(altText.includes(".xls") && (altText.includes("xlsx") || altText.includes("CSV")),
`eine alte .xls bekommt einen Weg gezeigt ("${altText.slice(0, 55)}…")`);
/* GEGENPROBE: Eine echte Text-Tabelle geht weiterhin durch. */
await seite.setInputFiles("#datei", {
name: "tage.csv",
mimeType: "text/csv",
buffer: Buffer.from("Datum;Diamanten;LIVE-Dauer (Minuten)" + String.fromCharCode(10)
+ gestern + ";1234;90" + String.fromCharCode(10), "utf8"),
});
await seite.waitForTimeout(900);
const csv = await seite.evaluate(() => ({
fehler: document.getElementById("import-fehler")?.textContent || "",
vorschau: document.getElementById("vorschau")?.hidden,
}));
ok(csv.vorschau === false && csv.fehler === "",
`eine CSV kommt weiterhin durch (Vorschau da: ${csv.vorschau === false})`);
await seite.click("#import-zu");
await seite.waitForTimeout(250);
/* ---- DER WEG UEBER DIE TIKTOK-DATEI IST WEG (11.09.2026) --------
Filipe: "ich will keine daten von denen kriegen, nur wir."
GEPRUEFT WIRD DIE ABWESENHEIT, und zwar an DREI Stellen -- sonst
hiesse "der Knopf ist weg" nur, dass er versteckt ist. Ein
versteckter Weg ist ein Weg: Wer die Adresse kennt, benutzt ihn.
Deshalb muss auch die Schnittstelle verschwunden sein.
Und der Text, der einen Creator um seine Daten bittet, darf
nirgends mehr auf der Seite stehen -- auch nicht in einem
Fenster, das niemand oeffnet. */
const spuren = await seite.evaluate(() => ({
knopf: !!document.getElementById("tt-oeffnen"),
fenster: !!document.getElementById("tt-dialog"),
text: document.body.textContent.includes("Deine Daten herunterladen"),
}));
ok(spuren.knopf === false, "der Knopf „TikTok-Datei des Creators\" ist weg");
ok(spuren.fenster === false, "und sein Fenster auch");
ok(spuren.text === false,
"und die Bitte an den Creator steht nirgends mehr auf der Seite");
/* ---- DIESELBE SEITE, ANDERE ROLLE (11.09.2026) -----------------
Filipe, mit der Kachel im Bild: "jetzt soll jede rolle diese
kachel sehen. spicy, dogfather, manager, scout und creator. aber
die creator kriegen dan quasi nur ihre daten zu sehen und der
manager scout dogfather oder spicy koennen eintragen."
Bis heute war die Seite auf ["admin"] gesperrt. Dass ich das
ueberhaupt gemerkt habe, verdanke ich dieser Gegenprobe: Sie
wurde rot, weil Sam auf der Startseite landete -- waehrend ich
nebenan eine Erklaerung "fuer Scouts und Manager" schrieb.
GEPRUEFT WIRD DER UNTERSCHIED, NICHT DIE ANWESENHEIT. Ein Scout
und ein Creator sehen dieselbe Seite; was sie unterscheidet, sind
die Knoepfe. Deshalb wird bei beiden JEDER Knopf einzeln gemessen
-- ein "sieht etwas" waere bei beiden gruen und wertlos. */
const rollenBild = async (rolle, code) => {
const c = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
const p = await c.newPage();
const fehler = [];
p.on("pageerror", (e) => fehler.push(String(e).slice(0, 140)));
await p.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await p.click(`.rolle[data-rolle="${rolle}"]`);
await p.fill("#code", code);
await p.click("button[type=submit]");
await p.waitForURL("**/start.html", { timeout: 25000 });
/* Erst die Kachel: Ein Weg, den man nur ueber die Adresse findet,
ist kein Weg. Filipe hat ausdruecklich die KACHEL gezeigt.
ABWARTEN, BIS SIE DA SIND. Der erste Entwurf las sofort nach
waitForURL -- da hatte das Skript die Kacheln noch gar nicht
gebaut, und die Pruefung meldete "keine Kachel" fuer beide
Rollen. Das war kein Befund, das war eine zu frueh gestellte
Frage. Und weil KEINE Kachel gefunden wurde, haette ein
schlichtes "mindestens eine" den Fehler auch nicht bemerkt. */
await p.waitForSelector(".kachel__link", { timeout: 20000 });
const kachel = await p.evaluate(() =>
[...document.querySelectorAll("a[href]")]
.some((a) => (a.getAttribute("href") || "").includes("leistung.html")));
await p.goto(BASIS + "/workspace/leistung.html", { waitUntil: "networkidle" });
await p.waitForTimeout(1800);
const bild = await p.evaluate(() => {
const h = (id) => {
const e = document.getElementById(id);
return e ? e.hidden : "fehlt";
};
return {
weg: location.pathname,
kacheln: 0,
import: h("import-oeffnen"), netz: h("netz-oeffnen"),
ziele: h("ziele-aendern"),
block: h("quellen-hinweis"), sNetz: h("quelle-netz"),
/* Wie viele Creator stehen zur Auswahl? Ein Creator darf hier
kein Auswahlfeld haben -- es haette nur ihn selbst drin. */
wahl: document.getElementById("creator-wahl")?.hidden === false
? document.querySelectorAll("#creator option").length : 0,
aendern: document.querySelectorAll(".tagzeile__knopf").length,
};
});
bild.kachel = kachel;
bild.fehler = fehler.join(" | ");
/* Bilder nur auf Zuruf (SCHIRM=1). Eine Pruefung, die bei jedem
Lauf Dateien ablegt, muellt das Verzeichnis zu -- und ein Bild
beweist ohnehin nichts, das misst der Code darueber. Es ist zum
HINSEHEN da, wenn Filipe fragt, wie es fuer eine Rolle aussieht. */
if (process.env.SCHIRM) {
await p.screenshot({ path: join(HIER, `pruef-zahlen-${rolle}.png`), fullPage: false });
}
await c.close();
return bild;
};
const scoutBild = await rollenBild("scout", "CODE-SAM-00001");
ok(scoutBild.kachel === true && /leistung\.html$/.test(scoutBild.weg),
`der Scout findet die Kachel und kommt auf die Seite (${scoutBild.weg})`);
ok(scoutBild.fehler === "", `kein Skriptfehler beim Scout (${scoutBild.fehler || "keiner"})`);
ok(scoutBild.import === false && scoutBild.ziele === false,
"der Scout darf eintragen: eigene Datei und Ziele sind da");
ok(scoutBild.netz === true,
"die Backstage-Tabelle bleibt ihm zu -- sie gilt für ALLE Creator");
ok(scoutBild.sNetz === true && scoutBild.block === true,
"die Backstage-Erklärung bekommt er nicht -- den Weg hat er nicht");
ok(scoutBild.aendern > 0,
`er kann einen Tag eintragen (${scoutBild.aendern} Knöpfe in der Liste)`);
/* MANAGER UND SPICY MEDIA -- die beiden, die ich beinahe nur
mitgedacht haette. Sie gehoeren zur Leitung, also sollten sie
alles koennen; "sollte" ist aber kein Messwert. Sie sehen
ausserdem UNTERSCHIEDLICH viele Creator: Spicy Media alle
(siehtAlles), ein Manager nur seine eigenen und die seiner
Scouts. Genau das wird hier verglichen -- eine Pruefung, die bei
beiden nur "sieht etwas" sagt, haette den Unterschied nie
bemerkt. */
for (const [rolle, code, mindestens] of [
["manager", "CODE-MIRA-0001", 1],
["spicy", "CODE-SASHA-0001", 3],
]) {
const b = await rollenBild(rolle, code);
ok(b.kachel === true && /leistung\.html$/.test(b.weg),
`${rolle}: findet die Kachel und kommt auf die Seite`);
ok(b.fehler === "", `${rolle}: kein Skriptfehler (${b.fehler || "keiner"})`);
ok(b.netz === false && b.import === false && b.ziele === false,
`${rolle}: alle drei Wege sind da -- auch die Backstage-Tabelle`);
ok(b.sNetz === false,
`${rolle}: und die Erklärung dazu`);
ok(b.wahl >= mindestens,
`${rolle}: sieht ${b.wahl} Creator zur Auswahl (mindestens ${mindestens})`);
}
/* Und der Unterschied selbst, als eigene Aussage: Spicy Media sieht
MEHR als der Manager. Waeren beide gleich, waere oben alles gruen
und die Haustrennung trotzdem kaputt. */
{
const m = await rollenBild("manager", "CODE-MIRA-0001");
const sp = await rollenBild("spicy", "CODE-SASHA-0001");
ok(sp.wahl > m.wahl,
`Spicy Media sieht mehr Creator als der Manager (${sp.wahl} zu ${m.wahl})`);
}
const creatorBild = await rollenBild("creator", "CODE-NOVA-0001");
ok(creatorBild.kachel === true && /leistung\.html$/.test(creatorBild.weg),
`der Creator findet die Kachel und kommt auf die Seite (${creatorBild.weg})`);
ok(creatorBild.fehler === "", `kein Skriptfehler beim Creator (${creatorBild.fehler || "keiner"})`);
ok(creatorBild.import === true && creatorBild.netz === true
&& creatorBild.ziele === true,
"der Creator sieht KEINEN einzigen Eintrag-Knopf");
ok(creatorBild.aendern === 0,
`auch nicht an den einzelnen Tagen (${creatorBild.aendern} Knöpfe)`);
ok(creatorBild.block === true,
"und keinen Quellen-Hinweis -- eine Erklärung ohne Knopf ist Rauschen");
ok(creatorBild.wahl === 0,
`und kein Auswahlfeld für fremde Creator (${creatorBild.wahl} Einträge)`);
/* GEGENPROBE ZUR GEGENPROBE: Dass der Creator nichts sieht, darf
nicht daran liegen, dass die Seite bei ihm leer bleibt. Seine
eigenen Zahlen muessen da sein -- sonst waere alles oben gruen,
weil nichts geladen hat. */
const creatorZahlen = await (async () => {
const c = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
const p = await c.newPage();
await p.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await p.click('.rolle[data-rolle="creator"]');
await p.fill("#code", "CODE-NOVA-0001");
await p.click("button[type=submit]");
await p.waitForURL("**/start.html", { timeout: 25000 });
await p.goto(BASIS + "/workspace/leistung.html", { waitUntil: "networkidle" });
await p.waitForTimeout(1800);
const t = await p.evaluate(() => document.getElementById("tagetabelle")?.textContent || "");
await c.close();
return t;
})();
ok(/\d/.test(creatorZahlen) && creatorZahlen.length > 20,
`seine eigenen Zahlen stehen trotzdem da (${creatorZahlen.length} Zeichen)`);
await browser.close();
}
kind.kill();
await new Promise((r) => setTimeout(r, 400));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ }
console.log(`\n${gemacht} Pruefungen`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);