Gestern Mittag gemessen: 39 `newContext`-Aufrufe nehmen ihre Breite
aus einer Variablen und setzen kein `hasTouch`. Ohne das meldet der
Browser `pointer: fine`, und KEINE Regel aus `@media (pointer:
coarse)` greift — dort stehen im ganzen Haus die 44-Pixel-
Beruehrziele, die ausgeblendeten Tastenkuerzel und die
eingeklappte Reiterleiste.
Was das anrichtet, war an pruef-breiten zu sehen: drei Befunde auf
320, 390 und 430 Pixeln, die mit dem Finger allesamt verschwanden —
Messfehler, keine Fehler.
37 DAVON HABEN IHN JETZT. Nur `hasTouch`, nicht `isMobile`: Gefragt
ist genau die eine Sache, um die es geht. `isMobile` waere eine
zweite Aenderung in derselben Zeile, und wenn danach etwas anders
aussieht, wuesste niemand, welche von beiden es war.
ZWEI BLEIBEN STEHEN, beide in pruef-grosscheck.mjs. Die Aenderung
dort waere ein Zweizeiler; sie zu pruefen hiesse, 206 Seiten ueber
vier Rollen laufen zu lassen, und das braucht Filipes Zusage. Eine
Aenderung, die ich nicht pruefen darf, liefere ich nicht aus.
Grundlinie in pruef-fingermass steht deshalb auf 2.
JEDE EINZELN NACHGELAUFEN — 37 Laeufe:
34 gruen, darunter pruef-handy 186, pruef-material 159,
pruef-start-ansicht 160, pruef-kalender 141, pruef-erwaehnung 129,
pruef-bewerbung-aufgaben 163, pruef-neue-seiten 109
3 mit Befunden, ALLE DREI VORBESTEHEND (Gegenprobe: alter Stand
derselben Datei, gleicher Lauf, gleiche Zahl):
pruef-browser 3 (WebKit startet auf diesem Rechner nicht)
pruef-chat-anhaenge 2
pruef-crew-wand-bild 3
EINE ZEITBOMBE GEFUNDEN UND ENTSCHAERFT
pruef-dabei-optik meldete „Haekchen: 0 -> 0" — aber nur, wenn vier
andere Pruefungen gleichzeitig liefen. Allein: „0 -> 1", gruen.
Dort stand `waitForTimeout(250)` mit der Begruendung „250 ms sind
reichlich ueber den 160" (der Dauer der Blende). Auf einem Rechner,
auf dem nebenher vier Browser messen, sind sie es nicht. Die
Pruefung war damit gruen, solange nichts anderes lief, und rot im
Gesamtlauf — also genau dann, wenn niemand sie einzeln nachstellen
kann.
Eine Wartezeit ist eine Annahme ueber den Rechner. Gewartet wird
jetzt auf das, worauf es ankommt: dass das Haekchen da ist. Laeuft
die Frist ab, faellt die Pruefung mit dem ECHTEN Wert um und nicht
mit einem Messfehler. Gegenprobe: unter derselben vierfachen Last,
die sie vorher rot gemacht hat, jetzt gruen.
UND DIE GRUNDLINIE WIEDER STRENG. Ich hatte sie kurz auf
„hoechstens" gestellt — damit haette ein Rueckgang stillschweigend
Platz fuer die naechste Suende gedeckt. Genau davor warnt der Kopf
derselben Datei bei der anderen Grundlinie, und ich habe es eine
Stunde spaeter selbst falsch gemacht.
Co-Authored-By: Claude Opus 5 <[email protected]>
311 lines
15 KiB
JavaScript
311 lines
15 KiB
JavaScript
/* =====================================================================
|
||
Prüfung: aussuchen ODER selbst eintragen (02.09.2026).
|
||
|
||
Wunsch: "ich will da auch sachen selber noch eintragen können, also
|
||
aussuchen und selbst eintragen." — an allen fünf Stellen mit
|
||
Personenauswahl.
|
||
|
||
Der gefährliche Teil ist NICHT, ob sich ein Name eintippen lässt.
|
||
Gefährlich ist, was danach mit dem Eintrag passiert: Er gehört zu
|
||
keiner Person mehr, und die halbe Anwendung sucht Einträge über
|
||
genau diese Verknüpfung. Ein frei benannter Eintrag könnte deshalb
|
||
lautlos verschwinden — aus der Liste, aus der Suche, aus der
|
||
Übersicht. Niemand bekäme eine Fehlermeldung; es wäre einfach nichts
|
||
mehr da.
|
||
|
||
Deshalb prüft diese Datei vor allem eins: DASS NICHTS VERSCHWINDET.
|
||
|
||
Dazu kommt die Wiederentdeckung eines Fehlers, der schon vorher da
|
||
war: Legte eine Managerin eine Aufgabe ohne Creator an, sah sie sie
|
||
selbst nicht mehr. Auch das steht hier — samt Gegenprobe.
|
||
===================================================================== */
|
||
import { mkdtempSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { heuteLokal, tagLokal } from "./helfer-zeit.mjs";
|
||
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-frei-"));
|
||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||
/* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026).
|
||
Ohne diese Zeile startet der eigene Server bei belegtem Port
|
||
STILL nicht -- und alles Folgende misst gegen einen fremden
|
||
Stand. Das ist der dritte Ausgang: belegt ist etwas anderes
|
||
als kaputt. */
|
||
const PORT = await eigenerPort(import.meta, "pruef-freie-namen");
|
||
process.env.PORT = `${PORT}`;
|
||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
||
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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 }); };
|
||
import { notbremse } from "./helfer-notbremse.mjs";
|
||
import { eigenerPort } from "./helfer-port.mjs";
|
||
await import("./index.js");
|
||
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
|
||
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
|
||
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
|
||
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
|
||
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
|
||
notbremse(480_000, "pruef-freie-namen");
|
||
await new Promise((r) => setTimeout(r, 700));
|
||
const BASIS = `http://127.0.0.1:${PORT}`;
|
||
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 240_000).unref?.();
|
||
|
||
let fehler = 0;
|
||
let geprueft = 0;
|
||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||
|
||
const { DatabaseSync } = await import("node:sqlite");
|
||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||
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 idCigdem = anlegen("Cigdem", "manager", "CODE-CIGDEM-01");
|
||
const idBen = anlegen("Ben", "scout", "CODE-BEN-00001");
|
||
const idTili = anlegen("Tili", "creator", "CODE-TILI-0001");
|
||
/* Cigdem betreut niemanden -- genau der Fall, in dem der alte Fehler
|
||
zuschlug. */
|
||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
|
||
.run(idTili, idBen, jetzt);
|
||
|
||
const zahl = (sql, ...w) => d.prepare(sql).get(...w)?.n ?? 0;
|
||
|
||
const { chromium } = await import(
|
||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
const browser = await chromium.launch();
|
||
|
||
async function anmelden(rolle, code, breite = 1440) {
|
||
const kontext = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
|
||
const seite = await kontext.newPage();
|
||
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 });
|
||
return seite;
|
||
}
|
||
|
||
const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => {
|
||
const a = await fetch(p, {
|
||
credentials: "same-origin",
|
||
...(o || {}),
|
||
headers: o?.koerper ? { "Content-Type": "application/json" } : (o?.kopf || undefined),
|
||
body: o?.koerper ? JSON.stringify(o.koerper) : undefined,
|
||
});
|
||
return { status: a.status, daten: await a.json().catch(() => ({})) };
|
||
}, [pfad, optionen]);
|
||
|
||
const seiteDogi = await anmelden("admin", "CODE-DOGI-0001");
|
||
const seiteCigdem = await anmelden("manager", "CODE-CIGDEM-01");
|
||
|
||
/* ===================================================================
|
||
TEIL 1 — Der alte Fehler: eine Aufgabe, die sich selbst versteckt
|
||
=================================================================== */
|
||
|
||
console.log("\n— Eine Aufgabe ohne Creator bleibt sichtbar —");
|
||
|
||
const ohne = await api(seiteCigdem, "/workspace/api/aufgaben", {
|
||
method: "POST",
|
||
koerper: { titel: "Ohne Zuordnung angelegt", prioritaet: "hoch" },
|
||
});
|
||
ok(ohne.status === 201, "Cigdem legt eine Aufgabe ohne Creator an");
|
||
|
||
const sichtCigdem = await api(seiteCigdem, "/workspace/api/aufgaben");
|
||
ok(sichtCigdem.daten.aufgaben?.some((a) => a.id === ohne.daten.id),
|
||
"und SIEHT sie danach auch – genau das ging vorher verloren");
|
||
|
||
/* GEGENPROBE: Es sieht sie nicht plötzlich jeder. Wäre die Regel zu
|
||
weit geraten, wäre die Zeile darüber ebenfalls grün — und niemand
|
||
hätte gemerkt, dass Cigdem jetzt fremde Aufgaben sieht. */
|
||
const seiteBen = await anmelden("scout", "CODE-BEN-00001");
|
||
const sichtBen = await api(seiteBen, "/workspace/api/aufgaben");
|
||
ok(!sichtBen.daten.aufgaben?.some((a) => a.id === ohne.daten.id),
|
||
"GEGENPROBE: Ben sieht Cigdems Aufgabe NICHT – die Regel ist nicht zu weit");
|
||
|
||
/* ===================================================================
|
||
TEIL 2 — Freie Namen: nichts darf verschwinden
|
||
=================================================================== */
|
||
|
||
console.log("\n— Aufgabe mit frei eingetragenem Namen —");
|
||
|
||
const frei = await api(seiteDogi, "/workspace/api/aufgaben", {
|
||
method: "POST",
|
||
koerper: {
|
||
titel: "Thumbnails von der Agentur", prioritaet: "mittel",
|
||
creator_extern: "Marke XY", verantwortlich_extern: "Agentur Müller",
|
||
},
|
||
});
|
||
ok(frei.status === 201, "eine Aufgabe mit zwei freien Namen lässt sich anlegen");
|
||
ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL AND creator_extern = 'Marke XY'",
|
||
frei.daten.id) === 1,
|
||
"der Name steht in der eigenen Spalte, die Verknüpfung bleibt leer");
|
||
|
||
const liste = await api(seiteDogi, "/workspace/api/aufgaben");
|
||
const eintrag = liste.daten.aufgaben.find((a) => a.id === frei.daten.id);
|
||
ok(!!eintrag, "die Aufgabe steht in der Liste – sie ist nicht verschwunden");
|
||
ok(eintrag?.creator_name === "Marke XY (extern)",
|
||
`der Name wird angezeigt und als extern gekennzeichnet ("${eintrag?.creator_name}")`);
|
||
ok(eintrag?.verantwortlich_name === "Agentur Müller (extern)",
|
||
`auch der Verantwortliche ("${eintrag?.verantwortlich_name}")`);
|
||
|
||
const suche = await api(seiteDogi, "/workspace/api/suche?q=Thumbnails");
|
||
const gefunden = JSON.stringify(suche.daten);
|
||
ok(gefunden.includes("Thumbnails von der Agentur"),
|
||
"die Suche findet sie – der häufigste Ort, an dem so etwas lautlos fehlt");
|
||
|
||
const uebersicht = await api(seiteDogi, "/workspace/api/uebersicht");
|
||
ok((uebersicht.daten.offen ?? 0) >= 1, "die Übersicht zählt sie mit");
|
||
|
||
console.log("\n— Termin mit frei eingetragenem Namen —");
|
||
|
||
/* ORTSZEIT, nicht UTC -- Begruendung in helfer-zeit.mjs. */
|
||
const morgen = tagLokal(1);
|
||
const termin = await api(seiteDogi, "/workspace/api/termine", {
|
||
method: "POST",
|
||
koerper: {
|
||
titel: "Call mit der Agentur", art: "call", beginn: morgen + "T14:00",
|
||
dauer_min: 30, teilnehmer_extern: "Agentur Müller",
|
||
},
|
||
});
|
||
ok(termin.status === 201, "ein Termin mit freiem Namen lässt sich anlegen");
|
||
|
||
const termine = await api(seiteDogi, `/workspace/api/termine?von=${morgen}&tage=2`);
|
||
const t = termine.daten.termine?.find((x) => x.id === termin.daten.id);
|
||
ok(!!t, "er steht im Kalender");
|
||
ok(t?.teilnehmer_name === "Agentur Müller (extern)",
|
||
`mit Namen und Kennzeichnung ("${t?.teilnehmer_name}")`);
|
||
|
||
console.log("\n— Wiederholung mit frei eingetragenem Namen —");
|
||
|
||
const serie = await api(seiteDogi, "/workspace/api/serien", {
|
||
method: "POST",
|
||
koerper: {
|
||
titel: "Wochen-Call Agentur", art: "call", takt: "woechentlich",
|
||
start_tag: morgen, uhrzeit: "11:00", teilnehmer_extern: "Agentur Müller",
|
||
},
|
||
});
|
||
ok(serie.status === 201, "eine Wiederholung mit freiem Namen lässt sich anlegen");
|
||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND teilnehmer_extern = 'Agentur Müller'",
|
||
serie.daten.id) > 20,
|
||
"der Name landet auf JEDER erzeugten Ausprägung, nicht nur auf der Regel");
|
||
|
||
console.log("\n— Bereichseintrag mit frei eingetragenem Namen —");
|
||
|
||
const eintragB = await api(seiteCigdem, "/workspace/api/bereich/live", {
|
||
method: "POST",
|
||
koerper: { art: "vorbereitung", titel: "Technikpartner meldet Ausfall", datum: morgen,
|
||
creator_extern: "Technikpartner" },
|
||
});
|
||
ok(eintragB.status === 201, "ein Bereichseintrag mit freiem Namen lässt sich anlegen"
|
||
+ (eintragB.status === 201 ? "" : " · " + (eintragB.daten.fehler || eintragB.status)));
|
||
|
||
const bereich = await api(seiteCigdem, "/workspace/api/bereich/live");
|
||
const be = bereich.daten.eintraege?.find((x) => x.id === eintragB.daten.id);
|
||
ok(!!be, "Cigdem sieht ihn danach – auch ohne zugeteilten Creator");
|
||
ok(be?.creator_name === "Technikpartner (extern)",
|
||
`mit Namen und Kennzeichnung ("${be?.creator_name}")`);
|
||
|
||
console.log("\n— Grenzen —");
|
||
|
||
for (const [koerper, was, erwartet] of [
|
||
[{ titel: "Langer Name", creator_extern: "A".repeat(200) }, "zu langer Name", 400],
|
||
[{ titel: "Beides gesetzt", creator_id: idTili, creator_extern: "Gast" }, "Person UND Name → Name gewinnt", 201],
|
||
]) {
|
||
const a = await api(seiteDogi, "/workspace/api/aufgaben", { method: "POST", koerper });
|
||
ok(a.status === erwartet, `${was} → ${a.status}${a.status === erwartet ? "" : " · " + (a.daten.fehler || "")}`);
|
||
if (a.status === 201) {
|
||
ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL", a.daten.id) === 1,
|
||
" und die Verknüpfung ist dabei wirklich leer – nie beides zugleich");
|
||
}
|
||
}
|
||
|
||
/* ===================================================================
|
||
TEIL 3 — Die Bedienung im Browser
|
||
=================================================================== */
|
||
|
||
console.log("\n— Suchen und eintragen im echten Browser —");
|
||
|
||
await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
|
||
await seiteDogi.waitForTimeout(800);
|
||
await seiteDogi.click("#neu-oeffnen").catch(() => {});
|
||
await seiteDogi.waitForTimeout(400);
|
||
|
||
/* Das Auswahlfeld ist durch wahl.js ein Knopf -- angeklickt wird der. */
|
||
const knopf = seiteDogi.locator('#f-creator').locator('xpath=following-sibling::button').first();
|
||
await knopf.click();
|
||
await seiteDogi.waitForTimeout(300);
|
||
|
||
const hatSuche = await seiteDogi.locator(".wahl2__suche").count();
|
||
ok(hatSuche === 1, "die geöffnete Liste hat ein Suchfeld");
|
||
|
||
await seiteDogi.fill(".wahl2__suche", "Til");
|
||
await seiteDogi.waitForTimeout(200);
|
||
const nachFilter = await seiteDogi.evaluate(() =>
|
||
[...document.querySelectorAll(".wahl2__eintrag")].map((e) => e.textContent.trim()));
|
||
ok(nachFilter.some((t) => t.includes("Tili")), "Tippen filtert die Liste auf Tili");
|
||
ok(!nachFilter.some((t) => t.includes("Cigdem")), "und blendet die anderen aus");
|
||
|
||
await seiteDogi.fill(".wahl2__suche", "Agentur Müller");
|
||
await seiteDogi.waitForTimeout(200);
|
||
const eigen = await seiteDogi.locator(".wahl2__eintrag--eigen").count();
|
||
ok(eigen === 1, "ein unbekannter Name wird als eigener Eintrag angeboten");
|
||
await seiteDogi.locator(".wahl2__eintrag--eigen").click();
|
||
await seiteDogi.waitForTimeout(200);
|
||
|
||
const zustand = await seiteDogi.evaluate(() => ({
|
||
extern: document.getElementById("f-creator").dataset.extern,
|
||
wert: document.getElementById("f-creator").value,
|
||
knopftext: document.querySelector('#f-creator + .wahl2__knopf .wahl2__text')?.textContent
|
||
|| document.querySelector('.wahl2 .wahl2__text')?.textContent,
|
||
hinweis: document.querySelector(".wahl2__hinweis")?.textContent || "",
|
||
}));
|
||
ok(zustand.extern === "Agentur Müller", `der Name steht am Feld ("${zustand.extern}")`);
|
||
ok(zustand.wert === "", "und die Personenauswahl ist dabei leer – nie beides");
|
||
ok(/Agentur Müller/.test(zustand.knopftext || ""), "der Knopf zeigt den Namen an");
|
||
ok(zustand.hinweis.length > 20,
|
||
"unter dem Feld steht, was ein freier Name bedeutet");
|
||
|
||
/* GEGENPROBE: Ohne Erlaubnis darf NICHTS erfunden werden. Die
|
||
Säulenauswahl im Content ist genau so ein Feld -- eine Säule gehört
|
||
immer einem echten Creator, der Server lehnte alles andere ab. */
|
||
await seiteDogi.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" });
|
||
await seiteDogi.waitForTimeout(800);
|
||
const freiFlags = await seiteDogi.evaluate(() => ({
|
||
creator: document.getElementById("f-creator")?.dataset.frei,
|
||
saeule: document.getElementById("s-creator")?.dataset.frei,
|
||
}));
|
||
ok(freiFlags.creator === "ja", "im Content darf man beim Creator selbst eintragen");
|
||
ok(freiFlags.saeule === "nein",
|
||
"GEGENPROBE: bei der Säulen-Zuordnung ausdrücklich NICHT – dort gäbe es der Server nicht her");
|
||
|
||
await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
|
||
await seiteDogi.waitForTimeout(600);
|
||
await seiteDogi.click("#neu-oeffnen").catch(() => {});
|
||
await seiteDogi.waitForTimeout(300);
|
||
await seiteDogi.screenshot({ path: "pruef-freie-namen.png", fullPage: false });
|
||
|
||
/* ===================================================================
|
||
Abschluss — die Anzahl zählt mit
|
||
=================================================================== */
|
||
const ERWARTET = 31;
|
||
console.log("");
|
||
ok(geprueft >= ERWARTET,
|
||
`es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`);
|
||
|
||
await browser.close();
|
||
d.close();
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||
console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`);
|
||
process.exit(fehler ? 1 : 0);
|