Aus der Probe wird ein Zugang - und die Seite sagt nicht mehr "Agentur"

Die drei offenen Punkte aus Abschnitt 9 des Plans, plus Filipes zweite
Ansage: "auf dieser seite soll nichts stehen von agentur, diese seite
hier ist nur fuer mich meine modis und meine community."

1. PROBE -> ZUGANG (ein Knopf statt eines Satzes)

Auf der Stufe "Probe" stand bisher "Zugang in Personen & Zugaenge
anlegen, Rolle Modi" - und wer das vergass, hatte eine Karte auf "Im
Team" und keinen Menschen darin. Jetzt haengt das Anlegen am Schritt
selbst.

Ueber DENSELBEN Weg wie in "Personen & Zugaenge", nicht ueber einen
zweiten: POST /workspace/api/verwaltung/personen, mit ihrer
Rechtepruefung, ihrem Protokolleintrag und ihrem genau einmal
angezeigten Code. Der Talente-Server legt selbst keinen einzigen Zugang
an, und die Pruefung zaehlt nach, dass es bei einem Weg bleibt.

Der Code steht im gleichen Kasten wie dort - die Gestaltung ist aus
personen.css nach start.css umgezogen, weil talente.html sie sonst nicht
laedt. Ein Geheimnis sieht im ganzen Haus gleich aus. Er steht
ausserhalb der Liste, sonst waere er in dem Moment weg, in dem er
entsteht: wenn die Karte auf "Im Team" springt.

Die rechte Hand bekommt an dieser Stelle KEINEN Knopf, sondern einen
Satz. Ein Knopf, der ihr jedes Mal "darfst du nicht" antwortet, waere
schlechter als gar keiner - er verspricht etwas.

2. DIE EIGENE KARTE (Abschnitt "Deine Karte")

Wer beschrieben wird, darf es lesen - aber erst, wenn ALLE gesetzt
haben (sonst waere die erste Einschaetzung eine Vorgabe fuer die
zweite), und ohne Namen und ohne Anlasstext. Gemessen in beide
Richtungen: vorher nicht sichtbar, nachher sichtbar.

3. DER VORLAGENTEXT

Gebaut aus den angeklickten Merkmalen, in einem Feld zum Aendern, nicht
zum Abschicken. Ohne Merkmale steht auch keines drin.

4. "GILT FUER" SAGT DER SERVER

In bereich.js stand woertlich "Agentur" - richtig auf der
Agenturadresse, falsch auf jeder anderen. Jetzt liefert der Server
`gehoert` ("Der Treff" / "Team Dogi" / "Agentur"); gemessen mit einem
einzigen Menschen auf zwei Adressen.

