diff --git a/server/pruef-reaktion-schmal.mjs b/server/pruef-reaktion-schmal.mjs new file mode 100644 index 00000000..82360333 --- /dev/null +++ b/server/pruef-reaktion-schmal.mjs @@ -0,0 +1,306 @@ +/* DIE REACTION AUF EINEM KLEINEN HANDY (02.10.2026) + ===================================================================== + + WARUM ES DIESE DATEI GIBT + + Auf meiner eigenen Merkliste stand seit dem 01.10.: „320x568 zeigt + noch ein 320x75 grosses Videofeld und eine 1 px schmale + Chatleiste." Nachgemessen stimmte davon keine einzige Zahl: + + Regie ZU bei 320x568: Video 127 px, Transport-Unterkante 568 + bei Fensterhoehe 568 -- alles erreichbar + Regie AUF bei 320x568: Video 0 px, Transport-Unterkante 1585 + Regie AUF bei 390/430: Video unveraendert, Transport am Rand + + Und die Chatleiste ist auf JEDER Handybreite `display: none` -- + sie liegt dort im Registerstreifen, das ist Absicht und keine + 1-px-Leiste. + + Eine Bestandsliste ist ein Wegweiser, keine Wahrheit. Auch meine + eigene. + + WAS HIER BEWACHT WIRD UND WAS NICHT + + Dass bei OFFENER Regie auf 320 px das Bild verschwindet, ist ein + BEKANNTER Mangel. In reaktion.css steht die Begruendung samt sechs + gescheiterten Versuchen, und dort steht auch, dass es ein eigenes + Nachdenken braucht (etwa: die Regie wird dort ein eigener + Bildschirm statt einer Schublade). Diese Pruefung macht daraus + KEINEN gruenen Haken -- sonst wuerde eine spaetere Reparatur hier + rot, und das waere die Welt verkehrt herum. + + Bewacht wird die GRENZE darum herum, und zwar dreierlei: + + 1. Mit GESCHLOSSENER Regie muss das Bild auch auf 320 px eine + brauchbare Hoehe haben. Faellt es dort je auf 0, ist das + etwas Neues und kein Bestand. + 2. Ab 360 px -- der Breite, ab der die Regie aus dem Fluss + genommen wird -- muss das Bild seine Hoehe auch bei OFFENER + Regie behalten. Genau dort verlaeuft die Zeile + `@media (pointer: coarse) and (min-width: 360px)`. + 3. IN JEDEM ZUSTAND muss der Knopf, der die Regie wieder + zumacht, im Fenster stehen UND obenauf liegen. Das ist der + Unterschied zwischen „haesslich" und „Falle": Solange er da + ist, kommt man immer wieder zum Bild zurueck. Gemessen ist + er es heute ueberall -- diese Zeile haelt das fest. + + DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-rschmal-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die schmale Reaction"); +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(300_000, "pruef-reaktion-schmal"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }), +}).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); +d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run("Filipe", "admin", hash, salt, 32768, new Date().toISOString()); +d.close(); + +const { chromium } = await import("playwright"); +const browser = await chromium.launch(); + +/* Die Grenze, ab der reaktion.css die Regie aus dem Fluss nimmt. + Sie steht dort als `and (min-width: 360px)` -- hier als Zahl, weil + diese Pruefung genau an ihr entlangmisst. */ +const AUS_DEM_FLUSS_AB = 360; +/* So hoch muss ein Bild mindestens sein, um ein Bild zu sein. 127 px + sind heute auf 320 gemessen; 100 laesst Luft, ohne 0 zu decken. */ +const BILD_MIN = 100; + +const GERAETE = [[320, 568, "Handy klein"], [390, 844, "Handy"], [430, 932, "Handy gross"]]; + +/** Was am Bildschirm wirklich steht -- in einem Zustand der Regie. */ +const messen = (seite) => seite.evaluate(() => { + const bild = document.getElementById("leinwand").getBoundingClientRect(); + const transport = document.querySelector(".transport")?.getBoundingClientRect(); + const zu = document.getElementById("reiter-auf"); + const zr = zu?.getBoundingClientRect(); + /* IM FENSTER heisst nicht ANKLICKBAR. `elementFromPoint` fragt, was + an dieser Stelle wirklich obenauf liegt -- ein Knopf unter einer + Schublade ist sichtbar und trotzdem nicht zu treffen. */ + const obenauf = zr + ? document.elementFromPoint(zr.left + zr.width / 2, zr.top + zr.height / 2) + : null; + return { + bild: Math.round(bild.height), + transportUnten: transport ? Math.round(transport.bottom) : null, + fenster: window.innerHeight, + zuImFenster: !!zr && zr.height > 0 && zr.top >= 0 && zr.bottom <= window.innerHeight + && zr.left >= 0 && zr.right <= window.innerWidth, + zuObenauf: !!obenauf && (obenauf.id === "reiter-auf" || !!obenauf.closest("#reiter-auf")), + }; +}); + +for (const [breite, hoehe, wie] of GERAETE) { + console.log(`\n=== ${wie} (${breite}x${hoehe}) ===`); + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, + hasTouch: true, isMobile: true }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message.slice(0, 90))); + + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + + /* DIE SENDUNG MUSS LAUFEN. Im Zustand „zu" sind Kino und Chat + `display: none` -- wer das misst, misst den geschlossenen Saal + und haelt das Video fuer kaputt. Genau dieser Fehler ist mir + heute beim ersten Anlauf passiert. + + Gestellt wird der Zustand ueber die SCHNITTSTELLE, nicht ueber + `dataset.stand` im Browser: Sonst pruefte die Messung einen + Zustand, den das Haus so nie herstellt. */ + await seite.evaluate(async () => { + await fetch("/workspace/api/reaktion", { + method: "PUT", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ titel: "Schmale Probe", video: "dQw4w9WgXcQ" }), + }); + await fetch("/workspace/api/reaktion/stand", { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "live" }), + }); + }); + await seite.goto(BASIS + "/workspace/reaktion.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1200); + + const stand = await seite.evaluate(() => document.getElementById("saal")?.dataset.stand); + ok(stand === "live", `die Sendung laeuft wirklich (data-stand="${stand}") — sonst misst alles darunter nichts`); + + /* ==== DIE KAMERAMELDUNG WIRD WEGGERAEUMT, UND ZWAR MIT GRUND ==== + + Ein kopfloser Browser hat keine Kamera. `getUserMedia` + scheitert, und die Seite schreibt zu Recht „Kamera oder Mikrofon + sind nicht freigegeben…" in `#fehler`. Gemessen werden soll hier + aber die Aufteilung einer LAUFENDEN SENDUNG, nicht die Warnung + eines Rechners ohne Kamera -- sonst misst diese Pruefung eine + Eigenschaft der Messumgebung und nennt sie einen Befund. + + WAS DIE MELDUNG KOSTET, IST TROTZDEM GEMESSEN und steht unten im + Protokoll. Am 02.10.2026: + + 320x568 Bild mit Meldung 56 px · ohne Meldung 127 px + 390x844 Bild 601 px -- die Meldung ist dort 0 px HOCH + + Beides ist auffaellig und beides gehoert Filipe vorgelegt, nicht + hier stillschweigend repariert: Auf dem kleinen Handy frisst die + Meldung 71 Pixel aus einer impliziten FUENFTEN Rasterzeile (der + Saal deklariert vier), auf dem normalen ist sie ueberhaupt nicht + zu lesen. Eine Meldung, die erklaert, wie man die Kamera + freigibt, und die man dabei nicht sehen kann, ist keine. */ + /* GEMESSEN WIRD, WAS SIE KOSTET -- nicht, wie hoch sie gerade ist. + + Erster Anlauf las die Hoehe des Feldes selbst. Die hing davon ab, + WANN gelesen wurde: in einem Lauf 62 px, im naechsten 0 px, weil + die Meldung erst eintrifft, wenn `getUserMedia` aufgegeben hat. + Eine Protokollzeile, die mal so und mal so ausfaellt, ist + wertlos. + + Der Unterschied VORHER/NACHHER im selben Lauf ist dagegen + stabil: Bild messen, Meldung leeren, Bild noch einmal messen. */ + /* ZUERST DIE REGIE ZUMACHEN. Steht sie offen, ist das Bild auf + 320 px ohnehin 0 -- dann misst die Kostenrechnung „0 -> 0" und + sagt nichts. Genau das war mein erster Anlauf. */ + await seite.evaluate(() => { document.getElementById("pult").dataset.auf = "nein"; }); + await seite.waitForTimeout(350); + + const kosten = await (async () => { + const vorher = await seite.evaluate(() => Math.round( + document.getElementById("raum").getBoundingClientRect().height)); + const text = await seite.evaluate(() => { + const f = document.getElementById("fehler"); + const t = (f?.textContent || "").trim(); + if (f) f.textContent = ""; + return t; + }); + await seite.waitForTimeout(350); + const nachher = await seite.evaluate(() => Math.round( + document.getElementById("raum").getBoundingClientRect().height)); + return { vorher, nachher, text }; + })(); + console.log(` Hinweisfeld: ${kosten.text ? `"${kosten.text.slice(0, 44)}…"` : "leer"}` + + ` · kostet das Bild ${kosten.nachher - kosten.vorher} px` + + ` (${kosten.vorher} -> ${kosten.nachher})`); + + /* ---- Regie zu ---------------------------------------------------- */ + await seite.evaluate(() => { document.getElementById("pult").dataset.auf = "nein"; }); + await seite.waitForTimeout(350); + const zu = await messen(seite); + console.log(` Regie zu : Bild ${zu.bild} px · Transport endet bei ` + + `${zu.transportUnten} von ${zu.fenster}`); + ok(zu.bild >= BILD_MIN, + `mit geschlossener Regie ist das Bild ${zu.bild} px hoch (mindestens ${BILD_MIN})`); + ok(zu.transportUnten !== null && zu.transportUnten <= zu.fenster + 1, + ` und die Transportleiste endet im Fenster (${zu.transportUnten} von ${zu.fenster})`); + ok(zu.zuImFenster && zu.zuObenauf, + " der Knopf zum Auf- und Zumachen steht im Fenster und liegt obenauf"); + + /* ---- Regie auf --------------------------------------------------- */ + await seite.evaluate(() => { document.getElementById("pult").dataset.auf = "ja"; }); + await seite.waitForTimeout(350); + const auf = await messen(seite); + console.log(` Regie auf: Bild ${auf.bild} px · Transport endet bei ` + + `${auf.transportUnten} von ${auf.fenster}`); + + if (breite >= AUS_DEM_FLUSS_AB) { + /* AB 360 px wird die Regie aus dem Fluss genommen und liegt ueber + dem Bild. Dann darf das Bild nichts verlieren. */ + ok(auf.bild >= BILD_MIN, + `auch mit offener Regie bleibt das Bild ${auf.bild} px hoch`); + ok(auf.transportUnten !== null && auf.transportUnten <= auf.fenster + 1, + ` und die Transportleiste bleibt im Fenster (${auf.transportUnten})`); + } else { + /* UNTER 360 px GILT DIESE ZUSAGE NICHT -- und das wird hier NICHT + als „in Ordnung" abgehakt. Behauptet wird nur, was auch nach + einer Reparatur noch wahr ist: dass man wieder herauskommt. + Ein gruener Haken auf „das Bild ist 0" wuerde bei der + Reparatur rot. */ + console.log(` (unter ${AUS_DEM_FLUSS_AB} px bleibt die Regie im Fluss — ` + + "bekannter Mangel, siehe reaktion.css. Hier wird nur geprueft, " + + "dass man wieder herauskommt.)"); + } + + ok(auf.zuImFenster && auf.zuObenauf, + " und der Knopf zum Zumachen ist auch jetzt da und anklickbar — " + + "das trennt einen Schoenheitsfehler von einer Falle"); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole.join(" | ") : "kein Absturz im Browser"); + + /* ---- GEGENPROBE: kann diese Messung ueberhaupt rot werden? -------- + Nur einmal, am schmalsten Geraet. Beide Behauptungen werden + absichtlich gebrochen und muessen dann durchfallen. */ + if (breite === 320) { + await seite.evaluate(() => { + document.getElementById("pult").dataset.auf = "nein"; + document.getElementById("leinwand").style.height = "0px"; + }); + await seite.waitForTimeout(250); + const kaputt = await messen(seite); + ok(kaputt.bild < BILD_MIN, + `Gegenprobe: ein auf 0 gedruecktes Bild faellt durch (${kaputt.bild} px)`); + + await seite.evaluate(() => { + document.getElementById("leinwand").style.height = ""; + const deckel = document.createElement("div"); + deckel.id = "deckel-probe"; + deckel.style.cssText = "position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.01)"; + document.body.append(deckel); + }); + await seite.waitForTimeout(250); + const verdeckt = await messen(seite); + ok(verdeckt.zuImFenster && !verdeckt.zuObenauf, + " und ein zugedeckter Knopf gilt nicht als anklickbar, obwohl er im Fenster steht"); + await seite.evaluate(() => document.getElementById("deckel-probe")?.remove()); + } + + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`); +console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); +process.exit(fehler === 0 ? 0 : 1);