diff --git a/server/pruef-dialog-sichtbar.mjs b/server/pruef-dialog-sichtbar.mjs new file mode 100644 index 00000000..4b60546f --- /dev/null +++ b/server/pruef-dialog-sichtbar.mjs @@ -0,0 +1,350 @@ +/* ===================================================================== + EIN DIALOG MUSS IM BILD STEHEN (05.10.2026) + ===================================================================== + + Patrick über den Support: „Sophie kann Abschnitt 3 und 4 nicht + bestätigen, beim drücken kommt ein schwarzer Bildschirm." + + Der Dialog war die ganze Zeit da — nur nicht zu sehen. `module.css` + setzte `.dialog { position: relative }`, und damit war die + Zentrierung des Browsers überschrieben: Ein Dialog aus + `showModal()` gehört mit `position: fixed` in die Mitte des + SICHTBAREN FENSTERS, mit `relative` landet er im Dokumentfluss nahe + dem SEITENANFANG. Wer nach unten gescrollt hatte, sah nur noch den + abdunkelnden Schleier. + + --------------------------------------------------------------------- + WARUM ES NIEMANDEM AUFGEFALLEN IST + + Auf einer kurzen Seite scrollt niemand, und dann stimmt es zufällig. + Es traf 22 Aufrufe von `showModal()` in 10 Dateien — vom Löschen + einer Datei bis zum Eintragen eines Monatsziels. Keine einzige + Prüfung im Haus hat je einen Dialog geöffnet, NACHDEM sie gescrollt + hat. Genau das tut diese hier. + + --------------------------------------------------------------------- + DREI EBENEN, UND DIE UNTERSTE ALLEIN WÄRE ZU WENIG + + 1. Im Quelltext: Keine Regel darf `.dialog` wieder aus dem Fenster + nehmen. Das findet den Rückfall sofort und ohne Browser. + 2. Am echten Bildschirm: zwei verschiedene Dialoge auf zwei + verschiedenen Seiten, jeweils ganz nach unten gescrollt. + 3. Die Gegenprobe: Mit wieder eingesetztem `position: relative` + MUSS die Messung anschlagen. Ohne sie wüsste niemand, ob hier + überhaupt etwas gemessen wird — „ist im Bild" ist auf einer + ungescrollten Seite immer wahr. + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { request as httpAnfrage } from "node:http"; + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Dialogpruefung"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const ordner = mkdtempSync(join(tmpdir(), "ws-dlg-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { + return ec.call(this, n, w, { ...(o || {}), secure: false }); +}; +await import("./index.js"); +notbremse(240_000, "pruef-dialog-sichtbar"); +await new Promise((r) => setTimeout(r, 900)); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +function braucht(bedingung, was) { + if (bedingung) return; + console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} + +/* ===================================================================== + 1 · IM QUELLTEXT + ===================================================================== + Billig, schnell und ohne Browser -- und findet den Rueckfall, bevor + jemand ihn sieht. Gesucht wird in ALLEN Stilvorlagen, nicht nur in + module.css: Die naechste Regel koennte woanders stehen. + ===================================================================== */ +melde("\n=== Im Quelltext ==="); +const CSS_ORDNER = join(HIER, "..", "workspace", "assets", "css"); +const dateien = readdirSync(CSS_ORDNER).filter((f) => f.endsWith(".css")); +braucht(dateien.length > 5, `nur ${dateien.length} Stilvorlagen gefunden`); + +const schlecht = []; +let regeln = 0; +/** Trifft diese Auswahl den Dialog SELBST? + * + * Beim ersten Lauf hat die Suche drei Fehlalarme gemeldet, und alle + * drei waren meine Schuld, nicht die des Hauses: + * + * * ein KOMMENTAR, in dem das Wort .dialog vorkam + * * `.dialog > *` -- das sind die KINDER, die duerfen relativ sein + * * `.dialog__titel` -- ein ganz anderes Element + * + * Eine Suche, die bei jedem Lauf meckert, wird nach dem zweiten Mal + * weggeklickt, zusammen mit dem echten Befund darin. Deshalb zaehlt + * nur, was wirklich auf dem Dialog endet. */ +const trifftDialog = (auswahl) => auswahl.split(",").some((teil) => { + const t = teil.trim(); + if (!t) return false; + /* Das letzte Glied der Auswahl muss der Dialog sein -- nach einem + Leerzeichen oder `>` kommen die Kinder. */ + const letztes = t.split(/[\s>+~]+/).filter(Boolean).pop() || ""; + if (!/(^|\.)dialog(?![\w-])/.test(letztes)) return false; + return !/::/.test(letztes); // ::backdrop und Co. duerfen +}); + +for (const name of dateien) { + /* KOMMENTARE ZUERST WEG. Dieses Haus schreibt viel in Kommentare -- + darunter das Wort `.dialog`. Ohne diesen Schritt misst die + Pruefung Prosa. */ + const text = readFileSync(join(CSS_ORDNER, name), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + const bloecke = text.matchAll(/([^{}]*)\{([^{}]*)\}/g); + for (const b of bloecke) { + const auswahl = b[1].trim(); + if (!trifftDialog(auswahl)) continue; + const stellung = b[2].match(/(^|[;\s])position\s*:\s*([a-z-]+)/); + if (!stellung) continue; + regeln++; + if (/^(relative|static|absolute)$/.test(stellung[2])) { + schlecht.push(`${name}: ${auswahl.slice(0, 48)} → position: ${stellung[2]}`); + } + } +} +ok(regeln > 0, `${regeln} Regel(n) setzen die Stellung eines Dialogs — es gibt etwas zu prüfen`); +ok(schlecht.length === 0, schlecht.length + ? `${schlecht.length} Regel(n) nehmen den Dialog aus dem Fenster: ${schlecht.join(" · ")}` + : "keine Regel nimmt den Dialog aus dem Fenster"); + +/* GEGENPROBE fuer die Textsuche: Sie muss so eine Regel auch WIRKLICH + finden. Eine Suche, die nie etwas findet, meldet immer „in Ordnung". */ +ok(trifftDialog(".dialog") && trifftDialog(".haus .dialog") && trifftDialog("a, .dialog"), + "Gegenprobe: der Dialog selbst wird erkannt"); +ok(!trifftDialog(".dialog > *") && !trifftDialog(".dialog__titel") + && !trifftDialog(".dialog .mz-feld"), + "Gegenprobe: Kinder und gleichnamige Nachbarn werden NICHT erkannt"); + +/* ===================================================================== + 2 · AM ECHTEN BILDSCHIRM + ===================================================================== */ +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"); +const jetzt = new Date().toISOString(); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kenn, jetzt); +const CODES = {}; +function anlegen(name, rolle, code) { + const salz = randomBytes(16).toString("hex"); + const h = scryptSync(code, salz, 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, h, salz, 32768, + createHmac("sha256", kenn).update(code).digest("hex"), "2025-10-01T08:00:00Z"); + CODES[name] = code; + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idCreator = anlegen("Creatorin", "creator", "CODE-DLG-CREA-1"); +anlegen("Kevin", "scout", "CODE-DLG-SCOUT-1"); + +/* DIE TABELLEN ENTSTEHEN ERST BEIM ERSTEN ZUGRIFF + (`tabelle()` in workspace-schulung.js). Ohne diesen Aufruf gibt es + `schulungen` noch gar nicht -- beim ersten Lauf stand hier „no such + table". Das ist kein Fehler im Haus, sondern die Reihenfolge. */ +d.close(); +await anfrageVorab(); +const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + +/* Vier Unterweisungen -- genau Sophies Lage: die ersten beiden + abgeschlossen, die unteren beiden offen. Dadurch stehen die + Knoepfe weit unten, und genau darum ging es. */ +for (let i = 1; i <= 4; i++) { + d2.prepare(`INSERT INTO schulungen (titel, beschreibung, pflicht, aktiv, erstellt) + VALUES (?,?,1,1,?)`).run(`Abschnitt ${i}`, `Regeln, Teil ${i}`, jetzt); +} +for (const sid of [1, 2]) { + d2.prepare(`INSERT INTO schulung_bestaetigt + (schulung_id, creator_id, betreuer_id, creator_am, betreuer_am, + abgeschlossen, abgeschlossen_am) VALUES (?,?,1,?,?,1,?)`) + .run(sid, idCreator, jetzt, jetzt, jetzt); +} +d2.close(); + +/* Ein Aufruf, der die Schulungstabellen anlegt -- angemeldet, denn + die Schnittstelle will eine Sitzung sehen. */ +async function anfrageVorab() { + const an = await anfrage("/workspace/api/anmelden", "POST", + { rolle: "creator", code: "CODE-DLG-CREA-1" }); + braucht(an.code === 200, `Vorab-Anmeldung ergab ${an.code}`); + const k = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; "); + await new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: "/workspace/api/schulung", method: "GET", + headers: { Cookie: k, Origin: `http://127.0.0.1:${PORT}` }, + }, (r) => { r.on("data", () => {}); r.on("end", fertig); }); + a.on("error", schief); + a.end(); + }); +} + +/* Als FUNKTION und nicht als `const`: Sie wird von `anfrageVorab` + weiter oben gebraucht, und eine `const` ist dort noch nicht da + (ReferenceError beim ersten Lauf). Eine Funktion wird vorgezogen. */ +function anfrage(pfad, art, koerper) { return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: art, + headers: { "Content-Type": "application/json", Origin: `http://127.0.0.1:${PORT}` }, + }, (r) => { + let t = ""; r.on("data", (x) => { t += x; }); + r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); +}); } +async function keksFuer(rolle, code) { + const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code }); + braucht(an.code === 200, `Anmeldung ${rolle} ergab ${an.code}`); + return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; "); +} +const keks = { + creator: await keksFuer("creator", "CODE-DLG-CREA-1"), + scout: await keksFuer("scout", "CODE-DLG-SCOUT-1"), +}; + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +/** Oeffnet einen Dialog NACH dem Scrollen und sagt, wo er landet. + * `kaputtMachen` setzt die alte, fehlerhafte Regel wieder ein -- + * damit laesst sich beweisen, dass die Messung auch Nein sagen kann. */ +async function dialogMessen(wer, pfad, knopfWahl, breite, hoehe, kaputtMachen = false) { + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860, + }); + await kontext.addCookies(keks[wer].split("; ").map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + await seite.goto(`http://127.0.0.1:${PORT}${pfad}`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(1500); + if (kaputtMachen) { + /* WAS DIESE GEGENPROBE BEWEIST -- und was nicht. + + Sie beweist, dass die MESSUNG anschlagen kann: Ein Dialog, der + nicht im sichtbaren Fenster steht, wird als solcher erkannt. + Ohne sie waere „er steht im Bild" womoeglich eine Zeile, die + immer wahr ist. + + Sie baut NICHT die historische Regel nach. Das habe ich zuerst + versucht und bin zweimal danebengelandet -- ein Element in der + obersten Ebene rechnet `relative` zu `absolute` um, und die + Reparatur schreibt `inset: 0` und `margin: auto` so + ausdruecklich hin, dass der Dialog selbst mit verstellter + Stellung noch mittig bleibt. Das ist eine gute Nachricht ueber + die Reparatur und eine schlechte ueber jenen Nachbau. + + Also wird hier schlicht ein Dialog aus dem Fenster geschoben. + Die Frage lautet: Merkt die Messung es? */ + await seite.addStyleTag({ + content: ".dialog { position: absolute !important; inset: auto !important;" + + " top: -9999px !important; margin: 0 !important; }", + }); + } + /* GANZ NACH UNTEN -- das ist der Unterschied zu allem, was im Haus + bisher gemessen wurde. */ + await seite.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await seite.waitForTimeout(350); + const gescrollt = await seite.evaluate(() => Math.round(window.scrollY)); + + const knopf = await seite.$(knopfWahl); + if (!knopf) { await kontext.close(); return { fehltKnopf: true, gescrollt }; } + await knopf.click(); + await seite.waitForTimeout(700); + + const lage = await seite.evaluate(() => { + const dlg = [...document.querySelectorAll("dialog")].find((x) => x.open); + if (!dlg) return null; + const k = dlg.getBoundingClientRect(); + return { + oben: Math.round(k.top), unten: Math.round(k.bottom), + hoehe: Math.round(k.height), fenster: window.innerHeight, + stellung: getComputedStyle(dlg).position, + /* GANZ im Bild, nicht „irgendwie angeschnitten". Ein Dialog, + dessen Knopf unter dem Fensterrand liegt, ist so unbrauchbar + wie einer, den man gar nicht sieht. */ + ganzImBild: k.top >= -1 && k.bottom <= window.innerHeight + 1, + }; + }); + await kontext.close(); + return { ...(lage || { fehlt: true }), gescrollt }; +} + +melde("\n=== Am echten Bildschirm ==="); +const FAELLE = [ + ["der gemeldete Fall: Unterweisung bestätigen", "creator", + "/workspace/bereich.html?b=schutz", ".unterweisung .knopf--klein"], + /* NICHT `.mz-zeile__fuss .knopf` -- das ist seit dem Schnell-Eintrag + der Ein-Klick-Knopf, der gar keinen Dialog oeffnet. Beim ersten + Lauf stand hier deshalb „es ging kein Dialog auf", und das war + meine veraltete Auswahl, kein Befund. */ + ["ein zweiter, ganz anderer Dialog: Monatsziel eintragen", "scout", + "/workspace/manager-ziele.html", '.mz-schnell button:text-is("Mehr Angaben")'], +]; + +const gemessen = []; +for (const [was, wer, pfad, wahl] of FAELLE) { + for (const [wie, b, h] of [["Computer", 1280, 800], ["Handy", 412, 780]]) { + const r = await dialogMessen(wer, pfad, wahl, b, h); + if (r.fehltKnopf) { ok(false, `${was} (${wie}): der Knopf ist gar nicht da`); continue; } + if (r.fehlt) { ok(false, `${was} (${wie}): es ging kein Dialog auf`); continue; } + gemessen.push(r); + ok(r.gescrollt > 0, + `${was} (${wie}): die Seite liess sich überhaupt scrollen (${r.gescrollt} px)`); + ok(r.stellung === "fixed", + `${was} (${wie}): der Dialog hängt am Fenster (${r.stellung})`); + ok(r.ganzImBild, + `${was} (${wie}): er steht GANZ im Bild (oben ${r.oben}, unten ${r.unten} von ${r.fenster})`); + } +} +braucht(gemessen.length === 4, `nur ${gemessen.length} von 4 Fällen gemessen`); + +/* ===================================================================== + 3 · DIE GEGENPROBE + ===================================================================== + Mit der alten Regel MUSS dieselbe Messung anschlagen. Sonst hiesse + „er steht im Bild" moeglicherweise nur „diese Zeile ist immer wahr". + ===================================================================== */ +melde("\n=== Gegenprobe: mit der alten Regel muss es auffallen ==="); +const kaputt = await dialogMessen("creator", + "/workspace/bereich.html?b=schutz", ".unterweisung .knopf--klein", 1280, 800, true); +/* NICHT `=== "relative"`: Ein Element in der obersten Ebene rechnet + `relative` zu `absolute` um -- gemessen, nicht nachgelesen. Wichtig + ist nur, dass es nicht mehr am Fenster haengt. */ +ok(kaputt.stellung !== "fixed", + `die alte Regel ist wieder eingesetzt (${kaputt.stellung} statt fixed)`); +ok(kaputt.ganzImBild === false, + `und die Messung schlägt an: oben ${kaputt.oben}, Fenster ${kaputt.fenster}` + + " — ein Dialog ausserhalb des Fensters faellt auf"); + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`); +process.exit(fehler ? 1 : 0); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index f50d832c..be00c96e 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`. --> - - + + - +