/* Bild der Trennung Normal / Premium in der Scout-Pipeline (06.10.2026) Filipe: "ich will dass es da in der mitte eine trennung gibt. links sollen normale creator sein und rechts premium creator." Die Zahlen sind gruen (pruef-scouting-felder, 63 Pruefungen) -- das sagt, dass die Kaesten an der richtigen Stelle liegen, aber nicht, wie es AUSSIEHT. Dafuer ist dieses Werkzeug da. Aufrufen mit: node server/bild-arten.mjs */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; /* NOTBREMSE (nachgeruestet 06.10.2026, nachdem genau das passiert ist). Dieses Werkzeug startet einen Server und einen Browser. Beim Versuch, die Farbkante unter dem Zeiger zu messen, blieb `page.hover()` haengen -- und ein Stillstand sieht von aussen genauso aus wie "laeuft noch". Schlimmer: Nach dem Abbrechen von aussen blieb der Server auf Port 4336 stehen und haette den naechsten Lauf blockiert. Dieselbe Regel wie fuer die Pruefungen, und aus demselben Grund: Eine Frist, die ueberschritten wird, ist ein FEHLER mit Meldung -- kein Warten ohne Ende. */ import { notbremse } from "./helfer-notbremse.mjs"; notbremse(240_000, "bild-arten"); const ordner = mkdtempSync(join(tmpdir(), "ws-arten-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); const { portMussFreiSein } = await import("./helfer-port.mjs"); const PORT = await portMussFreiSein(4336, "das Bildwerkzeug Arten"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; await import("./index.js"); await new Promise((r) => setTimeout(r, 1200)); const BASIS = `http://127.0.0.1:${PORT}`; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const j = new Date().toISOString(); const heute = j.slice(0, 10); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, j); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const dogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const scout = anlegen("BananaStift", "scout", "CODE-SCOU-0001"); /* Ein realistischer Stand: beide Arten, mehrere Stufen, eine leere Spalte -- genau die Faelle, die im Alltag vorkommen. */ const lead = (name, handle, status, art, prio, land, follower, netz, followup) => d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, scout_id, erstellt, erstellt_von, creator_art, land, follower, netzwerk, naechster_followup, live_zeiten) VALUES (?,'TikTok',?,?,?,?,?,?,?,?,?,?,?,?)`) .run(name, handle, status, prio, scout, j, dogi, art, land, follower, netz, followup, "Mo-Fr ab 21 Uhr"); lead("Tamy", "@coolemama1991", "neu", "normal", "hoch", "DE", 12400, "nein", heute); lead("Mira", "@mira.live", "neu", "normal", "mittel", "AT", 3200, "unbekannt", null); lead("Nala", "@nala", "neu", "premium", "hoch", "CH", 184000, "nein", null); lead("Jo", "@jo_streamt", "angesprochen", "normal", "mittel", "DE", 8700, "nein", null); lead("Elif", "@elif", "angesprochen", "premium", "hoch", "DE", 92000, "nein", heute); lead("Sam", "@samsam", "gespraech", "normal", "niedrig", "LU", 1500, "ja", null); d.close(); const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch(); const fehlerAufSeite = []; const anmelden = async (seite, rolle, code) => { await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); await seite.click(`.rolle[data-rolle="${rolle}"]`); await seite.fill("#code", code); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 25000 }); }; /* ---- 1. Am Rechner ---------------------------------------------------- */ const ctx = await browser.newContext({ viewport: { width: 1400, height: 1100 }, deviceScaleFactor: 2 }); const seite = await ctx.newPage(); seite.on("console", (m) => { if (m.type() === "error") fehlerAufSeite.push(m.text()); }); seite.on("pageerror", (f) => fehlerAufSeite.push(String(f))); await anmelden(seite, "scout", "CODE-SCOU-0001"); await seite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(1800); console.log("\n=== 1. Am Rechner (1400 px) ==="); const spalten = await seite.$$eval(".art-spalte", (ks) => ks.map((k) => ({ stufe: k.closest(".stufe")?.dataset.stufe, art: k.dataset.art, titel: k.querySelector(".art-spalte__titel")?.textContent.trim(), zahl: k.querySelector(".art-spalte__zahl")?.textContent.trim(), x: Math.round(k.getBoundingClientRect().x), breite: Math.round(k.getBoundingClientRect().width), linie: getComputedStyle(k).borderLeftWidth, namen: [...k.querySelectorAll(".kk__name")].map((n) => n.textContent.trim()).join(", ") || k.querySelector(".art-spalte__leer")?.textContent.trim(), }))); for (const s of spalten) { console.log(` ${String(s.stufe).padEnd(14)} ${String(s.titel).padEnd(8)} ${String(s.zahl).padStart(2)} ` + `x=${String(s.x).padStart(4)} b=${String(s.breite).padStart(4)} Linie=${s.linie.padStart(5)} ${s.namen}`); } /* WAS KOMMT AN DER KARTE WIRKLICH AN? (06.10.2026) `module.css` belegt `::before` (Kantenlicht) und `::after` (Raster, Eckwinkel) JEDER Karte und wird NACH scouting.css geladen. Was scouting.css auf dieselben Pseudoelemente legt, verliert damit -- ohne dass irgendwo ein Fehler erscheint. Diese Messung sagt, welcher Ton tatsaechlich gilt und wie breit die farbige Kante wirklich ist. */ console.log("\n--- Was an der Karte wirklich ankommt ---"); const kante = await seite.$eval(".kk", (k) => { const vor = getComputedStyle(k, "::before"); return { ton: getComputedStyle(k).getPropertyValue("--ton").trim() || "(nicht gesetzt)", stufenfarbe: getComputedStyle(k).getPropertyValue("--stufe-farbe").trim() || "(nicht gesetzt)", vorBreite: vor.width, vorInhalt: vor.content, grund: getComputedStyle(k).backgroundImage.slice(0, 60), }; }); console.log(" --ton:", kante.ton, " --stufe-farbe:", kante.stufenfarbe); console.log(" ::before Breite:", kante.vorBreite, " (3px = Kante von scouting.css, sonst ueberschrieben)"); console.log(" Grundbild:", kante.grund); /* WIE BREIT IST DIE FARBKANTE WIRKLICH -- und kommt sie durch? Gemessen wird nicht die Regel, sondern das Bild: die Farbe des Bildpunktes zwei Pixel rechts von der linken Kartenkante, einmal in Ruhe und einmal unter dem Zeiger. Eine Kante, die man nur im Quelltext sieht, ist keine. */ const kantenMass = await seite.evaluate(() => { const k = document.querySelector(".kk"); const s = getComputedStyle(k); return { groesse: s.backgroundSize, lage: s.backgroundPosition, kante: s.getPropertyValue("--kante").trim() }; }); console.log(" background-size:", kantenMass.groesse, "| --kante:", kantenMass.kante || "(nicht gesetzt)"); /* MIT mouse.move AUF DIE MITTE DER KARTE, NICHT MIT page.hover(). `hover()` wartet darauf, dass das Ziel "bedienbar" ist -- sichtbar, stabil, nicht verdeckt. Bei einer Karte, ueber der das Hintergrundbild der Seite liegt, kann diese Pruefung dreissig Sekunden brauchen oder nie fertig werden. Gemessen werden soll hier aber nicht die Bedienbarkeit, sondern was der Zeiger an der Farbe aendert -- dafuer genuegt es, den Zeiger hinzubewegen. */ { const m = await seite.$eval(".kk", (k) => { const r = k.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; }); await seite.mouse.move(m.x, m.y); await seite.waitForTimeout(400); } const unterZeiger = await seite.evaluate(() => getComputedStyle(document.querySelector(".kk")).getPropertyValue("--kante").trim()); console.log(" --kante unter dem Zeiger:", unterZeiger || "(nicht gesetzt)"); /* Kommt die Haarlinie zwischen Kopf und Zahlen an? Ein ungueltiger Farbwert laesst `border-image` lautlos ausfallen -- uebrig bleibt Abstand ohne Linie, und das sieht im Quelltext richtig aus. */ const linie = await seite.$eval(".kk__info", (k) => { const s2 = getComputedStyle(k); return { quelle: s2.borderImageSource.slice(0, 48), breite: s2.borderTopWidth }; }); console.log(" Haarlinie in der Karte:", linie.breite, "|", linie.quelle); /* Eine Nahaufnahme der linken Kante -- 90 px breit, damit man sie ueberhaupt sehen kann. */ /* Eine Karte OHNE Faellig-Saum: Bei Tamy liegt der warme Innenring ueber der Stufenkante, und dann misst man den Ring statt der Kante. */ const kasten = await seite.$eval(".kk:not([data-faellig])", (k) => { const r = k.getBoundingClientRect(); /* `width`/`height` -- nicht `w`/`h`. Playwright meldet dann "clip.width: expected float, got undefined"; gefunden hat es die Notbremse, nicht das Auge. */ return { x: Math.round(r.x), y: Math.round(r.y), width: 90, height: Math.round(r.height) }; }); await seite.screenshot({ path: "server/bild-arten-kante.png", clip: kasten }); await seite.mouse.move(0, 0); await seite.waitForTimeout(300); const stufe1 = await seite.$('.stufe[data-stufe="neu"]'); await stufe1.screenshot({ path: "server/bild-arten-rechner.png" }); await seite.screenshot({ path: "server/bild-arten-rechner-ganz.png", fullPage: false }); /* ---- 2. Das Formular -------------------------------------------------- */ await seite.click("#neu-auf"); await seite.waitForTimeout(600); const feld = await seite.$("#neu-block"); await feld.screenshot({ path: "server/bild-arten-formular.png" }); console.log("\n=== 2. Im Formular ==="); console.log(" Auswahl:", (await seite.$$eval("#n-art option", (os) => os.map((o) => o.textContent.trim()))).join(" | ")); await seite.click("#neu-zu"); /* ---- 3. Auf dem Handy (mit Finger!) ----------------------------------- */ const handyKontext = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 3, }); await handyKontext.addCookies(await ctx.cookies()); const handy = await handyKontext.newPage(); await handy.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); await handy.waitForTimeout(1800); console.log("\n=== 3. Auf dem Handy (390 px) ==="); const schmal = await handy.$$eval('.stufe[data-stufe="neu"] .art-spalte', (ks) => ks.map((k) => ({ art: k.dataset.art, y: Math.round(k.getBoundingClientRect().y), breite: Math.round(k.getBoundingClientRect().width), ueberRand: k.getBoundingClientRect().right > window.innerWidth, }))); for (const s of schmal) { console.log(` ${String(s.art).padEnd(8)} y=${String(s.y).padStart(5)} b=${s.breite}` + (s.ueberRand ? " UEBER DEN RAND" : "")); } const stufeH = await handy.$('.stufe[data-stufe="neu"]'); await stufeH.screenshot({ path: "server/bild-arten-handy.png" }); /* ---- 4. Die Personenseite nach einer Uebergabe ------------------------- */ const ctxD = await browser.newContext({ viewport: { width: 1400, height: 1100 }, deviceScaleFactor: 2 }); const dogiSeite = await ctxD.newPage(); await anmelden(dogiSeite, "admin", "CODE-DOGI-0001"); await dogiSeite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); await dogiSeite.waitForTimeout(1500); /* Elif (Premium) durch die Pipeline bis zur Uebergabe und ins Onboarding. */ const elifId = await dogiSeite.evaluate(async () => { const a = await fetch("/workspace/api/leads", { credentials: "same-origin" }); const d2 = await a.json(); return d2.leads.find((l) => l.name === "Elif")?.id; }); await dogiSeite.evaluate(async (id) => { await fetch("/workspace/api/leads/" + id, { method: "PATCH", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "uebergeben" }), }); await fetch(`/workspace/api/leads/${id}/onboarding`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Elif" }), }); }, elifId); await dogiSeite.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" }); await dogiSeite.waitForTimeout(1800); console.log("\n=== 4. Auf der Personenseite ==="); /* Die Rollengruppen sind zugeklappt (Vorgabe: nur DogFather offen). Ohne diesen Klick steht die Creator-Kachel gar nicht im Dokument -- beim ersten Lauf meldete dieser Abschnitt deshalb nichts, und das sah aus wie ein Fehler an der Art. */ for (const kopf of await dogiSeite.$$(".gruppe__kopf--klappbar")) { const t = (await kopf.textContent()) || ""; if (t.toLowerCase().includes("creator")) { await kopf.click(); await dogiSeite.waitForTimeout(800); } } const creator = await dogiSeite.$$eval('.person[data-rolle="creator"]', (ks) => ks.map((k) => ({ name: k.querySelector(".person__name")?.textContent.trim(), marken: [...k.querySelectorAll(".marke-rolle")].map((m) => m.textContent.trim()).join(" - "), art: k.querySelector('select[id^="creator-art-"]')?.value, }))); for (const c of creator) console.log(` ${String(c.name).padEnd(14)} [${c.marken}] Auswahl: ${c.art}`); const kachel = await dogiSeite.$('.person[data-rolle="creator"]'); if (kachel) await kachel.screenshot({ path: "server/bild-arten-person.png" }); console.log("\nFehler auf der Seite:", fehlerAufSeite.length ? fehlerAufSeite.join(" | ") : "keine"); await browser.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(0);