Files
dogfather-universe/server/pruef-material.mjs
T
DogFatherGit 077925ab25 Dreizehn rote Pruefungen, eine Ursache: openssl steht nicht im PATH
DER BEFUND

Im naechtlichen Lauf standen dreizehn Pruefungen mit derselben Zeile
rot:

    [uncaughtException] Error: spawnSync openssl ENOENT

Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau (der
Sitzungskeks des Hauses ist `secure` und kommt ueber http nicht an),
und dafuer erzeugen sie ein Wegwerf-Zertifikat mit openssl.

GEMESSEN, NICHT VERMUTET

  openssl liegt hier:   C:\Program Files\Git\mingw64\bin\openssl.exe
                        C:\Program Files\Git\usr\bin\openssl.exe
  im System-PATH steht: C:\Program Files\Git\cmd   (nur git.exe)
  im Benutzer-PATH:     nichts mit Git

Wer aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas.
Die Aufgabenplanung startet `node.exe` DIREKT, ohne Shell -- und
bekommt sie nicht. Bei mir gruen, nachts rot, und der Grund steht
nicht im Code.

Das Schlimmste daran ist nicht der Ausfall: Dreizehn dauerhaft rote
Zeilen in einer Notiz, die Filipe morgens liest, gewoehnen einem das
Hinsehen ab -- und decken dabei die echten Befunde zu.

DIE REPARATUR

`server/helfer-openssl.mjs` sucht openssl erst im PATH (ohne `where`
oder `which` -- beides sind selbst Programme und koennen genauso
fehlen), danach an den Orten, an denen es auf einem Windows-Rechner
mit Git wirklich liegt.

Dazu `zertifikatBauen(schluessel, zertifikat, host)`. Die acht
Aufrufformen im Haus waren identisch bis auf die Variablennamen
(schl/zert, schluesselDatei/zertDatei, zKey/zCrt, sk/zt, key/crt) --
eine Stelle traegt alle neunundzwanzig. Zwei Tage zuvor war eine
davon um ein `-addext` aermer als die anderen; gemerkt hat es
niemand, weil der Browser den fehlenden Alternativnamen erst bei
einer Weiterleitung anmahnt. Eine Stelle kann nicht von sich selbst
abweichen.

KEIN EINTRAG IN DEN SYSTEM-PATH. `mingw64\bin` enthaelt rund hundert
Programme mit Unix-Namen (`find`, `sort`, `link`), die gleichnamige
Windows-Befehle verdecken. Das fuer eine Pruefung zu aendern haette
an ganz anderen Stellen Fehler verursacht, die niemand hierher
zurueckverfolgt.

GEGENPROBE IN BEIDE RICHTUNGEN, mit dem PATH des Nachtlaufs:

  vorher (direkter Aufruf):  ABSTURZ: spawnSync openssl ENOENT
  nachher (ueber den Helfer): Zertifikat gebaut, 1236 Bytes

Und eine echte Pruefung unter denselben Bedingungen:

  PATH ohne mingw64 -> pruef-chat-neu-stelle
  63 Pruefungen, 0 Fehler, 0 Abstuerze

Genau diese Datei stand im Nachtlauf mit ENOENT rot.

EINE WACHE DAGEGEN

pruef-struktur prueft ab jetzt, dass niemand openssl wieder direkt
ruft -- der naechste merkt es dort und nicht erst in einem
Nachtlauf, den niemand liest. Drei Gegenproben.

Beim ersten Anlauf schlug sie auf ihre EIGENEN Probetexte an: Sie
liest alle Serverdateien, und dazu gehoert sie selbst. Die Texte
werden jetzt zusammengesetzt. Derselbe Selbsttreffer ist mir heute
schon zweimal passiert.

DER DRITTE AUSGANG BLEIBT, WO ER HINGEHOERT

`helfer-kachel-echtfarbe.mjs` faengt den Fehler weiterhin ab und
meldet `moeglich: false` mit Grund, statt abzubrechen. Eine
Sicherung abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags.

NEBENBEI: pruef-community-sicht

Sie war rot mit "ZU VIEL: reaktion.html" -- mein eigener Rueckstand
vom 28.09. Die Reaction steht als Kachel im Community-Bereich; die
Liste war nicht nachgezogen. Jetzt 10 / 0.

Die Liste bleibt bewusst von Hand gepflegt: Sie ist eine ABSICHT,
keine Ableitung. Aus rechte.js gelesen verglichen sich zwei Kopien
derselben Quelle -- immer gruen, nie ein Beweis.

GEPRUEFT
  pruef-struktur         ALLES IN ORDNUNG (mit der neuen Wache)
  pruef-community-sicht  10 / 0
  pruef-chat-neu-stelle  63 / 0  (mit dem PATH des Nachtlaufs)
  pruef-notizen, -teamlage-karten, -werdegang: EXIT 0, keine Abstuerze
  mess-quer              EXIT 0
  29 Dateien: node --check auf allen, kein direkter Aufruf mehr
2026-09-29 16:14:13 +02:00

1093 lines
52 KiB
JavaScript

