Files
dogfather-universe/server/pruef-eventkarte.mjs
T
DogFatherGitandClaude Opus 5 cadab24a5f Kampagnen-Kacheln: gegliederte Texte, Geschenke mit Preis, aufklappbar
Filipe, mit einem Bildschirmfoto des Regelblocks: "man soll die titel
in den texten besser erkennen und sehen was zu was passt. ich will
auch dass man sieht welche geschenke bei dem event zaehlen und so.
ich will alle informationen die moeglich sind wenn man den link
hochlaedt. ... auch wenn man teilt soll es einfach perfekt sein. man
soll alle infos sehen die man braucht. die kacheln von den events
soll auch nicht so lang sein, die soll man aufklappen koennen. genau
wie eine trennung fuer heute, letzte woche letzten monat und so."

DER FEHLER LAG NICHT IM TEXT, SONDERN IN SEINER ANZEIGE. Im Feld
stand die ganze Zeit eine Gliederung -- Hinweise mit "* ", eine
Ueberschrift mit Untertitel hinter einem Gedankenstrich, Absaetze
ohne Zeichen, Aufzaehlungen mit "- ". Die Kachel hat daraus mit
punkteAusText() EINE flache Liste gemacht: jede Zeile derselbe
Strich, alle gleich gross.

  gliederung()      liest die Gliederung, die schon dasteht, statt
                    den Text umzuschreiben. Die beiden Kampagnen, die
                    heute in der Datenbank liegen, sehen dadurch
                    sofort richtig aus -- ohne Umstellung.
  Geschenke         kommen aus der gelesenen LISTE, nicht aus dem
                    Text, und stehen OHNE Aufklappen auf der Kachel.
  Aufklappen        alles Uebrige hinter einem Druck: gemessen 555 px
                    weniger je Kachel bei Gipfelstuermer.
  Gruppen           je Spalte laeuft / kommt / vorbei, Vergangenes
                    nach diese Woche, dieser Monat, aelter.

NEU GELESEN (stand in allen vier gespeicherten Seiten und wurde nie
angesehen): die Rangliste mit ihren Gruppen ("Superstars" bis
"5. Liga", bei Glow Up "Team Glow"/"Team Shine"), die Beschreibung je
Missionsgruppe, der Steckbrief (Kurznummer, Markt, Sprache, Zone),
der zweite Bildbaustein, und Ueberschriften INNERHALB der Klapptexte
-- "WELCHE LIGA BIN ICH?" hat jetzt sechs eingerueckte Unterpunkte
statt zwoelf gleichrangiger Zeilen.

EVENT_TEXT_MAX 6000 -> 20000. Der Regeltext von Gipfelstuermer ist
7679 Zeichen lang; es fielen bisher jedes Mal rund 1600 Zeichen
echter Inhalt weg. Die Zahl steht jetzt an EINER Stelle.

TEILEN: Was die KAMPAGNE sagt, geht hinaus (Aufgaben, Geschenke,
Regeln) -- wer WIR sind, nicht (Name, Rolle, Haus, Kampagnennummer,
Steckbrief). Und keine Hinweiszeile: "Die Preise konnten nicht
gelesen werden -- bitte nachtragen" ist eine Notiz an DogFather und
hat auf einer Seite, die er in Discord postet, nichts zu suchen.

BERICHTIGT, WEIL NACHGEMESSEN:
  - Number(null) ist 0 und 0 ist endlich -- "Feuerwerk" bekam an drei
    Stellen "0 Coins" angeschrieben, also eine falsche Preisangabe.
  - Ein Kommentar im Leser behauptete, der Wertungszeitraum weiche
    vom Kampagnenzeitraum ab. Nachgemessen ist er in allen drei
    Seiten mit Rangliste auf die Millisekunde gleich; ich hatte zwei
    verschiedene Kampagnen verglichen.
  - bild-kampagne.mjs verlangte "jede Kachel traegt den Ton ihrer
    Seite" und war damit seit dem 07.10. rot, weil an dem Tag bewusst
    das Gegenteil entschieden wurde (Zustandston, Seitenkante). Nicht
    aufgefallen, weil nach dem Lauf nur die Zahl der MESSUNGEN notiert
    wurde, nicht die der FEHLER.
  - .kv-seite > .eintrag-karte wird .kv-seite .eintrag-karte: Die
    Karten haengen durch die Gruppen eine Ebene tiefer. Das waere der
    fuenfte Direktkind-Selektor gewesen, der nach einem Umbau still
    ausfaellt -- diesmal vorher gesehen.

GEPRUEFT: 186 (lesen, +58), 148 (Route/Teilen, +25), 158 (Kachel,
+64), 67 (Agentur, +5), 67 Messungen (Bild, +14), dazu struktur,
css-klassen, zeichen, deutsche-texte, tippziele, lesbarkeit und
haus-trennung (107, unveraendert). Sechs Gegenproben: alle sechs
Sabotagen werden bemerkt, alle Dateien byte-gleich wiederhergestellt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 22:39:03 +02:00

1763 lines
88 KiB
JavaScript

