diff --git a/server/mess-chat-neu-stelle.mjs b/server/mess-chat-neu-stelle.mjs
new file mode 100644
index 00000000..6fd48254
--- /dev/null
+++ b/server/mess-chat-neu-stelle.mjs
@@ -0,0 +1,262 @@
+/* =====================================================================
+ WO STEHT DER VERLAUF, WENN MAN IHN AUFMACHT? (25.09.2026)
+ =====================================================================
+
+ Filipe: „wenn ich in den chat rein gehe und es neue kommentare gibt,
+ will ich dass mein chat sich da öffnet wo die neuen nachrichten
+ anfangen die ich noch nicht gesehen hab bitte und nicht immer ganz
+ unten. sonst muss man immer hoch scrollen um die neuen zu lesen und
+ das ist scheisse."
+
+ IM QUELLTEXT STEHT DAS SEIT DEM 09.09.2026 -- eine Linie „Ab hier
+ neu" und ein Sprung darauf. Filipe sagt, es passiert nicht. Also
+ wird gemessen statt gelesen: Zwei Menschen, echte Nachrichten, und
+ danach die Frage, wo der Verlauf wirklich steht.
+
+ GEMESSEN WIRD IN PIXELN, nicht im Gefuehl: Wie weit ist die Linie
+ vom oberen Rand des sichtbaren Bereichs entfernt? Steht sie im Bild?
+ Und wie viel Verlauf liegt darunter -- also: wie weit haette man
+ scrollen muessen?
+
+ Aufrufen mit: node server/mess-chat-neu-stelle.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { execFileSync } from "node:child_process";
+import { request as httpAnfrage } from "node:http";
+import { createServer as httpsServer } from "node:https";
+
+const PORT = 5491, HP = 5492;
+const CREW = "crew.dogfather-universe.com";
+const ordner = mkdtempSync(join(tmpdir(), "ws-neustelle-"));
+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 });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(300_000, "mess-chat-neu-stelle");
+await new Promise((r) => setTimeout(r, 900));
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const sch = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", sch, jetzt);
+const anlegen = (name, rolle, code) => {
+ const s = randomBytes(16).toString("hex");
+ const h = scryptSync(code, s, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ return d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
+ .get(name, rolle, h, s, 32768,
+ createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
+};
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idA = anlegen("Kessi", "modi", "CODE-MODI-0001");
+d.close();
+
+const roh = (keks, pfad, methode = "GET", koerper = null) =>
+ new Promise((fertig, schief) => {
+ const daten = koerper === null ? null : JSON.stringify(koerper);
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ headers: {
+ Host: CREW, ...(keks ? { Cookie: keks } : {}),
+ Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/chat.html`,
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (r) => {
+ let t = ""; r.on("data", (x) => { t += x; });
+ r.on("end", () => fertig({ status: r.statusCode, kopf: r.headers,
+ json: () => { try { return JSON.parse(t); } catch { return {}; } } }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+const anmelden = async (rolle, code) => {
+ const a = await roh(null, "/workspace/api/anmelden", "POST", { rolle, code });
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+};
+
+const kDogi = await anmelden("admin", "CODE-DOGI-0001");
+const kA = await anmelden("modi", "CODE-MODI-0001");
+
+const gr = await roh(kDogi, "/workspace/api/chat/direkt", "POST", { mit: idA });
+const raumId = gr.json().raum_id;
+console.log(`Raum #${raumId}`);
+
+const schreiben = (keks, text) =>
+ roh(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST", { text });
+
+/* ---- 20 Nachrichten, die DogFather schon gelesen hat -------------- */
+let letzteAlte = 0;
+for (let i = 1; i <= 20; i++) {
+ const a = await schreiben(kA, `Alte Nachricht ${i} von Kessi.`);
+ letzteAlte = a.json().nachricht?.id || a.json().id || letzteAlte;
+}
+/* DogFather macht auf und liest. `bis` IST PFLICHT -- die Route
+ verlangt eine Nummer und lehnt sonst mit 400 ab. Im ersten Anlauf
+ habe ich ein leeres Objekt geschickt; `gelesen_bis` blieb damit auf
+ null, die Linie „Ab hier neu" entstand nie, und die Messung meldete
+ einen Fehler, den es nicht gab. Eine Messung, die falsch aufsetzt,
+ findet zuverlaessig Befunde -- nur nicht die echten. */
+await roh(kDogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`);
+const gel = await roh(kDogi, `/workspace/api/chat/raeume/${raumId}/gelesen`,
+ "PUT", { bis: letzteAlte });
+console.log(`gelesen bis #${letzteAlte}: HTTP ${gel.status}`);
+await new Promise((r) => setTimeout(r, 300));
+
+/* ---- Und jetzt zwoelf neue, die er NICHT gesehen hat -------------- */
+for (let i = 1; i <= 12; i++) await schreiben(kA, `NEUE Nachricht ${i} von Kessi.`);
+
+const stand = await roh(kDogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`);
+console.log(`gelesen_bis beim Oeffnen: ${stand.json().gelesen_bis}`
+ + `, Nachrichten: ${(stand.json().nachrichten || []).length}`);
+
+const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schl,
+ "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
+{ stdio: "ignore" });
+const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
+ (von, nach) => {
+ const w = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HP}` },
+ }, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
+ von.pipe(w);
+ });
+await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
+
+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"],
+});
+
+for (const [wie, breite, hoehe, mobil] of [
+ ["handy", 390, 844, true], ["rechner", 1280, 900, false],
+]) {
+ /* ==== VOR JEDEM DURCHLAUF DEN LESESTAND ZURUECKSETZEN ===========
+
+ Beim Oeffnen meldet die Seite „gelesen bis hier" -- das ist
+ richtig und hat im ersten Anlauf den zweiten Durchlauf ruiniert:
+ Nach dem Handy war nichts mehr neu, die Linie entstand nicht, und
+ die Messung meldete fuer den Rechner „Linie FEHLT". Ein Befund,
+ den die Messung selbst erzeugt hatte.
+
+ Zurueckgesetzt wird direkt in der Datenbank und nicht ueber die
+ Route: Die laesst `bis` absichtlich nicht kleiner werden. */
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe },
+ deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil,
+ });
+ await kontext.addCookies(kDogi.split("; ").map((k) => {
+ const [x, ...r] = k.split("=");
+ return { name: x, value: r.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (e) => {
+ if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
+ konsole.push(e.text().slice(0, 140));
+ }
+ });
+ /* DER LESESTAND WIRD UNMITTELBAR VOR DEM AUFRUF ZURUECKGESETZT.
+
+ Beim Oeffnen meldet die Seite „gelesen bis hier" -- richtig, und
+ im ersten Anlauf hat es den zweiten Durchlauf ruiniert: Nach dem
+ Handy war nichts mehr neu, die Linie entstand nicht, und die
+ Messung meldete fuer den Rechner „Linie FEHLT". Ein Befund, den
+ die Messung selbst erzeugt hatte.
+
+ Der Reset stand zuerst vor `newContext` -- ein paar Millisekunden
+ zu frueh: Die verzoegerte Meldung des vorigen Durchlaufs kam
+ danach. Jetzt steht er unmittelbar vor dem Aufruf, mit einer
+ kurzen Pause davor, damit der vorige Browser wirklich fertig ist.
+
+ Zurueckgesetzt wird in der Datenbank und nicht ueber die Route:
+ Die laesst `bis` absichtlich nicht kleiner werden. */
+ await new Promise((r) => setTimeout(r, 600));
+ {
+ const dd = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
+ .run(letzteAlte, raumId, idDogi);
+ dd.close();
+ }
+ await seite.goto(`https://${CREW}:${HP}/workspace/chat.html?raum=${raumId}`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(2200);
+
+ const lage = await seite.evaluate(() => {
+ const v = document.getElementById("verlauf");
+ if (!v) return { da: false };
+ const linie = v.querySelector(".chat-neu");
+ const alle = [...v.querySelectorAll(".chat-nachricht")];
+ const sicht = v.getBoundingClientRect();
+ const ersteSichtbare = alle.find((z) => {
+ const r = z.getBoundingClientRect();
+ return r.bottom > sicht.top + 4;
+ });
+ return {
+ da: true,
+ hoehe: Math.round(v.clientHeight),
+ gesamt: Math.round(v.scrollHeight),
+ oben: Math.round(v.scrollTop),
+ amEnde: v.scrollHeight - v.scrollTop - v.clientHeight < 40,
+ linieDa: !!linie,
+ linieOben: linie ? Math.round(linie.offsetTop) : null,
+ /* Steht die Linie IM BILD? Das ist die eigentliche Frage. */
+ linieImBild: linie
+ ? (linie.getBoundingClientRect().top >= sicht.top - 2
+ && linie.getBoundingClientRect().bottom <= sicht.bottom + 2)
+ : false,
+ linieAbstandVonOben: linie
+ ? Math.round(linie.getBoundingClientRect().top - sicht.top) : null,
+ ersteSichtbar: ersteSichtbare
+ ? (ersteSichtbare.textContent || "").trim().slice(0, 34) : "-",
+ nachrichten: alle.length,
+ /* Wie weit muesste man hochscrollen, um die Linie zu sehen? */
+ muessteHoch: linie
+ ? Math.max(0, Math.round(v.scrollTop - linie.offsetTop)) : null,
+ };
+ });
+
+ console.log(`\n=== ${wie} (${breite}x${hoehe}) ===`);
+ if (!lage.da) { console.log(" KEIN VERLAUF"); }
+ else {
+ console.log(` Verlauf ${lage.hoehe} px hoch, Inhalt ${lage.gesamt} px, `
+ + `steht bei ${lage.oben}${lage.amEnde ? " (GANZ UNTEN)" : ""}`);
+ console.log(` Linie „Ab hier neu": ${lage.linieDa ? "da" : "FEHLT"}`
+ + (lage.linieDa ? `, bei ${lage.linieOben} px` : ""));
+ if (lage.linieDa) {
+ console.log(` Im Bild: ${lage.linieImBild ? "JA" : "NEIN"}`
+ + `, ${lage.linieAbstandVonOben} px unter dem oberen Rand`
+ + (lage.muessteHoch ? ` -- man muesste ${lage.muessteHoch} px HOCHSCROLLEN` : ""));
+ }
+ console.log(` Oben im Bild steht: "${lage.ersteSichtbar}"`);
+ console.log(` ${lage.nachrichten} Nachrichten im Verlauf`);
+ }
+ if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`);
+ await seite.screenshot({ path: `server/mess-neustelle-${wie}.png` }).catch(() => {});
+ await kontext.close();
+}
+
+await browser.close();
+vorbau.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(0);
diff --git a/server/pruef-chat-neu-stelle.mjs b/server/pruef-chat-neu-stelle.mjs
new file mode 100644
index 00000000..67095a2d
--- /dev/null
+++ b/server/pruef-chat-neu-stelle.mjs
@@ -0,0 +1,379 @@
+/* =====================================================================
+ DER CHAT OEFFNET DORT, WO DAS NEUE ANFAENGT (25.09.2026)
+ =====================================================================
+
+ Filipe: „wenn ich in den chat rein gehe und es neue kommentare gibt,
+ will ich dass mein chat sich da öffnet wo die neuen nachrichten
+ anfangen die ich noch nicht gesehen hab bitte und nicht immer ganz
+ unten. sonst muss man immer hoch scrollen um die neuen zu lesen und
+ das ist scheisse. perfektionier das bei allen rollen."
+
+ ---------------------------------------------------------------------
+ WARUM ES DIESE PRUEFUNG GIBT, OBWOHL DIE FUNKTION SEIT DEM 09.09. DA IST
+
+ Sie war da und sass falsch. Der Sprung rechnete mit `offsetTop` --
+ dem Abstand zum `offsetParent`, und das ist nur dann der Verlauf
+ selbst, wenn dieser `position: relative` traegt. Tut er nicht.
+ Gemessen auf 390 px landete die Linie „Ab hier neu" 23 px OBERHALB
+ des sichtbaren Bereichs: Man musste genau das tun, was Filipe nicht
+ mehr tun wollte. Am Rechner stimmte es zufaellig, weil dort weniger
+ dazwischenliegt -- deshalb ist es nie aufgefallen.
+
+ EIN FEHLER, DEN NUR EINE MESSUNG IN PIXELN FINDET. „Die Linie ist
+ da" war die ganze Zeit wahr. Gemessen wird hier deshalb, ob sie IM
+ BILD steht und wie weit sie vom oberen Rand entfernt ist.
+
+ ---------------------------------------------------------------------
+ BEI ALLEN ROLLEN, UND ZWAR IN BEIDEN HAEUSERN
+
+ Die Mechanik ist dieselbe Datei fuer alle -- aber „dieselbe Datei"
+ war schon oefter ein Trugschluss: Die Kopfleiste ist je nach Rolle
+ verschieden hoch, der Treff hat eigene Regeln, und die Agentur laedt
+ ihre eigene Wand. Ein Verlauf, der bei DogFather richtig sitzt und
+ beim Modi 40 px daneben, ist fuer den Modi kaputt.
+
+ Aufrufen mit: node server/pruef-chat-neu-stelle.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { execFileSync } from "node:child_process";
+import { request as httpAnfrage } from "node:http";
+import { createServer as httpsServer } from "node:https";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-neustelle-p-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Pruefung der Oeffnungsstelle");
+const HP = 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";
+
+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 });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(300_000, "pruef-chat-neu-stelle");
+await new Promise((r) => setTimeout(r, 1000));
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+const CREW = "crew.dogfather-universe.com";
+const AGENTUR = "workspace.dogfather-universe.com";
+
+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++; };
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const sch = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", sch, jetzt);
+const anlegen = (name, rolle, code) => {
+ const s = randomBytes(16).toString("hex");
+ const h = scryptSync(code, s, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ return d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
+ .get(name, rolle, h, s, 32768,
+ createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
+};
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
+const idLinke = anlegen("Mara", "linke", "CODE-LINK-0001");
+const idModi = anlegen("Kessi", "modi", "CODE-MODI-0001");
+const idManager = anlegen("Cigdem", "manager", "CODE-MANA-0001");
+const idCreator = anlegen("Luna", "creator", "CODE-CREA-0001");
+/* DIE BETREUUNG GEHOERT DAZU. Im Agenturhaus darf ein Manager nur mit
+ SEINEN Creators schreiben -- ohne diese Zeile antwortet die Route
+ mit 403, und die Pruefung meldet einen Fehler, den es nicht gibt.
+ Im ersten Anlauf genau so passiert: „Manager: ein Gespraech entsteht
+ (403)". Der Server hatte recht; die Lage war unvollstaendig. */
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit, gesetzt_von)"
+ + " VALUES (?,?,?,?) ON CONFLICT(creator_id) DO UPDATE SET betreuer_id = excluded.betreuer_id")
+ .run(idCreator, idManager, jetzt, idDogi);
+d.close();
+
+const roh = (host, keks, pfad, methode = "GET", koerper = null) =>
+ new Promise((fertig, schief) => {
+ const daten = koerper === null ? null : JSON.stringify(koerper);
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ headers: {
+ Host: host, ...(keks ? { Cookie: keks } : {}),
+ Origin: `https://${host}`, Referer: `https://${host}/workspace/chat.html`,
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (r) => {
+ let t = ""; r.on("data", (x) => { t += x; });
+ r.on("end", () => fertig({ status: r.statusCode, kopf: r.headers,
+ json: () => { try { return JSON.parse(t); } catch { return {}; } } }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+const anmelden = async (host, rolle, code) => {
+ const a = await roh(host, null, "/workspace/api/anmelden", "POST", { rolle, code });
+ if (a.status !== 200) { console.log(`ABBRUCH: ${rolle} ${a.status}`); process.exit(2); }
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+};
+
+/* =======================================================================
+ DIE LAGE: je Rolle ein Gespraech mit 20 alten und 12 neuen Zeilen
+ ======================================================================= */
+const LEUTE = [
+ { wer: "DogFather", host: CREW, rolle: "admin", code: "CODE-DOGI-0001", id: idDogi,
+ partner: idModi, partnerCode: "CODE-MODI-0001", partnerRolle: "modi", partnerHost: CREW },
+ { wer: "rechte Hand", host: CREW, rolle: "hand", code: "CODE-HAND-0001", id: idHand,
+ partner: idModi, partnerCode: "CODE-MODI-0001", partnerRolle: "modi", partnerHost: CREW },
+ { wer: "linke Hand", host: CREW, rolle: "linke", code: "CODE-LINK-0001", id: idLinke,
+ partner: idModi, partnerCode: "CODE-MODI-0001", partnerRolle: "modi", partnerHost: CREW },
+ { wer: "Modi", host: CREW, rolle: "modi", code: "CODE-MODI-0001", id: idModi,
+ partner: idHand, partnerCode: "CODE-HAND-0001", partnerRolle: "hand", partnerHost: CREW },
+ { wer: "Manager", host: AGENTUR, rolle: "manager", code: "CODE-MANA-0001", id: idManager,
+ partner: idCreator, partnerCode: "CODE-CREA-0001", partnerRolle: "creator",
+ partnerHost: AGENTUR },
+ { wer: "Creator", host: AGENTUR, rolle: "creator", code: "CODE-CREA-0001", id: idCreator,
+ partner: idManager, partnerCode: "CODE-MANA-0001", partnerRolle: "manager",
+ partnerHost: AGENTUR },
+];
+
+/* ---- Zertifikat und Vorbau (eine Adresse je Haus) ------------------- */
+const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schl,
+ "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`,
+ "-addext", `subjectAltName=DNS:${CREW},DNS:${AGENTUR}`], { stdio: "ignore" });
+const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
+ (von, nach) => {
+ const w = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers },
+ }, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
+ von.pipe(w);
+ });
+await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
+
+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,MAP ${AGENTUR} 127.0.0.1`,
+ "--ignore-certificate-errors"],
+});
+
+/** Wo steht der Verlauf, nachdem die Seite geoeffnet wurde? */
+async function blick(person, breite, hoehe, keks, raumId) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe },
+ deviceScaleFactor: breite < 700 ? 2 : 1,
+ isMobile: breite < 700, hasTouch: breite < 700,
+ });
+ await kontext.addCookies(keks.split("; ").map((k) => {
+ const [x, ...r] = k.split("=");
+ return { name: x, value: r.join("="), domain: person.host, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message.slice(0, 120)));
+ await seite.goto(`https://${person.host}:${HP}/workspace/chat.html?raum=${raumId}`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(2200);
+
+ const lage = await seite.evaluate(() => {
+ const v = document.getElementById("verlauf");
+ if (!v) return { da: false };
+ const linie = v.querySelector(".chat-neu");
+ const sicht = v.getBoundingClientRect();
+ return {
+ da: true,
+ hoehe: Math.round(v.clientHeight),
+ amEnde: v.scrollHeight - v.scrollTop - v.clientHeight < 40,
+ linieDa: !!linie,
+ /* DIE EIGENTLICHE FRAGE: Steht sie im Bild, und wie weit unter
+ dem oberen Rand? Ein negativer Wert heisst: darueber, also
+ unsichtbar -- genau der Fehler, um den es hier geht. */
+ abstand: linie
+ ? Math.round(linie.getBoundingClientRect().top - sicht.top) : null,
+ sichtbar: linie
+ ? (linie.getBoundingClientRect().top >= sicht.top - 2
+ && linie.getBoundingClientRect().bottom <= sicht.bottom + 2)
+ : false,
+ /* Was steht ganz oben im Bild? Ueber der Linie soll Zusammenhang
+ stehen, nicht schon das Neue. */
+ obenImBild: (() => {
+ const z = [...v.querySelectorAll(".chat-nachricht")]
+ .find((x) => x.getBoundingClientRect().bottom > sicht.top + 4);
+ return z ? (z.textContent || "").trim().slice(0, 24) : "-";
+ })(),
+ };
+ });
+ await kontext.close();
+ return { ...lage, konsole };
+}
+
+melde("");
+melde("=== 1. Bei jeder Rolle oeffnet der Chat an der neuen Stelle ===");
+
+/* ==== JEDER BLICK BEKOMMT SEINEN EIGENEN RAUM ======================
+
+ Der erste Anlauf benutzte einen Raum je Rolle und setzte den
+ Lesestand zwischen Handy und Rechner zurueck. Das Ergebnis
+ FLATTERTE: Mal war DogFather rot, mal die rechte Hand, mal der Modi
+ -- bei unveraendertem Code.
+
+ Der Grund ist ein Wettlauf: Die Seite meldet „gelesen bis hier" beim
+ Oeffnen, und zwar nicht sofort. Wird der Stand danach
+ zurueckgesetzt, gewinnt mal das eine, mal das andere. Eine Pause
+ davor macht es wahrscheinlicher und nicht sicher -- und eine
+ Pruefung, die von Wahrscheinlichkeit lebt, ist keine.
+
+ MIT EINEM EIGENEN RAUM JE BLICK gibt es nichts zurueckzusetzen. Das
+ ist mehr Aufbau und dafuer eine Messung, die immer dasselbe sagt. */
+for (const person of LEUTE) {
+ const keks = await anmelden(person.host, person.rolle, person.code);
+ const kPartner = await anmelden(person.partnerHost, person.partnerRolle, person.partnerCode);
+
+ for (const [wie, breite, hoehe] of [["Handy", 390, 844], ["Rechner", 1280, 900]]) {
+ const gr = await roh(person.host, keks, "/workspace/api/chat/gruppe", "POST",
+ { name: `${person.wer} ${wie}`, mit: [person.partner] });
+ const raumId = gr.json().raum_id;
+ if (!raumId) {
+ ok(false, `${person.wer} / ${wie}: ein Raum entsteht (${gr.status})`);
+ continue;
+ }
+
+ let letzteAlte = 0;
+ for (let i = 1; i <= 20; i++) {
+ const a = await roh(person.partnerHost, kPartner,
+ `/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST",
+ { text: `Alte Nachricht ${i} -- lange genug, damit sie auf einem Handy umbricht.` });
+ letzteAlte = a.json().nachricht?.id || a.json().id || letzteAlte;
+ }
+ await roh(person.host, keks, `/workspace/api/chat/raeume/${raumId}/gelesen`,
+ "PUT", { bis: letzteAlte });
+ for (let i = 1; i <= 12; i++) {
+ await roh(person.partnerHost, kPartner,
+ `/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST",
+ { text: `NEUE Nachricht ${i} -- auch diese ist lang genug zum Umbrechen.` });
+ }
+
+ /* Was sagt der Server unmittelbar vor dem Blick? Ohne diese Zeile
+ steht bei einem Fehlschlag nur „die Linie fehlt" da -- und man
+ weiss nicht, ob die Oberflaeche sie nicht zeichnet oder der
+ Server schon alles als gelesen fuehrt. */
+ const vorher = await roh(person.host, keks,
+ `/workspace/api/chat/raeume/${raumId}/nachrichten`);
+ const standVor = vorher.json().gelesen_bis;
+
+ const l = await blick(person, breite, hoehe, keks, raumId);
+ const wo = `${person.wer} / ${wie}`;
+ ok(l.da && l.linieDa, `${wo}: die Linie „Ab hier neu" steht im Verlauf `
+ + `(gelesen_bis ${standVor}, letzte alte #${letzteAlte})`);
+ ok(l.sichtbar === true,
+ `${wo}: und sie ist zu SEHEN (${l.abstand} px unter dem oberen Rand)`);
+ /* Nicht am Rand kleben: Ueber der Linie soll Zusammenhang stehen.
+ Ein Viertel der Hoehe ist die Vorgabe; gemessen wird grosszuegig
+ (mindestens 24 px), damit eine andere Kopfhoehe nicht sofort rot
+ wird -- die Aussage ist „es steht etwas darueber", nicht „genau
+ ein Viertel". */
+ ok((l.abstand || 0) >= 24,
+ `${wo}: mit Zusammenhang darueber ("${l.obenImBild}")`);
+ ok(l.amEnde === false, `${wo}: und NICHT ganz unten`);
+ ok(l.konsole.length === 0, `${wo}: keine Abstuerze (${l.konsole.join(" | ") || "keine"})`);
+ }
+}
+
+/* =======================================================================
+ 2. GEGENPROBEN
+ ======================================================================= */
+melde("");
+melde("=== 2. Gegenproben (jede MUSS anschlagen) ===");
+{
+ const person = LEUTE[0];
+ const keks = await anmelden(person.host, person.rolle, person.code);
+ const kPartner = await anmelden(CREW, "modi", "CODE-MODI-0001");
+
+ const gr = await roh(person.host, keks, "/workspace/api/chat/direkt", "POST",
+ { mit: idModi });
+ const raumId = gr.json().raum_id;
+
+ /* (a) NICHTS NEUES -> ans Ende. Ohne diese Zeile koennte der Sprung
+ immer stattfinden, und wer nichts verpasst hat, laendete
+ grundlos mitten im Verlauf. */
+ let letzte = 0;
+ for (let i = 1; i <= 8; i++) {
+ const a = await roh(CREW, kPartner, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
+ "POST", { text: `Alles gelesen ${i}.` });
+ letzte = a.json().nachricht?.id || a.json().id || letzte;
+ }
+ await roh(person.host, keks, `/workspace/api/chat/raeume/${raumId}/gelesen`,
+ "PUT", { bis: letzte });
+ await new Promise((r) => setTimeout(r, 400));
+
+ const ohneNeues = await blick(person, 390, 844, keks, raumId);
+ ok(ohneNeues.linieDa === false,
+ `ohne Ungelesenes gibt es keine Linie (${ohneNeues.linieDa})`);
+ ok(ohneNeues.amEnde === true,
+ "und der Verlauf steht am Ende -- da, wo man weiterschreibt");
+
+ /* (b) Kann die Messung „nicht sichtbar" ueberhaupt erkennen? Sonst
+ waere jede gruene Zeile oben wertlos. Gemessen wird an einem
+ Verlauf, der absichtlich nach unten gescrollt wird. */
+ for (let i = 1; i <= 12; i++) {
+ await roh(CREW, kPartner, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
+ "POST", { text: `Danach noch etwas ${i}.` });
+ }
+ {
+ const dd = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
+ .run(letzte, raumId, person.id);
+ dd.close();
+ }
+ const kontext = await browser.newContext({ viewport: { width: 390, height: 844 },
+ deviceScaleFactor: 2, isMobile: true, hasTouch: true });
+ await kontext.addCookies(keks.split("; ").map((k) => {
+ const [x, ...r] = k.split("=");
+ return { name: x, value: r.join("="), domain: person.host, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ await seite.goto(`https://${person.host}:${HP}/workspace/chat.html?raum=${raumId}`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(2000);
+ const kuenstlich = await seite.evaluate(() => {
+ const v = document.getElementById("verlauf");
+ v.scrollTop = v.scrollHeight; /* mit Absicht ans Ende */
+ const linie = v.querySelector(".chat-neu");
+ const sicht = v.getBoundingClientRect();
+ if (!linie) return null;
+ return {
+ abstand: Math.round(linie.getBoundingClientRect().top - sicht.top),
+ sichtbar: linie.getBoundingClientRect().top >= sicht.top - 2
+ && linie.getBoundingClientRect().bottom <= sicht.bottom + 2,
+ };
+ });
+ await kontext.close();
+ ok(kuenstlich && kuenstlich.sichtbar === false && kuenstlich.abstand < 0,
+ `am Ende ist die Linie NICHT sichtbar (${kuenstlich?.abstand} px) `
+ + "-- die Messung kann also auch nein sagen");
+}
+
+await browser.close();
+vorbau.close();
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler === 0 ? 0 : 1);
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 5d130db6..93cf4345 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`. -->
-
-
+
+
-
+