diff --git a/server/mess-regie.mjs b/server/mess-regie.mjs
new file mode 100644
index 00000000..f6c5d526
--- /dev/null
+++ b/server/mess-regie.mjs
@@ -0,0 +1,501 @@
+/* =====================================================================
+ DER PLATZ HINTER DEM PULT (08.10.2026)
+
+ Zwei Dinge, die Filipe am 08.10.2026 gemeldet hat, und beide lassen
+ sich NUR in einem echten Browser messen:
+
+ 1. „in der regie will ich auch video hinzufuegen koennen fuer
+ andere tage und uhrzeiten, gerade geht das nicht. wie so ein
+ kalender wo vanvan und dogfather videos eintragen und
+ vorbereiten koennen."
+
+ 2. „wenn ich texte eingebe oder videos und kurz was anderes mache
+ und es nicht gespeichert hab loescht es sich von selbst. es
+ soll bleiben bis ich fertig bin und speichern druecke. oder
+ aus der seite gehe aber nicht solange wie ich noch da bin."
+
+ ---------------------------------------------------------------------
+ WARUM DER ZWEITE PUNKT HIER UND NICHT IN EINER PRUEFUNG STEHT
+
+ Er handelt von einem Zustand, den es auf dem Server gar nicht gibt:
+ getippter, noch nicht abgeschickter Text. Eine Schnittstellenpruefung
+ kann ihn nicht sehen. Gemessen wird deshalb der Vorgang selbst --
+ tippen, ein Neuzeichnen ausloesen, nachsehen, ob es noch dasteht.
+
+ DAS NEUZEICHNEN WIRD ECHT AUSGELOEST und nicht nachgeahmt: Die
+ Messung schickt eine Aenderung von einer ZWEITEN Sitzung (VanVan),
+ wie es an einem Sendeabend auch passiert. Ein `zeichnen()` von Hand
+ aufzurufen wuerde beweisen, dass die Funktion funktioniert -- nicht,
+ dass der Fall eintritt.
+
+ NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
+ Zertifikat -- und am Ende ist alles weg.
+
+ DREI AUSGAENGE: 0 in Ordnung · 1 ein Befund · 3 konnte nicht nachsehen
+
+ Aufrufen mit: node server/mess-regie.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { request } from "node:http";
+import { createServer as httpsServer } from "node:https";
+import { zertifikatBauen } from "./helfer-openssl.mjs";
+import { notbremse } from "./helfer-notbremse.mjs";
+import { eigenerPort } from "./helfer-port.mjs";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const ordner = mkdtempSync(join(tmpdir(), "mess-regie-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const PORT = await eigenerPort(import.meta);
+const HTTPS_PORT = await eigenerPort(import.meta, "mess-regie (HTTPS)", 1);
+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, "mess-regie");
+await new Promise((r) => setTimeout(r, 1000));
+
+const CREW = "crew.dogfather-universe.com";
+
+/* ---------- Leute ------------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const kenn = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kenn, new Date().toISOString());
+const ALT = new Date(Date.now() - 200 * 86400000).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,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", kenn).update(code).digest("hex"), ALT);
+}
+const CODES = { admin: "CODE-M00-000001", hand: "CODE-M01-000001" };
+anlegen("DogFather", "admin", CODES.admin);
+anlegen("VanVan", "hand", CODES.hand);
+d.close();
+
+function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
+ return new Promise((fertig, schief) => {
+ const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad, method,
+ headers: {
+ Host: CREW, Origin: `https://${CREW}`,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (s) => teile.push(s));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
+ fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+async function anmelden(wer) {
+ const a = await roh("/workspace/api/anmelden", {
+ method: "POST", rumpf: { rolle: wer, code: CODES[wer], alter_ok: true },
+ });
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+const k = { admin: await anmelden("admin"), hand: await anmelden("hand") };
+
+/* ---------- Zaehlwerk --------------------------------------------------- */
+let fehler = 0;
+let geprueft = 0;
+const ok = (b, t) => {
+ geprueft++;
+ console.log((b ? " ok " : " FEHL ") + t);
+ if (!b) fehler++;
+};
+function nichtNachsehbar(grund) {
+ console.log("\n -- NICHT NACHSEHBAR");
+ console.log(` ${grund}`);
+ console.log(" Das ist KEIN Befund am Code.\n");
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(3);
+}
+
+/* ---------- Der Vorbau -------------------------------------------------- */
+const schluesselDatei = join(ordner, "p.key");
+const zertDatei = join(ordner, "p.crt");
+try { zertifikatBauen(schluesselDatei, zertDatei, CREW); }
+catch (f) { nichtNachsehbar(`Kein Zertifikat zu bauen: ${f?.message}`); }
+const vorbau = httpsServer({
+ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
+}, (von, nach) => {
+ const weiter = request({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
+ }, (antwort) => {
+ nach.writeHead(antwort.statusCode || 502, antwort.headers);
+ antwort.pipe(nach);
+ });
+ weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
+ von.pipe(weiter);
+});
+await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+const BASIS = `https://${CREW}:${HTTPS_PORT}`;
+
+let pw = null;
+try {
+ pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+} catch (f) { nichtNachsehbar(`Playwright ist nicht da: ${f?.message}`); }
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
+ "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
+});
+
+async function alsWer(wer, breite = 1440, hoehe = 980) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe },
+ hasTouch: breite <= 860,
+ ignoreHTTPSErrors: true,
+ permissions: ["camera", "microphone"],
+ });
+ await kontext.addCookies((k[wer] || "").split("; ").filter(Boolean).map((z) => {
+ const [n, ...r] = z.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+ await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1200);
+ return { kontext, seite, konsole };
+}
+
+/** Die Regie aufklappen und auf ein Register gehen. */
+async function regieAuf(seite, tafel) {
+ await seite.evaluate(() => {
+ const auf = document.getElementById("reiter-auf");
+ if (auf && auf.getAttribute("aria-expanded") !== "true") auf.click();
+ });
+ await seite.waitForTimeout(150);
+ await seite.click(`#reiter-${tafel}`);
+ await seite.waitForTimeout(250);
+}
+
+async function bild(seite, name) {
+ await seite.screenshot({ path: join(HIER, `mess-regie-${name}.png`) });
+ console.log(` Bild: server/mess-regie-${name}.png`);
+}
+
+/* =====================================================================
+ 1. DER KALENDER
+ ===================================================================== */
+console.log("\n=== 1. Der Kalender ===");
+
+/* Zwei Videos mit Termin, eines ohne -- beide Faelle muessen sich im
+ Raster unterscheiden lassen. Die Termine werden RELATIV gerechnet:
+ Ein festes Datum waere eine Zeitbombe, die stimmt, bis der Kalender
+ sie einholt. */
+const INZWEITAGEN = new Date();
+INZWEITAGEN.setDate(INZWEITAGEN.getDate() + 2);
+INZWEITAGEN.setHours(20, 30, 0, 0);
+
+{
+ const a = await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://youtu.be/9bZkp7q19f0", wann: INZWEITAGEN.toISOString() },
+ });
+ ok(a.code === 200, `ein Video laesst sich GLEICH mit Termin anhaengen (${a.code})`);
+ const z = (a.j?.liste || []).find((x) => x.video === "9bZkp7q19f0");
+ ok(!!z && !!z.wann, `und der Termin steht daran (${z?.wann || "leer"})`);
+
+ const zweit = new Date(INZWEITAGEN);
+ zweit.setHours(22, 0, 0, 0);
+ await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://www.youtube.com/watch?v=kJQP7kiw5Fk", wann: zweit.toISOString() },
+ });
+ await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://www.youtube.com/watch?v=aqz-KE-bpKQ" },
+ });
+
+ /* UND EIN UNSINNIGES DATUM WIRD ABGEWIESEN -- mit einem eigenen
+ Wort, nicht mit „video_unklar". */
+ const b = await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://youtu.be/dQw4w9WgXcQ", wann: "irgendwann mal" },
+ });
+ ok(b.code === 400 && b.j?.fehler === "termin_unklar",
+ `ein unsinniges Datum wird abgewiesen (${b.code} ${b.j?.fehler})`);
+}
+
+{
+ const chef = await alsWer("admin");
+ await regieAuf(chef.seite, "liste");
+
+ /* ---- Die Umschaltung ---- */
+ const vorher = await chef.seite.evaluate(() => ({
+ liste: !document.getElementById("warteliste").hidden,
+ kalender: !document.getElementById("plan-kalender").hidden,
+ }));
+ ok(vorher.liste && !vorher.kalender, "zuerst steht die Reihenfolge da");
+
+ await chef.seite.click("#plan-sicht-kalender");
+ await chef.seite.waitForTimeout(250);
+ const nachher = await chef.seite.evaluate(() => ({
+ liste: !document.getElementById("warteliste").hidden,
+ kalender: !document.getElementById("plan-kalender").hidden,
+ monat: document.getElementById("plan-monat").textContent.trim(),
+ zellen: document.querySelectorAll("#plan-raster .plan-zelle:not(.plan-zelle--leer)").length,
+ voll: [...document.querySelectorAll("#plan-raster .plan-zelle[data-voll=\"ja\"]")]
+ .map((z) => z.dataset.tag),
+ heute: document.querySelectorAll("#plan-raster .plan-zelle[data-heute=\"ja\"]").length,
+ }));
+ ok(!nachher.liste && nachher.kalender, "der Knopf schaltet auf den Kalender");
+ ok(nachher.zellen >= 28 && nachher.zellen <= 31,
+ `der Monat hat so viele Tage, wie er hat (${nachher.zellen})`);
+ ok(/^[A-ZÄÖÜ][a-zäöü]+ \d{4}$/.test(nachher.monat),
+ `und er steht ausgeschrieben da (${nachher.monat})`);
+ ok(nachher.heute === 1, `heute ist genau einmal markiert (${nachher.heute})`);
+
+ /* ==== BEIDE TERMINE LIEGEN AUF DEMSELBEN TAG ==================
+ Deshalb darf GENAU EINE Zelle gefuellt sein -- zwei waeren ein
+ Zeichen dafuer, dass in UTC statt in Ortszeit gruppiert wird,
+ und genau dieser Fehler verschiebt eine Sendung um 00:30 auf
+ den Vortag. */
+ ok(nachher.voll.length === 1,
+ `zwei Termine am selben Tag ergeben EINE volle Zelle (${nachher.voll.length})`);
+
+ const erwartet = `${INZWEITAGEN.getFullYear()}-`
+ + `${String(INZWEITAGEN.getMonth() + 1).padStart(2, "0")}-`
+ + `${String(INZWEITAGEN.getDate()).padStart(2, "0")}`;
+ ok(nachher.voll[0] === erwartet,
+ `und zwar die richtige (${nachher.voll[0]} statt ${erwartet})`);
+
+ const anzahl = await chef.seite.textContent(
+ `#plan-raster .plan-zelle[data-tag="${erwartet}"] .plan-zelle__anzahl`);
+ ok(anzahl?.trim() === "2", `die Zelle zeigt die ANZAHL und nicht nur einen Punkt (${anzahl})`);
+ await bild(chef.seite, "1-kalender");
+
+ /* ---- Einen Tag antippen ---- */
+ await chef.seite.click(`#plan-raster .plan-zelle[data-tag="${erwartet}"]`);
+ await chef.seite.waitForTimeout(200);
+ const tag = await chef.seite.evaluate(() => ({
+ kopf: document.querySelector("#plan-tag .plan-tag__kopf")?.textContent.trim() || "",
+ eintraege: document.querySelectorAll("#plan-tag .plan-eintrag").length,
+ zeiten: [...document.querySelectorAll("#plan-tag .plan-eintrag__zeit")]
+ .map((z) => z.textContent.trim()),
+ }));
+ ok(tag.eintraege === 2, `der Tag zeigt beide Videos (${tag.eintraege})`);
+ ok(tag.zeiten[0] === "20:30 Uhr" && tag.zeiten[1] === "22:00 Uhr",
+ `nach Uhrzeit sortiert (${tag.zeiten.join(" / ")})`);
+ /* Vor dem Bild in die Schublade rollen: Sie ist hoeher als das
+ Fenster, und ein Bildschirmfoto vom oberen Rand zeigt genau das
+ nicht, worum es hier geht. */
+ await chef.seite.evaluate(() => {
+ document.getElementById("plan-tag")?.scrollIntoView({ block: "center" });
+ });
+ await chef.seite.waitForTimeout(250);
+ await bild(chef.seite, "2-tag");
+
+ /* ---- „Fuer diesen Tag anhaengen" fuellt das Feld ---- */
+ await chef.seite.click(".plan-tag__anhaengen");
+ await chef.seite.waitForTimeout(150);
+ const vorbelegt = await chef.seite.inputValue("#l-wann");
+ ok(vorbelegt === `${erwartet}T20:00`,
+ `der Knopf schlaegt den Abend vor, statt ihn zu erzwingen (${vorbelegt})`);
+ const wo = await chef.seite.evaluate(() => document.activeElement?.id);
+ ok(wo === "l-neu", `und setzt den Finger in die Adresse (${wo})`);
+
+ /* ---- Und ein Monat weiter ---- */
+ const jetztMonat = nachher.monat;
+ await chef.seite.click("#plan-vor");
+ await chef.seite.waitForTimeout(200);
+ const weiter = await chef.seite.textContent("#plan-monat");
+ ok(weiter?.trim() !== jetztMonat, `ein Monat vor wechselt (${jetztMonat} -> ${weiter?.trim()})`);
+ await chef.seite.click("#plan-heute");
+ await chef.seite.waitForTimeout(200);
+ const zurueck = await chef.seite.textContent("#plan-monat");
+ ok(zurueck?.trim() === jetztMonat, `und „Heute" bringt zurueck (${zurueck?.trim()})`);
+
+ ok(chef.konsole.length === 0, "nichts in der Konsole"
+ + (chef.konsole.length ? " -> " + chef.konsole.slice(0, 3).join(" | ") : ""));
+ await chef.kontext.close();
+}
+
+/* =====================================================================
+ 2. GETIPPTES ÜBERLEBT DAS NEUZEICHNEN
+
+ Der eigentliche Befund. Gemessen wird der Vorgang, den Filipe
+ beschrieben hat: tippen, den Finger woanders hin, etwas passiert --
+ und nachsehen, ob der Text noch dasteht.
+ ===================================================================== */
+console.log("\n=== 2. Getipptes ueberlebt das Neuzeichnen ===");
+{
+ const chef = await alsWer("admin");
+ await regieAuf(chef.seite, "sendung");
+
+ await chef.seite.fill("#p-titel", "Mein halber Satz");
+ /* ==== UND DANN WOANDERS HIN ================================
+ Genau das ist Filipes Fall: „und kurz was anderes mache". Der
+ alte Schutz hing am Fokus und griff ab hier nicht mehr. */
+ await chef.seite.click("#p-video");
+ await chef.seite.waitForTimeout(100);
+
+ const markiert = await chef.seite.getAttribute("#p-titel", "data-ungespeichert");
+ ok(markiert === "ja", `das Feld sagt, dass es ungespeichert ist (${markiert})`);
+
+ /* ==== DAS NEUZEICHNEN WIRD ECHT AUSGELOEST ==================
+ VanVan aendert etwas; der Rundruf kommt beim Host an und zeichnet
+ die Regie neu. Ein `zeichnen()` von Hand wuerde beweisen, dass
+ die Funktion laeuft -- nicht, dass der Fall eintritt. */
+ await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.hand, rumpf: { video: "https://youtu.be/ZZ5LpwO-An4" },
+ });
+
+ /* ==== AUF DAS EREIGNIS WARTEN, NICHT AUF DIE UHR ===============
+ Der erste Entwurf wartete 1200 ms und zaehlte dann. Die Zahl
+ stand auf 3 statt 4 -- das Neuzeichnen war also noch gar nicht
+ passiert, und die Zeile darunter („der Text steht noch da") war
+ damit wertlos: Er stand da, weil NICHTS ihn angefasst hatte.
+
+ Das ist der gefaehrlichste Messfehler ueberhaupt, weil er GRUEN
+ wird. Jetzt wird gewartet, bis VanVans Video wirklich
+ angekommen ist -- und erst dann gefragt, ob der getippte Text
+ ueberlebt hat. Scheitert das Warten, ist es ein Fehler und kein
+ Ueberspringen. */
+ let gezeichnet = true;
+ try {
+ await chef.seite.waitForFunction(
+ () => document.querySelectorAll("#warteliste .warte-zeile").length === 4,
+ null, { timeout: 10000 });
+ } catch { gezeichnet = false; }
+ ok(gezeichnet,
+ "VanVans Video kommt an -- es wurde also wirklich neu gezeichnet"
+ + (gezeichnet ? "" : " (nach 10 s nicht)"));
+
+ const steht = await chef.seite.inputValue("#p-titel");
+ ok(steht === "Mein halber Satz",
+ `und der getippte Text steht immer noch da ("${steht}")`);
+
+ /* ---- Speichern macht es wieder sauber ---- */
+ await chef.seite.click("#p-sichern");
+ await chef.seite.waitForTimeout(900);
+ const sauber = await chef.seite.getAttribute("#p-titel", "data-ungespeichert");
+ ok(sauber === "nein", `nach dem Speichern ist die Marke weg (${sauber})`);
+ const nachSpeichern = await chef.seite.inputValue("#p-titel");
+ ok(nachSpeichern === "Mein halber Satz", `und der Text steht weiter da ("${nachSpeichern}")`);
+ await bild(chef.seite, "3-getippt");
+
+ /* ---- Und in einer LISTE, die bei jedem Takt neu gebaut wird ----
+ Das ist der haertere Fall: Die Zeile selbst entsteht neu, das
+ Feld ist danach ein anderes Element. */
+ await regieAuf(chef.seite, "liste");
+ await chef.seite.evaluate(() => {
+ document.getElementById("plan-sicht-reihe").click();
+ });
+ await chef.seite.waitForTimeout(200);
+ const felder = await chef.seite.evaluate(() =>
+ document.querySelectorAll("#warteliste .warte-zeile__wann").length);
+ ok(felder >= 3, `die Warteschlange hat Terminfelder (${felder})`);
+
+ await chef.seite.evaluate(() => {
+ const f = document.querySelectorAll("#warteliste .warte-zeile__wann")[2];
+ f.value = "2027-03-05T19:45";
+ f.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await chef.seite.waitForTimeout(100);
+
+ await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.hand, rumpf: { video: "https://youtu.be/L_jWHffIx5E" },
+ });
+ await chef.seite.waitForTimeout(1200);
+
+ const inListe = await chef.seite.evaluate(() => {
+ const f = [...document.querySelectorAll("#warteliste .warte-zeile__wann")]
+ .find((x) => x.dataset.ungespeichert === "ja");
+ return f ? { wert: f.value, schluessel: f.dataset.schluessel } : null;
+ });
+ ok(inListe?.wert === "2027-03-05T19:45",
+ `auch in einer neu gebauten Liste bleibt es stehen (${inListe?.wert})`);
+ ok(/^warte-wann-\d+$/.test(inListe?.schluessel || ""),
+ `und haengt an der Kennung der Zeile, nicht an ihrer Nummer (${inListe?.schluessel})`);
+
+ ok(chef.konsole.length === 0, "nichts in der Konsole"
+ + (chef.konsole.length ? " -> " + chef.konsole.slice(0, 3).join(" | ") : ""));
+ await chef.kontext.close();
+}
+
+/* =====================================================================
+ 3. GEGENPROBEN
+ ===================================================================== */
+console.log("\n=== 3. Gegenproben ===");
+{
+ const chef = await alsWer("admin");
+ await regieAuf(chef.seite, "sendung");
+
+ /* ==== OHNE DAS GEDAECHTNIS MUSS DER TEXT VERSCHWINDEN =========
+
+ Sonst bewiese Abschnitt 2 nur, dass irgendetwas stehen bleibt.
+
+ DER ERSTE ENTWURF HAT DAS GEGENTEIL GEMESSEN: Er machte
+ `Getippt.binde` zu einer LEEREN Funktion -- damit schreibt
+ niemand mehr in das Feld, und der Text bleibt erst recht
+ stehen. „Ohne das Gedaechtnis" heisst nicht „ohne Schreiben",
+ sondern: wieder so wie frueher, naemlich stumpf
+ `feld.value = wert`. Genau das wird hier eingesetzt. */
+ await chef.seite.evaluate(() => {
+ window.Getippt.binde = (feld, schluessel, vomServer) => {
+ feld.value = vomServer === null || vomServer === undefined ? "" : String(vomServer);
+ };
+ window.Getippt.vergissAlles();
+ });
+ await chef.seite.fill("#p-titel", "Dieser Satz geht verloren");
+ await chef.seite.click("#p-video");
+ await roh("/workspace/api/reaktion", {
+ method: "PUT", keks: k.hand, rumpf: { titel: "Von VanVan" },
+ });
+ let kam = true;
+ try {
+ await chef.seite.waitForFunction(
+ () => document.getElementById("p-titel").value === "Von VanVan",
+ null, { timeout: 10000 });
+ } catch { kam = false; }
+ const weg = await chef.seite.inputValue("#p-titel");
+ ok(kam && weg === "Von VanVan",
+ `Gegenprobe: nach der alten Bauart ist der getippte Satz weg ("${weg}")`);
+
+ await chef.kontext.close();
+}
+
+/* =====================================================================
+ SCHLUSS
+ ===================================================================== */
+await browser.close();
+await new Promise((r) => vorbau.close(r));
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+console.log(`\n${geprueft} Messungen, ${fehler} Fehler`);
+if (geprueft < 20) {
+ console.log(`NICHT NACHSEHBAR: nur ${geprueft} Messungen gelaufen, erwartet mindestens 20.`);
+ process.exit(3);
+}
+if (fehler) { console.log("NICHT IN ORDNUNG"); process.exit(1); }
+console.log("ALLES IN ORDNUNG");
+process.exit(0);
diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs
index 89419683..6078a4c9 100644
--- a/server/pruef-reaktion.mjs
+++ b/server/pruef-reaktion.mjs
@@ -3691,6 +3691,140 @@ melde("=== 21. Das Regie-Tor ===");
}
}
+/* =======================================================================
+ 22. DER SENDEPLAN (08.10.2026)
+
+ Filipe: „in der regie will ich auch video hinzufuegen koennen fuer
+ andere tage und uhrzeiten, gerade geht das nicht. wie so ein kalender
+ wo vanvan und dogfather videos eintragen und vorbereiten koennen."
+
+ WAS HIER GEPRUEFT WIRD UND WAS NICHT: Hier steht, was die
+ Schnittstelle antwortet. Ob der Kalender im Browser richtig
+ gruppiert -- und ob getippter Text ein Neuzeichnen ueberlebt --
+ misst `mess-regie.mjs`; das laesst sich ohne Browser gar nicht
+ fragen.
+ ======================================================================= */
+melde("");
+melde("=== 22. Der Sendeplan ===");
+{
+ const dbP = new DatabaseSync(process.env.WORKSPACE_DB);
+ dbP.prepare("DELETE FROM reaktion_liste").run();
+
+ /* RELATIV GERECHNET und nicht mit einem festen Datum: Ein fester
+ Termin stimmt, bis der Kalender ihn einholt -- und dann wird die
+ Pruefung rot, ohne dass am Programm etwas ist. */
+ const inDreiTagen = new Date(Date.now() + 3 * 86400000);
+ inDreiTagen.setHours(20, 30, 0, 0);
+
+ {
+ const a = await roh(CREW, "/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://youtu.be/9bZkp7q19f0", wann: inDreiTagen.toISOString() },
+ });
+ ok(a.code === 200, `ein Video laesst sich GLEICH mit Termin anhaengen (${a.code})`);
+ const z = (a.j?.liste || []).find((x) => x.video === "9bZkp7q19f0");
+ ok(!!z?.wann, `der Termin steht sofort daran (${z?.wann || "leer"})`);
+ ok(z?.wann === inDreiTagen.toISOString(),
+ "und zwar auf die Minute der, der geschickt wurde");
+ }
+
+ /* OHNE TERMIN BLEIBT DER NORMALFALL. Ein Pflichtfeld haette aus
+ einer Vorbereitung eine Verabredung gemacht. */
+ {
+ const a = await roh(CREW, "/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://www.youtube.com/watch?v=kJQP7kiw5Fk" },
+ });
+ const z = (a.j?.liste || []).find((x) => x.video === "kJQP7kiw5Fk");
+ ok(a.code === 200 && z?.wann === "",
+ `ohne Termin geht weiterhin (${a.code}, wann="${z?.wann}")`);
+ }
+
+ /* EIN UNSINNIGES DATUM BEKOMMT SEIN EIGENES WORT und nicht
+ „video_unklar" -- sonst sucht jemand den Fehler in der Adresse. */
+ {
+ const a = await roh(CREW, "/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin,
+ rumpf: { video: "https://youtu.be/aqz-KE-bpKQ", wann: "naechsten Donnerstag" },
+ });
+ ok(a.code === 400 && a.j?.fehler === "termin_unklar",
+ `ein unsinniges Datum wird abgewiesen (${a.code} ${a.j?.fehler})`);
+ const n = dbP.prepare("SELECT COUNT(*) AS n FROM reaktion_liste WHERE video = ?")
+ .get("aqz-KE-bpKQ").n;
+ ok(n === 0, `und das Video landet GAR NICHT in der Liste (${n})`);
+ }
+
+ /* DIESELBE PRUEFUNG AUF BEIDEN WEGEN. Haette nur das Anhaengen sie
+ und das nachtraegliche Aendern nicht, kaeme derselbe Unsinn ueber
+ die zweite Tuer doch herein. */
+ {
+ const liste = (await roh(CREW, "/workspace/api/reaktion", { keks: k.admin })).j?.liste || [];
+ const id = liste[0]?.id;
+ const a = await roh(CREW, `/workspace/api/reaktion/liste/${id}/wann`, {
+ method: "PUT", keks: k.admin, rumpf: { wann: "irgendwann" },
+ });
+ ok(a.code === 400 && a.j?.fehler === "termin_unklar",
+ `auch beim nachtraeglichen Aendern (${a.code} ${a.j?.fehler})`);
+ const b = await roh(CREW, `/workspace/api/reaktion/liste/${id}/wann`, {
+ method: "PUT", keks: k.admin, rumpf: { wann: "" },
+ });
+ ok(b.code === 200 && (b.j?.liste || []).find((x) => x.id === id)?.wann === "",
+ "Gegenprobe: leer nimmt den Termin weg, statt abgelehnt zu werden");
+ }
+
+ /* WER NICHT FUEHRT, TRAEGT AUCH KEINEN TERMIN EIN. */
+ {
+ const a = await roh(CREW, "/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.modi,
+ rumpf: { video: "https://youtu.be/L_jWHffIx5E", wann: inDreiTagen.toISOString() },
+ });
+ ok(a.code === 403, `ein Modi kann nichts einplanen (${a.code})`);
+ }
+
+ /* ---- Was auf der Seite steht ---- */
+ {
+ const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
+ const js = readFileSync(
+ join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
+ const getippt = readFileSync(
+ join(HIER, "..", "workspace", "assets", "js", "getippt.js"), "utf8");
+ ok(/id="l-wann"/.test(html), "das Anhaengen hat ein Terminfeld");
+ ok(/id="plan-kalender"/.test(html) && /id="plan-raster"/.test(html),
+ "und es gibt ein Monatsraster");
+ ok(/data-sicht="reihe"/.test(html) && /data-sicht="kalender"/.test(html),
+ "zwei Sichten auf DIESELBE Liste, keine zweite Liste");
+ /* ==== GEMESSEN WIRD DER EINHAENGER, NICHT DER FLIESSTEXT =====
+ Der erste Entwurf verglich `html.indexOf("getippt.js")` mit
+ `html.indexOf("reaktion.js")` -- und wurde rot, obwohl die
+ Reihenfolge stimmt: Direkt ueber dem Einhaenger steht ein
+ Kommentar, in dem das Wort „reaktion.js" vorkommt, und den fand
+ `indexOf` zuerst. Eine Pruefung, die Prosa liest, misst Prosa. */
+ const einhaenger = (datei) => html.indexOf(`
+
-
-
-
-
-
-
+
+
+
+
+
+