/* =====================================================================
AGENTUR-EVENTS: KARTE, PUNKTE UND FORMULAR (03.10.2026)
Filipe, mit einem Bildschirmfoto des Oktober-Events: „ich will das
viel geiler viel perfekter. und auch wenn man die eintraegt und so
soll viel mehr perfektioniert und uebersichtlicher sein."
Umgebaut wurden drei Dinge, und alle drei werden hier nachgemessen:
1. DIE KARTE Buehne mit Titel auf dem Banner statt 638 px
leerem Bild darueber; Zeitraum einmal statt
dreimal; „noch 13 Tage" als Antwort.
2. DIE PUNKTE Aus Fliesstext wird eine Liste zum Abhaken,
jeder fuer sich. Neue Tabelle event_punkte.
3. DAS FORMULAR Ein Feld je Zeile statt eines leeren Textfeldes,
in das man Aufzaehlungszeichen selbst tippt.
WAS HIER AUSDRUECKLICH NICHT NUR BESTAETIGT WIRD:
Zu jeder Zusage gehoert eine Gegenprobe, die zeigt, dass die
Pruefung auch „nicht in Ordnung" sagen kann -- sonst bestaetigt sie
nichts (Projektgedaechtnis, 01.09.2026: `every()` auf einem leeren
Feld meldet `true`). Deshalb steht die Zahl der geprueften Dinge in
der BEDINGUNG, nicht nur im Meldetext.
KEINE WANDUHR ALS ANNAHME: Alle Daten sind relativ zu heute
gerechnet (`tag(-1)`, `tag(+12)`). Ein festes Datum waere eine
Zeitbombe -- gruen, bis der Kalender es einholt (Projektgedaechtnis,
06.09.2026, gate-oeffnung.mjs).
===================================================================== */
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tagLokal } from "./helfer-zeit.mjs";
const ordner = mkdtempSync(join(tmpdir(), "ws-evk-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Eventkarten-Pruefung");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
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");
notbremse(300_000, "pruef-eventkarte");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:" + PORT;
const PROTOKOLL = join(process.cwd(), "pruef-eventkarte-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => {
console.log(z);
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
};
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ---------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
/* ORTSZEIT, NICHT UTC (berichtigt 06.10.2026, gefunden von pruef-struktur).
Hier stand `toISOString().slice(0, 10)`. Zwischen 00:00 und 02:00
deutscher Zeit liegt UTC noch im Vortag -- ein Lauf um Mitternacht
haette Termine einen Tag zu frueh angelegt und dann an der falschen
Stelle gesucht. `tagLokal` ist die Stelle, an der das Haus das
einmal richtig macht. */
const tag = (v) => tagLokal(v);
/* `kampagneId` entscheidet, in WELCHER Spalte die Karte landet
(07.10.2026): links die Kampagnen von TikTok, rechts die Events
der Agentur. Die Pruefung braucht beide Seiten gefuellt -- sonst
misst sie eine Trennung, bei der eine Haelfte leer ist, und
„zwei nebeneinander" waere dann zu Recht rot. */
/* `daten` ist der Auszug, den der Leser beim Einlesen ablegt
(07.10.2026). Daraus baut der Server die Geschenke mit
Coinpreis, den Steckbrief und die Beschreibung je
Missionsgruppe. Ohne ihn waeren diese drei Dinge hier gar
nicht pruefbar -- und ein Feld, das keine Pruefung ansieht,
ist ein Feld, das beim naechsten Umbau still ausfaellt. */
function evt(titel, von, bis, auf, reg, kampagneId = null, daten = null) {
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, event_ende,"
+ " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von, kampagne_id,"
+ " kampagne_daten)"
+ " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?,?,?)")
.run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi, kampagneId,
daten === null ? null : JSON.stringify(daten));
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
/* Die drei Zustaende, die eventStand kennt -- jeder einzeln, nicht als
Summe. Und die Aufgaben genau so krumm getippt wie im echten Event:
einmal ohne Leerzeichen hinter dem Punkt, einmal mit. */
const laufend = evt("Oktober Event", tag(-1), tag(12),
"·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen",
"· Nur Streams auf dem eigenen Konto\n· Punkte zählen bis 23:59 am letzten Tag",
"7691493175936699143");
const kommend = evt("November Challenge", tag(20), tag(45),
"· 30 Live-Stunden", "· Nur eigene Konten");
const vorbei = evt("September Rückblick", tag(-40), tag(-12),
"· 20 Live-Stunden", "· Nur eigene Konten");
/* ==== EIN EVENT MIT ECHTER GLIEDERUNG (07.10.2026) ==========
Der Regeltext ist ZEICHENGLEICH mit dem, was am 07.10.2026
wirklich in der Datenbank steht (Eintrag 89, „LIVE Glow Up")
-- nur gekuerzt. Erfundene Testdaten haetten hier genau das
verdeckt, worum es geht: Die Hinweise mit `* ` davor, die
Ueberschrift mit Untertitel hinter einem Gedankenstrich, der
Absatz ohne Zeichen und die Aufzaehlung mit `· `. Jede dieser
vier Zeilensorten sah vorher gleich aus.
ZWEI GESCHENKE, UND EINES OHNE PREIS. Das ist die Gegenprobe
zu einem Fehler, der am 07.10.2026 beim ersten Lauf wirklich
auftrat: `Number(null)` ist 0, und 0 ist endlich -- „Feuerwerk"
bekam dadurch „0 Coins" angeschrieben, also eine falsche
Preisangabe. Mit nur einem Geschenk MIT Preis waere das nie
aufgefallen.
ES LAEUFT UND KOMMT VON TIKTOK: Damit liegt es links in der
Gruppe „Laeuft gerade", die offen steht -- sonst waere alles
darin zugeklappt und nicht messbar. */
const struktText = [
"* Diese Kampagne hat keinen Einleitungstext — der Inhalt steht in den Klapptexten.",
"* Diese Kampagne hat keine Preisliste auf der Seite.",
"",
"SAMMLE DEIN FEUERWERK — Erhalte doppelte Punkte für die Rangliste",
"· Feuerwerk (Sammle Feuerwerk in deinem LIVE!)",
"· Ahorn-Welt — 12000 Coins",
"",
"INFOS ZUR KAMPAGNE",
"LIVE Glow Up ist eine exklusive 4-wöchige Wachstum Challenge.",
"Wofür brauche ich das?",
"Damit du stärkere LIVE-Gewohnheiten aufbaust.",
"",
"### Superstars",
"Du kannst teilnehmen, wenn du eine Einladung hast.",
"",
"KAMPAGNENZEITPLAN",
"· Woche 1: 5. - 11.10.",
"",
"TEILNAHME",
"Länder: DE, CH, AT, LI, LU",
].join("\n");
const strukt = evt("Glow Up Struktur", tag(-2), tag(20),
"Tägliche Mission\nWöchentliche Mission", struktText,
"7690927903777262344", {
geschenke: [
{ id: "1", name: "Feuerwerk", preis: null, hinweis: "Sammle Feuerwerk in deinem LIVE!" },
{ id: "2", name: "Ahorn-Welt", preis: 12000 },
],
aufgabenGruppen: [
{ titel: "Tägliche Mission", beschreibung: "Erfülle sie jeden Tag neu.",
von_tag: "2026-10-05", bis_tag: "2026-10-11" },
],
steckbrief: {
nummer: "7690927903777262344", kurznummer: "1139900164",
region: "EU-TTP2", sprache: "de-DE", verbund: null,
zeitzone: "Europe/Berlin", anmeldung_ab: null, ergebnis_bis: null,
},
});
/* Ein gewoehnlicher Eintrag auf demselben Brett: Er darf sich NICHT
mitverwandeln. Ohne ihn wuerde "nur Events sind betroffen" hier gar
nicht geprueft. */
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von)"
+ " VALUES (?,?,'agentur','schulung','offen',?, 'mittel', ?, ?)")
.run("Einarbeitung für neue Modis", "Kein Event.", tag(0), jetzt, idDogi);
/* EIN EINTRAG, DEN LUNA NICHT SEHEN DARF -- fuer die Gegenprobe zur
Bildweg-Reparatur weiter unten. `entwicklung` ist eine Aufzeichnung
UEBER eine Person; sie selbst sieht sie nicht. Ohne diesen Eintrag
waere "es wurden keine Rechte ausgeweitet" eine Behauptung ohne
Beleg. */
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von, creator_id)"
+ " VALUES (?,?,'entwicklung','notiz','offen',?, 'mittel', ?, ?, ?)")
.run("Vertrauliche Aufzeichnung", "Nicht fuer Luna.", tag(0), jetzt, idDogi, idDogi);
const geheim = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.close();
/* Ein Banner fuer das laufende Event -- ohne Bild waere der Knopf
"Banner ganz" nicht pruefbar, und die Buehne mit Bild ist der Fall,
um den es Filipe ging. */
import { pngBauen } from "./helfer-png-bauen.mjs";
const bannerBild = pngBauen(1280, 720, (x, y, b, h) => {
const g = Math.max(0, 1 - Math.hypot(x / b - .5, y / h - .5) * 1.8);
return [Math.round(26 + g * 120), Math.round(10 + g * 30), Math.round(46 + g * 150)];
});
/* ---- Zugang ----------------------------------------------------------- */
const { request } = await import("node:http");
const HOST = "workspace.dogfather-universe.com";
function roh(pfad, o = {}) {
return new Promise((f, s) => {
const daten = o.rumpf === undefined ? null
: (Buffer.isBuffer(o.rumpf) ? o.rumpf : Buffer.from(JSON.stringify(o.rumpf)));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad,
method: o.methode || (daten ? "POST" : "GET"),
headers: {
Host: HOST, Origin: "https://" + HOST,
...(o.keks ? { Cookie: o.keks } : {}),
...(daten ? { "Content-Length": daten.length } : {}),
...(o.kopf || {}),
},
}, (r) => {
const t = [];
r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode, kopf: r.headers, text: Buffer.concat(t).toString("utf8") }));
});
a.on("error", s);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, code) {
const a = await roh("/workspace/api/anmelden", {
rumpf: { rolle, code }, kopf: { "Content-Type": "application/json" } });
if (a.code !== 200) { melde("ABBRUCH: Anmeldung " + rolle + " -> " + a.code); process.exit(3); }
return [].concat(a.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksLuna = await anmelden("creator", "CODE-CREA-0001");
const brett = async (keks) => JSON.parse((await roh("/workspace/api/bereich/agentur", { keks })).text);
let bannerId = null, geheimBildId = null;
{
const h = await roh("/workspace/api/bereich/agentur/" + laufend + "/bild", {
keks: keksDogi, rumpf: bannerBild, methode: "POST",
kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("banner.png") } });
if (h.code !== 201) {
melde("ABBRUCH: Banner liess sich nicht anhaengen (" + h.code + ") -- ohne es");
melde(" prueft diese Datei die Buehne MIT Bild gar nicht.");
process.exit(3);
}
bannerId = JSON.parse(h.text).id;
const g = await roh("/workspace/api/bereich/entwicklung/" + geheim + "/bild", {
keks: keksDogi, rumpf: bannerBild, methode: "POST",
kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("geheim.png") } });
if (g.code !== 201) {
melde("ABBRUCH: das Bild fuer die Gegenprobe ging nicht durch (" + g.code + ").");
melde(" Ohne es wuerde unten eine Rechtefrage geprueft, die gar nicht stattfindet.");
process.exit(3);
}
geheimBildId = JSON.parse(g.text).id;
}
melde("\n=== 0. Der Bildweg: wer den Eintrag sieht, sieht sein Bild ===");
{
/* GEMESSEN AM 03.10.2026, und es war ein echter Mangel: Das Banner
eines Agentur-Events kam bei einer Creatorin mit 404 zurueck --
an genau der Karte, die fuer sie gemacht ist. Grund: Die Regel
"wer das Brett sieht, sieht das Bild" galt nur fuer die sieben
Community-Bretter, und danach griff die Ablage-Regel, die an
Creator-Zuordnungen haengt. Ein Agentur-Event hat keine. */
const alsDogi = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksDogi });
const alsLuna = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksLuna });
ok(alsDogi.code === 200, `DogFather bekommt das Banner (HTTP ${alsDogi.code})`);
ok(alsLuna.code === 200, `und die Creatorin auch (HTTP ${alsLuna.code}, vorher 404)`);
/* DIE GEGENPROBE, auf die es ankommt: Die Reparatur darf NICHT mehr
oeffnen als noetig. Ein Eintrag, den Luna nicht sehen darf, muss
sein Bild weiterhin verweigern -- sonst waere aus einer Luecke
eine groessere geworden. */
const verboten = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksLuna });
ok(verboten.code === 404,
`ein Bild an einem fremden Eintrag bleibt verborgen (HTTP ${verboten.code})`);
/* Und dass dieses Bild ueberhaupt existiert -- sonst waere die
Gegenprobe gruen, weil es nichts zu sehen gab. */
const existiert = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksDogi });
ok(existiert.code === 200,
`es gibt dieses Bild wirklich (DogFather: HTTP ${existiert.code})`);
}
/* =======================================================================
1. Die Punkte: aus Fliesstext wird eine Liste
======================================================================= */
melde("\n=== 1. Die Punkte kommen als Liste vom Server ===");
{
const daten = await brett(keksDogi);
const e = (daten.eintraege || []).find((x) => x.titel === "Oktober Event");
ok(!!e, "das laufende Event ist auf dem Brett");
const p = e?.event_punkte || [];
ok(p.length === 3, `3 Aufgabenpunkte (${p.length})`);
/* DAS IST DER EIGENTLICHE MANGEL AUS FILIPES BILDSCHIRMFOTO: Im
Original stand „·Jeden Tag Live gehen" neben „· 33k Diamanten
erreichen" -- einmal ohne, einmal mit Leerzeichen. */
ok(p[0]?.text === "Jeden Tag Live gehen",
`ohne Leerzeichen getippt -> sauber: ${JSON.stringify(p[0]?.text)}`);
ok(p[2]?.text === "33k Diamanten erreichen",
`mit Leerzeichen getippt -> gleich sauber: ${JSON.stringify(p[2]?.text)}`);
ok(p.every((x) => /^[0-9a-f]{16}$/.test(x.schluessel || "")) && p.length > 0,
"jeder Punkt hat einen Schluessel (16 Hexzeichen)");
/* GEGLIEDERT STATT FLACH (07.10.2026). Hier stand
`event_regelpunkte` -- eine Liste gleichrangiger Zeilen. Der
Regeltext dieses Events hat keine Ueberschrift, also ist es
EIN Abschnitt ohne Titel mit zwei Punkten. Genau das wird
gemessen: nicht „es kommt irgendetwas", sondern die Form. */
const ab = e?.event_regelabschnitte || [];
ok(ab.length === 1 && ab[0].titel === null && ab[0].zeilen.length === 2,
`ein Abschnitt ohne Titel mit 2 Zeilen: ${JSON.stringify(ab)}`);
ok(ab[0]?.zeilen.every((z) => z.art === "punkt"),
"beide Zeilen sind Punkte, keine Absaetze");
/* DER ROHTEXT KOMMT WEITERHIN MIT -- er ist der dritte Ausgang,
wenn die Gliederung einmal ausfaellt. */
ok(typeof e?.event_regeln === "string" && e.event_regeln.includes("eigenen Konto"),
"der Rohtext steht als Rueckfall weiterhin in der Antwort");
/* ---- Das gegliederte Event: Geschenke, Steckbrief, Gruppen -- */
const s = (daten.eintraege || []).find((x) => x.titel === "Glow Up Struktur");
ok(!!s, "das gegliederte Event ist auf dem Brett");
const sab = s?.event_regelabschnitte || [];
const titel = sab.map((a) => a.titel);
ok(titel.includes("SAMMLE DEIN FEUERWERK") && titel.includes("INFOS ZUR KAMPAGNE")
&& titel.includes("KAMPAGNENZEITPLAN") && titel.includes("TEILNAHME"),
`die vier Ueberschriften sind erkannt: ${JSON.stringify(titel)}`);
const feuer = sab.find((a) => a.titel === "SAMMLE DEIN FEUERWERK");
ok(feuer?.unter === "Erhalte doppelte Punkte für die Rangliste",
`der Untertitel steht getrennt: ${JSON.stringify(feuer?.unter)}`);
ok(feuer?.art === "geschenke", `der Abschnitt ist als Geschenke erkannt (${feuer?.art})`);
/* DIE GEGENPROBE ZUM 0-COINS-FEHLER. `preis: null` darf NICHT
als 0 herauskommen -- „0 Coins" ist eine Preisangabe, und
zwar eine falsche. */
const gaben = (feuer?.zeilen || []).filter((z) => z.art === "geschenk");
ok(gaben.length === 2, `zwei Geschenke (${gaben.length})`);
const ohne = gaben.find((z) => z.text === "Feuerwerk");
const mit = gaben.find((z) => z.text === "Ahorn-Welt");
ok(ohne && (ohne.coins === null || ohne.coins === undefined),
`ein Geschenk ohne Preis hat KEINEN Preis, nicht 0: ${JSON.stringify(ohne)}`);
ok(mit?.coins === 12000, `und eines mit Preis hat ihn: ${JSON.stringify(mit?.coins)}`);
ok(ohne?.zusatz === "Sammle Feuerwerk in deinem LIVE!",
`der Hinweis aus der Geschenkliste kommt mit: ${JSON.stringify(ohne?.zusatz)}`);
/* Die Unterstufe aus einem fremden Text. */
const supers = sab.find((a) => a.titel === "Superstars");
ok(supers?.stufe === 3, `eine Unterueberschrift ist Stufe 3 (${supers?.stufe})`);
ok(sab.find((a) => a.titel === "INFOS ZUR KAMPAGNE")?.stufe === 2,
"und ein eigener Abschnitt Stufe 2");
/* Die Frage mit Antwort darunter. */
const infos = sab.find((a) => a.titel === "INFOS ZUR KAMPAGNE");
ok((infos?.zeilen || []).some((z) => z.art === "frage" && z.text === "Wofür brauche ich das?"),
`die Frage mit Antwort darunter ist erkannt: ${JSON.stringify(infos?.zeilen)}`);
/* GEGENPROBE: Eine Zeile aus nichts als Laendercodes waere
gross geschrieben und wuerde zur Ueberschrift. `regelnText`
schreibt deshalb „Länder: ..." -- und genau das muss ein
ABSATZ bleiben, kein Titel. */
const teil = sab.find((a) => a.titel === "TEILNAHME");
ok((teil?.zeilen || []).some((z) => z.art === "text" && z.text.startsWith("Länder:")),
`„Länder: DE, CH, ..." bleibt ein Absatz: ${JSON.stringify(teil?.zeilen)}`);
ok(!titel.includes("DE, CH, AT, LI, LU"),
"und wird nicht zur Ueberschrift");
/* Die Hinweise stehen in einem eigenen Abschnitt ohne Titel --
nicht zwischen den Regeln. Das war der Mangel im
Bildschirmfoto. */
const hin = sab.find((a) => a.art === "hinweise");
ok(hin && hin.titel === null && hin.zeilen.length === 2
&& hin.zeilen.every((z) => z.art === "hinweis"),
`die zwei Hinweise stehen fuer sich: ${JSON.stringify(hin)}`);
/* Steckbrief und Aufgabenbeschreibung. */
ok(s?.event_steckbrief?.kurznummer === "1139900164",
`der Steckbrief kommt mit: ${JSON.stringify(s?.event_steckbrief)}`);
ok(s?.event_steckbrief?.verbund === undefined,
"ein leeres Feld faellt weg, statt als Leerstelle zu erscheinen");
const tagMission = (s?.event_punkte || []).find((x) => x.text === "Tägliche Mission");
ok(tagMission?.beschreibung === "Erfülle sie jeden Tag neu.",
`die Beschreibung haengt an der Aufgabe: ${JSON.stringify(tagMission)}`);
ok(tagMission?.von_tag === "2026-10-05" && tagMission?.bis_tag === "2026-10-11",
"und ihr Zeitraum dazu");
const woche = (s?.event_punkte || []).find((x) => x.text === "Wöchentliche Mission");
ok(woche && woche.beschreibung === undefined,
"eine Aufgabe ohne Gruppe bekommt kein leeres Feld");
/* GEGENPROBE: Die drei Events OHNE Kampagnendaten bekommen
weder Geschenke noch Steckbrief. Ohne sie waere unbelegt,
dass die Felder aus `kampagne_daten` kommen und nicht
irgendwo erfunden werden. */
ok(e?.event_steckbrief === undefined,
"ein Event ohne Kampagnendaten hat keinen Steckbrief");
ok(!(e?.event_regelabschnitte || []).some((a) => a.art === "geschenke"),
"und keinen Geschenkabschnitt");
ok(e?.kampagne_daten === undefined,
"die Rohspalte selbst geht NICHT an die Oberflaeche");
ok(s?.kampagne_daten === undefined,
"auch nicht beim Event, das sie hat");
ok(e?.event_geschafft === 0, `noch nichts abgehakt (${e?.event_geschafft})`);
/* GEGENPROBE: Ein Eintrag, der KEIN Event ist, bekommt keine Punkte.
Ohne sie wuesste niemand, ob die Zerlegung nicht einfach ueberall
laeuft -- und dann waere "nur Events sind betroffen" unbelegt. */
const schulung = (daten.eintraege || []).find((x) => x.art === "schulung");
ok(!!schulung, "der Nicht-Event-Eintrag ist da (sonst prueft die Gegenprobe nichts)");
ok(schulung && schulung.event_punkte === undefined,
"ein Eintrag ohne Eventfelder bekommt keine Punkteliste");
}
/* =======================================================================
2. Abhaken -- und was dabei NICHT geht
======================================================================= */
melde("\n=== 2. Abhaken: jeder fuer sich ===");
let schluesselA = null, schluesselB = null;
{
const daten = await brett(keksDogi);
const e = daten.eintraege.find((x) => x.titel === "Oktober Event");
schluesselA = e.event_punkte[1].schluessel; /* 22-Live-Stunden */
schluesselB = e.event_punkte[2].schluessel; /* 33k Diamanten */
const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(a.code === 200, `Luna hakt einen Punkt ab (HTTP ${a.code})`);
const j = JSON.parse(a.text || "{}");
ok(j.geschafft === 1 && j.gesamt === 3, `Antwort sagt ${j.geschafft} von ${j.gesamt} (erwartet 1 von 3)`);
/* HAELT ES AUCH? Die Zahl aus der Antwort koennte gerechnet sein,
ohne dass etwas gespeichert wurde. Deshalb ein frischer Abruf. */
const nachher = await brett(keksLuna);
const eL = nachher.eintraege.find((x) => x.titel === "Oktober Event");
ok(eL.event_geschafft === 1, `nach frischem Abruf: ${eL.event_geschafft} abgehakt`);
ok(eL.event_punkte.filter((p) => p.meiner).length === 1
&& eL.event_punkte[1].meiner === true,
"und zwar genau der angeklickte Punkt");
/* DER KERN DER ZUSAGE: DogFather sieht Lunas Haken NICHT als seinen
eigenen. Ohne diese Pruefung waere "jeder hakt fuer sich ab" eine
Behauptung. */
const beiDogi = await brett(keksDogi);
const eD = beiDogi.eintraege.find((x) => x.titel === "Oktober Event");
ok(eD.event_geschafft === 0,
`DogFather sieht Lunas Haken nicht als seinen (${eD.event_geschafft})`);
ok(eD.event_punkte.every((p) => p.meiner === false),
"kein einziger Punkt ist bei ihm angehakt");
/* ... sieht aber, DASS jemand dabei ist -- er ist die Leitung. */
ok(Array.isArray(eD.event_stand) && eD.event_stand.length === 1
&& eD.event_stand[0].name === "Luna" && eD.event_stand[0].geschafft === 1,
`die Leitung sieht den Stand: ${JSON.stringify(eD.event_stand)}`);
/* GEGENPROBE ZUR LEITUNG: Luna selbst darf diese Liste NICHT
bekommen -- sonst waere "wer macht mit" eine Auskunft fuer alle. */
ok(eL.event_stand === undefined,
"eine Creatorin bekommt die Mitmacherliste gar nicht erst");
/* Zweimal dasselbe ist kein Fehler, aendert aber auch nichts. */
const nochmal = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
const j2 = JSON.parse(nochmal.text || "{}");
ok(nochmal.code === 200 && j2.geschafft === 1,
`zweimal derselbe Haken bleibt 1 (HTTP ${nochmal.code}, ${j2.geschafft})`);
/* Und wieder weg. */
const ab = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "DELETE" });
const j3 = JSON.parse(ab.text || "{}");
ok(ab.code === 200 && j3.geschafft === 0, `Haken wieder weg (${j3.geschafft})`);
/* Fuer die folgenden Pruefungen wieder setzen. */
await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
}
melde("\n=== 2b. Was NICHT geht ===");
{
/* EIN ERFUNDENER SCHLUESSEL. Ohne diese Schranke koennte sich jeder
„3 von 3" setzen, ohne dass eine der drei Aufgaben je dastand --
und eine Zahl, die man sich ausdenken kann, zaehlt nichts. */
const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/00112233445566ff`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(a.code === 404, `erfundener Punkt wird abgelehnt (HTTP ${a.code})`);
/* Unsinn in der Adresse. */
const b = await roh(`/workspace/api/eintrag/${laufend}/punkt/..%2F..%2Fetc`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(b.code === 400 || b.code === 404, `kein Schluesselformat -> abgelehnt (HTTP ${b.code})`);
/* Ohne Anmeldung gar nichts. */
const c = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`, {
methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(c.code === 401, `ohne Anmeldung abgewiesen (HTTP ${c.code})`);
/* Fremde Herkunft (CSRF). */
const e = await new Promise((f, s) => {
const a2 = request({
host: "127.0.0.1", port: PORT,
path: `/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`,
method: "PUT",
headers: { Host: HOST, Origin: "https://boeswillig.example", Cookie: keksLuna,
"Content-Type": "application/json", "Content-Length": 2 },
}, (r) => { const t = []; r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode })); });
a2.on("error", s); a2.write("{}"); a2.end();
});
ok(e.code === 403, `fremde Herkunft abgewiesen (HTTP ${e.code})`);
}
/* =======================================================================
3. Der Schluessel ueberlebt das Umsortieren -- aber nicht das Umschreiben
======================================================================= */
melde("\n=== 3. Haken bleiben am richtigen Punkt ===");
{
/* UMSORTIEREN: Der Haken an „22-Live-Stunden" muss bleiben, wo er
ist -- auch wenn eine Zeile DAVOR eingefuegt wird. Haengt er an
einer Zeilennummer, wandert er still auf eine andere Aufgabe, und
jemand hat etwas abgehakt, was er nie gemacht hat. */
const a = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 22-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
kopf: { "Content-Type": "application/json" } });
ok(a.code === 200, `Aufgaben umsortiert und ergaenzt (HTTP ${a.code})`);
const nach = await brett(keksLuna);
const e = nach.eintraege.find((x) => x.titel === "Oktober Event");
ok((e.event_punkte || []).length === 4, `jetzt 4 Punkte (${(e.event_punkte || []).length})`);
const haken = (e.event_punkte || []).filter((p) => p.meiner);
ok(haken.length === 1 && haken[0].text === "22-Live-Stunden erreichen",
`der Haken sitzt weiterhin an: ${JSON.stringify(haken.map((h) => h.text))}`);
/* GEGENPROBE: Wird die Bedingung SELBST geaendert, muss der Haken
fallen -- eine geaenderte Bedingung ist eine andere Bedingung, und
ein Haken daran waere eine Falschauskunft. */
const b = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 30-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
kopf: { "Content-Type": "application/json" } });
ok(b.code === 200, `Bedingung umgeschrieben: 22 -> 30 Stunden (HTTP ${b.code})`);
const nach2 = await brett(keksLuna);
const e2 = nach2.eintraege.find((x) => x.titel === "Oktober Event");
ok((e2.event_punkte || []).filter((p) => p.meiner).length === 0,
"der Haken ist gefallen -- 30 Stunden sind nicht 22");
ok(e2.event_geschafft === 0, `und die Zahl stimmt dazu (${e2.event_geschafft})`);
/* Zurueckstellen fuer die Oberflaechenpruefung. */
await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen" },
kopf: { "Content-Type": "application/json" } });
}
/* =======================================================================
4. Die Oberflaeche
======================================================================= */
melde("\n=== 4. Die Karte im Browser ===");
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const klagen = [];
async function alsRolle(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
await ctx.route("**/*", (route) => {
const u = new URL(route.request().url());
if (u.hostname === "127.0.0.1") {
return route.continue({ headers: { ...route.request().headers(), host: HOST } });
}
return route.continue();
});
const seite = await ctx.newPage();
seite.on("pageerror", (f) => klagen.push("pageerror: " + f.message));
seite.on("console", (m) => { if (m.type() === "error") klagen.push("console: " + m.text().slice(0, 160)); });
/* WELCHE Datei fehlt -- die Konsolenmeldung sagt nur "404 (Not
Found)". Beim ersten Lauf standen hier drei davon, und sie waren
nicht zuzuordnen; eine Pruefung, die nicht sagen kann, woran sie
scheitert, kostet eine Stunde Suchen (Projektgedaechtnis,
11.09.2026). */
seite.on("response", (r) => {
if (r.status() >= 400) klagen.push(`HTTP ${r.status()}: ${new URL(r.url()).pathname}`);
});
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click('.rolle[data-rolle="' + rolle + '"]');
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
await seite.waitForSelector(".eintrag-karte", { timeout: 15000 });
await seite.waitForTimeout(700);
return { ctx, seite };
}
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
/* VIER SEIT DEM 07.10.2026: dazu kam „Glow Up Struktur" mit
echter Gliederung. Die Zahl steht hier als Bedingung und
nicht nur im Meldetext -- ein stiller Rueckgang waere sonst
unsichtbar (CLAUDE.md, 28.08.2026).
ZUGEKLAPPTE GRUPPEN ZAEHLEN MIT: „Vorbei" steht von sich aus
zu, aber `hidden` entfernt ein Element nicht aus dem Baum.
Waere das anders, muesste hier erst aufgeklappt werden. */
const karten = await seite.$$('.eintrag-karte[data-eventkarte="ja"]');
ok(karten.length === 4, `4 Eventkarten (${karten.length}) -- die Schulung ist keine`);
/* GEGENPROBE: Die Schulung bleibt eine gewoehnliche Karte. Ohne sie
waere "nur Events sind umgebaut" unbelegt. */
const schulung = await seite.$('.eintrag-karte:not([data-eventkarte="ja"])');
ok(!!schulung, "der Nicht-Event-Eintrag ist weiterhin eine gewoehnliche Karte");
const masse = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const b = k.querySelector(".ev-buehne");
const bild = k.querySelector(".ev-buehne__bild");
const t = k.querySelector(".eintrag-titel");
const kr = k.getBoundingClientRect();
const tr = t.getBoundingClientRect();
return {
karte: Math.round(kr.height),
buehne: b ? Math.round(b.getBoundingClientRect().height) : 0,
bildBreite: bild ? Math.round(bild.getBoundingClientRect().width) : 0,
buehneBreite: b ? Math.round(b.getBoundingClientRect().width) : 0,
titelGroesse: parseFloat(getComputedStyle(t).fontSize),
textGroesse: parseFloat(getComputedStyle(k.querySelector(".eintrag-text")).fontSize),
markeGroesse: parseFloat(getComputedStyle(k.querySelector(".marke-art")).fontSize),
/* Wie weit oben steht der Titel INNERHALB der Karte? Das war der
eigentliche Mangel: vorher unter 638 px Bild. */
titelInKarte: Math.round(tr.top - kr.top),
datumZeilen: [...k.querySelectorAll(".ev-spanne, .im-kalender, .event-zeitraum")]
.map((x) => x.textContent.trim()),
noch: k.querySelector(".ev-noch")?.textContent || null,
};
});
melde(" -- gemessen: " + JSON.stringify(masse));
/* ==== NUR MESSEN (07.10.2026): Breite, Ton und tote Regeln ==== */
const lage = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')];
const erste = k[0], zweite = k[1];
const s = getComputedStyle(erste);
const liste = document.getElementById("liste");
return {
kartenBreite: Math.round(erste.getBoundingClientRect().width),
listenBreite: Math.round(liste.getBoundingClientRect().width),
nebeneinander: zweite
? Math.abs(erste.getBoundingClientRect().top - zweite.getBoundingClientRect().top) < 5 : null,
ton: s.getPropertyValue("--ton").trim() || "(nicht gesetzt)",
randLinks: s.borderLeftWidth, randOben: s.borderTopWidth,
radius: s.borderTopLeftRadius,
anzeige: getComputedStyle(liste).display,
/* Wo haengt die Karte wirklich? Im 4000-Zeilen-Modul stehen drei
Formen (Kanalspalten, Abschnitte nach Art, Zeitstrahl) -- die
Antwort aus dem Quelltext zu raten waere Raten. */
eltern: (() => {
const weg = []; let n = erste.parentElement;
for (let i = 0; i < 4 && n; i++, n = n.parentElement) {
weg.push((n.tagName + "." + (n.className || "")).trim()
+ (n.id ? "#" + n.id : "") + ":" + getComputedStyle(n).display);
}
return weg;
})(),
geschwister: erste.parentElement
? [...erste.parentElement.children].map((c) => c.tagName + "." + (c.className || "").split(" ")[0])
: [],
/* DIE KARTEN, WO IMMER SIE STEHEN (berichtigt 07.10.2026).
Hier stand `liste.children`. Seit das Agentur-Brett in zwei
Spalten geteilt ist (links TikTok, rechts die Agentur), sind
die Kinder der Liste die SPALTEN -- die Karten haengen eine
Ebene tiefer. Gemessen kam `b: undefined` heraus, und die
Pruefung meldete einen Schaden an einer Stelle, an der
keiner war.
`querySelectorAll` statt `children`: Die Frage ist „wo
liegen die Karten", nicht „was haengt direkt unter der
Liste". Die erste Frage ueberlebt den naechsten Umbau. */
reihen: [...liste.querySelectorAll(".eintrag-karte")].map((c) => {
const r = c.getBoundingClientRect();
return {
event: c.dataset.eventkarte === "ja",
titel: (c.querySelector(".eintrag-titel")?.textContent || "?").slice(0, 18),
x: Math.round(r.x), y: Math.round(r.y), b: Math.round(r.width),
};
}),
};
});
melde(" -- Lage: " + JSON.stringify(lage));
/* ===== ZWEI NEBENEINANDER (07.10.2026) ============================
Filipe: „die soll aber auch garnicht so breit sein. es sollen immer
zwei neben einander stehen können."
Gemessen VOR dem Umbau: 1160 px breit, eine je Reihe. */
const events = lage.reihen.filter((r) => r.event);
const andere = lage.reihen.filter((r) => !r.event);
ok(events.length >= 2 && events.every((r) => r.b < lage.listenBreite * 0.55),
`jede Eventkarte ist hoechstens halb so breit wie die Liste `
+ `(${events.map((r) => r.b).join(", ")} von ${lage.listenBreite} px)`);
/* DASS SIE SCHMAL SIND, HEISST NOCH NICHT, DASS ZWEI NEBENEINANDER
STEHEN -- sie koennten auch untereinander in einer halben Spalte
haengen. Gemessen wird deshalb ein PAAR: zwei Karten, gleiche
Hoehe, verschiedene Seite.
SEIT DEM 07.10.2026 IST DAS AGENTUR-BRETT GETEILT: links die
Kampagnen von TikTok, rechts die Events der Agentur. „Zwei
nebeneinander" heisst hier also eine aus jeder Spalte -- und
genau deshalb traegt eines der Testevents oben eine
`kampagne_id`. Ohne sie staenden alle drei rechts, und diese
Zeile waere zu Recht rot. */
const paar = events.some((a, i) => events.slice(i + 1)
.some((b2) => Math.abs(a.y - b2.y) < 5 && Math.abs(a.x - b2.x) > 100));
ok(paar, `zwei Eventkarten stehen wirklich nebeneinander `
+ `(${events.map((r) => `${r.titel}@${r.x}/${r.y}`).join(" ")})`);
/* GEGENPROBE: Was KEIN Event ist, bleibt ueber die ganze Breite --
sonst waere „die Liste ist jetzt zweispaltig" ein Umbau aller acht
Bereiche und nicht der Eventkarten. */
ok(andere.length > 0 && andere.every((r) => r.b > lage.listenBreite * 0.95),
`die Nicht-Event-Karte behaelt die volle Breite (${andere.map((r) => r.b).join(", ")} px)`);
/* ===== DIE KARTE SAGT, OB DAS EVENT LAEUFT ======================== */
const toene = await seite.evaluate(() => {
const raus = {};
for (const k of document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')) {
raus[k.dataset.event] = getComputedStyle(k).getPropertyValue("--ton").trim();
}
return raus;
});
/* ==== NUR MESSEN: Wie sieht eine Karte OHNE Banner aus? ====
Im Quelltext steht „OHNE BANNER GIBT ES TROTZDEM EINE BUEHNE --
dann aus Farbe". Auf dem Bild vom 07.10.2026 war davon nichts zu
sehen: Die Karte ohne Bild fing direkt mit dem Titel an. */
const buehnenLage = await seite.evaluate(() => {
const raus = [];
for (const k of document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')) {
const b = k.querySelector(".ev-buehne");
raus.push({
titel: (k.querySelector(".eintrag-titel")?.textContent || "?").slice(0, 16),
mitbild: b?.dataset.mitbild || "nein",
buehneHoehe: b ? Math.round(b.getBoundingClientRect().height) : 0,
buehneGrund: b ? getComputedStyle(b).backgroundImage.slice(0, 30) : "-",
marke: (() => { const m = k.querySelector(".marke-art"); if (!m) return null;
const r = m.getBoundingClientRect(); const st = getComputedStyle(m);
return { text: m.textContent.trim(), b: Math.round(r.width), h: Math.round(r.height),
farbe: st.color, grund: st.backgroundColor, sicht: st.visibility, deck: st.opacity,
yImKarton: Math.round(r.top - k.getBoundingClientRect().top),
xImKarton: Math.round(r.left - k.getBoundingClientRect().left) }; })(),
});
}
return raus;
});
melde(" -- Buehnen: " + JSON.stringify(buehnenLage));
/* ===== NICHTS DARF AUS DER KARTE HERAUSLAUFEN (07.10.2026) ========
Die Karte traegt ein `clip-path` -- was oben herausragt, ist nicht
verschoben, sondern WEG. Genau das ist am 07.10.2026 passiert, als
die Karten halb so breit wurden: Das Etikett „Agentur-Events" sass
auf der Karte „September Rueckblick" bei y = -28 px.
WARUM DIE ALTE MESSUNG ES NICHT FAND: Sie fragte nach Farbe,
Groesse, Sichtbarkeit und Deckkraft -- und bekam auf alles die
richtige Antwort. Nach der LAGE hatte sie nie gefragt. Ein Element
kann vollstaendig „sichtbar" sein und trotzdem ausserhalb des
Bildes liegen.
Deshalb jetzt fuer JEDES Stueck jeder Eventkarte, nicht nur fuer
das Etikett: Die naechste Sache, die oben herausfaellt, ist
bestimmt eine andere. */
const ueberstand = await seite.evaluate(() => {
const raus = [];
for (const k of document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')) {
const kr = k.getBoundingClientRect();
for (const t of k.querySelectorAll("*")) {
const r = t.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (r.top < kr.top - 1 || r.left < kr.left - 1 || r.right > kr.right + 1) {
raus.push(`${(k.querySelector(".eintrag-titel")?.textContent || "?").slice(0, 14)}: `
+ `${(t.className || t.tagName).toString().split(" ")[0]} `
+ `oben ${Math.round(r.top - kr.top)}px / links ${Math.round(r.left - kr.left)}px`);
}
}
}
return raus;
});
const geprueftTeile = await seite.evaluate(() =>
[...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"] *')].length);
/* DIE ZAHL STEHT IN DER BEDINGUNG: "nichts ragt heraus" waere auf
einer leeren Liste ebenfalls wahr. */
ok(geprueftTeile > 30 && ueberstand.length === 0,
`keines der ${geprueftTeile} Teile ragt aus seiner Karte heraus (${ueberstand.slice(0, 2).join(" | ") || "keins"})`);
/* ===== UND DIE BUEHNE DARF NICHT GEQUETSCHT WERDEN ================
Dieselbe Sache eine Ebene tiefer: Die Buehne ist ein Flex-Element
der Karte und darf deshalb schrumpfen. Tut sie das, laeuft ihr
Inhalt unten heraus -- im Bildschirmfoto vom 07.10.2026 lag der
Zeitbalken auf dem Satz „Beschreibung zum Test." darunter.
Gemessen wird der Inhalt gegen den Kasten, nicht das Aussehen:
`scrollHeight > clientHeight` heisst „es passt nicht hinein", egal
ob man es sieht oder nicht. */
const gequetscht = await seite.evaluate(() =>
[...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"] .ev-buehne')]
.map((b) => ({
titel: (b.closest(".eintrag-karte").querySelector(".eintrag-titel")?.textContent || "?").slice(0, 14),
innen: b.scrollHeight, kasten: b.clientHeight,
stil: (() => {
const st = getComputedStyle(b);
return { h: st.height, maxH: st.maxHeight, minH: st.minHeight,
flex: st.flex, ar: st.aspectRatio, pad: st.paddingTop + "/" + st.paddingBottom };
})(),
kinder: [...b.children].map((c) =>
(c.className || c.tagName).toString().split(" ")[0] + ":"
+ Math.round(c.getBoundingClientRect().height)),
})));
melde(" -- Buehnen-Inhalt: " + JSON.stringify(gequetscht));
ok(gequetscht.length >= 3 && gequetscht.every((b) => b.innen <= b.kasten + 1),
`der Inhalt passt in jede der ${gequetscht.length} Buehnen `
+ `(${gequetscht.map((b) => `${b.titel} ${b.innen}/${b.kasten}`).join(", ")})`);
const tonListe = Object.values(toene);
ok(tonListe.length >= 3 && new Set(tonListe).size === tonListe.length,
`jeder Zustand traegt einen eigenen Ton (${JSON.stringify(toene)})`);
/* Zum PAAR scrollen, nicht einfach den Bildschirm fotografieren: Oben
stehen Kopf, Filter und die Vorschlaege -- auf einem Bild davon
waere von den zwei Karten nebeneinander nichts zu sehen. */
await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')];
const paar = k.find((a, i) => k.slice(i + 1).some((b2) =>
Math.abs(a.getBoundingClientRect().top - b2.getBoundingClientRect().top) < 5));
(paar || k[0]).scrollIntoView({ block: "start", behavior: "instant" });
window.scrollBy(0, -24);
});
await seite.waitForTimeout(400);
await seite.screenshot({ path: "server/pruef-eventkarte-raster.png", fullPage: false });
/* ===== „BANNER GANZ" ZEIGT ES IN DER MITTE (07.10.2026) ===========
Filipe: „wenn man auf banner anzeigen drückt soll das banner schön
in der mitte sein."
Vorher oeffnete der Knopf die DATEI in einem neuen Tab -- also die
eingebaute Bildanzeige des Browsers: Bild oben links, Rest schwarz.
Jetzt die Bildschau des Hauses, die genau dafuer schon existierte
und hier nur nie angeschlossen war. */
const vorherAdresse = seite.url();
const bannerKnopf = await seite.$(".ev-buehne__ganz");
ok(!!bannerKnopf, "die Karte mit Banner hat den Knopf „Banner ganz“");
await bannerKnopf.click();
/* AUF DAS BILD WARTEN, nicht auf eine Zahl: Vor dem Laden ist
`naturalWidth` null, und null mal null steht weder in der Mitte
noch sonst wo. */
await seite.waitForFunction(() => {
const b = document.querySelector("dialog.bildschau .bildschau__bild");
return b && b.naturalWidth > 0;
}, null, { timeout: 15000 }).catch(() => {});
await seite.waitForTimeout(400);
const schau = await seite.evaluate(() => {
const d = document.querySelector("dialog.bildschau");
if (!d) return { da: false };
const b = d.querySelector(".bildschau__bild");
const r = b.getBoundingClientRect();
return {
da: true, offen: d.open,
mitteX: Math.round(r.x + r.width / 2), mitteY: Math.round(r.y + r.height / 2),
fensterX: Math.round(window.innerWidth / 2), fensterY: Math.round(window.innerHeight / 2),
breite: Math.round(r.width), hoehe: Math.round(r.height),
grund: getComputedStyle(d.querySelector(".bildschau__grund")).backgroundImage.slice(0, 22),
};
});
melde(" -- Bildschau: " + JSON.stringify(schau));
ok(schau.da && schau.offen, `die Bildschau geht auf (${schau.da ? "offen=" + schau.offen : "gar nicht da"})`);
/* IN DER MITTE, in Pixeln gemessen. Waagerecht genau, senkrecht mit
etwas mehr Spiel: Oben liegt die Leiste mit Namen und Knoepfen,
das Bild sitzt deshalb ein Stueck tiefer als die halbe Hoehe. */
ok(schau.da && Math.abs(schau.mitteX - schau.fensterX) <= 8,
`das Banner steht waagerecht in der Mitte (${schau.mitteX} gegen ${schau.fensterX})`);
ok(schau.da && Math.abs(schau.mitteY - schau.fensterY) <= 60,
`und senkrecht ebenfalls (${schau.mitteY} gegen ${schau.fensterY})`);
ok(schau.da && schau.breite > 300 && schau.hoehe > 100,
`es ist wirklich gross (${schau.breite}x${schau.hoehe} px)`);
ok(schau.da && schau.grund.includes("gradient"),
`mit dem Hintergrund der Schau statt schwarzer Leere (${schau.grund}…)`);
/* GEGENPROBE ZUM ABFANGEN: Haette der Klick NICHT gegriffen, waere
der Browser zur Bilddatei gesprungen. Dass die Adresse dieselbe
ist, beweist, dass die Schau den Klick wirklich uebernommen hat --
und nicht nur zufaellig irgendwo im Dokument liegt. */
ok(seite.url() === vorherAdresse,
`die Seite ist dabei nicht weggesprungen (${seite.url() === vorherAdresse ? "dieselbe Adresse" : seite.url()})`);
await seite.screenshot({ path: "server/pruef-eventkarte-schau.png", fullPage: false });
await seite.keyboard.press("Escape");
await seite.waitForTimeout(300);
const zu = await seite.evaluate(() =>
!document.querySelector("dialog.bildschau")?.open);
ok(zu, "und Escape schliesst sie wieder");
/* DIE ZAHLEN AUS DEM BEFUND, jede einzeln. */
ok(masse.titelInKarte < 180,
`der Titel steht ${masse.titelInKarte} px unter der Kartenkante (vorher 638+, Grenze 180)`);
ok(masse.titelGroesse >= 18,
`Titel ${masse.titelGroesse} px (vorher 15,36 -- Grenze 18)`);
ok(masse.titelGroesse >= masse.textGroesse * 1.3,
`Titel deutlich groesser als der Text darunter (${masse.titelGroesse} vs ${masse.textGroesse})`);
/* Die projekteigene Grenze fuer Grossbuchstaben mit Sperrung. Sie
galt bisher nur unter 760 px Fensterbreite -- eine Grenze, die nur
bei schmalen Fenstern gilt, ist keine. */
ok(masse.markeGroesse >= 11.5,
`Etikett ${masse.markeGroesse} px (vorher 10,88 -- Grenze 11,5)`);
ok(masse.buehne <= 320 && masse.buehne > 0,
`Buehne ${masse.buehne} px hoch (vorher 638)`);
ok(masse.datumZeilen.length === 1,
`das Datum steht einmal, nicht dreimal: ${JSON.stringify(masse.datumZeilen)}`);
ok(/^noch \d+ Tage?$/.test(masse.noch || ""),
`der Countdown beantwortet "wie lange noch": ${JSON.stringify(masse.noch)}`);
/* Die drei Zustaende -- jeder einzeln nachgewiesen, nicht als Summe. */
const staende = await seite.evaluate(() => {
const raus = {};
for (const k of document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')) {
const t = k.querySelector(".eintrag-titel")?.textContent || "?";
raus[t] = { stand: k.dataset.event, wort: k.querySelector(".ev-noch")?.textContent };
}
return raus;
});
ok(staende["Oktober Event"]?.stand === "laeuft",
`laufend: ${JSON.stringify(staende["Oktober Event"])}`);
ok(staende["November Challenge"]?.stand === "kommt"
&& /^startet in \d+ Tagen$/.test(staende["November Challenge"]?.wort || ""),
`kommend: ${JSON.stringify(staende["November Challenge"])}`);
ok(staende["September Rückblick"]?.stand === "vorbei"
&& /^vorbei seit \d+ Tagen$/.test(staende["September Rückblick"]?.wort || ""),
`vorbei: ${JSON.stringify(staende["September Rückblick"])}`);
/* EIN VORBEIGELAUFENES EVENT WIRD NICHT MEHR ABGEHAKT -- ein Haken
waere eine Behauptung ueber etwas, das nicht mehr stattfindet. */
const knoepfeVorbei = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("September"));
return {
knoepfe: k.querySelectorAll(".ev-punkt__knopf").length,
zeilen: k.querySelectorAll(".ev-punkt").length,
};
});
ok(knoepfeVorbei.zeilen > 0 && knoepfeVorbei.knoepfe === 0,
`vorbei: ${knoepfeVorbei.zeilen} Zeilen lesbar, ${knoepfeVorbei.knoepfe} Knoepfe`);
/* DER WEG ZUM GANZEN BANNER. Er war beim ersten Entwurf verloren
gegangen: Vorher war das Bild ein Verweis, als Buehne nicht mehr --
und bei Filipes Banner steht die eigentliche Ansage IM Bild. */
const ganz = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const a = k.querySelector(".ev-buehne__ganz");
const t = k.querySelector(".eintrag-titel");
if (!a) return null;
const ar = a.getBoundingClientRect(), tr = t.getBoundingClientRect();
return {
ziel: a.getAttribute("href"),
neuesFenster: a.getAttribute("target") === "_blank" && /noopener/.test(a.getAttribute("rel") || ""),
wort: a.textContent.trim(),
/* Deckt er den Titel zu? Das haeufigste Problem bei einem Knopf,
der absolut in einer Ecke sitzt. */
ueberlappt: !(ar.bottom < tr.top || ar.top > tr.bottom || ar.right < tr.left || ar.left > tr.right),
hoehe: Math.round(ar.height),
};
});
ok(!!ganz && /^\/workspace\/api\/dateien\/\d+\/bild$/.test(ganz.ziel || ""),
`"Banner ganz" fuehrt zum vollen Bild: ${JSON.stringify(ganz)}`);
ok(!!ganz && ganz.neuesFenster, "und zwar in einem neuen Fenster, mit noopener");
ok(!!ganz && !ganz.ueberlappt, "er liegt nicht ueber dem Titel");
/* GEGENPROBE: Ohne Bild gibt es ihn nicht -- ein Knopf, der auf eine
Fehlerseite fuehrt, ist schlimmer als keiner. */
const ohneBild = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("November Challenge"));
return { mitbild: k.querySelector(".ev-buehne")?.dataset.mitbild || null,
knopf: k.querySelectorAll(".ev-buehne__ganz").length };
});
ok(ohneBild.mitbild === null && ohneBild.knopf === 0,
`ohne Banner kein Knopf: ${JSON.stringify(ohneBild)}`);
/* Der Loeschknopf unterscheidet sich jetzt von den anderen. */
const gefahr = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const l = k.querySelector(".schritt--gefahr");
const andere = k.querySelector(".eintrag-knoepfe .schritt:not(.schritt--gefahr)");
return l && andere
? { l: getComputedStyle(l).color, a: getComputedStyle(andere).color, text: l.textContent.trim() }
: null;
});
ok(!!gefahr && gefahr.text === "löschen" && gefahr.l !== gefahr.a,
`"löschen" sieht anders aus als die uebrigen Knoepfe: ${JSON.stringify(gefahr)}`);
await ctx.close();
}
/* =======================================================================
4b. SIEHT MAN DIE GLIEDERUNG? (07.10.2026)
Das ist der Abschnitt, der Filipes Bildschirmfoto beantwortet. Und
zwar MESSEND: „es gibt ein h5" beweist nichts -- ein h5, das
genauso aussieht wie der Absatz darunter, ist genau der Zustand,
der beanstandet wurde. Gemessen werden deshalb die berechneten
Werte und ihr UNTERSCHIED.
EIN GRUENER HAKEN, DER NICHTS MISST, IST DAS SCHLIMMERE VON ZWEIEN
(CLAUDE.md, 01.09.2026): Jede Bedingung hier enthaelt die Zahl der
tatsaechlich untersuchten Dinge, und zu jeder Aussage „so sieht es
aus" gehoert eine, die „und so NICHT" sagt.
======================================================================= */
melde("\n=== 4b. Die Gliederung auf dem Bildschirm ===");
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
/* ---- Die Kachel ist zu, und das ist der Zweck --------------- */
const zuerst = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
if (!k) return { fehlt: true };
const mehr = k.querySelector(".ev-mehr__inhalt");
const knopf = k.querySelector(".ev-mehr__knopf");
return {
hoeheZu: Math.round(k.getBoundingClientRect().height),
verborgen: !!mehr?.hidden,
gedrueckt: knopf?.getAttribute("aria-expanded"),
zahl: k.querySelector(".ev-mehr__zahl")?.textContent.trim() || null,
wort: k.querySelector(".ev-mehr__wort")?.textContent.trim() || null,
/* Was TROTZ zugeklapptem Kasten dasteht. */
gaben: [...k.querySelectorAll(".ev-block--gaben .ev-gabe")].map((g) => ({
name: g.querySelector(".ev-gabe__name")?.textContent.trim(),
coins: g.querySelector(".ev-gabe__coins")?.textContent.trim() || null,
zusatz: g.querySelector(".ev-gabe__zusatz")?.textContent.trim() || null,
})),
hinweise: [...k.querySelectorAll(".ev-hinweise .ev-hinweis")]
.map((h) => h.textContent.trim()),
/* Die Hinweise duerfen NICHT in der Abschnittsliste stehen --
genau das war der Mangel. */
hinweisInRegeln: k.querySelectorAll(".ev-abschnitte .ev-hinweis").length,
zusatz: k.querySelector(".ev-punkt__zusatz")?.textContent.trim() || null,
};
});
melde(" -- zu: " + JSON.stringify(zuerst));
ok(!zuerst.fehlt, "die gegliederte Kachel steht auf dem Brett");
ok(zuerst.verborgen === true && zuerst.gedrueckt === "false",
`der Infokasten ist von sich aus zu (hidden=${zuerst.verborgen}, `
+ `aria-expanded=${zuerst.gedrueckt})`);
ok(zuerst.wort === "Alle Infos" && /Abschnitt/.test(zuerst.zahl || ""),
`und der Knopf sagt, was dahinter steht: "${zuerst.wort} / ${zuerst.zahl}"`);
/* ---- Die Geschenke stehen OHNE Aufklappen da ---------------- */
ok(zuerst.gaben.length === 2,
`zwei Geschenke sind sofort sichtbar (${zuerst.gaben.length})`);
const ahorn = zuerst.gaben.find((g) => g.name === "Ahorn-Welt");
const feuer = zuerst.gaben.find((g) => g.name === "Feuerwerk");
ok(/12\.000/.test(ahorn?.coins || "") && /Coins/.test(ahorn?.coins || ""),
`der Coinpreis steht mit Tausenderpunkt da: ${JSON.stringify(ahorn?.coins)}`);
/* DIE GEGENPROBE ZUM 0-COINS-FEHLER, diesmal auf dem Bildschirm:
Ein Kampagnengeschenk ohne Preis darf KEINE Preisplakette
bekommen. „0 Coins" waere eine falsche Preisangabe. */
ok(feuer && feuer.coins === null,
`ein Geschenk ohne Preis zeigt keinen Preis: ${JSON.stringify(feuer)}`);
ok(feuer?.zusatz === "Sammle Feuerwerk in deinem LIVE!",
`stattdessen steht da, wie man es bekommt: ${JSON.stringify(feuer?.zusatz)}`);
/* ---- Die Hinweise stehen fuer sich ------------------------- */
ok(zuerst.hinweise.length === 2,
`die zwei Hinweise stehen sichtbar in ihrem eigenen Kasten (${zuerst.hinweise.length})`);
ok(zuerst.hinweisInRegeln === 0,
`und KEINER zwischen den Regeln (${zuerst.hinweisInRegeln}) -- das war der Mangel`);
/* ---- Die Beschreibung haengt unter der Aufgabe -------------- */
ok(/Erfülle sie jeden Tag neu/.test(zuerst.zusatz || "")
&& /05\.10\.2026/.test(zuerst.zusatz || ""),
`unter der Aufgabe stehen Beschreibung und Zeitraum: ${JSON.stringify(zuerst.zusatz)}`);
/* ---- Aufklappen: wird die Kachel laenger? ------------------- */
/* DIE RICHTIGE KACHEL (berichtigt 07.10.2026).
Hier stand `.eintrag-karte[data-eventkarte="ja"] .ev-mehr__knopf`
-- das traf die ERSTE Eventkarte („Oktober Event"), nicht die
gegliederte. Gemessen wurde danach eine Kachel, deren Kasten
weiterhin zu war: „spart 0 px", „0 px eingerueckt",
„Untertitel null". Drei rote Zeilen, kein Schaden am Code.
Das ist die Sorte Fehlalarm, die Vertrauen kostet (CLAUDE.md,
26.09.2026) -- deshalb wird die Kachel ueber ihren TITEL
gesucht, nicht ueber ihre Position. */
const knopfNr = await seite.evaluate(() => {
const alle = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')];
const i = alle.findIndex((x) => x.textContent.includes("Glow Up Struktur"));
return i;
});
ok(knopfNr >= 0, `die gegliederte Kachel ist Karte Nr. ${knopfNr}`);
await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
k.querySelector(".ev-mehr__knopf").click();
});
await seite.waitForTimeout(300);
const danach = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
const mehr = k.querySelector(".ev-mehr__inhalt");
const lies = (w) => {
const s = getComputedStyle(w);
return {
groesse: Math.round(parseFloat(s.fontSize) * 100) / 100,
gewicht: Number(s.fontWeight),
sperrung: s.letterSpacing,
gross: s.textTransform,
farbe: s.color,
text: w.textContent.trim().slice(0, 40),
};
};
const k2 = k.querySelector('.ev-abschnitt[data-stufe="2"] > .ev-abschnitt__titel');
const k3 = k.querySelector('.ev-abschnitt[data-stufe="3"] > .ev-abschnitt__titel');
const abs = k.querySelector(".ev-abschnitt .ev-absatz");
const frage = k.querySelector(".ev-abschnitt .ev-frage");
return {
hoeheAuf: Math.round(k.getBoundingClientRect().height),
verborgen: !!mehr?.hidden,
titel: [...k.querySelectorAll(".ev-abschnitt__titel")].map((h) => h.textContent.trim()),
stufe2: k2 ? lies(k2) : null,
stufe3: k3 ? lies(k3) : null,
absatz: abs ? lies(abs) : null,
frage: frage ? lies(frage) : null,
/* DER UNTERTITEL DES GESCHENKABSCHNITTS STEHT IM
GESCHENKBLOCK (berichtigt 07.10.2026).
Hier stand `.ev-abschnitt__unter`. „SAMMLE DEIN FEUERWERK
— Erhalte doppelte Punkte" ist aber genau der Abschnitt,
der aus den Regeln HERAUSGENOMMEN und nach oben gezogen
wird (damit die Geschenke ohne Aufklappen sichtbar sind).
Sein Untertitel heisst dort `.ev-gaben__unter`. Die
Pruefung suchte ihn an der Stelle, an der er absichtlich
nicht mehr steht.
BEIDE WERDEN JETZT GEMESSEN: `unter` fuer einen
gewoehnlichen Abschnitt (es gibt hier keinen mit
Untertitel, also null -- das ist in Ordnung) und
`gabenWoher`/`gabenUnter` fuer den, um den es geht. */
unter: k.querySelector(".ev-gaben__unter")?.textContent.trim() || null,
gabenWoher: (() => {
const p = k.querySelector(".ev-gaben__woher");
if (!p) return null;
const u = p.querySelector(".ev-gaben__unter");
const ganz = p.textContent.trim();
return u ? ganz.slice(0, ganz.length - u.textContent.trim().length).trim() : ganz;
})(),
/* DIE EINRUECKUNG WIRD AM TEXT GEMESSEN, NICHT AM KASTEN
(berichtigt 07.10.2026).
Hier stand der Abstand der beiden ABSCHNITTE. Der ist 0,
und das ist richtig: Beide sind volle Kinder derselben
Spalte, die Einrueckung entsteht durch `padding-left` --
sie verschiebt den INHALT, nicht das Rechteck. Gemessen
kam „0 px eingerueckt" heraus, obwohl der Text sichtbar
eingerueckt war.
Was man sehen soll, ist der Text. Also wird der gemessen. */
einzug: (() => {
const h3 = k.querySelector('.ev-abschnitt[data-stufe="3"] > .ev-abschnitt__titel');
const h2 = k.querySelector('.ev-abschnitt[data-stufe="2"] > .ev-abschnitt__titel');
return h3 && h2
? Math.round(h3.getBoundingClientRect().x - h2.getBoundingClientRect().x)
: null;
})(),
steck: [...k.querySelectorAll(".ev-steck__name")].map((x) => x.textContent.trim()),
steckWerte: [...k.querySelectorAll(".ev-steck__wert")].map((x) => x.textContent.trim()),
};
});
melde(" -- auf: " + JSON.stringify(danach).slice(0, 700));
ok(danach.verborgen === false,
"ein Druck macht den Infokasten auf");
/* DAS IST DIE ANTWORT AUF „die kacheln sollen nicht so lang sein":
nicht „sie ist kurz", sondern „sie ist MESSBAR kuerzer als mit
allem offen". Eine Kachel, die zugeklappt genauso hoch ist, hat
nichts gespart. */
ok(danach.hoeheAuf > zuerst.hoeheZu + 80,
`zugeklappt spart die Kachel ${danach.hoeheAuf - zuerst.hoeheZu} px `
+ `(${zuerst.hoeheZu} statt ${danach.hoeheAuf})`);
/* ---- Und jetzt: SIEHT man die Ueberschrift? ----------------- */
ok(danach.titel.length >= 4,
`${danach.titel.length} Ueberschriften stehen da: ${JSON.stringify(danach.titel.slice(0, 6))}`);
ok(!!danach.stufe2 && !!danach.absatz,
"Ueberschrift und Absatz sind beide da (sonst misst der Vergleich nichts)");
/* DREI UNTERSCHIEDE, NICHT EINER. Ein einziger (etwa nur die
Farbe) wuerde bei einem anderen Farbschema wieder verschwinden. */
const u2 = danach.stufe2 || {};
const ua = danach.absatz || {};
const unterschiede = [
["Gewicht", u2.gewicht, ua.gewicht],
["Sperrung", u2.sperrung, ua.sperrung],
["Grossschreibung", u2.gross, ua.gross],
["Farbe", u2.farbe, ua.farbe],
].filter(([, a, b]) => a !== b);
ok(unterschiede.length >= 3,
`die Ueberschrift unterscheidet sich in ${unterschiede.length} von 4 Merkmalen `
+ `vom Absatz: ${JSON.stringify(unterschiede)}`);
ok(u2.gewicht >= 700 && ua.gewicht < 700,
`sie ist fett, der Absatz nicht (${u2.gewicht} gegen ${ua.gewicht})`);
ok(u2.gross === "uppercase" && ua.gross === "none",
`sie steht in Grossbuchstaben, der Absatz nicht (${u2.gross} / ${ua.gross})`);
/* Die Unterstufe sieht anders aus als die Oberstufe UND anders
als ein Absatz -- sonst waere die zweite Stufe keine. */
ok(!!danach.stufe3, "eine Unterueberschrift ist da");
const u3 = danach.stufe3 || {};
ok(u3.gross === "none" && u3.gewicht >= 600,
`die Unterstufe ist halbfett und NICHT in Grossbuchstaben `
+ `(${u3.gewicht}, ${u3.gross})`);
ok(u3.gewicht > ua.gewicht,
`aber immer noch kraeftiger als ein Absatz (${u3.gewicht} gegen ${ua.gewicht})`);
ok(danach.einzug >= 8,
`und sie ist ${danach.einzug} px eingerueckt (Grenze 8)`);
/* Der Untertitel hinter dem Gedankenstrich steht getrennt -- und
die Ueberschrift der Kampagne bleibt erhalten. „Sammle dein
Feuerwerk" sagt, WARUM diese Geschenke zaehlen; ein selbst
erfundenes „Geschenke" daneben haette das verschluckt. */
ok(danach.gabenWoher === "SAMMLE DEIN FEUERWERK",
`die Ueberschrift der Kampagne steht ueber den Geschenken: `
+ `${JSON.stringify(danach.gabenWoher)}`);
ok(danach.unter === "Erhalte doppelte Punkte für die Rangliste",
`der Untertitel steht als eigene Zeile: ${JSON.stringify(danach.unter)}`);
/* Die Frage ist kraeftiger als der Absatz unter ihr. */
ok(!!danach.frage && danach.frage.gewicht > ua.gewicht,
`eine Frage mit Antwort darunter steht kraeftiger da `
+ `(${danach.frage?.gewicht} gegen ${ua.gewicht}): ${JSON.stringify(danach.frage?.text)}`);
/* Der Steckbrief -- deutsche Namen, keine Rohfelder. */
ok(danach.steck.includes("Kurznummer") && danach.steck.includes("Markt"),
`der Steckbrief nennt die Felder auf Deutsch: ${JSON.stringify(danach.steck)}`);
ok(danach.steckWerte.includes("1139900164"),
`und zeigt die Werte: ${JSON.stringify(danach.steckWerte)}`);
ok(!danach.steck.some((n) => /_|activity|flag/i.test(n)),
"kein Rohfeldname steht auf der Kachel");
/* ---- Die eigene Wahl haelt das Neuladen aus ---------------- */
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForSelector(".ev-mehr__knopf", { timeout: 15000 });
await seite.waitForTimeout(600);
const nachher = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
return { verborgen: !!k?.querySelector(".ev-mehr__inhalt")?.hidden };
});
/* WARUM DAS WICHTIG IST: Das Brett baut sich bei jeder
Aktualisierung neu auf. Eine Kachel, die dabei zuklappt,
waehrend man liest, ist aergerlicher als eine lange. */
ok(nachher.verborgen === false,
"aufgeklappt bleibt aufgeklappt, auch nach dem Neuladen");
/* ---- Die Gruppen der Spalten ------------------------------- */
const gruppen = await seite.evaluate(() => {
const seiten = [...document.querySelectorAll(".kv-seite")].map((s) => ({
seite: s.dataset.seite,
gruppen: [...s.querySelectorAll(".ev-gruppe__kopf")].map((g) => ({
name: g.dataset.gruppe,
wort: g.querySelector(".ev-gruppe__wort")?.textContent.trim(),
zahl: g.querySelector(".ev-gruppe__zahl")?.textContent.trim(),
zu: g.nextElementSibling?.hidden === true,
})),
}));
return { seiten };
});
melde(" -- Gruppen: " + JSON.stringify(gruppen));
const alleGruppen = gruppen.seiten.flatMap((s) => s.gruppen);
ok(alleGruppen.length >= 3,
`${alleGruppen.length} Gruppen stehen in den Spalten`);
const laeuft = alleGruppen.filter((g) => g.name === "laeuft");
const vorbei = alleGruppen.filter((g) => g.name.startsWith("vorbei"));
ok(laeuft.length >= 1 && laeuft.every((g) => !g.zu),
`„Laeuft gerade" steht offen (${laeuft.length})`);
ok(vorbei.length >= 1 && vorbei.every((g) => g.zu),
`und Vergangenes zu (${vorbei.length}: `
+ `${vorbei.map((g) => g.wort).join(", ")})`);
/* KEINE LEERE GRUPPE. Fuenf Ueberschriften ueber nichts waeren
schlimmer als eine lange Spalte. */
ok(alleGruppen.every((g) => Number(g.zahl) > 0),
`jede Gruppe hat Eintraege: ${JSON.stringify(alleGruppen.map((g) => g.zahl))}`);
/* UND DIE SUMME STIMMT: Keine Karte faellt zwischen zwei Gruppen
durch. Ohne diese Zeile waere „es gibt Gruppen" vereinbar damit,
dass ein Event in keiner davon auftaucht. */
const summe = alleGruppen.reduce((s, g) => s + Number(g.zahl), 0);
ok(summe === 4, `alle 4 Events stehen in genau einer Gruppe (Summe ${summe})`);
/* GEGENPROBE: Links und rechts teilen sich den Zustand NICHT.
Dieser Fehler war bei den Zeitstufen schon einmal da.
ERST DRUECKEN, DANN NACHSEHEN (berichtigt 07.10.2026). Vorher
stand hier `... || merker.length === 0` -- und weil nie
jemand eine Gruppe angefasst hatte, war die Liste leer und
die Zeile gruen, ohne etwas gemessen zu haben. Genau die
Sorte Haken, die immer bestaetigt (CLAUDE.md, 01.09.2026).
Jetzt wird eine Gruppe LINKS zugeklappt und danach geprueft,
dass (1) ein Merker entstanden ist, (2) er die Seite nennt
und (3) die gleichnamige Gruppe RECHTS davon unberuehrt ist. */
const vorher = await seite.evaluate(() => {
const links = document.querySelector('.kv-seite[data-seite="tiktok"] .ev-gruppe__kopf');
const name = links?.dataset.gruppe;
const rechts = [...document.querySelectorAll('.kv-seite[data-seite="agentur"] .ev-gruppe__kopf')]
.find((g) => g.dataset.gruppe === name);
links?.click();
return {
name,
linksZu: links?.nextElementSibling?.hidden === true,
rechtsDa: !!rechts,
rechtsZu: rechts ? rechts.nextElementSibling?.hidden === true : null,
};
});
const merker = await seite.evaluate(() => Object.keys(localStorage)
.filter((s) => s.startsWith("abschnitt:agentur:")));
melde(" -- nach dem Druck: " + JSON.stringify({ ...vorher, merker }));
ok(merker.length > 0 && merker.every((s) => /:(tiktok|agentur):/.test(s)),
`der Merker nennt die Seite: ${JSON.stringify(merker)}`);
/* DIESE ZEILE WAR SELBST FALSCH (berichtigt 07.10.2026).
Hier stand `!s.includes(":agentur:")`. Der Merker heisst aber
`abschnitt:agentur:tiktok:laeuft` -- und „agentur" darin ist der
BRETTNAME, nicht die Seite. Die Zeile war damit nie erfuellbar.
Zwei Dinge heissen gleich und sind verschieden; genau dieser
Fehler stand am 07.10.2026 schon einmal im Projekt (das Feld
„Gilt für" nennt das HAUS, nicht das Brett).
Gemessen wird jetzt die Form des Schluessels GANZ: genau einer,
und er nennt die angefasste Seite. Das ist die Aussage, auf die
es ankommt -- der Zustand ist je Seite abgelegt, also kann ein
Druck links rechts nichts bewirken. */
ok(merker.length === 1 && merker[0] === `abschnitt:agentur:tiktok:${vorher.name}`,
`genau ein Merker, fuer die angefasste Seite: ${JSON.stringify(merker)}`);
/* UND DIE ANDERE SEITE STEHT NOCH SO DA WIE VORHER. Gemessen an
der Gruppe, die es dort gibt -- „Kommt noch" war offen und muss
offen bleiben, obwohl links gerade zugeklappt wurde. */
const andereSeite = await seite.evaluate(() => {
const g = [...document.querySelectorAll('.kv-seite[data-seite="agentur"] .ev-gruppe__kopf')]
.map((x) => ({ name: x.dataset.gruppe, zu: x.nextElementSibling?.hidden === true }));
return g;
});
const kommt = andereSeite.find((g) => g.name === "kommt");
ok(!!kommt && kommt.zu === false,
`rechts ist nichts mitgeklappt: ${JSON.stringify(andereSeite)}`);
ok(vorher.linksZu === true,
`die angefasste Gruppe links ist zu (${vorher.name})`);
await ctx.close();
}
melde("\n=== 5. Abhaken im Browser, mit Neuladen ===");
{
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 1280);
/* DEN ZAEHLER DER RICHTIGEN KARTE LESEN.
Beim ersten Lauf stand hier `seite.textContent(".ev-zaehler")` --
das nimmt den ERSTEN auf der Seite, und der gehoerte zur November-
Challenge ("0 von 1"). Die Pruefung meldete daraufhin FEHL, obwohl
am Zaehler nichts falsch war. Eine Pruefung, die das Falsche misst,
kostet dieselbe Zeit wie ein echter Fehler. */
const OKT = '.eintrag-karte[data-eventkarte="ja"]:has(.eintrag-titel:text-is("Oktober Event"))';
const zaehlerLesen = async () =>
(await seite.textContent(OKT + " .ev-zaehler") || "").trim();
const vorher = await zaehlerLesen();
ok(/^\d+ von \d+$/.test(vorher), `Zaehler steht da: ${JSON.stringify(vorher)}`);
/* Luna hat aus Teil 2 noch einen Haken -- also 1 von 3. */
ok(vorher === "1 von 3", `Lunas Stand vom Server: ${vorher}`);
const knoepfe = await seite.$$(OKT + " .ev-punkt__knopf");
ok(knoepfe.length === 3, `${knoepfe.length} abhakbare Punkte an diesem Event`);
/* Den NICHT bereits abgehakten nehmen -- sonst zaehlt der Klick
herunter statt herauf, und die Pruefung misst das Gegenteil. */
await knoepfe[0].click();
await seite.waitForTimeout(600);
const danach = await zaehlerLesen();
ok(danach === "2 von 3", `nach dem Klick: ${danach}`);
/* HAELT ES EINEN SEITENWECHSEL AUS? Eine Anzeige, die nur im
Browser zaehlt, sieht genauso aus -- und ist nichts wert. */
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForSelector(".ev-zaehler", { timeout: 15000 });
await seite.waitForTimeout(500);
const nachNeuladen = await zaehlerLesen();
ok(nachNeuladen === "2 von 3", `nach dem Neuladen immer noch: ${nachNeuladen}`);
/* Und wieder ab. */
const k2 = await seite.$$(OKT + " .ev-punkt__knopf");
await k2[0].click();
await seite.waitForTimeout(600);
const ab = await zaehlerLesen();
ok(ab === "1 von 3", `wieder abgewaehlt: ${ab}`);
await ctx.close();
}
melde("\n=== 6. Das Formular ===");
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
await seite.click("#neu-oeffnen");
await seite.waitForTimeout(250);
await seite.selectOption("#f-art", "kampagne");
await seite.waitForTimeout(350);
const editoren = await seite.$$(".zeileneditor");
ok(editoren.length === 2, `zwei Zeilen-Editoren (Aufgaben, Regeln): ${editoren.length}`);
/* DIE TEXTGESTALTUNGS-LEISTE GEHOERT ZUM VERBORGENEN FELD und darf
nicht allein unter der Punkteliste schweben. */
const leisten = await seite.evaluate(() => {
const raus = [];
for (const id of ["f-aufgaben", "f-regeln"]) {
const f = document.getElementById(id);
const l = f?.parentElement?.querySelector(".textform");
raus.push({ id, feldVerborgen: !!f?.hidden, leisteVerborgen: l ? !!l.hidden : null });
}
return raus;
});
ok(leisten.length === 2 && leisten.every((x) => x.feldVerborgen && x.leisteVerborgen === true),
`Leiste ist mit ihrem Feld verborgen: ${JSON.stringify(leisten)}`);
/* Platzhalter passend zum Feld -- "22 Live-Stunden" unter REGELN
waere eine Fehlanleitung. */
const halter = await seite.evaluate(() =>
[...document.querySelectorAll(".zeileneditor")].map((r) =>
r.querySelector(".zeileneditor__feld")?.placeholder || ""));
ok(halter.length === 2 && halter[0] !== halter[1]
&& /Live-Stunden/.test(halter[0]) && /Konto/.test(halter[1]),
`je Feld ein eigenes Beispiel: ${JSON.stringify(halter)}`);
await seite.fill("#f-titel", "Testevent");
await seite.fill("#f-datum", tag(0));
await seite.fill("#f-ende", tag(13));
await seite.waitForTimeout(250);
const dauer = (await seite.textContent("#hinweis-dauer") || "").trim();
ok(dauer === "Läuft 14 Tage.", `Dauer wird mitgerechnet: ${JSON.stringify(dauer)}`);
/* GEGENPROBE: Ein Ende VOR dem Anfang muss auffallen -- sonst stuende
auf der frischen Karte "vorbei seit 3 Tagen". */
await seite.fill("#f-ende", tag(-3));
await seite.waitForTimeout(250);
const warnung = (await seite.textContent("#hinweis-dauer") || "").trim();
const warnMerkmal = await seite.getAttribute("#hinweis-dauer", "data-warnung");
ok(/vor dem Anfang/.test(warnung) && warnMerkmal === "ja",
`verdrehter Zeitraum faellt auf: ${JSON.stringify(warnung)}`);
await seite.fill("#f-ende", tag(13));
await seite.waitForTimeout(250);
/* Die Vorschau zeigt die echte Buehne. */
const vorschau = await seite.evaluate(() => {
const v = document.getElementById("ev-vorschau");
return {
sichtbar: v ? !v.hidden : false,
buehne: !!v?.querySelector(".ev-buehne"),
titel: v?.querySelector(".eintrag-titel")?.textContent || null,
noch: v?.querySelector(".ev-noch")?.textContent || null,
};
});
ok(vorschau.sichtbar && vorschau.buehne && vorschau.titel === "Testevent"
&& /^noch \d+ Tage?$/.test(vorschau.noch || ""),
`Vorschau zeigt die echte Buehne: ${JSON.stringify(vorschau)}`);
/* DER KERN DES FORMULAR-UMBAUS: Was am Ende gespeichert wird, ist
gleichmaessig -- egal wie jemand tippt. */
const felder = await seite.$$(".zeileneditor__feld");
await felder[0].fill("Jeden Tag Live gehen");
await seite.click(".zeileneditor__mehr");
const f2 = await seite.$$(".zeileneditor__feld");
await f2[1].fill("22 Live-Stunden erreichen");
await seite.waitForTimeout(250);
const roh2 = await seite.inputValue("#f-aufgaben");
ok(roh2 === "· Jeden Tag Live gehen\n· 22 Live-Stunden erreichen",
`gleichmaessig gesetzte Zeichen: ${JSON.stringify(roh2)}`);
/* Und das Ganze wirklich abschicken -- bis auf die Karte. */
await seite.click("#neu-speichern");
await seite.waitForTimeout(1200);
/* IM BRETT SUCHEN, NICHT IM FORMULAR.
Beim ersten Lauf stand hier ein Suchlauf ueber ALLE Karten der
Seite -- und die VORSCHAU im Formular ist ebenfalls eine Karte mit
demselben Merkmal und demselben Titel. Gefunden wurde sie zuerst,
und sie hat naturgemaess keine Aufgabenliste. Die Pruefung meldete
einen Fehler, den es nicht gab. */
const neu = await seite.evaluate(() => {
const liste = document.getElementById("liste");
const k = [...liste.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Testevent"));
return k ? {
punkte: [...k.querySelectorAll(".ev-block--aufgaben .ev-punkt__text")].map((x) => x.textContent),
zaehler: k.querySelector(".ev-zaehler")?.textContent || null,
} : null;
});
ok(!!neu && neu.punkte.length === 2
&& neu.punkte[0] === "Jeden Tag Live gehen"
&& neu.punkte[1] === "22 Live-Stunden erreichen",
`aus dem Editor wurde die Liste auf der Karte: ${JSON.stringify(neu)}`);
ok(neu?.zaehler === "0 von 2", `und der Zaehler steht auf null: ${neu?.zaehler}`);
/* NACH DEM SPEICHERN IST DIE VORSCHAU LEER. Sonst stuende beim
naechsten "Neuer Eintrag" das vorige Event darin -- und man
glaubte, es sei nicht gespeichert worden. */
const nachher = await seite.evaluate(() => {
const v = document.getElementById("ev-vorschau");
return { verborgen: !!v?.hidden, inhalt: (v?.textContent || "").trim().length };
});
ok(nachher.verborgen && nachher.inhalt === 0,
`die Vorschau ist zurueckgesetzt: ${JSON.stringify(nachher)}`);
await ctx.close();
}
melde("\n=== 7. Auf dem Handy (412 px) ===");
{
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 412);
const handy = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const knopf = k.querySelector(".ev-punkt__knopf");
const b = k.querySelector(".ev-buehne");
return {
buehne: Math.round(b.getBoundingClientRect().height),
knopfHoehe: knopf ? Math.round(knopf.getBoundingClientRect().height) : 0,
/* Laeuft etwas ueber den Rand? Das haeufigste Handy-Problem. */
ueberlauf: Math.round(k.scrollWidth - k.clientWidth),
bloeckeSpalten: getComputedStyle(k.querySelector(".ev-bloecke")).gridTemplateColumns,
ganzHoehe: (() => { const a = k.querySelector(".ev-buehne__ganz");
return a ? Math.round(a.getBoundingClientRect().height) : null; })(),
ganzBreite: (() => { const a = k.querySelector(".ev-buehne__ganz");
return a ? Math.round(a.getBoundingClientRect().width) : null; })(),
};
});
melde(" -- gemessen: " + JSON.stringify(handy));
ok(handy.knopfHoehe >= 44,
`das Beruehrziel ist ${handy.knopfHoehe} px hoch (Grenze 44)`);
/* ===== AUF DEM HANDY STEHT WIEDER EINE JE REIHE (07.10.2026) ======
Die Gegenprobe zum Raster: Zwei Karten nebeneinander sind am
Rechner richtig und auf 412 px unbedienbar -- dort waere jede
199 px breit. Dass die Regel UMSCHALTET, muss gemessen werden;
sonst beweist „zwei nebeneinander" am Rechner nur, dass es dort
passt, und nichts darueber, was auf dem Telefon passiert. */
/* GEMESSEN WIRD, WAS MAN SIEHT (berichtigt 07.10.2026).
Hier standen ALLE Eventkarten. Seit die Spalten nach Zustand
und Alter gegliedert sind, steht „Vorbei — aelter" von sich
aus zugeklappt -- und eine Karte in einem `hidden`-Kasten hat
ein Rechteck von 0x0 an Position 0,0. Gemessen kam
„379, 379, 379, 379, 0 px" heraus, und die Pruefung meldete
eine zu schmale Karte, obwohl keine zu schmal war.
ZWEI SCHAEDEN, NICHT EINER: Die Paarprobe daneben haette bei
ZWEI zugeklappten Karten „zwei auf gleicher Hoehe" gemeldet --
beide liegen auf y=0. Das waere ein Fehlalarm, der erst
auftritt, wenn ein zweites Event vorbeigelaufen ist, also
irgendwann von selbst. Solche Fehlalarme kosten das Vertrauen
in den ganzen Satz (CLAUDE.md, 26.09.2026).
UND DAS VERSCHWINDEN WIRD SELBST GEPRUEFT: Eine unsichtbare
Karte darf nur deshalb unsichtbar sein, weil ihre Gruppe zu
ist. Ohne diese zweite Haelfte haette „wir messen nur die
sichtbaren" genau den Fall weggeschaut, in dem eine Karte
wirklich fehlt. */
const schmal = await seite.evaluate(() => {
const alle = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.map((x) => {
const r = x.getBoundingClientRect();
const gruppe = x.closest(".ev-gruppe__inhalt");
return {
x: Math.round(r.x), y: Math.round(r.y), b: Math.round(r.width),
sichtbar: r.width > 0 && r.height > 0,
inZu: !!(gruppe && gruppe.hidden),
};
});
const k = alle.filter((a) => a.sichtbar);
return {
karten: k,
versteckt: alle.filter((a) => !a.sichtbar),
/* Stehen zwei auf gleicher Hoehe? Dann waere nicht umgeschaltet. */
paar: k.some((a, i) => k.slice(i + 1).some((b2) => Math.abs(a.y - b2.y) < 5)),
quer: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
ok(schmal.karten.length >= 2 && !schmal.paar,
`auf 412 px steht eine Karte je Reihe (${schmal.karten.map((a) => `${a.b}px@${a.y}`).join(", ")})`);
ok(schmal.karten.every((a) => a.b > 330),
`und sie ist breit genug zum Bedienen (${schmal.karten.map((a) => a.b).join(", ")} px)`);
ok(schmal.versteckt.every((a) => a.inZu),
`was man nicht sieht, steckt in einer zugeklappten Gruppe `
+ `(${schmal.versteckt.length} verborgen, davon `
+ `${schmal.versteckt.filter((a) => a.inZu).length} erklaert)`);
ok(schmal.quer <= 1,
`die Seite laesst sich nicht seitlich schieben (${schmal.quer} px)`);
/* UND WENN DOCH, DANN WELCHES ELEMENT? Eine Zahl allein schickt
einen auf die Suche; der Schuldige steht im DOM und wird nur nicht
genannt. (Projektgedaechtnis: eine Pruefung, die nicht sagen kann,
woran sie scheitert, kostet eine Stunde.) */
if (handy.ueberlauf > 1) {
const taeter = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const kr = k.getBoundingClientRect();
return [...k.querySelectorAll("*")]
.map((e) => ({ was: e.className || e.tagName,
rechts: Math.round(e.getBoundingClientRect().right - kr.right) }))
.filter((x) => x.rechts > 0)
.sort((a, b) => b.rechts - a.rechts).slice(0, 5);
});
melde(" -- was ueberlaeuft: " + JSON.stringify(taeter)
+ (taeter.length === 0
? " (KEIN Element -- dann ist es ein ::before/::after, das"
+ " querySelectorAll nicht findet. Beim ersten Mal war es"
+ " .ev-buehne__inhalt::before mit festem -20px gegen 14px Polsterung.)"
: ""));
}
ok(handy.ueberlauf <= 1, `nichts laeuft ueber den Rand (${handy.ueberlauf} px)`);
ok(handy.bloeckeSpalten.split(" ").length === 1,
`auf 412 px eine Spalte, nicht zwei: ${handy.bloeckeSpalten}`);
ok(handy.buehne <= 240 && handy.buehne > 0, `Buehne ${handy.buehne} px`);
/* Auf dem Handy schrumpft "Banner ganz" auf die Lupe -- das Ziel
darf dabei nicht unter 32 px fallen. (32 statt 44: Er sitzt frei
in einer Ecke ohne Nachbarn, und sein Wort bleibt fuer
Vorleseprogramme erhalten.) */
ok(handy.ganzHoehe !== null && handy.ganzHoehe >= 32 && handy.ganzBreite >= 32,
`der Banner-Knopf bleibt greifbar: ${handy.ganzBreite} x ${handy.ganzHoehe} px`);
await ctx.close();
}
melde("\n=== 8. Das andere Haus bleibt unberuehrt ===");
{
/* DIE REGEL VOM 24.09.2026: "Ab sofort wird JEDER Umbau getrennt
gefuehrt. Eine Aenderung an einem der beiden Haeuser darf im
anderen nichts bewirken ... Wer etwas baut, sagt vorher, fuer
WELCHES Haus es ist, und weist danach nach, dass das andere
unveraendert ist."
DAS IST HIER NICHT SELBSTVERSTAENDLICH: `bereich.css` wird von
JEDEM Brett geladen, auch von denen der Crew, und `tuKnopf` ist
die Knopffabrik aller Bretter. Beides wurde angefasst. Dass
trotzdem nur Agentur-Events betroffen sind, haengt daran, dass
jede neue Regel an `[data-eventkarte="ja"]` oder einer `ev-`
Klasse haengt -- und dieses Merkmal setzt nur istEventArt().
Eine Behauptung, solange sie niemand nachmisst. */
const ctx = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
await ctx.route("**/*", (route) => {
const u = new URL(route.request().url());
if (u.hostname === "127.0.0.1") {
return route.continue({ headers: { ...route.request().headers(), host: "crew.dogfather-universe.com" } });
}
return route.continue();
});
const seite = await ctx.newPage();
seite.on("pageerror", (f) => klagen.push("crew pageerror: " + f.message));
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 });
/* Das Brett der Agentur gibt es auf dieser Adresse gar nicht. */
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const dort = await seite.evaluate(() => ({
adresse: location.pathname,
eventkarten: document.querySelectorAll('[data-eventkarte="ja"]').length,
evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
}));
/* ==== EIN BEFUND, DER NICHT AUS DIESEM UMBAU STAMMT ===============
Hier stand zuerst die Zusage "auf der Crew-Adresse nichts davon" --
und sie wurde rot: Das Agentur-Brett ist dort erreichbar, mit
Inhalt. Vor dem Schreiben einer Reparatur erst nachgemessen, ob
ich das verursacht habe (03.10.2026, server/_mess/haus.mjs, einmal
mit und einmal ohne den Umbau):
Host crew.dogfather-universe.com haus=crew
/workspace/api/bereich/agentur -> 200, 1 Eintrag
ZWEIMAL DASSELBE, mit und ohne. Der Durchgriff ist also aelter als
dieser Umbau und gehoert zur Regel vom 24.09.2026 ("Kein Bestand
wird geteilt"), nicht hierher -- er ist Filipe gemeldet und wird
getrennt entschieden. Ihn hier nebenbei zuzumauern hiesse, am
Rechtesystem beider Haeuser zu drehen, waehrend eigentlich eine
Karte umgebaut wird.
WAS DIESE DATEI DESHALB ZUSAGT, ist das, wofuer sie zustaendig
ist: Der Umbau aendert an der Crew-Adresse NICHTS -- die Karten
sehen dort aus wie auf der Agenturadresse, und die eigenen Bretter
der Crew bleiben unberuehrt (gleich darunter).
Die Zahl wird trotzdem ausgegeben: Faellt sie eines Tages auf 0,
ist der Durchgriff behoben, und das soll man hier sehen. */
melde(" -- Crew-Adresse, Brett `agentur`: " + JSON.stringify(dort)
+ (dort.eventkarten > 0
? " (vorbestehender Durchgriff, siehe Kommentar -- nicht aus diesem Umbau)"
: " (der Durchgriff ist behoben)"));
/* Wenn das Brett dort erscheint, dann wenigstens vollstaendig und
ohne Fehler -- eine halb gebaute Karte waere ein echter Schaden. */
ok(dort.eventkarten === 0 || dort.evTeile > 0,
`was dort erscheint, ist vollstaendig gebaut (${dort.eventkarten} Karten, ${dort.evTeile} Teile)`);
/* Und ein Brett, das es dort WIRKLICH gibt, sieht aus wie immer.
Ohne diese Haelfte waere oben nur bewiesen, dass die Seite leer
ist -- das waere sie auch bei einem kaputten Brett. */
await seite.goto(BASIS + "/workspace/bereich.html?b=treff", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const treff = await seite.evaluate(() => ({
liste: !!document.getElementById("liste"),
formular: !!document.getElementById("neu"),
knoepfe: document.querySelectorAll(".schritt").length,
gefahr: document.querySelectorAll(".schritt--gefahr").length,
evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
}));
ok(treff.liste && treff.formular,
`ein Crew-Brett steht und funktioniert: ${JSON.stringify(treff)}`);
ok(treff.evTeile === 0, `und zeigt keinen Teil des Event-Umbaus (${treff.evTeile})`);
/* Auch der Loeschknopf bleibt dort der alte: Die Markierung wird
zwar ueberall gesetzt, aber gestaltet wird sie nur innerhalb einer
Eventkarte. */
const farben = await seite.evaluate(() => {
const g = document.querySelector(".schritt--gefahr");
const n = document.querySelector(".schritt:not(.schritt--gefahr)");
return g && n ? { gefahr: getComputedStyle(g).color, normal: getComputedStyle(n).color } : null;
});
ok(farben === null || farben.gefahr === farben.normal,
`der Loeschknopf sieht dort aus wie immer: ${JSON.stringify(farben)}`);
await ctx.close();
}
await browser.close();
melde("\n=== Browser-Meldungen ===");
ok(klagen.length === 0, klagen.length ? "Klagen: " + klagen.join(" | ") : "keine Fehler in der Konsole");
melde("");
melde(`${geprueft} geprueft, ${fehler} Fehler`);
if (!geprueft) { melde("ABBRUCH: nichts geprueft -- das ist ein Fehler, kein Erfolg."); process.exit(3); }
process.exit(fehler ? 1 : 0);