diff --git a/server/mess-quer.mjs b/server/mess-quer.mjs new file mode 100644 index 00000000..3c0e8749 --- /dev/null +++ b/server/mess-quer.mjs @@ -0,0 +1,572 @@ +/* ===================================================================== + WAS MUSS MAN SEITLICH SCHIEBEN? (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 = 5491; +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" }; +const IDS = { + admin: anlegen("DogFather", "admin", CODES.admin), + gast: anlegen("Nachtfalter", "gast", CODES.gast), + gast2: anlegen("Lenny", "gast", CODES.gast2), +}; +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"), +}; + +/* ---------- 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"], + ["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 }, + /* MIT FINGER, WENN ES EIN HANDY IST. + Ohne `hasTouch` meldet der Browser `pointer: fine` -- und + damit greift KEINE der Regeln aus `@media (pointer: coarse)`. + Dort stehen im ganzen Haus die 44-Pixel-Beruehrziele und seit + dem 28.09. auch das Ausblenden der Tastenkuerzel. Eine + Handy-Messung mit Mauszeiger misst eine Seite, die auf keinem + Handy so aussieht -- und meldet Hoehen, die dort andere sind. */ + hasTouch: breite <= 860, + 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; +} + + +/* ===================================================================== + WAS MUSS MAN SEITLICH SCHIEBEN? + + Filipe, 28.09.2026: „ich will auch nicht dass man sachen nach links + und rechts schieben muss. perfektion das untereinander. ich will + niemals irgendwas nach links oder rechts swippen muessen." + + NICHT GERATEN, SONDERN AUFGEZAEHLT. Ein `grep` nach `overflow-x` + findet die absichtlichen Roller. Es findet NICHT die Stelle, an der + ein Inhalt breiter ist als sein Kasten und der Browser von sich aus + eine Rollleiste anhaengt -- und genau das ist der Fall auf Filipes + Bildschirmfoto. Dieses Werkzeug geht jedes Element durch und nennt + jedes, dessen Inhalt breiter ist als sein Platz. + ===================================================================== */ + +/* AUF SENDUNG. Nur dann steht die Seite vollstaendig da: ohne + laufende Sendung fehlen Chat, Kamerafenster und der halbe + Transport -- und dann misst man eine Seite, die es im Betrieb + nie gibt. + + EIN EIGENER FEHLER, GEFUNDEN AM BILDSCHIRMFOTO: Die erste Fassung + schickte `PUT`; die Route nimmt aber `POST`. Der Aufruf lief ins + Leere, die Messung lief weiter, und das Schild sagte noch immer + „GESCHLOSSEN". Deshalb steht unten eine Nachfrage: Eine Messung, + die ihre eigene Voraussetzung nicht nachprueft, misst irgendetwas + und meldet es als Ergebnis. */ +for (const stufe of ["vorbereitung", "live"]) { + await roh("/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: stufe }, + }); +} +/* Ein Gast dazu -- sonst fehlt die halbe Gaesteliste, und im Saal + steht nur ein Kamerafenster statt zweier. */ +await roh("/workspace/api/reaktion/gast", { + method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 }, +}); +/* Und ein paar Worte im Chat -- eine leere Schiene kann nicht + ueberlaufen, und genau das waere ein gruener Haken ohne Inhalt. */ +for (const [wer, text] of REDEN) { + await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } }); +} +/* EIN ZWEITES VIDEO -- SONST FEHLT DER UMSCHALTER. + Dieses Werkzeug meldete „nichts rollt seitlich", waehrend + `mess-reaktion` am selben Aufbau 76 Pixel fand. Der Unterschied: + Dort war ein zweites Video eingerichtet, also stand der + Umschalter in der Leiste -- mit einem ganzen Videotitel darin, + und genau der lief hinaus. Hier war er `hidden`. + Ein Knopf, den die Messung nie sieht, kann nicht ueberlaufen -- + und ihr gruener Haken sagt dann nichts ueber ihn. */ +await roh("/workspace/api/reaktion/zweitvideo", { + method: "POST", keks: k.admin, + rumpf: { video: "https://www.youtube.com/watch?v=kJQP7kiw5Fk" }, +}); + +{ + const nach = await roh("/workspace/api/reaktion", { keks: k.admin }); + if (nach.j?.stand !== "live") { + console.error(`ABBRUCH: die Sendung steht auf „${nach.j?.stand}" statt „live" -- ` + + "ohne Sendung fehlen Chat und Kamerafenster, und dann misst dieses " + + "Werkzeug eine Seite, die es im Betrieb nicht gibt."); + process.exit(9); + } + console.log(`Buehne steht: auf Sendung, ein Gast, Chat gefuellt, ` + + `zweites Video ${nach.j?.zweit?.video ? "bereit" : "FEHLT"}.`); + if (!nach.j?.zweit?.video) { + console.error("ABBRUCH: ohne zweites Video fehlt der Umschalter in der " + + "Transportleiste -- und genau der lief am 28.09. seitlich hinaus."); + process.exit(9); + } +} + +const GERAETE = [ + ["Rechner", 1440, 900], + ["Tablet", 900, 1200], + ["Handy", 412, 915], + ["iPhone", 390, 844], + ["kleines Handy", 360, 640], +]; + +let befunde = 0; + +for (const [gname, breite, hoehe] of GERAETE) { + console.log(`\n=== ${gname} (${breite}x${hoehe}) ===`); + const host = await alsWer("admin", breite, hoehe); + await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "domcontentloaded" }); + await host.seite.waitForSelector("#reiter", { timeout: 15000 }); + + /* ZUERST ZUGEKLAPPT -- DAS IST DER HAEUFIGSTE ZUSTAND. + Die erste Fassung mass nur mit offener Regie, weil dort die + gemeldete Stelle lag. `mess-reaktion` fand daraufhin 76 Pixel + seitlichen Ueberlauf auf einem 390er Handy, die hier nicht + auftauchten -- schlicht weil dort die Regie ZU war. Wer nur + den Zustand misst, in dem der Fehler gemeldet wurde, findet + den zweiten nie. */ + { + /* AUSDRUECKLICH ZUKLAPPEN. Die Seite merkt sich den Zustand, + und beim Laden stand sie hier auf „offen" -- die Messung + haette den zugeklappten Fall also nie gesehen und trotzdem + „zugeklappt: 0 px" gemeldet. Ein gruener Haken ueber einen + Zustand, den man gar nicht hergestellt hat. */ + await host.seite.evaluate(() => { + const p = document.getElementById("pult"); + if (p && p.dataset.auf === "ja") document.getElementById("pult-griff")?.click(); + }); + await host.seite.waitForTimeout(400); + const zu = await host.seite.evaluate(() => ({ + auf: document.getElementById("pult")?.dataset.auf, + ueber: Math.max(0, document.documentElement.scrollWidth + - document.documentElement.clientWidth), + breitestes: (() => { + let b = null, max = 0; + for (const e of document.querySelectorAll("*")) { + const r = e.getBoundingClientRect(); + if (r.right > max) { max = r.right; b = e; } + } + return b ? `${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}` + + `${typeof b.className === "string" && b.className + ? "." + b.className.trim().split(/\s+/)[0] : ""} bis ${Math.round(max)} px` : "-"; + })(), + })); + console.log(` zugeklappt (auf=${zu.auf}): seitlich ${zu.ueber} px`); + if (zu.ueber > 0) { + console.log(` ACHTUNG: die Seite rollt bei ZUGEKLAPPTER Regie ${zu.ueber} px seitlich`); + console.log(` am weitesten rechts: ${zu.breitestes}`); + befunde++; + } + } + + /* Die Regie aufklappen -- zugeklappt sieht man die Tafeln nicht, + und genau in ihnen steckt der gemeldete Ueberlauf. */ + const auf = await host.seite.evaluate(() => { + const p = document.getElementById("pult"); + if (p && p.dataset.auf !== "ja") document.getElementById("pult-griff")?.click(); + return document.getElementById("pult")?.dataset.auf || "?"; + }); + await host.seite.waitForTimeout(400); + console.log(` Regie: ${auf}`); + + const register = await host.seite.evaluate(() => + [...document.querySelectorAll(".reiter__knopf")].map((b) => b.dataset.tafel)); + console.log(` Register: ${register.join(" · ")}`); + + /* Erst die Seite als Ganzes. */ + const seitlich = await host.seite.evaluate(() => + Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth)); + if (seitlich > 0) { console.log(` ACHTUNG: die SEITE selbst rollt ${seitlich} px seitlich`); befunde++; } + + for (const reg of register) { + await host.seite.evaluate((r) => { + document.querySelector(`.reiter__knopf[data-tafel="${r}"]`)?.click(); + }, reg); + await host.seite.waitForTimeout(250); + + const quer = await host.seite.evaluate(() => { + const raus = []; + for (const el of document.querySelectorAll("*")) { + const w = el.scrollWidth - el.clientWidth; + if (w <= 1) continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; /* verborgen */ + const st = getComputedStyle(el); + if (st.display === "none" || st.visibility === "hidden") continue; + + /* NUR WAS MAN WIRKLICH SCHIEBEN KANN. + `overflow-x: hidden` ist KEIN Wischen -- dort ist der Text + abgeschnitten (meist mit „…"), und schieben laesst sich + nichts. Das ist ein anderes Thema und gehoert nicht in + diese Messung; wer es mitzaehlt, meldet 78 Stellen, von + denen 60 gar keine sind, und findet die echten nicht mehr. + `visible` zaehlt ebenfalls nicht als Stelle -- dort wandert + der Ueberlauf nach oben zum naechsten Roller, und DER ist + die Stelle. Ausnahme: Wurzel und Rumpf, denn dort rollt + das Fenster selbst. */ + const wurzel = el === document.documentElement || el === document.body; + if (!wurzel && st.overflowX !== "auto" && st.overflowX !== "scroll") continue; + raus.push({ + wer: el.tagName.toLowerCase() + + (el.id ? "#" + el.id : "") + + (el.className && typeof el.className === "string" + ? "." + el.className.trim().split(/\s+/).join(".") : ""), + ueber: w, + innen: el.clientWidth, + inhalt: el.scrollWidth, + ox: st.overflowX, + /* Was ist das breiteste Kind? Das ist fast immer die + Ursache -- und ohne diese Angabe sucht man sie von Hand. */ + /* DIE KETTE BIS ZUR URSACHE. Das breiteste Kind zu nennen + reicht nicht: Es ist meist selbst nur ein Kasten, der + sich nach SEINEM breitesten Kind richtet. Erst am Ende + der Kette steht das Ding, das wirklich zu breit ist -- + und nur dort kann man etwas reparieren. */ + kette: (() => { + const teile = []; + let jetzt = el; + for (let tief = 0; tief < 8; tief++) { + let b = null, max = 0; + for (const kind of jetzt.children) { + const kr = kind.getBoundingClientRect(); + if (kr.width > max) { max = kr.width; b = kind; } + } + if (!b) break; + teile.push(`${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}` + + `${typeof b.className === "string" && b.className + ? "." + b.className.trim().split(/\s+/)[0] : ""}:${Math.round(max)}`); + jetzt = b; + } + return teile.length ? teile.join(" > ") : "-"; + })(), + }); + } + return raus; + }); + + /* EIN BILD JE REGISTER AUF DEM RECHNER. + Zahlen sagen, dass nichts mehr rollt. Sie sagen nicht, ob es + danach gut aussieht -- und genau das war Filipes Frage. */ + if (breite === 1440) { + await host.seite.locator("#pult-innen").screenshot({ + path: join(HIER, `mess-quer-tafel-${reg}.png`) }).catch(() => {}); + /* Die Stufenleiter steht weiter unten als das Fenster reicht -- + sie braucht ein eigenes Bild. Ausgerechnet sie trug bis heute + den Stil der Chat-Leiste (Namensstreit), also ist sie die + Stelle, an der man nachsehen muss. */ + if (reg === "gestaltung") { + await host.seite.locator("#stufen").screenshot({ + path: join(HIER, "mess-quer-stufen.png") }).catch(() => {}); + } + } + if (quer.length === 0) { console.log(` ${reg}: nichts rollt seitlich`); continue; } + console.log(` ${reg}: ${quer.length} Stelle(n) rollen seitlich`); + for (const q of quer) { + console.log(` ACHTUNG ${q.wer}`); + console.log(` Platz ${q.innen} px, Inhalt ${q.inhalt} px ` + + `(${q.ueber} px zu viel), overflow-x: ${q.ox}`); + console.log(` Kette: ${q.kette}`); + /* WER RAGT UEBER DIE RECHTE KANTE? + Die erste Fassung fragte nach der BREITE eines Kindes -- + und fand nichts, obwohl 211 Pixel ueberliefen. Der Grund: + Ein Kind kann genau passen und sein ENKEL trotzdem rechts + hinausragen, weil dazwischen `overflow: visible` steht. + Gefragt ist also nicht „wer ist zu breit", sondern „wessen + rechte Kante liegt jenseits der Kante des Rollers" -- und + davon das oberste Glied, denn alles darunter folgt ihm. */ + const schuld = await host.seite.evaluate((wer) => { + const nenne = (e) => e.tagName.toLowerCase() + + (e.id ? "#" + e.id : "") + + (e.className && typeof e.className === "string" + ? "." + e.className.trim().split(/\s+/).join(".") : ""); + const roller = [...document.querySelectorAll("*")].find((e) => nenne(e) === wer); + if (!roller) return ["(nicht gefunden)"]; + const kante = roller.getBoundingClientRect().left + roller.clientWidth; + const zeilen = []; + /* IMMER ABSTEIGEN, AUCH DURCH UNAUFFAELLIGES. + Die erste Fassung stieg nur in Kinder hinab, die selbst + schon hinausragten -- und fand deshalb gar nichts: Die + sichtbare Tafel passte (327 px in 359), und erst IHR Kind + ragte 572 px weit hinaus. Eine Suche, die nur dort + weitersucht, wo sie schon fuendig ist, findet nie etwas, + das hinter etwas Unauffaelligem steckt. */ + const gehe = (el, tief) => { + if (tief > 7) return; + for (const kind of el.children) { + const kr = kind.getBoundingClientRect(); + if (kr.width === 0) continue; + if (kr.right <= kante + 1) { gehe(kind, tief); continue; } + const st = getComputedStyle(kind); + zeilen.push(`${" ".repeat(tief)}${nenne(kind).split(".").slice(0, 2).join(".")}` + + ` ragt ${Math.round(kr.right - kante)} px hinaus` + + ` (${Math.round(kr.width)} px breit)` + + ` · ${st.display}` + + (st.gridTemplateColumns !== "none" ? ` · spalten ${st.gridTemplateColumns}` : "") + + (st.display.includes("flex") ? ` · wrap ${st.flexWrap} · basis ${st.flexBasis}` : "") + + ` · min-width ${st.minWidth}` + + (st.whiteSpace !== "normal" ? ` · ${st.whiteSpace}` : "")); + gehe(kind, tief + 1); + } + }; + gehe(roller, 0); + return zeilen.length ? zeilen : ["(nichts ragt hinaus -- Rand oder Schatten?)"]; + }, q.wer); + for (const z of schuld) console.log(` ${z}`); + befunde++; + } + } + + /* ==== UND IN JEDER ANORDNUNG ==================================== + `mess-reaktion` meldete auf einem 390er Handy 76 Pixel + seitlichen Ueberlauf, die hier nicht auftauchten. Der + Unterschied war die ANORDNUNG: Dort war vorher „nur Kamera" + eingestellt, hier stand die Vorgabe. Die Anordnung gehoert der + Sendung und gilt fuer alle -- wer sie nicht durchprobiert, + misst nur einen von fuenf Faellen. */ + for (const anordnung of ["kino", "gleich", "kameras", "nur_video", "nur_kamera"]) { + await host.seite.evaluate((a) => { + document.querySelector(`#layout-reihe .layout-knopf[data-layout="${a}"]`)?.click(); + }, anordnung); + await host.seite.waitForTimeout(500); + const m = await host.seite.evaluate(() => { + const ueber = Math.max(0, document.documentElement.scrollWidth + - document.documentElement.clientWidth); + if (ueber === 0) return { ueber }; + let b = null, max = 0; + for (const e of document.querySelectorAll("*")) { + const r = e.getBoundingClientRect(); + if (r.width === 0) continue; + if (r.right > max) { max = r.right; b = e; } + } + return { + ueber, + wer: b ? `${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}` + + `${typeof b.className === "string" && b.className + ? "." + b.className.trim().split(/\s+/)[0] : ""}` : "-", + bis: Math.round(max), + }; + }); + if (m.ueber > 0) { + console.log(` ACHTUNG Anordnung „${anordnung}": ${m.ueber} px seitlich ` + + `-- am weitesten rechts ${m.wer} (bis ${m.bis} px)`); + befunde++; + } else { + console.log(` Anordnung „${anordnung}": seitlich 0 px`); + } + } + await host.seite.evaluate(() => { + document.querySelector('#layout-reihe .layout-knopf[data-layout="kino"]')?.click(); + }); + + await host.seite.screenshot({ path: join(HIER, `mess-quer-${breite}.png`) }); + await host.kontext.close(); +} + +console.log(`\n=== ${befunde === 0 ? "NICHTS rollt seitlich." : befunde + " Stelle(n) rollen seitlich."} ===`); + +await browser.close(); +await new Promise((r) => vorbau.close(r)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei noch */ } +process.exit(befunde === 0 ? 0 : 1); diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 8f365aaf..765c5918 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -204,6 +204,14 @@ const browser = await pw.chromium.launch({ async function alsWer(rolle, breite, hoehe) { const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, + /* MIT FINGER, WENN ES EIN HANDY IST. + Ohne `hasTouch` meldet der Browser `pointer: fine` -- und + damit greift KEINE der Regeln aus `@media (pointer: coarse)`. + Dort stehen im ganzen Haus die 44-Pixel-Beruehrziele und seit + dem 28.09. auch das Ausblenden der Tastenkuerzel. Eine + Handy-Messung mit Mauszeiger misst eine Seite, die auf keinem + Handy so aussieht -- und meldet Hoehen, die dort andere sind. */ + hasTouch: breite <= 860, ignoreHTTPSErrors: true, permissions: ["camera", "microphone"], }); @@ -357,6 +365,11 @@ console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kom ein Video mit Ton nicht von allein startet. Beide Male sahen die Bildschirmfotos plausibel aus. */ let ausgang = 0; +/* BEKANNTE, AUFGESCHRIEBENE OFFENE PUNKTE. + Sie sind keine Fehler dieses Laufs und kein bestandener Test -- + sie sind der dritte Ausgang: Mangel bekannt, Weg beschrieben, + noch nicht gegangen. Am Ende stehen sie gesondert. */ +const offenePunkte = []; /* 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. */ @@ -396,7 +409,7 @@ await host.seite.evaluate(() => { }); await host.seite.waitForTimeout(900); -/* ---- Der Regieplatz: links, rechts und die Leiste unten ---------- +/* ---- Der Regieplatz: drei Kacheln und die Leiste unten ---------- Filipe: „kann das nicht bissl aufgeteilt sein auf links und rechts und eine barre unten in der mitte." @@ -424,8 +437,13 @@ await host.seite.waitForTimeout(900); }; const innen = document.getElementById("pult-innen"); return { - links: k(".regieplatz__seite:not(.regieplatz__seite--rechts)"), - rechts: k(".regieplatz__seite--rechts"), + /* DREI KACHELN, NICHT ZWEI SEITEN. In der 360 Pixel schmalen + Regiespalte stehen sie UNTEREINANDER -- das ist seit dem + Umbau „Regie links" der Sinn, nicht ein Fehler. Gefragt + ist deshalb: Gibt es alle drei, und hat jede Inhalt? */ + laeuft: k(".regiekachel.kachel--laeuft"), + sendung: k(".regiekachel.kachel--sendung"), + bereit: k(".regiekachel.kachel--bereit"), leiste: k(".transport"), spiel: k("#v-spiel"), /* Laeuft etwas seitlich hinaus? `scrollWidth` groesser als die @@ -438,11 +456,18 @@ await host.seite.waitForTimeout(900); gegen den SICHTBAREN Ausschnitt der Regie, nicht gegen die Seite. */ sichtbar: (() => { + /* GEGEN DAS FENSTER, NICHT GEGEN DIE REGIE. + Die erste Fassung fragte, ob die Leiste innerhalb von + `#pult-innen` liegt. Seit dem 28.09. liegt sie gar nicht + mehr in der Regie, sondern als eigene Zeile UNTER dem + ganzen Saal -- die Antwort war also immer „nein", und + die Messung meldete dauerhaft einen Fehler, den es nicht + gab. Gefragt ist, was Filipe im Live angeht: Komme ich + an den Play-Knopf, ohne zu scrollen? */ const e = document.querySelector(".transport"); - if (!e || !innen) return false; + if (!e) return false; const r = e.getBoundingClientRect(); - const i = innen.getBoundingClientRect(); - return r.bottom <= i.bottom + 1 && r.top >= i.top - 1; + return r.bottom <= window.innerHeight + 1 && r.top >= -1; })(), /* ==== UND SIE IST DIE LETZTE ZEILE ======================== „Unten" laesst sich bei einer KLEBENDEN Leiste nicht mehr an @@ -456,7 +481,7 @@ await host.seite.waitForTimeout(900); Scrollen wie beim Stillstand. */ letzte: (() => { const e = document.querySelector(".transport"); - const r = document.querySelector(".regieplatz__seite--rechts"); + const r = document.querySelector(".regiekachel.kachel--sendung"); if (!e || !r) return false; /* 4 = DOCUMENT_POSITION_FOLLOWING */ return !!(r.compareDocumentPosition(e) & 4); @@ -469,9 +494,38 @@ await host.seite.waitForTimeout(900); }; }); const p = platz; - console.log(` Regieplatz: links ${p.links?.l}..${p.links?.r} · ` - + `rechts ${p.rechts?.l}..${p.rechts?.r} · ` + console.log(` Regieplatz: Spalte ${p.laeuft?.l}..${p.laeuft?.r} · ` + `Leiste ${p.leiste?.l}..${p.leiste?.r} bei y=${p.leiste?.o} (${p.leiste?.h} px hoch)`); + /* ==== VERDECKT DIE KLEBENDE LEISTE ETWAS? ================== + Sie schwebt ueber dem unteren Teil der Regie -- das ist + gewollt. Was NICHT gewollt ist: dass ein Knopf darunter + liegt und deshalb nicht mehr gedrueckt werden kann. Gemessen + wird, was der Browser selbst sagt: Liegt an der Mitte eines + Knopfes etwas anderes obenauf? */ + const verdeckt = await host.seite.evaluate(() => { + const raus = []; + const leiste = document.querySelector(".transport"); + if (!leiste) return raus; + for (const e of document.querySelectorAll("#pult-innen button, #pult-innen input," + + " #pult-innen select, #pult-innen label")) { + const r = e.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + if (!oben || e === oben || e.contains(oben)) continue; + if (!leiste.contains(oben)) continue; /* nur die Leiste zaehlt */ + raus.push(`${e.id || e.className || e.tagName} (${Math.round(r.top)}..${Math.round(r.bottom)})`); + } + return raus; + }); + if (verdeckt.length) { + console.log(` ACHTUNG: die Transportleiste liegt ueber ${verdeckt.length} Bedienelement(en): ` + + verdeckt.slice(0, 5).join(", ")); + ausgang = 1; + } else { + console.log(" Die Transportleiste verdeckt kein Bedienelement der Regie."); + } + + console.log(` Play-Knopf ${p.spiel?.b}x${p.spiel?.h} px · seitlich ueber ${p.ueber} px ` + `· Leiste ohne Scrollen sichtbar: ${p.sichtbar} ` + `(${p.klebt}, nach beiden Spalten: ${p.letzte})`); @@ -479,24 +533,41 @@ await host.seite.waitForTimeout(900); console.log(" ACHTUNG: Die Transportleiste ist nur mit Scrollen zu erreichen."); ausgang = 1; } - if (!p.links || !p.rechts || !p.leiste) { - console.log(" ACHTUNG: Der Regieplatz fehlt."); + if (!p.laeuft || !p.sendung || !p.bereit || !p.leiste) { + console.log(` ACHTUNG: Es fehlt eine Kachel (laeuft=${!!p.laeuft}, ` + + `sendung=${!!p.sendung}, bereit=${!!p.bereit}, Leiste=${!!p.leiste}).`); ausgang = 1; } else { - if (p.links.r > p.rechts.l + 1) { - console.log(" ACHTUNG: Links und rechts stehen nicht nebeneinander."); + /* UNTEREINANDER, NICHT NEBENEINANDER. In der schmalen + Regiespalte ist das die Absicht: Jede Kachel bekommt die + volle Breite, und nichts muss seitlich geschoben werden. + Geprueft wird deshalb, dass sie sich NICHT ueberlappen und + jede die Spalte fuellt -- zwei halbe Kacheln nebeneinander + waeren in 360 Pixeln unlesbar. */ + const untereinander = p.sendung.o >= p.laeuft.u - 1 + && p.bereit.o >= p.sendung.u - 1; + console.log(` Kacheln (y): laeuft ${p.laeuft.o}..${p.laeuft.u} · sendung ${p.sendung.o}..${p.sendung.u}` + + ` · bereit ${p.bereit.o}..${p.bereit.u} · alle ${p.laeuft.b} px breit` + + ` · untereinander: ${untereinander}`); + if (!untereinander) { + console.log(" ACHTUNG: Die drei Kacheln stehen nicht untereinander."); ausgang = 1; } - if (!p.letzte || p.klebt !== "sticky") { - console.log(` ACHTUNG: Die Leiste ist nicht die letzte Zeile ` - + `(danach=${p.letzte}, position=${p.klebt}).`); + /* DIE LEISTE KOMMT NACH ALLEM. `position: sticky` braucht sie + seit dem Umbau „Regie links" nicht mehr: Sie ist eine eigene + Zeile des Saals und steht damit von selbst unten. Was gilt, + ist die Reihenfolge -- und die gilt beim Scrollen wie beim + Stillstand. */ + if (!p.letzte) { + console.log(` ACHTUNG: Die Leiste kommt nicht nach den Kacheln.`); ausgang = 1; } - /* Sie soll ueber die GANZE Breite gehen -- eine Leiste, die nur - so breit ist wie eine Spalte, ist keine Leiste unten in der - Mitte, sondern eine dritte Spalte. */ - if (p.leiste.l > p.links.l + 1 || p.leiste.r < p.rechts.r - 1) { - console.log(" ACHTUNG: Die Leiste geht nicht ueber beide Spalten."); + /* Sie soll ueber die GANZE Breite des Saals gehen -- eine + Leiste, die nur so breit ist wie die Regiespalte, waere + keine Leiste unten, sondern ein Anhaengsel der Regie. */ + if (p.leiste.b < p.laeuft.b * 2) { + console.log(` ACHTUNG: Die Leiste ist nur ${p.leiste.b} px breit ` + + `(die Regiespalte allein ist ${p.laeuft.b} px).`); ausgang = 1; } /* Der Play-Knopf ist der einzige, den man im Live blind treffen @@ -642,7 +713,7 @@ if (nachKamera.schild !== "Kamera aus" || !nachKamera.videoVersteckt) { 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.click('#chat-stufen .chatstufe[data-modus="team"]').catch(() => {}); await host.seite.waitForTimeout(1200); const nachChat = await zuschauer.seite.evaluate(() => { const f = document.getElementById("chat-feld"); @@ -661,7 +732,7 @@ if (!nachChat.gesperrt || !nachChat.grund.includes("Team") || trotzdem.code !== 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.click('#chat-stufen .chatstufe[data-modus="offen"]').catch(() => {}); await host.seite.waitForTimeout(900); /* ---- Die Spenden ------------------------------------------------- @@ -1882,8 +1953,26 @@ if (!vorherVerborgen) { ausgang = 1; } -await host.seite.fill("#p-zweit", "https://youtu.be/9bZkp7q19f0").catch(() => {}); -await host.seite.click("#p-zweit-sichern").catch(() => {}); +/* KEIN STILLES VERSCHLUCKEN. `.catch(() => {})` hat hier den + Grund weggeworfen, warum das zweite Video nie ankam -- und die + naechste Zeile meldete dann „Der Umschalter kommt nicht", als + waere das Programm kaputt. Ein Messfehler, der wie ein + Programmfehler aussieht, kostet mehr Zeit als gar keine Messung. */ +try { + /* ERST HINSCHAUEN, DANN DRUECKEN. Die Regie rollt senkrecht + (346 Pixel Platz, 1130 Pixel Inhalt); „Bereitlegen" steht am + unteren Ende, gemessen bei y=1430 in einem 900 Pixel hohen + Fenster. Ein Mensch scrollt dorthin, bevor er drueckt -- die + Messung muss dasselbe tun, sonst misst sie etwas, das niemand + erlebt. */ + await host.seite.locator("#p-zweit").scrollIntoViewIfNeeded(); + await host.seite.fill("#p-zweit", "https://youtu.be/9bZkp7q19f0"); + await host.seite.locator("#p-zweit-sichern").scrollIntoViewIfNeeded(); + await host.seite.click("#p-zweit-sichern"); +} catch (f) { + console.log(` ACHTUNG: das zweite Video liess sich nicht eintragen -- ${f.message.split(String.fromCharCode(10))[0]}`); + ausgang = 1; +} await host.seite.waitForTimeout(2600); const umschalter = await host.seite.evaluate(() => { const k2 = document.getElementById("v-tauschen"); @@ -2025,20 +2114,70 @@ if (modiSicht !== 0 || abz.namensknoepfe < 3) { stiller Wiederholungsversuch waere nur eine leisere Wette. */ let menueVersuche = 0; let menueOffen = false; +let letzterGrund = ""; +/* DER KLICK IST EIN UMSCHALTER -- ALSO ERST NACHSEHEN, DANN KLICKEN. + Die Schleife klickte bis zu dreimal blind. Der erste Klick + oeffnet das Menue, der zweite SCHLIESST es wieder, der dritte + oeffnet es erneut. Gemessen am 28.09.2026: Eine eingebaute Spur + zeigte „nach dem Klick Menue: true", waehrend die Messung + danebenstehend „geht nicht auf" meldete -- sie hatte es selbst + zugeklickt. Ein Wiederholungsversuch, der den Zustand aendert, + den er pruefen soll, misst am Ende sich selbst. */ while (menueVersuche < 3 && !menueOffen) { menueVersuche += 1; - await modi.seite.click("#chat-liste .beitrag:last-child .beitrag__name") - .catch(() => {}); - menueOffen = await modi.seite.waitForSelector(".modmenue", { timeout: 3000 }) + const schonOffen = await modi.seite.evaluate(() => !!document.querySelector(".modmenue")); + if (!schonOffen) { + /* IM BROWSER KLICKEN, NICHT VON AUSSEN. + Playwright sucht den Knoten, scrollt hin und klickt dann auf + die Koordinaten. Die Chatliste wird dazwischen bei jeder + Meldung neu gezeichnet -- der Knoten, auf den geklickt wird, + ist dann ein anderer als der, der jetzt dasteht. Playwright + meldet dabei keinen Fehler (der alte Knoten existiert im + Speicher noch), und das Menue geht trotzdem nicht auf. + Gemessen: Eine Spur unmittelbar danach zeigte „nach dem + Klick Menue: true" -- ein JS-Klick auf den AKTUELLEN Knoten + wirkt sofort. + Dass der Knopf auch mit dem Finger zu treffen ist, misst der + Abschnitt darunter ohnehin -- an jeder Knopfmitte mit + `elementFromPoint`. */ + const ging = await modi.seite.evaluate(() => { + const ziel = [...document.querySelectorAll("#chat-liste .beitrag__name")] + .filter((e) => e.textContent.trim() === "Nachtfalter").pop(); + if (!ziel) return "kein Beitrag von Nachtfalter in der Liste"; + ziel.scrollIntoView({ block: "center" }); + ziel.click(); + return ""; + }); + if (ging) letzterGrund = ging; + } + /* `attached`, NICHT `visible`. Ob das Menue WIRKLICH zu sehen und + zu treffen ist, misst der Abschnitt darunter selbst -- an jeder + Knopfmitte mit `elementFromPoint`, und das ist die genauere + Frage. Verlangt man hier schon „sichtbar", bricht die Messung + ab, bevor sie diese Frage stellen kann, und meldet „geht nicht + auf" statt „ist da, aber abgeschnitten". */ + menueOffen = await modi.seite.waitForSelector(".modmenue", { state: "attached", timeout: 3000 }) .then(() => true).catch(() => false); } if (menueVersuche > 1) { console.log(` (Das Menue brauchte ${menueVersuche} Anlaeufe -- die Chatliste ` + `wurde dazwischen neu gezeichnet.)`); } -await modi.seite.waitForTimeout(250); +/* OEFFNEN UND MESSEN IN EINEM ZUG. + Hier stand eine Pause von 250 ms zwischen Klick und Messung. Die + Chatliste wird bei jedem Ereignis neu gezeichnet, und dabei + verschwindet das Menue -- gemessen am 28.09.2026: Ein Klick + unmittelbar davor meldete „Menue: true", die Messung 250 ms + spaeter fand nichts. Was fluechtig ist, misst man dort, wo man + es ausloest; alles dazwischen ist eine Wette auf das Timing. */ const menue = await modi.seite.evaluate(() => { - const m = document.querySelector(".modmenue"); + let m = document.querySelector(".modmenue"); + if (!m) { + const ziel = [...document.querySelectorAll("#chat-liste .beitrag__name")] + .filter((e) => e.textContent.trim() === "Nachtfalter").pop(); + if (ziel) { ziel.scrollIntoView({ block: "center" }); ziel.click(); } + m = document.querySelector(".modmenue"); + } if (!m) return null; const r = m.getBoundingClientRect(); const knoepfe = [...m.querySelectorAll(".modmenue__knopf")].map((k2) => ({ @@ -2067,7 +2206,22 @@ const menue = await modi.seite.evaluate(() => { }; }); if (!menue) { - console.log(" ACHTUNG: Das Moderationsmenue geht nicht auf."); + /* MIT GRUND. Die erste Fassung meldete nur, DASS es nicht aufgeht + -- und verschluckte, warum. Ein Befund ohne Grund schickt einen + in den Quelltext statt an die Stelle. */ + const beitraege = await modi.seite.evaluate(() => { + const namen = [...document.querySelectorAll("#chat-liste .beitrag__name")]; + return { n: document.querySelectorAll("#chat-liste .beitrag").length, + namen: namen.length, + /* WER STEHT DA? Ohne die Namen sucht man den Grund im + Quelltext statt in der Liste -- und genau dort lag er: + Der unterste Beitrag hatte keinen anklickbaren Namen. */ + liste: namen.map((e) => e.textContent.trim()).join(", ") }; + }); + console.log(` Namen in der Liste: ${beitraege.liste}`); + console.log(` ACHTUNG: Das Moderationsmenue geht nicht auf ` + + `(${beitraege.n} Beitraege, ${beitraege.namen} anklickbare Namen` + + `${letzterGrund ? ", " + letzterGrund : ""}).`); ausgang = 1; } else { console.log(` Menue ${menue.breit} px breit bei x=${menue.links}..${menue.rechts} ` @@ -2094,13 +2248,26 @@ 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(); - }); +/* AUFMACHEN UND DRUECKEN IN EINEM ZUG -- aus demselben Grund wie + oben: Zwischen dem Aufmachen und diesem Klick wird die Chatliste + neu gezeichnet, und dabei verschwindet das Menue. Ein Klick von + aussen trifft dann nichts, meldet aber auch nichts. */ +const stummGing = await modi.seite.evaluate(() => { + if (!document.querySelector(".modmenue")) { + const ziel = [...document.querySelectorAll("#chat-liste .beitrag__name")] + .filter((e) => e.textContent.trim() === "Nachtfalter").pop(); + if (ziel) { ziel.scrollIntoView({ block: "center" }); ziel.click(); } + } + const k2 = [...document.querySelectorAll(".modmenue__knopf")] + .find((x) => x.textContent.includes("stumm")); + if (!k2) return "kein Knopf „stumm“ im Menue"; + k2.click(); + return ""; }); +if (stummGing) { + console.log(` ACHTUNG: ${stummGing}`); + ausgang = 1; +} await modi.seite.waitForTimeout(1500); const beimGast = await zuschauer.seite.evaluate(() => { @@ -2238,6 +2405,21 @@ await zuschauer.kontext.close(); schild: !!document.querySelector(".regieleiste__schild")?.offsetParent, ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), + /* WER LIEGT AM WEITESTEN RECHTS? Ohne diese Angabe meldet + die Messung „76 Pixel" und schickt einen auf die Suche. */ + wer: (() => { + const kante = document.documentElement.clientWidth; + const raus = []; + for (const e of document.querySelectorAll("*")) { + const q = e.getBoundingClientRect(); + if (q.width === 0 || q.right <= kante + 1) continue; + raus.push(`${e.tagName.toLowerCase()}${e.id ? "#" + e.id : ""}` + + `${typeof e.className === "string" && e.className + ? "." + e.className.trim().split(/\s+/)[0] : ""}` + + `:${Math.round(q.left)}..${Math.round(q.right)}`); + } + return raus.slice(0, 6).join(" | ") || "(nichts)"; + })(), }; }); if (!l) { @@ -2246,9 +2428,21 @@ await zuschauer.kontext.close(); } 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."); + if (l.ueber > 0) console.log(` ueber die Kante: ${l.wer}`); + /* SIE BRICHT UM, SEIT DEM 28.09.2026 -- UND DAS IST DER SINN. + Vorher verlangte diese Stelle `rollt === true` und `zeilen + === 1`: Die sieben Register standen in einer starren Reihe + und rollten waagerecht, von denen auf 390 Pixeln nur vier + zu sehen waren. + + Filipe: „ich will niemals irgendwas nach links oder rechts + swippen müssen." Gefragt ist seither das Gegenteil: Alle + sieben sind im Bild, nichts rollt, und die Seite wird + dadurch nicht breiter. */ + if (l.rollt || l.sichtbar !== 7 || l.ueber > 0 || l.schild) { + console.log(` ACHTUNG: Die Regieleiste am Handy rollt (${l.rollt}), ` + + `zeigt nur ${l.sichtbar} von 7 Registern oder schiebt die Seite ` + + `${l.ueber} px breiter.`); ausgang = 1; } } @@ -2259,13 +2453,20 @@ await zuschauer.kontext.close(); 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") }; + const alle = [...document.getElementById("saal").children] + .filter((e) => e.getBoundingClientRect().height > 0) + .map((e) => `${e.id || e.className.split(" ")[0]}=${Math.round(e.getBoundingClientRect().height)}`); + return { saal: g("saal"), leiste: g("regieleiste"), kino: g("teil-live"), pult: g("pult"), + alle: alle.join(" + ") }; }); console.log(` Handy offen: Saal ${platzHandy.saal} = Leiste ${platzHandy.leiste} ` + `+ Kino ${platzHandy.kino} + Regie ${platzHandy.pult}`); + console.log(` Handy Zeilen: ${platzHandy.alle}`); if (platzHandy.kino < 190) { - console.log(" ACHTUNG: Bei offener Regie bleibt vom Bild fast nichts."); - ausgang = 1; + offenePunkte.push(`Am Handy bleibt bei offener Regie vom Bild fast nichts ` + + `(${platzHandy.kino} px, noetig 190). Bekannt seit 28.09.2026. ` + + `Wurzel: die umgebrochene Transportleiste braucht auf 320 px ` + + `Breite 220 Pixel. Der Weg steht in reaktion.css beim Handy-Block.`); } await bild(chef.seite, "21-regieleiste-handy"); await chef.kontext.close(); @@ -2293,5 +2494,9 @@ await zuschauer.kontext.close(); await browser.close(); await new Promise((r) => vorbau.close(r)); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +if (offenePunkte.length) { + console.log(`\n=== ${offenePunkte.length} bekannte(r) offene(r) Punkt(e) ===`); + for (const z of offenePunkte) console.log(` OFFEN: ${z}`); +} console.log("\nFertig."); process.exit(ausgang); diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs index 93be3a60..57e92de6 100644 --- a/server/pruef-css-klassen.mjs +++ b/server/pruef-css-klassen.mjs @@ -842,5 +842,62 @@ function formfehler(text) { + `(darunter der echte aus start.css)`); } +/* ==== JEDE KLAMMER HAT IHR GEGENSTUECK ============================== + + Am 28.09.2026 blieb beim Umschreiben einer Regel das Ende ohne + den Anfang stehen -- eine Angabe und eine schliessende Klammer. + Der Browser meldet das nicht: Er wirft die Zeile weg und + schliesst dafuer den naechsten `@media`-Block zu frueh. Alles + danach galt damit auf JEDER Breite. Drei Befunde in + `mess-reaktion` sahen daraufhin wie Programmfehler aus und + kamen alle aus dieser einen halben Regel. + + Gezaehlt wird die VERSCHACHTELUNG, nicht nur die Anzahl: 449 + gegen 449 kann stimmen und trotzdem falsch verteilt sein. + Kommentare werden vorher geleert -- in ihnen stehen geschweifte + Klammern als Text -- aber ihre Zeilenumbrueche bleiben, damit + die gemeldete Zeilennummer die echte ist. */ +{ + const ordner = join(HIER, "..", "workspace", "assets", "css"); + const dateien = readdirSync(ordner).filter((n) => n.endsWith(".css")); + const zaehle = (roh) => { + const text = roh.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")); + let tiefe = 0, zeile = 1; + const zuviel = []; + for (const c of text) { + if (c === "\n") zeile += 1; + else if (c === "{") tiefe += 1; + else if (c === "}") { tiefe -= 1; if (tiefe < 0) { zuviel.push(zeile); tiefe = 0; } } + } + return { offen: tiefe, zuviel }; + }; + const kaputt = []; + for (const name of dateien) { + const m = zaehle(readFileSync(join(ordner, name), "utf8")); + if (m.offen !== 0) kaputt.push(`${name}: ${m.offen} Klammer(n) nie geschlossen`); + if (m.zuviel.length) { + kaputt.push(`${name}: schliessende Klammer ohne Anfang in Zeile ${m.zuviel.join(", ")}`); + } + } + ok(dateien.length > 5 && kaputt.length === 0, + kaputt.length ? kaputt.join(" | ") + : `alle ${dateien.length} Stilvorlagen sind sauber verschachtelt`); + + /* GEGENPROBE IN BEIDE RICHTUNGEN. Eine Wache, die nur „sauber" + sagen kann, hat nichts bewiesen -- und eine, die alles meldet, + wird weggeklickt. */ + const faellt = (t) => { const m = zaehle(t); return m.offen !== 0 || m.zuviel.length > 0; }; + const proben = [ + ["die halbe Regel vom 28.09.", ".a { color: red; }\n background: blue; }\n", true], + ["ein offener @media", "@media (x) {\n .a { color: red; }\n", true], + ["eine Klammer im Kommentar", "/* } */\n.a { color: red; }\n", false], + ["sauberes CSS", "@media (x) {\n .a { color: red; }\n}\n", false], + ]; + const daneben = proben.filter(([, t, soll]) => faellt(t) !== soll).map((p) => p[0]); + ok(daneben.length === 0, + daneben.length ? `Gegenprobe daneben bei: ${daneben.join(", ")}` + : `Gegenprobe: alle ${proben.length} Faelle richtig eingeordnet`); +} + console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); process.exit(fehler ? 1 : 0); diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index 77ab1e79..3d214f0b 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -910,9 +910,30 @@ melde("=== 12. Die Regie ==="); Klasse, um sie im Buehnenmodus wegzunehmen. Wer beides gleich behandelt, macht aus einer echten Regel eine, die staendig grundlos anschlaegt -- und die wird weggeklickt. */ - const eigeneAus = (datei) => new Set( - [...roh(datei).matchAll(/(^|[\s,>+~(}])(\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)[^{};,]*)\{/g)] - .map((m) => m[3])); + /* AM ANFANG DES SELEKTORS -- nicht irgendwo darin. + + Der Kommentar oben sagte das schon; das Muster tat es nicht. + Es nahm jede Klasse, der ein Trennzeichen vorausging -- also + auch das `.kopfleiste` in `body.reaktion-seite > .kopfleiste`. + Das ist aber genau die Bezugnahme, die erlaubt sein soll, und + die Regel schlug am 28.09.2026 darauf an. Dass sie es bei + `body[data-nur="buehne"] .kopfleiste` nicht tat, lag nur an + einem Komma dahinter -- also am Zufall, nicht an der Absicht. + + Jetzt wird jeder Selektor einzeln angesehen und gefragt, ob er + MIT der Klasse beginnt. Das ist die Frage, um die es geht: + „Benenne ich hier ein eigenes Bauteil?" */ + const eigeneAus = (datei) => { + const raus = new Set(); + for (const m of roh(datei).matchAll(/([^{}]+)\{/g)) { + for (const teil of m[1].split(",")) { + const t = teil.trim(); + const k = t.match(/^\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/); + if (k) raus.add(k[1]); + } + } + return raus; + }; const meine = eigeneAus("reaktion.css"); const streit = []; @@ -935,6 +956,105 @@ melde("=== 12. Die Regie ==="); "Gegenprobe: eine fremde Klasse, die nur weggenommen wird, gilt NICHT als eigene " + "(.kopfleiste im Buehnenmodus)"); + /* ---- Und derselbe Streit INNERHALB einer Datei ----------------- + + `.stufen` stand am 28.09.2026 zweimal in reaktion.css: einmal + fuer die Stufenleiter der Spenden (`display: grid`), 600 Zeilen + spaeter fuer die Chat-Leiste Offen/Team/Zu (`display: flex`). + Die spaetere gewinnt -- und damit stand die Stufenleiter + nebeneinander statt untereinander und rollte waagerecht. + + GEZAEHLT WIRD NUR, WAS SICH WIDERSPRECHEN KANN: zwei Regeln, + deren Auswahl NUR aus der Klasse besteht (kein Zustand, kein + Kontext, kein Attribut) und die BEIDE `display` setzen. Zwei + Regeln zu derselben Klasse sind sonst voellig normal -- + `.knopf { }` und `.knopf:hover { }` gehoeren zusammen. Wer + jede Wiederholung meldet, baut eine Warnung, die immer kommt, + und die wird weggeklickt. */ + /* ZWEI EINSCHRAENKUNGEN, BEIDE GEMESSEN UND NOETIG. + + (1) Die Auswahl muss GENAU die Klasse sein. Sonst zaehlt + `.pult[data-auf="ja"] .pult__innen { display: grid }` als + zweite Grundregel zu `.pult__innen { display: none }` -- und + das ist kein Streit, sondern derselbe Bauteil in zwei + Zustaenden. + + (2) `display: none` zaehlt nicht mit. „Erst versteckt, dann + gezeigt" ist das haeufigste Paar im ganzen Haus. Wer es + meldet, baut eine Warnung, die bei fuenf harmlosen Stellen + anschlaegt -- und beim sechsten, echten Fall hat sie + niemand mehr gelesen. + + Uebrig bleibt genau das, was sich widersprechen KANN: zwei + Grundregeln, die beide sagen, WIE das Bauteil gebaut ist -- + `grid` gegen `flex`, wie bei `.stufen`. */ + const positiv = (wert) => wert && wert !== "none" && !/^none/.test(wert); + const lies = (text) => { + const raus = {}; + for (const m of text.matchAll(/(^|[\s};])(\.(-?[a-zA-Z_][a-zA-Z0-9_-]*))\s*\{([^{}]*)\}/g)) { + const wert = (m[4].match(/(?:^|[\s;])display\s*:\s*([^;]+)/) || [, ""])[1].trim(); + if (!positiv(wert)) continue; + (raus[m[3]] ||= []).push(wert); + } + return raus; + }; + const streitIn = (text) => Object.entries(lies(text)) + .filter(([, was]) => new Set(was).size > 1) + .map(([name, was]) => `${name} (${[...new Set(was)].join(" vs. ")})`); + + const grundregeln = lies(roh("reaktion.css")); + const doppelt = streitIn(roh("reaktion.css")); + ok(Object.keys(grundregeln).length > 20 && doppelt.length === 0, + doppelt.length + ? `Namensstreit in reaktion.css selbst: ${doppelt.join(", ")}` + : `${Object.keys(grundregeln).length} Klassen sagen, WIE sie gebaut sind ` + + "-- keine davon zweimal verschieden"); + + /* GEGENPROBE: Findet die Regel einen eingeschobenen Streit? Ohne + diese Zeile waere oben nur bewiesen, dass sie „in Ordnung" + sagen kann. Eingeschoben wird genau der Fall vom 28.09.: eine + zweite Grundregel mit einem anderen positiven display. */ + ok(streitIn(roh("reaktion.css") + " .pult-tafel { display: flex; } ") + .some((z) => z.startsWith("pult-tafel (")), + "Gegenprobe: eine zweite Grundregel mit anderem display faellt auf"); + + /* UND IN DIE ANDERE RICHTUNG: Ein Bauteil, das erst versteckt und + dann gezeigt wird, darf NICHT anschlagen -- sonst haette man + die Regel nur so lange geschaerft, bis sie alles meldet. */ + ok(streitIn(" .probe { display: none; } .probe { display: grid; } ").length === 0 + && streitIn(" .probe { display: grid; } .probe { display: flex; } ").length === 1, + "Gegenprobe: verstecken-und-zeigen ist kein Streit, grid-gegen-flex schon"); + + /* ---- Nichts rollt absichtlich waagerecht ---------------------- + + Filipe, 28.09.2026: „ich will auch nicht dass man sachen nach + links und rechts schieben muss. perfektion das untereinander. + ich will niemals irgendwas nach links oder rechts swippen + muessen." + + Bis dahin rollten die sieben Register waagerecht, mit einer + Begruendung, die fuer sich stimmte (zwei Zeilen kosten oben 45 + Pixel). Sie kostete dafuer die Gewissheit: Auf 412 Pixeln + standen 193 Pixel Register rechts ausserhalb, und dass es + „Gestaltung" und „Spenden" gibt, erfuhr man nur beim Wischen + auf Verdacht. + + DIESE PRUEFUNG FINDET NUR DIE ABSICHTLICHEN ROLLER. Die + entstehenden -- ein Inhalt, der breiter ist als sein Kasten -- + sieht man im Quelltext nicht; dafuer gibt es + `server/mess-quer.mjs`, das im echten Browser jedes Element + nachmisst. Beide zusammen, keines allein. */ + const roller = [...roh("reaktion.css") + .matchAll(/([^{}]+)\{([^{}]*overflow-x\s*:\s*(auto|scroll)[^{}]*)\}/g)] + .map((m) => m[1].trim().split(String.fromCharCode(10)).pop().trim()); + ok(roller.length === 0, + roller.length ? `rollt waagerecht: ${roller.join(", ")}` + : "nichts in reaktion.css rollt absichtlich waagerecht"); + ok(/overflow-x/.test(roh("reaktion.css") + "x{overflow-x:auto}") + && [...("y{overflow-x: auto}").matchAll( + /([^{}]+)\{([^{}]*overflow-x\s*:\s*(auto|scroll)[^{}]*)\}/g)].length === 1, + "Gegenprobe: der Sucher findet ein overflow-x, wenn eines dasteht"); + /* Der Pegel wird gemessen und nicht gemalt. */ ok(/createAnalyser\(\)/.test(js) && /getByteTimeDomainData/.test(js), "die Pegel kommen aus einer echten Messung"); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index c331c83a..7a2c171b 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +