Team-Lage: fuenf Stufen mit eigener Farbe, Karten neu gebaut, 404 wirft nicht mehr hinaus

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 <[email protected]>
This commit is contained in:
2026-09-22 19:46:47 +02:00
co-authored by Claude Opus 5
parent 3b76a9aab5
commit ba0e326585
54 changed files with 2146 additions and 672 deletions
+306
View File
@@ -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);
+139 -4
View File
@@ -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--<wert>), die ganze Karte ([data-stufe="<wert>"]) und den
Knopf zum Setzen (--st am [data-stufe="<wert>"]). 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
+393
View File
@@ -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);
+76 -15
View File
@@ -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";