Files
dogfather-universe/server/mess-reaktion.mjs
T
DogFatherGitandClaude Opus 5 96bdd79a91 Die Dogen-Muenzen: drei Saetze zu je drei Stufen
Filipe, 28.09.2026: "ich werde die drei varianten schicken fuer
niedrige spenden. mittlere und hohe spenden. alle drei varianten will
ich drauf so dass ich sie aussuchen kann wie ich will. aber pass sie
sofort diesen 3 kategorien an."

DIE ZUORDNUNG IST GELESEN, NICHT GERATEN

In allen drei Saetzen geht es nacktes Metall -> Steinkranz ->
Vollbesatz; Filipes Dateinummern (01/02/03) und die Uhrzeiten seiner
Entwuerfe laufen genau mit. Klassik: Palladium, Gold mit Saphir,
Diamant. Amethyst: Stahl, Rosegold, Vollbesatz. Neon: Chrom auf
Schwarz, Steinkranz, Vollbesatz.

AUS 25 MB WURDEN 443 KB

Je Bild 320 px WebP statt 1254 px PNG, Faktor 58. Die Zahl ist
gemessen: Groesster Fall in der Seite 110 px (Karte, hoechste Stufe),
auf der OBS-Tafel 189 px, bei doppelter Bildschirmdichte rund 220.
Drei Megabyte fuer ein 24-Pixel-Zeichen waeren ein Ladebalken mitten
in der Sendung -- wer auf dem Handy zusieht, bekaeme die Karte, wenn
sie schon wieder weg ist. Die Originale liegen neben den
Datenbanksicherungen, nicht im Repo: 26 MB, die bei jedem Klon
mitkaemen und die niemand ausliefert.

WELCHE MUENZE WANN -- ABGELEITET STATT GEPFLEGT

"Niedrig, mittel, hoch" gibt es im Haus schon: die Stufenleiter. Zwei
eigene Grenzen daneben waeren eine zweite Antwort auf dieselbe Frage,
und spaetestens beim Verschieben einer Stufe zeigte die Muenze etwas
anderes an als der Name auf der Karte. Die Leiter wird deshalb in
DRITTEL geteilt -- bei den drei Werksstufen genau eine je Muenze, bei
sechs zwei je Muenze, bei einer einzigen ueberall die mittlere.

DIE MUENZE STEHT AUCH GROSS AUF DER KARTE

Sonst haette Filipe neun Bilder fuer ein 24-Pixel-Zeichen gezeichnet.
"dogen" ist dafuer eine neue Vorlage neben Herz, Welle und Krone --
und die drei Werksstufen bekommen sie EINMAL zugeteilt, nur wo noch
die Werksvorlage steht und kein eigenes Bild hochgeladen ist. Wer
danach ein Herz zurueckstellt, findet es morgen nicht wieder als
Muenze vor: Eine Einstellung, die sich gegen den Benutzer durchsetzt,
wird abgeschafft.

Und steht sie gross da, nimmt das Stilblatt die kleine neben der Zahl
weg -- dieselbe Muenze in zwei Groessen auf einer Karte sieht aus wie
ein Versehen.

AM BILDSCHIRMFOTO NACHGEBESSERT

Bei 0,92em blieb an den Spendenknoepfen ein 13,5-Pixel-Fleck uebrig;
bei einem flachen Symbol reicht das, bei einer Muenze mit Pfote,
Schriftzug und Steinen nicht. Jetzt 1,05em ueberall und 1,6em auf den
Knoepfen. In der Auswahl sahen "Amethyst" und "Neon" bei 40 px
praktisch gleich aus -- und genau sie auseinanderzuhalten ist der
Zweck dieser Liste. Jetzt 56/64/72 px. Und was gewaehlt ist, steht
als WORT da: Neben neun glaenzenden Muenzen geht ein ruhiger Rahmen
unter, und wer Farben schlecht unterscheidet, sieht ihn gar nicht.

EIN SATZWECHSEL ERREICHT ALLE

Die Karten trugen ihren Satz immer selbst mit und waren richtig. Die
Muenzen an den Spendenknoepfen und beim eigenen Stand aber nicht --
die holt eine Seite nur bei einer Spende neu. Eine Einstellung, die
nur dort ankommt, wo sie gemacht wurde, ist keine Einstellung des
Hauses.

ZWEI BEFUNDE GEGEN DIE PRUEFUNG SELBST

1. Sie verlangte "erste Grenze ECHT kleiner als zweite". Bei genau
   zwei Stufen fallen beide absichtlich zusammen -- sie meldete einen
   Fehler, wo das Verhalten richtig ist.
2. Sie prueft die Muenzverteilung jetzt auf einer FRISCHEN Datenbank.
   Vorher lief sie auf der, die ein frueherer Abschnitt umgebaut
   hatte: Eine Pruefung, die den eigenen Kollateralschaden misst,
   misst sich selbst. Dazu eine Gegenprobe, dass eine selbst
   gewaehlte Vorlage NICHT ueberschrieben wird.

UND DREI AN DER MESSUNG

Sie suchte noch die gezeichnete Muenze (svg), wo jetzt ein Bild
liegt. Umgestellt nicht auf "ist ein img da", sondern auf
`naturalWidth > 0` -- ob es etwas ZEIGT. Ein img, das 404 antwortet,
steht genauso im Dokument wie eines, das laedt; auf dem Bildschirm
ist an seiner Stelle nichts. Und beim Satzwechsel las sie die vorige
Karte, die noch acht Sekunden stand: Die Karten laufen in einer
Schlange, also wird auf das Merkmal gewartet, das nur die neue hat.

pruef-spenden 167/0 (vorher 136) - pruef-reaktion 321/0 -
pruef-css-klassen ok - pruef-tippziele 11/0 -
mess-reaktion und mess-buehne ohne Beanstandung

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 19:36:05 +02:00

