screen4: Dogi-Media — bearbeiten, entfernen, von wann bis wann

Filipe, 22.09.2026: "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. ... uebrigens ersetz das
wort material durch Dogi-Media. und mach kategorien welche sind benutzt
welche nicht welche sind noch offen, welche laufen von wan bis wann."

WAS JETZT GEHT
- Rechte Hand, linke Hand und DogFather bearbeiten und entfernen jedes
  Stueck, nicht nur ihr eigenes. Bearbeitet wird Text und Zeitfenster,
  nicht die Datei: Sonst zeigte dieselbe Nummer etwas anderes als das,
  was sich jemand gerade angesehen hat.
- Ein vergebenes Stueck bleibt unberuehrt (409). Wer es genommen hat,
  hat sich auf diesen Text verlassen.
- Vier Zustaende, gerechnet statt gespeichert: jetzt frei, kommt noch,
  abgelaufen, schon benutzt. Die Kategorieleiste zeigt zu jedem die
  Anzahl aus den echten Daten und filtert BEIDE Listen.
- "Material" heisst ueberall "Dogi-Media", der Dateiname bleibt.

DREI FEHLER AUS DEM EIGENEN ENTWURF, ALLE VOR DEM AUSLIEFERN GEMESSEN

1. `tagLokal()` ohne Argument gab "NaN-NaN-NaN" zurueck -- eine
   Zeichenkette, die aussieht wie ein Datum. Im Vergleich gewinnt das N
   gegen jede Ziffer, also galt JEDES Stueck mit Enddatum vom ersten Tag
   an als abgelaufen, und "Kommt noch" gab es nie. Kein Absturz, keine
   Meldung. Die drei Kopien der Funktion waren hier auseinandergelaufen;
   alle drei haben jetzt die Vorbelegung und werfen bei einer Zahl, die
   keine ist.
2. `frageNach` liefert bei einer reinen Rueckfrage `true`, nicht
   `{ ok: true }`. Der Entwurf prueft auf `erg?.ok` -- "Entfernen" waere
   ein Knopf gewesen, der nichts tut.
3. Drei erfundene Klassennamen (`knopf--leise`, `m-kat__knopf`,
   `m-karte__weg-knopf`) statt der vorhandenen des Hauses. Jetzt
   `.knopf-still`, `.knopf-still--warn` und die Filterleiste `.filter`.

EIN FUND IM BESTAND, ZWEI WOCHEN ALT

In start.css fehlten an einer Stelle die zwei Zeichen, die einen
Kommentar schliessen. CSS-Kommentare schachteln nicht: Der Block lief
bis zum naechsten Abschluss weiter und verschluckte `.kacheln {`; die
Fehlerbehebung des Browsers nahm danach auch die Regel `.kachel` mit.
In Chromium gemessen, beide Fassungen nacheinander: 738 statt 740
Regeln.

Der Entwurf, der dadurch nie gewirkt hat, ist NICHT wiederhergestellt
worden: Seine Flaeche mischte den Kachelton mit 30/11/3 Prozent ein --
pruef-kachelfarben fiel sofort von 36,5 % auf 4,1 % angekommene Farbe,
und 31 Toene sahen gleich aus. Das ist das Gegenteil dessen, was am
22.09. verlangt war. Er ist geloescht, mit dem Grund daneben. Offen
bleibt eine Frage an Filipe, keine Entscheidung von mir: zu demselben
Entwurf gehoerte ein schmaleres Kachelraster (fuenf bis sechs statt
drei pro Reihe). Das aendert das Aussehen der Startseite sichtbar und
bleibt deshalb, wie es ist.

GEMEINSAME BAUSTEINE AUS DEN SEITENDATEIEN GEHOLT
`.filter` stand Zeichen fuer Zeichen doppelt in dateien.css und
bereich.css, `.feld-hinweis` in aufgaben.css und leistung.css (und die
zwei waren schon auseinandergelaufen). Dogi-Media war jeweils die
dritte Seite, die sie braucht, und laedt keine davon. Derselbe Fehler
wie damals bei .knopf-still und beim Schalter -- jetzt in gate.css
bzw. start.css.

GEPRUEFT
pruef-material 155/0 (von 126, neu: Zeitfenster, Bearbeiten, Entfernen,
Rechte in der Liste und ein Abschnitt am echten Bildschirm mit Browser).
pruef-css-klassen um eine Pruefung erweitert, die genau den
start.css-Fehler findet -- mit sieben Gegenproben, darunter der echte
Fall. pruef-kachelfarben 22/0, pruef-start-ansicht, pruef-struktur,
pruef-meldungen, pruef-zeitraum, pruef-content, pruef-serien 69/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-22 13:59:55 +02:00
co-authored by Claude Opus 5
parent fe1dad48e5
commit fc823240ab
54 changed files with 2287 additions and 668 deletions
+24 -1
View File
@@ -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())}`;
}
+25 -2
View File
@@ -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())}`;
+149
View File
@@ -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);
+609 -5
View File
@@ -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
+182 -7
View File
@@ -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);
+26 -3
View File
@@ -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())}`;