/* =====================================================================
pruef-material.mjs — BILDER UND VIDEOS ZUM POSTEN, JEDES NUR EINMAL
=====================================================================
Auftrag vom 22.09.2026:
"sobald ein bild oder ein video was von uns gepostet wurde,
runtergeladen wurde, soll direkt blockiert werden so dass man es
nicht mehr runterladen kann ... damit nie ein bild zwei mal
gepostet wird ... und die rechte hand und dogfather, nur die
beiden sollen auch immer sehen koennen wer das bild oder video
runtergeladen hat."
---------------------------------------------------------------------
WAS HIER GEMESSEN WIRD — und warum gerade das:
(1) DIE EINMALIGKEIT. Der entscheidende Nachweis ist NICHT der
gleichzeitige Zugriff: Node arbeitet die beiden Anfragen
ohnehin nacheinander ab, und `node:sqlite` ist synchron -- der
parallele Fall kann hier gar nicht schiefgehen. Was schiefgehen
KANN, ist die Reihenfolge "erst nachsehen, dann schreiben" ohne
Bedingung im UPDATE. Genau das faengt der ZWEITE Versuch nach
dem ersten ab: Er muss 409 bekommen, und der Vermerk darf sich
nicht mehr aendern. Der gleichzeitige Fall steht trotzdem hier --
er kostet nichts und faellt auf, falls jemand spaeter ein
`await` zwischen Lesen und Schreiben setzt.
(2) DASS DIE DATEI DANACH WIRKLICH ZU IST. Ein Vermerk in der
Datenbank ist keine Sperre, solange die Datei weiter
ausgeliefert wird. Geprueft wird der Abruf, nicht das Feld.
(3) DASS DIE VORSCHAU DIESELBE GRENZE HAT. Sonst waere sie der Weg,
ein vergebenes Bild doch noch zu bekommen (Rechtsklick,
speichern) -- und die ganze Einmaligkeit eine Behauptung.
(4) WER WEN SIEHT — fuer alle fuenf Rollen, nicht nur fuer die zwei,
die duerfen. Und nicht nur die Angabe `sieht_wer`, sondern das
Feld selbst: Eine Liste, die "nein" sagt und die Namen trotzdem
mitschickt, waere das Leck.
(5) DASS DIE COMMUNITY NICHTS EINSTELLT — und das Team schon. Ohne
die zweite Haelfte waere die erste auch dann gruen, wenn gar
nichts mehr ginge.
---------------------------------------------------------------------
WARUM node:http UND NICHT fetch: Das Team und die Community melden
sich an verschiedenen Waenden an, und welche gilt, entscheidet der
Host-Kopf. `fetch` in Node setzt den selbst und ignoriert einen
gesetzten -- die Anmeldung ginge an die falsche Wand.
===================================================================== */
import { mkdtempSync, rmSync } 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";
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const PORT = await eigenerPort(import.meta, "pruef-material");
/* DATEN_ORDNER wird in workspace.js aus dem Pfad der Datenbank
abgeleitet (`dirname(DB_PFAD)`) -- das Material landet damit von
selbst im Wegwerfordner. Eine eigene Umgebungsvariable dafuer waere
eine zweite Wahrheit, die beim naechsten Umbau auseinanderlaeuft. */
const ordner = mkdtempSync(join(tmpdir(), "ws-material-"));
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");
/* 240 -> 480 s (22.09.2026): Seit Abschnitt 13 faehrt ein echter
Browser mit. Eine Notbremse, die vor dem letzten Abschnitt
zuschlaegt, macht aus einer langsamen Pruefung eine rote. */
notbremse(480_000, "pruef-material");
await new Promise((r) => setTimeout(r, 900));
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";
/* ---------------------------------------------------------------------
PERSONEN
--------------------------------------------------------------------- */
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);
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("Lino", "linke", "CODE-LINK-0001");
anlegen("Frida", "modi", "CODE-MODI-0001");
anlegen("Kessi", "gast", "CODE-GAST-0001");
anlegen("Sam", "gast", "CODE-GAST-0002");
d.close();
/* ---------------------------------------------------------------------
DER DRAHT
--------------------------------------------------------------------- */
function roh(pfad, wie = {}) {
return new Promise((fertig, schief) => {
const daten = wie.roh !== undefined
? wie.roh
: (wie.body === undefined ? null : Buffer.from(JSON.stringify(wie.body)));
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: wie.methode || (daten ? "POST" : "GET"),
headers: {
Host: CREW,
...(daten ? {
"Content-Type": wie.typ || "application/json",
"Content-Length": daten.length,
} : {}),
...(wie.kopf || {}),
...(wie.keks ? { Cookie: wie.keks } : {}),
Origin: `https://${CREW}`,
Referer: `https://${CREW}/workspace/`,
},
}, (antwort) => {
const stuecke = [];
antwort.on("data", (s) => stuecke.push(s));
antwort.on("end", () => {
const puffer = Buffer.concat(stuecke);
const text = puffer.toString("utf8");
fertig({
code: antwort.statusCode, kopf: antwort.headers,
bytes: puffer.length, text,
json: (() => { try { return JSON.parse(text); } catch { return null; } })(),
});
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, code) {
/* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.). */
const r = await roh("/workspace/api/anmelden", {
body: rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code },
});
const kekse = [].concat(r.kopf?.["set-cookie"] || []);
return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code };
}
const chef = await anmelden("admin", "CODE-DOGI-0001");
const hand = await anmelden("hand", "CODE-HAND-0001");
const linke = await anmelden("linke", "CODE-LINK-0001");
const modi = await anmelden("modi", "CODE-MODI-0001");
const kessi = await anmelden("gast", "CODE-GAST-0001");
const sam = await anmelden("gast", "CODE-GAST-0002");
melde("");
melde("=== 0. Alle sechs kommen ueberhaupt herein ===");
for (const [wer, s] of [["DogFather", chef], ["rechte Hand", hand],
["linke Hand", linke], ["Modi", modi], ["Kessi", kessi], ["Sam", sam]]) {
ok(s.code === 200 && s.keks.length > 0, `${wer} ist angemeldet (${s.code})`);
}
/* Ein echtes, winziges PNG (1x1). Der Server prueft die Art -- ein
erfundener Byteblock haette den falschen Typ und die Pruefung waere
aus dem falschen Grund gruen. */
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64");
function einstellen(keks, name, text = "", fenster = {}) {
return roh("/workspace/api/material", {
methode: "POST", roh: PNG, typ: "image/png", keks,
kopf: {
"X-Datei-Name": encodeURIComponent(name),
"X-Text": encodeURIComponent(text),
/* NUR MITSCHICKEN, WENN ES ETWAS ZU SCHICKEN GIBT. Ein leerer
Kopf ist etwas anderes als ein fehlender, und die Pruefung
soll beides treffen koennen. */
...(fenster.ab !== undefined ? { "X-Gilt-Ab": fenster.ab } : {}),
...(fenster.bis !== undefined ? { "X-Gilt-Bis": fenster.bis } : {}),
},
});
}
const nehmen = (id, keks) =>
roh(`/workspace/api/material/${id}/nehmen`, { methode: "POST", keks });
const liste = (keks) => roh("/workspace/api/material", { keks });
/* =====================================================================
1. WER ETWAS BEREITSTELLEN DARF
===================================================================== */
melde("");
melde("=== 1. Wer etwas bereitstellen darf ===");
{
for (const [wer, s] of [["DogFather", chef], ["die rechte Hand", hand],
["die linke Hand", linke], ["ein Modi", modi]]) {
const a = await einstellen(s.keks, `Von ${wer}.png`, "Fuer TikTok");
ok(a.code === 201 && Number.isInteger(a.json?.id),
`${wer} stellt etwas bereit (${a.code}, Nummer ${a.json?.id})`);
}
/* DIE GEGENPROBE, und sie ist Filipes ausdruecklicher Satz:
"die community soll keine posten koennen in die kategorie". */
const g = await einstellen(kessi.keks, "Von der Community.png");
ok(g.code === 403, `die Community NICHT (${g.code})`);
ok(g.json?.fehler === "nur_team_stellt_ein",
`und bekommt einen Grund, zu dem es einen Satz gibt (${g.json?.fehler})`);
/* Und es ist auch wirklich nichts angekommen -- eine 403, nach der
die Datei trotzdem in der Liste steht, waere die schlimmere Sorte. */
const l = await liste(chef.keks);
const vonKessi = (l.json?.material || []).filter((m) => m.von === "Kessi").length;
ok(vonKessi === 0, `und es liegt auch nichts von ihr da (${vonKessi})`);
}
/* =====================================================================
2. WAS NICHT HINEINGEHOERT
===================================================================== */
melde("");
melde("=== 2. Was nicht hineingehoert ===");
{
const a = await roh("/workspace/api/material", {
methode: "POST", roh: Buffer.from("MZ-Programm"),
typ: "application/octet-stream", keks: chef.keks,
kopf: { "X-Datei-Name": encodeURIComponent("schadprogramm.exe") },
});
ok(a.code === 400, `ein Programm wird abgelehnt (${a.code})`);
ok(a.json?.fehler === "art_nicht_erlaubt", `mit Grund (${a.json?.fehler})`);
const b = await roh("/workspace/api/material", {
methode: "POST", roh: Buffer.alloc(0), typ: "image/png", keks: chef.keks,
});
ok(b.code === 400 && b.json?.fehler === "datei_fehlt",
`eine leere Datei ebenso (${b.code}, ${b.json?.fehler})`);
}
/* =====================================================================
3. DER KERN: JEDES NUR EINMAL
===================================================================== */
melde("");
melde("=== 3. Jedes nur einmal ===");
{
const a = await einstellen(chef.keks, "Umkaempft.png", "Wer zuerst kommt");
const id = a.json?.id;
ok(Number.isInteger(id), `ein Stueck zum Wettlauf liegt bereit (${id})`);
/* Beide Anfragen gehen los, bevor die erste beantwortet ist. */
const [x, y] = await Promise.all([nehmen(id, kessi.keks), nehmen(id, sam.keks)]);
const gewonnen = [x, y].filter((r) => r.code === 200).length;
const verloren = [x, y].filter((r) => r.code === 409).length;
ok(gewonnen === 1, `genau einer bekommt es (${gewonnen} von 2)`);
ok(verloren === 1, `und genau einer bekommt "schon vergeben" (${verloren})`);
ok([x, y].find((r) => r.code === 409)?.json?.fehler === "schon_genommen",
`mit Grund (${[x, y].find((r) => r.code === 409)?.json?.fehler})`);
/* DER EIGENTLICHE NACHWEIS: Wer danach kommt, bekommt nichts -- und
der Vermerk darf sich nicht mehr aendern. Ein Weg ohne Bedingung
im UPDATE wuerde hier den zweiten Namen eintragen, und die Liste
saehe danach voellig normal aus. */
const vorName = (await liste(chef.keks)).json?.material
?.find((m) => m.id === id)?.genommen_von;
const z = await nehmen(id, modi.keks);
ok(z.code === 409, `ein Dritter danach ebenso wenig (${z.code})`);
const nachName = (await liste(chef.keks)).json?.material
?.find((m) => m.id === id)?.genommen_von;
ok(!!vorName && vorName === nachName,
`und der Vermerk bleibt stehen (${vorName} -> ${nachName})`);
}
/* =====================================================================
4. DIE DATEI IST DANACH WIRKLICH ZU
===================================================================== */
melde("");
melde("=== 4. Die Datei ist danach wirklich zu ===");
{
const a = await einstellen(chef.keks, "Abgeschlossen.png");
const id = a.json?.id;
const vorher = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks });
ok(vorher.code === 403,
`vor dem Nehmen bekommt sie niemand (${vorher.code})`);
const genommen = await nehmen(id, kessi.keks);
ok(genommen.code === 200, `Kessi nimmt es (${genommen.code})`);
const seine = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks });
ok(seine.code === 200 && seine.bytes === PNG.length,
`und bekommt die Datei (${seine.code}, ${seine.bytes} von ${PNG.length} Bytes)`);
ok(/attachment/.test(String(seine.kopf?.["content-disposition"] || "")),
"als Download, nicht zum Anschauen im Browser");
/* DER RIEGEL. Ein Vermerk in der Datenbank ist keine Sperre,
solange die Datei weiter ausgeliefert wird. */
for (const [wer, s] of [["ein anderer aus der Community", sam],
["ein Modi", modi], ["die linke Hand", linke],
["sogar die rechte Hand", hand]]) {
const r = await roh(`/workspace/api/material/${id}/datei`, { keks: s.keks });
ok(r.code === 403, `${wer} bekommt sie nicht (${r.code})`);
}
/* UND DIE VORSCHAU HAT DIESELBE GRENZE -- sonst waere sie der Weg,
es doch noch zu bekommen. */
const schauFremd = await roh(`/workspace/api/material/${id}/vorschau`, { keks: sam.keks });
ok(schauFremd.code === 403,
`auch die Vorschau ist fuer andere zu (${schauFremd.code})`);
const schauSeine = await roh(`/workspace/api/material/${id}/vorschau`, { keks: kessi.keks });
ok(schauSeine.code === 200 && schauSeine.bytes === PNG.length,
`wer es genommen hat, sieht es weiter (${schauSeine.code})`);
}
/* =====================================================================
5. SOLANGE ES FREI IST, DARF JEDER HINSEHEN
=====================================================================
Die Gegenprobe zu Abschnitt 4: Waere die Vorschau schlicht immer
gesperrt, waeren alle Zeilen oben ebenfalls gruen -- und niemand
koennte je entscheiden, ob ein Bild zu seinem Kanal passt. */
melde("");
melde("=== 5. Solange es frei ist, darf jeder hinsehen ===");
{
const a = await einstellen(chef.keks, "Noch frei.png");
const id = a.json?.id;
for (const [wer, s] of [["die Community", kessi], ["ein Modi", modi],
["die linke Hand", linke], ["die rechte Hand", hand]]) {
const r = await roh(`/workspace/api/material/${id}/vorschau`, { keks: s.keks });
ok(r.code === 200 && r.bytes === PNG.length,
`${wer} sieht die Vorschau (${r.code}, ${r.bytes} Bytes)`);
}
}
/* =====================================================================
6. WER SIEHT, WER ES GENOMMEN HAT
=====================================================================
"die rechte hand und dogfather, NUR die beiden" -- gemessen fuer
alle fuenf Rollen, und nicht nur an der Angabe `sieht_wer`, sondern
am Feld selbst. */
melde("");
melde("=== 6. Wer sieht, wer es genommen hat ===");
{
const soll = { admin: true, hand: true, linke: false, modi: false, gast: false };
const kekse = { admin: chef, hand, linke, modi, gast: kessi };
let vergebenGesamt = 0;
for (const [rolle, darf] of Object.entries(soll)) {
const a = await liste(kekse[rolle].keks);
ok(a.code === 200, `${rolle}: die Liste kommt (${a.code})`);
ok(a.json?.sieht_wer === darf,
` und "sieht_wer" ist ${darf} (${a.json?.sieht_wer})`);
const vergeben = (a.json?.material || []).filter((m) => m.genommen);
const mitNamen = vergeben.filter((m) => m.genommen_von !== undefined).length;
vergebenGesamt = Math.max(vergebenGesamt, vergeben.length);
ok(vergeben.length > 0 && (darf ? mitNamen === vergeben.length : mitNamen === 0),
` ${vergeben.length} vergeben, davon ${mitNamen} mit Namen`);
}
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Ohne vergebene Stuecke waere
"keine Namen dabei" trivial wahr. */
ok(vergebenGesamt >= 2,
`es lagen ueberhaupt vergebene Stuecke vor (${vergebenGesamt})`);
}
/* =====================================================================
7. JEDER SIEHT, WAS ER SELBST GENOMMEN HAT
===================================================================== */
melde("");
melde("=== 7. Jeder sieht, was er selbst genommen hat ===");
{
/* SAM MUSS ERST WIRKLICH ETWAS HABEN. Im Wettlauf oben hat Kessi
gewonnen -- ohne diesen Schritt vergliche die Gegenprobe unten
zwei Nullen und waere gruen, ohne irgendetwas gesehen zu haben.
Genau der gruene Haken ueber der Null. */
const fuerSam = await einstellen(chef.keks, "Fuer Sam.png");
const holt = await nehmen(fuerSam.json?.id, sam.keks);
ok(holt.code === 200, `Sam nimmt sich eines (${holt.code})`);
const meine = ((await liste(kessi.keks)).json?.material || [])
.filter((m) => m.von_mir_genommen);
ok(meine.length >= 1, `Kessi sieht ihre eigenen (${meine.length})`);
const fremde = ((await liste(sam.keks)).json?.material || [])
.filter((m) => m.von_mir_genommen);
const ids = new Set(meine.map((m) => m.id));
const verwechselt = fremde.filter((m) => ids.has(m.id)).length;
ok(verwechselt === 0,
`und keines davon steht bei Sam als "von mir" (${verwechselt})`);
/* Gegenprobe gegen die Liste des DogFathers: Sams eigene Sicht muss
zu dem passen, was dort mit seinem Namen steht. */
const samHat = ((await liste(chef.keks)).json?.material || [])
.filter((m) => m.genommen_von === "Sam").length;
ok(samHat > 0 && fremde.length === samHat,
`Sams eigene Sicht deckt sich mit der Liste des DogFathers (${fremde.length} / ${samHat})`);
}
/* =====================================================================
8. DIE AGENTUR HAT HIER NICHTS
===================================================================== */
melde("");
melde("=== 8. Die Grenzen des Bereichs ===");
{
const { darfSeite } = await import("./rechte.js");
for (const r of ["admin", "hand", "linke", "modi", "gast"]) {
ok(darfSeite({ rolle: r, haus: "crew" }, "/workspace/material.html"),
`${r} darf die Seite oeffnen`);
}
for (const r of ["creator", "manager", "scout", "spicy"]) {
ok(!darfSeite({ rolle: r, haus: "agentur" }, "/workspace/material.html"),
`${r} aus der Agentur nicht`);
}
/* Und die Kachel gibt es -- sonst waere die Seite da und niemand
faende sie. Abgeleitet aus derselben Quelle wie die Startseite. */
const w = await import("./workspace.js");
for (const r of ["gast", "modi", "hand", "linke", "admin"]) {
const treffer = [...(w.bereicheFuer({ rolle: r, haus: "crew" }) || [])]
.filter((k) => k.ziel === "material.html");
/* HEISST SEIT DEM 22.09.2026 "Dogi-Media" (Filipe: "ersetz das wort
material durch Dogi-Media"). Der DATEINAME bleibt material.html:
Eine Adresse umzubenennen bricht jedes Lesezeichen und jeden
Verweis, und der sichtbare Name ist das, was zaehlt -- dieselbe
Entscheidung wie bei "Unsere Seiten" -> "Draussen". */
ok(treffer.length === 1 && treffer[0].name === "Dogi-Media",
`${r} hat genau eine Kachel dorthin (${treffer.length}, "${treffer[0]?.name}")`);
}
}
/* =====================================================================
9. VON WANN BIS WANN (22.09.2026)
=====================================================================
Filipe: "mach kategorien welche sind benutzt welche nicht welche sind
noch offen, welche laufen von wan bis wann, das muss man auch machen
koennen und detailliert perfektioniert."
GERECHNET WIRD RELATIV ZU HEUTE, nie mit einem festen Datum. Ein
Test, der "2026-10-01" als Zukunft annimmt, ist am 2. Oktober rot,
ohne dass irgendetwas kaputt waere -- und ein Fehlalarm, den niemand
nachstellen kann, schaltet den ganzen Satz ab.
===================================================================== */
melde("");
melde("=== 9. Der Zeitraum: gesetzt, gespeichert, gerechnet ===");
const { tagLokal } = await import("./helfer-tag.mjs");
const HEUTE = tagLokal(0);
const GESTERN = tagLokal(-1);
const VORGESTERN = tagLokal(-2);
const MORGEN = tagLokal(1);
const IN10 = tagLokal(10);
{
/* Vier Stuecke, eines je Zustand -- und zwar wirklich vier, nicht
drei und eine Annahme. Die Zaehlung unten prueft das. */
const faelle = [
["ohne-fenster.png", {}, "frei"],
["laeuft-schon.png", { ab: VORGESTERN, bis: IN10 }, "frei"],
["kommt-noch.png", { ab: MORGEN, bis: IN10 }, "spaeter"],
["ist-vorbei.png", { ab: VORGESTERN, bis: GESTERN }, "abgelaufen"],
];
for (const [name, f, erwartet] of faelle) {
const r = await einstellen(chef.keks, name, "Probe", f);
ok(r.code === 201 && Number.isInteger(r.json?.id),
`"${name}" angelegt (${r.code})`);
}
const l = await liste(chef.keks);
const finde = (n) => (l.json?.material || []).find((m) => m.name === n);
for (const [name, f, erwartet] of faelle) {
const m = finde(name);
ok(!!m && m.zustand === erwartet,
`"${name}" steht als "${erwartet}" da (${m?.zustand})`);
ok(!!m && (m.gilt_ab || null) === (f.ab || null)
&& (m.gilt_bis || null) === (f.bis || null),
`"${name}" gibt sein Fenster unveraendert zurueck `
+ `(${m?.gilt_ab} / ${m?.gilt_bis})`);
}
/* DER HEUTIGE TAG KOMMT MIT UND IST EIN ECHTES DATUM.
Genau hier lag der Fehler, der diese Pruefung ausgeloest hat:
`tagLokal()` ohne Argument gab "NaN-NaN-NaN" zurueck. Beim
Zeichenkettenvergleich gewinnt das grosse N gegen jede Ziffer --
also galt jedes Stueck mit Enddatum sofort als abgelaufen, und
"Kommt noch" gab es nie. Kein Absturz, keine Meldung.
Deshalb wird die FORM geprueft, nicht nur die Anwesenheit. */
ok(/^\d{4}-\d{2}-\d{2}$/.test(String(l.json?.heute || "")),
`die Liste nennt den heutigen Tag als Datum ("${l.json?.heute}")`);
ok(l.json?.heute === HEUTE,
`und es ist wirklich heute (${l.json?.heute} vs ${HEUTE})`);
/* GEGENPROBE: Wuerde "heute" nicht stimmen, muesste die Rechnung
ein anderes Ergebnis liefern. Sonst misst der Test nur, dass
irgendetwas zurueckkommt. */
const { zustandVon } = await import("./workspace-material.js");
ok(zustandVon({ gilt_ab: MORGEN }, HEUTE) === "spaeter"
&& zustandVon({ gilt_ab: MORGEN }, "NaN-NaN-NaN") !== "spaeter",
"mit einem kaputten Tag faellt die Rechnung um -- die Probe kann also anschlagen");
/* ALLE VIER ZUSTAENDE KOMMEN AUCH WIRKLICH VOR. Ohne diese Zeile
waeren drei gruene Haken auch dann gruen, wenn ein Zustand gar
nicht mehr erreichbar ist. */
const NOETIG = ["frei", "spaeter", "abgelaufen"];
const gezaehlt = new Set((l.json?.material || []).map((m) => m.zustand));
const fehlend = NOETIG.filter((z) => !gezaehlt.has(z));
/* DIE ZAHL IM SATZ WIRD GEZAEHLT, NICHT GETIPPT. Der erste Entwurf
schrieb "drei Zustaende sind besetzt" und zaehlte daneben vier auf
-- ein Satz, der schon beim ersten Lesen nicht zu seiner eigenen
Klammer passte. */
ok(fehlend.length === 0,
`alle ${NOETIG.length} Zustaende aus diesem Abschnitt sind besetzt; `
+ `gefunden: ${[...gezaehlt].sort().join(", ")}`
+ (fehlend.length ? ` -- es fehlt: ${fehlend.join(", ")}` : ""));
}
melde("");
melde("=== 9a. Die Umstellung merkt, wenn sie nicht durchkam ===");
{
/* WARUM DAS HIER STEHT: Im ersten Entwurf setzte `tabellen()` den
Vermerk "erledigt" UNBEDINGT -- auch wenn das ALTER scheiterte,
dessen Fehler daneben still in die Konsole ging. Ein einziger
misslungener Versuch haette die Umstellung fuer immer als getan
vermerkt, und danach scheitert JEDE Liste an `m.gilt_ab`: 503 fuer
alle, mit einer Meldung, die nicht sagt, woran es liegt.
Geprueft wird an einer Wegwerfdatei, nicht am laufenden Haus --
eine Tabelle ohne die Spalten laesst sich sonst gar nicht
herstellen, ohne etwas kaputtzumachen. */
const probe = new (await import("node:sqlite")).DatabaseSync(join(ordner, "probe-schema.db"));
probe.exec("CREATE TABLE material (id INTEGER PRIMARY KEY, dateiname TEXT)");
const NOETIG = ["gilt_ab", "gilt_bis"];
const fehlt = () => {
const da = new Set(probe.prepare("PRAGMA table_info(material)").all().map((c) => c.name));
return NOETIG.filter((n) => !da.has(n));
};
ok(fehlt().length === 2,
`vor der Umstellung fehlen beide Spalten (${fehlt().join(", ")})`);
probe.exec("ALTER TABLE material ADD COLUMN gilt_ab TEXT");
ok(fehlt().length === 1 && fehlt()[0] === "gilt_bis",
`nach der halben Umstellung fehlt genau eine (${fehlt().join(", ")}) `
+ `-- genau dieser Fall haette "erledigt" heissen koennen`);
probe.exec("ALTER TABLE material ADD COLUMN gilt_bis TEXT");
ok(fehlt().length === 0, "nach der ganzen keine mehr");
probe.close();
/* UND AM LAUFENDEN HAUS: Die Spalten sind wirklich da. Die Zeile
darueber beweist nur, dass die Rechnung stimmt -- nicht, dass sie
hier angewandt wurde. */
const { DatabaseSync: DS } = await import("node:sqlite");
const echt = new DS(process.env.WORKSPACE_DB);
const spalten = echt.prepare("PRAGMA table_info(material)").all().map((c) => c.name);
echt.close();
ok(NOETIG.every((n) => spalten.includes(n)),
`und in der laufenden Ablage stehen sie auch (${spalten.length} Spalten: `
+ `${spalten.join(", ")})`);
}
melde("");
melde("=== 9b. Ein Fenster, das nicht geht ===");
{
const verkehrt = await einstellen(chef.keks, "verkehrt.png", "", { ab: IN10, bis: GESTERN });
ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt",
`Ende vor Anfang wird abgelehnt (${verkehrt.code}/${verkehrt.json?.fehler})`);
const quatsch = await einstellen(chef.keks, "quatsch.png", "", { ab: "morgen frueh" });
ok(quatsch.code === 400 && quatsch.json?.fehler === "datum_unbrauchbar",
`ein Datum, das keines ist, wird abgelehnt (${quatsch.code}/${quatsch.json?.fehler})`);
/* GEGENPROBE zu beiden: Dasselbe ohne Fenster muss durchgehen --
sonst koennte die Ablehnung auch an etwas ganz anderem liegen. */
const sauber = await einstellen(chef.keks, "sauber.png", "", { ab: HEUTE, bis: IN10 });
ok(sauber.code === 201, `dasselbe mit gueltigem Fenster geht (${sauber.code})`);
/* UND NICHTS DAVON IST LIEGENGEBLIEBEN. Eine abgelehnte Anfrage darf
keine halbe Datei im Ordner hinterlassen. */
const l = await liste(chef.keks);
const namen = (l.json?.material || []).map((m) => m.name);
ok(!namen.includes("verkehrt.png") && !namen.includes("quatsch.png"),
"die abgelehnten Stuecke stehen nirgends in der Liste");
}
/* =====================================================================
10. BEARBEITEN -- wer darf, und was bleibt unberuehrt
=====================================================================
Filipe: "wenn wir in der kategorie material was hochladen will ich
dass die rechte und linke hand es auch bearbeiten und loeschen
koennen. dogfather auch fals es ein fehler gab."
===================================================================== */
melde("");
melde("=== 10. Bearbeiten ===");
const aendern = (id, keks, was) =>
roh(`/workspace/api/material/${id}`, { methode: "PATCH", keks, body: was });
{
/* Das Stueck gehoert der RECHTEN HAND -- damit ist jede Aenderung
durch jemand anderen eine FREMDE Aenderung. Haette es DogFather
eingestellt, waere sein eigener Zugriff kein Nachweis. */
const r = await einstellen(hand.keks, "fremdes-stueck.png", "Erster Text");
const id = r.json?.id;
ok(Number.isInteger(id), `Stueck der rechten Hand liegt (${r.code})`);
for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) {
const a = await aendern(id, s2.keks, { text: `Geaendert von ${wer}` });
ok(a.code === 200 && a.json?.text === `Geaendert von ${wer}`,
`${wer} darf fremden Text aendern (${a.code})`);
}
for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) {
const a = await aendern(id, s2.keks, { text: "heimlich" });
ok(a.code === 403 || a.code === 404,
`${wer} darf nicht (${a.code}/${a.json?.fehler})`);
}
/* GEGENPROBE ZUR ABLEHNUNG: Der Text muss danach noch der von vorhin
sein. Eine 403, nach der die Aenderung trotzdem drinsteht, waere
der eigentliche Schaden -- und sie saehe von aussen richtig aus. */
const l = await liste(chef.keks);
const m = (l.json?.material || []).find((x) => x.id === id);
ok(m?.text === "Geaendert von linke Hand",
`und der Text ist unveraendert geblieben ("${m?.text}")`);
/* DAS FENSTER LAESST SICH NACHTRAEGLICH SETZEN UND WIEDER LEEREN.
Das Leeren ist der Fall, den man vergisst: Wer ein Enddatum
falsch getippt hat, muss es LOESCHEN koennen, nicht nur
ueberschreiben. */
const setzen = await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 });
ok(setzen.code === 200 && setzen.json?.zustand === "spaeter",
`Fenster nachtraeglich gesetzt, Zustand "${setzen.json?.zustand}"`);
const leeren = await aendern(id, hand.keks, { gilt_ab: "", gilt_bis: "" });
ok(leeren.code === 200 && !leeren.json?.gilt_ab && !leeren.json?.gilt_bis
&& leeren.json?.zustand === "frei",
`und wieder geleert, Zustand "${leeren.json?.zustand}"`);
const verkehrt = await aendern(id, hand.keks, { gilt_ab: IN10, gilt_bis: GESTERN });
ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt",
`ein verkehrtes Fenster wird auch beim Aendern abgelehnt (${verkehrt.code})`);
/* WAS NICHT MITGESCHICKT WIRD, BLEIBT. Sonst loescht jeder, der nur
den Text aendert, nebenbei das Fenster -- und merkt es nie. */
await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 });
const nurText = await aendern(id, hand.keks, { text: "Nur der Text" });
ok(nurText.code === 200 && nurText.json?.gilt_ab === MORGEN
&& nurText.json?.gilt_bis === IN10,
`eine Aenderung nur am Text laesst das Fenster stehen `
+ `(${nurText.json?.gilt_ab} / ${nurText.json?.gilt_bis})`);
}
melde("");
melde("=== 10b. Was vergeben ist, bleibt, wie es ist ===");
{
const r = await einstellen(chef.keks, "schon-weg.png", "Steht so da");
const id = r.json?.id;
const n = await nehmen(id, modi.keks);
ok(n.code === 200, `ein Modi hat es genommen (${n.code})`);
const a = await aendern(id, chef.keks, { text: "nachtraeglich umgeschrieben" });
ok(a.code === 409 && a.json?.fehler === "schon_im_umlauf",
`selbst DogFather aendert es nicht mehr (${a.code}/${a.json?.fehler})`);
const l = await liste(chef.keks);
const m = (l.json?.material || []).find((x) => x.id === id);
ok(m?.text === "Steht so da", `der Text von damals steht noch ("${m?.text}")`);
ok(m?.zustand === "benutzt", `und der Zustand heisst "benutzt" (${m?.zustand})`);
/* Der vierte Zustand wird hier NEU erzeugt und nicht nur mitgezaehlt:
Weiter oben gibt es schon vergebene Stuecke aus Abschnitt 6, aber
dass AUSGERECHNET DIESES nach dem Nehmen "benutzt" heisst, ist die
Aussage -- und die gilt nur fuer das Stueck von eben. */
ok((l.json?.material || []).filter((x) => x.zustand === "benutzt").length >= 1,
"es gibt mindestens ein Stueck im Zustand \"benutzt\"");
}
/* =====================================================================
11. ENTFERNEN -- dieselbe Runde, andere Tuer
===================================================================== */
melde("");
melde("=== 11. Entfernen ===");
const wegnehmen = (id, keks) =>
roh(`/workspace/api/material/${id}`, { methode: "DELETE", keks });
{
for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) {
/* Jedes Mal ein frisches Stueck von jemand anderem -- sonst
entfernte der Zweite nur noch eine Nummer, die es nicht gibt,
und bekaeme 404 statt eines echten Nachweises. */
const r = await einstellen(hand.keks, `weg-${wer}.png`, "");
const id = r.json?.id;
const w = await wegnehmen(id, s2.keks);
ok(w.code === 200, `${wer} darf ein fremdes Stueck entfernen (${w.code})`);
const l = await liste(chef.keks);
ok(!(l.json?.material || []).some((x) => x.id === id),
`und es ist danach wirklich weg`);
}
const r = await einstellen(hand.keks, "bleibt-liegen.png", "");
const id = r.json?.id;
for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) {
const w = await wegnehmen(id, s2.keks);
ok(w.code === 403 || w.code === 404,
`${wer} darf nicht entfernen (${w.code}/${w.json?.fehler})`);
}
const l = await liste(chef.keks);
ok((l.json?.material || []).some((x) => x.id === id),
"und das Stueck liegt danach immer noch da");
}
/* =====================================================================
12. WAS DIE LISTE UEBER DIE RECHTE SAGT
=====================================================================
Die Oberflaeche rechnet NICHT nach, wer was darf -- sie liest
`darf_ich` und `darf_verwalten`. Waeren die Angaben falsch, staenden
Knoepfe da, die beim Druecken eine Absage bringen; oder sie fehlten
dort, wo jemand darf. Beides ist schlimmer als ein Serverfehler,
weil es aussieht, als sei alles in Ordnung.
===================================================================== */
melde("");
melde("=== 12. Was die Liste ueber die Rechte sagt ===");
{
const r = await einstellen(hand.keks, "rechte-probe.png", "");
const id = r.json?.id;
const darf = { admin: true, hand: true, linke: true };
for (const [wer, s2, rolle] of [["DogFather", chef, "admin"], ["rechte Hand", hand, "hand"],
["linke Hand", linke, "linke"], ["Modi", modi, "modi"], ["Kessi", kessi, "gast"]]) {
const l = await liste(s2.keks);
const soll = darf[rolle] === true;
ok(l.json?.darf_verwalten === soll,
`${wer}: darf_verwalten = ${l.json?.darf_verwalten} (erwartet ${soll})`);
const m = (l.json?.material || []).find((x) => x.id === id);
ok(!!m && m.darf_ich === soll,
`${wer}: darf_ich am fremden Stueck = ${m?.darf_ich} (erwartet ${soll})`);
}
/* GEGENPROBE: Am EIGENEN Stueck muss `darf_ich` auch fuer jemanden
wahr sein, der fremde nicht anfassen darf. Sonst koennte das Feld
schlicht `darf_verwalten` wiederholen und niemandem auffallen.
Ein Modi stellt nichts ein -- also nimmt die Probe die rechte
Hand, deren eigenes Stueck das hier ist. */
const l = await liste(hand.keks);
const m = (l.json?.material || []).find((x) => x.id === id);
ok(m?.darf_ich === true, "die rechte Hand darf ihr eigenes Stueck (darf_ich = true)");
}
/* =====================================================================
13. AM ECHTEN BILDSCHIRM
=====================================================================
Die zwoelf Abschnitte davor messen die Schnittstelle. Keiner von
ihnen haette gemerkt, wenn der Knopf "Bearbeiten" gar nicht
erscheint, das Formular hinter der Karte verschwindet oder die
Datumsfelder am Handy aus dem Bild laufen.
Genau daran ist die Teilnehmerwahl am 05.09. gescheitert: vierzig
gruene Pruefungen, und auf dem Bildschirm klebten die Namen
aneinander. Niemand hatte gefragt, ob das Ergebnis auch AUSSIEHT wie
gedacht.
WARUM EIN HTTPS-VORBAU: Team Dogi wohnt auf crew.dogfather-universe.com,
und welche Wand gilt, entscheidet der Host-Kopf. Chromium setzt den
selbst und hebt diese Domain ausserdem von sich aus auf https --
also bekommt sie hier einen echten, selbst unterschriebenen
Vorbau davor. Derselbe Weg wie in pruef-willkommen.
===================================================================== */
melde("");
melde("=== 13. Am echten Bildschirm ===");
{
const { execFileSync } = await import("node:child_process");
const { createServer: httpsServer } = await import("node:https");
const { readFileSync } = await import("node:fs");
const schluessel = join(ordner, "pruef.key");
const zertifikat = join(ordner, "pruef.crt");
zertifikatBauen(schluessel, zertifikat, CREW);
const HTTPS_PORT = PORT + 1;
const vorbau = httpsServer({
key: readFileSync(schluessel), cert: readFileSync(zertifikat),
}, (von, nach) => {
const weiter = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
}, (antwort) => {
nach.writeHead(antwort.statusCode || 502, antwort.headers);
antwort.pipe(nach);
});
von.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
const SEITE = `https://${CREW}:${HTTPS_PORT}`;
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"],
});
/* Ein sauberer Ausgangszustand fuer die Augen: ein Stueck mit
laufendem Fenster, eines fuer spaeter, eines abgelaufen. Alles im
Wegwerfordner -- an die echte Datenbank kommt hier nichts. */
await einstellen(chef.keks, "schau-laeuft.png", "Laeuft gerade", { ab: VORGESTERN, bis: IN10 });
await einstellen(chef.keks, "schau-spaeter.png", "Kommt noch", { ab: MORGEN, bis: IN10 });
await einstellen(chef.keks, "schau-vorbei.png", "War einmal", { ab: VORGESTERN, bis: GESTERN });
const meckern = [];
async function seiteAls(rolle, code, breite) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 950 } });
const s2 = await ctx.newPage();
s2.on("pageerror", (e) => meckern.push(`${rolle}: Absturz ${String(e).slice(0, 120)}`));
s2.on("console", (m) => {
if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) {
meckern.push(`${rolle}: Konsole ${m.text().slice(0, 120)}`);
}
});
await s2.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" });
await s2.click(`.rolle[data-rolle="${rolle}"]`);
await s2.fill("#code", code);
await s2.click("button[type=submit]");
await s2.waitForTimeout(900);
await s2.goto(`${SEITE}/workspace/material.html`, { waitUntil: "networkidle" });
await s2.waitForTimeout(1200);
return { ctx, s: s2 };
}
try {
/* ---- DogFather am Rechner ------------------------------------ */
const a = await seiteAls("admin", "CODE-DOGI-0001", 1280);
try {
const bild = await a.s.evaluate(() => {
const knoepfe = [...document.querySelectorAll("#m-kat .schritt")];
const karte = document.querySelector(".m-karte");
const zeit = document.querySelector(".m-karte__zeit");
return {
ueberschrift: document.querySelector("h1")?.textContent?.trim(),
kategorien: knoepfe.map((b) => ({
wort: b.textContent.replace(/\d+$/, "").trim(),
zahl: b.querySelector(".m-kat__zahl")?.textContent,
gedrueckt: b.getAttribute("aria-pressed"),
})),
/* Sieht der Knopf nach Knopf aus? Ein `.schritt` ohne
Polsterung waere nackter Text -- genau der Fehler von der
Teilnehmerwahl. */
knopfHoehe: knoepfe[0] ? Math.round(knoepfe[0].getBoundingClientRect().height) : 0,
verwalten: !!karte?.querySelector(".m-karte__verwalten"),
verwaltenKnoepfe: [...(karte?.querySelectorAll(".m-karte__verwalten button") || [])]
.map((b) => b.textContent.trim()),
zeitMarke: zeit?.querySelector(".m-karte__zeit-marke")?.textContent?.trim() || null,
zeitFarbe: zeit ? getComputedStyle(zeit.querySelector(".m-karte__zeit-marke")).color : null,
zeitText: zeit?.textContent?.replace(/\s+/g, " ").trim() || null,
fensterFelder: document.querySelectorAll(".m-neu__fenster input[type=date]").length,
};
});
ok(bild.ueberschrift === "Dogi-Media",
`die Seite heisst "${bild.ueberschrift}"`);
ok(bild.kategorien.length === 5,
`fuenf Kategorieknoepfe stehen da (${bild.kategorien.length}: `
+ `${bild.kategorien.map((k) => `${k.wort} ${k.zahl}`).join(" | ")})`);
ok(bild.kategorien[0]?.gedrueckt === "true",
`"Alle" ist beim Aufschlagen gewaehlt (${bild.kategorien[0]?.gedrueckt})`);
ok(bild.knopfHoehe >= 26,
`und sie sehen aus wie Knoepfe, nicht wie Text (${bild.knopfHoehe} px hoch)`);
ok(bild.fensterFelder === 2,
`der Einstellkasten hat zwei Datumsfelder (${bild.fensterFelder})`);
ok(bild.verwalten && bild.verwaltenKnoepfe.join("/") === "Bearbeiten/Entfernen",
`DogFather sieht beide Verwaltungsknoepfe (${bild.verwaltenKnoepfe.join(", ")})`);
ok(!!bild.zeitMarke && !!bild.zeitText,
`der Zeitraum steht an der Karte ("${bild.zeitText}")`);
ok(bild.zeitFarbe !== null && bild.zeitFarbe !== "rgb(0, 0, 0)",
`und die Zustandsmarke hat ihre Farbe (${bild.zeitFarbe})`);
/* ---- Der Filter filtert wirklich, und beide Listen ---- */
const vorher = await a.s.evaluate(() => ({
oben: document.querySelectorAll("#frei .m-karte").length,
unten: document.querySelectorAll("#weg .m-karte").length,
}));
await a.s.click('#m-kat .schritt[data-wert="spaeter"]');
await a.s.waitForTimeout(400);
const nachher = await a.s.evaluate(() => ({
oben: document.querySelectorAll("#frei .m-karte").length,
unten: document.querySelectorAll("#weg .m-karte").length,
gedrueckt: document.querySelector('#m-kat .schritt[data-wert="spaeter"]')
?.getAttribute("aria-pressed"),
/* AM ZUSTAND GEMESSEN, NICHT AM DATEINAMEN. Der erste Entwurf
prueft, ob jeder Name "spaeter" enthaelt -- und wurde rot,
weil ein Stueck aus Abschnitt 10 voellig zu Recht dabei war:
Dort hat die rechte Hand ihm ein Fenster in der Zukunft
gegeben. Die Pruefung hatte recht behalten wollen, nicht
gemessen. */
zustaende: [...document.querySelectorAll("#frei .m-karte")]
.map((k) => k.querySelector(".m-karte__zeit")?.dataset.zustand || "ohne"),
}));
ok(nachher.gedrueckt === "true" && nachher.oben < vorher.oben && nachher.oben > 0,
`"Kommt noch" filtert die obere Liste (${vorher.oben} -> ${nachher.oben})`);
ok(nachher.unten === 0 && vorher.unten > 0,
`und die untere gleich mit (${vorher.unten} -> ${nachher.unten}) `
+ `-- ein Filter, der nur die halbe Seite betrifft, sieht kaputt aus`);
ok(nachher.zustaende.length > 0
&& nachher.zustaende.every((z) => z === "spaeter"),
`uebrig bleibt nur, was spaeter gilt `
+ `(${nachher.zustaende.length} Karten, alle: ${[...new Set(nachher.zustaende)].join(", ")})`);
/* Noch einmal auf denselben Knopf heisst "alle". */
await a.s.click('#m-kat .schritt[data-wert="spaeter"]');
await a.s.waitForTimeout(400);
const zurueck = await a.s.evaluate(() =>
document.querySelectorAll("#frei .m-karte").length);
ok(zurueck === vorher.oben,
`ein zweiter Druck fuehrt zurueck zu allen (${zurueck} von ${vorher.oben})`);
/* ---- BEARBEITEN: Formular IN der Karte, vorbelegt ---- */
await a.s.click(".m-karte .m-karte__verwalten button");
await a.s.waitForTimeout(400);
const form = await a.s.evaluate(() => {
const k = document.querySelector(".m-karte");
const f = k?.querySelector(".m-bearb");
if (!f) return null;
return {
inKarte: !!f && k.contains(f),
text: f.querySelector("textarea")?.value,
kartenText: k.querySelector(".m-karte__text")?.textContent,
datumsfelder: f.querySelectorAll("input[type=date]").length,
ab: f.querySelectorAll("input[type=date]")[0]?.value,
knoepfe: [...f.querySelectorAll("button")].map((b) => b.textContent.trim()),
};
});
ok(!!form && form.inKarte, "das Formular steht in der Karte, nicht davor");
ok(!!form && form.text === form.kartenText,
`das Textfeld ist mit dem bisherigen Text vorbelegt ("${form?.text}")`);
ok(!!form && form.datumsfelder === 2 && !!form.ab,
`und die zwei Datumsfelder auch (${form?.ab})`);
ok(!!form && form.knoepfe.join("/") === "Speichern/Abbrechen",
`Speichern und Abbrechen stehen da (${form?.knoepfe.join(", ")})`);
/* GEGENPROBE ZUM VORBELEGEN: Genau das konnte `frageNach` nicht,
und genau deshalb steht hier ein eigenes Formular. Ein leeres
Feld waere kein Bearbeiten, sondern ein Neuschreiben. */
/* ---- Ein verkehrtes Fenster wird schon hier gesagt ---- */
const felder = ".m-karte .m-bearb input[type=date]";
await a.s.evaluate((wahl) => {
const d2 = document.querySelectorAll(wahl);
d2[0].value = "2030-01-01"; d2[1].value = "2029-01-01";
}, felder);
await a.s.click(".m-karte .m-bearb button[type=submit]");
await a.s.waitForTimeout(500);
const gemeckert = await a.s.evaluate(() =>
document.querySelector(".m-karte .m-bearb__fehler")?.textContent?.trim());
ok(!!gemeckert && gemeckert.length > 10,
`ein verkehrtes Fenster wird im Formular gesagt ("${gemeckert}")`);
/* ---- Speichern geht wirklich durch ---- */
await a.s.evaluate(() => {
const f = document.querySelector(".m-karte .m-bearb");
f.querySelector("textarea").value = "Am Bildschirm geaendert";
for (const d2 of f.querySelectorAll("input[type=date]")) d2.value = "";
});
await a.s.click(".m-karte .m-bearb button[type=submit]");
await a.s.waitForTimeout(1200);
const danach = await a.s.evaluate(() => ({
formWeg: !document.querySelector(".m-karte .m-bearb"),
text: document.querySelector(".m-karte .m-karte__text")?.textContent,
}));
ok(danach.formWeg, "nach dem Speichern ist das Formular zu");
ok(danach.text === "Am Bildschirm geaendert",
`und der neue Text steht auf der Karte ("${danach.text}")`);
} finally { await a.ctx.close(); }
/* ---- Ein Modi am Handy ----------------------------------------
ZWEI ANNAHMEN HIER WAREN FALSCH, und beide standen vier Zeilen
weiter oben im selben Lauf richtig da:
"ein Modi stellt etwas bereit (201, Nummer 4)"
Ein Modi GEHOERT zum Team Dogi. Er darf einstellen, und an
seinen EIGENEN Stuecken darf er selbstverstaendlich auch
aendern. Die erste Fassung verlangte "kein einziger
Verwaltungsknopf" und "kein Einstellkasten" -- das waere keine
Pruefung gewesen, sondern der Wunsch, recht zu behalten.
Was wirklich zaehlt, ist die Grenze: seine eigenen ja, fremde
nein. Und die wird nicht geschaetzt, sondern gegen die Liste
gerechnet, die der Server ihm selbst schickt. */
const b = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
const seinStand = (await liste(modi.keks)).json || {};
const darfIds = new Set((seinStand.material || [])
.filter((m) => m.darf_ich && !m.genommen).map((m) => String(m.id)));
const fremdIds = new Set((seinStand.material || [])
.filter((m) => !m.darf_ich && !m.genommen).map((m) => String(m.id)));
const handy = await b.s.evaluate(() => ({
karten: document.querySelectorAll(".m-karte").length,
mitVerwalten: [...document.querySelectorAll(".m-karte")]
.filter((k) => k.querySelector(".m-karte__verwalten"))
.map((k) => k.dataset.id),
einstellen: !document.getElementById("neu-block").hidden,
breite: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
/* Jeder Kategorieknopf muss am Finger zu treffen sein. */
kleinsteKat: Math.min(...[...document.querySelectorAll("#m-kat .schritt")]
.map((x) => Math.round(x.getBoundingClientRect().height))),
}));
ok(handy.karten > 0, `der Modi sieht Stuecke (${handy.karten})`);
const gezeigt = new Set(handy.mitVerwalten);
const zuViel = [...gezeigt].filter((id) => fremdIds.has(id));
const zuWenig = [...darfIds].filter((id) => !gezeigt.has(id));
/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG: Ohne `darfIds.size > 0`
waeren beide Listen leer und die Zeile gruen, ohne dass je
ein Knopf geprueft wurde. */
ok(darfIds.size > 0 && fremdIds.size > 0
&& zuViel.length === 0 && zuWenig.length === 0,
`Verwaltungsknoepfe genau an seinen eigenen `
+ `(${gezeigt.size} gezeigt, ${darfIds.size} erlaubt, `
+ `${fremdIds.size} fremde daneben)`
+ (zuViel.length ? ` -- zu viel an: ${zuViel.join(", ")}` : "")
+ (zuWenig.length ? ` -- es fehlt an: ${zuWenig.join(", ")}` : ""));
ok(handy.einstellen === true,
"der Einstellkasten steht da -- ein Modi gehoert zum Team und stellt bereit");
ok(handy.breite <= handy.sichtbar + 1,
`nichts laeuft seitlich aus dem Bild (${handy.breite} von ${handy.sichtbar} px)`);
ok(handy.kleinsteKat >= 26,
`die Kategorieknoepfe sind am Finger zu treffen (${handy.kleinsteKat} px)`);
} finally { await b.ctx.close(); }
/* ---- Und die Community: gar kein Kasten -----------------------
DAS IST DIE GEGENPROBE zur Zeile darueber. Waere der Kasten
einfach immer da, waere "er steht da" genauso gruen. */
const c = await seiteAls("gast", "CODE-GAST-0001", 390);
try {
const gast = await c.s.evaluate(() => ({
karten: document.querySelectorAll(".m-karte").length,
einstellen: !document.getElementById("neu-block").hidden,
verwalten: document.querySelectorAll(".m-karte__verwalten").length,
breite: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
ok(gast.karten > 0, `die Community sieht die Stuecke (${gast.karten})`);
ok(gast.einstellen === false,
"aber keinen Einstellkasten -- ein Formular, das eine Absage bringt, ist schlimmer als keins");
ok(gast.verwalten === 0,
`und keinen einzigen Verwaltungsknopf (${gast.verwalten})`);
ok(gast.breite <= gast.sichtbar + 1,
`auch bei ihr laeuft nichts aus dem Bild (${gast.breite} von ${gast.sichtbar} px)`);
} finally { await c.ctx.close(); }
ok(meckern.length === 0, meckern.length
? `der Browser meldet: ${meckern.slice(0, 3).join(" | ")}`
: "kein Absturz und keine Fehlermeldung im Browser");
} finally {
await browser.close();
await new Promise((r) => vorbau.close(r));
}
}
/* =====================================================================
ERGEBNIS
===================================================================== */
melde("");
/* Die Zahl gehoert in die Bedingung, nicht nur in den Text: Ein Lauf,
der nichts geprueft hat, ist kein bestandener Lauf. */
/* Von 40 ueber 90 auf 145 angehoben (22.09.2026), als das Bearbeiten,
das Zeitfenster und der Blick am echten Bildschirm dazukamen. Die
Zahl ist keine Schaetzung: Sie steht knapp unter dem, was der Lauf
danach tatsaechlich gemeldet hat (159). Bei 90 waere der ganze
Browserabschnitt still ausfallen koennen, ohne dass die Zahl etwas
gesagt haette -- und genau das soll sie fangen. */
if (geprueft < 150) {
console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 150+`);
fehler++;
}
melde(fehler === 0
? `Alles in Ordnung. ${geprueft} Pruefungen, 0 Fehler.`
: `${fehler} FEHLER bei ${geprueft} Pruefungen.`);
melde("");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);