/* ===================================================================== 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 { tagLokal } from "./helfer-tag.mjs"; 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(); /* HEUTE IST DER TAG, DEN DER SERVER MEINT -- nicht der in Greenwich. 16.09.2026, 00:40 Berlin: `toISOString()` sagte "2026-09-15", der Server (heuteLokal(), Ortszeit) sagte "2026-09-16". Damit war das hier berechnete "morgen" in Wahrheit HEUTE, und die Gegenprobe "ein Datum in der Zukunft wird abgelehnt" schlug fehl -- an der Zeitzone, nicht am Code. Zwischen Mitternacht und zwei Uhr morgens lag diese Pruefung also taeglich zwei Stunden lang falsch. Eine Zeitbombe genau der Sorte, die schon einmal notiert wurde (Shop, 06.09.2026): Die Wanduhr darf Eingabe sein, nie Annahme. ALLE DREI WERDEN AUS EINER STELLE ABGELEITET -- drei eigene Rechnungen waeren drei Gelegenheiten, wieder auseinanderzulaufen. */ /* SEIT DEM 16.09.2026 AUS helfer-tag.mjs. Die Rechnung stand hier richtig -- und wurde 1270 Zeilen weiter unten trotzdem nicht benutzt: Dort stand wieder `toISOString().slice(0, 10)`. Ein Helfer, den man beim naechsten Mal vergisst, ist eine halbe Loesung. Jetzt gibt es ihn einmal fuer alle Pruefungen, und pruef-struktur meldet, wer ihn nicht benutzt. */ const heute = tagLokal(0); const gestern = tagLokal(-1); const morgen = tagLokal(1); 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)`); } } /* ======================================================================= ALLE 41 SPALTEN -- nichts faellt mehr weg (16.09.2026) ======================================================================= Filipe: "ich will dass alles von der excel datei genommen wird. perfektionnier das, aber wenn ich dir runter lade soll alles notiert und angezeigt werden." --------------------------------------------------------------------- DIE VORLAGE IST SEINE ECHTE AUSGABE vom 14.09.2026 ("Creator:innen- daten"). Sie hat EINUNDVIERZIG Spalten; erkannt und gedeutet werden acht. Die restlichen 33 wurden beim Einlesen weggeworfen, ohne dass irgendwo stand, dass es sie gab. DIE ECHTE DATEI STEHT NICHT IM REPO -- in ihr stehen Name, ID und Agent seiner Creator. Was hier steht, sind ihre UEBERSCHRIFTEN (die gehoeren TikTok, nicht ihm) mit erfundenen Werten. Die echte Datei hat die Form belegt; diese Pruefung haelt sie fest. WAS GEMESSEN WIRD, und jedes davon hat einen Grund: * Alle 41 Spalten kommen an -- gezaehlt, nicht gehofft. * Eine Spalte, die es in KEINEM Muster gibt ("Graduierungsstatus"), kommt mit ihrem TEXT an. Waere nur die Zahl gespeichert, stuende dort nichts. * Eine LEERE Zelle erzeugt keine Zeile -- sonst stuende auf der Karte "Status: (nichts)", eine Auskunft, die die Datei nicht gibt. * Eine ERFUNDENE Spalte kommt genauso durch. Das ist die eigentliche Gegenprobe: Sie beweist, dass hier keine Liste gepflegt wird, sondern die Datei entscheidet -- also auch eine Spalte durchkommt, die TikTok erst naechstes Jahr hinzufuegt. * Ein ZWEITER Durchgang mit weniger Spalten laesst keine Leichen zurueck. Ohne das Wegraeumen vorher stuende ein Wert aus einer Datei da, die es nicht mehr gibt. ======================================================================= */ console.log(""); console.log("=== Alle Spalten der echten Ausgabe ==="); { /* Die 41 Ueberschriften, wortgleich aus seiner Ausgabe. */ const KOPF = [ "Datenzeitraum", "Creator*in-ID", "Creator*innen-Anmeldename", "Gruppe", "Agent", "Beitrittszeit", "Tage seit dem Beitritt", "Diamanten", "LIVE-Dauer", "Gültige LIVE-Gehen-Tage", "Neue Follower*innen", "Livestreams", "Diamanten im letzten Monat", "LIVE-Dauer (in Std.) im letzten Monat", "Gültige LIVE-Gehen-Tage im letzten Monat", "Neue Follower*innen im letzten Monat", "Livestreams im letzten Monat", "Diamanten - Prozent erreicht", "LIVE-Dauer - Prozent erreicht", "Gültige LIVE-Gehen-Tage - Prozent erreicht", "Neue Follower*innen - Prozent erreicht", "Livestreams - Prozent erreicht", "Diamanten - im Vergleich zu letztem Monat", "LIVE-Dauer - im Vergleich zu letztem Monat", "Gültige LIVE-Gehen-Tage - im Vergleich zu letztem Monat", "Neue Follower*innen - im Vergleich zu letztem Monat", "Livestreams - im Vergleich zu letztem Monat", "Matches", "Diamanten aus Matches", "Neue*r LIVE-Creator*innen", "Diamanten von Multi-Gast-LIVEs", "Diamanten von Multi-Gast-LIVEs (als Host)", "Diamanten von Multi-Gast-LIVEs (als Gäst*in)", "Graduierungsstatus", "Stufen-Status", "Neue Fans", "Gesamte Diamanten des Fanclubs", "Beitrag durch Fans in %", "Fans insgesamt", "Aktive Fans vom Fanclub", "Status", ]; ok(KOPF.length === 41, `die Vorlage hat ${KOPF.length} Spalten`); /* Erfundene Werte in der FORM der echten -- mit allen Eigenheiten, die dort vorkommen: Prozent mit englischem Punkt, eine Dauer mit Einheiten, ein Bruch, der trotz "%" im Namen KEINER ist, Text statt Zahl, und eine leere Zelle am Ende. */ const WERT = [ "2026-08-01 ~ 2026-08-31", "700001", "@Nova_Live", "Dogfather", "Scout Sam", "2026-07-09 19:30:57 (UTC+0)", "68", "13210", "6Std. 24Min. 7Sek.", "5", "97", "5", "29488", "13Std. 22Min. 40Sek.", "9", "126", "16", "44.79%", "47.85%", "55.55%", "76.98%", "31.25%", "98.55%", "83.68%", "150.00%", "361.90%", "0.00%", "13", "8665", "Nein", "26", "26", "0", "Nicht graduiert", "Nicht beibehalten", "17", "13128", "0.99379", "1182", "75", "", ]; ok(WERT.length === KOPF.length, `und genauso viele Werte (${WERT.length})`); const alles = [KOPF.join("\t"), WERT.join("\t")].join("\n"); const v = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", keksDogi, { text: alles }); ok(v.daten?.spalten_gesamt === 41, `die Vorschau nennt alle Spalten der Datei (${v.daten?.spalten_gesamt})`); ok(v.daten?.felder === 40, `und wie viele davon einen Wert tragen (${v.daten?.felder} -- "Status" ist leer)`); const a = await ruf("/workspace/api/leistung/netzwerk-import", keksDogi, { text: alles }); ok(a.daten?.felder === 40, `uebernommen werden ${a.daten?.felder} Werte`); const d4 = new DatabaseSync(DB, { readOnly: true }); const felder = d4.prepare(`SELECT nr, name, text, zahl FROM leistung_zeitraum_feld WHERE creator_id = ? AND von = ? AND bis = ? ORDER BY nr`) .all(idNova, "2026-08-01", "2026-08-31"); d4.close(); ok(felder.length === 40, `in der Datenbank stehen ${felder.length} Felder (41 Spalten, eine leer)`); const nach = new Map(felder.map((f) => [f.name, f])); ok(felder.length > 0 && !nach.has("Status"), "die leere Zelle erzeugt KEINE Zeile -- sonst staende dort \"Status: nichts\""); /* ---- WER IST DAS UEBERHAUPT? (16.09.2026) -------------------------- DER TEUERSTE FEHLER DIESES TAGES, und er stand nicht im Verdacht. Seine Ausgabe hat DREI Spalten, die nach Person aussehen: "Creator*in-ID", "Creator*innen-Anmeldename" und "Agent". Die alte Regel nahm die erste mit dem Wort "creator" darin -- die ID. Danach wurde nach einem Creator namens "700001" gesucht, und die ganze Datei kam als "nicht zugeordnet" zurueck, mit einem Hinweis, der in die voellig falsche Richtung zeigte. Die Pruefung oben faellt darauf herein wie er: Ohne Zuordnung gibt es keinen Zeitraum, also auch keine Felder. Deshalb steht es hier noch einmal ausdruecklich -- damit beim naechsten Mal die MELDUNG sagt, was los ist, und nicht nur eine Zahl fehlt. */ ok(nach.get("Creator*innen-Anmeldename")?.text === "@Nova_Live", "die Zeile wurde ueber den Anmeldenamen zugeordnet, nicht ueber die ID"); ok(nach.get("Creator*in-ID")?.text === "700001", "und die ID steht trotzdem in der Ablage -- sie ist eine Auskunft, kein Schluessel"); { /* GEGENPROBE: Eine Tabelle, in der NUR eine Kennnummer steht, darf niemanden zuordnen -- und muss das sagen, statt stillschweigend nichts zu tun. */ const nurId = [ ["Datenzeitraum", "Creator*in-ID", "Diamanten"].join("\t"), ["2026-08-01 ~ 2026-08-31", "700001", "500"].join("\t"), ].join("\n"); const r = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", keksDogi, { text: nurId }); ok(r.status === 400 && /Name oder Handle/.test(r.daten?.fehler || ""), `eine Tabelle mit nur einer Kennnummer wird begruendet abgelehnt ` + `(HTTP ${r.status}: "${String(r.daten?.fehler || "").slice(0, 40)}…")`); } /* Eine Spalte, die KEIN Muster kennt, und ihr Wert ist TEXT. */ ok(nach.get("Graduierungsstatus")?.text === "Nicht graduiert", `eine Spalte ohne Muster kommt woertlich an ("${nach.get("Graduierungsstatus")?.text}")`); ok(nach.get("Graduierungsstatus")?.zahl === null, "und traegt keine erfundene Zahl"); /* Prozent mit englischem Punkt -- die Falle, an der eine deutsche Zahlenlesung 4479 statt 44,79 liefern wuerde. */ ok(nach.get("Diamanten - Prozent erreicht")?.zahl === 44.79, `"44.79%" wird zu ${nach.get("Diamanten - Prozent erreicht")?.zahl}, nicht zu 4479`); /* Und der Bruch, der trotz "%" im Spaltennamen keiner ist. */ ok(nach.get("Beitrag durch Fans in %")?.zahl === 0.99379, `"0.99379" bleibt ${nach.get("Beitrag durch Fans in %")?.zahl} ` + "-- der Spaltenname sagt Prozent, der Wert ist ein Bruch"); /* Die Reihenfolge der Datei bleibt erhalten. */ ok(felder[0].name === "Datenzeitraum" && felder[felder.length - 1].name === "Aktive Fans vom Fanclub", `die Reihenfolge der Datei bleibt (${felder[0].name} … ${felder[felder.length - 1].name})`); /* ---- DIE EIGENTLICHE GEGENPROBE ------------------------------------ Eine Spalte, die es bei TikTok (noch) gar nicht gibt. Kommt sie durch, wird hier keine Liste gepflegt -- sondern die Datei entscheidet. Genau das soll die Bauart leisten. */ { const erfunden = [ [...KOPF, "Sternenstaub pro Woche"].join("\t"), [...WERT, "4711"].join("\t"), ].join("\n"); await ruf("/workspace/api/leistung/netzwerk-import", keksDogi, { text: erfunden }); const d5 = new DatabaseSync(DB, { readOnly: true }); const f = d5.prepare(`SELECT text, zahl FROM leistung_zeitraum_feld WHERE creator_id = ? AND von = ? AND bis = ? AND name = ?`) .get(idNova, "2026-08-01", "2026-08-31", "Sternenstaub pro Woche"); d5.close(); ok(f?.zahl === 4711, `auch eine Spalte, die es heute nicht gibt, kommt an (${f?.zahl})`); } /* ---- UND KEINE LEICHEN --------------------------------------------- Dieselbe Datei noch einmal, diesmal OHNE die erfundene Spalte. Sie muss verschwinden. Ohne das Wegraeumen vor dem Schreiben stuende dort ein Wert aus einer Datei, die es nicht mehr gibt -- und niemand koennte ihn von einem frischen unterscheiden. */ { await ruf("/workspace/api/leistung/netzwerk-import", keksDogi, { text: alles }); const d6 = new DatabaseSync(DB, { readOnly: true }); const n = d6.prepare(`SELECT COUNT(*) AS n FROM leistung_zeitraum_feld WHERE creator_id = ? AND von = ? AND bis = ?`) .get(idNova, "2026-08-01", "2026-08-31").n; const weg = d6.prepare(`SELECT 1 FROM leistung_zeitraum_feld WHERE creator_id = ? AND name = ?`).get(idNova, "Sternenstaub pro Woche"); d6.close(); ok(!weg && n === 40, `beim zweiten Einlesen bleibt keine Leiche zurueck (${n} Felder)`); } /* ---- UND DIE SEITE BEKOMMT SIE AUCH ------------------------------ */ { const antwort = await (await fetch(BASIS + `/workspace/api/leistung/${idNova}`, { headers: { Cookie: keksDogi } })).json(); /* GEZIELT DEN AUGUST, nicht "den ersten". Nova hat inzwischen zwei Zeitraeume; `[0]` waere der jüngere und die Pruefung haette ohne Vorwarnung etwas anderes gemessen als gemeint. Genau dieser Fehler ist mir am 14.09. schon einmal unterlaufen. */ const z = (antwort?.zeitraeume || []).find((x) => x.von === "2026-08-01"); ok(Array.isArray(z?.felder) && z.felder.length === 40, `die Seite bekommt alle Werte mitgeliefert (${z?.felder?.length})`); ok(z?.felder?.[0]?.name === "Datenzeitraum", "und zwar in der Reihenfolge der Datei"); /* Und der ANDERE Zeitraum hat seine eigenen -- die Felder haengen am Zeitraum, nicht am Creator. */ const sept = (antwort?.zeitraeume || []).find((x) => x.von === "2026-09-01"); ok((sept?.felder || []).length === 5, `der andere Zeitraum hat seine eigenen ${(sept?.felder || []).length} Werte`); } } { /* ---- DER EINZEL-IMPORT KENNT DEN DRITTEN AUSGANG (14.09.2026) ---- Als tagAusZelle() den Zeitraum dazubekam, kannte ihn nur der Backstage-Weg. Im Einzel-Import stand weiterhin `gelesen.tag` -- bei einem Zeitraum `undefined`. Folge: Die VORSCHAU haette die Zeile gezaehlt, der SCHREIBWEG sie still uebersprungen. Zwei Aussagen ueber dieselbe Datei, die einander widersprechen -- das ist der Unterschied, den niemand bemerkt, weil beide Seiten fuer sich plausibel aussehen. GEPRUEFT WIRD DESHALB DER ABGLEICH: Was die Vorschau verspricht, muss danach dastehen. */ const datei = [ "Datum;Diamanten;LIVE-Dauer", "2026-09-01 ~ 2026-09-11;53.679;86Std. 10Min. 52Sek.", ].join("\n"); /* WIE VIELE TAGESZEILEN LUMI SCHON HAT. Zum zweiten Mal heute derselbe Griff: Der Backstage-Import weiter oben hat ihr bereits einen Tag geschrieben, der in dieselbe Spanne faellt. Gemessen wird die VERAENDERUNG, nicht der Absolutwert. */ const tageVorLumi = (() => { 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-11'") .get(idLumi).n; d0.close(); return n; })(); const v = await ruf(`/workspace/api/leistung/${idLumi}/import/vorschau`, keksDogi, { text: datei }); ok(v.status === 200, `die Vorschau nimmt den Zeitraum an (HTTP ${v.status})`); const versprochen = (v.daten?.neu ?? 0) + (v.daten?.ersetzt ?? 0); ok(versprochen === 1, `sie verspricht genau eine Zeile (${versprochen})`); /* `fehlerhaft` ist eine ZAHL, keine Liste -- nachgesehen in der Antwort, nicht geraten. Beim ersten Anlauf stand hier `.length` auf einer Zahl: immer `undefined`, immer ungleich 0, immer rot. */ ok((v.daten?.fehlerhaft ?? -1) === 0, `und meldet keinen Fehler (${v.daten?.fehlerhaft})`); /* KEIN `undefined` IN DER VORSCHAU. Der alte Weg haette hier eine Zeile mit `tag: undefined` gezeigt -- sichtbar als leere Spalte. */ const beispiel = (v.daten?.beispiele || [])[0] || (v.daten?.ersetzt_beispiele || [])[0] || {}; ok(beispiel.von === "2026-09-01" && beispiel.bis === "2026-09-11", `und nennt die Spanne statt eines leeren Tages (${beispiel.von}–${beispiel.bis})`); ok(beispiel.tage === 11, `mit der Zahl der Tage (${beispiel.tage})`); const s = await ruf(`/workspace/api/leistung/${idLumi}/import`, keksDogi, { text: datei }); ok(s.status === 200, `der Import laeuft (HTTP ${s.status})`); const angekommen = (s.daten?.geschrieben ?? 0) + (s.daten?.zeitraeume ?? 0); ok(angekommen === versprochen, `es kommt an, was die Vorschau versprochen hat (${versprochen} -> ${angekommen})`); ok(s.daten?.zeitraeume === 1 && s.daten?.geschrieben === 0, `und zwar als Zeitraum, 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(idLumi); const tage = d2.prepare( "SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ? AND tag >= '2026-09-01' AND tag <= '2026-09-11'") .get(idLumi).n; d2.close(); ok(z?.tage === 11 && z?.diamanten === 53679, `er steht in der Datenbank (${z?.tage} Tage, ${z?.diamanten} Diamanten)`); ok(z?.dauer_min === 5171, `mit der Dauer in Minuten (${z?.dauer_min})`); ok(tage === tageVorLumi, `und KEINE Tageszeile kam hinzu (${tageVorLumi} -> ${tage})`); } /* GEGENPROBE: Eine Datei mit echten Tagen geht weiterhin als Tage durch -- sonst hiesse alles oben nur, dass jetzt alles ein Zeitraum ist. */ const tagesdatei = [ "Datum;Diamanten", `${gestern};1.111`, ].join("\n"); const t = await ruf(`/workspace/api/leistung/${idLumi}/import`, keksDogi, { text: tagesdatei }); ok(t.daten?.geschrieben === 1 && (t.daten?.zeitraeume ?? 0) === 0, `eine Tagesdatei bleibt eine Tagesdatei (${t.daten?.geschrieben} Tag, ` + `${t.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__spanne")?.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"); /* ---- DIE HAUPTZAHL IST GROSS (14.09.2026) ----------------------- Filipe zur ersten Fassung: "das muss viel geiler aussehen und viel klarer." Sie war eine Liste ohne Rangfolge -- alles gleich gross, nichts sprang heraus. GEMESSEN IN PIXELN, NICHT NACH GEFUEHL: Die Diamantenzahl muss deutlich groesser sein als die Nebenwerte, sonst ist es wieder eine Liste. */ const groessen = await seite.evaluate(() => { const g = (s) => { const e = document.querySelector(s); return e ? Math.round(parseFloat(getComputedStyle(e).fontSize)) : 0; }; return { haupt: g(".spanne__haupt-zahl"), neben: g(".spanne__zahl"), spanne: g(".spanne__spanne"), text: document.querySelector(".spanne__haupt-zahl")?.textContent || "" }; }); ok(groessen.haupt >= groessen.neben * 1.6, `die Hauptzahl ist deutlich groesser als die Nebenwerte ` + `(${groessen.haupt} px zu ${groessen.neben} px)`); ok(groessen.spanne > groessen.neben, `und die Spanne groesser als ein Nebenwert (${groessen.spanne} px)`); ok(/\d/.test(groessen.text), `die Hauptzahl traegt Diamanten ("${groessen.text}")`); /* ---- "11 VON 13 TAGEN LIVE" (16.09.2026) ------------------------ Die Spalte "Gültige LIVE-Gehen-Tage" steht seit dem 14.09. in dieser Pruefdatei -- und wurde die ganze Zeit weggeworfen, weil `spaltenRaten` kein Muster dafuer hatte. Die Pruefung hat den Wert zwar GESCHICKT, aber nie nachgesehen, ob er ankommt. GEMESSEN WIRD DER ANTEIL IN PIXELN, nicht das Vorhandensein des Streifens: Ein Streifen, der immer voll ist, zeigt keine Zahl, sondern eine Dekoration. 11 von 13 sind 85 %. */ const lauf = await seite.evaluate(() => { const bahn = document.querySelector(".spanne__lauf-bahn"); const teil = document.querySelector(".spanne__lauf-teil"); if (!bahn || !teil) return null; return { anteil: Math.round((teil.getBoundingClientRect().width / bahn.getBoundingClientRect().width) * 100), text: document.querySelector(".spanne__lauf-text")?.textContent || "", }; }); ok(!!lauf, "der Streifen der gueltigen Tage steht auf der Karte"); ok(lauf && lauf.text.includes("11 von 13 Tagen live"), `er nennt die Zahl im Klartext ("${lauf?.text}")`); ok(lauf && Math.abs(lauf.anteil - 85) <= 2, `und ist zu ${lauf?.anteil} % gefuellt (11 von 13 = 85 %)`); /* ---- DER EIGENTLICHE WUNSCH: LESBARKEIT (16.09.2026) ------------ Filipe: "ich will dass es auch seeeehr gut erkennbar ist, genau wie der text drüber." Hinter dem Inhalt dieser Seite liegt die Buehne -- ein Foto mit einem HELLROTEN Berg. Ueberschrift und Erklaersatz standen bis heute direkt darauf. GEMESSEN WIRD AN ECHTEN BILDPUNKTEN, nicht an der Farbangabe im Stil: Die weiss nichts davon, was hinter ihr liegt. Das Foto ist stark rot, das Innenglas blaeulich-dunkel (#0b111c: R=11, B=28) -- ein Bildpunkt mit R deutlich ueber B kann dort also nur vom Foto stammen. WAS BEIM ERSTEN ANLAUF SCHIEFGING, und es ist lehrreich: Die Messung meldete zehn rote Bildpunkte -- und hatte recht. Sie kamen aber nicht vom Foto, sondern vom ANSCHLAG des Typenschilds, den drei roten Kerben, die ich selbst dorthin gesetzt habe. Eine Messung, die "alles Rote ist das Foto" annimmt, misst irgendwann die eigene Gestaltung. Der Anschlag wird deshalb ausgespart -- und zwar mit seiner EIGENEN Breite aus dem Stil, nicht mit einer abgeschriebenen Zahl. */ const roteBildpunkte = async (b64) => { const bild = new Image(); bild.src = "data:image/png;base64," + b64; await bild.decode(); const c = document.createElement("canvas"); c.width = bild.width; c.height = bild.height; const g = c.getContext("2d"); g.drawImage(bild, 0, 0); const skala = bild.width / window.innerWidth; const schild = document.querySelector(".spannen__schild"); const satz = document.querySelector(".spannen__satz"); if (!schild || !satz) return null; /* Der Anschlag ist ein ::before und hat kein eigenes Rechteck. Seine Breite und der Abstand dahinter stehen aber im Stil -- daraus wird die Stelle abgeleitet, ab der die Zeile beginnt. */ const vor = getComputedStyle(schild, "::before"); const st = getComputedStyle(schild); const versatz = (parseFloat(vor.width) || 0) + (parseFloat(st.columnGap) || 0) + 2; const sr = schild.getBoundingClientRect(); const zr = satz.getBoundingClientRect(); const felder = [ { name: "Ueberschrift", l: sr.left + versatz, t: sr.top, w: sr.width - versatz, h: sr.height }, { name: "Erklaersatz", l: zr.left, t: zr.top, w: zr.width, h: zr.height }, ]; let punkte = 0, rot = 0; for (const f of felder) { const w = Math.max(1, Math.round(f.w * skala)); const h = Math.max(1, Math.round(f.h * skala)); const p = g.getImageData(Math.round(f.l * skala), Math.round(f.t * skala), w, h).data; for (let i = 0; i < p.length; i += 4) { punkte++; if (p[i] > p[i + 2] + 20) rot++; } } return { punkte, rot }; }; const durchsicht = await seite.evaluate(roteBildpunkte, (await seite.screenshot({ type: "png" })).toString("base64")); ok(durchsicht && durchsicht.punkte > 5000, `hinter Ueberschrift und Erklaersatz wurden ${durchsicht?.punkte} Bildpunkte abgetastet`); ok(durchsicht && durchsicht.rot === 0, `kein einziger davon traegt das Rot des Fotos (${durchsicht?.rot})`); /* Das Bild der fertigen Tafel -- MIT Glas, also so, wie Filipe sie sieht. Es entsteht hier und nicht am Ende: Am Ende ist ein anderer Creator geoeffnet, dessen Zeitraum keine gueltigen Tage hat. */ if (process.env.SCHIRM) { await seite.locator("#spannen-block").screenshot( { path: join(HIER, "pruef-spannen.png") }); } /* ---- "ALLES ANGEZEIGT" HEISST: MAN KANN ES SEHEN (16.09.2026) ---- Filipe: "wenn ich dir runter lade soll alles notiert und angezeigt werden." Dass die Werte in der Datenbank stehen, ist oben belegt. Ob sie auf dem Bildschirm ankommen, ist eine ANDERE Frage -- und genau die Sorte, die hier schon einmal keiner gestellt hat (die Teilnehmerwahl im Kalender, 05.09.). GEMESSEN WIRD MIT `checkVisibility()`, UND DAS IST KEINE GESCHMACKSFRAGE (16.09.2026, an einem Miniaturfall nachgemessen): zugeklappt getBoundingClientRect().height 72 aufgeklappt getBoundingClientRect().height 72 Dieselbe Zahl. Auch `offsetHeight`, `offsetParent` und `getClientRects()` sind in beiden Zustaenden identisch -- Chromium verbirgt den Inhalt eines geschlossenen
mit `content-visibility: hidden`, und das behaelt die letzte Ausmessung. Wer die Hoehe misst, bekommt also fuer "unsichtbar" und "sichtbar" dieselbe Antwort und merkt es nicht. Genau daran ist mein erster Anlauf gescheitert: Er meldete "zugeklappt 141 px hoch" -- im Bildschirmfoto war an derselben Stelle nichts zu sehen. Die Messung log, nicht die Seite. `checkVisibility()` unterscheidet beides sauber (false / true) und ist das einzige der fuenf Verfahren, das es kann. */ { const deckel = await seite.$$(".spanne__alles-kopf"); ok(deckel.length >= 1, `es gibt ${deckel.length} Deckel "Alle Werte aus Backstage"`); const zuVorher = await seite.evaluate(() => document.querySelector(".spanne__alles-liste")?.checkVisibility() ?? null); ok(zuVorher === false, `zugeklappt ist die Liste nicht zu sehen (${zuVorher})`); /* Der LETZTE Deckel gehoert zum August -- dem Zeitraum mit allen 41 Spalten. Der erste haette nur fuenf und die Pruefung waere gruen, ohne das Gemeinte gemessen zu haben. */ await deckel[deckel.length - 1].click(); await seite.waitForTimeout(350); const auf = await seite.evaluate(() => { const offen = document.querySelector(".spanne__alles[open]"); if (!offen) return null; const namen = [...offen.querySelectorAll(".spanne__alles-name")]; const werte = [...offen.querySelectorAll(".spanne__alles-wert")]; const liste = offen.querySelector(".spanne__alles-liste"); return { namen: namen.length, werte: werte.length, /* SICHTBAR, nicht "hat eine Hoehe" -- siehe die Begruendung oben. Die Hoehe steht daneben, aber nur als Auskunft. */ sichtbar: liste.checkVisibility(), hoehe: Math.round(liste.getBoundingClientRect().height), erster: namen[0]?.textContent || "", letzter: namen[namen.length - 1]?.textContent || "", /* Steht ein Wert, den es OHNE diese Liste nirgends gaebe? */ graduierung: namen.some((e) => e.textContent === "Graduierungsstatus") ? werte[namen.findIndex((e) => e.textContent === "Graduierungsstatus")]?.textContent : "", kleinste: Math.min(...[...namen, ...werte].map((e) => parseFloat(getComputedStyle(e).fontSize))), }; }); ok(auf && auf.namen === 40 && auf.werte === 40, `aufgeklappt stehen ${auf?.namen} Namen und ${auf?.werte} Werte da`); ok(auf && auf.sichtbar === true, `und sie sind jetzt wirklich sichtbar (${auf?.sichtbar}, ${auf?.hoehe} px hoch)`); ok(auf && auf.erster === "Datenzeitraum", `in der Reihenfolge der Datei (zuerst "${auf?.erster}")`); ok(auf && auf.graduierung === "Nicht graduiert", `ein Wert, den es sonst NIRGENDS gaebe, steht da ("${auf?.graduierung}")`); ok(auf && auf.kleinste >= 11.5, `und nichts darin ist kleiner als 11,5 px (${auf?.kleinste} px)`); if (process.env.SCHIRM) { await seite.locator(".spanne__alles[open]").screenshot( { path: join(HIER, "pruef-spannen-alles.png") }); } /* Wieder zu -- das Bild der Tafel oben zeigt den Normalfall, und die Kontrastmessung danach misst den Kopf, nicht eine offene Liste, die die Seite verschoben hat. */ await deckel[deckel.length - 1].click(); await seite.waitForTimeout(250); } /* GEGENPROBE -- und sie ist hier das Wichtigste. "Null rote Bildpunkte" koennte auch heissen, dass gar kein Foto dahinterliegt, dass das Fenster zu klein ist oder dass ich die falsche Stelle abtaste. Also wird das Innenglas fuer einen Augenblick weggenommen und werden DIESELBEN Stellen mit DEMSELBEN Abtaster noch einmal gemessen: Kommt das Rot dann durch, war es vorher das Glas, das es aufgehalten hat -- und die Messung kann ueberhaupt Rot sehen. Genau die Sorte Pruefung, die ohne Gegenprobe jahrelang gruen bleibt und nichts beweist. */ await seite.addStyleTag({ content: ".spannen-tafel { background: none !important; }" + ".spannen-tafel::before { display: none !important; }" + ".spannen, .spanne { background: none !important; }" }); await seite.waitForTimeout(250); const ohneGlas = await seite.evaluate(roteBildpunkte, (await seite.screenshot({ type: "png" })).toString("base64")); ok(ohneGlas && ohneGlas.rot > 200, `ohne das Innenglas kommt das Foto sofort durch (${ohneGlas?.rot} rote Bildpunkte) ` + "-- die Messung kann Rot also sehen"); await seite.reload({ waitUntil: "networkidle" }); await seite.waitForTimeout(1500); /* ---- EINE WOCHE OHNE EINEN EINZIGEN TAG (14.09.2026) ------------ Filipe: "wieso werden die zahlen oben nicht ausgefuellt von der excel datei?" Genau sein Fall: ein Zeitraum ist da, Tageszahlen gibt es keine. Vorher standen dort sieben Karten mit "0" und "wie in der Vorwoche" -- das sieht aus wie ein Fehler und beantwortet die Frage nicht. HERGESTELLT WIRD DER FALL, nicht abgewartet: Kiros Tageszeilen fallen weg, ein Zeitraum kommt hin. Eine Pruefung, die auf einen Zustand hofft, prueft irgendwann gar nichts mehr. */ { const d4 = new DatabaseSync(DB); d4.prepare("DELETE FROM leistung WHERE creator_id = ?").run(idKiro); d4.prepare(`INSERT OR REPLACE INTO leistung_zeitraum (creator_id, von, bis, tage, diamanten, dauer_min, follower_neu, erfasst, erfasst_von) VALUES (?,?,?,?,?,?,?,?,?)`) .run(idKiro, "2026-09-01", "2026-09-13", 13, 13210, 384, 97, new Date().toISOString(), 1); d4.close(); } await seite.goto(BASIS + `/workspace/leistung.html?creator=${idKiro}`, { waitUntil: "networkidle" }); await seite.waitForTimeout(1800); const ohneTage = await seite.evaluate(() => ({ leerDa: document.getElementById("woche-leer")?.hidden === false, kartenWeg: document.getElementById("zahlkarten")?.hidden === true, nullen: [...document.querySelectorAll(".zahlkarte__wert")] .filter((e) => e.offsetParent !== null && e.textContent.trim() === "0").length, satz: document.getElementById("woche-leer")?.textContent || "", })); ok(ohneTage.leerDa, "statt der Nullen steht die Erklaerung da"); ok(ohneTage.kartenWeg && ohneTage.nullen === 0, `keine einzige sichtbare Null mehr (${ohneTage.nullen})`); ok(/kein einziger Tag erfasst/.test(ohneTage.satz), `sie sagt, WAS los ist ("${ohneTage.satz.slice(0, 46)}…")`); /* MIT DEM, WAS SIE GESEHEN HAT. Diese Zeile scheiterte am 16.09.2026 und sagte nur "nennt den Zeitraum nicht" -- wo der Fehler lag, war daran nicht zu erkennen. Eine Pruefung, die ihren Befund nicht zeigt, kostet genau die Zeit, die sie sparen soll. */ ok(/01\.09\./.test(ohneTage.satz) && /13 Tage/.test(ohneTage.satz), `und nennt den Zeitraum, der stattdessen da ist ` + `("${ohneTage.satz.replace(/\s+/g, " ").slice(0, 150)}")`); ok(/EINEN Tag/.test(ohneTage.satz), "und den Weg, wie man die Wochenzahlen doch fuellt"); if (process.env.SCHIRM) { await seite.locator("#woche").screenshot( { path: join(HIER, "pruef-woche-leer.png") }); } /* GEGENPROBE: Sobald EIN Tag da ist, verschwindet die Erklaerung und die Karten kommen zurueck. Ohne sie hiesse alles oben nur, dass die Karten jetzt immer weg sind. */ { const d5 = new DatabaseSync(DB); d5.prepare(`INSERT OR REPLACE INTO leistung (creator_id, tag, diamanten, gueltiger_tag, erfasst, erfasst_von, quelle) VALUES (?,?,?,1,?,?, 'hand')`) .run(idKiro, gestern, 500, new Date().toISOString(), 1); d5.close(); } await seite.reload({ waitUntil: "networkidle" }); await seite.waitForTimeout(1600); const mitTag = await seite.evaluate(() => ({ leerDa: document.getElementById("woche-leer")?.hidden === false, kartenDa: document.getElementById("zahlkarten")?.hidden === false, })); ok(!mitTag.leerDa && mitTag.kartenDa, "mit einem einzigen Tag sind die Karten wieder da"); 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 = tagLokal(-2); 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);