From ab204e96f9c7723ef2212f273c69f127dea9e562 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 8 Oct 2026 15:16:54 +0200 Subject: [PATCH] Der Sendeplan: ein Kalender in der Regie -- und getippter Text bleibt stehen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filipe: „in der regie will ich auch video hinzufügen können für andere tage und uhrzeiten, gerade geht das nicht. wie so ein kalender wo vanvan und dogfather videos eintragen und vorbereiten können. und wenn ich texte eingebe oder videos und kurz was anderes mache und es nicht gespeichert hab löscht es sich von selbst. es soll bleiben bist ich fertig bin und speichern drücke. oder aus der seite gehe aber nicht solange wie ich noch da bin." Haus: Team Dogi (crew.dogfather-universe.com). Die Agentur ist nicht berührt -- pruef-haus-trennung 107/0. ====================================================================== 1. GETIPPTER TEXT ÜBERLEBT DAS NEUZEICHNEN ====================================================================== WARUM ER VERSCHWUNDEN IST: Die Regie zeichnet sich bei jedem Ereignis neu -- jemand kommt dazu, der Chat bekommt eine Zeile, das Video springt eine Sekunde weiter. Dabei werden Listen von Grund auf gebaut (`kasten.textContent = ''`), und jedes Eingabefeld darin entsteht neu, gefüllt mit dem Wert vom Server. Es gab einen Notbehelf: `if (document.activeElement !== feld)`, also „überschreib es nicht, solange der Finger drinsteht". Filipe beschreibt den Fall, in dem der zu kurz greift, wörtlich: „und kurz was anderes mache". Wer tippt und dann woanders hinklickt, verliert den Fokus -- und beim nächsten Takt auch seinen Text. An einem Sendeabend sind das hunderte Gelegenheiten. DIE FRAGE IST NICHT „HAT ES DEN FOKUS", SONDERN „IST ES SAUBER". Steht im Feld genau das, was der Server zuletzt geliefert hat, darf eine neue Auskunft es ersetzen -- sie ist aktueller. Weicht es ab, hat ein Mensch etwas hineingetan, das noch nirgends steht; dann gewinnt der Mensch. Das hat zwei Nebenwirkungen, und beide sind gewollt: * Wer NICHTS angefasst hat, sieht Änderungen des anderen sofort. * Wer etwas angefasst hat, verliert es auch dann nicht, wenn dieselbe Stelle gerade von jemand anderem geändert wurde. EIN NEUER BAUSTEIN, NICHT ZWANZIG EINZELFÄLLE: `assets/js/getippt.js`. Ein Feld wird an seinen Serverwert gebunden, meldet beim Tippen, was offen ist, und wird nach dem Speichern wieder sauber. Benutzt an zehn Stellen der Regie: Titel, Video, Vorschaubild, Startzeit, zweites Video, die Termine der Warteschlange, Name und Gruß an jeder Spende, die fertigen Sätze. DER SCHLÜSSEL TRÄGT DIE KENNUNG, NICHT DIE POSITION (`warte-wann-7`, nicht „Zeile 3"). Sonst landete der getippte Termin in der falschen Zeile, sobald jemand eine nach oben schiebt. IM SPEICHER UND NICHT IN `localStorage` -- genau wie Filipe es gesagt hat: „oder aus der seite gehe". Ein halber Satz, der drei Tage später wieder auftaucht, ist keine Hilfe; man weiß dann nicht mehr, ob er gelten soll. UND MAN SIEHT ES: Ein ungespeichertes Feld bekommt eine bernsteine Kante links -- dieselbe Farbe wie ein überfälliger Termin. Keine eingefärbte Fläche: Die schreit, eine Kante sagt dasselbe und lässt den Text in Ruhe. Ohne diese Marke wäre die Reparatur halb -- der Text stünde da, sähe aus wie gespeichert, und niemand drückte mehr auf „Sichern". ====================================================================== 2. DER KALENDER ====================================================================== Es ging bisher nur in zwei Schritten: anhängen, dann in der Zeile den Termin setzen. Wer einen Abend für nächste Woche plant, macht das je Video zweimal -- und der zweite Handgriff ist der, den man vergisst. Jetzt steht das Terminfeld neben der Adresse, und `POST /liste` nimmt `wann` entgegen. ZWEI SICHTEN AUF DIESELBE LISTE, keine zweite Tabelle: Reihenfolge -> „was kommt als Nächstes" (mitten in einer Sendung) Kalender -> „wann läuft was" (beim Vorbereiten) Eine zweite Tabelle „Sendeplan" wären zwei Antworten auf denselben Bestand, und spätestens beim ersten Verschieben liefen sie auseinander. EIN MONATSRASTER UND KEINE WOCHE. Wer einen Abend plant, denkt in „nächsten Donnerstag", nicht in „in sechs Tagen". Tage mit etwas darin tragen die ANZAHL und nicht nur einen Punkt -- „da ist etwas" und „da sind vier" sind zwei verschiedene Auskünfte. Heute trägt einen Ring, was vorbei ist und noch dasteht, wird warm markiert. Das × an einem Eintrag nimmt NUR DEN TERMIN weg; das Video bleibt in der Warteschlange. Gelöscht wird in der Reihenfolge-Sicht, und zwar nur dort, damit es beim Umplanen nicht aus Versehen passiert. EINE TERMINPRÜFUNG, ZWEI WEGE: `terminLesen()` im Server -- das Anhängen und das nachträgliche Ändern fragen dieselbe Funktion. Zwei Abschriften wären zwei Antworten auf „ist das ein Datum", und die zweite wäre die, die beim nächsten Umbau stehen bleibt. Leer ist gültig und bleibt der Normalfall. ====================================================================== 3. EIN FUND, DER NEBENBEI HERAUSFIEL ====================================================================== `mess-regie` wollte nur ein Neuzeichnen auslösen -- VanVan hängt ein Video an, DogFather hat die Regie offen -- und wartete vergeblich. Nach zehn Sekunden war es bei ihm immer noch nicht da. `empfaenger()` schickte an die Zusehenden, die Gäste und den Host DER LAUFENDEN Sendung. Solange nichts läuft, gibt es keinen `host_id` und niemanden, der „dabei" ist -- der Rundruf ging also an NIEMANDEN. Ausgerechnet beim Vorbereiten, also genau dann, wenn die beiden zusammenarbeiten. Was das angerichtet hätte: VanVan trägt den Donnerstag ein, DogFather sieht seinen alten Stand, trägt daneben etwas ein -- und einer von beiden wundert sich später, wo sein Eintrag geblieben ist. Das fällt erst auf, wenn es weh tut. Die Host-Rollen sind jetzt immer Empfänger, ABGELEITET aus `HOST_ROLLEN` statt aufgezählt. DIE FRAGE DAHINTER IST ALLGEMEIN: Ist die Menge im RUHEZUSTAND leer? Ein Verteiler, der nur im Betrieb gefüllt ist, schweigt genau in der Vorbereitungsphase -- und die ist die einzige, in der zwei Leute gleichzeitig an derselben Sache arbeiten. Dieselbe Form wie `every()` auf einem leeren Feld: Beides sagt Ja, weil nichts da ist. ====================================================================== 4. DREI FEHLER IN DEN EIGENEN MESSUNGEN, BEHOBEN ====================================================================== (a) GRÜN AUS DEM FALSCHEN GRUND. Die Messung wartete nach VanVans Änderung 1200 ms und fragte dann, ob der getippte Text noch dasteht. Er stand da -- weil das Neuzeichnen noch gar nicht passiert war. Ein grüner Haken über einer Voraussetzung, die nicht eingetreten ist. Jetzt wird auf das EREIGNIS gewartet, nicht auf die Uhr, und dass es eingetreten ist, ist eine eigene Zusage. (b) DIE GEGENPROBE MASS DAS GEGENTEIL. Sie machte `Getippt.binde` zu einer LEEREN Funktion -- damit schreibt niemand mehr in das Feld, also bleibt der Text erst recht stehen. „Ohne das Gedächtnis" heißt nicht „ohne Schreiben", sondern: wieder so wie früher, nämlich `feld.value = wert`. Genau das wird jetzt eingesetzt. (c) ZWEI PRÜFUNGEN LASEN FLIESSTEXT STATT CODE. `html.indexOf("reaktion.js")` fand das Wort im Kommentar darüber, und `!/localStorage/` schlug an, weil in getippt.js im Kommentar steht „liegt im Speicher und NICHT in localStorage". Beide waren rot, obwohl der Code stimmte. Jetzt wird der Skript-Einhänger verglichen und der Aufruf `localStorage.setItem` gesucht. UND EINER, DEN NUR DAS BILDSCHIRMFOTO ZEIGEN KONNTE: Ich hatte `--w-tief`, `--w-hoch` und `--w-ring` als `background` und `border-color` benutzt -- das sind aber SCHATTEN, keine Farben, und das steht im Werkstoff-Abschnitt direkt darüber. Tückisch daran: Der Ersatzwert in `var(--x, fallback)` greift dabei NICHT. Er hilft nur, wenn die Variable fehlt, nicht wenn ihr Wert für die Eigenschaft unsinnig ist. Die Sichtumschaltung sah dadurch aus wie zwei Wörter ohne Knopf. ====================================================================== GEMESSEN ====================================================================== pruef-reaktion 588, 0 Fehler (vorher 571) -- Abschnitt 22 mess-regie (neu) 28, 0 Fehler, 3 Bildschirmfotos mess-foyer 96, 0 Fehler pruef-struktur 102 · pruef-css-klassen 37 · pruef-portnummern 41 pruef-lesbarkeit 14 · pruef-bewegung 9 · pruef-tippziele 13 pruef-deutsche-texte 12 · pruef-crew-adresse 173 pruef-haus-trennung 107 · pruef-community-sicht 10 pruef-sackgassen 14 alle 0 Fehler Co-Authored-By: Claude Opus 5 --- server/mess-regie.mjs | 501 ++++++++++++++++++++++++++++++ server/pruef-reaktion.mjs | 134 ++++++++ server/workspace-reaktion.js | 83 ++++- workspace/anleitung.html | 28 +- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/reaktion.css | 246 +++++++++++++++ workspace/assets/js/getippt.js | 214 +++++++++++++ workspace/assets/js/reaktion.js | 430 +++++++++++++++++++++++-- workspace/aufgaben.html | 38 +-- workspace/automation.html | 32 +- workspace/befinden.html | 34 +- workspace/bereich.html | 48 +-- workspace/bewerben.html | 38 +-- workspace/bewerbungen.html | 36 +-- workspace/buehne.html | 6 +- workspace/calls.html | 34 +- workspace/chat.html | 42 +-- workspace/content.html | 34 +- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +- workspace/entwicklung.html | 36 +-- workspace/hilfe.html | 30 +- workspace/index.html | 10 +- workspace/kalender.html | 34 +- workspace/leistung.html | 32 +- workspace/manager-ziele.html | 28 +- workspace/material.html | 32 +- workspace/notizen.html | 28 +- workspace/personen.html | 32 +- workspace/profil.html | 36 +-- workspace/reaktion-foyer.html | 30 +- workspace/reaktion.html | 92 ++++-- workspace/rechte.html | 34 +- workspace/report.html | 36 +-- workspace/scouting.html | 32 +- workspace/start.html | 38 +-- workspace/startcheck.html | 34 +- workspace/steckbrief.html | 36 +-- workspace/support.html | 28 +- workspace/tafel.html | 6 +- workspace/talente.html | 36 +-- workspace/team.html | 32 +- workspace/teamlage.html | 34 +- workspace/teilen.html | 30 +- workspace/treff-moderation.html | 32 +- workspace/treff-regeln.html | 32 +- workspace/uebersicht.html | 32 +- workspace/unsere-seiten.html | 30 +- workspace/unterstuetzen.html | 32 +- workspace/werdegang.html | 32 +- workspace/willkommen.html | 2 +- workspace/wissen.html | 34 +- 54 files changed, 2330 insertions(+), 742 deletions(-) create mode 100644 server/mess-regie.mjs create mode 100644 workspace/assets/js/getippt.js 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(` + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index d939995f..c1c4299d 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +