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
+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