diff --git a/server/helfer-tag.mjs b/server/helfer-tag.mjs
index 524dc113..79c1ad8e 100644
--- a/server/helfer-tag.mjs
+++ b/server/helfer-tag.mjs
@@ -41,8 +41,31 @@ export function heuteLokal() {
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
}
-/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück. */
+/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück.
+ *
+ * ZWEI DINGE AM 22.09.2026 NACHGEZOGEN, beide durch einen echten Schaden:
+ *
+ * (1) `versatz = 0`. Dogi-Media rief `tagLokal()` ohne Argument auf und
+ * bekam `"NaN-NaN-NaN"` zurück -- eine Zeichenkette, die aussieht
+ * wie ein Datum und sich wie keines verhält. Im Vergleich
+ * `"2026-12-31" < "NaN-NaN-NaN"` gewinnt das N, also galt JEDES
+ * Stück mit Enddatum vom ersten Tag an als abgelaufen, und
+ * "Kommt noch" gab es nie. Kein Absturz, keine Meldung, keine
+ * Zeile im Protokoll -- nur eine Seite, die etwas anderes zeigt
+ * als die Wahrheit. Die drei Kopien dieser Funktion waren an
+ * dieser Stelle auseinandergelaufen: helfer-tag.mjs hatte die
+ * Vorbelegung, die beiden anderen nicht.
+ *
+ * (2) Der Wurf bei einer Zahl, die keine ist. Eine Vorbelegung deckt
+ * nur den leeren Aufruf ab; `tagLokal(irgendwas)` mit einer
+ * undefinierten Variablen läge weiter still daneben. Ein Datum,
+ * das sich nicht ausrechnen lässt, muss SCHEITERN und nicht
+ * schweigen -- sonst wandert der Unsinn in die Datenbank und in
+ * die Anzeige, und gefunden wird er Wochen später. */
export function tagLokal(versatz = 0) {
+ if (!Number.isFinite(Number(versatz))) {
+ throw new TypeError(`tagLokal: "${versatz}" ist keine Zahl von Tagen.`);
+ }
const d = new Date(Date.now() + versatz * 86400_000);
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
}
diff --git a/server/helfer-zeit.mjs b/server/helfer-zeit.mjs
index e663d1e7..33f0c462 100644
--- a/server/helfer-zeit.mjs
+++ b/server/helfer-zeit.mjs
@@ -40,12 +40,35 @@ export function heuteLokal() {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
-/** Derselbe Tag, um n Tage verschoben (negativ = in der Vergangenheit).
+/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück.
+ *
+ * ZWEI DINGE AM 22.09.2026 NACHGEZOGEN, beide durch einen echten Schaden:
+ *
+ * (1) `versatz = 0`. Dogi-Media rief `tagLokal()` ohne Argument auf und
+ * bekam `"NaN-NaN-NaN"` zurück -- eine Zeichenkette, die aussieht
+ * wie ein Datum und sich wie keines verhält. Im Vergleich
+ * `"2026-12-31" < "NaN-NaN-NaN"` gewinnt das N, also galt JEDES
+ * Stück mit Enddatum vom ersten Tag an als abgelaufen, und
+ * "Kommt noch" gab es nie. Kein Absturz, keine Meldung, keine
+ * Zeile im Protokoll -- nur eine Seite, die etwas anderes zeigt
+ * als die Wahrheit. Die drei Kopien dieser Funktion waren an
+ * dieser Stelle auseinandergelaufen: helfer-tag.mjs hatte die
+ * Vorbelegung, die beiden anderen nicht.
+ *
+ * (2) Der Wurf bei einer Zahl, die keine ist. Eine Vorbelegung deckt
+ * nur den leeren Aufruf ab; `tagLokal(irgendwas)` mit einer
+ * undefinierten Variablen läge weiter still daneben. Ein Datum,
+ * das sich nicht ausrechnen lässt, muss SCHEITERN und nicht
+ * schweigen -- sonst wandert der Unsinn in die Datenbank und in
+ * die Anzeige, und gefunden wird er Wochen später.
*
* Gerechnet wird über die Millisekunden und danach lokal ausgelesen.
* `setDate(getDate() + n)` täte es auch, hat aber an den zwei
* Umstellungstagen im Jahr seine Tücken. */
-export function tagLokal(versatz) {
+export function tagLokal(versatz = 0) {
+ if (!Number.isFinite(Number(versatz))) {
+ throw new TypeError(`tagLokal: "${versatz}" ist keine Zahl von Tagen.`);
+ }
const d = new Date(Date.now() + versatz * 86400_000);
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs
index 3688dd44..93be3a60 100644
--- a/server/pruef-css-klassen.mjs
+++ b/server/pruef-css-klassen.mjs
@@ -693,5 +693,154 @@ console.log("\n=== module.css: die Modulliste steht siebenmal — und überall g
`die zweite Wand laedt ihr Haus zuletzt (${wandBl.length} Stilvorlagen)`);
}
+/* =========================================================================
+ IST DAS CSS UEBERHAUPT HEIL? (22.09.2026)
+ =========================================================================
+
+ ENTSTANDEN AUS EINEM ECHTEN FEHLER, der zwei Wochen live stand.
+
+ In start.css fehlten an einer Stelle die zwei Zeichen, die einen
+ Kommentar schliessen:
+
+ ...
+ der Inhalt darin angeordnet ist.
+ =====================================================
+ <- hier gehoerte `*` `/` hin
+ .kacheln {
+
+ CSS-Kommentare schachteln NICHT. Der Block lief deshalb weiter bis
+ zum naechsten Abschluss -- und der stand mitten in `.kacheln`.
+ Verschluckt wurde damit die oeffnende Klammer. Die Fehlerbehebung
+ des Browsers hat danach auch noch die FOLGENDE Regel mitgenommen,
+ weil sie deren Rumpf als Block einer ungueltigen Auswahl las.
+
+ Gemessen in Chromium, beide Fassungen nacheinander: 738 statt 740
+ Regeln. Sichtbar war es als Kachelreihe mit drei Spalten zu 413 px
+ statt fuenf zu 243 px.
+
+ WARUM ES NIEMANDEM AUFFIEL: Eine aeltere `.kacheln`-Regel weiter
+ oben sprang ein. Die Seite sah nicht kaputt aus -- nur anders als
+ gemeint. Das ist die gefaehrlichste Sorte: Es gibt nichts zu melden,
+ keinen Fehler in der Konsole, keinen roten Haken. Nur eine Seite,
+ die etwas anderes zeigt als das, was im Quelltext steht.
+
+ WAS HIER GEMESSEN WIRD -- und warum gerade das:
+
+ Auf oberster Ebene einer Stilvorlage darf nur dreierlei stehen: eine
+ Auswahl mit ihrem Block, eine At-Regel, und Leerraum. Steht dort
+ eine ANGABE (`gap: 12px;`) oder eine schliessende Klammer ohne ihre
+ oeffnende, dann ist etwas verschluckt worden -- fast immer ein
+ Kommentar ohne Abschluss.
+
+ Das ist strenger als blosses Klammernzaehlen: Eine Klammernprobe ist
+ gruen, solange gleich viele auf- wie zugehen, auch wenn ein
+ Kommentar mittendrin einen ganzen Block gefressen hat.
+
+ Kommentare und Zeichenketten werden dabei genauso ueberlesen, wie
+ der Browser sie ueberliest -- sonst schlaegt die Pruefung bei jedem
+ `/* ... } ... *' + '/` an, und eine Warnung, die immer kommt, ist
+ keine mehr.
+ ========================================================================= */
+console.log("\n=== Ist das CSS ueberhaupt heil? ===");
+
+/** Alle Formfehler einer Stilvorlage. Leeres Feld = in Ordnung. */
+function formfehler(text) {
+ const funde = [];
+ const zeileVon = (stelle) => text.slice(0, stelle).split("\n").length;
+ let i = 0, tiefe = 0, puffer = "", pufferAb = 0;
+
+ while (i < text.length) {
+ const c = text[i];
+
+ if (c === "/" && text[i + 1] === "*") {
+ const auf = i;
+ i += 2;
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
+ if (i >= text.length) {
+ funde.push({ zeile: zeileVon(auf), was: "ein Kommentar wird nie geschlossen" });
+ break;
+ }
+ i += 2;
+ continue;
+ }
+ if (c === '"' || c === "'") {
+ const q = c; i++;
+ while (i < text.length && text[i] !== q) { if (text[i] === "\\") i++; i++; }
+ i++; continue;
+ }
+
+ if (tiefe === 0) {
+ if (c === "{") { tiefe = 1; puffer = ""; pufferAb = i + 1; i++; continue; }
+ if (c === ";") {
+ /* `@import url(...);` und Geschwister duerfen auf oberster
+ Ebene mit einem Strichpunkt enden. Alles andere nicht. */
+ if (!puffer.trim().startsWith("@")) {
+ funde.push({
+ zeile: zeileVon(pufferAb),
+ was: "eine Angabe steht ausserhalb jeder Regel",
+ beleg: puffer.trim().slice(0, 70).replace(/\s+/g, " "),
+ });
+ }
+ puffer = ""; pufferAb = i + 1; i++; continue;
+ }
+ if (c === "}") {
+ funde.push({ zeile: zeileVon(i), was: "eine schliessende Klammer ohne oeffnende" });
+ puffer = ""; pufferAb = i + 1; i++; continue;
+ }
+ puffer += c; i++; continue;
+ }
+
+ if (c === "{") tiefe++;
+ else if (c === "}") tiefe--;
+ if (tiefe === 0) { puffer = ""; pufferAb = i + 1; }
+ i++;
+ }
+
+ if (tiefe !== 0) {
+ funde.push({ zeile: "Ende", was: `${tiefe} Regel(n) werden nie geschlossen` });
+ }
+ return funde;
+}
+
+{
+ const css = readdirSync(join(WS, "assets", "css")).filter((n) => n.endsWith(".css"));
+ /* DRITTER AUSGANG: Ohne Dateien misst die Pruefung nichts, und "0
+ Fehler" waere dann ein gruener Haken fuer gar nichts. */
+ ok(css.length > 20, `${css.length} Stilvorlagen gefunden`);
+
+ const kaputt = [];
+ for (const name of css) {
+ const funde = formfehler(readFileSync(join(WS, "assets", "css", name), "utf8"));
+ for (const f of funde) {
+ kaputt.push(`${name}:${f.zeile} — ${f.was}` + (f.beleg ? ` ("${f.beleg}")` : ""));
+ }
+ }
+ ok(kaputt.length === 0 && css.length > 20,
+ kaputt.length
+ ? `${kaputt.length} Stelle(n), an denen CSS still verloren geht:\n `
+ + kaputt.join("\n ")
+ : `keine Stilvorlage verliert still eine Regel (${css.length} geprueft)`);
+
+ /* GEGENPROBEN: Die Pruefung muss beides koennen -- anschlagen und
+ schweigen. Der vorletzte Fall ist der echte von start.css,
+ Zeichen fuer Zeichen nachgebaut. */
+ const proben = [
+ ["gesundes CSS", ".a{b:c} /* } *" + "/ .d[e=';']{f:g}", false],
+ ["@import mit Strichpunkt", "@import url(x.css); .a{b:c}", false],
+ ["Kommentar ohne Abschluss", ".a{b:c} /* geht nie zu", true],
+ ["Angabe ausserhalb jeder Regel", "gap: 12px; .a{b:c}", true],
+ ["schliessende Klammer zu viel", ".a{b:c}}", true],
+ ["Regel ohne Abschluss", ".a{b:c", true],
+ ["der Fall aus start.css", "/* Titel\n ====\n\n.kacheln {\n /* innen *" + "/\n"
+ + " gap: 12px;\n}\n\n.kachel { color: red; }", true],
+ ];
+ const daneben = proben.filter(([, text, soll]) => (formfehler(text).length > 0) !== soll);
+ ok(daneben.length === 0 && proben.length === 7,
+ daneben.length
+ ? `Gegenprobe daneben bei: ${daneben.map((p) => p[0]).join(", ")}`
+ : `Gegenprobe: alle ${proben.length} Faelle richtig eingeordnet `
+ + `(darunter der echte aus start.css)`);
+}
+
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-material.mjs b/server/pruef-material.mjs
index ae4952a8..81c8445e 100644
--- a/server/pruef-material.mjs
+++ b/server/pruef-material.mjs
@@ -72,7 +72,10 @@ const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
-notbremse(240_000, "pruef-material");
+/* 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); });
@@ -182,12 +185,17 @@ const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64");
-function einstellen(keks, name, text = "") {
+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 } : {}),
},
});
}
@@ -421,19 +429,615 @@ melde("=== 8. Die Grenzen des Bereichs ===");
for (const r of ["gast", "modi", "hand", "linke", "admin"]) {
const treffer = [...(w.bereicheFuer({ rolle: r, haus: "crew" }) || [])]
.filter((k) => k.ziel === "material.html");
- ok(treffer.length === 1 && treffer[0].name === "Material",
+ /* 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("=== 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");
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluessel, "-out", zertifikat, "-days", "2",
+ "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
+ { stdio: "ignore" });
+
+ 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. */
-if (geprueft < 40) {
- console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 40+`);
+/* 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 (155). Bei 90 waere der ganze
+ Browserabschnitt still ausfallen koennen, ohne dass die Zahl etwas
+ gesagt haette -- und genau das soll sie fangen. */
+if (geprueft < 145) {
+ console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 145+`);
fehler++;
}
melde(fehler === 0
diff --git a/server/workspace-material.js b/server/workspace-material.js
index 094bd2ce..d7fe4bea 100644
--- a/server/workspace-material.js
+++ b/server/workspace-material.js
@@ -62,6 +62,11 @@ import {
db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER,
istDogFather, TEAM_DOGI_ROLLEN, TREFF_ROLLEN,
} from "./workspace.js";
+/* DIE UHR DES HAUSES, nicht `new Date().toISOString().slice(0,10)`.
+ Das waere UTC -- zwischen Mitternacht und zwei Uhr laege der Tag
+ damit einen zurueck, und ein Stueck, das heute anfaengt, staende
+ noch als "spaeter" da. */
+import { tagLokal } from "./helfer-zeit.mjs";
export const materialRouter = express.Router();
@@ -103,9 +108,69 @@ function tabellen() {
CREATE INDEX IF NOT EXISTS idx_material_frei
ON material (genommen_von, erstellt);
`);
+
+ /* VON WANN BIS WANN (22.09.2026).
+ ===================================================================
+ Filipe: "welche sind benutzt welche nicht welche sind noch offen,
+ welche laufen von wan bis wann, das muss man auch machen koennen."
+
+ Ein Bild zur Halloween-Aktion ist am 2. November kein Angebot mehr,
+ sondern ein Stolperstein. Ohne Fenster bleibt es bis in alle
+ Ewigkeit in der Liste, und irgendwann postet es jemand.
+
+ BEIDE FELDER SIND FREIWILLIG. Der haeufigste Fall ist "gilt
+ einfach" -- ein Pflichtfeld erzwaenge hier ein erfundenes Datum,
+ und ein erfundenes Datum ist schlechter als keins.
+
+ ERGAENZT STATT ANGENOMMEN: Die Tabelle steht auf echten Rechnern
+ schon mit Daten. `ALTER TABLE` waere beim zweiten Start ein
+ Fehler, ein neues CREATE wuerde die vorhandenen Zeilen nicht
+ erreichen. Also nachsehen, was da ist, und nur ergaenzen, was
+ fehlt -- dieselbe Bauart wie bei den Personen-Spalten. */
+ const da = new Set(d.prepare("PRAGMA table_info(material)").all().map((c) => c.name));
+ for (const [spalte, form] of [["gilt_ab", "TEXT"], ["gilt_bis", "TEXT"]]) {
+ if (da.has(spalte)) continue;
+ try {
+ d.exec(`ALTER TABLE material ADD COLUMN ${spalte} ${form}`);
+ console.log(`[material] Spalte '${spalte}' ergaenzt.`);
+ } catch (f) { console.error(`[material] Spalte '${spalte}':`, f?.message); }
+ }
bereit = true;
}
+/* ---------------------------------------------------------------------
+ IN WELCHEM ZUSTAND IST EIN STUECK?
+ ---------------------------------------------------------------------
+ Filipe will die Liste nach Zustand sortieren koennen. Der Zustand
+ wird GERECHNET und nicht gespeichert: Eine Spalte "zustand" muesste
+ jemand taeglich nachfuehren, und am Tag, an dem das vergessen wird,
+ steht "laeuft" an etwas, das seit drei Wochen abgelaufen ist.
+
+ Vier Zustaende, und die Reihenfolge der Fragen entscheidet:
+ benutzt -- jemand hat es genommen. Schlaegt alles andere; ein
+ vergebenes Stueck ist weg, auch wenn sein Fenster
+ noch laeuft.
+ spaeter -- das Fenster faengt erst an.
+ abgelaufen -- das Fenster ist vorbei.
+ frei -- alles andere. Auch alles ohne Fenster: "kein Datum"
+ heisst "gilt", nicht "gilt nicht".
+ --------------------------------------------------------------------- */
+export function zustandVon(z, heute) {
+ if (z.genommen_von) return "benutzt";
+ if (z.gilt_ab && z.gilt_ab > heute) return "spaeter";
+ if (z.gilt_bis && z.gilt_bis < heute) return "abgelaufen";
+ return "frei";
+}
+
+/** Ein Datum im Format JJJJ-MM-TT -- oder null. Nichts dazwischen. */
+function datumOderNull(roh) {
+ const t = String(roh ?? "").trim();
+ if (!t) return null;
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) return undefined; /* undefined = ungueltig */
+ const d = new Date(`${t}T12:00:00Z`);
+ return Number.isNaN(d.getTime()) ? undefined : t;
+}
+
/* ---------------------------------------------------------------------
WER DARF WAS
---------------------------------------------------------------------
@@ -127,6 +192,24 @@ export const darfMaterialNehmen = (person) =>
export const siehtWerGenommenHat = (person) =>
!!person && (istDogFather(person) || person.rolle === "hand");
+/* WER FREMDES BEARBEITEN UND ENTFERNEN DARF (22.09.2026).
+ ---------------------------------------------------------------------
+ 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."
+
+ Bis heute kam nur der Einsteller selbst und DogFather an ein Stueck.
+ Wer einen Tippfehler im Text hatte oder das falsche Bild erwischte,
+ musste warten, bis jemand anders Zeit hat -- und in der Zwischenzeit
+ steht das Falsche da, wo die Community sich bedient.
+
+ NICHT ueber `darfMaterialEinstellen`: Dort stehen auch die Modis
+ drin, und die sollen ihr eigenes Stueck zuruecknehmen koennen, aber
+ nicht das von jemand anderem. Wer fremdes anfasst, ist die Leitung. */
+export const darfFremdesVerwalten = (person) =>
+ !!person && (istDogFather(person)
+ || person.rolle === "hand" || person.rolle === "linke");
+
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
@@ -148,7 +231,7 @@ materialRouter.get("/workspace/api/material", (req, res) => {
const zeigtNamen = siehtWerGenommenHat(req.person);
const reihen = db().prepare(`
SELECT m.id, m.dateiname, m.art, m.groesse, m.text, m.erstellt,
- m.genommen_am, m.genommen_von,
+ m.genommen_am, m.genommen_von, m.von_id, m.gilt_ab, m.gilt_bis,
v.name AS von_name,
g.name AS genommen_name
FROM material m
@@ -157,9 +240,17 @@ materialRouter.get("/workspace/api/material", (req, res) => {
ORDER BY (m.genommen_von IS NOT NULL), m.erstellt DESC
`).all();
+ /* HEUTE WIRD EINMAL GERECHNET, NICHT JE ZEILE. Sonst koennte eine
+ Liste, die ueber Mitternacht gelesen wird, zwei verschiedene
+ Tage benutzen -- und ein Stueck waere in derselben Antwort
+ gleichzeitig "laeuft" und "abgelaufen". */
+ const heute = tagLokal(0);
+
res.json({
darf_einstellen: darfMaterialEinstellen(req.person),
+ darf_verwalten: darfFremdesVerwalten(req.person),
sieht_wer: zeigtNamen,
+ heute,
max_bytes: MAX_BYTES,
material: reihen.map((z) => ({
id: z.id,
@@ -180,6 +271,14 @@ materialRouter.get("/workspace/api/material", (req, res) => {
/* Und jeder sieht, ob ER es war -- sonst wuesste er nicht mehr,
was er selbst schon geholt hat. */
von_mir_genommen: z.genommen_von === req.person.id,
+ gilt_ab: z.gilt_ab || null,
+ gilt_bis: z.gilt_bis || null,
+ zustand: zustandVon(z, heute),
+ /* DARF ICH DIESES STUECK ANFASSEN? Die Antwort steht beim
+ Stueck und wird nicht in der Oberflaeche nachgerechnet --
+ zwei Rechnungen fuer dieselbe Frage laufen auseinander, und
+ die im Browser ist die, der man nicht trauen darf. */
+ darf_ich: z.von_id === req.person.id || darfFremdesVerwalten(req.person),
})),
});
} catch (f) {
@@ -229,11 +328,30 @@ materialRouter.post(
const datei = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`;
writeFileSync(join(ORDNER, datei), req.body, { flag: "wx" });
+ /* DAS FENSTER KOMMT WIE NAME UND TEXT IN KOPFZEILEN.
+ Derselbe Weg wie alles andere an diesem Aufruf -- die Datei
+ liegt roh im Rumpf, also ist fuer Angaben nur der Kopf uebrig.
+ Ein zweiter Aufruf danach waere ein zweiter Weg, der beim
+ Abbrechen ein halbes Stueck hinterliesse. */
+ const ab = datumOderNull(req.get("x-gilt-ab"));
+ const bis = datumOderNull(req.get("x-gilt-bis"));
+ if (ab === undefined || bis === undefined) {
+ return res.status(400).json({ fehler: "datum_unbrauchbar" });
+ }
+ /* EIN FENSTER, DAS RUECKWAERTS LAEUFT, IST KEINS. Es waere in
+ jedem Zustand gleichzeitig "spaeter" und "abgelaufen" -- und
+ die Liste zeigte dann, was die Reihenfolge der Fragen
+ zufaellig ergibt, statt was jemand gemeint hat. */
+ if (ab && bis && ab > bis) {
+ return res.status(400).json({ fehler: "fenster_verkehrt" });
+ }
+
const { lastInsertRowid } = db().prepare(`
- INSERT INTO material (datei, dateiname, typ, art, groesse, text, von_id, erstellt)
- VALUES (?,?,?,?,?,?,?,?)
+ INSERT INTO material (datei, dateiname, typ, art, groesse, text, von_id, erstellt,
+ gilt_ab, gilt_bis)
+ VALUES (?,?,?,?,?,?,?,?,?,?)
`).run(datei, name, typ, art, req.body.length, text, req.person.id,
- new Date().toISOString());
+ new Date().toISOString(), ab, bis);
protokolliere(req.person.id, "material_eingestellt",
`${art} "${name}" (${req.body.length} Bytes)`, echteIp(req));
@@ -277,7 +395,7 @@ materialRouter.post("/workspace/api/material/:id/nehmen", (req, res) => {
});
}
- protokolliere(req.person.id, "material_genommen", `Material ${id}`, echteIp(req));
+ protokolliere(req.person.id, "material_genommen", `Dogi-Media ${id}`, echteIp(req));
res.json({ ok: true, weg: `/workspace/api/material/${id}/datei` });
} catch (f) {
console.error("[material] Nehmen:", f?.message);
@@ -362,17 +480,74 @@ materialRouter.get("/workspace/api/material/:id/datei", (req, res) => {
/* ---------------------------------------------------------------------
WEGNEHMEN
--------------------------------------------------------------------- */
+/* ---------------------------------------------------------------------
+ BEARBEITEN (22.09.2026)
+ ---------------------------------------------------------------------
+ Filipe: "will ich dass die rechte und linke hand es auch bearbeiten
+ und loeschen koennen. dogfather auch fals es ein fehler gab."
+
+ GEAENDERT WERDEN TEXT UND FENSTER -- die DATEI nicht. Wer ein
+ anderes Bild will, stellt ein anderes Stueck ein: Sonst zeigte die
+ Liste nach der Aenderung etwas anderes als das, was jemand sich
+ gerade angesehen hat, unter derselben Nummer.
+
+ EIN VERGEBENES STUECK BLEIBT, WIE ES IST. Wer es genommen hat, hat
+ sich auf diesen Text und dieses Fenster verlassen; es hinterher zu
+ aendern hiesse, die Absprache nachtraeglich umzuschreiben.
+ --------------------------------------------------------------------- */
+materialRouter.patch("/workspace/api/material/:id", express.json({ limit: "32kb" }),
+ (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const z = db().prepare("SELECT * FROM material WHERE id = ?").get(id);
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (z.von_id !== req.person.id && !darfFremdesVerwalten(req.person)) {
+ return res.status(403).json({ fehler: "nicht_deins" });
+ }
+ /* EIGENER CODE, NICHT `schon_vergeben`: Der ist im Haus schon
+ vergeben -- fuer Aufgaben ("die Aufgabe ist schon uebernommen").
+ Ein Code, der zwei Dinge bedeutet, zeigt frueher oder spaeter
+ an einer der beiden Stellen den falschen Satz an. */
+ if (z.genommen_von) return res.status(409).json({ fehler: "schon_im_umlauf" });
+
+ const text = String(req.body?.text ?? z.text ?? "").trim().slice(0, 600);
+ const ab = req.body?.gilt_ab === undefined
+ ? z.gilt_ab : datumOderNull(req.body.gilt_ab);
+ const bis = req.body?.gilt_bis === undefined
+ ? z.gilt_bis : datumOderNull(req.body.gilt_bis);
+ if (ab === undefined || bis === undefined) {
+ return res.status(400).json({ fehler: "datum_unbrauchbar" });
+ }
+ if (ab && bis && ab > bis) {
+ return res.status(400).json({ fehler: "fenster_verkehrt" });
+ }
+
+ db().prepare("UPDATE material SET text = ?, gilt_ab = ?, gilt_bis = ? WHERE id = ?")
+ .run(text, ab, bis, id);
+ protokolliere(req.person.id, "material_geaendert", `Dogi-Media ${id}`, echteIp(req));
+ res.json({ ok: true, id, text, gilt_ab: ab, gilt_bis: bis,
+ zustand: zustandVon({ ...z, gilt_ab: ab, gilt_bis: bis }, tagLokal(0)) });
+ } catch (f) {
+ console.error("[material] Aendern:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
materialRouter.delete("/workspace/api/material/:id", (req, res) => {
try {
const id = Number(req.params.id);
const z = db().prepare("SELECT * FROM material WHERE id = ?").get(id);
if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
- if (z.von_id !== req.person.id && !istDogFather(req.person)) {
+ /* ERWEITERT AM 22.09.2026: auch rechte und linke Hand.
+ Vorher kam nur der Einsteller und DogFather an ein Stueck. Wer
+ das falsche Bild erwischt hatte, musste warten -- und bis dahin
+ stand das Falsche dort, wo die Community sich bedient. */
+ if (z.von_id !== req.person.id && !darfFremdesVerwalten(req.person)) {
return res.status(403).json({ fehler: "nicht_deins" });
}
db().prepare("DELETE FROM material WHERE id = ?").run(id);
try { unlinkSync(join(ORDNER, z.datei)); } catch { /* schon weg */ }
- protokolliere(req.person.id, "material_entfernt", `Material ${id}`, echteIp(req));
+ protokolliere(req.person.id, "material_entfernt", `Dogi-Media ${id}`, echteIp(req));
res.json({ ok: true });
} catch (f) {
console.error("[material] Entfernen:", f?.message);
diff --git a/server/workspace.js b/server/workspace.js
index baf76f27..e48e0cfc 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -1318,7 +1318,7 @@ const TREFF_BEREICHE = [
Ton 42 ist GERECHNET, nicht gegriffen -- tools/kachel-farbe-
einzeln.mjs, Abstand 0,0823 zum naechsten Nachbarn (1,3-fach
besser als der erste Vorschlag), Kontrast 6,08:1. */
- { ...TREFF_GRUPPE, name: "Material", unter: "Bilder und Videos zum Posten – jedes nur einmal",
+ { ...TREFF_GRUPPE, name: "Dogi-Media", unter: "Bilder und Videos zum Posten – jedes nur einmal",
zeichen: "galerie", ton: 42, ziel: "material.html", szene: "studio" },
/* DIE FARBE, DIE "REGELN & HILFE" HATTE (22.09.2026).
Filipes screen5: "die farbe dieser kachel soll die farbe von der
@@ -5849,8 +5849,31 @@ export function heuteLokal() {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
-/** Derselbe Tag, um n Tage verschoben. */
-export function tagLokal(versatz) {
+/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück.
+ *
+ * ZWEI DINGE AM 22.09.2026 NACHGEZOGEN, beide durch einen echten Schaden:
+ *
+ * (1) `versatz = 0`. Dogi-Media rief `tagLokal()` ohne Argument auf und
+ * bekam `"NaN-NaN-NaN"` zurück -- eine Zeichenkette, die aussieht
+ * wie ein Datum und sich wie keines verhält. Im Vergleich
+ * `"2026-12-31" < "NaN-NaN-NaN"` gewinnt das N, also galt JEDES
+ * Stück mit Enddatum vom ersten Tag an als abgelaufen, und
+ * "Kommt noch" gab es nie. Kein Absturz, keine Meldung, keine
+ * Zeile im Protokoll -- nur eine Seite, die etwas anderes zeigt
+ * als die Wahrheit. Die drei Kopien dieser Funktion waren an
+ * dieser Stelle auseinandergelaufen: helfer-tag.mjs hatte die
+ * Vorbelegung, die beiden anderen nicht.
+ *
+ * (2) Der Wurf bei einer Zahl, die keine ist. Eine Vorbelegung deckt
+ * nur den leeren Aufruf ab; `tagLokal(irgendwas)` mit einer
+ * undefinierten Variablen läge weiter still daneben. Ein Datum,
+ * das sich nicht ausrechnen lässt, muss SCHEITERN und nicht
+ * schweigen -- sonst wandert der Unsinn in die Datenbank und in
+ * die Anzeige, und gefunden wird er Wochen später. */
+export function tagLokal(versatz = 0) {
+ if (!Number.isFinite(Number(versatz))) {
+ throw new TypeError(`tagLokal: "${versatz}" ist keine Zahl von Tagen.`);
+ }
const d = new Date(Date.now() + versatz * 86400_000);
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index dfba0cec..f89ac0d1 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+