Files
dogfather-universe/server/pruef-ampel.mjs
T
DogFatherGitandClaude Opus 5 d7cf598b00 "Heute" war zwei Stunden lang gestern -- und vier Fehler auf Handy und PC
Auftrag: "einen grossen Check machen, ob alles klappt, auf dem Handy und
PC." Dafuer ein neuer Rundgang (pruef-grosscheck.mjs), der stumpf ueber
alles geht: 19 Workspace-Seiten mal 4 Rollen mal 2 Bildschirmgroessen
plus 33 oeffentliche Seiten, zweimal. 208 Seiten, 50 774 Elemente.

Solche Rundgaenge finden andere Fehler als gezielte Pruefungen: nicht
den falsch gerechneten Wert, sondern die Seite, die bei genau einer
Rolle ueberlaeuft.

=== DER WICHTIGSTE FUND: "heute" war in UTC gerechnet ===

Der Check lief um 01:10 Uhr. Ortszeit war der 2. September, in UTC noch
der 1. -- und in diesem Fenster rechnete die Anwendung an ZWOELF Stellen
"heute" als toISOString(), also in UTC. Server und Benutzer stehen beide
auf Europe/Berlin.

Was das im Alltag bedeutete, jede Nacht zwischen 0 und 2 Uhr:
  * eine heute faellige Aufgabe galt noch nicht als faellig
  * eine um Mitternacht ueberfaellig gewordene erschien erst um 2 Uhr
  * der Filter "Heute faellig" zeigte den Vortag
  * Datumsfelder schlugen gestern vor
  * der Kalender begann seine Vorgabe einen Tag zu frueh

Also genau dann, wenn nach einem Stream gearbeitet wird.

kalender.js machte es die ganze Zeit RICHTIG -- samt Begruendung, warum
die ARITHMETIK trotzdem in UTC laufen muss (UTC-Mittag ueberlebt die
Zeitumstellung; wer lokal rechnet, verliert am 27. Oktober einen Tag).
Diese Trennung gilt jetzt ueberall, aus je einer Quelle:
  RECHNEN mit Datumsangaben  -> UTC-Mittag, unveraendert
  WELCHER TAG IST HEUTE      -> Ortszeit (heuteLokal/tagLokal im Server,
                                window.heuteLokal in kopf.js)

WIE ES AUFFIEL, und das ist die eigentliche Lehre: Zuerst schlugen zwei
Pruefungen fehl -- und die Ursache lag in IHNEN, sie rechneten selbst in
UTC (36 Stellen in 17 Dateien). Nach deren Reparatur schlugen sie WIEDER
fehl, und erst da zeigten sie auf die Anwendung. Wer beim ersten Mal
aufgehoert haette ("ist ja nur die Pruefung"), haette den echten Fehler
nie gesehen.

Nachtrag desselben Musters: pruef-uebersicht legte den Termin weiterhin
in UTC an, waehrend die Erwartung schon auf Ortszeit stand. Wer eine
Datumsrechnung umstellt, muss BEIDE Seiten umstellen -- die, die
schreibt, und die, die prueft.

=== VIER FEHLER AUF HANDY UND PC ===

1. Ein langer Creator-Name ("SpongBobSchwammKopf") schob die Startseite
   auf dem Handy um 48 Pixel aus dem Bild -- ein Wort ohne Trennstelle,
   und die Seite liess sich seitlich wegschieben. Trifft echte Namen:
   Creator heissen selten "Tim".

2. Die klebende Speicherleiste verdeckte auf dem Handy ein Textfeld.
   Beim Tippen sieht man die eigene Zeile nicht. Behoben mit
   scroll-margin-bottom (WCAG 2.2, 2.4.11 "Focus Not Obscured").

3./4. Zwei Beschriftungen waren mit 9,6 px (Uebersicht: "ueberfaellig",
   "dringend", "offen") und 9,9 px (Kalender: "heute") zu klein. Fuers
   Handy gab es laengst eine Ausnahme -- nur der Rechner war vergessen
   worden. Ausgerechnet die Woerter, die den Zahlen ihre Bedeutung geben.

=== WAS KEINE FEHLER WAREN ===

Der erste Durchgang meldete 19 Maengel, die keine waren. Alle einzeln im
Quelltext nachgeprueft und dem Rundgang beigebracht:
  * Kacheln und Kopfzeilen "abgeschnitten" -- das Wasserzeichen ragt
    ABSICHTLICH ueber den Rand (steht so im Quelltext)
  * "verdeckt: wahl2__echt" -- das echte <select> liegt absichtlich
    unsichtbar unter seinem Knopf
  * "zurueck-knopf__text abgeschnitten" -- das uebliche Muster fuer
    "nur fuer Vorleseprogramme"
  * drei "zu kleine" Verweise -- WCAG 2.5.8 nimmt Verweise im Fliesstext
    AUSDRUECKLICH aus. Eine Pruefung, die ihre eigene Messlatte nicht
    kennt, misst nichts.

Beim vierten Punkt haette ich fast an der falschen Stelle repariert.

Und statt die Sticky-Meldung abzuschalten (dann faende sie auch echte
Ueberdeckungen nie mehr), wurde sie GENAUER: Ueberdeckt etwas Klebendes
ein Eingabefeld, ist das nur in Ordnung, wenn das Feld genug
scroll-margin-bottom hat, um darunter hervorzukommen. Aus einer vagen
Meldung wird eine pruefbare Zusage.

Vier Gegenproben belegen, dass der Rundgang ueberhaupt etwas finden
kann: ein zu breites Element, ein winziger Knopf, ein winziger Verweis
AUSSERHALB eines Satzes und ein wirklich abgeschnittenes Wort werden
alle gemeldet. Ohne diesen Nachweis waere "alles in Ordnung" wertlos.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-02 01:51:28 +02:00

278 lines
14 KiB
JavaScript

