From fc823240ab764a60622a2dd560eb187789b8f350 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Tue, 22 Sep 2026 13:59:55 +0200 Subject: [PATCH] =?UTF-8?q?screen4:=20Dogi-Media=20=E2=80=94=20bearbeiten,?= =?UTF-8?q?=20entfernen,=20von=20wann=20bis=20wann?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/helfer-tag.mjs | 25 +- server/helfer-zeit.mjs | 27 +- server/pruef-css-klassen.mjs | 149 ++++++++ server/pruef-material.mjs | 614 +++++++++++++++++++++++++++++- server/workspace-material.js | 189 ++++++++- server/workspace.js | 29 +- workspace/anruf-probe.html | 8 +- workspace/app.webmanifest | 8 +- workspace/assets/css/aufgaben.css | 9 +- workspace/assets/css/bereich.css | 18 +- workspace/assets/css/dateien.css | 18 +- workspace/assets/css/gate.css | 20 + workspace/assets/css/leistung.css | 7 +- workspace/assets/css/material.css | 196 ++++++++++ workspace/assets/css/start.css | 113 ++++-- workspace/assets/js/material.js | 387 +++++++++++++++++-- workspace/assets/js/meldung.js | 30 +- workspace/aufgaben.html | 34 +- workspace/automation.html | 30 +- workspace/befinden.html | 28 +- workspace/bereich.html | 40 +- workspace/bewerben.html | 34 +- workspace/bewerbungen.html | 32 +- workspace/calls.html | 32 +- workspace/chat.html | 36 +- workspace/content.html | 32 +- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 32 +- workspace/entwicklung.html | 28 +- workspace/hilfe.html | 26 +- workspace/index.html | 8 +- workspace/kalender.html | 32 +- workspace/leistung.html | 30 +- workspace/material.html | 74 +++- workspace/personen.html | 30 +- workspace/profil.html | 34 +- workspace/rechte.html | 28 +- workspace/report.html | 32 +- workspace/scouting.html | 30 +- workspace/start.html | 30 +- workspace/startcheck.html | 32 +- workspace/steckbrief.html | 34 +- workspace/talente.html | 30 +- workspace/team.html | 28 +- workspace/teamlage.html | 28 +- workspace/teilen.html | 26 +- workspace/treff-moderation.html | 28 +- workspace/treff-regeln.html | 28 +- workspace/uebersicht.html | 30 +- workspace/unsere-seiten.html | 26 +- workspace/werdegang.html | 28 +- workspace/willkommen.html | 28 +- workspace/wissen.html | 32 +- 54 files changed, 2287 insertions(+), 668 deletions(-) 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`. --> - - + + - +