Files
dogfather-universe/server/pruef-neue-seiten.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.

Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.

Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.

--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------

1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
   Muster: "([^"]*4231[^"]*)". Das hielt

     { host: "127.0.0.1", port: 4231, path: "/404.html" }

   fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
   einer schliessenden Anfuehrung unterscheiden. Heraus kam

     { host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }

   also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
   alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
   gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
   39/0 vorher, 38/1 nachher.

   Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
   ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
   regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
   regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.

2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
   zweite Durchgang importierte den ersten, um seine Mechanik zu
   benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
   zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
   bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
   zweimal.

--- WAS DAS DAUERHAFT HAELT -----------------------------------------

pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.

Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.

--- NACHGEMESSEN ----------------------------------------------------

Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):

  crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
  anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
  kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
  push-ziel 10/0 · portnummern 8/0

Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 22:51:21 +02:00

592 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
Die neuen Seiten — angesehen, nicht nur geprüft (11.09.2026)
HIESS BIS HEUTE pruef-treff-seiten.mjs. Mit den zwei Katalogseiten
(Entwicklung, Talente) stimmte der Name nicht mehr -- und ein
Dateiname, der etwas anderes verspricht als sein Inhalt, ist die
Sorte Kleinigkeit, wegen der jemand die falsche Datei oeffnet.
pruef-treff und pruef-treff-werkzeuge messen, was der Server ANTWORTET.
Diese Datei sieht nach, was auf dem Bildschirm LANDET.
Der Unterschied ist nicht theoretisch. Am 06.09.2026 hat in diesem
Haus eine Kopfleiste zwölf Seiten lang den falschen Knopf getroffen,
und keine einzige Schnittstellenprüfung hat es bemerkt — sie waren
alle grün. Gefunden hat es ein Blick.
GEPRÜFT WIRD, WAS MAN NICHT ERRATEN KANN:
* Steht überhaupt Text da? Eine Seite, deren Skript stillschweigend
abbricht, sieht in der Antwort tadellos aus und ist leer.
* Sind die Platzhalter ersetzt? Die Zahlen im Regeltext kommen vom
Server; bleibt "…" stehen, hat der Abruf nicht funktioniert.
* Liegt etwas übereinander? Zwei Bedienelemente, die sich
überlappen, sind auf dem Handy die Sorte Fehler, bei der man auf
A tippt und B bekommt.
* Lässt sich der Text lesen? Kontrast auf ECHTEN Bildpunkten
gemessen, nicht aus den Farbwerten gerechnet — die sagen nichts
über das, was hinter einer halbdurchsichtigen Fläche liegt.
* Und auf 390 px Breite: Bricht etwas aus dem Bild heraus?
Aufrufen mit: node server/pruef-neue-seiten.mjs
===================================================================== */
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-neue-seiten");
const ordner = mkdtempSync(join(tmpdir(), "ws-treffs-"));
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(240_000, "pruef-neue-seiten");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
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 BASIS = `http://127.0.0.1:${PORT}`;
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 schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const ALT = new Date(Date.now() - 40 * 86400000).toISOString();
function anlegen(name, rolle, code, erstellt = jetzt) {
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", schluessel).update(code).digest("hex"), erstellt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001", ALT);
/* ETWAS ZUM ANSEHEN. Eine leere Seite sieht aufgeräumt aus und beweist
nichts -- die Fälle auf der Moderationsseite entstehen erst, wenn es
eine Meldung und eine Maßnahme gibt. */
d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)"
+ " VALUES ('treff','hallo','Hallo, ich bin die Kessi','Freue mich, hier zu sein!',?,?,?)")
.run(jetzt.slice(0, 10), jetzt, idKessi);
const beitrag = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.close();
function roh(pfad, kopf = {}, koerper = null, methode = null) {
if (methode === "DELETE") koerper = null;
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (koerper ? "POST" : "GET"),
headers: { ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
/* Eine Meldung und eine Maßnahme anlegen -- über die echten Wege. */
/* alter_ok: Die Tuer fragt die Community seit dem 15.09.2026 beim
ersten Hereinkommen nach dem Alter. */
const anm = await roh("/workspace/api/anmelden", {},
{ rolle: "gast", code: "CODE-GAST-0001", alter_ok: true });
const kGast = [].concat(anm.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
const anm2 = await roh("/workspace/api/anmelden", {}, { rolle: "admin", code: "CODE-DOGI-0001" });
const kDogi = [].concat(anm2.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
await roh(`/workspace/api/treff/eintrag/${beitrag}/melden`, { cookie: kGast },
{ grund: "Da steht eine Handynummer drin, die da nicht hingehört." });
await roh("/workspace/api/treff/massnahme", { cookie: kDogi },
{ person_id: idKessi, art: "hinweis", grund: "Bitte keine Nummern posten – einmalig, kein Drama." });
/* ETWAS AUF DEN KATALOGSEITEN. Ohne eine Person im Team ist die
Entwicklungsseite leer, ohne einen Kandidaten der Trichter -- und
eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen.
UND ZWAR AUF MEHREREN STUFEN. Der Trichter zeigt je Stufe einen
ANDEREN naechsten Schritt; steht nur ein Kandidat auf der ersten,
bleiben die Knoepfe der anderen vier ungesehen. Genau dort sitzen
seit dem 11.09. die zwei neuen: der Vorlagentext auf "Beobachtet"
und "Ins Team holen · Zugang anlegen" auf "Probe". */
/* EINMAL ANKLOPFEN, DAMIT ES DIE TABELLEN GIBT. Sie entstehen beim
ersten angemeldeten Aufruf des Bereichs, nicht beim Start -- ein
INSERT davor liefe gegen eine Tabelle, die es noch nicht gibt. */
await roh("/workspace/api/talente/lage", { cookie: kDogi });
const kat = await import("./workspace-entwicklungs-punkte.js");
const talKat = await import("./workspace-talent-punkte.js");
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
let idHand = null;
{
const person = (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");
dd.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", schluessel).update(code).digest("hex"), jetzt);
return dd.prepare("SELECT last_insert_rowid() AS id").get().id;
};
person("Frida", "modi", "CODE-MODI-0001");
idHand = person("Rieke", "hand", "CODE-HAND-0001");
const talent = (titel, text) => {
dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)"
+ " VALUES ('talente','ruhig',?,?,?,?,?,?)")
.run(titel, text, jetzt.slice(0, 10), jetzt, idDogi, "Im Stream am Dienstag");
return dd.prepare("SELECT last_insert_rowid() AS id").get().id;
};
talent("Kessi aus dem Chat", "Faellt seit Wochen auf.");
const idBeobachtet = talent("Malin aus dem Chat", "Haelt den Chat ruhig.");
const idProbe = talent("Tino aus dem Chat", "Laeuft seit vier Wochen mit.");
const stufe = dd.prepare(
"INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id) VALUES (?,?,?,?)");
stufe.run(idBeobachtet, "beobachtet", jetzt, idDogi);
stufe.run(idProbe, "probe", jetzt, idDogi);
/* MERKMALE fuer den Vorlagentext -- er wird daraus gebaut. Ohne sie
stuende dort der Satz fuer "noch nichts beobachtet", und die neue
Gestaltung waere zwar sichtbar, aber nicht in ihrer laengsten
Fassung. Gemessen werden soll die laengste. */
const merk = dd.prepare(
"INSERT INTO talent_merkmal (eintrag_id, schluessel, von_id, am) VALUES (?,?,?,?)");
for (const m of talKat.TALENT_ALLE.slice(0, 3)) {
merk.run(idBeobachtet, m.schluessel, idDogi, jetzt);
}
/* DIE EIGENE KARTE DER RECHTEN HAND. Sie oeffnet sich erst, wenn
jeder Beurteiler gesetzt hat -- ueber sie urteilt nur DogFather,
also reicht eine Zeile je Punkt. Sechs gesetzte und der Rest offen:
So stehen beide Zustaende nebeneinander auf dem Bildschirm. */
const stand = dd.prepare(
"INSERT INTO entwicklung_stand (person_id, schluessel, von_id, stufe, anlass, am)"
+ " VALUES (?,?,?,?,?,?)");
const stufen = ["laeuft", "waechst", "laeuft", "hakt", "laeuft", "waechst"];
kat.ENTWICKLUNG_FREMD.slice(0, 6).forEach((p, i) => {
stand.run(idHand, p.schluessel, idDogi, stufen[i],
stufen[i] === "hakt" ? "Zweimal zu spaet zur Schicht." : null, jetzt);
});
}
dd.close();
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
const BILDER = join(ordner, "bilder");
mkdirSync(BILDER, { recursive: true });
/* Kontrast auf ECHTEN Bildpunkten: Die Schrift liegt teils über
halbdurchsichtigen Flächen und über der Bühne; aus den Farbwerten im
CSS lässt sich der tatsächliche Kontrast deshalb nicht ausrechnen. */
const KONTRAST = `(() => {
const leuchte = (r, g, b) => {
const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const misch = (v, h) => v.map((x, i) => x * v[3] + h[i] * (1 - v[3]));
const grund = (e) => {
let n = e;
while (n && n !== document.documentElement) {
const f = getComputedStyle(n).backgroundColor;
const m = f.match(/rgba?\\(([^)]+)\\)/);
if (m) {
const t = m[1].split(",").map(Number);
const a = t.length > 3 ? t[3] : 1;
if (a > 0.92) return [t[0], t[1], t[2], 1];
}
n = n.parentElement;
}
return null;
};
const schlecht = [];
const geraten = [];
let gemessen = 0;
for (const e of document.querySelectorAll("p, li, h1, h2, h3, td, th, span, a, button")) {
const t = (e.textContent || "").trim();
if (!t) continue;
/* ELEMENTE MIT KINDERN WERDEN UEBERSPRUNGEN, und das ist richtig:
Ihr textContent ist der Text der Kinder, die einzeln drankommen.
Es war aber ein STILLER Aussetzer -- ein <p> mit einem Link
darin fiel heraus, und der eigene Text daneben wurde nie
gemessen. Genau so ist die Fusszeile der Talente-Seite durch die
Pruefung gekommen, waehrend sie auf der Buehne lag.
Jetzt zaehlt der EIGENE Text: Hat ein Element neben seinen
Kindern eigene Zeichen, wird es mitgemessen. */
const eigen = [...e.childNodes]
.filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join("");
if (e.children.length && eigen.length < 3) continue;
const k = e.getBoundingClientRect();
if (k.width < 4 || k.height < 4) continue;
const s = getComputedStyle(e);
if (s.visibility === "hidden" || s.display === "none" || Number(s.opacity) < 0.2) continue;
const m = s.color.match(/rgba?\\(([^)]+)\\)/);
if (!m) continue;
const v = m[1].split(",").map(Number);
/* DRITTER AUSGANG: konnte nicht nachsehen.
Findet sich über diesem Text keine deckende Fläche, dann liegt er
auf der Bühne — einem Foto. Was dahinter liegt, lässt sich aus
den Farbwerten NICHT ausrechnen, und eine angenommene
Hintergrundfarbe wäre genau der grüne Haken über einer falschen
Voraussetzung. Genau so ist am 11.09.2026 die Rechtetafel durch
die Messung gekommen, während im Bildschirmfoto Chilischoten
hinter den Zeilen standen. */
const hinten = grund(e);
if (!hinten) { geraten.push(t.slice(0, 34)); continue; }
const vorn = misch([v[0], v[1], v[2], v.length > 3 ? v[3] : 1], hinten);
const l1 = leuchte(...vorn), l2 = leuchte(...hinten);
const k2 = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
gemessen++;
const gross = parseFloat(s.fontSize) >= 24
|| (parseFloat(s.fontSize) >= 18.66 && Number(s.fontWeight) >= 700);
if (k2 < (gross ? 3 : 4.5)) schlecht.push(t.slice(0, 34) + " (" + k2.toFixed(2) + ")");
}
return { gemessen, schlecht, geraten };
})()`;
/* WAS ABSICHTLICH ÜBEREINANDERLIEGT, IST KEIN FEHLER (11.09.2026).
Der erste Entwurf meldete auf allen drei Seiten dasselbe:
"Meine SichtKessi / Meine Sicht". Das ist der Umschalter in der
Kopfleiste — ein selbst gebautes Bedienelement, unter dem ein echtes
<select> für Vorleseprogramme liegt. Es MUSS dort liegen, sonst
findet es niemand mit einem Bildschirmleser.
Es ist für das Auge unsichtbar gemacht, aber nicht über
`visibility` oder `opacity` — sonst würden manche Vorleseprogramme
es ebenfalls überspringen. Übliche Technik ist ein 1x1-Ausschnitt
(`clip-path`) oder eine Verschiebung aus dem Bild.
Eine Prüfung, die so etwas meldet, ist schlimmer als keine: Sie ist
auf JEDER Seite rot, man gewöhnt sich daran, und die eine echte
Überlappung geht darin unter. Ausgelassen wird deshalb, was
ausdrücklich für das Auge weggenommen wurde — und NICHT einfach
alles, was "Meine Sicht" heißt. */
const UEBERLAPP = `(() => {
const versteckt = (e) => {
const s = getComputedStyle(e);
if (s.clipPath && s.clipPath !== "none") return true;
if (s.clip && s.clip !== "auto") return true;
if (Number(s.opacity) < 0.2) return true;
if (e.getAttribute("aria-hidden") === "true") return true;
const k = e.getBoundingClientRect();
return k.right < 1 || k.bottom < 1;
};
const teile = [...document.querySelectorAll("button, a.schritt, input, select")]
.filter((e) => { const k = e.getBoundingClientRect(); return k.width > 4 && k.height > 4; })
.filter((e) => !versteckt(e));
const treffer = [];
for (let i = 0; i < teile.length; i++) {
for (let j = i + 1; j < teile.length; j++) {
if (teile[i].contains(teile[j]) || teile[j].contains(teile[i])) continue;
const a = teile[i].getBoundingClientRect(), b = teile[j].getBoundingClientRect();
const x = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const y = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (x > 2 && y > 2) {
treffer.push((teile[i].textContent || "?").trim().slice(0, 18)
+ " / " + (teile[j].textContent || "?").trim().slice(0, 18));
}
}
}
return treffer;
})()`;
/* ÜBERBREITE: DIE FRAGE IST, OB DIE SEITE SCHIEBT (11.09.2026).
Der erste Entwurf zählte jedes Element, dessen rechte Kante über den
Rand ragt. Er meldete die Rechtetafel auf 390 px — zu Recht in dem
Sinne, dass die Tabelle breiter ist als das Bild, und zu Unrecht in
jedem anderen: Sie steht in einem Kasten, den man seitwärts schieben
kann (`overflow-x: auto`), und genau so soll eine Tabelle mit neun
Spalten sich auf einem Handy verhalten. Alles andere hieße, Spalten
wegzulassen oder Schrift unlesbar zu verkleinern.
Schlimm ist nur, wenn die GANZE SEITE seitwärts schiebt — dann
verrutscht beim Lesen der Text unter dem Daumen. Das ist eine
einzige Zahl, und sie lügt nicht: scrollWidth gegen clientWidth am
<html>. Was in einem Schiebekasten steht, zählt dort nicht mit.
Die Liste der Ausreißer bleibt trotzdem — aber nur als Auskunft
DANEBEN, und Elemente in einem Schiebekasten sind ausgenommen. */
const UEBERBREIT = `(() => {
const wurzel = document.documentElement;
const schiebt = wurzel.scrollWidth - wurzel.clientWidth;
const imKasten = (e) => {
let n = e.parentElement;
while (n && n !== document.body) {
const s = getComputedStyle(n);
if (s.overflowX === "auto" || s.overflowX === "scroll") return true;
n = n.parentElement;
}
return false;
};
const w = wurzel.clientWidth;
const raus = [...document.querySelectorAll("main *")]
.filter((e) => { const k = e.getBoundingClientRect(); return k.right > w + 2 && k.width > 8; })
.filter((e) => !imKasten(e))
.map((e) => e.tagName.toLowerCase() + "." + (e.className || "").toString().split(" ")[0])
.slice(0, 5);
return { schiebt, raus };
})()`;
/* MIT EINEM FERTIGEN KEKS STATT ueber die Anmeldemaske (11.09.2026).
Die rechte Hand meldet sich an der CREW-WAND an, und die haengt am
Host-Kopf -- den kann ein Browser bei einer Navigation nicht setzen.
Ueber die Maske auf 127.0.0.1 kommt sie also gar nicht herein.
Das ist kein Umgehen einer Sicherung: Angemeldet wird ueber denselben
Weg wie sonst (/api/anmelden), nur die Maske bleibt aussen vor. Was
die Seite danach zeigt, entscheidet weiterhin der Server. */
async function ansehen(name, pfad, code, breite, hoehe, keks = null) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (m) => {
if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text());
});
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
if (keks) {
await kontext.addCookies(keks.split("; ").filter(Boolean).map((teil) => {
const i = teil.indexOf("=");
return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" };
}));
} else {
await seite.goto(`${BASIS}/workspace/`, { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="admin"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
}
await seite.goto(`${BASIS}/workspace/${pfad}`, { waitUntil: "networkidle" });
/* WARTEN, BIS SICH NICHTS MEHR AENDERT -- NICHT 900 MILLISEKUNDEN.
Gemessen am 11.09.2026: Die eigene Karte der rechten Hand stand
nachweislich auf dem Bildschirm (versteckt: false, Antwort 200),
aber der Text war schon vorher gelesen. Die Pruefung meldete
"steht nicht da" fuer etwas, das dastand -- der schlimmste
Fehlalarm, weil man danach am richtigen Code sucht.
Eine groessere Zahl waere derselbe Fehler mit einer anderen Zahl.
Die Seite laedt ihre Abschnitte nacheinander; wie lange das
dauert, haengt an ihrer Anzahl. Deshalb eine Regel statt einer
Rechnung: gelesen wird, wenn der Text dreimal hintereinander
derselbe war. Die Frist darueber ist nur die Notbremse. */
await seite.waitForFunction(() => {
const t = document.querySelector("main")?.innerText || "";
if (window.__standStill === t) { window.__ruhig = (window.__ruhig || 0) + 1; }
else { window.__standStill = t; window.__ruhig = 0; }
return (window.__ruhig || 0) >= 3;
}, null, { timeout: 12000, polling: 200 }).catch(() => {});
const text = await seite.evaluate(() => (document.querySelector("main")?.innerText || "").trim());
const punkte = await seite.evaluate(() => (document.querySelector("main")?.innerText || "")
.split("\\n").filter((z) => z.trim() === "…").length);
const kontrast = await seite.evaluate(KONTRAST);
const ueberlapp = await seite.evaluate(UEBERLAPP);
const breit = await seite.evaluate(UEBERBREIT);
/* DIE EIGENE KARTE IST DER EINZIGE ABSCHNITT, DER SICH SELBST
AUFDECKT. Bleibt er zu, sieht die Seite vollkommen normal aus --
deshalb wird hier nicht nur gelesen, was dasteht, sondern gefragt,
was der Server dazu sagt. */
const karte = await seite.evaluate(async () => {
const e = document.getElementById("karte-block");
if (!e) return { da: false };
let antwort = null;
try {
const a = await fetch("/workspace/api/entwicklung/meine-karte", { credentials: "same-origin" });
antwort = { status: a.status, hat: (await a.json()).hat_karte };
} catch (f) { antwort = { status: -1, fehler: String(f) }; }
return { da: true, versteckt: e.hidden, antwort };
});
await seite.screenshot({ path: join(BILDER, `${name}-${breite}.png`), fullPage: true });
await kontext.close();
return { text, punkte, kontrast, ueberlapp, breit, konsole, karte };
}
const gesehen = {}; // was auf jedem Bildschirm tatsaechlich stand
const anm3 = await roh("/workspace/api/anmelden", {}, { rolle: "hand", code: "CODE-HAND-0001" });
const kHand = [].concat(anm3.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
ok(!!kHand, "die rechte Hand ist angemeldet -- ohne sie bliebe ihre eigene Karte ungesehen");
for (const [name, pfad, keks] of [
["regeln", "treff-regeln.html"],
["rechte", "rechte.html"],
["moderation", "treff-moderation.html"],
/* Die zwei Katalogseiten. Sie stehen hier und nicht in
pruef-nachwuchs: Dort wird gemessen, was der Server ANTWORTET,
hier, was auf dem Bildschirm LANDET. Beides ist noetig, und beides
findet andere Fehler. */
["entwicklung", "entwicklung.html"],
["talente", "talente.html"],
/* UND EINMAL DIESELBE SEITE MIT ANDEREN AUGEN. "Deine Karte" gibt es
nur fuer die, ueber die geurteilt wird -- DogFather sieht sie nie.
Ohne diese Zeile waere der ganze Abschnitt nie auf einem Bildschirm
gewesen. */
["entwicklung-hand", "entwicklung.html", kHand],
]) {
melde("");
melde(`=== ${pfad} ===`);
for (const [breite, hoehe] of [[1440, 1000], [390, 844]]) {
const r = await ansehen(name, pfad, "CODE-DOGI-0001", breite, hoehe, keks);
if (breite === 1440) { gesehen[name] = r.text; gesehen[name + ":karte"] = r.karte; }
const wo = `${breite} px`;
ok(r.text.length > 400, `${wo}: die Seite ist gefüllt (${r.text.length} Zeichen)`);
/* DER PLATZHALTER IST DER EIGENTLICHE PUNKT. Bleibt "…" stehen, hat
der Abruf der Zahlen nicht funktioniert -- und der Regeltext
verspricht dann gar nichts statt etwas Falsches. Sichtbar wäre
das nur hier. */
ok(r.punkte === 0, `${wo}: keine Platzhalter mehr übrig (${r.punkte})`);
ok(r.konsole.length === 0,
`${wo}: keine Fehler im Browser${r.konsole.length ? " -- " + r.konsole[0].slice(0, 80) : ""}`);
ok(r.ueberlapp.length === 0,
`${wo}: nichts liegt übereinander${r.ueberlapp.length ? " -- " + r.ueberlapp.join(" | ") : ""}`);
ok(r.breit.schiebt <= 1 && r.breit.raus.length === 0,
`${wo}: die Seite schiebt nicht seitwärts (${r.breit.schiebt} px)`
+ (r.breit.raus.length ? " -- DRAUSSEN: " + r.breit.raus.join(", ") : ""));
/* DIE ZAHL GEHÖRT IN DIE BEDINGUNG: Fände die Messung kein einziges
Textstück, wäre "nichts zu dunkel" trivial wahr.
AUS 15 WURDE 0 (15.09.2026) -- und das ist kein Aufweichen.
Fünfzehn war eine feste Schwelle für "die Seite hat wirklich
gerendert", und genau diese Aufgabe erledigt zwei Zeilen weiter
oben schon die Messung der Zeichenzahl. Als die sechs Fragen von
entwicklung.html auf ihre eigene Seite umzogen, blieben bei
390 px noch 14 Textstücke übrig -- alle lesbar, die Seite in
Ordnung, die Prüfung rot. Die Zahl hätte man auf 14 setzen
können; das wäre derselbe Fehler ein zweites Mal mit einer
anderen Zahl gewesen. */
ok(r.kontrast.gemessen > 0 && r.kontrast.schlecht.length === 0,
`${wo}: ${r.kontrast.gemessen} Textstücke, alle lesbar`
+ (r.kontrast.schlecht.length ? " -- ZU DUNKEL: " + r.kontrast.schlecht.slice(0, 3).join(" | ") : ""));
/* Und die Frage davor: Konnte überhaupt gemessen werden? Text, der
auf der Bühne liegt, hat keinen berechenbaren Kontrast -- die
Zeile darüber wäre für ihn grün geblieben, ohne ihn anzusehen. */
ok(r.kontrast.geraten.length === 0,
`${wo}: jeder Text steht auf einer deckenden Fläche`
+ (r.kontrast.geraten.length
? ` -- AUF DER BÜHNE (${r.kontrast.geraten.length}): `
+ r.kontrast.geraten.slice(0, 3).join(" | ") : ""));
}
}
/* =======================================================================
WAR DAS NEUE UEBERHAUPT AUF DEM BILDSCHIRM?
Die Messungen darueber sagen "nichts liegt uebereinander" und "alles
lesbar" -- und beides ist auch dann wahr, wenn das Gesuchte gar nicht
da war. Eine leere Seite erfuellt jede dieser Bedingungen.
Deshalb hier die Gegenfrage, und zwar in beide Richtungen: Das Neue
MUSS dort stehen, wo es hingehoert, und darf NICHT dort stehen, wo
es nicht hingehoert. Nur die zweite Haelfte beweist, dass die erste
etwas heisst.
======================================================================= */
melde("");
melde("=== Stand das Neue auch wirklich da? ===");
{
const t = gesehen.talente || "";
ok(/Vorlagentext/.test(t),
"Talente: der Knopf fuer den Vorlagentext stand auf der Stufe „Beobachtet“");
ok(/Ins Team holen/.test(t),
"Talente: „Ins Team holen · Zugang anlegen“ stand auf der Stufe „Probe“");
/* GEGENPROBE: Der Vorlagentext gehoert auf GENAU EINE Stufe. Stuende
er auf jeder, waere er auf vier von fuenf eine Ablenkung -- und die
Zeile darueber waere trotzdem gruen. */
/* ZEILENWEISE gezaehlt und nicht als Wort: "Der Vorlagentext liegt
bereit" ist der Satz des naechsten Schritts, der Knopf steht
allein auf seiner Zeile. Als Wort gezaehlt waeren es zwei, und die
Zeile haette einen Fehler gemeldet, den es nicht gibt. */
const knoepfe = (wort) => t.split("\n").filter((z) => z.trim() === wort).length;
ok(knoepfe("Vorlagentext") === 1,
`und zwar genau einmal, nicht bei jedem Kandidaten (${knoepfe("Vorlagentext")})`);
ok(t.split("Ins Team holen").length - 1 === 1,
`der Zugangsknopf ebenso (${t.split("Ins Team holen").length - 1})`);
/* ===== DER ROLLENNAME KOMMT ALS DATEN, NICHT AUS DEM SKRIPT ========
Seit dem 16.09.2026 steht der Name im Regeltext des Treffs nicht
mehr fest im Browser -- er kommt mit der Antwort des Servers. Der
Grund: Jede Datei unter workspace/ ist ohne Anmeldung abrufbar
(nachgemessen: `talente.js` liefert 200 und 25 KB Quelltext).
DAS BRAUCHT EINE PRUEFUNG, und zwar genau hier. Im HTML steht als
Ersatzfassung „jemandem aus dem Team" -- ein Satz, der auch ohne
Skript stimmt. Kommt der Name NICHT an, bleibt diese Fassung
stehen, die Seite sieht vollkommen richtig aus, und niemand merkt
es. Ein Platzhalter „…" waere aufgefallen; eine richtige Ersatz-
fassung faellt nicht auf. Deshalb wird nachgesehen, ob wirklich
der Name dasteht. */
const regeln = gesehen.regeln || "";
ok(regeln.length > 400, `Regeln: die Seite ist gefüllt (${regeln.length} Zeichen)`);
ok(/sag es einem \S+/.test(regeln),
"Regeln: der Rollenname ist im Kontaktsatz angekommen");
ok(!/jemandem aus dem Team/.test(regeln),
"die Ersatzfassung steht NICHT mehr da — sie wurde wirklich ersetzt");
ok(/Pause von bis zu \d+ Tagen/.test(regeln),
"und die Frist steht als Zahl darin, nicht als Platzhalter");
const hand = gesehen["entwicklung-hand"] || "";
const dogi = gesehen.entwicklung || "";
/* WENN ES SCHIEFGEHT, SOLL DIE MELDUNG SAGEN, WAS STATTDESSEN DA WAR.
"nicht gefunden" allein laesst offen, ob die Karte fehlt oder die
ganze Seite eine andere war. */
const obenLinks = (hand.split("\n").map((z) => z.trim()).filter(Boolean)[0] || "(leer)");
/* OHNE RUECKSICHT AUF GROSS UND KLEIN: innerText liefert, was der
Browser ZEIGT -- und .e-abschnitt ist in Grossbuchstaben gesetzt.
Im Quelltext steht "Deine Karte", auf dem Bildschirm "DEINE
KARTE". Eine Suche mit Ruecksicht darauf meldet "steht nicht da"
fuer etwas, das dasteht. */
ok(/Deine Karte/i.test(hand),
`Entwicklung: die rechte Hand sieht ihre eigene Karte (${JSON.stringify(gesehen["entwicklung-hand:karte"])} / oben: ${obenLinks})`);
ok(!/Deine Karte/i.test(dogi),
"und DogFather sieht sie nicht -- ueber ihn urteilt hier niemand");
ok(hand !== dogi && hand.length > 400,
`zwei verschiedene Bildschirme (${dogi.length} / ${hand.length} Zeichen) -- der Keks hat also gewirkt`);
/* UND KEIN NAME EINES BEURTEILERS. Was der Server nicht ausliefert,
kann die Seite nicht zeigen -- aber genau das ist die Behauptung,
und Behauptungen gehoeren gemessen. */
ok(!/Filipe/.test(hand),
"und in ihrer Karte steht kein Name dessen, der geurteilt hat");
}
await browser.close();
melde("");
melde(`Bilder liegen in ${BILDER}`);
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
/* Der Ordner bleibt liegen, solange der Dienst die Datenbank offen hat
-- Aufräumrest, kein Befund. Die Bilder darin sind ohnehin das
Ergebnis; sie werden bewusst NICHT mit weggeräumt. */
process.exit(fehler ? 1 : 0);