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