/* Prueft Bewertung und Rueckmeldungen.
Der wichtigste Fall steht in der Mitte: Ein Creator darf sich NICHT
selbst bewerten. Koennte er es, stuende alles auf gruen und die
ganze Ampel waere wertlos. Und "noch verbessern" ohne Begruendung
muss abgelehnt werden -- eine Bewertung, mit der der Creator nichts
anfangen kann, ist nicht streng, sondern nur entmutigend. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-ampel-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4206";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
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");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4206";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 250_000).unref?.();
/* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des
Benutzers; toISOString() liefert dagegen UTC und damit zwischen
Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese
Pruefung fehl, ohne dass etwas kaputt war. */
const tagText = (d = new Date()) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001");
const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt);
d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von)
VALUES ('live','vorbereitung','Ton geprüft','Eine Minute aufnehmen.',?,'offen',?,?,?)`)
.run(tagText(), idTili, jetzt, idScout);
const eintragId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von)
VALUES ('live','vorbereitung','Fremder Eintrag','x',?,'offen',?,?,?)`)
.run(tagText(), idFremd, jetzt, idDogi);
const fremdEintrag = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.close();
const keks = async (rolle, code) => {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }) });
return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
};
const kDogi = await keks("admin", "CODE-DOGI-0001");
const kScout = await keks("scout", "CODE-SCOU-0001");
const kTili = await keks("creator", "CODE-ANNA-0001");
const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } });
const senden = (pfad, k, art, koerper) => ruf(pfad, k, {
method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) });
/* ---------- Bewerten ------------------------------------------------------ */
console.log("\n=== Bewerten darf nur die Betreuung");
{
/* DER WICHTIGSTE FALL. Koennte ein Creator sich selbst auf gruen
setzen, waere die ganze Ampel wertlos. */
const selbst = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kTili, "PUT",
{ stufe: "gut" });
ok(selbst.status === 403,
`ein Creator kann sich NICHT selbst bewerten (${selbst.status})`);
const f = await selbst.json();
ok(/Betreuung/.test(f.fehler || ""), `und erfaehrt, wer es kann ("${f.fehler}")`);
const durchBetreuer = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT",
{ stufe: "gut" });
ok(durchBetreuer.ok, `sein Betreuer kann es (${durchBetreuer.status})`);
const r = await durchBetreuer.json();
ok(r.ampel.stufe === "gut", `die Stufe steht (${r.ampel.stufe})`);
ok(r.ampel.von === idScout, `mit dem Namen des Bewertenden (${r.ampel.von})`);
ok(!!r.ampel.am, "und dem Zeitpunkt");
/* Ein Scout nicht bei einem fremden Creator. */
const fremd = await senden(`/workspace/api/eintrag/${fremdEintrag}/ampel`, kScout, "PUT",
{ stufe: "gut" });
ok(fremd.status === 404, `nicht bei einem fremden Creator (${fremd.status})`);
}
console.log("\n=== 'Noch verbessern' braucht einen Grund");
{
/* Eine Bewertung, mit der der Creator nichts anfangen kann, ist nicht
streng, sondern nur entmutigend. */
const ohne = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT",
{ stufe: "verbessern" });
ok(ohne.status === 400, `ohne Begruendung abgelehnt (${ohne.status})`);
const f = await ohne.json();
ok(/verbessert/.test(f.fehler || ""), `mit einer Begruendung, warum ("${f.fehler}")`);
const mit = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT",
{ stufe: "verbessern", begruendung: "Der Ton rauscht im Hintergrund." });
ok(mit.ok, `mit Begruendung geht es (${mit.status})`);
const r = await mit.json();
ok(r.ampel.begruendung === "Der Ton rauscht im Hintergrund.", "und sie wird gespeichert");
/* "gut" darf ohne Grund gesetzt werden -- da gibt es nichts zu
erklaeren. */
const gut = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT",
{ stufe: "gut" });
ok(gut.ok, `"Passt so" braucht keinen Grund (${gut.status})`);
}
/* ---------- Der Creator SIEHT die Bewertung ------------------------------- */
console.log("\n=== Der Creator sieht, wo er steht");
{
await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT",
{ stufe: "verbessern", begruendung: "Mikro naeher heranstellen." });
const a = await (await ruf(`/workspace/api/eintrag/${eintragId}/rueckmeldung`, kTili)).json();
ok(a.ampel?.stufe === "verbessern", `er sieht die Stufe (${a.ampel?.stufe})`);
ok(a.ampel?.begruendung === "Mikro naeher heranstellen.", "und den Grund");
ok(a.darf_bewerten === false, `aber darf sie nicht setzen (${a.darf_bewerten})`);
}
/* ---------- Nachrichten --------------------------------------------------- */
console.log("\n=== Nachrichten -- beide Seiten");
{
const vomCreator = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kTili, "POST",
{ text: "Ich habe ein anderes Mikro bestellt." });
ok(vomCreator.ok, `ein Creator DARF antworten (${vomCreator.status})`);
const vomBetreuer = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kScout, "POST",
{ text: "Gut, sag Bescheid wenn es da ist." });
ok(vomBetreuer.ok, `die Betreuung auch (${vomBetreuer.status})`);
const { nachrichten } = await vomBetreuer.json();
ok(nachrichten.length === 2, `beide stehen im Verlauf (${nachrichten.length})`);
ok(nachrichten[0].von_name === "Tili" && nachrichten[1].von_name === "Patrick",
"in der richtigen Reihenfolge und mit Namen");
const leer = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kTili, "POST",
{ text: " " });
ok(leer.status === 400, `eine leere Nachricht wird abgelehnt (${leer.status})`);
/* Nur die EIGENE laesst sich zuruecknehmen. Fremde zu loeschen hiesse,
ein Gespraech nachtraeglich umzuschreiben. */
const meine = nachrichten.find((n) => n.von === idTili);
const fremdeWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${meine.id}`,
kScout, { method: "DELETE" });
ok(fremdeWeg.status === 403,
`eine fremde Nachricht laesst sich nicht loeschen (${fremdeWeg.status})`);
const eigeneWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${meine.id}`,
kTili, { method: "DELETE" });
ok(eigeneWeg.ok, `die eigene schon (${eigeneWeg.status})`);
/* Auch die Leitung kann fremde Nachrichten nicht loeschen -- ein
Gespraech gehoert denen, die es gefuehrt haben. */
const rest = await (await ruf(`/workspace/api/eintrag/${eintragId}/rueckmeldung`, kDogi)).json();
const vonPatrick = rest.nachrichten.find((n) => n.von === idScout);
const dogiWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${vonPatrick.id}`,
kDogi, { method: "DELETE" });
ok(dogiWeg.status === 403,
`auch DogFather nicht (${dogiWeg.status})`);
}
/* ---------- Fremde Eintraege bleiben unsichtbar --------------------------- */
console.log("\n=== Fremdes bleibt fremd");
{
const a = await ruf(`/workspace/api/eintrag/${fremdEintrag}/rueckmeldung`, kTili);
ok(a.status === 404,
`ein Creator kommt nicht an einen fremden Eintrag -- und erfaehrt nicht, dass es ihn gibt (${a.status})`);
const b = await senden(`/workspace/api/eintrag/${fremdEintrag}/nachricht`, kTili, "POST",
{ text: "hallo" });
ok(b.status === 404, `und kann dort auch nichts schreiben (${b.status})`);
}
/* ---------- Sammelabfrage -------------------------------------------------- */
console.log("\n=== Sammelabfrage");
{
const a = await (await ruf(
`/workspace/api/eintrag/ampeln?ids=${eintragId},${fremdEintrag}`, kTili)).json();
ok(!!a.ampeln[eintragId], "der eigene Eintrag ist dabei");
ok(!a.ampeln[fremdEintrag], "der fremde nicht -- auch nicht in der Sammelabfrage");
}
/* ---------- Im Browser ----------------------------------------------------- */
console.log("\n=== Die Seite");
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
for (const [rolle, code, name, darfBewerten] of [
["scout", "CODE-SCOU-0001", "Patrick", true],
["creator", "CODE-ANNA-0001", "Tili", false],
]) {
const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
const konsole = [];
const seite = await kontext.newPage();
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.goto(BASIS + "/workspace/bereich.html?b=live", { waitUntil: "networkidle" });
await seite.waitForTimeout(1100);
const r = await seite.evaluate(() => ({
ampeln: document.querySelectorAll(".ampel").length,
stufe: document.querySelector(".ampel")?.dataset.stufe,
marke: document.querySelector(".ampel__marke")?.textContent,
grund: document.querySelector(".ampel__grund")?.textContent,
knopf: !!document.querySelector(".ampel .schritt"),
}));
ok(r.ampeln >= 1, `${name}: die Ampel steht an der Karte (${r.ampeln})`);
ok(r.stufe === "verbessern", ` mit der richtigen Stufe (${r.stufe})`);
ok(/verbessern/i.test(r.marke || ""), ` und lesbarem Text ("${r.marke}")`);
ok(/Mikro/.test(r.grund || ""), ` der Grund steht dabei ("${r.grund}")`);
/* Aufklappen: Wer bewerten darf, sieht die Knoepfe -- wer nicht, den
Hinweis. Einem Creator einen Knopf zu zeigen, der beim Klicken 403
sagt, waere schlechter als kein Knopf. */
await seite.click(".ampel .schritt");
await seite.waitForTimeout(600);
const auf = await seite.evaluate(() => ({
kasten: !!document.querySelector(".rueckmeldung"),
bewerten: document.querySelectorAll(".ampel__knopf").length,
hinweis: !!document.querySelector(".rueckmeldung__hinweis"),
nachrichten: document.querySelectorAll(".nachricht").length,
schreibfeld: !!document.querySelector(".nachricht-neu textarea"),
}));
ok(auf.kasten, " der Rueckmeldebereich klappt auf");
if (darfBewerten) {
ok(auf.bewerten === 2, ` ${name} sieht die Bewertungsknoepfe (${auf.bewerten})`);
ok(!auf.hinweis, " und keinen Hinweis, dass er nicht darf");
} else {
ok(auf.bewerten === 0, ` ${name} sieht KEINE Bewertungsknoepfe (${auf.bewerten})`);
ok(auf.hinweis, " sondern den Hinweis, wer bewertet");
}
ok(auf.schreibfeld, " aber beide koennen schreiben");
ok(auf.nachrichten >= 1, ` und sehen den Verlauf (${auf.nachrichten})`);
ok(konsole.length === 0, ` keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
if (rolle === "scout") await seite.screenshot({ path: "pruef-ampel.png", fullPage: false });
await kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung.");
process.exit(fehler ? 1 : 0);