PRUEFUNGEN: treff 58 -> 66, nachwuchs 69 -> 109, neue-seiten 70 -> 93.
Die Katalogseiten werden jetzt auch mit den Augen der rechten Hand
angesehen - ohne das waere "Deine Karte" nie auf einem Bildschirm
gewesen. Und pruef-neue-seiten wartet nicht mehr 900 ms, sondern bis
sich der Text nicht mehr aendert: Die Karte stand da und wurde
trotzdem als fehlend gemeldet, weil zu frueh gelesen wurde.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 23:13:50 +02:00
co-authored by Claude Opus 5
parent 44e5ace109
commit bba3642664
41 changed files with 1447 additions and 420 deletions
+191 -21
View File
@@ -121,19 +121,72 @@ await roh("/workspace/api/treff/massnahme", { cookie: kDogi },
/* ETWAS AUF DEN KATALOGSEITEN. Ohne eine Person im Team ist die
Entwicklungsseite leer, ohne einen Kandidaten der Trichter -- und
eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen. */
eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen.
UND ZWAR AUF MEHREREN STUFEN. Der Trichter zeigt je Stufe einen
ANDEREN naechsten Schritt; steht nur ein Kandidat auf der ersten,
bleiben die Knoepfe der anderen vier ungesehen. Genau dort sitzen
seit dem 11.09. die zwei neuen: der Vorlagentext auf "Beobachtet"
und "Ins Team holen · Zugang anlegen" auf "Probe". */
/* EINMAL ANKLOPFEN, DAMIT ES DIE TABELLEN GIBT. Sie entstehen beim
ersten angemeldeten Aufruf des Bereichs, nicht beim Start -- ein
INSERT davor liefe gegen eine Tabelle, die es noch nicht gibt. */
await roh("/workspace/api/talente/lage", { cookie: kDogi });
const kat = await import("./workspace-entwicklungs-punkte.js");
const talKat = await import("./workspace-talent-punkte.js");
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
let idHand = null;
{
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-MODI-0001", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES ('Frida','modi',?,?,?,?,1,?)")
.run(hash, salt, 32768,
createHmac("sha256", schluessel).update("CODE-MODI-0001").digest("hex"), jetzt);
dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)"
+ " VALUES ('talente','ruhig','Kessi aus dem Chat','Faellt seit Wochen auf.',?,?,?,?)")
.run(jetzt.slice(0, 10), jetzt, idDogi, "Im Stream am Dienstag");
const person = (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");
dd.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 dd.prepare("SELECT last_insert_rowid() AS id").get().id;
};
person("Frida", "modi", "CODE-MODI-0001");
idHand = person("Rieke", "hand", "CODE-HAND-0001");
const talent = (titel, text) => {
dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)"
+ " VALUES ('talente','ruhig',?,?,?,?,?,?)")
.run(titel, text, jetzt.slice(0, 10), jetzt, idDogi, "Im Stream am Dienstag");
return dd.prepare("SELECT last_insert_rowid() AS id").get().id;
};
talent("Kessi aus dem Chat", "Faellt seit Wochen auf.");
const idBeobachtet = talent("Malin aus dem Chat", "Haelt den Chat ruhig.");
const idProbe = talent("Tino aus dem Chat", "Laeuft seit vier Wochen mit.");
const stufe = dd.prepare(
"INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id) VALUES (?,?,?,?)");
stufe.run(idBeobachtet, "beobachtet", jetzt, idDogi);
stufe.run(idProbe, "probe", jetzt, idDogi);
/* MERKMALE fuer den Vorlagentext -- er wird daraus gebaut. Ohne sie
stuende dort der Satz fuer "noch nichts beobachtet", und die neue
Gestaltung waere zwar sichtbar, aber nicht in ihrer laengsten
Fassung. Gemessen werden soll die laengste. */
const merk = dd.prepare(
"INSERT INTO talent_merkmal (eintrag_id, schluessel, von_id, am) VALUES (?,?,?,?)");
for (const m of talKat.TALENT_ALLE.slice(0, 3)) {
merk.run(idBeobachtet, m.schluessel, idDogi, jetzt);
}
/* DIE EIGENE KARTE DER RECHTEN HAND. Sie oeffnet sich erst, wenn
jeder Beurteiler gesetzt hat -- ueber sie urteilt nur DogFather,
also reicht eine Zeile je Punkt. Sechs gesetzte und der Rest offen:
So stehen beide Zustaende nebeneinander auf dem Bildschirm. */
const stand = dd.prepare(
"INSERT INTO entwicklung_stand (person_id, schluessel, von_id, stufe, anlass, am)"
+ " VALUES (?,?,?,?,?,?)");
const stufen = ["laeuft", "waechst", "laeuft", "hakt", "laeuft", "waechst"];
kat.ENTWICKLUNG_FREMD.slice(0, 6).forEach((p, i) => {
stand.run(idHand, p.schluessel, idDogi, stufen[i],
stufen[i] === "hakt" ? "Zweimal zu spaet zur Schicht." : null, jetzt);
});
}
dd.close();
@@ -299,7 +352,16 @@ const UEBERBREIT = `(() => {
return { schiebt, raus };
})()`;
async function ansehen(name, pfad, code, breite, hoehe) {
/* MIT EINEM FERTIGEN KEKS STATT ueber die Anmeldemaske (11.09.2026).
Die rechte Hand meldet sich an der CREW-WAND an, und die haengt am
Host-Kopf -- den kann ein Browser bei einer Navigation nicht setzen.
Ueber die Maske auf 127.0.0.1 kommt sie also gar nicht herein.
Das ist kein Umgehen einer Sicherung: Angemeldet wird ueber denselben
Weg wie sonst (/api/anmelden), nur die Maske bleibt aussen vor. Was
die Seite danach zeigt, entscheidet weiterhin der Server. */
async function ansehen(name, pfad, code, breite, hoehe, keks = null) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
const seite = await kontext.newPage();
const konsole = [];
@@ -308,14 +370,39 @@ async function ansehen(name, pfad, code, breite, hoehe) {
});
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
await seite.goto(`${BASIS}/workspace/`, { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="admin"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
if (keks) {
await kontext.addCookies(keks.split("; ").filter(Boolean).map((teil) => {
const i = teil.indexOf("=");
return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" };
}));
} else {
await seite.goto(`${BASIS}/workspace/`, { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="admin"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
}
await seite.goto(`${BASIS}/workspace/${pfad}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
/* WARTEN, BIS SICH NICHTS MEHR AENDERT -- NICHT 900 MILLISEKUNDEN.
Gemessen am 11.09.2026: Die eigene Karte der rechten Hand stand
nachweislich auf dem Bildschirm (versteckt: false, Antwort 200),
aber der Text war schon vorher gelesen. Die Pruefung meldete
"steht nicht da" fuer etwas, das dastand -- der schlimmste
Fehlalarm, weil man danach am richtigen Code sucht.
Eine groessere Zahl waere derselbe Fehler mit einer anderen Zahl.
Die Seite laedt ihre Abschnitte nacheinander; wie lange das
dauert, haengt an ihrer Anzahl. Deshalb eine Regel statt einer
Rechnung: gelesen wird, wenn der Text dreimal hintereinander
derselbe war. Die Frist darueber ist nur die Notbremse. */
await seite.waitForFunction(() => {
const t = document.querySelector("main")?.innerText || "";
if (window.__standStill === t) { window.__ruhig = (window.__ruhig || 0) + 1; }
else { window.__standStill = t; window.__ruhig = 0; }
return (window.__ruhig || 0) >= 3;
}, null, { timeout: 12000, polling: 200 }).catch(() => {});
const text = await seite.evaluate(() => (document.querySelector("main")?.innerText || "").trim());
const punkte = await seite.evaluate(() => (document.querySelector("main")?.innerText || "")
@@ -323,12 +410,32 @@ async function ansehen(name, pfad, code, breite, hoehe) {
const kontrast = await seite.evaluate(KONTRAST);
const ueberlapp = await seite.evaluate(UEBERLAPP);
const breit = await seite.evaluate(UEBERBREIT);
/* DIE EIGENE KARTE IST DER EINZIGE ABSCHNITT, DER SICH SELBST
AUFDECKT. Bleibt er zu, sieht die Seite vollkommen normal aus --
deshalb wird hier nicht nur gelesen, was dasteht, sondern gefragt,
was der Server dazu sagt. */
const karte = await seite.evaluate(async () => {
const e = document.getElementById("karte-block");
if (!e) return { da: false };
let antwort = null;
try {
const a = await fetch("/workspace/api/entwicklung/meine-karte", { credentials: "same-origin" });
antwort = { status: a.status, hat: (await a.json()).hat_karte };
} catch (f) { antwort = { status: -1, fehler: String(f) }; }
return { da: true, versteckt: e.hidden, antwort };
});
await seite.screenshot({ path: join(BILDER, `${name}-${breite}.png`), fullPage: true });
await kontext.close();
return { text, punkte, kontrast, ueberlapp, breit, konsole };
return { text, punkte, kontrast, ueberlapp, breit, konsole, karte };
}
for (const [name, pfad] of [
const gesehen = {}; // was auf jedem Bildschirm tatsaechlich stand
const anm3 = await roh("/workspace/api/anmelden", {}, { rolle: "hand", code: "CODE-HAND-0001" });
const kHand = [].concat(anm3.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
ok(!!kHand, "die rechte Hand ist angemeldet -- ohne sie bliebe ihre eigene Karte ungesehen");
for (const [name, pfad, keks] of [
["regeln", "treff-regeln.html"],
["rechte", "rechte.html"],
["moderation", "treff-moderation.html"],
@@ -338,11 +445,17 @@ for (const [name, pfad] of [
findet andere Fehler. */
["entwicklung", "entwicklung.html"],
["talente", "talente.html"],
/* UND EINMAL DIESELBE SEITE MIT ANDEREN AUGEN. "Deine Karte" gibt es
nur fuer die, ueber die geurteilt wird -- DogFather sieht sie nie.
Ohne diese Zeile waere der ganze Abschnitt nie auf einem Bildschirm
gewesen. */
["entwicklung-hand", "entwicklung.html", kHand],
]) {
melde("");
melde(`=== ${pfad} ===`);
for (const [breite, hoehe] of [[1440, 1000], [390, 844]]) {
const r = await ansehen(name, pfad, "CODE-DOGI-0001", breite, hoehe);
const r = await ansehen(name, pfad, "CODE-DOGI-0001", breite, hoehe, keks);
if (breite === 1440) { gesehen[name] = r.text; gesehen[name + ":karte"] = r.karte; }
const wo = `${breite} px`;
ok(r.text.length > 400, `${wo}: die Seite ist gefüllt (${r.text.length} Zeichen)`);
@@ -374,6 +487,63 @@ for (const [name, pfad] of [
}
}
/* =======================================================================
WAR DAS NEUE UEBERHAUPT AUF DEM BILDSCHIRM?
Die Messungen darueber sagen "nichts liegt uebereinander" und "alles
lesbar" -- und beides ist auch dann wahr, wenn das Gesuchte gar nicht
da war. Eine leere Seite erfuellt jede dieser Bedingungen.
Deshalb hier die Gegenfrage, und zwar in beide Richtungen: Das Neue
MUSS dort stehen, wo es hingehoert, und darf NICHT dort stehen, wo
es nicht hingehoert. Nur die zweite Haelfte beweist, dass die erste
etwas heisst.
======================================================================= */
melde("");
melde("=== Stand das Neue auch wirklich da? ===");
{
const t = gesehen.talente || "";
ok(/Vorlagentext/.test(t),
"Talente: der Knopf fuer den Vorlagentext stand auf der Stufe „Beobachtet“");
ok(/Ins Team holen/.test(t),
"Talente: „Ins Team holen · Zugang anlegen“ stand auf der Stufe „Probe“");
/* GEGENPROBE: Der Vorlagentext gehoert auf GENAU EINE Stufe. Stuende
er auf jeder, waere er auf vier von fuenf eine Ablenkung -- und die
Zeile darueber waere trotzdem gruen. */
/* ZEILENWEISE gezaehlt und nicht als Wort: "Der Vorlagentext liegt
bereit" ist der Satz des naechsten Schritts, der Knopf steht
allein auf seiner Zeile. Als Wort gezaehlt waeren es zwei, und die
Zeile haette einen Fehler gemeldet, den es nicht gibt. */
const knoepfe = (wort) => t.split("\n").filter((z) => z.trim() === wort).length;
ok(knoepfe("Vorlagentext") === 1,
`und zwar genau einmal, nicht bei jedem Kandidaten (${knoepfe("Vorlagentext")})`);
ok(t.split("Ins Team holen").length - 1 === 1,
`der Zugangsknopf ebenso (${t.split("Ins Team holen").length - 1})`);
const hand = gesehen["entwicklung-hand"] || "";
const dogi = gesehen.entwicklung || "";
/* WENN ES SCHIEFGEHT, SOLL DIE MELDUNG SAGEN, WAS STATTDESSEN DA WAR.
"nicht gefunden" allein laesst offen, ob die Karte fehlt oder die
ganze Seite eine andere war. */
const obenLinks = (hand.split("\n").map((z) => z.trim()).filter(Boolean)[0] || "(leer)");
/* OHNE RUECKSICHT AUF GROSS UND KLEIN: innerText liefert, was der
Browser ZEIGT -- und .e-abschnitt ist in Grossbuchstaben gesetzt.
Im Quelltext steht "Deine Karte", auf dem Bildschirm "DEINE
KARTE". Eine Suche mit Ruecksicht darauf meldet "steht nicht da"
fuer etwas, das dasteht. */
ok(/Deine Karte/i.test(hand),
`Entwicklung: die rechte Hand sieht ihre eigene Karte (${JSON.stringify(gesehen["entwicklung-hand:karte"])} / oben: ${obenLinks})`);
ok(!/Deine Karte/i.test(dogi),
"und DogFather sieht sie nicht -- ueber ihn urteilt hier niemand");
ok(hand !== dogi && hand.length > 400,
`zwei verschiedene Bildschirme (${dogi.length} / ${hand.length} Zeichen) -- der Keks hat also gewirkt`);
/* UND KEIN NAME EINES BEURTEILERS. Was der Server nicht ausliefert,
kann die Seite nicht zeigen -- aber genau das ist die Behauptung,
und Behauptungen gehoeren gemessen. */
ok(!/Filipe/.test(hand),
"und in ihrer Karte steht kein Name dessen, der geurteilt hat");
}
await browser.close();
melde("");
melde(`Bilder liegen in ${BILDER}`);