From ba0e3265857a9d6747a8675fee89ad29ebe2dba2 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Tue, 22 Sep 2026 19:46:47 +0200 Subject: [PATCH] Team-Lage: fuenf Stufen mit eigener Farbe, Karten neu gebaut, 404 wirft nicht mehr hinaus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B7 -- "wenn ich auf diese sachen drücke werd ich auf die startseite geschickt" und "ich will das es ein zwei kategorien mehr gibt und das soll geiler aussehen und jede seine eigene farbe, stark erkennbar" 1. DER RAUSWURF. Ein Druck auf einen Stufen-Knopf schickte auf die Startseite. Ursache: `hole()` stand ACHTMAL Zeichen fuer Zeichen gleich in acht Dateien, und darin warf JEDER 404 hinaus -- auch der auf eine blosse Handlung. Jetzt eine gemeinsame `assets/js/holen.js` mit der engeren, abgeleiteten Regel: Ein 404 wirft nur, solange die Seite noch gar nichts bekommen hat. 2. ZWEI STUFEN MEHR, und keine davon erfunden: "Fortgeschritten" gibt es im Entwicklungs-Katalog laengst (ENTWICKLUNG_ERWARTUNG), hier fehlte genau diese Mitte; "Vertretung" stand bis heute IN der Beschreibung von Senior. Keine Datenbankaenderung noetig -- die Spalte ist absichtlich ein TEXT ohne CHECK. 3. DIE FARBEN SIND GERECHNET. Nachgemessen lagen "Probe" und "Standard" bei 0,0576 OKLab-Abstand; MINDEST_ABSTAND ist 0,090 -- die beiden waren nebeneinander dieselbe Farbe, und alle drei lagen unter MINDEST_BUNTHEIT. Die neuen fuenf sind ein Faecher aus fuenf Farbwinkeln im Abstand von 72 Grad; der Versatz ist der, bei dem der kleinste Abstand am groessten wird. Ergebnis: 0,1531 untereinander, 0,1108 zur Rollenmarke daneben, Kontrast 7,4 bis 8,6. B8 -- "die sollen viel krasser geiler und übersichtlicher sein ... und die kacheln sollen viel krass geiler und spezieller aussehen auch der hintergrund, veränder das komplett" 4. DIE KARTE IST NEU: Kopfband in der Farbe der Stufe bis an die Kanten, Zeichen von 44 auf 52 px mit Ring, Zahlenband als eigene Flaeche, neuer Hintergrund (Farbschein unter dem Kopfband, feine Schraffur, dunklere Platte). 781 px hoch gemessen, danach 746. 5. DIE ROLLE STEHT JETZT DA -- als Marke auf der Karte und als Ueberschrift ueber jeder Gruppe. Sortiert war schon vorher nach Rolle; man konnte es nur nicht sehen. DREI FEHLER, DIE DABEI AUFFIELEN * `auto-fit` liess die einzelne Karte der rechten Hand ueber die ganze Bildschirmbreite laufen, sobald gruppiert wurde. `auto-fill` haelt die leeren Spalten offen. * Der Name der Person trug `tperson__namen` statt `tperson__name` -- ein Buchstabe, und damit die Klasse fuer graues Kleingedrucktes. Auf einer Seite ueber Personen war der Name kleiner als die Beschriftung darunter. Gefunden hat es die neue Pruefung, die ueberall "?" las. * `stufeSetzen()` suchte das Stufen-Schild mit `[class*="tmerkmal--"]:not(...)` -- einer Aufzaehlung dessen, was es NICHT ist. Sobald die Rollenmarke davorstand, traf die Suche sie: Ein Druck auf "Standard" haette aus "Modi" das Wort "Standard" gemacht. Und die Karte faerbte sich beim Setzen nicht mit um. DREI PRUEFUNGEN, ALLE MIT GEGENPROBE * pruef-holen.mjs (NEU, 14): fuehrt die echte Datei aus und misst fuenf Wege; dieselben fuenf noch einmal an der ALTEN Fassung, die bei genau zwei durchfallen muss. Dazu: laedt jede der acht Seiten holen.js, und zwar VOR ihrem Skript. * pruef-teamlage-karten.mjs (NEU, 22): im Browser. Gruppen, Rollenwort, Stufenknoepfe nur bei Modis, der Druck selbst (kein Sprung, ein Abruf, Schild und Kartenfarbe ziehen mit), Gegenprobe mit dem zweiten Druck, und die neuen Baender auf 390 px. * pruef-team-stufen.mjs (28 -> 47): die abgeschriebene Liste ["probe","standard","senior"] ist raus -- geprueft werden jetzt Eigenschaften und die Farbabstaende, gelesen aus team.css. Gegenprobe ist der Stand von gestern, der durch dieselbe Messung faellt. Co-Authored-By: Claude Opus 5 --- server/pruef-holen.mjs | 306 ++++++++++++++++++++++ server/pruef-team-stufen.mjs | 143 +++++++++- server/pruef-teamlage-karten.mjs | 393 +++++++++++++++++++++++++++ server/workspace-teamlage.js | 91 +++++-- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/module.css | 2 +- workspace/assets/css/team.css | 408 +++++++++++++++++++++++++++-- workspace/assets/js/befinden.js | 12 +- workspace/assets/js/bereich.js | 12 +- workspace/assets/js/entwicklung.js | 12 +- workspace/assets/js/holen.js | 78 ++++++ workspace/assets/js/rechte.js | 12 +- workspace/assets/js/report.js | 12 +- workspace/assets/js/talente.js | 12 +- workspace/assets/js/team.js | 12 +- workspace/assets/js/teamlage.js | 151 +++++++++-- workspace/aufgaben.html | 36 +-- workspace/automation.html | 32 +-- workspace/befinden.html | 31 +-- workspace/bereich.html | 43 +-- workspace/bewerben.html | 36 +-- workspace/bewerbungen.html | 34 +-- workspace/calls.html | 34 +-- workspace/chat.html | 38 +-- workspace/content.html | 34 +-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +-- workspace/entwicklung.html | 31 +-- workspace/hilfe.html | 28 +- workspace/index.html | 10 +- workspace/kalender.html | 34 +-- workspace/leistung.html | 32 +-- workspace/material.html | 32 +-- workspace/personen.html | 32 +-- workspace/profil.html | 36 +-- workspace/rechte.html | 31 +-- workspace/report.html | 35 +-- workspace/scouting.html | 32 +-- workspace/start.html | 32 +-- workspace/startcheck.html | 34 +-- workspace/steckbrief.html | 36 +-- workspace/talente.html | 33 +-- workspace/team.html | 31 +-- workspace/teamlage.html | 31 +-- workspace/teilen.html | 28 +- workspace/treff-moderation.html | 30 +-- workspace/treff-regeln.html | 30 +-- workspace/uebersicht.html | 32 +-- workspace/unsere-seiten.html | 28 +- workspace/werdegang.html | 30 +-- workspace/willkommen.html | 30 +-- workspace/wissen.html | 34 +-- 54 files changed, 2146 insertions(+), 672 deletions(-) create mode 100644 server/pruef-holen.mjs create mode 100644 server/pruef-teamlage-karten.mjs create mode 100644 workspace/assets/js/holen.js diff --git a/server/pruef-holen.mjs b/server/pruef-holen.mjs new file mode 100644 index 00000000..f98bdef6 --- /dev/null +++ b/server/pruef-holen.mjs @@ -0,0 +1,306 @@ +/* ===================================================================== + DER GEMEINSAME ABRUF — und die Regel, wann ein 404 hinauswirft + ===================================================================== + + ENTSTANDEN AUS EINEM ECHTEN FEHLER (22.09.2026). + + Filipe: „wenn ich auf diese sachen drücke werd ich auf die + startseite geschickt, scheck das bitte ab." Gemeint waren die + Stufen-Knöpfe auf der Team-Lage. Im Browser nachgestellt: + + Druck auf „Standard" + → PUT /workspace/api/teamlage/stufe 404 + → Adresse springt auf /workspace/start.html + + Die Ursache stand in ACHT Dateien, Zeichen für Zeichen dieselbe: + + if (a.status === 404) { location.assign('/workspace/start.html'); … } + + Gemeint war: „Du darfst diese Seite nicht sehen — hier ist der + Ausgang." Geschrieben stand: „Irgendein Abruf hat 404 geliefert — + raus mit dir." Eine einzelne Handlung, die nicht klappt, warf damit + den Menschen von einer Seite, auf der alles andere in Ordnung war — + und er sah nicht einmal, was schiefging, weil die Meldung auf der + Seite erschienen wäre, die gerade verschwindet. + + --------------------------------------------------------------------- + WARUM DAS EINE DAUERHAFTE PRÜFUNG BRAUCHT + + Weil die Regel eine ZEITLICHE ist und sich deshalb nicht ansehen + lässt: „Ein 404 wirft nur hinaus, solange die Seite noch gar nichts + bekommen hat." Wer die Datei liest, sieht drei Zeilen und findet sie + plausibel. Ob sie stimmen, zeigt erst der Ablauf — und zwar in + BEIDE Richtungen. Eine Prüfung, die nur den Rauswurf misst, wäre mit + der alten, falschen Fassung ebenfalls grün gewesen. + + Deshalb misst diese Prüfung fünf Wege durch dieselbe Funktion und + führt danach dieselben fünf Messungen noch einmal gegen die ALTE + Fassung — sie MUSS dort durchfallen. Sonst wäre nicht bewiesen, dass + sie den Unterschied überhaupt bemerkt. + + Und sie misst den zweiten Teil, der genauso wichtig ist: dass die + acht Seiten die gemeinsame Datei wirklich laden, und zwar VOR ihrem + eigenen Skript. Eine Regel, die auf einer Seite nicht geladen wird, + ist dort keine Regel — sondern ein „holen is not a function". + ===================================================================== */ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createContext, runInContext } from "node:vm"; +import { notbremse } from "./helfer-notbremse.mjs"; + +notbremse(60_000, "pruef-holen"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WORKSPACE = join(HIER, "..", "workspace"); +const SKRIPTE = join(WORKSPACE, "assets", "js"); + +let fehler = 0, geprueft = 0, offen = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +console.log("=== Der gemeinsame Abruf (holen.js) ==="); +console.log(""); + +/* ===================================================================== + 1. DAS VERHALTEN + + Die echte Datei wird ausgeführt, nicht nachgebaut. Ein Nachbau + würde beweisen, dass der Nachbau stimmt. + ===================================================================== */ +console.log("--- Wie sich holen() verhält ---"); + +let quelle = null; +try { + quelle = readFileSync(join(SKRIPTE, "holen.js"), "utf8"); +} catch (f) { + unklar(`assets/js/holen.js ist nicht lesbar (${f.code || f.message})` + + " — ohne die Datei lässt sich hier nichts messen."); +} + +/* Die ALTE Fassung, wörtlich so, wie sie in den acht Dateien stand. + Sie ist die Gegenprobe: Dieselben Messungen müssen an ihr + scheitern. */ +const ALTE_FASSUNG = ` + window.holen = async function holen(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('nicht erlaubt'); } + return a; + }; +`; + +/** Führt eine Fassung aus und gibt eine Prüfbank zurück. */ +function bank(js) { + const gesprungen = []; + const welt = { + window: {}, + location: { assign: (z) => gesprungen.push(z) }, + fetch: null, + console, + }; + welt.globalThis = welt; + const raum = createContext(welt); + runInContext(js, raum, { timeout: 5000 }); + return { + gesprungen, + /** Setzt die nächste Antwort und ruft holen() auf. */ + async ruf(status, optionen) { + welt.fetch = async () => ({ status, ok: status >= 200 && status < 300 }); + try { + return { antwort: await welt.window.holen("/irgendwo", optionen), wurf: null }; + } catch (f) { + return { antwort: null, wurf: String(f && f.message) }; + } + }, + }; +} + +/** Die fünf Wege. Gibt zurück, was dabei herauskam — als Text, damit + * beide Fassungen mit derselben Elle gemessen werden. */ +async function fuenfWege(js) { + const b = bank(js); + const erg = {}; + + /* (1) Der erste Abruf einer Seite läuft ins Leere: Die Seite gibt es + für diesen Menschen nicht. Hier IST der Sprung richtig. */ + const a1 = await b.ruf(404, undefined); + erg.ersterGetSpringt = b.gesprungen.at(-1) === "/workspace/start.html" && a1.antwort === null; + + /* (2) Eine Seite, die steht, und danach eine Handlung, die nicht + klappt. Hier darf NICHTS springen. */ + const b2 = bank(js); + await b2.ruf(200, undefined); + const vorher2 = b2.gesprungen.length; + const a2 = await b2.ruf(404, { method: "PUT" }); + erg.handlungSpringtNicht = b2.gesprungen.length === vorher2 + && a2.antwort !== null && a2.antwort.status === 404; + + /* (3) Auch ein späterer GET darf nicht mehr werfen — die Seite steht + ja. Ein Nachladen, das ins Leere geht, ist eine Meldung wert, + kein Rauswurf. */ + const b3 = bank(js); + await b3.ruf(200, undefined); + const vorher3 = b3.gesprungen.length; + await b3.ruf(404, undefined); + erg.spaeterGetSpringtNicht = b3.gesprungen.length === vorher3; + + /* (4) 401 wirft IMMER zur Anmeldung — auch mitten in der Arbeit. + Wer abgemeldet ist, kann nirgends mehr etwas tun. */ + const b4 = bank(js); + await b4.ruf(200, undefined); + const a4 = await b4.ruf(401, { method: "POST" }); + erg.abgemeldetSpringtImmer = b4.gesprungen.at(-1) === "/workspace/" + && a4.antwort === null; + + /* (5) Der Normalfall: eine Antwort kommt zurück, nichts springt. */ + const b5 = bank(js); + const a5 = await b5.ruf(200, undefined); + erg.normalfallRuhig = b5.gesprungen.length === 0 + && a5.antwort !== null && a5.antwort.status === 200; + + return erg; +} + +const WEGE = { + ersterGetSpringt: "ein 404 auf den ERSTEN Abruf wirft zur Startseite", + handlungSpringtNicht: "ein 404 auf eine Handlung (PUT) wirft NICHT — die Seite bleibt stehen", + spaeterGetSpringtNicht: "ein 404 auf einen späteren Abruf wirft ebenfalls nicht", + abgemeldetSpringtImmer: "ein 401 wirft immer zur Anmeldung, auch mitten in der Arbeit", + normalfallRuhig: "der Normalfall gibt die Antwort zurück und springt nirgends", +}; + +let neuErg = null; +if (quelle) { + neuErg = await fuenfWege(quelle); + for (const [schluessel, satz] of Object.entries(WEGE)) { + ok(neuErg[schluessel] === true, satz); + } +} + +/* ---- Die Gegenprobe -------------------------------------------------- + Dieselben fünf Messungen an der alten Fassung. Zwei davon MÜSSEN + dort umschlagen — sonst misst diese Prüfung nicht, was sie + behauptet. */ +console.log(""); +console.log("--- Gegenprobe: dieselben Messungen an der alten Fassung ---"); +const altErg = await fuenfWege(ALTE_FASSUNG); +const umgeschlagen = Object.keys(WEGE).filter((k) => altErg[k] !== true); +ok(umgeschlagen.length === 2 + && umgeschlagen.includes("handlungSpringtNicht") + && umgeschlagen.includes("spaeterGetSpringtNicht"), + `die alte Fassung fällt bei genau den zwei Wegen durch, um die es ging` + + ` (${umgeschlagen.length}: ${umgeschlagen.join(", ") || "keinem"})`); +ok(altErg.ersterGetSpringt === true && altErg.abgemeldetSpringtImmer === true + && altErg.normalfallRuhig === true, + "und sie besteht die drei anderen — gemessen wird also die REGEL," + + " nicht einfach die Frage, ob es die neue Datei ist" + + " (eine Prüfung, die jede alte Fassung verwirft, prüft nichts)"); + +/* ===================================================================== + 2. DIE EINBINDUNG + + ABGELEITET, NICHT AUFGEZÄHLT: Welche Seitenskripte den gemeinsamen + Abruf benutzen, steht in den Dateien selbst. Eine Liste hier wäre + die, die beim neunten Skript nicht mitwächst. + ===================================================================== */ +console.log(""); +console.log("--- Wird holen.js überall geladen, und zwar zuerst? ---"); + +const skriptNamen = readdirSync(SKRIPTE).filter((n) => n.endsWith(".js")); +const nutzer = []; +for (const name of skriptNamen) { + if (name === "holen.js") continue; + const text = readFileSync(join(SKRIPTE, name), "utf8"); + if (/window\.holen\s*\(/.test(text)) nutzer.push({ name, text }); +} +ok(nutzer.length >= 8, + `${nutzer.length} Seitenskripte benutzen den gemeinsamen Abruf` + + ` (${nutzer.map((n) => n.name.replace(/\.js$/, "")).join(", ")})`); + +/* KEINE EIGENE KOPIE DER REGEL MEHR. + + --------------------------------------------------------------------- + DIE ERSTE FASSUNG DIESER PRÜFUNG WAR ZU STRENG — und das ist selbst + ein Befund wert. Sie verbot jedes `location.assign('/workspace/ + start.html')` in einem Seitenskript und wurde sofort rot, bei + bereich.js und team.js. Nachgesehen: Alle drei Stellen sind richtig + und sollen bleiben. + + bereich.js `?b=` ist kein gültiger Bereichsname → es gibt die + Seite nicht + bereich.js der erste Abruf des Bereichs ist nicht ok → diese + Person sieht diesen Bereich nicht + team.js die Rolle passt nicht → leere Seite vermeiden + + Ein Sprung zur Startseite ist nicht das Problem. Das Problem war, + dass die ZEITLICHE Regel („nur solange die Seite noch nichts hat") + achtmal nachgebaut danebenstand. Eine Prüfung, die den gesunden + Fall bestraft, wird abgeschaltet — und mit ihr die Warnung, auf die + es ankommt. + + Verboten ist deshalb genau zweierlei: ein eigener `hole()`-Helfer + neben dem gemeinsamen, und die Paarung „404 → Startseite" in einem + Seitenskript. Beides ist die Dopplung, nichts sonst. + --------------------------------------------------------------------- */ +const eigenerHelfer = nutzer.filter((n) => + /(async\s+)?function\s+hole\s*\(/.test(n.text)); +ok(nutzer.length > 0 && eigenerHelfer.length === 0, + `keines baut sich daneben einen eigenen hole()-Helfer` + + ` (${eigenerHelfer.map((n) => n.name).join(", ") || "keines"})`); + +const mitEigenerRegel = nutzer.filter((n) => + /status\s*===\s*404[\s\S]{0,120}?location\.assign\((['"])\/workspace\/start\.html\1\)/ + .test(n.text)); +ok(nutzer.length > 0 && mitEigenerRegel.length === 0, + `und keines entscheidet selbst, was ein 404 bedeutet` + + ` (${mitEigenerRegel.map((n) => n.name).join(", ") || "keines"})`); + +/* GEGENPROBE ZU DEN ZWEI REGELN OBEN. Zwei Suchen, die nichts finden, + sehen genauso aus wie zwei Suchen, die nichts finden KOENNEN — das + ist der leere `every()`-Fehler in anderer Gestalt. Also einmal an + dem Text messen, den es zu verhindern gilt: der alten, acht Mal + kopierten Fassung. Findet die Suche ihn nicht, ist sie kaputt. */ +const ALTE_KOPIE = ` + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('nicht erlaubt'); } + return a; + } +`; +ok(/(async\s+)?function\s+hole\s*\(/.test(ALTE_KOPIE) + && /status\s*===\s*404[\s\S]{0,120}?location\.assign\((['"])\/workspace\/start\.html\1\)/ + .test(ALTE_KOPIE), + "Gegenprobe: beide Suchen finden die alte Kopie — sie koennen also anschlagen"); + +/* Und jetzt die Seiten. Welche HTML-Datei welches Skript lädt, steht + in der HTML-Datei — auch das wird gelesen, nicht gepflegt. */ +const seiten = readdirSync(WORKSPACE).filter((n) => n.endsWith(".html")); +let geprueftePaare = 0; +const ohneHolen = [], falscheReihenfolge = []; +for (const seite of seiten) { + const html = readFileSync(join(WORKSPACE, seite), "utf8"); + for (const n of nutzer) { + const stelle = html.indexOf(`assets/js/${n.name}`); + if (stelle < 0) continue; + geprueftePaare++; + const holenStelle = html.indexOf("assets/js/holen.js"); + if (holenStelle < 0) { ohneHolen.push(`${seite} → ${n.name}`); continue; } + /* VOR dem Seitenskript. Beide tragen `defer`, also zählt die + Reihenfolge im Quelltext — nicht die des Eintreffens. */ + if (holenStelle > stelle) falscheReihenfolge.push(`${seite} → ${n.name}`); + } +} +ok(geprueftePaare >= 8, + `${geprueftePaare} Seite-Skript-Paare gefunden — es gibt also etwas zu prüfen`); +ok(geprueftePaare > 0 && ohneHolen.length === 0, + `jede dieser Seiten lädt holen.js (${ohneHolen.join(", ") || "keine Lücke"})`); +ok(geprueftePaare > 0 && falscheReihenfolge.length === 0, + `und immer VOR dem Seitenskript (${falscheReihenfolge.join(", ") || "überall richtig"})`); + +/* ---- Ende ------------------------------------------------------------ */ +console.log(""); +console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}` + + ` -- ${geprueft} geprüft, ${fehler} Fehler, ${offen} nicht nachsehbar`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/pruef-team-stufen.mjs b/server/pruef-team-stufen.mjs index 7f04c8a4..cbde5ada 100644 --- a/server/pruef-team-stufen.mjs +++ b/server/pruef-team-stufen.mjs @@ -1,5 +1,5 @@ /* ===================================================================== - DIE MITGLIEDER-KACHEL UND DIE DREI STUFEN (Blueprint Kapitel 4/4.1) + DIE MITGLIEDER-KACHEL UND DIE STUFEN (Blueprint Kapitel 4/4.1) Vier Fragen, und die dritte ist die, an der es schiefgeht: @@ -180,12 +180,147 @@ ok(marina?.stufe === "probe" && kim?.stufe === "probe", `ohne Eintrag ist die Stufe "Probe" (${marina?.stufe} / ${kim?.stufe})`); /* Die Liste der Stufen kommt VOM SERVER -- eine zweite im Browser - waere die Stelle, an der beim naechsten Mal eine fehlt. */ + waere die Stelle, an der beim naechsten Mal eine fehlt. + + KEINE ABGESCHRIEBENE LISTE MEHR (22.09.2026). Hier stand + `=== JSON.stringify(["probe","standard","senior"])`. Als die vierte + und fuenfte Stufe dazukamen, wurde die Pruefung rot, ohne dass + irgendetwas kaputt war -- sie hat die Zahl von gestern verteidigt. + Dieselbe Krankheit wie eine abgeschriebene Spaltenliste. + + Geprueft werden jetzt die EIGENSCHAFTEN, die wirklich gelten + muessen, egal wie viele es sind. */ const stufen = (lage.stufen || []).map((s) => s.wert); -ok(JSON.stringify(stufen) === JSON.stringify(["probe", "standard", "senior"]), - `die drei Stufen kommen mit, in ihrer Reihenfolge (${stufen.join(", ")})`); +ok(stufen.length >= 3, + `mindestens drei Stufen kommen mit (${stufen.length}: ${stufen.join(", ")})`); +ok(new Set(stufen).size === stufen.length, "keine Stufe doppelt"); +/* "probe" MUSS die erste sein: Der Server bildet NULL auf die erste ab + (stufeVon), und die Oberflaeche waehlt STUFEN[0] vor. Stuende dort + etwas anderes, waere jeder neue Mensch still auf dieser anderen + Stufe -- ohne dass es irgendwo auffaellt. */ +ok(stufen[0] === "probe", + `die erste ist "probe" (${stufen[0]}) -- darauf faellt ein leeres Feld zurueck`); ok((lage.stufen || []).every((s) => s.name && s.name !== s.wert), "und jede hat einen lesbaren Namen"); +ok(new Set((lage.stufen || []).map((s) => s.name)).size === stufen.length, + "kein Name doppelt -- zwei gleich beschriftete Knoepfe waeren ein Ratespiel"); + +/* ===================================================================== + DIE FARBEN -- NACHGERECHNET, NICHT GEGLAUBT (22.09.2026) + + Filipe: „und ich will das es ein zwei kategorien mehr gibt und das + soll geiler aussehen und jede seine eigene farbe, stark erkennbar." + + „Stark erkennbar" ist keine Geschmacksfrage, sondern ein Abstand. + Das Haus hat dafuer laengst Schwellen (tools/kachelton-regeln.mjs); + die Stufenfarben waren nur nie daran gemessen worden. Nachgerechnet + lagen "Probe" und "Standard" bei 0,0576 -- weit unter den 0,090, die + ueberall sonst gelten. Es war also kein Geschmacksurteil, sondern + ein messbarer Mangel, und er stand seit dem 10.09. da. + + DREI ORTE, EINE FARBE: Die Stufe faerbt das Schild + (.tmerkmal--), die ganze Karte ([data-stufe=""]) und den + Knopf zum Setzen (--st am [data-stufe=""]). Stimmen die drei + nicht ueberein, fuehrt ein oranger Knopf zu einer gruenen Karte. + ===================================================================== */ +console.log(""); +console.log("--- Die Farben der Stufen ---"); + +const { readFileSync: leseDatei } = await import("node:fs"); +const { dirname: ordnerVon, join: pfadJoin } = await import("node:path"); +const { fileURLToPath: alsPfad } = await import("node:url"); +const HIER_ST = ordnerVon(alsPfad(import.meta.url)); +const teamCss = leseDatei( + pfadJoin(HIER_ST, "..", "workspace", "assets", "css", "team.css"), "utf8"); + +const { oklab } = await import("./helfer-kachel-echtfarbe.mjs"); +const regeln = await import("../tools/kachelton-regeln.mjs"); + +const hexZuRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); +const linearAnteil = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const leuchtkraft = ([R, G, B]) => { + const [r, g, b] = [R / 255, G / 255, B / 255].map(linearAnteil); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const kontrastZu = (a, b) => { + const x = leuchtkraft(a), y = leuchtkraft(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +}; +const okAbstand = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +const buntheit = (o) => Math.hypot(o[1], o[2]); +const GRUND_RGB = hexZuRgb(regeln.GRUND); + +/* Die Farben werden AUS DER STILVORLAGE GELESEN, nicht hier + aufgezaehlt. Eine Liste, die niemand pflegt, kann nicht veralten. */ +const holeFarbe = (muster) => { + const t = teamCss.match(muster); + return t ? t[1].toLowerCase() : null; +}; +const farben = new Map(); +for (const wert of stufen) { + const schild = holeFarbe(new RegExp( + "\\.tmerkmal--" + wert + "\\s*\\{[^}]*color-mix\\(in srgb,\\s*(#[0-9a-fA-F]{6})")); + const karte = holeFarbe(new RegExp( + "\\.tperson\\[data-stufe=\"" + wert + "\"\\]\\s*\\{\\s*--r:\\s*(#[0-9a-fA-F]{6})")); + const knopf = holeFarbe(new RegExp( + "\\.tstufen__knopf\\[data-stufe=\"" + wert + "\"\\]\\s*\\{\\s*--st:\\s*(#[0-9a-fA-F]{6})")); + ok(Boolean(schild && karte && knopf), + `"${wert}" hat eine Farbe am Schild, an der Karte und am Knopf` + + ` (${schild || "fehlt"} / ${karte || "fehlt"} / ${knopf || "fehlt"})`); + ok(schild !== null && schild === karte && schild === knopf, + `und es ist ueberall dieselbe (${schild})`); + if (schild) farben.set(wert, schild); +} + +ok(farben.size === stufen.length, + `alle ${stufen.length} Stufen haben eine Farbe (${farben.size})`); + +/* Die eigentliche Messung. Die ZAHL der geprueften Paare steht in der + Bedingung -- eine leere Schleife darf nicht "in Ordnung" heissen. */ +const werte = [...farben.entries()]; +let paare = 0, engstes = Infinity, engstesPaar = ""; +for (let i = 0; i < werte.length; i++) { + for (let j = i + 1; j < werte.length; j++) { + paare++; + const a = okAbstand(oklab(hexZuRgb(werte[i][1])), oklab(hexZuRgb(werte[j][1]))); + if (a < engstes) { engstes = a; engstesPaar = `${werte[i][0]}/${werte[j][0]}`; } + } +} +ok(paare === (werte.length * (werte.length - 1)) / 2 && paare > 0 + && engstes >= regeln.MINDEST_ABSTAND, + `${paare} Paare geprueft, engstes ${engstesPaar} = ${engstes.toFixed(4)}` + + ` (noetig ${regeln.MINDEST_ABSTAND})`); + +let blasseste = Infinity, grellste = 0, schwaechster = Infinity; +for (const [, hex] of werte) { + const rgb = hexZuRgb(hex), o = oklab(rgb); + blasseste = Math.min(blasseste, buntheit(o)); + grellste = Math.max(grellste, buntheit(o)); + schwaechster = Math.min(schwaechster, kontrastZu(rgb, GRUND_RGB)); +} +ok(werte.length > 0 && blasseste >= regeln.MINDEST_BUNTHEIT, + `die blasseste hat Buntheit ${blasseste.toFixed(3)} (noetig ${regeln.MINDEST_BUNTHEIT})`); +ok(werte.length > 0 && grellste <= regeln.HOECHST_BUNTHEIT, + `die bunteste hat ${grellste.toFixed(3)} (hoechstens ${regeln.HOECHST_BUNTHEIT})`); +ok(werte.length > 0 && schwaechster >= regeln.MINDEST_KONTRAST, + `der schwaechste Kontrast gegen den Kartengrund ist ${schwaechster.toFixed(2)}` + + ` (noetig ${regeln.MINDEST_KONTRAST})`); + +/* GEGENPROBE ZUR MESSUNG SELBST. Ohne sie waere nicht bewiesen, dass + diese Rechnung ueberhaupt "nicht in Ordnung" sagen kann -- und der + Stand von gestern ist der beste Beleg dafuer, weil er wirklich so + ausgeliefert war. */ +const alteFarben = ["#9fb6c6", "#7fc4e8", "#8fd6a6"]; +let altEngstes = Infinity; +for (let i = 0; i < alteFarben.length; i++) { + for (let j = i + 1; j < alteFarben.length; j++) { + altEngstes = Math.min(altEngstes, + okAbstand(oklab(hexZuRgb(alteFarben[i])), oklab(hexZuRgb(alteFarben[j])))); + } +} +ok(altEngstes < regeln.MINDEST_ABSTAND, + `Gegenprobe: der Stand von gestern faellt durch dieselbe Messung` + + ` (${altEngstes.toFixed(4)} < ${regeln.MINDEST_ABSTAND})`); /* ===================================================================== 2. WER DARF SIE SETZEN diff --git a/server/pruef-teamlage-karten.mjs b/server/pruef-teamlage-karten.mjs new file mode 100644 index 00000000..e0d57411 --- /dev/null +++ b/server/pruef-teamlage-karten.mjs @@ -0,0 +1,393 @@ +/* ===================================================================== + DIE PERSONENKARTEN DER TEAM-LAGE — im echten Browser + ===================================================================== + + ENTSTANDEN AUS ZWEI MELDUNGEN AM 22.09.2026. + + (1) „wenn ich auf diese sachen drücke werd ich auf die startseite + geschickt, scheck das bitte ab." + (2) „was verstehst du nicht wenn ich sage die sollen viel krasser + geiler und übersichtlicher sein. und auch den rollen + entsprechend reihenfolge. nicht einfach so sondern die rolle + soll die reihenfolge ausmachen." + + --------------------------------------------------------------------- + WARUM DAS IM BROWSER GEMESSEN WIRD UND NICHT AM SERVER + + Beide Befunde waren am Server NICHT SICHTBAR. + + Zu (1): Der Server antwortete völlig korrekt mit 404 — die rechte + Hand hat keine Stufe, also gibt es dort nichts zu setzen. Den + Schaden richtete der Browser an: Er machte aus jedem 404 einen + Rauswurf. `pruef-team-stufen.mjs` misst den 404 und ist grün; sie + kann den Sprung gar nicht sehen. + + Zu (2): Die Reihenfolge war am Server seit Tagen richtig + (ROLLEN_SORTIERUNG in der Abfrage) — und Filipe hat sie trotzdem + nicht wiedererkannt, weil auf der Karte nur die STUFE stand, nie die + Rolle. Eine Ordnung, die man nicht sehen kann, sieht aus wie Zufall. + Auch das ist am Server nicht messbar: Die Antwort war richtig. + + Beides zusammen ist dieselbe Lehre: Wo die Antwort stimmt und das + Ergebnis trotzdem falsch ankommt, muss dort gemessen werden, wo der + Mensch steht. + + --------------------------------------------------------------------- + DIE GEGENPROBE STECKT IM ZWEITEN DRUCK + + Ein Druck auf einen Knopf, der schon gewählt ist, darf NICHTS tun — + kein Abruf, keine Änderung. Ohne diesen zweiten Druck wäre nicht + bewiesen, dass die Messung „hat sich etwas geändert" überhaupt einen + Unterschied bemerkt: Eine Seite, die auf jeden Druck alles neu malt, + bestünde den ersten Teil genauso. + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { execFileSync } from "node:child_process"; +import { createServer as httpsServer } from "node:https"; +import { notbremse } from "./helfer-notbremse.mjs"; +import { eigenerPort } from "./helfer-port.mjs"; + +const CREW = "crew.dogfather-universe.com"; +const PORT = await eigenerPort(import.meta, "pruef-teamlage-karten"); +const ordner = mkdtempSync(join(tmpdir(), "ws-tlk-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(240_000, "pruef-teamlage-karten"); +await new Promise((r) => setTimeout(r, 900)); + +let fehler = 0, geprueft = 0, offen = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +/* ---------- Leute anlegen --------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Rieke", "hand", "CODE-HAND-0001"); + +/* DIE STUFEN KOMMEN AUS DEM SERVERMODUL, nicht aus einer Liste hier. + Genau so bleibt die Pruefung gueltig, wenn morgen eine sechste + dazukommt -- und sie prueft dann auch die sechste mit. */ +const { STUFEN, STUFEN_NAME } = await import("./workspace-teamlage.js"); +const modiNamen = ["Frida", "Nele", "Ghost", "Maja", "Liv", "Tara", "Juno", "Pia"]; +const modis = STUFEN.map((stufe, i) => { + const name = modiNamen[i] || `Modi${i + 1}`; + const id = anlegen(name, "modi", `CODE-MODI-00${10 + i}`); + d.prepare("UPDATE personen SET stufe = ? WHERE id = ?").run(stufe, id); + return { id, name, stufe }; +}); +d.close(); + +/* ---------- Der Vorbau, damit der Host-Kopf ankommt ------------------- */ +/* `fetch` in Node verwirft den Host-Kopf, und Chromium hebt crew.… auf + https. Ein selbstsigniertes Zertifikat plus MAP-Regel ist der Weg, + den auch die anderen Browserpruefungen dieses Hauses gehen. */ +const schl = join(ordner, "p.key"), zert = join(ordner, "p.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", schl, "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`, + "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" }); +const HP = PORT + 1; +const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) }, + (von, nach) => { + const w = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url, + method: von.method, headers: { ...von.headers, host: `${CREW}:${HP}` } }, + (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); }); + von.pipe(w); + }); +await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r)); +const SEITE = `https://${CREW}:${HP}`; + +/* ---------- Browser --------------------------------------------------- */ +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"] }); +const ctx = await browser.newContext({ viewport: { width: 1420, height: 1200 } }); +const seite = await ctx.newPage(); +const meckern = []; +const abrufe = []; +seite.on("pageerror", (f) => meckern.push("Absturz: " + String(f).slice(0, 160))); +seite.on("console", (m) => { + if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) { + meckern.push(m.text().slice(0, 160)); + } +}); +seite.on("response", (r) => { + if (/\/workspace\/api\/teamlage\//.test(r.url())) { + abrufe.push(`${r.status()} ${r.url().split("/workspace")[1]}`); + } +}); + +console.log("=== Die Personenkarten der Team-Lage ==="); +console.log(""); + +await seite.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" }); +await seite.click('.rolle[data-rolle="admin"]'); +await seite.fill("#code", "CODE-DOGI-0001"); +await seite.click("button[type=submit]"); +await seite.waitForTimeout(900); +await seite.goto(`${SEITE}/workspace/teamlage.html`, { waitUntil: "networkidle" }); +await seite.waitForTimeout(1600); + +const angekommen = new URL(seite.url()).pathname; +if (angekommen !== "/workspace/teamlage.html") { + unklar(`die Seite hat uns nach ${angekommen} geschickt —` + + " ab hier laesst sich nichts messen."); +} else { + /* =================================================================== + 1. DIE KARTEN UND IHRE REIHENFOLGE + =================================================================== */ + console.log("--- Die Reihenfolge macht die Rolle ---"); + + const lage = await seite.evaluate(() => { + const karten = [...document.querySelectorAll(".t-leute .tperson")]; + return { + karten: karten.map((k) => ({ + name: k.querySelector(".tperson__name")?.textContent.trim() || "?", + rolle: k.dataset.rolle || "", + stufe: k.dataset.stufe || "", + rollenwort: k.querySelector(".tmerkmal--rolle")?.textContent.trim() || "", + stufenwort: k.querySelector(".tmerkmal--stufe")?.textContent.trim() || "", + knoepfe: k.querySelectorAll(".tstufen__knopf").length, + })), + gruppen: [...document.querySelectorAll(".t-gruppe")].map((g) => ({ + rolle: g.dataset.rolle || "", + wort: g.querySelector(".t-gruppe__wort")?.textContent.trim() || "", + zahl: Number(g.querySelector(".t-gruppe__zahl")?.textContent.trim()), + })), + /* Wie viele Karten stehen in demselben Block wie die Ueberschrift + davor? Gemessen am Baum, nicht gezaehlt aus den Daten. */ + bloecke: [...document.querySelectorAll(".t-leute")] + .map((l) => l.querySelectorAll(".tperson").length), + }; + }); + + ok(lage.karten.length === 1 + modis.length, + `${lage.karten.length} Karten stehen da (erwartet ${1 + modis.length})`); + + /* DIE ROLLE STEHT AUF JEDER KARTE. Ohne das Wort ist die Sortierung + unsichtbar -- genau der Befund vom 22.09. */ + const ohneWort = lage.karten.filter((k) => !k.rollenwort); + ok(lage.karten.length > 0 && ohneWort.length === 0, + `jede Karte nennt ihre Rolle im Klartext` + + ` (${ohneWort.map((k) => k.name).join(", ") || "keine Luecke"})`); + + /* GRUPPIERT, NICHT NUR SORTIERT: Keine Rolle darf zweimal auftauchen, + nachdem eine andere dazwischenstand. Abgeleitet aus dem, was da + steht -- ohne eine Erwartung, welche Rollen es gibt. */ + const folge = lage.karten.map((k) => k.rolle); + const bloecke = folge.filter((r, i) => r !== folge[i - 1]); + ok(folge.length > 0 && new Set(bloecke).size === bloecke.length, + `die Rollen stehen in geschlossenen Bloecken (${bloecke.join(" → ")})`); + + /* Und jede Gruppe hat ihre Ueberschrift mit der richtigen Zahl. */ + ok(lage.gruppen.length === bloecke.length, + `${lage.gruppen.length} Ueberschriften fuer ${bloecke.length} Bloecke`); + const zahlenStimmen = lage.gruppen.length > 0 + && lage.gruppen.every((g, i) => g.zahl === lage.bloecke[i]) + && lage.gruppen.every((g, i) => g.rolle === bloecke[i]); + ok(zahlenStimmen, + `jede Ueberschrift nennt ihre Rolle und ihre Anzahl richtig` + + ` (${lage.gruppen.map((g) => `${g.wort} ${g.zahl}`).join(" · ")})`); + + /* =================================================================== + 2. DIE STUFE STEHT NUR DA, WO ES EINE GIBT + =================================================================== */ + console.log(""); + console.log("--- Wer eine Stufe hat, und wer nicht ---"); + + const mitStufe = lage.karten.filter((k) => k.rolle === "modi"); + const ohneStufe = lage.karten.filter((k) => k.rolle !== "modi"); + ok(mitStufe.length === modis.length && ohneStufe.length >= 1, + `${mitStufe.length} Modis und ${ohneStufe.length} ohne Stufe im Bild`); + ok(mitStufe.length > 0 && mitStufe.every((k) => k.knoepfe === STUFEN.length), + `jeder Modi hat alle ${STUFEN.length} Stufenknoepfe` + + ` (${[...new Set(mitStufe.map((k) => k.knoepfe))].join("/")})`); + /* DER EIGENTLICHE BEFUND VOM 22.09.: Hier standen die Knoepfe auch an + der rechten Hand. Der Server lehnte mit 404 ab, und der 404 warf + von der Seite. */ + ok(ohneStufe.length > 0 && ohneStufe.every((k) => k.knoepfe === 0), + `wer keine Stufe hat, hat auch keine Knoepfe` + + ` (${ohneStufe.map((k) => `${k.name}:${k.knoepfe}`).join(", ")})`); + ok(ohneStufe.length > 0 && ohneStufe.every((k) => !k.stufenwort), + `und auch kein Stufen-Schild — "Probe" an der rechten Hand waere` + + ` eine Auskunft, die niemand aendern kann` + + ` (${ohneStufe.map((k) => k.stufenwort || "leer").join(", ")})`); + + /* Jede Stufe traegt ihren lesbaren Namen. */ + const falschBeschriftet = mitStufe.filter( + (k) => k.stufenwort !== (STUFEN_NAME[k.stufe] || "")); + ok(mitStufe.length > 0 && falschBeschriftet.length === 0, + `jede Stufe steht mit ihrem Namen auf der Karte` + + ` (${mitStufe.map((k) => k.stufenwort).join(", ")})`); + + /* =================================================================== + 3. EIN DRUCK AUF EINEN STUFENKNOPF + =================================================================== */ + console.log(""); + console.log("--- Was ein Druck auf die Stufe tut ---"); + + /* Die erste Karte, die Knoepfe hat, und darin ein Knopf, der noch + nicht gewaehlt ist. */ + const ziel = await seite.evaluate(() => { + const k = [...document.querySelectorAll(".t-leute .tperson")] + .find((x) => x.querySelectorAll(".tstufen__knopf").length > 0); + if (!k) return null; + const b = [...k.querySelectorAll(".tstufen__knopf")] + .find((x) => x.dataset.an !== "ja"); + if (!b) return null; + k.dataset.pruefziel = "ja"; + b.dataset.pruefknopf = "ja"; + return { name: k.querySelector(".tperson__name")?.textContent.trim(), + vorher: k.dataset.stufe, nach: b.dataset.stufe, wort: b.textContent.trim() }; + }); + + if (!ziel) { + unklar("kein waehlbarer Stufenknopf gefunden — ohne ihn ist der Druck nicht messbar"); + } else { + const adresseVorher = new URL(seite.url()).pathname; + abrufe.length = 0; + await seite.click('.tstufen__knopf[data-pruefknopf="ja"]'); + await seite.waitForTimeout(1400); + + const adresseNachher = new URL(seite.url()).pathname; + ok(adresseNachher === adresseVorher, + `die Adresse bleibt ${adresseNachher} — kein Sprung zur Startseite`); + ok(abrufe.length === 1 && abrufe[0].startsWith("200"), + `genau ein Abruf, und er geht durch (${abrufe.join(" | ") || "keiner"})`); + + const danach = await seite.evaluate(() => { + const k = document.querySelector('.tperson[data-pruefziel="ja"]'); + return k ? { + stufe: k.dataset.stufe, + schild: k.querySelector(".tmerkmal--stufe")?.textContent.trim() || "", + gewaehlt: [...k.querySelectorAll('.tstufen__knopf[data-an="ja"]')] + .map((b) => b.dataset.stufe), + meldung: document.getElementById("fehler")?.textContent.trim() || "", + } : null; + }); + ok(danach?.gewaehlt.length === 1 && danach.gewaehlt[0] === ziel.nach, + `der gedrueckte Knopf ist gewaehlt und nur er (${(danach?.gewaehlt || []).join(", ")})`); + ok(danach?.schild === (STUFEN_NAME[ziel.nach] || ziel.nach), + `das Schild zieht mit (${danach?.schild})`); + /* DIE KARTENFARBE HAENGT AN data-stufe. Blieb sie stehen, sah die + Karte nach dem Speichern aus wie vorher -- und "gespeichert" + fuehlt sich dann an wie "nicht gespeichert". */ + ok(danach?.stufe === ziel.nach, + `und die Karte traegt die neue Stufe, faerbt sich also mit um (${danach?.stufe})`); + ok(!danach?.meldung, + `keine Fehlermeldung auf der Seite (${danach?.meldung || "leer"})`); + + /* ---- GEGENPROBE: derselbe Knopf noch einmal ---------------------- */ + abrufe.length = 0; + await seite.click('.tstufen__knopf[data-pruefknopf="ja"]'); + await seite.waitForTimeout(900); + const nochmal = await seite.evaluate(() => { + const k = document.querySelector('.tperson[data-pruefziel="ja"]'); + return { stufe: k?.dataset.stufe, + gewaehlt: k ? [...k.querySelectorAll('.tstufen__knopf[data-an="ja"]')].length : -1 }; + }); + ok(abrufe.length === 0 && nochmal.stufe === ziel.nach && nochmal.gewaehlt === 1, + `Gegenprobe: derselbe Knopf noch einmal ruft nichts ab und aendert nichts` + + ` (${abrufe.length} Abrufe)`); + } +} + +/* ===================================================================== + 4. DIE BAENDER AUF EINEM SCHMALEN SCHIRM + + Das Kopfband und das Zahlenband laufen mit NEGATIVEN Aussenabstaenden + bis an die Kanten der Karte. Solche Werte sind Gegenmasse zum Polster + der Karte -- und die Karte hat auf schmalen Schirmen ein anderes + (14/15 px statt 17/19). Passt das nicht zusammen, steht das Band + ueber, und waagerechtes Scrollen ist auf einem Handy der schlimmste + Fehler. + + Gemessen statt gerechnet: 390 px ist die Breite, auf der Filipe + nachsieht. Geprueft wird die SEITE (scrollt sie waagerecht?) und + jedes einzelne Band (steht es ueber seiner Karte?) -- das zweite + deshalb, weil `clip-path` an der Karte ein ueberstehendes Band + abschneiden wuerde: Die Seite waere still in Ordnung, und der Text + im Band waere trotzdem weg. + ===================================================================== */ +console.log(""); +console.log("--- Die Baender auf 390 px ---"); + +await seite.setViewportSize({ width: 390, height: 844 }); +await seite.reload({ waitUntil: "networkidle" }); +await seite.waitForTimeout(1500); + +const schmal = await seite.evaluate(() => { + const karten = [...document.querySelectorAll(".t-leute .tperson")]; + const ueber = []; + for (const k of karten) { + const kr = k.getBoundingClientRect(); + for (const wahl of [".tperson__kopf", ".tperson__zahlen"]) { + const b = k.querySelector(wahl); + if (!b) continue; + const br = b.getBoundingClientRect(); + /* Ein halber Pixel Spiel: Die Fase rechnet mit Bruchteilen. */ + if (br.left < kr.left - 0.5 || br.right > kr.right + 0.5) { + ueber.push(`${wahl} ${Math.round(br.left - kr.left)}/${Math.round(br.right - kr.right)}`); + } + } + } + return { + karten: karten.length, + scrollBreite: document.documentElement.scrollWidth, + sichtbar: window.innerWidth, + ueber, + /* Bei einer Spalte darf keine Karte breiter sein als der Schirm. */ + breiteste: Math.round(Math.max(0, ...karten.map((k) => k.getBoundingClientRect().width))), + }; +}); + +ok(schmal.karten > 0, `${schmal.karten} Karten auch auf 390 px`); +ok(schmal.karten > 0 && schmal.scrollBreite <= schmal.sichtbar + 1, + `die Seite scrollt nicht waagerecht (${schmal.scrollBreite} von ${schmal.sichtbar})`); +ok(schmal.karten > 0 && schmal.ueber.length === 0, + `kein Band steht ueber seiner Karte (${schmal.ueber.join(", ") || "keines"})`); +ok(schmal.karten > 0 && schmal.breiteste <= schmal.sichtbar, + `die breiteste Karte passt (${schmal.breiteste} von ${schmal.sichtbar})`); + +ok(meckern.length === 0, + `der Browser meldet nichts (${meckern.slice(0, 2).join(" | ") || "still"})`); + +/* ---------- Ende ------------------------------------------------------ */ +await browser.close(); +await new Promise((r) => vorbau.close(r)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt manchmal */ } + +console.log(""); +console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}` + + ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-teamlage.js b/server/workspace-teamlage.js index 7c17040e..c4eeea59 100644 --- a/server/workspace-teamlage.js +++ b/server/workspace-teamlage.js @@ -77,30 +77,63 @@ const GESAMT = Object.fromEntries( * merkt es nicht -- eine vergessene Schranke faellt nur dem auf, der * hindurchgeht. */ /* ===================================================================== - DIE DREI STUFEN (Blueprint V3.0, Kapitel 4.1) + DIE FUENF STUFEN (Blueprint V3.0, Kapitel 4.1 -- erweitert 22.09.2026) - Probe -- in der Einarbeitung, wird eingeschaetzt. - Standard -- der Normalfall: moderiert, arbeitet eigene Aufgaben ab. - Senior -- kann zusaetzlich neue Probe-Modis einschaetzen, mehrere - Kategorien gleichzeitig tragen und die rechte Hand bei - Abwesenheit vertreten. + Probe -- in der Einarbeitung, wird begleitet. + Standard -- der Normalfall: moderiert, arbeitet eigene + Aufgaben ab. + Fortgeschritten-- kennt die Ablaeufe und braucht keinen Anstoss mehr. + Senior -- schaetzt neue Probe-Modis ein und traegt mehrere + Kategorien gleichzeitig. + Vertretung -- springt fuer die rechte Hand ein, wenn sie nicht + da ist. - SIE SIND EINE ARBEITSEINTEILUNG, KEINE RECHTEGRENZE -- jedenfalls - heute noch nicht. Das gehoert ausgesprochen, damit niemand sie - spaeter fuer eine haelt: Was ein Modi in dieser App darf, haengt an - der ROLLE. Die Stufe sagt, wo er im Team steht, und macht den - naechsten Schritt sichtbar. Die Rechte, die der Blueprint dort - nennt, liegen ausserdem groesstenteils gar nicht hier, sondern im - Stream (Timeout, Bann). + ES WAREN DREI, UND ZWEI DAVON WAREN KEINE EIGENE STUFE, SONDERN EIN + BUENDEL. Filipe am 22.09.2026: "und ich will das es ein zwei + kategorien mehr gibt und das soll geiler aussehen und jede seine + eigene farbe, stark erkennbar." + + Die beiden neuen sind nicht erfunden, sondern herausgeloest: + + (1) "Fortgeschritten" gibt es im Haus laengst -- der + Entwicklungs-Katalog nennt seine Erwartungsstufen + probe/standard/fort/senior (ENTWICKLUNG_ERWARTUNG in + workspace-entwicklungs-punkte.js). Genau diese Mitte fehlte + hier, und Filipe hatte sie dort am 17.09. selbst eingefordert + ("wieso ist da immer noch nicht perfektionniert wie bei den + anderen mit fortgeschritten und so"). Zwei verschiedene + Leitern fuer dieselben Menschen waren der eigentliche Fehler. + + (2) "Vertretung" stand bis heute IN der Beschreibung von Senior -- + "und die rechte Hand bei Abwesenheit vertreten". Das ist etwas + anderes als "kann einschaetzen": Einschaetzen koennen mehrere, + einspringen tut eine bestimmte Person. In einer Zeile gebuendelt + war es unsichtbar. + + SIE SIND WEITERHIN EINE ARBEITSEINTEILUNG, KEINE RECHTEGRENZE. Das + gehoert ausgesprochen, damit niemand sie fuer eine haelt: Was ein + Modi in dieser App darf, haengt an der ROLLE. Die Stufe sagt, wo er + im Team steht, und macht den naechsten Schritt sichtbar. Die Rechte, + die der Blueprint dort nennt, liegen ausserdem groesstenteils gar + nicht hier, sondern im Stream (Timeout, Bann). + + KEINE DATENBANKAENDERUNG NOETIG, und das ist kein Zufall: Die Spalte + `personen.stufe` ist absichtlich ein blankes TEXT ohne CHECK (siehe + den Kommentar in workspace.js). Dort steht woertlich, warum: "Kaeme + morgen eine vierte dazu, waere ein Tabellenneubau auf einer + Live-Datenbank ein hoher Preis fuer eine Beschriftung." Heute kamen + eine vierte und eine fuenfte, und der Preis war null. Die Reihenfolge ist die Steigerung -- sie steht hier einmal, damit keine Liste im Browser eine eigene erfindet. */ -export const STUFEN = ["probe", "standard", "senior"]; +export const STUFEN = ["probe", "standard", "fort", "senior", "vertretung"]; export const STUFEN_NAME = { probe: "Probe", standard: "Standard", + fort: "Fortgeschritten", senior: "Senior", + vertretung: "Vertretung", }; /** NULL heisst "Probe", nicht "unbekannt". @@ -121,6 +154,16 @@ function darfEingang(person) { return fuehrtTeamDogi(person); } +/** Wer eine Stufe hat -- und damit eine, die sich setzen laesst. + * + * NUR MODIS. Ein Manager oder Creator hat keine; die rechte Hand + * auch nicht -- sie ist eine Rolle, keine Stufe darin. Steht hier an + * EINER Stelle, weil zwei Stellen danach fragen: die Liste (damit + * die Knoepfe gar nicht erst erscheinen) und der Weg zum Setzen + * (damit niemand daran vorbeikommt). Zwei Fassungen waeren die, bei + * der die Oberflaeche etwas anbietet, das der Server ablehnt. */ +const hatStufe = (p) => p?.rolle === "modi"; + teamlageRouter.get("/workspace/api/teamlage", (req, res) => { try { const person = sitzungLesen(req); @@ -406,6 +449,24 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => { genau das soll sie: andere Reihenfolge, anderes Aussehen. Sie stand bisher nicht einmal in der Abfrage. */ rolle: m.rolle, + /* OB SICH EINE STUFE UEBERHAUPT SETZEN LAESST (22.09.2026). + + Filipe: „wenn ich auf diese sachen druecke werd ich auf + die startseite geschickt, scheck das bitte ab." + + Nachgestellt: Die Oberflaeche zeigte die drei Stufen- + Knoepfe an JEDER Karte -- auch an der der rechten Hand. + Die hat keine Stufe (sie ist eine Rolle, keine Stufe + darin), der Server antwortete mit 404, und der 404 warf + von der Seite (das steckte in holen.js und ist dort + behoben). + + Ein Knopf, der mit 404 antwortet, ist schlimmer als + keiner. Die Antwort auf „hat diese Person eine Stufe" + kommt deshalb von der Stelle, die es entscheidet -- und + aus derselben Funktion, die der Weg weiter unten + benutzt. */ + stufe_aenderbar: hatStufe(m), bild: m.bild ? `/workspace/api/steckbrief/bild/${m.bild}` : null, letzter_login: m.letzter_login, /* Die drei Felder aus Kapitel 4: Status, "dabei seit", Stufe. */ @@ -484,7 +545,7 @@ teamlageRouter.put("/workspace/api/teamlage/stufe", gleicheHerkunft, eine Rolle, keine Stufe darin. */ const ziel = db().prepare( "SELECT id, name, rolle, stufe FROM personen WHERE id = ?").get(personId); - if (!ziel || ziel.rolle !== "modi") { + if (!ziel || !hatStufe(ziel)) { return res.status(404).json({ fehler: "nicht_gefunden" }); } const vorher = STUFEN.includes(String(ziel.stufe || "")) ? String(ziel.stufe) : "probe"; diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index aa903e2f..44e5798c 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`. --> - - + + - +