Stufe 8 aus dem Plan zur Perfektion: "Loeschkonzept je Datenart ·
IP-Adressen im Protokoll nach 30 Tagen weg. Je mehr Daten, desto teurer
das Nachholen."
WARUM NICHT ALS NOTIZ
Ein Loeschkonzept als Dokument ist nach dem ersten neuen Feld falsch,
und niemand merkt es. workspace-aufbewahrung.js ist beides: die
Uebersicht, was wie lange aufgehoben wird -- UND das Programm, das es
tut. Was dort nicht steht, wird nicht geraeumt; was dort steht, wird
geraeumt.
GEMESSEN, BEVOR ETWAS GEBAUT WURDE
42 Tabellen durchgesehen. Drei halten IP-Adressen:
versuche.ip wird beim Anmelden im Fenster geraeumt ok
sitzungen.ip nur beim Abmelden -- ABGELAUFENE Sitzungen
standen ewig da, samt IP und Browser Luecke
protokoll.ip wurde nirgends geraeumt, wuchs ewig Luecke
Zwei echte Luecken also, nicht fuenf vermutete.
DREI SORTEN EINTRAG, UND DIE DRITTE IST DIE WICHTIGSTE
raeumen Diese Datei loescht selbst.
fremd Eine andere Stelle loescht -- hier wird nur GEZAEHLT.
Bricht die andere Stelle, steht die Zahl trotzdem da.
bleibt Wird absichtlich aufgehoben, mit Begruendung.
Ein Loeschkonzept, das nur Loeschungen auffuehrt, ist ein halbes: Die
begruendete Aufbewahrung ist der Teil, nach dem gefragt wird. Jede
Zeile nennt Frist, Zweck, Rechtsgrundlage und Wirkung.
SICHTBAR, NICHT NUR WIRKSAM
Auf automation.html -- dort steht alles, was ohne Zutun laeuft, und
genau das ist es. Bei jeder Zeile die Zahl, wie viele Datensaetze sie
gerade betrifft, und ein Knopf "Jetzt aufraeumen", damit es kein
schwarzer Kasten ist. Die Sorte steht als WORT daneben, nicht nur als
Farbe.
BEI ETWAS, DAS LOESCHT, IST DIE WICHTIGERE HAELFTE: WAS ES NICHT LOESCHT
Zu jeder Loeschung eine Gegenprobe:
IP nach 30 Tagen weg -> und die von vor 29 Tagen bleibt
IP weg -> und die ZEILE bleibt (nur das Feld)
abgelaufene Sitzung weg -> und die gueltige bleibt
abgelaufene Sitzung weg -> und ich bin danach noch angemeldet
Die letzte ist die unbequemste und die wichtigste: Ein Aufraeumlauf,
der die eigene Anmeldung mitnimmt, wirft im Betrieb alle hinaus -- und
zwar einmal taeglich. Dazu: ein zweiter Lauf muss NICHTS mehr finden
(sonst waere "aufgeraeumt" nicht von "nichts getan" zu unterscheiden),
und eine Zaehlung ueber eine fehlende Tabelle ergibt `null`, nicht 0.
NEBENBEI: EINE EIGENE DOPPLUNG VON GESTERN
Drei der Seitenerklaerungen sagten in "Wofuer" und "Du tust" fast
dasselbe (report 80 %, treff-regeln 67 %, startcheck 50 %). Gestern
hatte ich nur den Fall geprueft, an dem ich mich verbrannt hatte --
"Wofuer" gegen die Unterzeile -- und die naheliegendere Dopplung
uebersehen. Jetzt gemessen, schlechtester Wert 25 %.
PRUEFUNGEN: pruef-aufbewahrung neu mit 41, davon 7 im Browser
(der Knopf muss eine Zahl wirklich fallen lassen, und die IP muss
danach in der Datenbank weg sein). pruef-erklaerung 44 -> 47.
Co-Authored-By: Claude Opus 5 <[email protected]>
487 lines
23 KiB
JavaScript
487 lines
23 KiB
JavaScript
/* =====================================================================
|
|
Die kurze Erklärung auf jeder Seite (14.09.2026)
|
|
|
|
Filipe: „ich will in jeder seite auch eine kleine detaillierte aber
|
|
schnelle erklärung kurz und knapps noch zu jeder seite."
|
|
|
|
---------------------------------------------------------------------
|
|
WAS HIER WIRKLICH GEMESSEN WIRD
|
|
|
|
Dass ein Text ankommt, ist die leichteste und unwichtigste Frage.
|
|
Die drei, auf die es ankommt:
|
|
|
|
1. GIBT ES ZU JEDER SEITE EINEN? — abgeleitet aus der Rechtetafel,
|
|
nicht gegen eine Zahl. Kommt morgen eine 26. Seite dazu und
|
|
niemand schreibt ihr einen Text, muss das hier rot werden.
|
|
|
|
2. IST DIE ZEILE „SIEHT" GERECHNET ODER BEHAUPTET? — geprüft,
|
|
indem die Rechtetafel wirklich umgestellt wird. Wandert der
|
|
Satz mit, kommt er aus der Tafel. Ohne diese Gegenprobe wäre
|
|
„steht da" auch dann grün, wenn der Satz danebengeschrieben
|
|
wäre — und das ist die eine Angabe, die niemals lügen darf.
|
|
|
|
3. IST DIE ERKLÄRUNG SO VERSCHLOSSEN WIE DIE SEITE? — ein Modi
|
|
fragt nach „Personen & Zugänge" und muss 404 bekommen, nicht
|
|
den Text und nicht „verboten". Sonst ist dieser Weg eine
|
|
Landkarte des Hauses.
|
|
|
|
Dazu, im Browser: Der Kasten muss auf einer Seite MIT Kopfzeile
|
|
erscheinen und auf einer OHNE. Der zweite Fall ist der, der lautlos
|
|
ausfallen würde — fünf Seiten haben keine `.kopf-zeile`, darunter
|
|
Chat und Kalender.
|
|
|
|
Aufrufen mit: node server/pruef-erklaerung.mjs
|
|
===================================================================== */
|
|
|
|
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { request as httpAnfrage } from "node:http";
|
|
import { portMussFreiSein } from "./helfer-port.mjs";
|
|
|
|
const PORT = await portMussFreiSein(4406, "pruef-erklaerung");
|
|
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-erkl-"));
|
|
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-erklaerung");
|
|
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 CREW = "crew.dogfather-universe.com";
|
|
const HAUS = "workspace.dogfather-universe.com";
|
|
const BASIS = `http://127.0.0.1:${PORT}`;
|
|
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
|
|
const rechte = await import("./rechte.js");
|
|
const { ERKLAERUNGEN } = await import("./workspace-erklaerung.js");
|
|
|
|
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);
|
|
function anlegen(name, rolle, code) {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
|
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
|
|
+ " VALUES (?,?,?,?,?,?,1,?)")
|
|
.run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
|
|
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
}
|
|
anlegen("Filipe", "admin", "CODE-DOGI-0001");
|
|
anlegen("Rieke", "hand", "CODE-HAND-0001");
|
|
anlegen("Frida", "modi", "CODE-MODI-0001");
|
|
d.close();
|
|
|
|
function roh(pfad, host, 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: { Host: host, ...(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();
|
|
});
|
|
}
|
|
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
|
|
async function anmelden(rolle, code, host) {
|
|
const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
|
|
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
|
|
}
|
|
const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
|
|
const kHand = await anmelden("hand", "CODE-HAND-0001", CREW);
|
|
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
|
|
ok(!!kDogi && !!kHand && !!kModi, "DogFather, rechte Hand und ein Modi sind angemeldet");
|
|
|
|
const erkl = (datei, host, keks) =>
|
|
roh("/workspace/api/erklaerung?seite=" + datei, host, { cookie: keks });
|
|
|
|
/* =======================================================================
|
|
1. ZU JEDER SEITE EIN TEXT — abgeleitet, nicht gegen eine Zahl
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 1. Jede Seite hat einen Text ===");
|
|
{
|
|
const seiten = rechte.alleSeiten().map((p) => p.replace("/workspace/", ""));
|
|
ok(seiten.length > 20, `${seiten.length} Seiten in der Rechtetafel`);
|
|
|
|
const ohne = seiten.filter((s) => !ERKLAERUNGEN[s]);
|
|
ok(ohne.length === 0,
|
|
ohne.length ? `OHNE TEXT: ${ohne.join(", ")}` : `zu allen ${seiten.length} Seiten steht ein Text`);
|
|
|
|
/* Und umgekehrt: kein Text zu einer Seite, die es gar nicht gibt.
|
|
Sonst sammelt sich hier Prosa zu Seiten, die längst weg sind. */
|
|
const verwaist = Object.keys(ERKLAERUNGEN).filter((s) => !seiten.includes(s));
|
|
ok(verwaist.length === 0,
|
|
verwaist.length ? `TEXT OHNE SEITE: ${verwaist.join(", ")}` : "und kein Text zu einer Seite, die es nicht gibt");
|
|
|
|
/* GEGENPROBE zum Sucher selbst: Eine erfundene Seite MUSS als fehlend
|
|
auffallen -- sonst wäre die Zeile darüber auch bei leerer Tabelle grün. */
|
|
ok(!ERKLAERUNGEN["gibt-es-nicht.html"],
|
|
"eine erfundene Seite hat erwartungsgemäß keinen Text");
|
|
}
|
|
|
|
/* =======================================================================
|
|
2. KURZ UND KNAPP — seine Bedingung, also gemessen
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 2. Kurz und knapp ===");
|
|
{
|
|
const alle = Object.entries(ERKLAERUNGEN);
|
|
ok(alle.length > 20, `${alle.length} Erklärungen geprüft`);
|
|
|
|
const zuLang = alle.filter(([, e]) =>
|
|
[e.wofuer, e.tun, e.merke].filter(Boolean).some((s) => s.length > 135));
|
|
ok(zuLang.length === 0,
|
|
zuLang.length ? `ZU LANG: ${zuLang.map(([s]) => s).join(", ")}`
|
|
: "kein Satz über 135 Zeichen — „kurz und knapp“ war die Bedingung");
|
|
|
|
const leer = alle.filter(([, e]) => !e.wofuer?.trim() || !e.tun?.trim());
|
|
ok(leer.length === 0, "jede Erklärung hat „Wofür“ und „Du tust“");
|
|
|
|
/* KEINE KLICKANLEITUNGEN. Sie altern mit jedem Umbau der Oberfläche
|
|
und zeigen danach ins Leere -- schlimmer als gar kein Text. */
|
|
const klick = alle.filter(([, e]) =>
|
|
/klicke|tippe auf|oben rechts|unten links|Schaltfläche/i.test(
|
|
[e.wofuer, e.tun, e.merke].filter(Boolean).join(" ")));
|
|
ok(klick.length === 0,
|
|
klick.length ? `KLICKANLEITUNG: ${klick.map(([s]) => s).join(", ")}`
|
|
: "keine Klickanleitungen — erklärt wird der Zweck, nicht der Weg");
|
|
ok(/klicke/i.test("dann klicke oben rechts"), "und der Sucher findet so eine auch wirklich");
|
|
|
|
/* JEDE ZEILE SAGT ETWAS EIGENES.
|
|
|
|
Gemessen wird der Anteil der langen Woerter, die in BEIDEN Zeilen
|
|
vorkommen. Ein paar gemeinsame sind unvermeidlich und richtig --
|
|
„Zeitraum" steht in „Wofuer" und in „Du tust", weil es um den
|
|
Zeitraum geht. Ueber 40 Prozent ist es keine zweite Auskunft mehr,
|
|
sondern derselbe Satz in anderer Reihenfolge. */
|
|
const lang = (s) => new Set((s.toLowerCase().match(/[a-zäöüß]{5,}/g) || []));
|
|
const echo = alle.map(([seite, e]) => {
|
|
const a = lang(e.wofuer), b = lang(e.tun);
|
|
if (!a.size) return null;
|
|
let gleich = 0;
|
|
for (const x of a) if (b.has(x)) gleich++;
|
|
return { seite, anteil: gleich / a.size, gleich, gesamt: a.size };
|
|
}).filter(Boolean);
|
|
ok(echo.length === alle.length, `${echo.length} Paare „Wofür“/„Du tust“ verglichen`);
|
|
const doppelt = echo.filter((x) => x.anteil >= 0.4);
|
|
ok(doppelt.length === 0,
|
|
doppelt.length
|
|
? "SPRECHEN EINANDER NACH: " + doppelt.map((x) =>
|
|
`${x.seite} (${Math.round(x.anteil * 100)} %, ${x.gleich}/${x.gesamt})`).join(", ")
|
|
: `keine Seite sagt zweimal dasselbe (schlechtester Wert: ${Math.round(Math.max(...echo.map((x) => x.anteil)) * 100)} %)`);
|
|
/* GEGENPROBE zum Messverfahren selbst -- sonst waere „keine Seite"
|
|
auch dann gruen, wenn der Vergleich gar nichts findet. */
|
|
{
|
|
const a = lang("Alle Aufgaben des Teams mit Frist und Rückmeldungen");
|
|
const b = lang("Alle Aufgaben des Teams mit Frist und Rückmeldungen ansehen");
|
|
let g = 0; for (const x of a) if (b.has(x)) g++;
|
|
ok(g / a.size >= 0.9, `und zwei fast gleiche Sätze erkennt es als solche (${g}/${a.size})`);
|
|
}
|
|
|
|
/* HAUSREGEL: echte Umlaute in allem, was ein Mensch liest. */
|
|
const ascii = alle.filter(([, e]) =>
|
|
/\b(fuer|koenn|muess|ueber|waer|moecht|oeffn|schliess)/i.test(
|
|
[e.wofuer, e.tun, e.merke].filter(Boolean).join(" ")));
|
|
ok(ascii.length === 0,
|
|
ascii.length ? `ASCII-UMLAUTE: ${ascii.map(([s]) => s).join(", ")}` : "keine ASCII-Ersatzumlaute");
|
|
}
|
|
|
|
/* =======================================================================
|
|
3. DIE ZEILE „SIEHT" IST GERECHNET, NICHT BEHAUPTET
|
|
|
|
Das ist die Prüfung, derentwegen es diese Datei gibt.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 3. Gerechnet, nicht behauptet ===");
|
|
{
|
|
const vorher = json(await erkl("talente.html", HAUS, kDogi));
|
|
ok(vorher.hat_text === true, "DogFather bekommt die Erklärung zu „Talente“");
|
|
ok(Array.isArray(vorher.sichtbar) && vorher.sichtbar.includes("Rechte Hand"),
|
|
`dort steht, wer sie sieht: ${(vorher.sichtbar || []).join(" · ")}`);
|
|
/* Die Rangfolge gilt auch hier: die rechte Hand direkt hinter DogFather. */
|
|
ok(vorher.sichtbar[0] === "DogFather" && vorher.sichtbar[1] === "Rechte Hand",
|
|
"in der Rangfolge des Hauses, nicht alphabetisch");
|
|
|
|
/* JETZT DIE TAFEL WIRKLICH UMSTELLEN. */
|
|
const zu = await roh("/workspace/api/rechte/feld", HAUS, { cookie: kDogi },
|
|
{ pfad: "/workspace/talente.html", rolle: "hand", erlaubt: false }, "PUT");
|
|
ok(zu.code === 200, `die rechte Hand wird für „Talente“ abgeschaltet (${zu.code})`);
|
|
|
|
const nachher = json(await erkl("talente.html", HAUS, kDogi));
|
|
ok(!(nachher.sichtbar || []).includes("Rechte Hand"),
|
|
`danach steht sie nicht mehr da: ${(nachher.sichtbar || []).join(" · ")}`);
|
|
ok(vorher.sichtbar.length - 1 === nachher.sichtbar.length,
|
|
"genau eine Rolle weniger — der Satz kommt also aus der Tafel");
|
|
|
|
/* Und sie bekommt die Erklärung jetzt auch selbst nicht mehr. */
|
|
const ihre = await erkl("talente.html", CREW, kHand);
|
|
ok(ihre.code === 404,
|
|
`und sie selbst bekommt jetzt 404 statt des Textes (${ihre.code})`);
|
|
|
|
/* ZURÜCK — eine Prüfung, die den Stand verändert zurücklässt, ist
|
|
eine Falle für die nächste. */
|
|
const auf = await roh("/workspace/api/rechte/feld", HAUS, { cookie: kDogi },
|
|
{ pfad: "/workspace/talente.html", rolle: "hand", erlaubt: true }, "PUT");
|
|
ok(auf.code === 200, "danach ist alles wieder wie vorher");
|
|
const zurueck = json(await erkl("talente.html", HAUS, kDogi));
|
|
ok((zurueck.sichtbar || []).join() === vorher.sichtbar.join(),
|
|
"und der Satz steht wieder genau so da wie am Anfang");
|
|
}
|
|
|
|
/* =======================================================================
|
|
4. SO VERSCHLOSSEN WIE DIE SEITE SELBST
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 4. Dieselbe Schranke ===");
|
|
{
|
|
const fremd = await erkl("personen.html", CREW, kModi);
|
|
ok(fremd.code === 404,
|
|
`ein Modi bekommt zu „Personen & Zugänge“ ein 404 (${fremd.code})`);
|
|
ok(!/Zugangscode|Rolle/.test(fremd.text),
|
|
"und kein Wort aus dem Text — die Absage verrät nichts über die Seite");
|
|
|
|
/* GEGENPROBE: Eine Seite, die er öffnen darf, bekommt er auch erklärt.
|
|
Ohne sie wäre „404“ oben auch dann grün, wenn der Weg gar nicht
|
|
funktioniert. */
|
|
const seine = json(await erkl("entwicklung.html", CREW, kModi));
|
|
ok(seine.hat_text === true, "seine eigene Entwicklungsseite bekommt er erklärt");
|
|
|
|
const ohneAnmeldung = await erkl("start.html", HAUS, "");
|
|
ok(ohneAnmeldung.code === 401, `ohne Anmeldung gar nichts (${ohneAnmeldung.code})`);
|
|
|
|
/* Kein Weg aus dem Verzeichnis heraus. */
|
|
for (const bösartig of ["../../etc/passwd", "..%2Fgeheim.html", "start.html%00", "start"]) {
|
|
const r = await erkl(encodeURIComponent(bösartig), HAUS, kDogi);
|
|
ok(r.code === 400, `„${bösartig}“ wird abgelehnt (${r.code})`);
|
|
}
|
|
}
|
|
|
|
/* =======================================================================
|
|
5. AUF JEDER SEITE EINGEBUNDEN — auch auf denen ohne Kopfzeile
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 5. Auf jeder Seite eingebunden ===");
|
|
{
|
|
const dateien = readdirSync("workspace").filter((n) => n.endsWith(".html"));
|
|
const mitKopf = dateien.filter((n) =>
|
|
/assets\/js\/kopf\.js/.test(readFileSync("workspace/" + n, "utf8")));
|
|
ok(mitKopf.length > 20, `${mitKopf.length} Seiten laden kopf.js`);
|
|
|
|
const ohne = mitKopf.filter((n) =>
|
|
!/assets\/js\/erklaerung\.js/.test(readFileSync("workspace/" + n, "utf8")));
|
|
ok(ohne.length === 0,
|
|
ohne.length ? `OHNE erklaerung.js: ${ohne.join(", ")}` : "alle davon laden auch erklaerung.js");
|
|
|
|
/* Die fünf ohne Kopfzeile sind der Fall, der lautlos ausfallen würde
|
|
-- der Kasten hängt sich dort an <main> statt hinter die Kopfzeile.
|
|
Die Zahl gehört in die Bedingung: Fände die Suche keine, wäre der
|
|
zweite Weg nie geprüft worden. */
|
|
const ohneKopfzeile = mitKopf.filter((n) =>
|
|
!/class="kopf-zeile"/.test(readFileSync("workspace/" + n, "utf8")));
|
|
ok(ohneKopfzeile.length > 0,
|
|
`${ohneKopfzeile.length} Seiten haben keine Kopfzeile: ${ohneKopfzeile.join(", ")}`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
6. UND STEHT SIE AUCH WIRKLICH AUF DEM BILDSCHIRM?
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 6. Im Browser ===");
|
|
{
|
|
const { chromium } = await import(
|
|
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
|
const browser = await chromium.launch();
|
|
const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
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));
|
|
|
|
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 });
|
|
|
|
/** Wartet, bis der Kasten da ist -- oder bis feststeht, dass er nicht kommt. */
|
|
async function ansehen(datei) {
|
|
await seite.goto(`${BASIS}/workspace/${datei}`, { waitUntil: "networkidle" });
|
|
await seite.waitForSelector(".erkl", { timeout: 6000 }).catch(() => {});
|
|
return seite.evaluate(() => {
|
|
const k = document.querySelector(".erkl");
|
|
if (!k) return { da: false };
|
|
const r = k.getBoundingClientRect();
|
|
const schilder = [...k.querySelectorAll(".erkl__schild")].map((s) => s.textContent.trim());
|
|
return {
|
|
da: true, hoehe: Math.round(r.height), breite: Math.round(r.width),
|
|
inMain: !!k.closest("main"),
|
|
schilder, text: k.innerText.trim(),
|
|
/* Steht er VOR dem ersten Inhalt und nicht irgendwo mittendrin? */
|
|
erstesDrittel: r.top < window.innerHeight,
|
|
};
|
|
});
|
|
}
|
|
|
|
/* Eine Seite MIT Kopfzeile ... */
|
|
const mit = await ansehen("talente.html");
|
|
ok(mit.da === true, "auf „Talente“ (mit Kopfzeile) steht der Kasten");
|
|
ok(mit.inMain && mit.erstesDrittel, "im Inhalt und ohne Scrollen sichtbar");
|
|
ok(mit.schilder.join(",").includes("SIEHT") || /SIEHT/i.test(mit.text),
|
|
`mit allen Zeilen: ${mit.schilder.join(" / ")}`);
|
|
ok(mit.hoehe > 30 && mit.hoehe < 260, `und er bleibt klein (${mit.hoehe} px hoch)`);
|
|
|
|
/* ===================================================================
|
|
STEHT DER TEXT AUF EINER FLAECHE -- UND IST ER LESBAR?
|
|
|
|
BEIDE MAENGEL WAREN IN DER ERSTEN FASSUNG GRUEN. Gefunden hat sie
|
|
nicht diese Datei, sondern ein Blick auf das Bildschirmfoto: Der
|
|
Kasten stand mitten auf der Buehne, mit einem Lichtfleck hinter
|
|
„SIEHT". Eine Pruefung, die Groesse und Position misst und nicht
|
|
die Lesbarkeit, prueft das Bequeme statt das Wichtige.
|
|
|
|
DREI AUSGAENGE, wie ueberall: lesbar / zu dunkel / konnte nicht
|
|
nachsehen. Der dritte ist hier der eigentliche Fund -- Text auf
|
|
einem Foto hat gar keinen berechenbaren Kontrast, und genau das
|
|
war der Zustand. */
|
|
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 zahlen = (s) => (s.match(/[\d.]+/g) || []).map(Number);
|
|
/* Der erste Vorfahr mit einer WIRKLICH deckenden Flaeche. Alles
|
|
darunter ist Durchsicht -- und ein Foto ist keine Flaeche. */
|
|
const grund = (e) => {
|
|
let n = e;
|
|
while (n && n !== document.documentElement) {
|
|
const st = getComputedStyle(n);
|
|
const f = zahlen(st.backgroundColor);
|
|
const deckend = f.length >= 3 && (f.length < 4 || f[3] >= 0.92);
|
|
if (deckend && st.backgroundImage === 'none') return f.slice(0, 3);
|
|
if (deckend) return null; // deckend, aber ein Bild darauf
|
|
n = n.parentElement;
|
|
}
|
|
return null;
|
|
};
|
|
const kasten = document.querySelector('.erkl');
|
|
if (!kasten) return { fehlt: true };
|
|
const stuecke = [...kasten.querySelectorAll('.erkl__schild, .erkl__satz')];
|
|
const schlecht = [], geraten = [];
|
|
let gemessen = 0;
|
|
for (const s of stuecke) {
|
|
if (!s.textContent.trim()) continue;
|
|
const h = grund(s);
|
|
if (!h) { geraten.push(s.textContent.trim().slice(0, 30)); continue; }
|
|
const v = zahlen(getComputedStyle(s).color).slice(0, 3);
|
|
const a = leuchte(...v), b = leuchte(...h);
|
|
const v2 = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
gemessen++;
|
|
if (v2 < 4.5) schlecht.push(s.textContent.trim().slice(0, 30) + ' = ' + v2.toFixed(2));
|
|
}
|
|
return { gemessen, schlecht, geraten };
|
|
})()`;
|
|
|
|
const k = await seite.evaluate(KONTRAST);
|
|
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Faende die Messung kein
|
|
Textstueck, waere „alles lesbar" trivial wahr. */
|
|
ok(k.gemessen >= 6 && k.schlecht.length === 0,
|
|
`${k.gemessen} Textstücke im Kasten, alle lesbar`
|
|
+ (k.schlecht.length ? " -- ZU DUNKEL: " + k.schlecht.join(" | ") : ""));
|
|
ok(k.geraten.length === 0,
|
|
`jedes davon steht auf einer deckenden Fläche`
|
|
+ (k.geraten.length ? ` -- AUF DER BÜHNE (${k.geraten.length}): ` + k.geraten.slice(0, 3).join(" | ") : ""));
|
|
|
|
/* ===================================================================
|
|
SPRICHT „WOFUER" DIE UNTERZEILE NACH?
|
|
|
|
Auch das war gruen und trotzdem falsch: Auf „Talente" stand oben
|
|
„Zuschauer, die ins Team passen koennten. Ein Trichter, keine
|
|
Liste" -- und zwei Zeilen darunter fast woertlich dasselbe. Das
|
|
liest sich wie ein Kopierfehler und verschenkt die Zeile.
|
|
|
|
Gemessen wird der Anteil der langen Woerter, die in BEIDEN Saetzen
|
|
vorkommen. Ueber der Haelfte ist es keine Erklaerung mehr, sondern
|
|
ein Echo. */
|
|
const echo = await seite.evaluate(() => {
|
|
const u = document.querySelector('main .unterzeile')?.textContent?.trim() || '';
|
|
const zeilen = [...document.querySelectorAll('.erkl__zeile')];
|
|
const w = zeilen.find((z) => /wof/i.test(z.querySelector('.erkl__schild')?.textContent || ''));
|
|
const wofuer = w?.querySelector('.erkl__satz')?.textContent?.trim() || '';
|
|
if (!u || !wofuer) return { pruefbar: false, u, wofuer };
|
|
const lang = (s) => new Set((s.toLowerCase().match(/[a-zäöüß]{5,}/g) || []));
|
|
const a = lang(wofuer), b = lang(u);
|
|
if (!a.size) return { pruefbar: false, u, wofuer };
|
|
let gleich = 0;
|
|
for (const x of a) if (b.has(x)) gleich++;
|
|
return { pruefbar: true, anteil: gleich / a.size, gleich, gesamt: a.size, u, wofuer };
|
|
});
|
|
ok(echo.pruefbar === true,
|
|
echo.pruefbar ? "Unterzeile und „Wofür“ stehen beide da — vergleichbar"
|
|
: `nicht vergleichbar (Unterzeile: „${(echo.u || "").slice(0, 40)}")`);
|
|
ok(echo.pruefbar && echo.anteil < 0.5,
|
|
`„Wofür“ sagt etwas anderes als die Unterzeile (${echo.gleich} von ${echo.gesamt} langen Wörtern gleich)`);
|
|
|
|
|
|
/* ... und eine OHNE. Das ist der Fall, der lautlos ausfallen würde. */
|
|
const ohne = await ansehen("kalender.html");
|
|
ok(ohne.da === true, "auf „Kalender“ (OHNE Kopfzeile) steht er genauso");
|
|
ok(ohne.inMain, "und zwar im Inhalt, nicht davor");
|
|
|
|
/* Am Handy: bricht nichts seitlich aus? */
|
|
const handy = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const s2 = await handy.newPage();
|
|
await s2.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
|
await s2.click('.rolle[data-rolle="admin"]');
|
|
await s2.fill("#code", "CODE-DOGI-0001");
|
|
await s2.click("button[type=submit]");
|
|
await s2.waitForURL("**/start.html", { timeout: 20000 });
|
|
await s2.goto(BASIS + "/workspace/talente.html", { waitUntil: "networkidle" });
|
|
await s2.waitForSelector(".erkl", { timeout: 6000 }).catch(() => {});
|
|
const eng = await s2.evaluate(() => {
|
|
const k = document.querySelector(".erkl");
|
|
if (!k) return null;
|
|
const r = k.getBoundingClientRect();
|
|
return { raus: Math.round(r.right - document.documentElement.clientWidth), hoehe: Math.round(r.height) };
|
|
});
|
|
ok(eng !== null && eng.raus <= 1, `am Handy nichts seitlich draußen (${eng?.raus} px)`);
|
|
ok(eng !== null && eng.hoehe < 340, `und immer noch kompakt (${eng?.hoehe} px)`);
|
|
|
|
ok(konsole.length === 0,
|
|
konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser");
|
|
|
|
await browser.close();
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- */
|
|
melde("");
|
|
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
|
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
|
|
process.exit(fehler ? 1 : 0);
|