2100 lines
93 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
WIE DIE REACTION AUSSIEHT (28.09.2026)
Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
schaltet sie durch alle drei Zustaende und legt Bilder ab.
Gemessen wird dabei auch, was man sonst uebersieht:
- laeuft etwas in die Konsole?
- ragt etwas seitlich hinaus?
- ist der Saal so hoch wie das Fenster, oder muss man scrollen?
NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
Zertifikat -- und am Ende ist alles weg.
Aufrufen mit: node server/mess-reaktion.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { request } from "node:http";
import { execFileSync } from "node:child_process";
import { createServer as httpsServer } from "node:https";
const HIER = dirname(fileURLToPath(import.meta.url));
const ordner = mkdtempSync(join(tmpdir(), "mess-reakt-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const PORT = 5483;
const HTTPS_PORT = PORT + 1;
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
for (const art of ["uncaughtException", "unhandledRejection"]) {
process.on(art, (f) => {
console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
process.exit(8);
});
}
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, 1000));
const CREW = "crew.dogfather-universe.com";
/* ---------- Leute ------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const kenn = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kenn, new Date().toISOString());
const ALT = new Date(Date.now() - 200 * 86400000).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,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", kenn).update(code).digest("hex"), ALT);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const CODES = {
admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001",
modi: "CODE-M03-000001", linke: "CODE-M04-000001",
};
const IDS = {
admin: anlegen("DogFather", "admin", CODES.admin),
gast: anlegen("Nachtfalter", "gast", CODES.gast),
gast2: anlegen("Lenny", "gast", CODES.gast2),
/* Fuer die Moderation. Sie ist der Teil, bei dem ein Fehler am
meisten kostet -- also wird sie im Browser gemessen und nicht
nur ueber die Schnittstelle. */
modi: anlegen("Miss", "modi", CODES.modi),
linke: anlegen("Funny", "linke", CODES.linke),
};
d.close();
function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
return new Promise((fertig, schief) => {
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad, method,
headers: {
Host: CREW, Origin: `https://${CREW}`,
...(keks ? { Cookie: keks } : {}),
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
},
}, (antwort) => {
const teile = [];
antwort.on("data", (s) => teile.push(s));
antwort.on("end", () => {
const text = Buffer.concat(teile).toString("utf8");
let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle) {
const a = await roh("/workspace/api/anmelden", {
method: "POST",
rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true },
});
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
const k = {
admin: await anmelden("admin"),
gast: await anmelden("gast"),
gast2: await anmelden("gast2"),
modi: await anmelden("modi"),
linke: await anmelden("linke"),
};
/* ---------- Die Buehne fuellen ------------------------------------------ */
await roh("/workspace/api/reaktion", {
method: "PUT", keks: k.admin,
rumpf: {
titel: "Wir schauen den neuen Trailer",
video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
},
});
/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
for (const adresse of ["https://youtu.be/9bZkp7q19f0",
"https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
await roh("/workspace/api/reaktion/liste", {
method: "POST", keks: k.admin, rumpf: { video: adresse },
});
}
const REDEN = [
["gast", "Bin schon daaa 🐺"],
["gast2", "Ich auch! Hab extra Popcorn geholt"],
["gast", "Wie lange noch?"],
["admin", "Zwei Minuten, ich richte noch die Kamera"],
/* DAS TEAM SCHREIBT MIT. Ohne einen Beitrag von einer linken Hand
und einem Modi liesse sich gar nicht messen, ob die Abzeichen
ankommen -- und eine Messung, die ihren Gegenstand nicht
herstellt, misst nichts. */
["linke", "Ich pass auf den Chat auf, schreibt ruhig"],
["modi", "Und ich auch 🙂"],
["gast2", "Kein Stress, wir sitzen bequem"],
["gast", "Letztes Mal war so gut, ich hab geheult"],
];
/* ---------- Der Vorbau -------------------------------------------------- */
const schluesselDatei = join(ordner, "p.key");
const zertDatei = join(ordner, "p.crt");
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
"-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
const vorbau = httpsServer({
key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
}, (von, nach) => {
const weiter = request({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
}, (antwort) => {
nach.writeHead(antwort.statusCode || 502, antwort.headers);
antwort.pipe(nach);
});
weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
von.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
const BASIS = `https://${CREW}:${HTTPS_PORT}`;
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
/* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
Aufnahme aus, als waere die Kamera kaputt. */
"--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
});
/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
schliesst, nimmt den spaeteren den Platz weg.
GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
ein Messfehler, der wie ein Programmfehler aussah. */
async function alsWer(rolle, breite, hoehe) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
ignoreHTTPSErrors: true,
permissions: ["camera", "microphone"],
});
await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
const [n, ...r] = z.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
const anfragen = [];
seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("response", (a) => {
if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
});
return { kontext, seite, konsole, anfragen };
}
async function bild(seite, name) {
await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
console.log(` Bild: server/mess-reaktion-${name}.png`);
}
async function vermessen(seite, wo) {
const m = await seite.evaluate(() => ({
ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
stand: document.getElementById("saal")?.dataset.stand || "?",
schild: document.getElementById("schild-wort")?.textContent || "",
}));
console.log(` ${wo}: Stand „${m.stand}", Schild „${m.schild}", `
+ `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
return m;
}
console.log("\n=== ZU ===");
{
const { kontext, seite, konsole } = await alsWer("gast", 1440, 900);
await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
await vermessen(seite, "Rechner");
await bild(seite, "1-zu-rechner");
if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
await kontext.close();
}
console.log("\n=== VORBEREITUNG ===");
await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } });
for (const [wer, text] of REDEN) {
await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } });
}
{
const { kontext, seite, konsole } = await alsWer("gast", 1440, 900);
await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
await vermessen(seite, "Rechner");
await bild(seite, "2-warte-rechner");
if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
await kontext.close();
}
console.log("\n=== LIVE ===");
await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
await roh("/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 } });
/* ==== BEIDE SEITEN GLEICHZEITIG ====================================
Der Host sendet, die Zuschauerin empfaengt -- das geht nur, wenn
beide zur selben Zeit offen sind. */
const host = await alsWer("admin", 1440, 900);
await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
await host.seite.waitForTimeout(2500);
const gast2 = await alsWer("gast2", 1280, 800);
await gast2.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
await gast2.seite.waitForTimeout(2500);
const zuschauer = await alsWer("gast", 1440, 900);
await zuschauer.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
/* Verbindungen brauchen ein paar Sekunden: Adressen holen, Angebot,
Antwort, Kandidaten. Acht Sekunden sind grosszuegig -- es geht
darum, ob es UEBERHAUPT ankommt, nicht wie schnell. */
await zuschauer.seite.waitForTimeout(8000);
async function bildBreiten(seite) {
return seite.evaluate(() => {
const raus = {};
for (const k2 of document.querySelectorAll("#kameras .kamera")) {
const v = k2.querySelector("video");
raus[k2.querySelector(".kamera__name")?.textContent || k2.dataset.wer] = {
breite: v ? v.videoWidth : -1,
strom: !!(v && v.srcObject),
laeuft: !!(v && !v.paused),
stumm: !!(v && v.muted),
};
}
return raus;
});
}
console.log("\n --- Fliesst ein Bild? ---");
for (const [wer, w] of [["Host", host.seite], ["Gast", gast2.seite], ["Zuschauerin", zuschauer.seite]]) {
const z = await w.evaluate(() => ({
url: location.pathname,
saal: !!document.getElementById("saal"),
stand: document.getElementById("saal")?.dataset.stand,
kameras: document.querySelectorAll("#kameras .kamera").length,
})).catch((f) => ({ url: "FEHLER " + f.message }));
console.log(` ${wer.padEnd(12)} ${z.url} saal=${z.saal} stand=${z.stand} kameras=${z.kameras}`);
const q = { Host: host, Gast: gast2, Zuschauerin: zuschauer }[wer];
console.log(" Strom-Anfragen: "
+ q.anfragen.filter((x) => x.includes("strom")).length + " | Signale: " + q.anfragen.filter((x) => x.includes("signal")).length);
}
const beiZuschauer = await bildBreiten(zuschauer.seite);
for (const [name, m] of Object.entries(beiZuschauer)) {
const gut = m.breite > 0;
console.log(` Zuschauerin sieht ${name.padEnd(12)} `
+ `${gut ? "BILD " + m.breite + " px" : (m.strom ? "Strom da, aber 0 px" : "NICHTS")}`
+ ` ${m.laeuft ? "laeuft" : "STEHT"} ${m.stumm ? "stumm" : "mit Ton"}`);
}
/* AUCH DER HOST MUSS SEINEN GAST SEHEN. Wenn nur die Zuschauer
etwas sehen, redet der Host gegen eine schwarze Kachel -- und
merkt nicht, wenn beim Gast die Kamera aus ist. */
/* LAEUFT DAS VIDEO UEBERHAUPT? Dieselbe Falle wie bei den Kameras:
Etwas, das Ton macht, darf nicht von allein losgehen. */
let videoLaeuft = 0;
for (const [wer, w] of [["Host", host.seite], ["Zuschauerin", zuschauer.seite]]) {
const v = await w.evaluate(() => ({
stand: document.getElementById("video-platz")?.dataset.spieler || "(nichts)",
rahmen: !!document.querySelector("#video-platz iframe"),
}));
console.log(` Video bei ${wer.padEnd(12)} Rahmen=${v.rahmen} Zustand=${v.stand}`);
if (v.stand === "laeuft" || v.stand === "puffert") videoLaeuft++;
}
const beimHost = await bildBreiten(host.seite);
for (const [name, m] of Object.entries(beimHost)) {
console.log(` Host sieht ${name.padEnd(12)} `
+ `${m.breite > 0 ? "BILD " + m.breite + " px" : "NICHTS"}`
+ ` ${m.laeuft ? "laeuft" : "STEHT"}`);
}
const zaehlt = Object.values(beiZuschauer).filter((m) => m.breite > 0).length;
console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kommen an.`);
/* DIESE MESSUNG HAT EINEN RUECKGABEWERT -- und zwar wegen dieser
einen Zahl. Alles andere kann gruen sein: Die Schnittstelle
antwortet, die Seite sieht gut aus, die Verbindung steht. Wenn
hier 0 steht, sitzt trotzdem jeder vor einem schwarzen Rechteck.
Gemessen wurde genau das zweimal: einmal, weil das Angebot
hinausging, bevor der Empfaenger zuhoeren konnte -- einmal, weil
ein Video mit Ton nicht von allein startet. Beide Male sahen die
Bildschirmfotos plausibel aus. */
let ausgang = 0;
/* LAEUFT DAS VIDEO? Ein Zuschauer hat keine Bedienung -- startet es
nicht von selbst, sitzt er eine Stunde vor einem Standbild und
kann nichts dagegen tun. Gemessen war genau das der Fall. */
if (videoLaeuft < 2) {
console.log(` ACHTUNG: Das Video laeuft nur bei ${videoLaeuft} von 2.`);
ausgang = 1;
}
const beimHostAn = Object.values(beimHost).filter((m) => m.breite > 0).length;
if (beimHostAn < Object.keys(beimHost).length) {
console.log(" ACHTUNG: Der Host sieht nicht alle Kameras.");
ausgang = 1;
}
if (!zaehlt) {
console.log(" ACHTUNG: Es kommt kein einziges Bild an.");
ausgang = 1;
}
const stehen = [...Object.values(beiZuschauer), ...Object.values(beimHost)]
.filter((m) => m.breite > 0 && !m.laeuft).length;
if (stehen) {
console.log(` ACHTUNG: ${stehen} Bild(er) kommen an, stehen aber still `
+ "-- ein Video mit Ton startet nicht von allein.");
ausgang = 1;
}
/* ---- Die Regie ---------------------------------------------------
NICHT UMSCHALTEN, SONDERN HERSTELLEN. Ein Klick auf „Griff" heisst
„das Gegenteil von dem, was gerade ist" -- und setzt damit voraus,
dass die Messung den Zustand vorher kennt. Genau daran ist sie
einmal gescheitert: Seit das Pult aufgeklappt startet, hat der
Klick es ZUgemacht, und auf dem Bild war von der Regie nichts zu
sehen. */
console.log("");
console.log(" --- Die Regie ---");
await host.seite.evaluate(() => {
const p = document.getElementById("pult");
if (p && p.dataset.auf !== "ja") document.getElementById("pult-griff")?.click();
});
await host.seite.waitForTimeout(900);
const regie = await host.seite.evaluate(() => {
const t = (id) => document.getElementById(id)?.textContent?.trim() || "";
const sicht = (w) => {
const e = document.querySelector(w);
if (!e) return false;
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
return {
auf: document.getElementById("pult")?.dataset.auf,
lampe: t("pult-wort"),
uhr: t("pult-zeit"),
dabei: t("z-dabei"),
sicht_zahl: t("z-sicht"),
last: t("z-last"),
spur: t("spur-titel"),
zeit: t("v-zeit"),
reiter: document.querySelectorAll("#reiter .reiter__knopf").length,
tafeln: document.querySelectorAll(".pult-tafel").length,
tafel_sichtbar: [...document.querySelectorAll(".pult-tafel")].filter((e) => !e.hidden).length,
regler: document.querySelectorAll("#regler .regler").length,
pegel: document.querySelectorAll("#regler .pegel").length,
knoepfe_sichtbar: ["p-vorbereiten", "p-starten", "p-beenden"]
.filter((id) => sicht("#" + id)).length,
/* WOHER KOMMT EIN UEBERLAUF? Die blosse Zahl „197 Pixel zum
Scrollen" schickt einen ins Raten. Die Hoehen daneben zeigen
in einer Zeile, welcher Kasten zu gross ist. */
hoehen: (() => {
const h = (w) => {
const e = document.querySelector(w);
return e ? Math.round(e.getBoundingClientRect().height) : -1;
};
return `fenster ${window.innerHeight} · saal ${h("#saal")}`
+ ` · kino ${h("#teil-live")} · pult ${h("#pult")}`
+ ` (leiste ${h(".pult__leiste")} + innen ${h("#pult-innen")})`
+ ` · tafeln sichtbar ${[...document.querySelectorAll(".pult-tafel")]
.filter((e) => e.getBoundingClientRect().height > 0).length}`
+ ` · innen rollt ${document.getElementById("pult-innen").scrollHeight}`;
})(),
kinder: [...document.getElementById("pult-innen").children].map((e) => {
const r = e.getBoundingClientRect();
const c = getComputedStyle(e);
return `${e.className || e.tagName}:${Math.round(r.top)}+${Math.round(r.height)}`
+ `/${c.display}/${c.position}`;
}).join(" "),
};
});
console.log(" " + regie.hoehen);
console.log(" " + regie.kinder);
console.log(` Lampe „${regie.lampe}" Uhr ${regie.uhr} `
+ `Sehen zu ${regie.dabei} mit Bild ${regie.sicht_zahl} Upload ${regie.last}`);
console.log(` Spur: „${regie.spur}" ${regie.zeit}`);
console.log(` ${regie.reiter} Register, ${regie.tafel_sichtbar} Tafel offen, `
+ `${regie.regler} Regler mit ${regie.pegel} Pegeln, `
+ `${regie.knoepfe_sichtbar}/3 grosse Knoepfe sichtbar`);
/* JEDE DIESER ZAHLEN KANN STILL FALSCH SEIN -- und genau dann sieht
ein Bildschirmfoto trotzdem gut aus. Deshalb werden sie geprueft
und nicht nur ausgegeben. */
if (regie.auf !== "ja") {
console.log(" ACHTUNG: Die Regie liess sich nicht aufklappen.");
ausgang = 1;
}
if (!/^\d+:\d\d/.test(regie.uhr) || regie.uhr === "0:00") {
console.log(` ACHTUNG: Die Laufzeit steht still (${regie.uhr}).`);
ausgang = 1;
}
if (!/kbit\/s|Mbit\/s/.test(regie.last)) {
console.log(` ACHTUNG: Der Upload wird nicht gemessen (${regie.last}).`);
ausgang = 1;
}
/* GEZAEHLT, NICHT FESTGESCHRIEBEN. Vorher stand hier „genau 5" --
und beim sechsten Register wurde die Messung rot, obwohl nichts
kaputt war. Was zaehlt, ist: zu jedem Reiter gehoert eine Tafel,
und genau eine ist offen. */
if (regie.reiter < 5 || regie.reiter !== regie.tafeln || regie.tafel_sichtbar !== 1) {
console.log(` ACHTUNG: Die Register stimmen nicht (${regie.reiter} Reiter, `
+ `${regie.tafeln} Tafeln, ${regie.tafel_sichtbar} offen).`);
ausgang = 1;
}
if (regie.regler !== 4 || regie.pegel !== 4 || regie.knoepfe_sichtbar !== 3) {
console.log(" ACHTUNG: Regler, Pegel oder die grossen Knoepfe fehlen.");
ausgang = 1;
}
/* ---- Kamera aus und Chat-Stufe ------------------------------------
Beides ist erst gebaut, wenn es beim ZUSCHAUER ankommt. */
console.log("");
console.log(" --- Kamera aus, Chat zu ---");
await host.seite.click("#s-kamera").catch(() => {});
await host.seite.waitForTimeout(1200);
const nachKamera = await zuschauer.seite.evaluate(() => {
const f = document.querySelector('#kameras .kamera[data-kamera="aus"]');
return {
schild: f ? f.querySelector(".kamera__aus")?.textContent?.trim() : null,
wessen: f ? f.querySelector(".kamera__name")?.textContent?.trim() : null,
videoVersteckt: f ? getComputedStyle(f.querySelector("video")).opacity === "0" : false,
};
});
console.log(` Zuschauerin sieht: „${nachKamera.schild || "(nichts)"}" `
+ `bei ${nachKamera.wessen || "(niemandem)"}, Bild verdeckt=${nachKamera.videoVersteckt}`);
if (nachKamera.schild !== "Kamera aus" || !nachKamera.videoVersteckt) {
console.log(" ACHTUNG: Das abgeschaltete Bild kommt nicht als Schild an.");
ausgang = 1;
}
await host.seite.click("#s-kamera").catch(() => {});
await host.seite.waitForTimeout(900);
await host.seite.click('#chat-stufen .stufe[data-modus="team"]').catch(() => {});
await host.seite.waitForTimeout(1200);
const nachChat = await zuschauer.seite.evaluate(() => {
const f = document.getElementById("chat-feld");
return { gesperrt: !!f?.disabled, grund: f?.placeholder || "",
stufe: document.getElementById("schiene")?.dataset.chat };
});
console.log(` Zuschauerin: Feld gesperrt=${nachChat.gesperrt} `
+ `Grund „${nachChat.grund}" Stufe=${nachChat.stufe}`);
/* GEGENPROBE AM SERVER. Ein ausgegrautes Feld haelt niemanden auf,
der die Schnittstelle kennt -- also wird auch dort nachgefragt. */
const trotzdem = await roh("/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast, rumpf: { text: "geht das trotzdem?" },
});
console.log(` ... und der Server lehnt ab: ${trotzdem.code}/${trotzdem.j?.fehler}`);
if (!nachChat.gesperrt || !nachChat.grund.includes("Team") || trotzdem.code !== 403) {
console.log(" ACHTUNG: Die Chat-Stufe kommt nicht an.");
ausgang = 1;
}
await host.seite.click('#chat-stufen .stufe[data-modus="offen"]').catch(() => {});
await host.seite.waitForTimeout(900);
/* ---- Die Spenden -------------------------------------------------
Der ganze Weg: eintragen -> Karte laeuft bei allen. Melden ->
wartet. Bestaetigen -> laeuft. Und eine unbestaetigte Meldung
darf NICHT im Bild stehen. */
console.log("");
console.log(" --- Spenden ---");
/* Damit es Betragsknoepfe gibt, braucht die Unterstuetzen-Seite eine
PayPal.me-Adresse. Sie wird ueber den echten Weg gesetzt -- eine
Zeile direkt in die Datenbank zu schreiben wuerde genau die
Pruefungen umgehen, um die es geht. */
const wege = await roh("/workspace/api/unterstuetzung", { keks: k.admin });
const ppWeg = (wege.j?.wege || wege.j?.liste || []).find((w) => w.schluessel === "paypal");
if (ppWeg) {
await roh(`/workspace/api/unterstuetzung/${ppWeg.id}`, {
method: "PUT", keks: k.admin,
rumpf: {
ziel: "https://paypal.me/probehase", knopf: "Dogi was ausgeben", sichtbar: true,
},
});
}
const spLage = await roh("/workspace/api/spenden", { keks: k.gast });
console.log(` Betragsknoepfe: ${(spLage.j?.knoepfe || []).map((b) => b.ziel.split("/").pop()).join(", ") || "(keine)"}`);
if ((spLage.j?.knoepfe || []).length !== 4) {
console.log(" ACHTUNG: Die Betragsknoepfe fehlen.");
ausgang = 1;
}
/* DIE ZUSCHAUERSEITE WIRD NEU GELADEN. Sie wurde geoeffnet, BEVOR
die PayPal-Adresse gesetzt war -- ohne sie gibt es keinen
Geben-Kasten, und der ist ab hier das Gemessene. Ein Neuladen ist
dabei nicht geschummelt, sondern der Normalfall: So kommt jeder
an, der spaeter dazustoesst. */
await zuschauer.seite.reload({ waitUntil: "domcontentloaded" });
await zuschauer.seite.waitForTimeout(1800);
/* 1. Von Hand eintragen -- die Karte muss bei der Zuschauerin laufen. */
await zuschauer.seite.evaluate(() => { window.__karten = []; });
await zuschauer.seite.evaluate(() => {
const t = document.getElementById("spendentafel");
new MutationObserver((m) => {
for (const e of m) {
for (const n of e.addedNodes) {
if (n.querySelector) {
window.__karten.push({
/* NUR DIE ZAHL, NICHT DIE GANZE ZEILE. Seit dem
28.09. steht neben ihr ein Zeichen und das Wort
„Dogen"; `textContent` klebte beides zusammen
(„2,5Dogen"), und eine Suche mit regulaerem Ausdruck
darin traefe frueher oder spaeter das Falsche. */
betrag: n.querySelector(".dogen__zahl")?.textContent?.trim(),
wort: n.querySelector(".dogen__wort")?.textContent?.trim(),
wer: n.querySelector(".spendenkarte__wer")?.textContent?.trim(),
stufe: n.querySelector(".spendenkarte__stufe")?.textContent?.trim(),
gruss: n.querySelector(".spendenkarte__gruss")?.textContent?.trim(),
/* Welche Muenze gross auf der Karte steht -- und ob die
kleine daneben weggenommen wurde. Dieselbe Muenze in
zwei Groessen auf einer Karte saehe aus wie ein
Versehen. */
muenze: (n.querySelector(".spendenkarte__bild img")
?.getAttribute("src") || "").split("/").pop(),
kleinWeg: n.dataset?.muenze === "ja",
});
}
}
}
}).observe(t, { childList: true });
});
await roh("/workspace/api/spenden", {
method: "POST", keks: k.admin,
rumpf: { dogen_mille: 2500, name: "Nachtfalter", gruss: "Fuer das Rudel!" },
});
await zuschauer.seite.waitForFunction(
() => document.querySelector('.spendenkarte[data-da="ja"]'), null, { timeout: 8000 },
).catch(() => {});
await zuschauer.seite.waitForTimeout(700);
await bild(zuschauer.seite, "10-spendenkarte");
/* ==== 2. DER ZUSCHAUER GIBT UND SCHREIBT SELBST ==================
Filipe: „der text der dazu erscheint sollen die leute selber
schreiben koennen ... es sollen personalisierte texte sein von den
leuten selbst."
Gemessen wird der WEG DURCH DIE OBERFLAECHE und nicht der Aufruf
dahinter: Ein Knopf, den man drueckt, ein Feld, das man ausfuellt,
ein Text, der ankommt. Der Aufruf allein haette nie gezeigt, ob
das Feld ueberhaupt existiert.
Und dieselbe Meldung beantwortet die alte Frage gleich mit: Sie
ist UNBESTAETIGT und darf deshalb NICHT im Bild stehen. */
const geben = await zuschauer.seite.evaluate(() => {
const k2 = document.getElementById("geben");
const knoepfe = [...document.querySelectorAll(".geben__betrag")];
return {
da: k2 && !k2.hidden,
knoepfe: knoepfe.length,
zahlen: knoepfe.map((a) => a.querySelector(".dogen__zahl")?.textContent || "?"),
/* ==== DAS ZEICHEN MUSS DA SEIN -- UND GELADEN ==============
Seit dem 28.09. ist es ein BILD (Filipes Muenzen) und keine
Zeichnung mehr. Gezaehlt wird deshalb nicht, ob ein Knoten da
ist, sondern ob er etwas ZEIGT: Ein `img`, das 404 antwortet,
steht genauso im Dokument wie eines, das laedt -- und auf dem
Bildschirm ist an seiner Stelle nichts.
`naturalWidth > 0` ist der Unterschied, und er ist genau der,
den man sonst erst im Livestream sieht. */
zeichen: knoepfe.filter((a) => {
const b = a.querySelector(".dogen__zeichen");
if (!b) return false;
return b.tagName.toLowerCase() === "img" ? b.naturalWidth > 0 : true;
}).length,
/* Und nirgends ein Euro. */
geld: /€|EUR|Euro/.test(k2?.textContent || ""),
};
});
console.log(` Geben-Kasten: ${geben.knoepfe} Knoepfe (${geben.zahlen.join(" · ")}), `
+ `${geben.zeichen} mit Zeichen, Geld im Text: ${geben.geld}`);
if (!geben.da || geben.knoepfe !== 4 || geben.zeichen !== 4 || geben.geld
|| geben.zahlen[0] !== "0,2") {
console.log(" ACHTUNG: Der Geben-Kasten zeigt nicht, was er soll.");
ausgang = 1;
}
/* ==== DIE PROBE AENDERT NUR DAS, WAS SIE AENDERN MUSS ============
Der Knopf ist ein Verweis mit `target="_blank"` und fuehrt zu
PayPal. Ein Klick in der Messung wuerde also ins Netz gehen. Ein
Zuhoerer in der EINFANGPHASE nimmt nur die Navigation weg -- der
Zuhoerer der Seite laeuft danach ganz normal.
Das ist die Lehre vom 28.09. in klein: Eine Probe, die mehr
aendert als das, wonach man sucht, beantwortet eine andere
Frage. Hier wird kein Ereignis nachgebaut und keine Funktion
direkt gerufen -- es wird wirklich geklickt. */
await zuschauer.seite.evaluate(() => {
document.addEventListener("click", (e) => {
if (e.target.closest && e.target.closest("a.geben__betrag")) e.preventDefault();
}, true);
});
await zuschauer.seite.click(".geben__betrag");
await zuschauer.seite.waitForTimeout(400);
const felder = await zuschauer.seite.evaluate(() => {
const name = document.getElementById("geben-name");
const text = document.getElementById("geben-text");
return {
offen: !document.getElementById("geben-nach").hidden,
frage: document.getElementById("geben-frage")?.textContent?.trim() || "",
knopf: document.getElementById("geben-melden")?.textContent?.trim() || "",
name: name?.value || "",
grenze: text?.maxLength || 0,
/* Am Finger muessen beide Felder gross genug sein. */
hoch: Math.round(text?.getBoundingClientRect().height || 0),
};
});
console.log(` Nach dem Tippen: „${felder.frage}" · Knopf „${felder.knopf}"`);
console.log(` Felder: Name „${felder.name}", Text bis ${felder.grenze} Zeichen, `
+ `${felder.hoch} px hoch`);
if (!felder.offen || felder.grenze !== 140 || felder.name !== "Nachtfalter"
|| /€|EUR/.test(felder.frage) || !felder.frage.includes("Dogen")) {
console.log(" ACHTUNG: Die Felder fuer den eigenen Text stimmen nicht.");
ausgang = 1;
}
/* Der Zaehler steht erst da, wenn es eng wird -- vorher waere er
eine Aufsicht statt einer Hilfe. */
const kurz = await zuschauer.seite.evaluate(() => {
const t = document.getElementById("geben-text");
t.value = "Kurz";
t.dispatchEvent(new Event("input", { bubbles: true }));
return document.getElementById("geben-rest")?.textContent || "";
});
const lang = await zuschauer.seite.evaluate(() => {
const t = document.getElementById("geben-text");
t.value = "Danke fuer den Abend, ihr Lieben! Bleibt genau so wie ihr seid "
+ "-- das hier ist das Beste am ganzen Tag, ehrlich jetzt.";
t.dispatchEvent(new Event("input", { bubbles: true }));
return document.getElementById("geben-rest")?.textContent || "";
});
console.log(` Zaehler: bei „Kurz" „${kurz}", bei langem Text „${lang}"`);
if (kurz !== "" || !lang.startsWith("noch ")) {
console.log(" ACHTUNG: Der Zaehler erscheint zur falschen Zeit.");
ausgang = 1;
}
await zuschauer.seite.click("#geben-melden");
await zuschauer.seite.waitForTimeout(1400);
const danach = await zuschauer.seite.evaluate(() => ({
danke: document.getElementById("geben-danke")?.textContent?.trim() || "",
offen: !document.getElementById("geben-nach").hidden,
leer: document.getElementById("geben-text")?.value === "",
}));
console.log(` Danach: „${danach.danke}"`);
if (danach.offen || !danach.leer || !danach.danke.includes("Text")) {
console.log(" ACHTUNG: Nach dem Melden stimmt die Antwort nicht.");
ausgang = 1;
}
const nachMelden = await zuschauer.seite.evaluate(() => window.__karten);
console.log(` Karten bei der Zuschauerin: ${JSON.stringify(nachMelden)}`);
if (nachMelden.length !== 1 || nachMelden[0]?.betrag !== "2,5") {
console.log(" ACHTUNG: Die eingetragene Spende kommt nicht als Karte an.");
ausgang = 1;
}
if (nachMelden.some((k2) => k2.betrag === "0,2")) {
console.log(" ACHTUNG: Eine UNBESTAETIGTE Meldung stand im Bild.");
ausgang = 1;
}
/* 3. Bestaetigen -- jetzt muss sie laufen. */
const offen = await roh("/workspace/api/spenden", { keks: k.admin });
const wartet = (offen.j?.offen || [])[0];
console.log(` Im Pult wartet: ${wartet ? wartet.dogen_mille / 1000 + " Dogen von " + wartet.name : "(nichts)"}`);
if (!wartet) {
console.log(" ACHTUNG: Die Meldung kommt nicht im Pult an.");
ausgang = 1;
} else {
/* ==== DIE SCHRANKE VOR DEM STREAM ============================
Der Text kommt von einem Fremden und steht gleich im Bild.
Geprueft wird deshalb, dass er (a) im Pult ANKOMMT, (b) sich
dort AENDERN laesst und (c) genau so auf der Karte landet. */
console.log(` Der Zuschauer schrieb: „${(wartet.gruss || "").slice(0, 60)}…"`);
if (!wartet.gruss || !wartet.gruss.startsWith("Danke fuer den Abend")) {
console.log(" ACHTUNG: Der selbst geschriebene Text kommt nicht im Pult an.");
ausgang = 1;
}
const inFeldern = await host.seite.evaluate(() => {
const z = document.querySelector("#sp-offen .spendenzeile");
const f = z ? [...z.querySelectorAll(".spendenzeile__feld")] : [];
return {
felder: f.length,
wert: f[1]?.value || "",
grenze: f[1]?.maxLength || 0,
zahl: z?.querySelector(".dogen__zahl")?.textContent || "",
};
});
console.log(` Im Pult: ${inFeldern.felder} Felder, Text bis ${inFeldern.grenze} `
+ `Zeichen, Zahl „${inFeldern.zahl}"`);
if (inFeldern.felder !== 2 || inFeldern.grenze !== 140
|| inFeldern.zahl !== "0,2") {
console.log(" ACHTUNG: Die Leitung kann den Text nicht sehen oder nicht aendern.");
ausgang = 1;
}
await roh(`/workspace/api/spenden/${wartet.id}/bestaetigen`, {
method: "POST", keks: k.admin,
rumpf: { dogen_mille: 1000, gruss: "Danke fuer den Abend!" },
});
/* AUF DAS MERKMAL WARTEN, NICHT AUF DIE UHR. Die erste Karte
laeuft elf Sekunden (die hoechste Stufe steht am laengsten); die
zweite wartet in der Schlange, damit sie nicht uebereinander
liegen. Eine feste Pause haette das als Fehler gemeldet. */
await zuschauer.seite.waitForFunction(
() => (window.__karten || []).length >= 2, null, { timeout: 20000 },
).catch(() => {});
const nachJa = await zuschauer.seite.evaluate(() => window.__karten);
console.log(` Nach dem Bestaetigen: ${JSON.stringify(nachJa.slice(-1))}`);
if (nachJa.length !== 2 || nachJa[1]?.betrag !== "1") {
console.log(" ACHTUNG: Die bestaetigte Spende laeuft nicht -- oder mit falschem Betrag.");
ausgang = 1;
}
/* DIE STUFE MUSS ZUR ZAHL PASSEN. 2,5 Dogen ist die hoechste
(„Rudel-Legende"), 1 Doge die mittlere („Starke Runde"). Ohne
diese Probe koennte jede Karte dieselbe Stufe tragen und es
faellt niemandem auf. */
console.log(` Stufen: ${nachMelden[0]?.stufe} / ${nachJa[1]?.stufe}`);
/* EINZAHL UND MEHRZAHL. „1 Dogen" waere die Sorte Fehler, die
jeder sieht und niemand meldet. */
console.log(` Einheit: „${nachMelden[0]?.wort}" bei 2,5 · `
+ `„${nachJa[1]?.wort}" bei 1`);
if (nachMelden[0]?.wort !== "Dogen" || nachJa[1]?.wort !== "Doge") {
console.log(" ACHTUNG: Einzahl und Mehrzahl stimmen nicht.");
ausgang = 1;
}
/* Der Text, nicht das Bild: Grossbuchstaben macht das Stilblatt. */
if (nachMelden[0]?.stufe !== "Rudel-Legende" || nachJa[1]?.stufe !== "Starke Runde") {
console.log(" ACHTUNG: Die Stufe passt nicht zur Zahl.");
ausgang = 1;
}
/* ==== DER BERICHTIGTE TEXT STEHT AUF DER KARTE ================
Nicht der, den der Zuschauer geschrieben hat, sondern der, den
die Leitung freigegeben hat. Andersherum waere die ganze
Schranke wirkungslos -- und niemandem fiele es auf, bis es
einmal zaehlt. */
console.log(` Auf der Karte steht: „${nachJa[1]?.gruss}"`);
if (nachJa[1]?.gruss !== "Danke fuer den Abend!") {
console.log(" ACHTUNG: Der freigegebene Text steht nicht auf der Karte.");
ausgang = 1;
}
/* ==== UND DIE PUNKTE SIND SOFORT DA ===========================
Filipe: „ich will dass die den leuten auch als punkte
hinzugefuegt werden."
GEMESSEN BEIM ZUSCHAUER UND OHNE NEULADEN. Genau hier war ein
Loch: `spendenHolen()` ruft bei einer Zuschauerin niemand auf,
das tut nur der Host -- ihr Stand waere erst beim naechsten
Seitenaufruf gewachsen. „Hinzugefuegt werden" heisst sichtbar
hinzugefuegt. */
await zuschauer.seite.waitForFunction(
() => !document.getElementById("geben-stand")?.hidden, null, { timeout: 8000 },
).catch(() => {});
const stand = await zuschauer.seite.evaluate(() => {
const p = document.getElementById("geben-stand");
return {
da: !!p && !p.hidden,
zahl: p?.querySelector(".dogen__zahl")?.textContent || "",
wort: p?.querySelector(".dogen__wort")?.textContent || "",
zeichen: (() => {
const b = p?.querySelector(".dogen__zeichen");
if (!b) return false;
return b.tagName.toLowerCase() === "img" ? b.naturalWidth > 0 : true;
})(),
text: p?.textContent?.trim() || "",
};
});
console.log(` Eigener Stand ohne Neuladen: „${stand.text}" `
+ `(Zeichen: ${stand.zeichen})`);
if (!stand.da || stand.zahl !== "1" || stand.wort !== "Doge" || !stand.zeichen) {
console.log(" ACHTUNG: Der eigene Dogen-Stand kommt nicht an.");
ausgang = 1;
}
/* ==== DIE MUENZE MUSS ZUR HOEHE DER SPENDE PASSEN =============
Filipe: „drei varianten fuer niedrige spenden. mittlere und
hohe spenden. aber pass sie sofort diesen 3 kategorien an."
Die erste Karte sind 2,5 Dogen (Rudel-Legende, oberes Drittel),
die zweite 1 Doge (Starke Runde, mittleres). Stuende auf beiden
dieselbe Muenze, waere die ganze Einteilung wirkungslos -- und
das sieht man einer einzelnen Karte nie an. */
console.log(` Muenzen: „${nachMelden[0]?.muenze}" bei 2,5 · `
+ `„${nachJa[1]?.muenze}" bei 1`);
console.log(` Kleine Muenze daneben weggenommen: `
+ `${nachMelden[0]?.kleinWeg} / ${nachJa[1]?.kleinWeg}`);
if (!/-hoch\.webp$/.test(nachMelden[0]?.muenze || "")
|| !/-mittel\.webp$/.test(nachJa[1]?.muenze || "")) {
console.log(" ACHTUNG: Die Muenze passt nicht zur Hoehe der Spende.");
ausgang = 1;
}
if (!nachMelden[0]?.kleinWeg || !nachJa[1]?.kleinWeg) {
console.log(" ACHTUNG: Die Muenze steht zweimal auf derselben Karte.");
ausgang = 1;
}
/* ---- UND DER SATZ LAESST SICH WECHSELN ----
Gemessen ueber die Probe beim Host: Sie schreibt nichts in die
Datenbank und ist damit der einzige Weg, eine Karte zu sehen,
ohne eine Spende zu erfinden. */
await roh("/workspace/api/spenden/zeichen/satz", {
method: "PUT", keks: k.admin, rumpf: { satz: "neon" },
});
await host.seite.waitForTimeout(700);
await roh("/workspace/api/spenden/probe", {
method: "POST", keks: k.admin, rumpf: { dogen_mille: 2500 },
});
/* AUF DIE PROBEKARTE WARTEN, NICHT AUF IRGENDEINE.
Beim ersten Lauf stand noch die vorige Karte („Starke Runde",
acht Sekunden) auf dem Bildschirm, und die Messung las deren
Muenze -- sie meldete „klassik-mittel" und damit einen Fehler,
den es nicht gab. Die Karten laufen in einer SCHLANGE: Die
naechste kommt erst, wenn die vorige abgelaufen ist. Also auf
das Merkmal warten, das nur die neue hat. */
await host.seite.waitForFunction(
() => [...document.querySelectorAll('.spendenkarte[data-da="ja"]')].some(
(k2) => k2.querySelector(".spendenkarte__wer")?.textContent === "Probe"
&& k2.querySelector(".spendenkarte__bild img")),
null, { timeout: 20000 },
).catch(() => {});
const nachWechsel = await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll('.spendenkarte[data-da="ja"]')]
.find((x) => x.querySelector(".spendenkarte__wer")?.textContent === "Probe");
const b = k2?.querySelector(".spendenkarte__bild img");
/* Und auch hier: geladen, nicht nur vorhanden. */
return b && b.naturalWidth > 0
? (b.getAttribute("src") || "").split("/").pop() : "(nicht geladen)";
});
console.log(` Nach dem Wechsel auf „Neon": „${nachWechsel}"`);
if (nachWechsel !== "neon-hoch.webp") {
console.log(" ACHTUNG: Der gewechselte Muenzsatz kommt nicht an.");
ausgang = 1;
}
await roh("/workspace/api/spenden/zeichen/satz", {
method: "PUT", keks: k.admin, rumpf: { satz: "klassik" },
});
}
await host.seite.click('#reiter .reiter__knopf[data-tafel="spenden"]').catch(() => {});
await host.seite.waitForTimeout(700);
await bild(host.seite, "9-live-regie-spenden");
await vermessen(host.seite, "Host am Rechner");
await bild(host.seite, "3-live-host");
/* Und einmal die Warteschlange -- das ist der Teil, den Filipe
vorher benutzt, nicht waehrend der Sendung. */
await host.seite.click('#reiter .reiter__knopf[data-tafel="liste"]').catch(() => {});
await host.seite.waitForTimeout(600);
const warte = await host.seite.evaluate(() => ({
zeilen: document.querySelectorAll("#warteliste .warte-zeile").length,
bilder: [...document.querySelectorAll(".warte-zeile__bild")]
.filter((b) => b.naturalWidth > 0).length,
titel: [...document.querySelectorAll(".warte-zeile__titel")]
.map((e) => e.textContent.trim()).join(" | "),
naechstes: document.getElementById("v-naechstes")?.disabled,
}));
console.log(` Warteschlange: ${warte.zeilen} Zeilen, `
+ `${warte.bilder} Vorschaubilder geladen, „Naechstes" `
+ `${warte.naechstes ? "grau" : "nutzbar"}`);
console.log(` ${warte.titel}`);
if (warte.zeilen !== 2 || warte.naechstes) {
console.log(" ACHTUNG: Die Warteschlange kommt nicht an.");
ausgang = 1;
}
await bild(host.seite, "7-live-regie-liste");
await host.seite.click('#reiter .reiter__knopf[data-tafel="ton"]').catch(() => {});
await host.seite.waitForTimeout(500);
await bild(host.seite, "8-live-regie-ton");
if (host.konsole.length) console.log(" KONSOLE:", host.konsole.slice(0, 4).join(" | "));
await vermessen(zuschauer.seite, "Zuschauerin am Rechner");
await bild(zuschauer.seite, "4-live-zuschauer");
if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice(0, 4).join(" | "));
/* ---- Rollen und Moderation im Chat --------------------------------
Filipe: „die modis, linke hand und rechte hand soll im chat extra
aussehen und auch sachen wie stummschaltungen, sperrungen,
meldunge ... machen koennen."
GEMESSEN UND NICHT HERGELEITET. Die Schiene ist die schmalste
Flaeche der ganzen Seite, und in jede Beitragszeile ist gerade ein
Abzeichen UND ein zweiter Knopf dazugekommen. Genau so ist am
28.09. die Regie 197 px uebergelaufen: Die Rechnung ging auf, die
Wirklichkeit nicht. */
/* ---- Groesse und Ecke der Kamerafenster -------------------------
Filipe: „und danach perfektionierst du auch die verschiedenen
groessen von kamera und so."
GEMESSEN WIRD BEIM ZUSCHAUER. Dass in der Datenbank 1,5 steht,
sagt nichts darueber, ob das Fenster bei den anderen groesser
wird -- dazwischen liegen ein Rundruf, eine Variable im Stilblatt
und eine Rechnung mit `clamp`. */
console.log("");
console.log(" --- Kameragroesse und Ecke ---");
{
/* Sauberer Anfang: „Video gross", damit die Ecke ueberhaupt gilt. */
await roh("/workspace/api/reaktion/layout", {
method: "POST", keks: k.admin, rumpf: { layout: "kino" },
});
await zuschauer.seite.waitForTimeout(900);
const messen = () => zuschauer.seite.evaluate(() => {
const kino = document.getElementById("teil-live");
const kk = document.querySelector(".kameras");
const ein = document.querySelector(".kamera");
const lein = document.querySelector(".leinwand");
if (!kk || !ein || !lein) return null;
const r = ein.getBoundingClientRect();
const lr = lein.getBoundingClientRect();
const fenster = [...document.querySelectorAll(".kamera")];
return {
gross: kino.style.getPropertyValue("--kam-gross") || "(?)",
ecke: kino.dataset.ecke || "(?)",
breit: Math.round(r.width),
/* Wo sitzt der Stapel? Als Abstand zu den vier Kanten der
Leinwand -- daran erkennt man die Ecke ohne Vorwissen. */
links: Math.round(kk.getBoundingClientRect().left - lr.left),
rechts: Math.round(lr.right - kk.getBoundingClientRect().right),
oben: Math.round(kk.getBoundingClientRect().top - lr.top),
unten: Math.round(lr.bottom - kk.getBoundingClientRect().bottom),
/* Und liegt noch alles IN der Leinwand? Sie hat
`overflow: hidden` -- was daneben liegt, ist unsichtbar,
nicht abgeschnitten sichtbar. */
drin: fenster.filter((f) => {
const q = f.getBoundingClientRect();
return q.left >= lr.left - 1 && q.right <= lr.right + 1
&& q.top >= lr.top - 1 && q.bottom <= lr.bottom + 1;
}).length,
fenster: fenster.length,
leinwand: Math.round(lr.width),
};
});
const normal = await messen();
console.log(` 1×: Fenster ${normal?.breit} px, Ecke ${normal?.ecke}, `
+ `Abstände l/r/o/u ${normal?.links}/${normal?.rechts}/${normal?.oben}/${normal?.unten}, `
+ `${normal?.drin}/${normal?.fenster} im Bild`);
/* ---- DIE GROESSE MUSS WIRKEN ---- */
const { KAMERA_GROESSEN } = await import("./reaktion-tabellen.js");
const gross = KAMERA_GROESSEN[KAMERA_GROESSEN.length - 1];
await roh("/workspace/api/reaktion/layout", {
method: "POST", keks: k.admin, rumpf: { kamera_gross: gross },
});
await zuschauer.seite.waitForTimeout(900);
const dick = await messen();
console.log(` ${gross}×: Fenster ${dick?.breit} px, `
+ `${dick?.drin}/${dick?.fenster} im Bild (Leinwand ${dick?.leinwand} px)`);
if (!normal || !dick) {
console.log(" ACHTUNG: Die Kamerafenster liessen sich nicht messen.");
ausgang = 1;
} else {
/* NICHT AUF DEN FAKTOR GENAU. `clamp` deckelt die Grundbreite,
und `max-width: 46%` deckelt den Stapel -- beides ist Absicht.
Geprueft wird, dass es SPUERBAR waechst und im Bild bleibt. */
const wuchs = dick.breit / normal.breit;
console.log(` Das Fenster wuchs um das ${wuchs.toFixed(2)}-fache`);
if (wuchs < 1.3) {
console.log(" ACHTUNG: Die Groesse wirkt nicht.");
ausgang = 1;
}
if (dick.drin !== dick.fenster) {
console.log(" ACHTUNG: Ein Fenster liegt ausserhalb der Leinwand.");
ausgang = 1;
}
if (dick.breit > dick.leinwand * 0.5) {
console.log(" ACHTUNG: Ein Fenster deckt mehr als die halbe Leinwand zu.");
ausgang = 1;
}
}
/* ---- DIE VIER ECKEN ----
Geprueft wird der ABSTAND zu den Kanten, nicht der Name der
Ecke. Eine Einstellung, die nur ihren eigenen Namen bestaetigt,
bestaetigt nichts. */
await roh("/workspace/api/reaktion/layout", {
method: "POST", keks: k.admin, rumpf: { kamera_gross: 1 },
});
const erwartet = {
ol: ["links", "oben"], or: ["rechts", "oben"],
ul: ["links", "unten"], ur: ["rechts", "unten"],
};
for (const [ecke, [waag, senk]] of Object.entries(erwartet)) {
await roh("/workspace/api/reaktion/layout", {
method: "POST", keks: k.admin, rumpf: { kamera_ecke: ecke },
});
await zuschauer.seite.waitForTimeout(800);
const m = await messen();
const nahWaag = m[waag] < (waag === "links" ? m.rechts : m.links);
const nahSenk = m[senk] < (senk === "oben" ? m.unten : m.oben);
console.log(` Ecke ${ecke}: l/r/o/u ${m.links}/${m.rechts}/${m.oben}/${m.unten} `
+ `-> ${waag}+${senk} ${nahWaag && nahSenk ? "stimmt" : "STIMMT NICHT"}`);
if (!nahWaag || !nahSenk || m.ecke !== ecke) {
console.log(` ACHTUNG: Die Ecke „${ecke}" kommt nicht an.`);
ausgang = 1;
}
}
await bild(zuschauer.seite, "22-kamera-ecke");
/* ---- EIN KNOPF, DER NICHTS TUN KANN, IST GRAU ----
Die Ecke wirkt nur bei „Video gross"; die Groesse wirkt ueberall,
wo es Kamerafenster GIBT -- bei „Nur Video" gibt es keine.
GEMESSEN WIRD DER ZUSTAND DER KNOEPFE, nicht der Satz darunter.
Ein Hinweis, der die Wahrheit sagt, waehrend der Knopf daneben
bedienbar bleibt, ist die schlechtere Haelfte: Man liest ihn
nicht und drueckt trotzdem. */
const knoepfe = () => host.seite.evaluate(() => {
const e = [...document.querySelectorAll("#kamecke .kamecke__knopf")];
const g = [...document.querySelectorAll("#kamgroesse .kamgroesse__knopf")];
return {
ecken: e.length, eckenGrau: e.filter((x) => x.disabled).length,
groessen: g.length, groessenGrau: g.filter((x) => x.disabled).length,
hinweis: document.getElementById("kamecke-hinweis")?.textContent || "",
};
});
for (const [anordnung, sollEcken, sollGroessen, wort] of [
["kamera", "alle", "keine", "Mitte"],
["nur_video", "alle", "alle", "Nur Video"],
["kino", "keine", "keine", "Ecke"],
]) {
await roh("/workspace/api/reaktion/layout", {
method: "POST", keks: k.admin, rumpf: { layout: anordnung },
});
await host.seite.waitForTimeout(800);
const m = await knoepfe();
console.log(` „${anordnung}": ${m.eckenGrau}/${m.ecken} Ecken grau, `
+ `${m.groessenGrau}/${m.groessen} Größen grau`);
console.log(` Hinweis: „${m.hinweis.replace(/\s+/g, " ").trim()}"`);
const soll = (art, wieviel, da) => (wieviel === "alle" ? art === da : art === 0);
if (m.ecken !== 4 || m.groessen < 4) {
console.log(" ACHTUNG: Die Knoepfe fuer Groesse oder Ecke fehlen.");
ausgang = 1;
} else if (!soll(m.eckenGrau, sollEcken, m.ecken)
|| !soll(m.groessenGrau, sollGroessen, m.groessen)) {
console.log(` ACHTUNG: Bei „${anordnung}" stimmt das Grauwerden nicht.`);
ausgang = 1;
} else if (!m.hinweis.includes(wort)) {
console.log(` ACHTUNG: Der Hinweis sagt nichts von „${wort}".`);
ausgang = 1;
}
}
await roh("/workspace/api/reaktion/layout", {
method: "POST", keks: k.admin, rumpf: { layout: "kino", kamera_ecke: "ur" },
});
await host.seite.waitForTimeout(700);
}
/* ---- Die Regieleiste ueber dem Saal -----------------------------
Filipe, 28.09.2026: „ich haette das gerne oben also nicht in dieser
kachel sondern ueber der kachel wo die videos laufen werden."
GEMESSEN WIRD, WAS SIE KOSTET UND WAS SIE KANN. Eine Leiste am
oberen Rand nimmt Hoehe -- und zwar dem Video, um das es geht.
Zwei Zeilen statt einer waeren rund 90 Pixel, und das sieht man
auf dem Bildschirmfoto nicht, weil beides „eine Leiste" ist. */
console.log("");
console.log(" --- Die Regieleiste ---");
{
const leiste = await host.seite.evaluate(() => {
const l = document.getElementById("regieleiste");
const r = document.getElementById("reiter");
const kino = document.getElementById("teil-live");
if (!l || !r || !kino) return null;
const lr = l.getBoundingClientRect();
const kr = kino.getBoundingClientRect();
const knoepfe = [...r.querySelectorAll(".reiter__knopf")];
return {
hoch: Math.round(lr.height),
oben: Math.round(lr.top),
/* STEHT SIE WIRKLICH UEBER DEM KINO? Das ist die Frage, nicht
ob sie irgendwo existiert. */
ueberDemKino: lr.bottom <= kr.top + 1,
knoepfe: knoepfe.length,
/* EINE ZEILE ODER ZWEI? Gemessen an den Oberkanten -- wenn
zwei Knoepfe verschieden hoch sitzen, bricht sie um. */
zeilen: new Set(knoepfe.map((k) => Math.round(k.getBoundingClientRect().top))).size,
kleinster: Math.min(...knoepfe.map((k) => Math.round(k.getBoundingClientRect().height))),
rollt: r.scrollWidth > r.clientWidth,
/* Genau EIN Register in der Tabulatorfolge. */
imTabulator: knoepfe.filter((k) => k.tabIndex === 0).length,
marke: (() => {
const m = document.getElementById("reiter-marke");
if (!m || m.hidden) return null;
const mr = m.getBoundingClientRect();
const a = knoepfe.find((k) => k.getAttribute("aria-selected") === "true");
const ar = a.getBoundingClientRect();
return {
breit: Math.round(mr.width),
/* Sitzt sie unter dem gewaehlten Register? */
versatz: Math.round(Math.abs(mr.left - ar.left)),
gleichBreit: Math.abs(mr.width - ar.width) < 2,
};
})(),
};
});
if (!leiste) {
console.log(" ACHTUNG: Die Regieleiste gibt es nicht.");
ausgang = 1;
} else {
console.log(` ${leiste.knoepfe} Register in ${leiste.zeilen} Zeile(n), `
+ `Leiste ${leiste.hoch} px hoch bei y=${leiste.oben}, `
+ `kleinstes Ziel ${leiste.kleinster} px, rollt=${leiste.rollt}`);
console.log(` Ueber dem Kino: ${leiste.ueberDemKino} · `
+ `im Tabulator: ${leiste.imTabulator} · `
+ `Marke ${leiste.marke ? `${leiste.marke.breit} px, Versatz ${leiste.marke.versatz} px` : "(keine)"}`);
if (!leiste.ueberDemKino) {
console.log(" ACHTUNG: Die Leiste steht nicht ueber dem Kino.");
ausgang = 1;
}
if (leiste.zeilen !== 1) {
console.log(" ACHTUNG: Die Register brechen um -- das kostet das Video Hoehe.");
ausgang = 1;
}
if (leiste.kleinster < 44) {
console.log(" ACHTUNG: Ein Register ist kleiner als 44 px.");
ausgang = 1;
}
if (leiste.imTabulator !== 1) {
console.log(" ACHTUNG: Der Tabulator wandert nicht -- sieben Schritte bis zum Inhalt.");
ausgang = 1;
}
if (!leiste.marke || leiste.marke.versatz > 2 || !leiste.marke.gleichBreit) {
console.log(" ACHTUNG: Die Marke sitzt nicht unter dem gewaehlten Register.");
ausgang = 1;
}
}
/* ---- DIE TASTATUR MUSS TUN, WAS `role=tablist` VERSPRICHT ----
Ein Versprechen im Markup, das das Programm nicht einloest, ist
schlimmer als gar keines: Der Vorleser sagt etwas an, was nicht
stimmt. Also wird es gedrueckt, nicht gelesen. */
await host.seite.evaluate(() => {
document.querySelector('#reiter .reiter__knopf[data-tafel="sendung"]').focus();
});
const tasten = [];
for (const [taste, wohin] of [["ArrowRight", "rechts"], ["ArrowRight", "rechts"],
["ArrowLeft", "links"], ["End", "ans Ende"], ["Home", "an den Anfang"]]) {
await host.seite.keyboard.press(taste);
await host.seite.waitForTimeout(220);
const wo = await host.seite.evaluate(() => {
const a = document.activeElement;
const g = [...document.querySelectorAll("#reiter .reiter__knopf")]
.find((k) => k.getAttribute("aria-selected") === "true");
const t = [...document.querySelectorAll(".pult-tafel")].find((x) => !x.hidden);
return {
fokus: a?.dataset?.tafel || "(?)",
gewaehlt: g?.dataset?.tafel || "(?)",
tafel: t?.dataset?.tafel || "(keine)",
};
});
tasten.push(`${wohin}->${wo.fokus}`);
/* FOKUS, AUSWAHL UND OFFENE TAFEL MUESSEN DASSELBE SAGEN.
Laufen sie auseinander, zeigt die Regie eine andere Tafel als
das hervorgehobene Register -- und man stellt etwas ein, von
dem man glaubt, es gehoere woandershin. */
if (wo.fokus !== wo.gewaehlt || wo.gewaehlt !== wo.tafel) {
console.log(` ACHTUNG: Fokus ${wo.fokus}, gewaehlt ${wo.gewaehlt}, `
+ `offen ${wo.tafel} -- das passt nicht zusammen.`);
ausgang = 1;
}
}
console.log(` Tastatur: ${tasten.join(" · ")}`);
/* ---- EIN REGISTER MACHT DIE ZUGEKLAPPTE REGIE AUF ----
Seit die Leiste oben steht, kann das Pult zu sein, waehrend man
ein Register waehlt. Ohne das waere der Knopf einer, bei dem
sichtbar nichts passiert. */
await host.seite.click("#pult-griff").catch(() => { });
await host.seite.waitForTimeout(400);
const zu = await host.seite.evaluate(() =>
document.getElementById("pult").dataset.auf);
await host.seite.click('#reiter .reiter__knopf[data-tafel="ton"]').catch(() => { });
await host.seite.waitForTimeout(500);
const danach = await host.seite.evaluate(() => ({
auf: document.getElementById("pult").dataset.auf,
tafel: [...document.querySelectorAll(".pult-tafel")].find((x) => !x.hidden)?.dataset.tafel,
sichtbar: (() => {
const t = document.getElementById("tafel-ton");
const r = t.getBoundingClientRect();
return r.height > 0 && r.top < window.innerHeight;
})(),
}));
console.log(` Regie zugeklappt (auf=${zu}), Register „Ton" gedrueckt `
+ `-> auf=${danach.auf}, offen=${danach.tafel}, sichtbar=${danach.sichtbar}`);
if (zu !== "nein" || danach.auf !== "ja" || !danach.sichtbar) {
console.log(" ACHTUNG: Ein Register macht die zugeklappte Regie nicht auf.");
ausgang = 1;
}
await bild(host.seite, "20-regieleiste");
/* ---- WAS SIE DEM VIDEO KOSTET ----
Die eigentliche Frage bei einer Leiste am oberen Rand. */
const platz = await host.seite.evaluate(() => {
const l = document.getElementById("regieleiste").getBoundingClientRect();
const k = document.getElementById("teil-live").getBoundingClientRect();
const s2 = document.getElementById("saal").getBoundingClientRect();
return {
leiste: Math.round(l.height), kino: Math.round(k.height), saal: Math.round(s2.height),
ueber: Math.max(0, document.documentElement.scrollHeight
- document.documentElement.clientHeight),
};
});
console.log(` Saal ${platz.saal} px = Leiste ${platz.leiste} + Kino ${platz.kino} `
+ `+ Regie, senkrecht ueber ${platz.ueber} px`);
if (platz.ueber > 6 || platz.kino < 200) {
console.log(" ACHTUNG: Die Leiste nimmt dem Bild zu viel oder die Seite laeuft ueber.");
ausgang = 1;
}
}
/* ---- Die Gestaltung der Spendenkarten ---------------------------
Filipe: „mach paar fertige und so dass ich auch hochladen kann.
auch so dass ich das anders gestalten kann oder die größe
verändern kann."
GEMESSEN WIRD DIE KARTE, NICHT DIE EINSTELLUNG. Dass in der
Datenbank 2.0 steht, sagt nichts darüber, ob die Karte doppelt so
groß erscheint -- dazwischen liegen eine Zahl im Stilblatt, eine
Umrechnung in `em` und ein Dutzend Regeln, die noch in `rem`
stehen könnten. Also: Karte laufen lassen und nachmessen. */
console.log("");
console.log(" --- Die Gestaltung der Spendenkarten ---");
await host.seite.click('#reiter .reiter__knopf[data-tafel="gestaltung"]').catch(() => {});
await host.seite.waitForTimeout(700);
const leiter = await host.seite.evaluate(() => {
const zeilen = [...document.querySelectorAll("#stufen .stufe-zeile")];
return {
zeilen: zeilen.length,
ersteName: zeilen[0]?.querySelector(".stufe-name")?.value || "",
felder: zeilen[0] ? [...zeilen[0].querySelectorAll("input, select")].length : 0,
/* Jedes Bedienelement muss mit dem Finger zu treffen sein --
hier wird eingestellt, was vor Publikum im Bild steht. */
/* VERSTECKTES ZAEHLT NICHT. Hinter jedem „Bild hochladen" liegt
ein `<input type="file" hidden>` -- es ist 0 px hoch, und das
ist richtig so: Angefasst wird das Schild davor. Die erste
Fassung hat es mitgezaehlt und drei Fehler gemeldet, die
keine waren. Eine Messung, die Unsichtbares misst, misst das
Falsche. */
zuKlein: zeilen.flatMap((z) => [...z.querySelectorAll("input, select, .stufe-tun")])
.filter((e) => !e.hidden && e.offsetParent !== null)
.filter((e) => e.getBoundingClientRect().height < 40).length,
ueber: Math.max(0, document.documentElement.scrollWidth
- document.documentElement.clientWidth),
};
});
console.log(` ${leiter.zeilen} Stufen, erste „${leiter.ersteName}", `
+ `${leiter.felder} Felder je Zeile, ${leiter.zuKlein} zu kleine Ziele, `
+ `seitlich ${leiter.ueber} px`);
if (leiter.zeilen < 3 || leiter.felder < 6 || leiter.zuKlein > 0 || leiter.ueber > 0) {
console.log(" ACHTUNG: Die Stufenleiter ist unvollstaendig oder laeuft ueber.");
ausgang = 1;
}
await bild(host.seite, "17-stufenleiter");
/* ---- Eine Stufe umgestalten und nachsehen, ob es ankommt ---- */
const vorherKarte = await host.seite.evaluate(async () => {
/* Erst einmal bei Größe 1 messen -- ohne Bezugswert sagt die zweite
Messung nichts. */
const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2];
k2.querySelector(".stufe-groesse").value = "1";
k2.querySelector(".stufe-groesse").dispatchEvent(new Event("input", { bubbles: true }));
document.getElementById("stufen-sichern").click();
return true;
});
await host.seite.waitForTimeout(1200);
/* ==== ERST AUFRAEUMEN, DANN MESSEN ================================
Die erste Fassung hat einfach `document.querySelector(".spenden-
karte")` genommen -- und damit die Karte der echten 10-Euro-Spende
erwischt, die aus dem vorigen Messabschnitt noch im Bild stand.
Ergebnis: drei gemeldete Fehler, die keine waren, darunter „die
Probe laeuft im ganzen Saal". Eine Messung, die nicht weiss, WELCHE
Karte sie vor sich hat, misst irgendetwas.
Also: beide Tafeln leeren, dann druecken, dann auf eine Karte
warten, die sich als Probe ausweist. */
const tafelnLeeren = async () => {
for (const s2 of [host.seite, zuschauer.seite]) {
await s2.evaluate(() => {
for (const k2 of document.querySelectorAll(".spendenkarte")) k2.remove();
});
}
};
const messeKarte = async (erwartet) => {
await tafelnLeeren();
await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2];
[...k2.querySelectorAll(".stufe-tun")].find((x) => x.textContent === "Ausprobieren")?.click();
});
/* ==== AUF DIE RICHTIGE KARTE WARTEN, NICHT AUF DIE UHR ==========
Zwei Fallen auf einmal, und die zweite hat die erste Fassung
gekostet:
(1) Die Karten laufen in einer SCHLANGE. Ein Knoten aus dem
Dokument zu nehmen beendet nicht, was die Seite gerade
zeigt -- die naechste Karte kommt erst, wenn die Dauer der
vorigen abgelaufen ist. Bei elf Sekunden Dauer und acht
Sekunden Wartezeit kam sie nie, und die Messung meldete
einen Fehler, den es nicht gab.
(2) Gewartet wird auf die Karte MIT DER ERWARTETEN GROESSE.
Sonst koennte die vorige Probe die Antwort geben, und die
Messung bestaetigte eine Aenderung, die gar nicht
angekommen ist. */
await host.seite.waitForFunction(
(soll) => [...document.querySelectorAll('.spendenkarte[data-da="ja"]')].some((k2) =>
k2.querySelector(".spendenkarte__wer")?.textContent === "Probe"
&& Math.abs(parseFloat(getComputedStyle(k2).fontSize) - soll) < 0.5),
erwartet, { timeout: 16000 },
).catch(() => {});
/* DIE KARTE KOMMT VON LINKS HEREIN (450 ms). Wer sofort misst,
misst einen Zwischenschritt: Waehrend des Hereinkommens steht
sie 28 px weiter links und ist halb durchsichtig. Ein
Bildschirmfoto aus dieser Phase sieht aus, als waere sie
abgeschnitten -- und genau das hat mich beim ersten Ansehen in
die Irre gefuehrt. */
await host.seite.waitForTimeout(700);
return host.seite.evaluate(() => {
/* `data-da="ja"` HEISST: GANZ DA. Beim Hereinkommen UND beim
Verschwinden steht die Karte auf `translateX(-28px) scale(.96)`
-- gemessen wurden dadurch 413 statt 430 Pixel (genau das
0,96-fache) und ein linker Rand von -1. Beides war kein
Fehler der Karte, sondern ein Blick zur falschen Zeit. */
const k2 = [...document.querySelectorAll('.spendenkarte[data-da="ja"]')]
.find((x) => x.querySelector(".spendenkarte__wer")?.textContent === "Probe");
if (!k2) return null;
const r = k2.getBoundingClientRect();
const c = getComputedStyle(k2);
const betrag = k2.querySelector(".spendenkarte__betrag");
return {
schrift: Math.round(parseFloat(c.fontSize) * 100) / 100,
breit: Math.round(r.width), hoch: Math.round(r.height),
betragText: betrag?.textContent || "",
betragSchrift: Math.round(parseFloat(getComputedStyle(betrag).fontSize) * 100) / 100,
stufe: k2.querySelector(".spendenkarte__stufe")?.textContent || "",
wer: k2.querySelector(".spendenkarte__wer")?.textContent || "",
links: Math.round(r.left), oben: Math.round(r.top),
/* ALLE VIER SEITEN. Die erste Fassung hat nur rechts und unten
geprueft -- eine Karte, die links heraussteht, waere
durchgegangen. Im Stream ist links genauso abgeschnitten wie
rechts. */
imBild: r.left >= -1 && r.top >= -1
&& r.right <= window.innerWidth + 1 && r.bottom <= window.innerHeight + 1,
};
});
};
const kKlein = await messeKarte(16);
console.log(` Größe 1: Karte ${kKlein?.breit}x${kKlein?.hoch} px, Schrift ${kKlein?.schrift} px, `
+ `Betrag „${kKlein?.betragText}" bei ${kKlein?.betragSchrift} px, Stufe „${kKlein?.stufe}"`);
if (!kKlein || kKlein.wer !== "Probe") {
console.log(" ACHTUNG: Die Probe zeigt keine Karte -- oder sie ist nicht als Probe erkennbar.");
ausgang = 1;
}
await bild(host.seite, "18-karte-normal");
/* DIE PROBE DARF NUR BEIM DRUECKENDEN ERSCHEINEN. Eine Probe, die im
ganzen Saal laeuft, ist eine Spende, die es nicht gab. */
const beimZuschauer = await zuschauer.seite.evaluate(() =>
document.querySelectorAll(".spendenkarte").length);
console.log(` Karten bei der Zuschauerin waehrend der Probe: ${beimZuschauer} (soll: 0)`);
if (beimZuschauer !== 0) {
console.log(" ACHTUNG: Die Probe laeuft im ganzen Saal.");
ausgang = 1;
}
/* Jetzt auf doppelte Größe -- und nachmessen. */
await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2];
k2.querySelector(".stufe-groesse").value = "2";
k2.querySelector(".stufe-groesse").dispatchEvent(new Event("input", { bubbles: true }));
k2.querySelector(".stufe-farbe").value = "#55d6a0";
k2.querySelector(".stufe-farbe").dispatchEvent(new Event("input", { bubbles: true }));
document.getElementById("stufen-sichern").click();
});
await host.seite.waitForTimeout(1400);
const kGross = await messeKarte(32);
console.log(` Größe 2: Karte ${kGross?.breit}x${kGross?.hoch} px, Schrift ${kGross?.schrift} px, `
+ `Betrag bei ${kGross?.betragSchrift} px, links ${kGross?.links} / oben ${kGross?.oben}, `
+ `ganz im Bild=${kGross?.imBild}`);
await bild(host.seite, "19-karte-doppelt");
if (!kGross || !kKlein) {
console.log(" ACHTUNG: Die Karte liess sich nicht zweimal messen.");
ausgang = 1;
} else {
/* ZWEIFACH HEISST ZWEIFACH. Wenn eine einzige Regel noch in `rem`
stünde statt in `em`, wüchse die Karte ungleichmäßig -- und das
sieht man auf einem Bildschirmfoto nicht, weil beides „größer"
aussieht. */
const faktor = kGross.betragSchrift / kKlein.betragSchrift;
console.log(` Der Betrag wuchs um das ${faktor.toFixed(2)}-fache (soll: 2,00)`);
if (Math.abs(faktor - 2) > 0.05) {
console.log(" ACHTUNG: Die Größe wirkt nicht oder nicht gleichmaessig.");
ausgang = 1;
}
if (kGross.hoch <= kKlein.hoch) {
console.log(" ACHTUNG: Die Karte wird nicht hoeher.");
ausgang = 1;
}
/* UND SIE MUSS INS BILD PASSEN. Die Obergrenze 2,5 ist daran
ausgerichtet -- eine Karte, die rechts heraussteht, ist im
Stream abgeschnitten. */
if (!kGross.imBild) {
console.log(" ACHTUNG: Die doppelt grosse Karte steht aus dem Bild heraus.");
ausgang = 1;
}
}
/* ---- Und die Farbe? ---- */
const farbe = await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll('.spendenkarte[data-da="ja"]')]
.find((x) => x.querySelector(".spendenkarte__wer")?.textContent === "Probe");
return k2 ? getComputedStyle(k2).getPropertyValue("--sp-farbe").trim() : "";
});
console.log(` Farbe der Karte: „${farbe}" (gesetzt war #55d6a0)`);
if (farbe.toLowerCase() !== "#55d6a0") {
console.log(" ACHTUNG: Die Farbe kommt nicht an.");
ausgang = 1;
}
/* Wieder auf einen vernuenftigen Wert -- die naechste Messung soll
nicht mit einer Karte in doppelter Groesse anfangen. */
await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2];
k2.querySelector(".stufe-groesse").value = "1.45";
k2.querySelector(".stufe-groesse").dispatchEvent(new Event("input", { bubbles: true }));
k2.querySelector(".stufe-farbe").value = "#e0a13a";
k2.querySelector(".stufe-farbe").dispatchEvent(new Event("input", { bubbles: true }));
document.getElementById("stufen-sichern").click();
});
await host.seite.waitForTimeout(1000);
/* ---- Das Video: Tempo, Anordnung, Umschalter --------------------
Filipe: „auch bei den videos sachen wie pause geschwindigkeit ...
nur mein bild, nur das video ... auch gerne so dass ich vielleicht
noch ein zweites nebenbei vorbereiten kann."
GEMESSEN WIRD DIE WIRKUNG, NICHT DIE EINSTELLUNG. Dass in der
Datenbank 1.5 steht, sagt nichts darüber, ob das Video schneller
läuft -- YouTube ignoriert eine unmögliche Geschwindigkeit
schweigend. Also wird die Uhr zweimal abgelesen und nachgerechnet. */
console.log("");
console.log(" --- Video: Tempo, Anordnung, Umschalter ---");
/* Die Regie zeigt „1:23 / 4:56". Das ist die einzige Stelle, an der
der Spielstand als sichtbarer Text steht -- und damit die einzige,
an der man von aussen nachrechnen kann. */
const uhrLesen = (seite) => seite.evaluate(() => {
const t = document.getElementById("v-zeit")?.textContent || "";
const [links] = t.split("/");
const [m, sek] = String(links).trim().split(":").map(Number);
return Number.isFinite(m) && Number.isFinite(sek) ? m * 60 + sek : null;
});
await host.seite.click('#reiter .reiter__knopf[data-tafel="sendung"]').catch(() => {});
await host.seite.waitForTimeout(600);
const tempoKnoepfe = await host.seite.evaluate(() =>
[...document.querySelectorAll("#tempo .tempo__knopf")].map((k2) => ({
wort: k2.textContent.trim(),
hoch: Math.round(k2.getBoundingClientRect().height),
breit: Math.round(k2.getBoundingClientRect().width),
})));
console.log(` ${tempoKnoepfe.length} Tempo-Knoepfe: `
+ tempoKnoepfe.map((t) => t.wort).join(" "));
if (tempoKnoepfe.length !== 6 || tempoKnoepfe.some((t) => t.breit < 44)) {
console.log(" ACHTUNG: Die Tempo-Knoepfe fehlen oder sind zu schmal.");
ausgang = 1;
}
/* NORMALES TEMPO ZUERST MESSEN -- als Bezugswert. Ohne ihn wüsste man
nicht, ob 1,5x wirklich schneller ist oder ob das Video einfach
schon lief. */
await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll("#tempo .tempo__knopf")]
.find((x) => Number(x.dataset.tempo) === 1);
if (k2) k2.click();
});
await host.seite.waitForTimeout(1500);
const a1 = await uhrLesen(host.seite);
await host.seite.waitForTimeout(6000);
const a2 = await uhrLesen(host.seite);
await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll("#tempo .tempo__knopf")]
.find((x) => Number(x.dataset.tempo) === 2);
if (k2) k2.click();
});
await host.seite.waitForTimeout(1800);
const b1 = await uhrLesen(host.seite);
await host.seite.waitForTimeout(6000);
const b2 = await uhrLesen(host.seite);
const normal = (a1 !== null && a2 !== null) ? (a2 - a1) / 6 : null;
const schnell = (b1 !== null && b2 !== null) ? (b2 - b1) / 6 : null;
console.log(` 1x: ${normal === null ? "?" : normal.toFixed(2)} Sekunden je Sekunde · `
+ `2x: ${schnell === null ? "?" : schnell.toFixed(2)}`);
if (normal === null || schnell === null || schnell < normal * 1.5) {
console.log(" ACHTUNG: Die Geschwindigkeit wirkt nicht -- das Video laeuft gleich schnell.");
ausgang = 1;
}
/* UND DER ZUSCHAUER? Er muss es SEHEN -- sonst haelt er seine Leitung
fuer kaputt. */
await zuschauer.seite.waitForTimeout(1200);
const schild = await zuschauer.seite.evaluate(() => {
const s2 = document.getElementById("leinwand-tempo");
return {
versteckt: !!s2?.hidden, wort: s2?.textContent || "",
amKino: document.getElementById("teil-live")?.dataset.tempo,
};
});
console.log(` Die Zuschauerin sieht: „${schild.wort}" `
+ `(versteckt=${schild.versteckt}, am Kino ${schild.amKino})`);
if (schild.versteckt || !schild.wort.includes("2") || schild.amKino !== "2") {
console.log(" ACHTUNG: Der Zuschauerin wird die Geschwindigkeit nicht gezeigt.");
ausgang = 1;
}
await host.seite.evaluate(() => {
const k2 = [...document.querySelectorAll("#tempo .tempo__knopf")]
.find((x) => Number(x.dataset.tempo) === 1);
if (k2) k2.click();
});
await zuschauer.seite.waitForTimeout(1200);
const wieder = await zuschauer.seite.evaluate(() =>
!!document.getElementById("leinwand-tempo")?.hidden);
console.log(` Zurueck auf 1x: Schild wieder weg = ${wieder}`);
if (!wieder) {
console.log(" ACHTUNG: Das Schild bleibt stehen, obwohl es normal laeuft.");
ausgang = 1;
}
/* ---- Die fuenf Anordnungen, beim Zuschauer gemessen ---- */
await host.seite.click('#reiter .reiter__knopf[data-tafel="bild"]').catch(() => {});
await host.seite.waitForTimeout(500);
const anordnungen = {};
for (const [name, was] of [
["kino", "Video groß"], ["gleich", "Gleich groß"], ["kamera", "Kameras groß"],
["nur_video", "Nur Video"], ["nur_kamera", "Nur Kamera"],
]) {
await host.seite.click(`#layout-reihe .layout-knopf[data-layout="${name}"]`).catch(() => {});
await host.seite.waitForTimeout(1100);
anordnungen[name] = await zuschauer.seite.evaluate(() => {
const sicht = (w) => {
const e = document.querySelector(w);
if (!e) return "(fehlt)";
const c = getComputedStyle(e);
const r = e.getBoundingClientRect();
return c.display === "none" ? "weg" : `${Math.round(r.width)}x${Math.round(r.height)}`;
};
/* LAEUFT ETWAS UEBER? Die Leinwand hat `overflow: hidden` --
ein Kamerafenster, das daneben liegt, ist damit unsichtbar
und nicht etwa abgeschnitten sichtbar. Genau so verschwand am
28.09. bei „nur Kamera“ der zweite Gast komplett: Behaelter
2175 px breit in einer 1072 px breiten Leinwand. Auf dem Bild
sah es aus wie eine Anordnung fuer einen -- und niemand haette
gefragt, wo der zweite geblieben ist. */
const lein = document.querySelector(".leinwand")?.getBoundingClientRect();
const kk = document.querySelector(".kameras")?.getBoundingClientRect();
const fenster = [...document.querySelectorAll(".kamera")];
return {
layout: document.getElementById("teil-live")?.dataset.layout,
video: sicht(".leinwand__video"), kameras: sicht(".kameras"),
kamera: sicht(".kamera"),
ueber: (lein && kk) ? Math.round(Math.max(0, kk.width - lein.width)) : 0,
fenster: fenster.length,
/* Und liegt auch jedes einzelne innerhalb? */
drin: !lein ? 0 : fenster.filter((f) => {
const r = f.getBoundingClientRect();
return r.left >= lein.left - 1 && r.right <= lein.right + 1
&& r.top >= lein.top - 1 && r.bottom <= lein.bottom + 1;
}).length,
};
});
const m = anordnungen[name];
console.log(` ${was.padEnd(12)} (${name}): Video ${m.video}, `
+ `Kameras ${m.kameras}, eine ${m.kamera}, ${m.drin}/${m.fenster} im Bild`
+ (m.ueber ? `, UEBER ${m.ueber} px` : ""));
if (m.ueber > 0 || (m.fenster > 0 && m.drin !== m.fenster && name !== "nur_video")) {
console.log(` ACHTUNG: Bei „${was}“ liegt ein Kamerafenster ausserhalb der Leinwand.`);
ausgang = 1;
}
await bild(zuschauer.seite, `15-anordnung-${name}`);
}
/* DAS WEGLASSEN MUSS WIRKLICH WEGLASSEN. `display: none` und nicht
nur klein -- ein unsichtbarer YouTube-Rahmen spielt weiter und
haelt den Ton. */
if (anordnungen.nur_video.kameras !== "weg") {
console.log(" ACHTUNG: Bei „nur Video“ stehen die Kameras noch da.");
ausgang = 1;
}
if (anordnungen.nur_kamera.video !== "weg") {
console.log(" ACHTUNG: Bei „nur Kamera“ steht das Video noch da.");
ausgang = 1;
}
/* UND DIE KAMERAS MUESSEN DABEI GROSS WERDEN. Ein Weglassen, nach dem
zwei Briefmarken in einer schwarzen Flaeche stehen, ist keine
Anordnung, sondern ein Fehler. */
const klein = Number(String(anordnungen.kino.kamera).split("x")[0]) || 0;
const gross = Number(String(anordnungen.nur_kamera.kamera).split("x")[0]) || 0;
console.log(` Kamerabreite: kino ${klein} px -> nur_kamera ${gross} px`);
if (gross <= klein * 1.5) {
console.log(" ACHTUNG: Bei „nur Kamera“ wird die Kamera nicht groesser.");
ausgang = 1;
}
await host.seite.click('#layout-reihe .layout-knopf[data-layout="kino"]').catch(() => {});
await host.seite.waitForTimeout(800);
/* ---- Der Umschalter zwischen zwei Videos ---- */
await host.seite.click('#reiter .reiter__knopf[data-tafel="sendung"]').catch(() => {});
await host.seite.waitForTimeout(500);
const vorherVerborgen = await host.seite.evaluate(() =>
!!document.getElementById("v-tauschen")?.hidden);
console.log(` Ohne zweites Video ist der Umschalter verborgen: ${vorherVerborgen}`);
if (!vorherVerborgen) {
console.log(" ACHTUNG: Ein Umschalter ohne Gegenstueck ist eine Falle.");
ausgang = 1;
}
await host.seite.fill("#p-zweit", "https://youtu.be/9bZkp7q19f0").catch(() => {});
await host.seite.click("#p-zweit-sichern").catch(() => {});
await host.seite.waitForTimeout(2600);
const umschalter = await host.seite.evaluate(() => {
const k2 = document.getElementById("v-tauschen");
const r = k2.getBoundingClientRect();
return {
versteckt: !!k2.hidden, wort: k2.textContent.replace(/\s+/g, " ").trim(),
breit: Math.round(r.width), hoch: Math.round(r.height),
laeuftJetzt: document.getElementById("spur-titel")?.textContent || "",
};
});
console.log(` Umschalter: „${umschalter.wort}" (${umschalter.breit}x${umschalter.hoch} px)`);
console.log(` Es laeuft: „${umschalter.laeuftJetzt}"`);
if (umschalter.versteckt || umschalter.hoch < 40) {
console.log(" ACHTUNG: Der Umschalter kommt nicht oder ist zu klein.");
ausgang = 1;
}
/* EINMAL HIN. Die Stelle muss sich merken -- ohne das waere „hin und
her" ein Neustart von vorn, und dann schaltet niemand zweimal. */
const stelleVorher = await uhrLesen(host.seite);
await host.seite.click("#v-tauschen");
await host.seite.waitForTimeout(3000);
const nachHin = await host.seite.evaluate(() => ({
titel: document.getElementById("spur-titel")?.textContent || "",
zurueckZu: document.getElementById("v-tausch-titel")?.textContent || "",
zeit: document.getElementById("v-zeit")?.textContent || "",
}));
console.log(` Nach dem Wechseln laeuft: „${nachHin.titel}", zurueck zu „${nachHin.zurueckZu}"`);
/* UND ZURUECK. */
await host.seite.click("#v-tauschen");
await host.seite.waitForTimeout(3000);
const nachZurueck = await host.seite.evaluate(() => ({
titel: document.getElementById("spur-titel")?.textContent || "",
zeit: document.getElementById("v-zeit")?.textContent || "",
}));
const stelleNachher = await uhrLesen(host.seite);
console.log(` Zurueck bei: „${nachZurueck.titel}", Uhr ${nachZurueck.zeit} `
+ `(vorher war ${stelleVorher} s, jetzt ${stelleNachher} s)`);
if (nachHin.titel === nachZurueck.titel) {
console.log(" ACHTUNG: Der Umschalter wechselt nicht.");
ausgang = 1;
}
/* Die gemerkte Stelle: etwa dort weiter, wo es aufgehoert hat --
sechs Sekunden Spiel fuer das Laden des Videos. */
if (stelleVorher === null || stelleNachher === null
|| Math.abs(stelleNachher - stelleVorher) > 8) {
console.log(` ACHTUNG: Die gemerkte Stelle ging verloren `
+ `(${stelleVorher} -> ${stelleNachher}).`);
ausgang = 1;
}
await bild(host.seite, "16-umschalter");
await host.seite.fill("#p-zweit", "").catch(() => {});
await host.seite.click("#p-zweit-sichern").catch(() => {});
await host.seite.waitForTimeout(900);
console.log("");
console.log(" --- Rollen und Moderation ---");
const modi = await alsWer("modi", 1440, 900);
await modi.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "domcontentloaded" });
await modi.seite.waitForTimeout(2500);
const abz = await modi.seite.evaluate(() => {
const liste = document.getElementById("chat-liste");
const marken = [...liste.querySelectorAll(".beitrag__rolle")].map((m) => ({
wort: m.textContent.trim(),
rolle: m.dataset.rolle,
schrift: Math.round(parseFloat(getComputedStyle(m).fontSize) * 100) / 100,
breit: Math.round(m.getBoundingClientRect().width),
}));
/* LAEUFT EINE ZEILE UEBER? Nicht die Zahl aus dem Stilblatt, die
tatsaechliche Breite gegen die tatsaechliche Liste. */
const innen = liste.getBoundingClientRect();
const ueber = [...liste.querySelectorAll(".beitrag")]
.map((b) => Math.round(b.scrollWidth - b.clientWidth))
.filter((x) => x > 0);
/* Und die Kopfzeile selbst -- dort stehen Name, Abzeichen und
Knopf nebeneinander. */
const koepfe = [...liste.querySelectorAll(".beitrag__wer")]
.map((h) => Math.round(h.scrollWidth - h.clientWidth))
.filter((x) => x > 0);
return {
marken, ueber, koepfe,
listenbreite: Math.round(innen.width),
namensknoepfe: liste.querySelectorAll(".beitrag__name").length,
beitraege: liste.children.length,
};
});
console.log(` Schiene ${abz.listenbreite} px breit, ${abz.beitraege} Beitraege, `
+ `${abz.marken.length} Abzeichen, ${abz.namensknoepfe} Namen zum Anfassen.`);
for (const m of abz.marken) {
console.log(` „${m.wort}" (${m.rolle}) ${m.schrift} px, ${m.breit} px breit`);
}
if (abz.marken.length < 3) {
console.log(" ACHTUNG: Die Abzeichen kommen im Chat nicht an.");
ausgang = 1;
}
/* 11,5 px ist die Untergrenze des Hauses (pruef-css-klassen). */
if (abz.marken.some((m) => m.schrift < 11.5)) {
console.log(" ACHTUNG: Ein Abzeichen ist kleiner als 11,5 px.");
ausgang = 1;
}
if (abz.ueber.length || abz.koepfe.length) {
console.log(` ACHTUNG: ${abz.ueber.length} Beitraege und ${abz.koepfe.length} Kopfzeilen `
+ `laufen ueber (${[...abz.ueber, ...abz.koepfe].join(", ")} px).`);
ausgang = 1;
}
/* EIN MODI MELDET NICHT -- er handelt. Der Melden-Knopf gehoert
denen, die nichts anderes tun koennen. */
const modiSicht = await modi.seite.evaluate(() =>
document.querySelectorAll("#chat-liste .beitrag__melden").length);
console.log(` Der Modi sieht ${modiSicht} Melden-Knoepfe (soll: 0) `
+ `und ${abz.namensknoepfe} Namen zum Anfassen.`);
if (modiSicht !== 0 || abz.namensknoepfe < 3) {
console.log(" ACHTUNG: Melden und Moderieren sind vertauscht.");
ausgang = 1;
}
/* DAS MENUE MUSS INS BILD PASSEN. Ein Menue, das unten oder rechts
heraussteht, ist auf dem Handy die Haelfte der Zeit nicht
bedienbar -- und man sieht es erst, wenn man es aufmacht. */
await modi.seite.click("#chat-liste .beitrag:last-child .beitrag__name").catch(() => {});
await modi.seite.waitForTimeout(400);
const menue = await modi.seite.evaluate(() => {
const m = document.querySelector(".modmenue");
if (!m) return null;
const r = m.getBoundingClientRect();
const knoepfe = [...m.querySelectorAll(".modmenue__knopf")].map((k2) => ({
wort: k2.textContent.trim(),
hoch: Math.round(k2.getBoundingClientRect().height),
}));
/* IST ES WIRKLICH ZU SEHEN? „Im Fenster" reicht nicht als Frage.
Beim ersten Bau lag das Menue nachweislich komplett im Fenster
(x=1099..1315 von 1440, y=652..840 von 900) und war trotzdem
abgeschnitten -- die Chatliste rollt, und ein rollender
Vorfahre beschneidet sein Kind. Gefunden hat das nicht die
Messung, sondern das Bildschirmfoto.
Deshalb jetzt der einzige Test, der die Frage beantwortet: An
die Stelle jedes Knopfes fassen und nachsehen, was dort
tatsaechlich liegt. */
const greifbar = [...m.querySelectorAll(".modmenue__knopf")].filter((k2) => {
const q = k2.getBoundingClientRect();
const oben = document.elementFromPoint(q.left + q.width / 2, q.top + q.height / 2);
return !!oben && (k2 === oben || k2.contains(oben));
}).length;
return {
links: Math.round(r.left), rechts: Math.round(r.right),
oben: Math.round(r.top), unten: Math.round(r.bottom),
breit: Math.round(r.width), knoepfe, greifbar,
fensterBreit: window.innerWidth, fensterHoch: window.innerHeight,
};
});
if (!menue) {
console.log(" ACHTUNG: Das Moderationsmenue geht nicht auf.");
ausgang = 1;
} else {
console.log(` Menue ${menue.breit} px breit bei x=${menue.links}..${menue.rechts} `
+ `(Fenster ${menue.fensterBreit}), y=${menue.oben}..${menue.unten}`);
for (const b of menue.knoepfe) console.log(` „${b.wort}" ${b.hoch} px hoch`);
console.log(` ${menue.greifbar} von ${menue.knoepfe.length} Knoepfen sind anfassbar`);
const raus = menue.rechts > menue.fensterBreit || menue.links < 0
|| menue.unten > menue.fensterHoch;
if (raus) {
console.log(" ACHTUNG: Das Menue steht aus dem Bild heraus.");
ausgang = 1;
}
if (menue.greifbar !== menue.knoepfe.length) {
console.log(" ACHTUNG: Ein Knopf des Menues ist verdeckt oder abgeschnitten.");
ausgang = 1;
}
if (menue.knoepfe.length < 4 || menue.knoepfe.some((b) => b.hoch < 44)) {
console.log(" ACHTUNG: Ein Knopf im Menue ist kleiner als 44 px.");
ausgang = 1;
}
}
await bild(modi.seite, "12-moderationsmenue");
/* STUMM SCHALTEN -- und beim Betroffenen nachsehen.
Der Chat steht dabei auf „offen": genau der Fall, in dem die
Auskunft vorher falsch war („Der Chat ist gerade zu"). */
await modi.seite.click('.modmenue__knopf:text("10 Minuten stumm")').catch(async () => {
await modi.seite.evaluate(() => {
const k2 = [...document.querySelectorAll(".modmenue__knopf")]
.find((x) => x.textContent.includes("stumm"));
if (k2) k2.click();
});
});
await modi.seite.waitForTimeout(1500);
const beimGast = await zuschauer.seite.evaluate(() => {
const f = document.getElementById("chat-feld");
return { gesperrt: !!f?.disabled, grund: f?.placeholder || "" };
});
console.log(` Die Stummgeschaltete sieht: gesperrt=${beimGast.gesperrt}, `
+ `Grund „${beimGast.grund}"`);
const serverSagt = await roh("/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast, rumpf: { text: "und jetzt?" },
});
console.log(` ... und der Server: ${serverSagt.code}/${serverSagt.j?.fehler}`);
if (!beimGast.gesperrt || !beimGast.grund.includes("stumm")
|| serverSagt.j?.fehler !== "stumm_geschaltet") {
console.log(" ACHTUNG: Die Stummschaltung kommt nicht oder mit falschem Grund an.");
ausgang = 1;
}
await roh(`/workspace/api/reaktion/massnahme/${IDS.gast}`, { method: "DELETE", keks: k.admin });
await modi.seite.waitForTimeout(900);
/* MELDEN -- der Weg fuer die, die selbst nichts tun koennen. */
const letzter = await roh("/workspace/api/reaktion/chat", { keks: k.admin });
const zuMelden = (letzter.j?.beitraege || []).filter((b) => b.person_id === IDS.gast2).pop();
if (zuMelden) {
await roh(`/workspace/api/reaktion/chat/${zuMelden.id}/melden`, {
method: "POST", keks: k.gast, rumpf: { grund: "unfreundlich" },
});
}
await modi.seite.waitForTimeout(1800);
const chip = await modi.seite.evaluate(() => {
const k2 = document.getElementById("chat-meldungen");
if (!k2) return null;
const r = k2.getBoundingClientRect();
return { versteckt: !!k2.hidden, wort: k2.textContent.trim(), hoch: Math.round(r.height) };
});
console.log(` Meldungs-Zeichen: „${chip?.wort}" (versteckt=${chip?.versteckt}, `
+ `${chip?.hoch} px hoch)`);
if (!chip || chip.versteckt || !/Meldung/.test(chip.wort)) {
console.log(" ACHTUNG: Die Meldung erreicht die Moderation nicht.");
ausgang = 1;
}
await modi.seite.click("#chat-meldungen").catch(() => {});
await modi.seite.waitForTimeout(400);
const liste = await modi.seite.evaluate(() => {
const m = document.querySelector(".modmenue--liste");
if (!m) return null;
const r = m.getBoundingClientRect();
const knoepfe = [...m.querySelectorAll(".modmenue__knopf")];
const greifbar = knoepfe.filter((k2) => {
const q = k2.getBoundingClientRect();
const oben = document.elementFromPoint(q.left + q.width / 2, q.top + q.height / 2);
return !!oben && (k2 === oben || k2.contains(oben));
}).length;
return {
zeilen: m.querySelectorAll(".modmeldung").length,
text: m.querySelector(".modmeldung__text")?.textContent || "",
knoepfe: knoepfe.length, greifbar,
rechts: Math.round(r.right), unten: Math.round(r.bottom),
fensterBreit: window.innerWidth, fensterHoch: window.innerHeight,
};
});
if (!liste || liste.zeilen < 1) {
console.log(" ACHTUNG: Die Meldungsliste geht nicht auf.");
ausgang = 1;
} else {
console.log(` ${liste.zeilen} Meldung(en), erste: „${liste.text}" -- `
+ `${liste.greifbar}/${liste.knoepfe} Knoepfe anfassbar`);
if (liste.rechts > liste.fensterBreit || liste.unten > liste.fensterHoch) {
console.log(" ACHTUNG: Die Meldungsliste steht aus dem Bild heraus.");
ausgang = 1;
}
if (liste.greifbar !== liste.knoepfe) {
console.log(" ACHTUNG: Ein Knopf der Meldungsliste ist verdeckt.");
ausgang = 1;
}
}
await bild(modi.seite, "13-meldungen");
if (modi.konsole.length) console.log(" KONSOLE:", modi.konsole.slice(0, 4).join(" | "));
/* AUS DER SENDUNG -- der haerteste Fall. Wer rausgenommen wird, muss
es ERFAHREN; vorher lief bei ihm das Video weiter, und nur die
Anwesenheitsmeldung schlug still fehl. */
await roh("/workspace/api/reaktion/massnahme", {
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast, art: "raus" },
});
await zuschauer.seite.waitForTimeout(2500);
const rausgesetzt = await zuschauer.seite.evaluate(() => {
const k2 = document.querySelector(".raus");
return {
da: !!k2, markiert: document.body.dataset.raus === "ja",
titel: k2?.querySelector(".raus__titel")?.textContent || "",
deckt: k2 ? Math.round(k2.getBoundingClientRect().height) : 0,
fensterHoch: window.innerHeight,
};
});
console.log(` Die Rausgenommene sieht: „${rausgesetzt.titel}" `
+ `(${rausgesetzt.deckt} von ${rausgesetzt.fensterHoch} px)`);
if (!rausgesetzt.da || !rausgesetzt.markiert
|| rausgesetzt.deckt < rausgesetzt.fensterHoch - 2) {
console.log(" ACHTUNG: Wer rausgenommen wird, merkt es nicht.");
ausgang = 1;
}
await bild(zuschauer.seite, "14-aus-der-sendung");
await roh(`/workspace/api/reaktion/massnahme/${IDS.gast}`, { method: "DELETE", keks: k.admin });
await modi.kontext.close();
await host.kontext.close();
await gast2.kontext.close();
await zuschauer.kontext.close();
{
/* ==== DIE REGIELEISTE AM HANDY ==================================
Sieben Register auf 390 Pixel passen nicht nebeneinander -- sie
muss rollen. Gemessen wird beides: dass sie rollt, und dass sie
trotzdem nur EINE Zeile hoch ist. Umbrechen waere die stille
Alternative, und die kostet das Video 44 Pixel. */
{
const chef = await alsWer("admin", 390, 844);
await chef.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "domcontentloaded" });
await chef.seite.waitForTimeout(2600);
const l = await chef.seite.evaluate(() => {
const r = document.getElementById("reiter");
const le = document.getElementById("regieleiste");
if (!r || le.hidden) return null;
const k = [...r.querySelectorAll(".reiter__knopf")];
const rr = r.getBoundingClientRect();
return {
hoch: Math.round(le.getBoundingClientRect().height),
zeilen: new Set(k.map((x) => Math.round(x.getBoundingClientRect().top))).size,
rollt: r.scrollWidth > r.clientWidth + 1,
sichtbar: k.filter((x) => {
const q = x.getBoundingClientRect();
return q.left >= rr.left - 1 && q.right <= rr.right + 1;
}).length,
schild: !!document.querySelector(".regieleiste__schild")?.offsetParent,
ueber: Math.max(0, document.documentElement.scrollWidth
- document.documentElement.clientWidth),
};
});
if (!l) {
console.log(" ACHTUNG: Am Handy gibt es die Regieleiste nicht.");
ausgang = 1;
} else {
console.log(` Handy-Regie: ${l.hoch} px, ${l.zeilen} Zeile(n), rollt=${l.rollt}, `
+ `${l.sichtbar} von 7 im Bild, Schild=${l.schild}, seitlich ${l.ueber} px`);
if (l.zeilen !== 1 || !l.rollt || l.ueber > 0 || l.schild) {
console.log(" ACHTUNG: Die Regieleiste am Handy bricht um, rollt nicht "
+ "oder schiebt die Seite breiter.");
ausgang = 1;
}
}
/* WAS BLEIBT VOM BILD, WENN DIE REGIE OFFEN IST?
Am Handy ist das die eigentliche Frage. Ein Kinosaal von 131
Pixeln ist kein Bild mehr, sondern ein Streifen -- und man
merkt es erst, wenn man mitten in der Sendung etwas einstellen
will und nicht mehr sieht, worueber man redet. */
const platzHandy = await chef.seite.evaluate(() => {
const g = (id) => Math.round(document.getElementById(id).getBoundingClientRect().height);
return { saal: g("saal"), leiste: g("regieleiste"), kino: g("teil-live"), pult: g("pult") };
});
console.log(` Handy offen: Saal ${platzHandy.saal} = Leiste ${platzHandy.leiste} `
+ `+ Kino ${platzHandy.kino} + Regie ${platzHandy.pult}`);
if (platzHandy.kino < 190) {
console.log(" ACHTUNG: Bei offener Regie bleibt vom Bild fast nichts.");
ausgang = 1;
}
await bild(chef.seite, "21-regieleiste-handy");
await chef.kontext.close();
}
const handy = await alsWer("gast2", 390, 844);
await handy.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
await handy.seite.waitForTimeout(4000);
await vermessen(handy.seite, "Handy");
await bild(handy.seite, "5-live-handy");
await handy.seite.waitForTimeout(500);
await bild(handy.seite, "6-live-handy-chat");
const hm = await handy.seite.evaluate(() => {
const g = (w) => { const e = document.querySelector(w); if (!e) return "(fehlt)";
const r = e.getBoundingClientRect(); const c = getComputedStyle(e);
return `${Math.round(r.width)}x${Math.round(r.height)} display=${c.display}`; };
return { saal: g("#saal"), kino: g("#teil-live"), leinwand: g(".leinwand"),
schiene: g(".schiene"), schieneWert: document.getElementById("teil-live")?.dataset.schiene };
});
console.log(" Handy-Masse:", JSON.stringify(hm));
if (handy.konsole.length) console.log(" KONSOLE:", handy.konsole.slice(0, 4).join(" | "));
}
await browser.close();
await new Promise((r) => vorbau.close(r));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("\nFertig.");
process.exit(ausgang);