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 <[email protected]>
3832 lines
183 KiB
JavaScript
3832 lines
183 KiB
JavaScript
/* =====================================================================
|
|
DIE REACTION (28.09.2026)
|
|
|
|
Geprueft wird der ganze Weg aus Filipes Kurznotiz:
|
|
|
|
Kachel sichtbar -> geschlossen -> Vorbereitung ->
|
|
Wartebereich/Chat -> Countdown -> LIVE ->
|
|
Reaction + Gaeste + Chat + PayPal -> Ende
|
|
|
|
---------------------------------------------------------------------
|
|
WARUM DIESE PRUEFUNG UEBER DIE HAUSADRESSE SPRICHT
|
|
|
|
Die Community meldet sich ueber den STILLEN ZUGANG an -- ein Modi
|
|
oder ein Zuschauer tippt auf irgendeine Kachel und gibt seinen Code
|
|
ein, der Code allein entscheidet. Dieser Weg ist auf einer
|
|
Pruefadresse (127.0.0.1) absichtlich abgeschaltet: Er wuerde dort
|
|
eine messbare Zeitspur hinterlassen, und genau die soll er nicht.
|
|
|
|
Folge: Ohne Host-Kopfzeile koennte sich hier kein einziger
|
|
Zuschauer anmelden -- und eine Pruefung, die ihren Hauptdarsteller
|
|
nicht hereinbekommt, prueft nichts. `fetch` kann die Kopfzeile
|
|
nicht setzen, deshalb node:http.
|
|
|
|
Aufrufen mit: node server/pruef-reaktion.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";
|
|
|
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-reakt-"));
|
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
|
|
|
import { notbremse } from "./helfer-notbremse.mjs";
|
|
const { eigenerPort } = await import("./helfer-port.mjs");
|
|
const PORT = await eigenerPort(import.meta, "die Reaction-Pruefung");
|
|
|
|
process.env.PORT = `${PORT}`;
|
|
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
|
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
|
/* DREI STATT DREISSIG. Sonst muesste die Pruefung dreissig Videos
|
|
anhaengen, um den vollen Fall zu erreichen -- und ein Grenzfall,
|
|
den niemand prueft, weil das Pruefen zu lange dauert, ist ein
|
|
ungeprueftes Verhalten. */
|
|
process.env.REAKTION_LISTE_MAX = "3";
|
|
/* DIE EINLADUNG DARF NICHT AN DER UHR SCHEITERN.
|
|
|
|
`reaktion_live` traegt die Ruheregel „spaet" -- gesperrt ist damit
|
|
nur zwischen 1 und 7 Uhr. Liefe diese Pruefung um halb drei nachts,
|
|
waere sie rot, ohne dass an der Software etwas kaputt ist: genau die
|
|
Zeitbombe, die in den Hausregeln steht und die am 07.09. schon die
|
|
Weckerpruefung getroffen hat.
|
|
|
|
`PUSH_SPAET_AB = 24` heisst: „gesperrt ab Stunde 24" -- die es nicht
|
|
gibt. Damit ist „spaet" hier NIE gesperrt, zu jeder Tageszeit. Die
|
|
Grenze selbst wird in pruef-push-ziel mit festen Stunden geprueft,
|
|
wo sie hingehoert: als EINGABE, nicht als Annahme ueber „jetzt". */
|
|
process.env.PUSH_SPAET_AB = "24";
|
|
|
|
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(260_000, "pruef-reaktion");
|
|
await new Promise((r) => setTimeout(r, 900));
|
|
|
|
let fehler = 0, geprueft = 0;
|
|
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
const melde = (t) => console.log(t);
|
|
process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } });
|
|
|
|
const CREW = "crew.dogfather-universe.com";
|
|
const AGENTUR = "workspace.dogfather-universe.com";
|
|
|
|
/* ---------- Zugaenge anlegen -------------------------------------------- */
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
|
|
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const nun = new Date().toISOString();
|
|
const kennSchluessel = randomBytes(32).toString("hex");
|
|
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
|
|
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
|
|
.run("code_kennung_schluessel", kennSchluessel, nun);
|
|
|
|
/* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger.
|
|
Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von
|
|
heute waere ein Sonderfall, den niemand im Blick hat. */
|
|
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", kennSchluessel).update(code).digest("hex"), ALT);
|
|
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
}
|
|
const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
|
|
["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]];
|
|
const CODES = {}; const IDS = {};
|
|
for (const [i, [rolle, name]] of ROLLEN.entries()) {
|
|
CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`;
|
|
IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]);
|
|
}
|
|
d.close();
|
|
|
|
/* ---------- Sprechen mit Hausadresse ------------------------------------ */
|
|
function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) {
|
|
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: host,
|
|
Origin: origin === undefined ? `https://${host}` : origin,
|
|
...(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(rolle, host) {
|
|
const a = await roh(host, "/workspace/api/anmelden", {
|
|
method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle,
|
|
code: CODES[rolle], alter_ok: true },
|
|
});
|
|
const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
|
|
return a.code === 200 && keks ? keks : null;
|
|
}
|
|
|
|
const k = {};
|
|
for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW);
|
|
|
|
/* =======================================================================
|
|
1. ALLE KOMMEN HEREIN — auch die Community
|
|
======================================================================= */
|
|
melde("=== 1. Wer die Reaction sieht ===");
|
|
{
|
|
const fehlend = ROLLEN.map(([r]) => r).filter((r) => r !== "creator" && !k[r]);
|
|
ok(fehlend.length === 0,
|
|
`alle Rollen des Hauses sind angemeldet (${fehlend.length ? "fehlt: " + fehlend.join(", ") : "5 von 5"})`);
|
|
|
|
for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] });
|
|
ok(a.code === 200 && a.j?.stand === "zu",
|
|
`${rolle}: sieht die Lage und sie ist „zu" (${a.code}/${a.j?.stand})`);
|
|
}
|
|
const s = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast });
|
|
ok(s.code === 200, `die Seite steht der Community offen (${s.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
2. DAS ANDERE HAUS SIEHT NICHTS
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 2. Die Agentur kennt sie nicht ===");
|
|
{
|
|
const kA = await anmelden("admin", AGENTUR);
|
|
ok(!!kA, "DogFather meldet sich auch auf der Agenturadresse an");
|
|
const a = await roh(AGENTUR, "/workspace/api/reaktion", { keks: kA });
|
|
ok(a.code === 404, `und bekommt dort 404 (${a.code})`);
|
|
const s = await roh(AGENTUR, "/workspace/reaktion.html", { keks: kA });
|
|
ok(s.code === 404, `auch die Seite gibt es dort nicht (${s.code})`);
|
|
|
|
/* Das andere Ende derselben Regel: Auf einer Pruefadresse hat
|
|
niemand ein Haus, und die Schranke darf das nicht mit dem
|
|
fremden Haus verwechseln. Genau dieser Fehler ist am 27.09.
|
|
beim Notizblock passiert. */
|
|
const kP = await anmelden("admin", "127.0.0.1");
|
|
const p = await roh("127.0.0.1", "/workspace/api/reaktion", { keks: kP });
|
|
ok(p.code === 200, `auf der Pruefadresse ist sie offen (${p.code}) -- ohne Haus wird nicht gesperrt`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
3. NUR DER HOST STELLT EIN
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 3. Wer die Sendung fuehrt ===");
|
|
{
|
|
/* ZWEI FUEHREN, UND NUR DIE ZWEI (Filipe, 28.09.2026):
|
|
„die rolle dogfather und vanvan sollen alles sehen und bereit
|
|
machen auch vorher. nur die zwei sollen alles sehen, vorbereiten
|
|
und einschakten koennen."
|
|
|
|
Geprueft wird deshalb in BEIDE Richtungen -- wer darf, und wer
|
|
ausdruecklich nicht. Nur die eine Haelfte zu messen hiesse: Die
|
|
Tuer laesst sich spaeter weit aufmachen, ohne dass etwas rot
|
|
wird. */
|
|
const { REAKTION_HOST_ROLLEN } = await import("./workspace-reaktion.js");
|
|
ok(REAKTION_HOST_ROLLEN.has("admin") && REAKTION_HOST_ROLLEN.has("hand")
|
|
&& REAKTION_HOST_ROLLEN.size === 2,
|
|
`genau zwei fuehren die Sendung: ${[...REAKTION_HOST_ROLLEN].join(", ")}`);
|
|
|
|
for (const rolle of ["linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k[rolle], rumpf: { titel: "Nicht du" },
|
|
});
|
|
ok(a.code === 403, `${rolle}: darf nicht einstellen (${a.code})`);
|
|
}
|
|
|
|
/* Die rechte Hand darf ALLES, was zum Fuehren gehoert -- nicht nur
|
|
einstellen. Ein Knopf, der da ist und mit 403 antwortet, ist eine
|
|
Falle; deshalb wird jeder einzeln nachgefahren. */
|
|
const wege = [
|
|
["einstellen", "PUT", "/workspace/api/reaktion", { titel: "VanVan richtet ein" }],
|
|
["vorbereiten", "POST", "/workspace/api/reaktion/stand", { stand: "vorbereitung" }],
|
|
["Anordnung", "POST", "/workspace/api/reaktion/layout", { layout: "gleich" }],
|
|
];
|
|
for (const [was, art, pfad, rumpf] of wege) {
|
|
const a = await roh(CREW, pfad, { method: art, keks: k.hand, rumpf });
|
|
ok(a.code === 200, `die rechte Hand darf ${was} (${a.code})`);
|
|
}
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.hand, rumpf: { stand: "zu" } });
|
|
await roh(CREW, "/workspace/api/reaktion/layout", { method: "POST", keks: k.admin, rumpf: { layout: "kino" } });
|
|
|
|
const a = await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Wir schauen den Trailer", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
|
|
});
|
|
ok(a.code === 200 && a.j?.video === "dQw4w9WgXcQ",
|
|
`DogFather auch -- und aus der Adresse wird die Kennung (${a.j?.video})`);
|
|
|
|
/* DAS REGIEPULT HAENGT AN `ich.host` UND AN NICHTS SONST. Ein
|
|
zweiter Ort, an dem der Browser dieselbe Frage noch einmal
|
|
beantwortet, waere der, der spaeter abweicht -- und dann stuenden
|
|
Knoepfe da, die mit 403 antworten. */
|
|
for (const [rolle, soll] of [["admin", true], ["hand", true],
|
|
["linke", false], ["modi", false], ["gast", false]]) {
|
|
const b = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] });
|
|
ok(b.j?.ich?.host === soll,
|
|
`${rolle}: ${soll ? "sieht" : "sieht kein"} Regiepult (ich.host=${b.j?.ich?.host})`);
|
|
}
|
|
}
|
|
|
|
/* ---- Die Kennung kommt aus jeder Form, die man wirklich einfuegt ---- */
|
|
{
|
|
const { videoKennung } = await import("./workspace-reaktion.js");
|
|
const faelle = [
|
|
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"],
|
|
["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
["dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
];
|
|
const sitzt = faelle.filter(([roh2, soll]) => videoKennung(roh2) === soll).length;
|
|
ok(sitzt === faelle.length, `alle ${faelle.length} Formen der Adresse ergeben die Kennung`);
|
|
/* Gegenprobe: Was keine Kennung ist, darf auch keine werden -- sonst
|
|
stuende vor Publikum ein schwarzes Rechteck. */
|
|
const mist = ["", "https://vimeo.com/12345", "kein-video", "https://www.youtube.com/watch?v=zu_kurz"];
|
|
ok(mist.every((m) => videoKennung(m) === ""),
|
|
"und was keine ist, wird auch keine (Gegenprobe)");
|
|
const b = await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin, rumpf: { video: "https://vimeo.com/12345" },
|
|
});
|
|
ok(b.code === 400 && b.j?.fehler === "video_unklar",
|
|
`eine fremde Adresse wird abgelehnt (${b.code}/${b.j?.fehler})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
4. DER ABLAUF
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 4. zu -> vorbereitung -> live -> zu ===");
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" },
|
|
});
|
|
ok(a.code === 200 && a.j?.stand === "vorbereitung", `Vorbereitung geht (${a.j?.stand})`);
|
|
|
|
const b = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "erfunden" },
|
|
});
|
|
ok(b.code === 400, `ein erfundener Stand wird abgelehnt (${b.code})`);
|
|
|
|
/* Im Wartebereich darf schon geschrieben werden -- das ist der
|
|
halbe Sinn der Vorbereitung. */
|
|
const c = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "Bin schon da!" },
|
|
});
|
|
ok(c.code === 201, `im Wartebereich ist der Chat offen (${c.code})`);
|
|
|
|
const e = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "live" },
|
|
});
|
|
ok(e.code === 200 && e.j?.stand === "live" && e.j?.gestartet_am,
|
|
`auf Sendung -- mit Startzeit (${e.j?.stand})`);
|
|
|
|
/* WER EINSCHALTET, IST IM BILD.
|
|
|
|
Vorher stand als Host, wer zuletzt etwas eingestellt hatte. Mit
|
|
zwei Leuten, die vorbereiten duerfen, ist das eine Falle: VanVan
|
|
richtet nachmittags ein, Filipe drueckt abends auf Sendung -- und
|
|
im Bild stuende VanVans Name, waehrend Filipe redet. */
|
|
ok(e.j?.host?.id === IDS.admin,
|
|
`wer eingeschaltet hat, fuehrt die Sendung (${e.j?.host?.name})`);
|
|
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin, rumpf: { titel: "Wir schauen den Trailer", video: "dQw4w9WgXcQ" },
|
|
});
|
|
const f = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.hand, rumpf: { stand: "live" },
|
|
});
|
|
ok(f.j?.host?.id === IDS.hand,
|
|
`drueckt die rechte Hand, fuehrt sie (${f.j?.host?.name})`);
|
|
/* Und zurueck auf DogFather, damit der Rest der Pruefung von ihm
|
|
aus weiterlaeuft. */
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.hand, rumpf: { stand: "zu" } });
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin, rumpf: { titel: "Wir schauen den Trailer", video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
}
|
|
|
|
/* ---- Ohne Video kein Live ---- */
|
|
{
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
|
|
await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Ohne", video: "" } });
|
|
const a = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "live" },
|
|
});
|
|
ok(a.code === 400 && a.j?.fehler === "kein_video",
|
|
`ohne Video laesst sich nicht senden (${a.code}/${a.j?.fehler})`);
|
|
/* und danach wieder aufbauen */
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Wir schauen den Trailer", video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
}
|
|
|
|
/* =======================================================================
|
|
5. WER DABEI IST
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 5. Dabei sein ===");
|
|
{
|
|
for (const rolle of ["gast", "gast2", "modi"]) {
|
|
await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] });
|
|
}
|
|
const a = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(a.j?.dabei === 3, `drei sind dabei (${a.j?.dabei})`);
|
|
|
|
ok(Array.isArray(a.j?.zuschauer) && a.j.zuschauer.length === 3,
|
|
"DogFather sieht, WER dabei ist");
|
|
|
|
const h = await roh(CREW, "/workspace/api/reaktion", { keks: k.hand });
|
|
ok(Array.isArray(h.j?.zuschauer) && h.j.zuschauer.length === 3,
|
|
"die rechte Hand sieht es auch -- sie fuehrt mit");
|
|
|
|
const z = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(z.j?.zuschauer === null,
|
|
"ein Zuschauer bekommt die Namensliste NICHT");
|
|
ok(z.j?.dabei === 3, `die Zahl sieht er aber (${z.j?.dabei})`);
|
|
|
|
/* „NUR DIE ZWEI SOLLEN ALLES SEHEN." Die Moderation stand hier
|
|
frueher mit drin, weil sie Beitraege wegnehmen darf -- das ist
|
|
eine Vermischung zweier Dinge, die nichts miteinander zu tun
|
|
haben. Wegnehmen duerfen sie weiter (Abschnitt 7). */
|
|
for (const rolle of ["linke", "modi"]) {
|
|
const m = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] });
|
|
ok(m.j?.zuschauer === null,
|
|
`${rolle}: moderiert, bekommt die Namensliste aber nicht`);
|
|
}
|
|
|
|
/* Jeder Dabeisitzende hat einen Kameraplatz bekommen -- es sind
|
|
erst drei, und zwoelf waeren erlaubt. */
|
|
ok(a.j.zuschauer.every((x) => x.sicht === true),
|
|
"alle drei haben einen Kameraplatz (bei 12 Plaetzen)");
|
|
|
|
/* Abmelden: der Weg, den `sendBeacon` beim Schliessen benutzt --
|
|
POST, nicht DELETE. Beides muss gehen. */
|
|
const w1 = await roh(CREW, "/workspace/api/reaktion/dabei-weg", { method: "POST", keks: k.modi });
|
|
ok(w1.code === 200, `abmelden per POST geht (fuer sendBeacon) (${w1.code})`);
|
|
const n1 = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(n1.j?.dabei === 2, `danach sind es zwei (${n1.j?.dabei})`);
|
|
await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k.modi });
|
|
}
|
|
|
|
/* =======================================================================
|
|
6. GAESTE
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 6. Ein bis zwei Gaeste ===");
|
|
{
|
|
const { GAESTE_MAX } = await import("./reaktion-tabellen.js");
|
|
ok(GAESTE_MAX === 2, `zwei Plaetze, wie in der Notiz (${GAESTE_MAX})`);
|
|
|
|
/* =====================================================================
|
|
SEIT DEM 08.10.2026 WIRD GEFRAGT, NICHT GEHOLT
|
|
|
|
Filipe: „wenn gäste eingeladen werden, sollen die akzeptieren
|
|
oder ablehnen können. bevor die auch angezeigt werden sollen die
|
|
auswählen können ob die kamera an ist oder aus ist."
|
|
|
|
Diese Pruefung hiess vorher „Gast 1 dazu" und erwartete nach
|
|
EINEM Aufruf einen Gast auf der Buehne. Genau das darf jetzt
|
|
nicht mehr passieren -- und sie ist rot geworden, als es nicht
|
|
mehr passierte. Das ist der Fall, fuer den sie da ist.
|
|
===================================================================== */
|
|
const a = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast },
|
|
});
|
|
ok(a.code === 201, `Gast 1 eingeladen (${a.code})`);
|
|
ok((a.j?.gaeste || []).length === 0,
|
|
`und steht NICHT auf der Buehne, bevor er ja gesagt hat (${a.j?.gaeste?.length})`);
|
|
ok((a.j?.einladungen || []).some((e) => e.id === IDS.gast && e.stand === "gefragt"),
|
|
"der Host sieht die offene Frage");
|
|
ok((a.j?.einladungen || [])[0]?.noch_sek > 0,
|
|
`und wie lange sie noch gilt (${a.j?.einladungen?.[0]?.noch_sek}s)`);
|
|
|
|
/* ==== DER GEFRAGTE WEISS ES -- UND SONST NIEMAND ============== */
|
|
const gLage = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(gLage.j?.ich?.einladung?.noch_sek > 0,
|
|
"der Gefragte bekommt seine Einladung mit Restzeit");
|
|
ok(gLage.j?.ich?.gast === false,
|
|
"er ist dabei aber noch KEIN Gast");
|
|
ok(gLage.j?.einladungen === undefined,
|
|
"und er sieht die Liste der Einladungen nicht (die gehoert dem Host)");
|
|
const fremd = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 });
|
|
ok(!fremd.j?.ich?.einladung,
|
|
"wer nicht gefragt wurde, hat auch keine Einladung");
|
|
ok(fremd.j?.einladungen === undefined,
|
|
"und erfaehrt nicht, dass jemand anderes gefragt wurde");
|
|
|
|
/* ==== EIN GEFRAGTER SENDET NOCH NICHT =========================
|
|
Die wichtigste Pruefung dieses Abschnitts: Solange er nicht
|
|
zugesagt hat, darf er nichts schalten -- sonst waere „gefragt"
|
|
nur ein anderes Wort fuer „dabei". */
|
|
const fruehSenden = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.gast, rumpf: { kamera_aus: true },
|
|
});
|
|
ok(fruehSenden.code === 403,
|
|
`wer nur gefragt ist, sendet nicht (${fruehSenden.code})`);
|
|
|
|
/* ==== DIE ZUSAGE BRINGT IHN AUF DIE BUEHNE -- MIT KAMERA AUS == */
|
|
const ja = await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.gast, rumpf: { ja: true, kamera: false, mikro: true },
|
|
});
|
|
ok(ja.code === 200 && (ja.j?.gaeste || []).length === 1,
|
|
`nach der Zusage steht er auf der Buehne (${ja.code})`);
|
|
ok(ja.j?.gaeste?.[0]?.platz === 1, `er bekommt Platz 1 (${ja.j?.gaeste?.[0]?.platz})`);
|
|
/* DIE VORWAHL MUSS AM SERVER ANGEKOMMEN SEIN. Haette sie nur im
|
|
Browser gegolten, saehe der ganze Saal ein schwarzes Fenster
|
|
ohne Beschriftung -- und der Gast glaubte, seine Kamera sei
|
|
aus, waehrend sein Bild hinausgeht. */
|
|
ok(ja.j?.gaeste?.[0]?.kamera_aus === true,
|
|
"seine Kamera ist aus, weil er sie abgewaehlt hat");
|
|
ok(ja.j?.gaeste?.[0]?.mikro_aus === false,
|
|
"sein Mikro ist an, weil er es angelassen hat");
|
|
ok((ja.j?.einladungen || []).length === 0,
|
|
"die beantwortete Einladung ist aus der Liste");
|
|
|
|
/* ==== ABLEHNEN BLEIBT SICHTBAR ===============================
|
|
Verschwaende die Zeile, saehe der Host nur, dass nichts
|
|
passiert -- und lade dieselbe Person ein zweites Mal ein. */
|
|
const b0 = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
|
|
});
|
|
ok(b0.code === 201, `Gast 2 eingeladen (${b0.code})`);
|
|
const nein = await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.gast2, rumpf: { ja: false },
|
|
});
|
|
ok(nein.code === 200, `ablehnen geht (${nein.code})`);
|
|
const nachNein = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok((nachNein.j?.gaeste || []).length === 1,
|
|
"wer ablehnt, steht nicht auf der Buehne");
|
|
ok((nachNein.j?.einladungen || []).some(
|
|
(e) => e.id === IDS.gast2 && e.stand === "abgelehnt"),
|
|
"aber der Host sieht, DASS abgelehnt wurde");
|
|
|
|
/* ==== OHNE EINLADUNG KEINE ANTWORT =========================== */
|
|
const ohne = await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.modi, rumpf: { ja: true },
|
|
});
|
|
ok(ohne.code === 404 && ohne.j?.fehler === "keine_einladung",
|
|
`wer nicht gefragt wurde, kann nicht zusagen (${ohne.code}/${ohne.j?.fehler})`);
|
|
|
|
/* Die abgelehnte Zeile wegraeumen -- sonst zaehlt sie beim
|
|
naechsten Abschnitt mit. (Sie zaehlt NICHT fuer die Grenze,
|
|
siehe gleich; weg muss sie trotzdem, weil der Host sie sonst
|
|
bis zum Sendungsende vor sich hat.) */
|
|
await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast2}`, {
|
|
method: "DELETE", keks: k.admin,
|
|
});
|
|
|
|
/* ==== EINE OFFENE FRAGE HAELT IHREN PLATZ ====================
|
|
Sonst laedt der Host zwei Leute ein, beide sagen ja, und der
|
|
zweite steht vor einer vollen Buehne -- nachdem er zugesagt
|
|
hat. */
|
|
const b = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
|
|
});
|
|
ok(b.code === 201, `Gast 2 wieder eingeladen (${b.code})`);
|
|
|
|
const c = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
|
|
});
|
|
ok(c.code === 409 && c.j?.fehler === "gaeste_voll",
|
|
`ein dritter passt nicht -- die offene Frage zaehlt mit (${c.code}/${c.j?.fehler})`);
|
|
|
|
/* Und jetzt sagt der Zweite auch zu, damit der Rest des
|
|
Abschnitts zwei Gaeste auf der Buehne vorfindet. */
|
|
const ja2 = await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.gast2, rumpf: { ja: true, kamera: true, mikro: true },
|
|
});
|
|
ok(ja2.code === 200 && (ja2.j?.gaeste || []).length === 2,
|
|
`Gast 2 ist dabei (${ja2.code})`);
|
|
|
|
/* EIN GAST BEKOMMT DIE NAMENSLISTE -- und das ist kein Zufall:
|
|
Ohne sie wuesste er nicht, wen er anrufen soll, und sein Bild
|
|
kaeme bei keinem Zuschauer an. */
|
|
const g = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(Array.isArray(g.j?.zuschauer),
|
|
"wer auf der Buehne steht, bekommt die Liste (sonst sieht ihn niemand)");
|
|
ok(g.j?.ich?.gast === true, "und er weiss, dass er Gast ist");
|
|
|
|
/* Stummschalten gilt fuer alle -- das steht am Server, nicht im
|
|
Browser des Hosts. */
|
|
const s = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast}/stumm`, {
|
|
method: "PUT", keks: k.admin, rumpf: { stumm: true },
|
|
});
|
|
ok(s.code === 200 && s.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true,
|
|
"stummschalten wirkt");
|
|
const s2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 });
|
|
ok(s2.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true,
|
|
"und alle anderen sehen es auch (nicht nur der Host)");
|
|
|
|
/* Ein Gast kann sich nicht selbst holen oder entfernen. */
|
|
const f = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.gast, rumpf: { person_id: IDS.modi },
|
|
});
|
|
ok(f.code === 403, `ein Gast holt niemanden dazu (${f.code})`);
|
|
const f2 = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.modi, rumpf: { person_id: IDS.linke },
|
|
});
|
|
ok(f2.code === 403, `ein Modi auch nicht (${f2.code})`);
|
|
|
|
const w = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast2}`, {
|
|
method: "DELETE", keks: k.admin,
|
|
});
|
|
ok(w.code === 200 && w.j?.gaeste?.length === 1, `entfernen geht (${w.code})`);
|
|
|
|
/* Der frei gewordene Platz wird WIEDERVERWENDET. Mit „Anzahl + 1"
|
|
bliebe Platz 1 fuer immer leer, sobald einmal jemand gegangen
|
|
ist -- und das faellt erst im dritten Stream auf.
|
|
|
|
Der Platz wird seit dem 08.10.2026 bei der ZUSAGE vergeben und
|
|
nicht bei der Einladung: Wer ablehnt, soll Platz 1 nicht zwei
|
|
Minuten lang blockiert haben. */
|
|
await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
|
|
});
|
|
const neu = await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.modi, rumpf: { ja: true, kamera: true, mikro: true },
|
|
});
|
|
const plaetze = (neu.j?.gaeste || []).map((x) => x.platz).sort();
|
|
ok(plaetze.join(",") === "1,2",
|
|
`der frei gewordene Platz wird wiederverwendet (${plaetze.join(",")})`);
|
|
|
|
/* ==== DIE FRIST IST ECHT UND NICHT NUR EIN TEXT ===============
|
|
(08.10.2026)
|
|
|
|
Eine Einladung, die nach zwei Minuten „abgelaufen" HEISST, aber
|
|
weiterhin angenommen werden kann, ist keine Frist. Geprueft wird
|
|
das nicht mit Warten -- zwei Minuten Wartezeit in einem Pruflauf
|
|
waere genau die Sorte Pruefung, die beim naechsten Mal jemand
|
|
ueberspringt. Stattdessen wird der Zeitpunkt der Frage in der
|
|
Datenbank zurueckdatiert; das ist dieselbe Lage, nur sofort.
|
|
|
|
GEGENPROBE INBEGRIFFEN: Vorher wird bestaetigt, dass dieselbe
|
|
Einladung OHNE Rueckdatierung angenommen worden waere. Ohne sie
|
|
bewiese ein 409 nur, dass irgendetwas nicht ging. */
|
|
{
|
|
const { db } = await import("./workspace.js");
|
|
/* Erst Platz schaffen -- die Buehne ist gerade voll. */
|
|
await roh(CREW, `/workspace/api/reaktion/gast/${IDS.modi}`, {
|
|
method: "DELETE", keks: k.admin,
|
|
});
|
|
const e1 = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
|
|
});
|
|
ok(e1.code === 201, `fuer die Fristprobe noch einmal gefragt (${e1.code})`);
|
|
const vorDatum = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi });
|
|
ok(vorDatum.j?.ich?.einladung?.noch_sek > 0,
|
|
"Gegenprobe: unmittelbar nach der Frage gilt sie noch");
|
|
|
|
/* Zehn Minuten zurueck -- weit jenseits der zwei Minuten. */
|
|
const alt = new Date(Date.now() - 600_000).toISOString();
|
|
db().prepare("UPDATE reaktion_gaeste SET gefragt_am = ? WHERE person_id = ?")
|
|
.run(alt, IDS.modi);
|
|
|
|
const zuSpaet = await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.modi, rumpf: { ja: true },
|
|
});
|
|
ok(zuSpaet.code === 409 && zuSpaet.j?.fehler === "einladung_abgelaufen",
|
|
`eine abgelaufene Einladung wird abgelehnt (${zuSpaet.code}/${zuSpaet.j?.fehler})`);
|
|
|
|
const danach = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi });
|
|
ok(!danach.j?.ich?.einladung,
|
|
"und der Gefragte sieht sie nicht mehr");
|
|
const beimHost = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok((beimHost.j?.einladungen || []).some(
|
|
(x) => x.id === IDS.modi && x.stand === "keine_antwort"),
|
|
"der Host sieht „keine Antwort“ statt einer verschwundenen Zeile");
|
|
|
|
await roh(CREW, `/workspace/api/reaktion/gast/${IDS.modi}`, {
|
|
method: "DELETE", keks: k.admin,
|
|
});
|
|
/* Und wieder herstellen, was der naechste Abschnitt erwartet:
|
|
zwei Gaeste auf der Buehne. */
|
|
await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
|
|
});
|
|
const zurueck = await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.modi, rumpf: { ja: true, kamera: true, mikro: true },
|
|
});
|
|
ok((zurueck.j?.gaeste || []).length === 2,
|
|
`nach der Fristprobe stehen wieder zwei auf der Buehne (${zurueck.j?.gaeste?.length})`);
|
|
}
|
|
}
|
|
|
|
/* =======================================================================
|
|
7. DER LIVE-CHAT UND SEINE MODERATION
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 7. Chat und Moderation ===");
|
|
let fremderBeitrag = 0;
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: "Das ist gut!" },
|
|
});
|
|
ok(a.code === 201 && a.j?.id > 0, `schreiben geht (${a.code})`);
|
|
fremderBeitrag = a.j.id;
|
|
|
|
const leer = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: " " },
|
|
});
|
|
ok(leer.code === 400, `ein leerer Beitrag wird abgelehnt (${leer.code})`);
|
|
|
|
const liste = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast });
|
|
ok(liste.code === 200 && liste.j.beitraege.some((b) => b.id === fremderBeitrag),
|
|
"und steht in der Liste");
|
|
|
|
/* Ein Zuschauer nimmt einen FREMDEN Beitrag nicht weg. */
|
|
const nein = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, {
|
|
method: "DELETE", keks: k.gast,
|
|
});
|
|
ok(nein.code === 403, `ein Zuschauer nimmt fremde Beitraege nicht weg (${nein.code})`);
|
|
|
|
/* Sein eigener geht. */
|
|
const eigen = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "ups" },
|
|
});
|
|
const weg = await roh(CREW, `/workspace/api/reaktion/chat/${eigen.j.id}`, {
|
|
method: "DELETE", keks: k.gast,
|
|
});
|
|
ok(weg.code === 200, `den eigenen schon (${weg.code})`);
|
|
|
|
/* Ein Modi moderiert. */
|
|
const mod = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, {
|
|
method: "DELETE", keks: k.modi,
|
|
});
|
|
ok(mod.code === 200, `ein Modi moderiert (${mod.code})`);
|
|
|
|
const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast });
|
|
ok(!nach.j.beitraege.some((b) => b.id === fremderBeitrag),
|
|
"und der Beitrag ist aus der Liste");
|
|
|
|
/* WEGGENOMMEN UND NICHT GELOESCHT -- wer moderiert, soll spaeter
|
|
sagen koennen, was er weggenommen hat. */
|
|
const { DatabaseSync: DB2 } = await import("node:sqlite");
|
|
const d2 = new DB2(process.env.WORKSPACE_DB, { readOnly: true });
|
|
const zeile = d2.prepare("SELECT weg_am, weg_von, text FROM reaktion_chat WHERE id = ?")
|
|
.get(fremderBeitrag);
|
|
d2.close();
|
|
ok(!!zeile && !!zeile.weg_am && zeile.weg_von === IDS.modi && zeile.text === "Das ist gut!",
|
|
"er steht noch da -- mit Zeitpunkt und Namen dessen, der ihn genommen hat");
|
|
}
|
|
|
|
/* ---- Die Schreibbremse ---- */
|
|
{
|
|
const { SCHREIB_BREMSE } = await import("./reaktion-tabellen.js");
|
|
let gebremst = 0;
|
|
for (let i = 0; i < SCHREIB_BREMSE + 6; i++) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.linke, rumpf: { text: "spam " + i },
|
|
});
|
|
if (a.code === 429) gebremst++;
|
|
}
|
|
ok(gebremst > 0, `die Schreibbremse greift (${gebremst} von ${SCHREIB_BREMSE + 6} abgewiesen)`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
8. DIE VERBINDUNGSDATEN GEHEN NUR AN LEUTE, DIE DABEI SIND
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 8. Kameras: der Hinweg ===");
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/signal", {
|
|
method: "POST", keks: k.admin, rumpf: { an: IDS.gast, daten: { typ: "angebot" } },
|
|
});
|
|
ok(a.code === 200, `an jemanden, der dabei ist, geht es (${a.code})`);
|
|
|
|
/* Die Creatorin ist nicht dabei -- und im anderen Haus. An sie darf
|
|
nichts gehen, sonst waere die Reaction ein Weg, jemanden
|
|
anzuklingeln. */
|
|
const b = await roh(CREW, "/workspace/api/reaktion/signal", {
|
|
method: "POST", keks: k.admin, rumpf: { an: IDS.creator, daten: { typ: "angebot" } },
|
|
});
|
|
ok(b.code === 409 && b.j?.fehler === "nicht_dabei",
|
|
`an jemanden, der nicht dabei ist, nicht (${b.code}/${b.j?.fehler})`);
|
|
|
|
const c = await roh(CREW, "/workspace/api/reaktion/signal", {
|
|
method: "POST", keks: k.admin, rumpf: { an: IDS.gast },
|
|
});
|
|
ok(c.code === 400, `ohne Inhalt wird abgelehnt (${c.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
9. DAS ENDE RAEUMT AUF
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 9. Beenden ===");
|
|
{
|
|
const vorher = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin });
|
|
ok(vorher.j.beitraege.length > 0, `vor dem Ende steht etwas im Chat (${vorher.j.beitraege.length})`);
|
|
|
|
const a = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "zu" },
|
|
});
|
|
ok(a.code === 200 && a.j?.stand === "zu", `beenden geht (${a.j?.stand})`);
|
|
ok(a.j.gaeste.length === 0, "die Gaeste sind entlassen");
|
|
ok(a.j.dabei === 0, "und niemand sitzt mehr im Saal");
|
|
|
|
const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin });
|
|
ok(nach.j.beitraege.length === 0,
|
|
"der Live-Chat dieser Sendung ist abgeraeumt -- die naechste faengt nicht mit den Rufen der letzten an");
|
|
|
|
/* Was gelaufen ist, steht im Verlauf. */
|
|
const { DatabaseSync: DB3 } = await import("node:sqlite");
|
|
const d3 = new DB3(process.env.WORKSPACE_DB, { readOnly: true });
|
|
const v = d3.prepare("SELECT * FROM reaktion_verlauf ORDER BY id DESC LIMIT 1").get();
|
|
d3.close();
|
|
ok(!!v && v.titel === "Wir schauen den Trailer" && v.beitraege > 0,
|
|
`die Sendung steht im Verlauf (${v?.titel}, ${v?.beitraege} Beitraege)`);
|
|
|
|
/* Bei „zu" ist der Chat zu -- sonst redeten die Leute in einen
|
|
leeren Saal. */
|
|
const c = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "Hallo?" },
|
|
});
|
|
ok(c.code === 409 && c.j?.fehler === "saal_zu",
|
|
`und schreiben geht nicht mehr (${c.code}/${c.j?.fehler})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
10. DIE KACHEL, DIE RECHTE UND DIE INHALTSRICHTLINIE
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 10. Was um die Seite herum stimmen muss ===");
|
|
{
|
|
const { SEITEN } = await import("./rechte.js");
|
|
const erlaubt = SEITEN["/workspace/reaktion.html"] || [];
|
|
ok(erlaubt.includes("gast"),
|
|
`die Community steht in der Rechtetafel (${erlaubt.join(", ")})`);
|
|
|
|
/* Die Kachel. Gesucht wird sie am ZIEL und nicht am Namen -- Namen
|
|
haben sich in diesem Haus schon zweimal geaendert. */
|
|
const w = await import("./workspace.js");
|
|
const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW);
|
|
/* SEIT DEM 08.10.2026 ZEIGT SIE AUFS FOYER. Filipe: „wenn man
|
|
drauf drückt geht zuerst eine seite auf mit reihen/kategorien."
|
|
In den Saal geht es von dort aus. */
|
|
const kachel = (alle || []).find((b) => b.ziel === "reaktion-foyer.html");
|
|
ok(!!kachel, "die Community hat die Reaction-Kachel");
|
|
ok(kachel?.live === true,
|
|
"sie traegt das Merkmal `live` statt einer Ton-Nummer (der Farbraum ist bei 46 voll)");
|
|
ok(!kachel?.ton, "und ausdruecklich KEINE Ton-Nummer");
|
|
|
|
/* DAS MERKMAL HEISST NICHT `data-live`. Das ist seit dem 21.09.
|
|
vergeben -- die Draussen-Kachel bekommt es, sobald Filipe auf
|
|
Twitch sendet. Haetten beide denselben Namen, waere der
|
|
Draussen-Kachel waehrend jedes Streams die Farbe abhanden
|
|
gekommen. */
|
|
const startJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "start.js"), "utf8");
|
|
ok(/dataset\.sendung = 'ja'/.test(startJs),
|
|
"die Wand setzt `data-sendung` (und nicht `data-live`, das ist vergeben)");
|
|
const startCss = readFileSync(join(HIER, "..", "workspace", "assets", "css", "start.css"), "utf8");
|
|
ok(!/\.kachel\[data-live="ja"\]\s*\{\s*--ton/.test(startCss),
|
|
"und kein Stil greift der Draussen-Kachel ihre Farbe ab");
|
|
|
|
/* DIE INHALTSRICHTLINIE. Ohne YouTube darin startet die Sendung,
|
|
alle sind da, und die Leinwand bleibt schwarz -- ohne
|
|
Fehlermeldung, nur ein Eintrag in der Browser-Konsole. Genau so
|
|
ist am 27.08.2026 der Musik-Knopf ausgefallen. */
|
|
const seite = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast });
|
|
const regel = String(seite.kopf["content-security-policy"] || "");
|
|
ok(/frame-src[^;]*youtube-nocookie\.com/.test(regel),
|
|
"der YouTube-Rahmen ist erlaubt");
|
|
ok(/script-src[^;]*www\.youtube\.com/.test(regel),
|
|
"und die Einbett-API darf geladen werden");
|
|
ok(/img-src[^;]*ytimg\.com/.test(regel),
|
|
"und die Vorschaubilder auch");
|
|
}
|
|
|
|
/* =======================================================================
|
|
11. DIE WARTESCHLANGE
|
|
|
|
Vorher gab es genau EIN Videofeld. Wer zwei Sachen hintereinander
|
|
schauen wollte, tippte mitten in der Sendung eine Adresse ein --
|
|
vor Publikum, mit laufender Kamera. Ein Tippfehler war ein
|
|
schwarzes Rechteck.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 11. Die Warteschlange ===");
|
|
{
|
|
await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "zu" },
|
|
});
|
|
|
|
/* ERFUNDENE KENNUNGEN, mit Absicht. Elf erlaubte Zeichen -- die
|
|
Form stimmt, das Video gibt es nicht. Damit laeuft nebenbei der
|
|
Fall mit, in dem YouTube keinen Titel herausrueckt: Der Eintrag
|
|
muss trotzdem entstehen, und im Titel muss ein LEERER Text
|
|
stehen, keine Erfindung. */
|
|
const A = "aaaaaaaaaaa", B = "bbbbbbbbbbb", C = "ccccccccccc", D = "ddddddddddd";
|
|
const haengen = (rolle, video) => roh(CREW, "/workspace/api/reaktion/liste", {
|
|
method: "POST", keks: k[rolle], rumpf: { video },
|
|
});
|
|
|
|
for (const rolle of ["linke", "modi", "gast"]) {
|
|
const a = await haengen(rolle, A);
|
|
ok(a.code === 403, `${rolle}: haengt nichts an (${a.code})`);
|
|
}
|
|
|
|
const mist = await haengen("admin", "das ist keine adresse");
|
|
ok(mist.code === 400 && mist.j?.fehler === "video_unklar",
|
|
`eine kaputte Adresse wird abgelehnt (${mist.code}/${mist.j?.fehler})`);
|
|
|
|
const eins = await roh(CREW, "/workspace/api/reaktion/liste", {
|
|
method: "POST", keks: k.admin,
|
|
rumpf: { video: `https://www.youtube.com/watch?v=${A}` },
|
|
});
|
|
ok(eins.code === 200 && eins.j?.liste?.length === 1,
|
|
`aus der Adresse wird ein Eintrag (${eins.code}, ${eins.j?.liste?.length})`);
|
|
ok(eins.j?.liste?.[0]?.video === A,
|
|
`und die Kennung stimmt (${eins.j?.liste?.[0]?.video})`);
|
|
ok(typeof eins.j?.liste?.[0]?.titel === "string",
|
|
"der Titel ist IMMER ein Text -- auch wenn YouTube nichts hergab "
|
|
+ `(hier: „${eins.j?.liste?.[0]?.titel}")`);
|
|
|
|
const doppelt = await haengen("admin", A);
|
|
ok(doppelt.code === 409 && doppelt.j?.fehler === "liste_doppelt",
|
|
`dasselbe Video nicht zweimal (${doppelt.code}/${doppelt.j?.fehler})`);
|
|
|
|
/* Die rechte Hand raeumt genauso ein -- beide bereiten vor. */
|
|
const zwei = await haengen("hand", B);
|
|
ok(zwei.code === 200 && zwei.j?.liste?.length === 2,
|
|
`die rechte Hand raeumt auch ein (${zwei.code})`);
|
|
const drei = await haengen("admin", C);
|
|
ok(drei.j?.liste?.map((z) => z.video).join(",") === [A, B, C].join(","),
|
|
`die Reihenfolge ist die des Anhaengens (${drei.j?.liste?.map((z) => z.video).join(",")})`);
|
|
|
|
const voll = await haengen("admin", D);
|
|
ok(voll.code === 409 && voll.j?.fehler === "liste_voll",
|
|
`ueber der Grenze ist Schluss (${voll.code}/${voll.j?.fehler})`);
|
|
|
|
/* ---- Schieben ---- */
|
|
const idB = drei.j.liste[1].id;
|
|
const hoch = await roh(CREW, `/workspace/api/reaktion/liste/${idB}/schieben`, {
|
|
method: "POST", keks: k.hand, rumpf: { richtung: "hoch" },
|
|
});
|
|
ok(hoch.j?.liste?.map((z) => z.video).join(",") === [B, A, C].join(","),
|
|
`hoch tauscht mit dem Nachbarn (${hoch.j?.liste?.map((z) => z.video).join(",")})`);
|
|
const nochmal = await roh(CREW, `/workspace/api/reaktion/liste/${idB}/schieben`, {
|
|
method: "POST", keks: k.admin, rumpf: { richtung: "hoch" },
|
|
});
|
|
ok(nochmal.code === 200
|
|
&& nochmal.j?.liste?.map((z) => z.video).join(",") === [B, A, C].join(","),
|
|
"ganz oben noch einmal hoch aendert nichts und ist kein Fehler");
|
|
const runter = await roh(CREW, `/workspace/api/reaktion/liste/${idB}/schieben`, {
|
|
method: "POST", keks: k.admin, rumpf: { richtung: "runter" },
|
|
});
|
|
ok(runter.j?.liste?.map((z) => z.video).join(",") === [A, B, C].join(","),
|
|
`und runter wieder zurueck (${runter.j?.liste?.map((z) => z.video).join(",")})`);
|
|
|
|
/* ---- Wer die Liste sieht ---- */
|
|
const h = await roh(CREW, "/workspace/api/reaktion", { keks: k.hand });
|
|
ok(Array.isArray(h.j?.liste) && h.j.liste.length === 3,
|
|
`die zwei sehen die ganze Liste (${h.j?.liste?.length})`);
|
|
for (const rolle of ["linke", "modi", "gast"]) {
|
|
const z = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] });
|
|
ok(z.j?.liste === null, `${rolle}: bekommt die Warteschlange NICHT`);
|
|
ok(z.j?.naechstes === A,
|
|
`${rolle}: was als Naechstes kommt, darf er wissen (${z.j?.naechstes})`);
|
|
}
|
|
|
|
/* ---- Weiterschalten ---- */
|
|
const w = await roh(CREW, "/workspace/api/reaktion/weiter", {
|
|
method: "POST", keks: k.admin, rumpf: {},
|
|
});
|
|
ok(w.code === 200 && w.j?.video === A,
|
|
`„Naechstes" holt das oberste ins Bild (${w.j?.video})`);
|
|
ok(w.j?.liste?.length === 2 && !w.j.liste.some((z) => z.video === A),
|
|
"und nimmt es aus der Liste heraus");
|
|
ok(w.j?.laeuft === true && w.j?.sekunde === 0,
|
|
`es beginnt von vorn und laeuft (laeuft=${w.j?.laeuft}, sekunde=${w.j?.sekunde})`);
|
|
|
|
const gezielt = await roh(CREW, "/workspace/api/reaktion/weiter", {
|
|
method: "POST", keks: k.hand, rumpf: { id: w.j.liste[1].id },
|
|
});
|
|
ok(gezielt.j?.video === C,
|
|
`„Jetzt" holt genau das gewaehlte, nicht das oberste (${gezielt.j?.video})`);
|
|
|
|
const nurEins = await roh(CREW, "/workspace/api/reaktion/weiter", {
|
|
method: "POST", keks: k.gast, rumpf: {},
|
|
});
|
|
ok(nurEins.code === 403, `ein Zuschauer schaltet nicht weiter (${nurEins.code})`);
|
|
|
|
await roh(CREW, "/workspace/api/reaktion/weiter", { method: "POST", keks: k.admin, rumpf: {} });
|
|
const leer = await roh(CREW, "/workspace/api/reaktion/weiter", {
|
|
method: "POST", keks: k.admin, rumpf: {},
|
|
});
|
|
ok(leer.code === 409 && leer.j?.fehler === "liste_leer",
|
|
`eine leere Liste sagt das auch (${leer.code}/${leer.j?.fehler})`);
|
|
|
|
/* Und der Titel des laufenden Videos steht in der Lage -- auch
|
|
dann, wenn er leer ist. Ein fehlendes Feld waere `undefined`
|
|
und wuerde in der Oberflaeche zu „undefined". */
|
|
const jetzt = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(typeof jetzt.j?.video_titel === "string",
|
|
`der Titel des laufenden Videos ist immer ein Text („${jetzt.j?.video_titel}")`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
12. DIE REGIE
|
|
|
|
Hier wird nicht das Aussehen geprueft -- das kann eine Maschine
|
|
nicht -- sondern zweierlei, das man beim Umbauen still kaputt
|
|
macht:
|
|
|
|
A) JEDES `$('id')`, das die Oberflaeche anfasst, muss es in der
|
|
Seite auch GEBEN. Ein Tippfehler ergibt sonst `null`, und der
|
|
Fehler faellt erst auf, wenn jemand den Knopf drueckt --
|
|
mitten in einer Sendung.
|
|
|
|
B) Die drei grossen Knoepfe muessen AUSSERHALB des
|
|
einklappbaren Teils liegen. Wer mitten in der Sendung
|
|
„Beenden" drueckt, drueckt es, weil etwas passiert ist. Das
|
|
darf nicht hinter einer Bewegung liegen.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 12. Die Regie ===");
|
|
{
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
|
|
const vorhanden = new Set([...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
|
|
/* Kommentare erst weg -- sonst zaehlt ein Beispiel im Fliesstext
|
|
als Zugriff. */
|
|
const ohneNotiz = js.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
const gesucht = [...new Set([...ohneNotiz.matchAll(/\$\('([a-z0-9-]+)'\)/g)].map((m) => m[1]))];
|
|
const fehlend = gesucht.filter((n) => !vorhanden.has(n));
|
|
ok(gesucht.length > 30 && fehlend.length === 0,
|
|
fehlend.length
|
|
? `FEHLT in der Seite: ${fehlend.join(", ")}`
|
|
: `alle ${gesucht.length} angefassten Kennungen gibt es in der Seite`);
|
|
|
|
/* GEGENPROBE: Kann diese Pruefung ueberhaupt „nein" sagen? Eine,
|
|
die immer bestaetigt, bestaetigt nichts. */
|
|
ok(!vorhanden.has("gibt-es-nicht")
|
|
&& ["p-starten", "gibt-es-nicht"].filter((n) => !vorhanden.has(n)).length === 1,
|
|
"Gegenprobe: eine erfundene Kennung wuerde auffallen");
|
|
|
|
/* Auch die Register muessen zusammenpassen. */
|
|
/* Die Reihenfolge der Merkmale im Text ist beliebig -- gesucht
|
|
wird deshalb erst die Zeile mit `role="tab"` und darin das
|
|
Merkmal. Ein Muster, das eine bestimmte Reihenfolge erwartet,
|
|
bricht beim naechsten Umformatieren. */
|
|
const reiter = [...html.matchAll(/<button[^>]*role="tab"[^>]*>/g)]
|
|
.map((m) => (m[0].match(/data-tafel="([a-z]+)"/) || [])[1])
|
|
.filter(Boolean);
|
|
const tafeln = [...html.matchAll(/class="pult-tafel" data-tafel="([a-z]+)"/g)].map((m) => m[1]);
|
|
/* GEZAEHLT, NICHT FESTGESCHRIEBEN. Hier stand „genau 5" -- und
|
|
beim sechsten Register wurde die Pruefung rot, obwohl nichts
|
|
kaputt war. Was wirklich zaehlt: zu jedem Reiter gehoert eine
|
|
Tafel, und keine Tafel steht ohne Reiter da. */
|
|
ok(reiter.length >= 5
|
|
&& reiter.length === tafeln.length
|
|
&& reiter.every((r) => tafeln.includes(r)),
|
|
`zu jedem der ${reiter.length} Register gibt es genau eine Tafel (${reiter.join(", ")})`);
|
|
const steuert = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((m) => m[1]);
|
|
ok(steuert.length > 0 && steuert.every((n) => vorhanden.has(n)),
|
|
`alle ${steuert.length} Verweise mit aria-controls zeigen auf etwas Vorhandenes`);
|
|
|
|
/* B) Die grossen Knoepfe liegen ausserhalb des Klappteils. */
|
|
const woLeiste = html.indexOf('class="pult__leiste"');
|
|
const woInnen = html.indexOf('id="pult-innen"');
|
|
const woBeenden = html.indexOf('id="p-beenden"');
|
|
ok(woLeiste > 0 && woInnen > woLeiste,
|
|
"die Leiste steht vor dem einklappbaren Teil");
|
|
ok(woBeenden > woLeiste && woBeenden < woInnen,
|
|
"der Beenden-Knopf liegt in der Leiste -- also auch dann erreichbar, "
|
|
+ "wenn die Regie zugeklappt ist");
|
|
|
|
/* ==== JEDE TASTE STEHT AN IHREM KNOPF ========================
|
|
|
|
Hier wurde bis zum 28.09. eine LEGENDE geprueft: ein Absatz am
|
|
Ende der Regie, der alle Kuerzel aufzaehlte. Der stand hinter
|
|
ALLEN Registern -- also auch unter „Ton" und „Spenden", wo
|
|
keines davon etwas tut. Und eine Legende zwingt dazu, vom Knopf
|
|
zur Liste zu schauen und zurueck.
|
|
|
|
Jetzt steht jede Taste an dem Knopf, den sie bedient. Geprueft
|
|
wird deshalb nicht mehr, ob sie IRGENDWO steht, sondern ob sie
|
|
am RICHTIGEN Knopf steht -- ein Kuerzel an der falschen Stelle
|
|
ist schlimmer als keins. */
|
|
const nahBei = (kennung, taste) => {
|
|
const i = html.indexOf(`id="${kennung}"`);
|
|
if (i < 0) return false;
|
|
/* Vom Knopfanfang bis zu seinem Ende -- nicht weiter, sonst
|
|
zaehlte die Taste des Nachbarn mit. */
|
|
const bis = html.indexOf("</button>", i);
|
|
return bis > 0 && html.slice(i, bis).includes(`<kbd>${taste}</kbd>`);
|
|
};
|
|
for (const [kennung, taste, wo] of [
|
|
["v-zurueck", "←", "zehn Sekunden zurueck"],
|
|
["v-vor", "→", "zehn Sekunden vor"],
|
|
["v-naechstes", "N", "Naechstes"],
|
|
["v-tauschen", "T", "Wechseln"],
|
|
]) {
|
|
ok(nahBei(kennung, taste), `die Taste ${taste} steht an „${wo}"`);
|
|
}
|
|
ok(/<span class="transport__taste"><kbd>Leertaste<\/kbd><\/span>/.test(html),
|
|
"und die Leertaste unter dem Play-Knopf");
|
|
ok(/id="s-mikro-wort">Mikro<\/span><kbd class="sender-taste">M<\/kbd>/.test(html),
|
|
"das M am Mikro-Knopf");
|
|
ok(/<kbd>1<\/kbd> bis <kbd>5<\/kbd>/.test(html),
|
|
"und Eins bis Fuenf bei der Anordnung");
|
|
/* GEGENPROBE: Die alte Legende darf NICHT mehr dastehen -- sonst
|
|
stuende dasselbe zweimal, und beim naechsten Kuerzel pflegt
|
|
jemand nur eine der beiden Stellen. */
|
|
ok(!/<p class="kuerzel">/.test(html),
|
|
"Gegenprobe: die Legende hinter allen Registern ist weg");
|
|
ok(/taste === 'm' \|\| taste === 'M'/.test(js) && /taste === 'n' \|\| taste === 'N'/.test(js),
|
|
"und das Programm hoert auch darauf");
|
|
ok(/closest\('input, textarea, select/.test(js),
|
|
"wer in einem Feld tippt, tippt -- die Leertaste gehoert dann dem Text");
|
|
|
|
/* ==== KEIN KLASSENNAME ZWEIMAL ================================
|
|
|
|
reaktion.html laedt FUENF Stilvorlagen: gate.css, start.css,
|
|
reaktion.css, module.css, haus.css. Wer hier eine Klasse
|
|
erfindet, die es dort schon gibt, erbt deren Regeln -- lautlos.
|
|
|
|
Gemessen am 28.09.2026: `.tafel` gehoert seit je der
|
|
Anmeldeseite und steht dort `position: absolute`. Die
|
|
Register-Tafel der Regie trug damit NICHTS zur Hoehe des Pults
|
|
bei (114 Pixel Raster, 294 Pixel Inhalt) und malte quer ueber
|
|
Register und Kuerzel hinaus. Auf dem Bildschirmfoto sah es aus
|
|
wie ein Anzeigefehler; in Wahrheit war es ein Namensstreit.
|
|
|
|
Derselbe Fehler wie im August bei .knopf-still und .schalter --
|
|
deshalb steht er ab jetzt in einer Pruefung und nicht in einem
|
|
Kommentar. */
|
|
const roh = (datei) => readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "css", datei), "utf8")
|
|
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
|
|
/* JEDE Klasse, die in dieser Datei ueberhaupt vorkommt. */
|
|
const klassenAus = (datei) => new Set(
|
|
[...roh(datei).matchAll(/(^|[\s,>+~(])\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/g)]
|
|
.map((m) => m[2]));
|
|
|
|
/* NUR die Klassen, die hier ein EIGENES Bauteil benennen -- also am
|
|
ANFANG eines Selektors stehen.
|
|
|
|
Der Unterschied ist der ganze Punkt. `.tafel {` war als eigenes
|
|
Bauteil gemeint und hat deshalb die Regeln der Anmeldeseite
|
|
geerbt. `body[data-nur="buehne"] .kopfleiste { display: none }`
|
|
ist das Gegenteil: eine absichtliche Bezugnahme auf eine fremde
|
|
Klasse, um sie im Buehnenmodus wegzunehmen. Wer beides gleich
|
|
behandelt, macht aus einer echten Regel eine, die staendig
|
|
grundlos anschlaegt -- und die wird weggeklickt. */
|
|
/* AM ANFANG DES SELEKTORS -- nicht irgendwo darin.
|
|
|
|
Der Kommentar oben sagte das schon; das Muster tat es nicht.
|
|
Es nahm jede Klasse, der ein Trennzeichen vorausging -- also
|
|
auch das `.kopfleiste` in `body.reaktion-seite > .kopfleiste`.
|
|
Das ist aber genau die Bezugnahme, die erlaubt sein soll, und
|
|
die Regel schlug am 28.09.2026 darauf an. Dass sie es bei
|
|
`body[data-nur="buehne"] .kopfleiste` nicht tat, lag nur an
|
|
einem Komma dahinter -- also am Zufall, nicht an der Absicht.
|
|
|
|
Jetzt wird jeder Selektor einzeln angesehen und gefragt, ob er
|
|
MIT der Klasse beginnt. Das ist die Frage, um die es geht:
|
|
„Benenne ich hier ein eigenes Bauteil?" */
|
|
const eigeneAus = (datei) => {
|
|
const raus = new Set();
|
|
for (const m of roh(datei).matchAll(/([^{}]+)\{/g)) {
|
|
for (const teil of m[1].split(",")) {
|
|
const t = teil.trim();
|
|
const k = t.match(/^\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
if (k) raus.add(k[1]);
|
|
}
|
|
}
|
|
return raus;
|
|
};
|
|
|
|
const meine = eigeneAus("reaktion.css");
|
|
const streit = [];
|
|
for (const fremd of ["gate.css", "start.css", "module.css", "haus.css"]) {
|
|
for (const name of klassenAus(fremd)) if (meine.has(name)) streit.push(`${name} (${fremd})`);
|
|
}
|
|
ok(meine.size > 40 && streit.length === 0,
|
|
streit.length
|
|
? `Namensstreit: ${streit.join(", ")}`
|
|
: `alle ${meine.size} Klassen aus reaktion.css sind nur dort vergeben`);
|
|
ok(klassenAus("gate.css").has("tafel"),
|
|
"Gegenprobe: der Sucher findet die Klasse, an der es lag (.tafel in gate.css)");
|
|
|
|
/* GEGENPROBE IN BEIDE RICHTUNGEN. Eine Regel, die nur „eigenes
|
|
Bauteil" von „Bezugnahme" trennen SOLL, muss beides koennen --
|
|
sonst hat man sie nur so lange geschaerft, bis sie schweigt. */
|
|
ok(meine.has("pult-tafel"),
|
|
"Gegenprobe: ein eigenes Bauteil wird als eigenes erkannt (.pult-tafel)");
|
|
ok(!meine.has("kopfleiste") && klassenAus("reaktion.css").has("kopfleiste"),
|
|
"Gegenprobe: eine fremde Klasse, die nur weggenommen wird, gilt NICHT als eigene "
|
|
+ "(.kopfleiste im Buehnenmodus)");
|
|
|
|
/* ---- Und derselbe Streit INNERHALB einer Datei -----------------
|
|
|
|
`.stufen` stand am 28.09.2026 zweimal in reaktion.css: einmal
|
|
fuer die Stufenleiter der Spenden (`display: grid`), 600 Zeilen
|
|
spaeter fuer die Chat-Leiste Offen/Team/Zu (`display: flex`).
|
|
Die spaetere gewinnt -- und damit stand die Stufenleiter
|
|
nebeneinander statt untereinander und rollte waagerecht.
|
|
|
|
GEZAEHLT WIRD NUR, WAS SICH WIDERSPRECHEN KANN: zwei Regeln,
|
|
deren Auswahl NUR aus der Klasse besteht (kein Zustand, kein
|
|
Kontext, kein Attribut) und die BEIDE `display` setzen. Zwei
|
|
Regeln zu derselben Klasse sind sonst voellig normal --
|
|
`.knopf { }` und `.knopf:hover { }` gehoeren zusammen. Wer
|
|
jede Wiederholung meldet, baut eine Warnung, die immer kommt,
|
|
und die wird weggeklickt. */
|
|
/* ZWEI EINSCHRAENKUNGEN, BEIDE GEMESSEN UND NOETIG.
|
|
|
|
(1) Die Auswahl muss GENAU die Klasse sein. Sonst zaehlt
|
|
`.pult[data-auf="ja"] .pult__innen { display: grid }` als
|
|
zweite Grundregel zu `.pult__innen { display: none }` -- und
|
|
das ist kein Streit, sondern derselbe Bauteil in zwei
|
|
Zustaenden.
|
|
|
|
(2) `display: none` zaehlt nicht mit. „Erst versteckt, dann
|
|
gezeigt" ist das haeufigste Paar im ganzen Haus. Wer es
|
|
meldet, baut eine Warnung, die bei fuenf harmlosen Stellen
|
|
anschlaegt -- und beim sechsten, echten Fall hat sie
|
|
niemand mehr gelesen.
|
|
|
|
Uebrig bleibt genau das, was sich widersprechen KANN: zwei
|
|
Grundregeln, die beide sagen, WIE das Bauteil gebaut ist --
|
|
`grid` gegen `flex`, wie bei `.stufen`. */
|
|
const positiv = (wert) => wert && wert !== "none" && !/^none\b/.test(wert);
|
|
|
|
/* ZWEI EINSCHRAENKUNGEN MEHR, BEIDE AM 29.09.2026 NOETIG GEWORDEN.
|
|
|
|
(3) Der Selektor muss GENAU die Klasse sein. `.saal .pult {
|
|
display: flex }` ist keine zweite Grundregel zu `.pult {
|
|
display: grid }`, sondern dieselbe Klasse in einem anderen
|
|
Zusammenhang -- und genau dafuer sind Selektoren da. Die
|
|
erste Fassung nahm jede Klasse, der ein Trennzeichen
|
|
vorausging, und meldete deshalb `pult (grid vs. flex)`.
|
|
|
|
(4) Regeln INNERHALB einer Medienabfrage zaehlen nicht. Sie
|
|
sind per Definition eine Variante fuer eine andere
|
|
Umgebung -- `messwerte__paar` steht am Fingergeraet als
|
|
`flex` und sonst als `grid`, und das ist die Absicht.
|
|
|
|
Was uebrig bleibt, ist der Fall, um den es ging: `.stufen`
|
|
stand zweimal als blanke Grundregel, ohne Medienabfrage und
|
|
ohne Zusammenhang -- zwei verschiedene Bauteile, ein Name. */
|
|
const ohneMedien = (text) => text.replace(
|
|
/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g,
|
|
(m) => m.replace(new RegExp("[^" + String.fromCharCode(10) + "]", "g"), " "));
|
|
|
|
const lies = (text) => {
|
|
const raus = {};
|
|
/* LOOKBEHIND STATT VERBRAUCHEN. Mit `(^|[};])` frisst der erste
|
|
Treffer die schliessende Klammer, die der naechste als Anfang
|
|
braeuchte -- `.stufen { … } .stufen { … }` lieferte dann nur
|
|
EINEN Treffer, und die Gegenprobe zum echten Fall vom 28.09.
|
|
schlug fehl. */
|
|
for (const m of ohneMedien(text).matchAll(/(?:^|(?<=[};]))\s*(\.(-?[a-zA-Z_][a-zA-Z0-9_-]*))\s*\{([^{}]*)\}/g)) {
|
|
const wert = (m[3].match(/(?:^|[\s;])display\s*:\s*([^;]+)/) || [, ""])[1].trim();
|
|
if (!positiv(wert)) continue;
|
|
(raus[m[2]] ||= []).push(wert);
|
|
}
|
|
return raus;
|
|
};
|
|
const streitIn = (text) => Object.entries(lies(text))
|
|
.filter(([, was]) => new Set(was).size > 1)
|
|
.map(([name, was]) => `${name} (${[...new Set(was)].join(" vs. ")})`);
|
|
|
|
const grundregeln = lies(roh("reaktion.css"));
|
|
const doppelt = streitIn(roh("reaktion.css"));
|
|
ok(Object.keys(grundregeln).length > 20 && doppelt.length === 0,
|
|
doppelt.length
|
|
? `Namensstreit in reaktion.css selbst: ${doppelt.join(", ")}`
|
|
: `${Object.keys(grundregeln).length} Klassen sagen, WIE sie gebaut sind `
|
|
+ "-- keine davon zweimal verschieden");
|
|
|
|
/* GEGENPROBEN AN EIGENSTAENDIGEN TEXTEN, nicht an die echte
|
|
Datei angehaengt. Die erste Fassung tat das -- und scheiterte,
|
|
sobald der Sucher Medienabfragen ausklammert: Was hinter dem
|
|
letzten `@media` angehaengt wird, kann darin verschwinden.
|
|
Eine Gegenprobe, die vom Inhalt der geprueften Datei abhaengt,
|
|
prueft nicht die Regel, sondern die Datei. */
|
|
{
|
|
const faelle = [
|
|
["der Fall vom 28.09. (zwei Grundregeln, anderes display)",
|
|
".stufen { display: grid; } .stufen { display: flex; }", true],
|
|
["verstecken und zeigen",
|
|
".probe { display: none; } .probe { display: grid; }", false],
|
|
["dieselbe Klasse in einem Zusammenhang",
|
|
".pult { display: grid; } .saal .pult { display: flex; }", false],
|
|
["eine Variante in einer Medienabfrage",
|
|
".paar { display: grid; } @media (pointer: coarse) { .paar { display: flex; } }", false],
|
|
["zweimal dasselbe display",
|
|
".gleich { display: grid; } .gleich { display: grid; }", false],
|
|
];
|
|
const daneben = faelle
|
|
.filter(([, text, soll]) => (streitIn(text).length > 0) !== soll)
|
|
.map(([name]) => name);
|
|
ok(daneben.length === 0,
|
|
daneben.length ? `Gegenprobe daneben bei: ${daneben.join(", ")}`
|
|
: `Gegenprobe: alle ${faelle.length} Faelle richtig eingeordnet `
|
|
+ "(Streit, Zustand, Zusammenhang, Medienabfrage, Wiederholung)");
|
|
}
|
|
|
|
/* ---- Nichts rollt absichtlich waagerecht ----------------------
|
|
|
|
Filipe, 28.09.2026: „ich will auch nicht dass man sachen nach
|
|
links und rechts schieben muss. perfektion das untereinander.
|
|
ich will niemals irgendwas nach links oder rechts swippen
|
|
muessen."
|
|
|
|
Bis dahin rollten die sieben Register waagerecht, mit einer
|
|
Begruendung, die fuer sich stimmte (zwei Zeilen kosten oben 45
|
|
Pixel). Sie kostete dafuer die Gewissheit: Auf 412 Pixeln
|
|
standen 193 Pixel Register rechts ausserhalb, und dass es
|
|
„Gestaltung" und „Spenden" gibt, erfuhr man nur beim Wischen
|
|
auf Verdacht.
|
|
|
|
DIESE PRUEFUNG FINDET NUR DIE ABSICHTLICHEN ROLLER. Die
|
|
entstehenden -- ein Inhalt, der breiter ist als sein Kasten --
|
|
sieht man im Quelltext nicht; dafuer gibt es
|
|
`server/mess-quer.mjs`, das im echten Browser jedes Element
|
|
nachmisst. Beide zusammen, keines allein. */
|
|
const roller = [...roh("reaktion.css")
|
|
.matchAll(/([^{}]+)\{([^{}]*overflow-x\s*:\s*(auto|scroll)[^{}]*)\}/g)]
|
|
.map((m) => m[1].trim().split(String.fromCharCode(10)).pop().trim());
|
|
ok(roller.length === 0,
|
|
roller.length ? `rollt waagerecht: ${roller.join(", ")}`
|
|
: "nichts in reaktion.css rollt absichtlich waagerecht");
|
|
ok(/overflow-x/.test(roh("reaktion.css") + "x{overflow-x:auto}")
|
|
&& [...("y{overflow-x: auto}").matchAll(
|
|
/([^{}]+)\{([^{}]*overflow-x\s*:\s*(auto|scroll)[^{}]*)\}/g)].length === 1,
|
|
"Gegenprobe: der Sucher findet ein overflow-x, wenn eines dasteht");
|
|
|
|
/* Der Pegel wird gemessen und nicht gemalt. */
|
|
ok(/createAnalyser\(\)/.test(js) && /getByteTimeDomainData/.test(js),
|
|
"die Pegel kommen aus einer echten Messung");
|
|
ok(/schluessel === 'video'/.test(js) && /eingestellte Lautstaerke|eingestellte Lautstärke/.test(js),
|
|
"beim YouTube-Regler steht dabei, dass dort nichts gemessen werden kann");
|
|
ok(/getStats\(\)/.test(js),
|
|
"der Upload wird an der Verbindung gemessen, nicht geschaetzt");
|
|
}
|
|
|
|
/* =======================================================================
|
|
13. KAMERA UND CHAT — die zwei fehlenden Host-Steuerungen
|
|
|
|
Filipes Notiz nennt acht: Kamera, Mikrofon, Video, Gaeste,
|
|
Lautstaerke, Chat, Layout, Start/Ende. Nachgezaehlt war sechsmal
|
|
etwas da und zweimal nichts.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 13. Kamera und Chat ===");
|
|
{
|
|
/* ABSCHNITT 9 HAT DIE SENDUNG BEENDET -- also wird hier neu
|
|
aufgemacht. Sich auf den Stand eines frueheren Abschnitts zu
|
|
verlassen ist die Kopplung, die spaeter jemand aus Versehen
|
|
zerreisst; dann faellt ein Abschnitt um, an dem nichts falsch
|
|
ist. */
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Kamera und Chat", video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "live" },
|
|
});
|
|
for (const rolle of ["admin", "hand", "linke", "modi", "gast", "gast2"]) {
|
|
await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] });
|
|
}
|
|
/* ZWEI SCHRITTE SEIT DEM 08.10.2026: fragen und zusagen. Hier
|
|
steht beides hintereinander, weil dieser Abschnitt die Kamera
|
|
prueft und nicht die Einladung -- die hat ihren eigenen
|
|
Abschnitt. Der zweite Aufruf gehoert trotzdem dazu: Ohne ihn
|
|
stuende hier ein Gefragter und kein Gast, und alles darunter
|
|
maesse etwas anderes. */
|
|
await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/gast/antwort", {
|
|
method: "POST", keks: k.gast2, rumpf: { ja: true, kamera: true, mikro: true },
|
|
});
|
|
|
|
const stand0 = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(stand0.j?.stand === "live" && stand0.j?.gaeste?.length === 1,
|
|
`die Buehne steht (${stand0.j?.stand}, ${stand0.j?.gaeste?.length} Gast)`);
|
|
|
|
/* ---- Kamera ---- */
|
|
const fremd = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.gast, rumpf: { kamera_aus: true },
|
|
});
|
|
ok(fremd.code === 403 && fremd.j?.fehler === "sendest_nicht",
|
|
`wer nicht sendet, schaltet nichts (${fremd.code}/${fremd.j?.fehler})`);
|
|
|
|
const aus = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.admin, rumpf: { kamera_aus: true },
|
|
});
|
|
ok(aus.code === 200 && aus.j?.ich?.kamera_aus === true,
|
|
`der Host schaltet sein Bild ab (${aus.code})`);
|
|
ok(aus.j?.host?.kamera_aus === true,
|
|
"und es steht an der Sendung, damit es ALLE sehen");
|
|
|
|
/* TEILWEISE AENDERN DARF DEN ANDEREN WERT NICHT ANFASSEN. Sonst
|
|
muesste jede Seite immer beide Werte mitschicken -- und wer das
|
|
vergisst, schaltet dem anderen versehentlich das Bild wieder an. */
|
|
const nurMikro = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.admin, rumpf: { mikro_aus: true },
|
|
});
|
|
ok(nurMikro.j?.ich?.mikro_aus === true && nurMikro.j?.ich?.kamera_aus === true,
|
|
"nur das Mikro zu schalten laesst die Kamera in Ruhe");
|
|
|
|
const leer = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.admin, rumpf: {},
|
|
});
|
|
ok(leer.code === 400 && leer.j?.fehler === "nichts_zu_tun",
|
|
`ein Aufruf ohne Inhalt sagt das auch (${leer.code})`);
|
|
|
|
/* Ein Gast darf es fuer sich -- sonst muesste er den Host bitten. */
|
|
const gastAus = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.gast2, rumpf: { kamera_aus: true },
|
|
});
|
|
ok(gastAus.code === 200,
|
|
`ein Gast schaltet sein eigenes Bild ab (${gastAus.code})`);
|
|
const beimHost = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(beimHost.j?.gaeste?.some((g) => g.id === IDS.gast2 && g.kamera_aus === true),
|
|
"und der Host sieht es an der Gastkarte");
|
|
|
|
/* Zurueck, damit der Rest der Pruefung von einem sauberen Stand
|
|
aus weiterlaeuft. */
|
|
await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.admin, rumpf: { kamera_aus: false, mikro_aus: false },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.gast2, rumpf: { kamera_aus: false },
|
|
});
|
|
|
|
/* ---- Chat ---- */
|
|
for (const rolle of ["linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/chatmodus", {
|
|
method: "POST", keks: k[rolle], rumpf: { modus: "zu" },
|
|
});
|
|
ok(a.code === 403, `${rolle}: stellt den Chat nicht um (${a.code})`);
|
|
}
|
|
const unsinn = await roh(CREW, "/workspace/api/reaktion/chatmodus", {
|
|
method: "POST", keks: k.admin, rumpf: { modus: "halboffen" },
|
|
});
|
|
ok(unsinn.code === 400 && unsinn.j?.fehler === "chatmodus_unbekannt",
|
|
`eine erfundene Stufe wird abgelehnt (${unsinn.code})`);
|
|
|
|
const offen = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "offen geht" },
|
|
});
|
|
ok(offen.code === 201, `bei „offen" schreibt die Community (${offen.code})`);
|
|
|
|
const team = await roh(CREW, "/workspace/api/reaktion/chatmodus", {
|
|
method: "POST", keks: k.hand, rumpf: { modus: "team" },
|
|
});
|
|
ok(team.code === 200 && team.j?.chat_modus === "team",
|
|
`die rechte Hand stellt auf „Team" (${team.j?.chat_modus})`);
|
|
|
|
const gastTeam = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "und jetzt?" },
|
|
});
|
|
ok(gastTeam.code === 403 && gastTeam.j?.fehler === "chat_nur_team",
|
|
`dann schweigt die Community (${gastTeam.code}/${gastTeam.j?.fehler})`);
|
|
const modiTeam = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.modi, rumpf: { text: "wir ordnen kurz" },
|
|
});
|
|
ok(modiTeam.code === 201, `ein Modi schon -- er soll ja ordnen (${modiTeam.code})`);
|
|
|
|
/* DIE OBERFLAECHE MUSS DIESELBE ANTWORT BEKOMMEN wie der Server.
|
|
Sonst steht ein Feld da, das sich beschreiben laesst und beim
|
|
Absenden 403 sagt -- eine Falle, keine Bedienung. */
|
|
const sichtGast = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(sichtGast.j?.ich?.darf_schreiben === false,
|
|
"und die Oberflaeche erfaehrt es, bevor jemand tippt");
|
|
const sichtModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi });
|
|
ok(sichtModi.j?.ich?.darf_schreiben === true,
|
|
"der Modi erfaehrt das Gegenteil");
|
|
|
|
const zu = await roh(CREW, "/workspace/api/reaktion/chatmodus", {
|
|
method: "POST", keks: k.admin, rumpf: { modus: "zu" },
|
|
});
|
|
ok(zu.j?.chat_modus === "zu", "die Stufe zu geht auch");
|
|
const modiZu = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.modi, rumpf: { text: "auch ich?" },
|
|
});
|
|
ok(modiZu.code === 403 && modiZu.j?.fehler === "chat_zu",
|
|
`dann schweigt auch der Modi (${modiZu.code}/${modiZu.j?.fehler})`);
|
|
const hostZu = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.admin, rumpf: { text: "ich sage noch etwas" },
|
|
});
|
|
ok(hostZu.code === 201,
|
|
`die zwei, die fuehren, koennen weiter reden (${hostZu.code})`);
|
|
|
|
/* GEGENPROBE: Es geht auch wieder auf. Eine Sperre, die man nicht
|
|
loesen kann, ist keine Stufe, sondern ein Ende. */
|
|
await roh(CREW, "/workspace/api/reaktion/chatmodus", {
|
|
method: "POST", keks: k.admin, rumpf: { modus: "offen" },
|
|
});
|
|
const wiederOffen = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "wieder da" },
|
|
});
|
|
ok(wiederOffen.code === 201,
|
|
`Gegenprobe: zurueck auf „offen", und die Community schreibt wieder (${wiederOffen.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
14. KEIN WEG HEISST ZWEIMAL GLEICH
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 14. Keine doppelten Wege ===");
|
|
{
|
|
/* Beim Notizblock hat genau das eine Stunde gekostet:
|
|
`DELETE /workspace/api/notizen/:id` gab es schon woanders, und
|
|
express nimmt schweigend die erste passende Route. */
|
|
const { default: app } = await import("./index.js");
|
|
const wege = [];
|
|
const sammeln = (stapel, vorn = "") => {
|
|
for (const s of stapel) {
|
|
if (s.route) {
|
|
for (const m of Object.keys(s.route.methods)) {
|
|
wege.push(`${m.toUpperCase()} ${vorn}${s.route.path}`);
|
|
}
|
|
} else if (s.handle?.stack) {
|
|
sammeln(s.handle.stack, vorn);
|
|
}
|
|
}
|
|
};
|
|
sammeln(app._router.stack);
|
|
ok(wege.length > 300, `${wege.length} Wege im Baum gefunden`);
|
|
const doppelt = wege.filter((x, i) => wege.indexOf(x) !== i);
|
|
ok(doppelt.length === 0,
|
|
doppelt.length ? `DOPPELT: ${[...new Set(doppelt)].join(", ")}` : "kein Weg kommt zweimal vor");
|
|
const reaktionswege = wege.filter((x) => x.includes("/api/reaktion")).length;
|
|
ok(reaktionswege >= 17, `die Reaction bringt ${reaktionswege} eigene Wege mit`);
|
|
}
|
|
|
|
|
|
/* =======================================================================
|
|
15. MODERATION IM LIVE-CHAT
|
|
|
|
Der heikelste Teil der ganzen Reaction: Hier kann jemand einem
|
|
anderen das Wort nehmen. Geprueft wird deshalb nicht nur, dass es
|
|
GEHT, sondern vor allem, wer es NICHT darf und was dabei nicht
|
|
herauskommen darf.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 15. Moderation im Live-Chat ===");
|
|
{
|
|
/* Sauberer Anfang: Der Chat steht auf „offen". */
|
|
await roh(CREW, "/workspace/api/reaktion/chatmodus", {
|
|
method: "POST", keks: k.admin, rumpf: { modus: "offen" },
|
|
});
|
|
|
|
/* ---- Wer eine Massnahme verhaengen darf ---- */
|
|
const zuschauer = await roh(CREW, "/workspace/api/reaktion/massnahme", {
|
|
method: "POST", keks: k.gast, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: 10 },
|
|
});
|
|
ok(zuschauer.code === 403 && zuschauer.j?.fehler === "nur_moderation",
|
|
`ein Zuschauer schaltet niemanden stumm (${zuschauer.code}/${zuschauer.j?.fehler})`);
|
|
|
|
/* ---- Nicht gegen das Team ----
|
|
Wer einen Modi stummschalten koennte, haette einen Weg, die
|
|
Moderation selbst auszuschalten -- mitten in der Sendung. */
|
|
const gegenTeam = await roh(CREW, "/workspace/api/reaktion/massnahme", {
|
|
method: "POST", keks: k.modi, rumpf: { person_id: IDS.linke, art: "stumm", minuten: 10 },
|
|
});
|
|
ok(gegenTeam.code === 403 && gegenTeam.j?.fehler === "nicht_gegen_team",
|
|
`ein Modi schaltet die linke Hand nicht stumm (${gegenTeam.code}/${gegenTeam.j?.fehler})`);
|
|
const gegenHost = await roh(CREW, "/workspace/api/reaktion/massnahme", {
|
|
method: "POST", keks: k.modi, rumpf: { person_id: IDS.admin, art: "raus" },
|
|
});
|
|
ok(gegenHost.code === 403, `und DogFather erst recht nicht (${gegenHost.code})`);
|
|
|
|
/* ---- Die Dauer hat eine Obergrenze ---- */
|
|
const { STUMM_MAX_MINUTEN, MASSNAHMEN } = await import("./reaktion-tabellen.js");
|
|
for (const [min, wort] of [[0, "null"], [-5, "minus"], [STUMM_MAX_MINUTEN + 1, "zu lang"]]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/massnahme", {
|
|
method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: min },
|
|
});
|
|
ok(a.code === 400 && a.j?.fehler === "dauer_unklar",
|
|
`${min} Minuten (${wort}) wird abgelehnt (${a.code})`);
|
|
}
|
|
const quatsch = await roh(CREW, "/workspace/api/reaktion/massnahme", {
|
|
method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "verbannen" },
|
|
});
|
|
ok(quatsch.code === 400 && quatsch.j?.fehler === "massnahme_unbekannt",
|
|
`eine erfundene Massnahme wird abgelehnt (${quatsch.code})`);
|
|
ok(MASSNAHMEN.length === 2 && MASSNAHMEN.includes("stumm") && MASSNAHMEN.includes("raus"),
|
|
`es gibt genau zwei kurze Massnahmen: ${MASSNAHMEN.join(", ")}`);
|
|
|
|
/* ---- Stumm wirkt -- und sagt den richtigen Grund ----
|
|
Der Chat steht dabei auf „offen". Genau das ist der Fall, der
|
|
vorher die falsche Auskunft gab („Der Chat ist gerade zu"). */
|
|
const vorher = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: "vorher geht es" },
|
|
});
|
|
ok(vorher.code === 201, `vor der Massnahme schreibt gast2 (${vorher.code})`);
|
|
|
|
const stumm = await roh(CREW, "/workspace/api/reaktion/massnahme", {
|
|
method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: 10 },
|
|
});
|
|
ok(stumm.code === 200, `ein Modi schaltet einen Zuschauer stumm (${stumm.code})`);
|
|
|
|
const nachher = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: "und jetzt?" },
|
|
});
|
|
ok(nachher.code === 403 && nachher.j?.fehler === "stumm_geschaltet",
|
|
`danach nicht mehr -- und der Grund stimmt (${nachher.code}/${nachher.j?.fehler})`);
|
|
|
|
const sichtGast2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 });
|
|
ok(sichtGast2.j?.ich?.darf_schreiben === false
|
|
&& sichtGast2.j?.ich?.schreib_grund === "stumm_geschaltet",
|
|
"und er steht auch in seinem eigenen Stand");
|
|
ok(sichtGast2.j?.ich?.massnahme?.art === "stumm" && !!sichtGast2.j.ich.massnahme.bis,
|
|
"mit Art und Ablaufzeitpunkt");
|
|
|
|
/* ZUSEHEN GEHT WEITER. „stumm" ist nicht „raus" -- wer das
|
|
verwechselt, wirft jemanden fuer einen Satz aus der Sendung. */
|
|
const nochDabei = await roh(CREW, "/workspace/api/reaktion/dabei", {
|
|
method: "POST", keks: k.gast2,
|
|
});
|
|
ok(nochDabei.code === 200, `zusehen geht weiter (${nochDabei.code})`);
|
|
|
|
/* ---- Die Massnahmen sind keine oeffentliche Liste ----
|
|
In `meldungen` steht der Name dessen, der gemeldet hat. Wer
|
|
meldet, muss sich darauf verlassen koennen, dass das niemand
|
|
sieht -- sonst meldet beim naechsten Mal keiner mehr. */
|
|
ok(Array.isArray(stumm.j?.massnahmen) && stumm.j.massnahmen.length >= 1,
|
|
"die Moderation sieht die laufenden Massnahmen");
|
|
ok(sichtGast2.j?.massnahmen === null && sichtGast2.j?.meldungen === null,
|
|
"ein Zuschauer sieht weder Massnahmen noch Meldungen");
|
|
const sichtModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi });
|
|
ok(Array.isArray(sichtModi.j?.meldungen), "ein Modi schon");
|
|
|
|
/* GEGENPROBE AM RUNDRUF: `oeffentlich()` muss beide Felder
|
|
wegnehmen. Es aus dem Quelltext zu lesen ist hier genauer als
|
|
jede Messung -- der Rundruf geht an alle, und ein Fehler darin
|
|
waere erst aufgefallen, wenn jemand mitgelesen haette. */
|
|
{
|
|
const quelle = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8");
|
|
const zeile = quelle.match(/const \{([^}]*)\} = l;/);
|
|
const weg = (zeile ? zeile[1] : "").split(",").map((x) => x.trim().replace(/\.\.\..*/, ""));
|
|
for (const feld of ["zuschauer", "ich", "liste", "meldungen", "massnahmen"]) {
|
|
ok(weg.includes(feld), `oeffentlich() nimmt „${feld}" aus dem Rundruf`);
|
|
}
|
|
}
|
|
|
|
/* ---- Wieder aufheben ---- */
|
|
const auf = await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, {
|
|
method: "DELETE", keks: k.gast,
|
|
});
|
|
ok(auf.code === 403, `ein Zuschauer hebt sie nicht auf (${auf.code})`);
|
|
const auf2 = await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, {
|
|
method: "DELETE", keks: k.modi,
|
|
});
|
|
ok(auf2.code === 200, `ein Modi schon (${auf2.code})`);
|
|
const wieder = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: "wieder da" },
|
|
});
|
|
ok(wieder.code === 201, `Gegenprobe: danach schreibt er wieder (${wieder.code})`);
|
|
|
|
/* ---- Melden ---- */
|
|
const zuMelden = wieder.j.id;
|
|
const selbst = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, {
|
|
method: "POST", keks: k.gast2, rumpf: {},
|
|
});
|
|
ok(selbst.code === 400 && selbst.j?.fehler === "eigener_beitrag",
|
|
`den eigenen Beitrag meldet man nicht (${selbst.code})`);
|
|
|
|
const gemeldet = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, {
|
|
method: "POST", keks: k.gast, rumpf: { grund: "unfreundlich" },
|
|
});
|
|
ok(gemeldet.code === 201, `ein Zuschauer darf melden (${gemeldet.code})`);
|
|
|
|
/* ZWEIMAL MELDEN IST EINMAL. Ohne die Sperre koennte einer allein
|
|
die Liste fuellen, und die Moderation sieht die echte nicht. */
|
|
const nochmal = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, {
|
|
method: "POST", keks: k.gast, rumpf: {},
|
|
});
|
|
ok(nochmal.code === 200 && nochmal.j?.schon === true,
|
|
`zweimal melden zaehlt einmal -- ohne Fehlermeldung (${nochmal.code})`);
|
|
|
|
const beiModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi });
|
|
const m = (beiModi.j?.meldungen || []).find((x) => x.beitrag_id === zuMelden);
|
|
ok(!!m, "die Meldung steht bei der Moderation");
|
|
ok(m?.text === "wieder da" && m?.wer_id === IDS.gast2,
|
|
"mit dem Text und der Person, um die es geht");
|
|
|
|
/* ---- Erledigen ---- */
|
|
const nein = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, {
|
|
method: "POST", keks: k.gast,
|
|
});
|
|
ok(nein.code === 403, `ein Zuschauer erledigt keine Meldung (${nein.code})`);
|
|
const ja = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, {
|
|
method: "POST", keks: k.modi,
|
|
});
|
|
ok(ja.code === 200, `ein Modi schon (${ja.code})`);
|
|
ok(!(ja.j?.meldungen || []).some((x) => x.id === m.id),
|
|
"und sie ist aus der offenen Liste");
|
|
const zweimal = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, {
|
|
method: "POST", keks: k.modi,
|
|
});
|
|
ok(zweimal.code === 409 && zweimal.j?.fehler === "nicht_offen",
|
|
`zweimal erledigen geht nicht (${zweimal.code})`);
|
|
|
|
/* ---- „raus" heisst raus ---- */
|
|
const raus = await roh(CREW, "/workspace/api/reaktion/massnahme", {
|
|
method: "POST", keks: k.hand, rumpf: { person_id: IDS.gast2, art: "raus" },
|
|
});
|
|
ok(raus.code === 200, `die rechte Hand nimmt jemanden aus der Sendung (${raus.code})`);
|
|
const rein = await roh(CREW, "/workspace/api/reaktion/dabei", {
|
|
method: "POST", keks: k.gast2,
|
|
});
|
|
ok(rein.code === 403 && rein.j?.fehler === "aus_der_sendung",
|
|
`er kommt nicht mehr herein -- und erfaehrt warum (${rein.code}/${rein.j?.fehler})`);
|
|
|
|
const weg2 = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: "hallo?" },
|
|
});
|
|
ok(weg2.code === 403 && weg2.j?.fehler === "aus_der_sendung",
|
|
`und schreiben auch nicht (${weg2.code}/${weg2.j?.fehler})`);
|
|
|
|
await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, {
|
|
method: "DELETE", keks: k.admin,
|
|
});
|
|
const zurueck = await roh(CREW, "/workspace/api/reaktion/dabei", {
|
|
method: "POST", keks: k.gast2,
|
|
});
|
|
ok(zurueck.code === 200, `Gegenprobe: aufgehoben, und er ist wieder dabei (${zurueck.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
16. WAS DIE SEITE DARAUS MACHT
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 16. Rollen und Moderation auf der Seite ===");
|
|
{
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
|
|
const saetze = readFileSync(join(HIER, "..", "workspace", "assets", "js", "meldung.js"), "utf8");
|
|
|
|
/* JEDE ROLLE MIT EIGENEM AUSSEHEN BEKOMMT AUCH EINE REGEL.
|
|
Ein Abzeichen ohne Stil ist ein Wort ohne Kasten -- es faellt
|
|
nicht auf, und damit ist der ganze Zweck weg. */
|
|
/* ==== DIE ABZEICHEN STEHEN SEIT DEM 07.10.2026 AUF DEM SERVER ====
|
|
|
|
Sie standen in reaktion.js -- und damit stand der Schluessel einer
|
|
verborgenen Rolle in einer Datei, die jeder bekommt. Jetzt
|
|
entscheidet der Server (CHAT_ABZEICHEN in workspace-reaktion.js)
|
|
und schickt Wort und Farbkennung fertig mit.
|
|
|
|
==== UND DIESE PRUEFUNG KANN JETZT NICHT MEHR AUSSETZEN ==========
|
|
|
|
Vorher lief sie ueber die GEFUNDENEN Treffer: `for (const … of
|
|
abzeichen)`. Als die Liste umzog, fand das Suchmuster nichts --
|
|
und mit ihm verschwanden ZWOELF Pruefungen lautlos. Der Lauf fiel
|
|
von 421 auf 409, und nur EINE Zeile wurde rot. Genau davor warnt
|
|
das Projektgedaechtnis (28.08.2026): „bedingte Bloecke sind stille
|
|
Aussetzer; findet die Pruefung ihre Voraussetzung nicht, muss das
|
|
ein Fehler sein, kein Ueberspringen."
|
|
|
|
Deshalb laeuft die Schleife jetzt ueber die ERWARTETEN vier Rollen,
|
|
nicht ueber die gefundenen. Fehlt eine, wird ihre Zeile rot --
|
|
und die Anzahl bleibt dieselbe. */
|
|
const dienst = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8");
|
|
const ERWARTET = ["admin", "hand", "linke", "modi"];
|
|
const gefunden = new Map([...dienst.matchAll(
|
|
/^\s{2}(admin|hand|linke|modi):\s*\{\s*wort:\s*"([^"]+)",\s*ton:\s*"([^"]+)"\s*\},/gm)]
|
|
.map((m) => [m[1], { wort: m[2], ton: m[3] }]));
|
|
ok(gefunden.size === ERWARTET.length,
|
|
`vier Rollen tragen ein Abzeichen (${gefunden.size})`);
|
|
|
|
/* DAS WORT IST NICHT DER RANG. Filipe steht nie ueber seinem Team --
|
|
Woerter wie „Chef" oder „Leitung" haetten hier nichts zu suchen. */
|
|
const rangwoerter = ["Chef", "Boss", "Leiter", "Leitung", "Kopf"];
|
|
for (const rolle of ERWARTET) {
|
|
const a = gefunden.get(rolle);
|
|
ok(!!a && a.wort.length >= 3 && a.wort.length <= 6,
|
|
`„${a?.wort ?? "fehlt"}" fuer ${rolle} ist kurz genug`);
|
|
/* Ein Abzeichen ohne Stil ist ein Wort ohne Kasten -- es faellt
|
|
nicht auf, und damit ist der ganze Zweck weg. */
|
|
ok(!!a && css.includes(`.beitrag__rolle[data-rolle="${a.ton}"]`),
|
|
`und „${a?.ton ?? "fehlt"}" hat eine eigene Farbe`);
|
|
ok(!!a && !rangwoerter.includes(a.wort),
|
|
`„${a?.wort ?? "fehlt"}" ist kein Rang`);
|
|
}
|
|
/* GEGENPROBE: Kann diese Pruefung „nein" sagen? */
|
|
ok(!css.includes('.beitrag__rolle[data-rolle="gibtesnicht"]'),
|
|
"Gegenprobe: eine erfundene Marke hat keine Regel");
|
|
|
|
/* UND DIE OBERFLAECHE KENNT KEINE ROLLENNAMEN MEHR. Das ist der
|
|
Punkt der ganzen Umstellung -- ohne diese Zeile koennte jemand die
|
|
Liste spaeter „der Einfachheit halber" zurueckholen, und alles
|
|
waere wieder offen. `pruef-modi-wortleck` faende es zwar auch,
|
|
aber hier steht der Grund dabei. */
|
|
ok(!/ABZEICHEN\s*=\s*\{/.test(js),
|
|
"die Seite fuehrt keine eigene Abzeichenliste mehr");
|
|
ok(js.includes("b.abzeichen"),
|
|
"sie nimmt, was der Server schickt");
|
|
|
|
ok(html.includes('id="chat-meldungen"'), "der Meldungs-Knopf steht in der Seite");
|
|
ok(css.includes(".schiene__meldungen"), "und hat eine Regel");
|
|
ok(js.includes("meldungenZeichnen()") && js.includes("meldungenZeigen"),
|
|
"die Seite zeichnet und oeffnet ihn");
|
|
|
|
/* DIE TIPPZIELE IM MODERATIONSMENUE. Hier wird jemand stumm
|
|
geschaltet; ein Fehlgriff trifft den Falschen. */
|
|
const knopf = css.match(/\.modmenue__knopf \{[^}]*\}/);
|
|
ok(!!knopf && /min-height:\s*44px/.test(knopf[0]),
|
|
"jeder Knopf im Moderationsmenue ist 44 px hoch");
|
|
|
|
/* JEDES FEHLERWORT DES SERVERS HAT EINEN DEUTSCHEN SATZ.
|
|
Sonst steht im Chat ein Maschinenwort wie „nicht_gegen_team". */
|
|
const server = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8");
|
|
const woerter = [...new Set([...server.matchAll(/fehler:\s*"([a-z_0-9]+)"/g)]
|
|
.map((x) => x[1]))].filter((w) => w !== "nicht_verfuegbar");
|
|
ok(woerter.length > 20, `${woerter.length} Fehlerwoerter im Server gefunden`);
|
|
const ohneSatz = woerter.filter((w) => !new RegExp(`^\\s+${w}:`, "m").test(saetze));
|
|
ok(ohneSatz.length === 0,
|
|
ohneSatz.length ? `OHNE SATZ: ${ohneSatz.join(", ")}` : "alle haben einen deutschen Satz");
|
|
/* Die Gegenprobe braucht ein Wort, das es WIRKLICH nicht gibt --
|
|
„gibt_es_nicht" steht seit Langem in der Tafel und liess die
|
|
Gegenprobe genau das melden, wovor sie warnen sollte. */
|
|
ok(!/^\s+zwetschgenknoedel:/m.test(saetze),
|
|
"Gegenprobe: ein erfundenes Wort hat keinen Satz");
|
|
|
|
/* KEIN SCHLUESSEL STEHT ZWEIMAL IN DER SATZTAFEL.
|
|
Ein zweiter Eintrag mit demselben Namen gewinnt stillschweigend --
|
|
genau so stand am 28.09. in der Hilfe „Der Saal ist zu". */
|
|
const schluessel = [...saetze.matchAll(/^ {4}([a-z_0-9]+):/gm)].map((x) => x[1]);
|
|
const doppelt = [...new Set(schluessel.filter((x, i) => schluessel.indexOf(x) !== i))];
|
|
ok(doppelt.length === 0,
|
|
doppelt.length ? `DOPPELT: ${doppelt.join(", ")}` : `${schluessel.length} Saetze, keiner doppelt`);
|
|
}
|
|
|
|
|
|
/* =======================================================================
|
|
17. DAS VIDEO: TEMPO, ANORDNUNG, DAS ZWEITE
|
|
|
|
Filipe: „auch bei den videos sachen wie pause geschwindigkeit.
|
|
meine kamera groesser machen video kleiner. nur mein bild, nur das
|
|
video ... auch gerne so dass ich vielleicht noch ein zweites
|
|
nebenbei vorbereiten kann so dass ich hin und her switchen kann."
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 17. Tempo, Anordnung und das zweite Video ===");
|
|
{
|
|
const { TEMPI, LAYOUTS } = await import("./reaktion-tabellen.js");
|
|
|
|
/* ---- Die Anordnungen ---- */
|
|
ok(LAYOUTS.length === 5 && LAYOUTS.includes("nur_video") && LAYOUTS.includes("nur_kamera"),
|
|
`es gibt fuenf Anordnungen: ${LAYOUTS.join(", ")}`);
|
|
for (const l of LAYOUTS) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: { layout: l },
|
|
});
|
|
ok(a.code === 200 && a.j?.layout === l, `„${l}" laesst sich stellen (${a.code})`);
|
|
}
|
|
const quatschL = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: { layout: "diagonal" },
|
|
});
|
|
ok(quatschL.code === 400 && quatschL.j?.fehler === "layout_unbekannt",
|
|
`eine erfundene Anordnung wird abgelehnt (${quatschL.code})`);
|
|
const nichtHost = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.modi, rumpf: { layout: "kino" },
|
|
});
|
|
ok(nichtHost.code === 403, `ein Modi stellt die Anordnung nicht (${nichtHost.code})`);
|
|
await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: { layout: "kino" },
|
|
});
|
|
|
|
/* JEDE ANORDNUNG BRAUCHT AUCH EINE REGEL -- sonst ist sie ein
|
|
Knopf, der nichts tut. „kino" ist die Vorgabe und braucht keine. */
|
|
{
|
|
const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
|
|
const ohne = LAYOUTS.filter((l) => l !== "kino")
|
|
.filter((l) => !css.includes(`[data-layout="${l}"]`));
|
|
ok(ohne.length === 0,
|
|
ohne.length ? `OHNE REGEL: ${ohne.join(", ")}` : "jede Anordnung hat eine eigene Regel");
|
|
ok(!css.includes('[data-layout="diagonal"]'),
|
|
"Gegenprobe: eine erfundene Anordnung hat keine");
|
|
/* KEIN `opacity: 0` FUER DAS WEGLASSEN. Ein unsichtbarer
|
|
YouTube-Rahmen spielt weiter -- man hoerte ein Video, das
|
|
niemand sieht. */
|
|
const nurKamera = css.match(/\.kino\[data-layout="nur_kamera"\] \.leinwand__video \{[^}]*\}/);
|
|
ok(!!nurKamera && /display:\s*none/.test(nurKamera[0]),
|
|
"„nur Kamera“ nimmt das Video ganz weg und macht es nicht nur durchsichtig");
|
|
}
|
|
|
|
/* ---- Die Geschwindigkeit ---- */
|
|
ok(Array.isArray(TEMPI) && TEMPI.includes(1) && TEMPI.includes(0.5) && TEMPI.includes(2),
|
|
`es gibt ${TEMPI.length} Geschwindigkeiten: ${TEMPI.join(", ")}`);
|
|
/* YouTube kann 0,25 bis 2. Alles ausserhalb waere ein Knopf, den
|
|
der Spieler schweigend ignoriert -- und dann steht die Regie auf
|
|
3x und es laeuft normal. */
|
|
ok(TEMPI.every((t) => t >= 0.25 && t <= 2), "und alle liegen in dem, was YouTube kann");
|
|
|
|
for (const t of [1.5, 0.5, 1]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/video", {
|
|
method: "POST", keks: k.admin, rumpf: { tempo: t, laeuft: true, sekunde: 12 },
|
|
});
|
|
ok(a.code === 200 && a.j?.tempo === t, `${t}-fach laesst sich stellen (${a.code})`);
|
|
}
|
|
for (const t of [3, 0.1, "schnell", 1.1]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/video", {
|
|
method: "POST", keks: k.admin, rumpf: { tempo: t, laeuft: true, sekunde: 12 },
|
|
});
|
|
ok(a.code === 400 && a.j?.fehler === "tempo_unklar",
|
|
`„${t}" wird abgelehnt (${a.code})`);
|
|
}
|
|
|
|
/* DER TAKT DARF DAS TEMPO NICHT ZURUECKDREHEN. Die Videoroute
|
|
laeuft alle fuenf Sekunden; schriebe sie das Tempo jedes Mal mit,
|
|
wuerde ein Takt mit altem Wert die Einstellung ueberschreiben. */
|
|
await roh(CREW, "/workspace/api/reaktion/video", {
|
|
method: "POST", keks: k.admin, rumpf: { tempo: 1.5, laeuft: true, sekunde: 20 },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/video", {
|
|
method: "POST", keks: k.admin, rumpf: { laeuft: true, sekunde: 25 },
|
|
});
|
|
const nachTakt = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(nachTakt.j?.tempo === 1.5,
|
|
`ein Takt ohne Tempo laesst es stehen (${nachTakt.j?.tempo})`);
|
|
|
|
/* UND ES GEHT AN ALLE. Wer sie nicht bekaeme, saehe dasselbe Video
|
|
in einem anderen Tempo. */
|
|
ok(nachTakt.j?.tempo === 1.5 && Array.isArray(nachTakt.j?.tempi),
|
|
"auch ein Zuschauer erfaehrt Tempo und Auswahl");
|
|
await roh(CREW, "/workspace/api/reaktion/video", {
|
|
method: "POST", keks: k.admin, rumpf: { tempo: 1, laeuft: true, sekunde: 25 },
|
|
});
|
|
|
|
/* =====================================================================
|
|
DER GLEICHLAUF: WIE ALT IST DER STAND? (30.09.2026)
|
|
|
|
Bis heute schickte der Server seine UHRZEIT mit (`gesendet`), und
|
|
der Browser rechnete `Date.now() - gesendet`. Das vergleicht die
|
|
Uhr des Servers mit der Uhr des Telefons -- zwei Uhren, die nie
|
|
jemand abgeglichen hat.
|
|
|
|
Gemessen am echten Browser (mess-reaktion): Ein Geraet, dessen Uhr
|
|
30 Sekunden vorgeht, spielte das Video 29,97 Sekunden weiter als
|
|
alle anderen. Und wer mitten in der Sendung dazukam, stieg 6,91
|
|
Sekunden hinter allen ein, weil der gespeicherte Stand so alt war
|
|
wie der letzte Takt des Hosts.
|
|
|
|
Jetzt geht ein ALTER hinaus -- die Differenz zweier SERVERzeiten.
|
|
Der Empfaenger braucht dafuer keine eigene Uhr.
|
|
===================================================================== */
|
|
{
|
|
const jetztStellen = (sek, alter) => roh(CREW, "/workspace/api/reaktion/video", {
|
|
method: "POST", keks: k.admin, rumpf: { laeuft: true, sekunde: sek, alter },
|
|
});
|
|
const holen = async () => (await roh(CREW, "/workspace/api/reaktion", { keks: k.gast })).j;
|
|
|
|
await jetztStellen(100, 0);
|
|
const frisch = await holen();
|
|
ok(Number.isFinite(frisch?.alter) && frisch.alter >= 0 && frisch.alter < 500,
|
|
`ein frischer Stand ist fast null Millisekunden alt (${frisch?.alter})`);
|
|
|
|
/* ES IST WIRKLICH EIN ALTER UND KEINE FESTE ZAHL. Ohne diese
|
|
Zeile hiesse „0 ms" oben womoeglich nur, dass dort immer 0
|
|
steht -- und dann rechnete niemand je etwas dazu. */
|
|
await new Promise((r) => setTimeout(r, 1300));
|
|
const gereift = await holen();
|
|
ok(gereift?.alter >= 1200 && gereift?.alter <= 2600,
|
|
`nach 1,3 s ist derselbe Stand 1,3 s alt (${gereift?.alter} ms)`);
|
|
|
|
/* UND DIE RECHNUNG GEHT AUF. Das ist der eigentliche Zweck:
|
|
sekunde + alter muss die Stelle ergeben, an der die Sendung
|
|
jetzt steht. */
|
|
const stelle = (gereift.sekunde || 0) + (gereift.alter || 0) / 1000;
|
|
ok(Math.abs(stelle - 101.3) < 0.4,
|
|
`daraus folgt die richtige Stelle: ${stelle.toFixed(2)} s (erwartet ~101,3)`);
|
|
|
|
/* ---- Was der Host ueber seine eigene Ablesung sagt ----------
|
|
Er misst oertlich, wie alt sie beim Absenden war. Das gehoert
|
|
dazugerechnet -- sonst saesse jeder Zuschauer genau um diese
|
|
Spanne zu weit hinten. */
|
|
await jetztStellen(200, 3000);
|
|
const mitAlter = await holen();
|
|
ok(mitAlter?.alter >= 3000 && mitAlter?.alter < 3600,
|
|
`eine 3 s alte Ablesung kommt auch als 3 s alt an (${mitAlter?.alter})`);
|
|
|
|
/* GEDECKELT, WEIL ES VOM BROWSER KOMMT. Was hereinkommt, wird
|
|
geprueft und nicht geglaubt -- sonst setzte eine einzige
|
|
falsche Zahl alle Zuschauer an eine erfundene Stelle. */
|
|
await jetztStellen(300, 999999);
|
|
const frech = await holen();
|
|
ok(frech?.alter !== null && frech.alter <= 5600,
|
|
`ein unsinniges Alter wird auf 5 s gedeckelt (${frech?.alter})`);
|
|
|
|
await jetztStellen(310, -8000);
|
|
const negativ = await holen();
|
|
ok(negativ?.alter !== null && negativ.alter >= 0 && negativ.alter < 600,
|
|
`ein negatives Alter gilt als null (${negativ?.alter})`);
|
|
|
|
/* ---- Der dritte Ausgang: „weiss ich nicht" -------------------
|
|
Ein Stand ohne Zeitstempel (alte Zeile, frisch aufgeraeumte
|
|
Sendung) darf NICHT als „null Millisekunden alt" durchgehen.
|
|
`null` heisst beim Empfaenger „nicht hochrechnen"; eine 0
|
|
hiesse „taufrisch" und schickte alle an eine alte Stelle. */
|
|
/* EIGENES HANDLE. Das vom Anfang der Datei ist laengst
|
|
geschlossen (Zeile 108) -- die Pruefung legt die Leute an und
|
|
ueberlaesst die Datenbank danach dem Server. */
|
|
const { DatabaseSync: DB3 } = await import("node:sqlite");
|
|
const d3 = new DB3(process.env.WORKSPACE_DB);
|
|
d3.prepare("UPDATE reaktion SET sekunde_am = 0 WHERE id = 1").run();
|
|
const ohne = await holen();
|
|
ok(ohne?.alter === null,
|
|
`ohne Zeitstempel ist das Alter ausdruecklich unbekannt (${JSON.stringify(ohne?.alter)})`);
|
|
|
|
/* UND EIN UNMOEGLICH ALTER STAND EBENSO. Eine Zeile aus der
|
|
vorigen Woche wuerde sonst „604800000 ms" melden, und der
|
|
Browser rechnete sie brav dazu. */
|
|
d3.prepare("UPDATE reaktion SET sekunde_am = ? WHERE id = 1")
|
|
.run(Date.now() - 7 * 86400000);
|
|
const uralt = await holen();
|
|
ok(uralt?.alter === null,
|
|
`ein eine Woche alter Stand gilt als unbekannt (${JSON.stringify(uralt?.alter)})`);
|
|
|
|
d3.close();
|
|
|
|
/* Und wieder in einen brauchbaren Zustand -- die Pruefungen
|
|
danach sollen keinen Scherbenhaufen vorfinden. */
|
|
await jetztStellen(0, 0);
|
|
|
|
/* ---- UND EIN RIEGEL GEGEN DIE RUECKKEHR ---------------------
|
|
|
|
Die alte Zeile sah harmlos aus:
|
|
|
|
const unterwegs = Date.now() - stand.gesendet;
|
|
|
|
Der Kommentar daneben erklaerte sogar richtig, warum die
|
|
Laufzeit dazugehoert. Nur verglich sie die Uhr des Servers mit
|
|
der Uhr des Telefons -- und das faellt oertlich NIE auf: Auf
|
|
einem Rechner sind beide Uhren dieselbe. Erst als jemand die
|
|
Uhr absichtlich verstellt hat, kam es heraus.
|
|
|
|
GEMESSEN WIRD AM KOMMENTARFREIEN TEXT. Im Kopf der Datei steht
|
|
die alte Zeile als Beispiel, und ein Beispiel im Kommentar ist
|
|
kein Programm. Genau diese Falle hat mich heute frueh beim
|
|
Abzeichen schon einmal erwischt. */
|
|
const { jsOhneKommentar: ohneK } = await import("./helfer-ohne-kommentar.mjs");
|
|
const rjs = ohneK(readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8"));
|
|
|
|
ok(!/\bgesendet\b/.test(rjs),
|
|
"der Browser rechnet nicht mehr mit der Uhrzeit des Servers");
|
|
|
|
const kern = rjs.slice(rjs.indexOf("function sollJetzt"));
|
|
const rumpf = kern.slice(0, kern.indexOf("\n }"));
|
|
ok(rumpf.length > 40 && /performance\.now\(\)/.test(rumpf),
|
|
"die Soll-Stelle wird mit der Stoppuhr gerechnet, nicht mit dem Kalender");
|
|
ok(/anker\.tempo/.test(rumpf),
|
|
"und das Tempo geht in die Hochrechnung ein (bei 2x laeuft die Videouhr doppelt)");
|
|
ok(!/Date\.now\(\)/.test(rumpf),
|
|
"in der Rechnung selbst steckt keine Tagesuhr mehr");
|
|
|
|
/* GEGENPROBE: Die Suche liest wirklich etwas. Ohne sie hiessen
|
|
die drei Zeilen darueber womoeglich nur, dass `rumpf` leer
|
|
ist -- und ein leerer Text besteht jede Verbotspruefung. */
|
|
ok(/const her =/.test(rumpf),
|
|
`die Suche findet den Rumpf von sollJetzt (${rumpf.length} Zeichen)`);
|
|
|
|
/* ---- KEIN NAME ZWEIMAL ---------------------------------------
|
|
|
|
`reaktion.js` ist ein einziger Rumpf von 4400 Zeilen. Wer dort
|
|
einen Funktionsnamen ein zweites Mal vergibt, ueberschreibt
|
|
die erste -- und zwar RUECKWIRKEND fuer die ganze Datei, auch
|
|
fuer Aufrufe weit davor. Es gibt keine Fehlermeldung.
|
|
|
|
GENAU DAS IST AM 30.09.2026 PASSIERT. Die neue Nachfuehrung
|
|
hiess `springen(ziel)` („spring AUF diese Stelle"), der Host
|
|
hatte laengst ein `springen(um)` („spring UM so viele
|
|
Sekunden"). Gewonnen hat das zweite. Ein Zuschauer bei
|
|
Sekunde 100, dem „geh auf 101" gesagt wird, waere bei 201
|
|
gelandet.
|
|
|
|
DIE MESSUNG IM BROWSER HAT ES NICHT GEFUNDEN -- oertlich blieb
|
|
der Abstand unter der Sprungschwelle, die Zeile lief in drei
|
|
Laeufen kein einziges Mal. Gefunden hat es erst diese Frage an
|
|
die ganze Datei. */
|
|
const namen = [...rjs.matchAll(/^\s{0,4}(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)]
|
|
.map((t) => t[1]);
|
|
const doppelt = [...new Set(namen.filter((n, i) => namen.indexOf(n) !== i))];
|
|
ok(namen.length > 40 && doppelt.length === 0,
|
|
doppelt.length
|
|
? `ZWEIMAL VERGEBEN: ${doppelt.join(", ")} — die spaetere gewinnt, lautlos`
|
|
: `alle ${namen.length} Funktionsnamen in reaktion.js sind einmalig`);
|
|
}
|
|
|
|
/* ---- Das zweite Video ---- */
|
|
const A = "dQw4w9WgXcQ";
|
|
const B = "9bZkp7q19f0";
|
|
await roh(CREW, "/workspace/api/reaktion/video", {
|
|
method: "POST", keks: k.admin, rumpf: { video: A, laeuft: true, sekunde: 0 },
|
|
});
|
|
|
|
const nurHost2 = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
|
|
method: "POST", keks: k.modi, rumpf: { video: B },
|
|
});
|
|
ok(nurHost2.code === 403, `ein Modi legt kein zweites Video bereit (${nurHost2.code})`);
|
|
|
|
const selbes = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
|
|
method: "POST", keks: k.admin, rumpf: { video: A },
|
|
});
|
|
ok(selbes.code === 409 && selbes.j?.fehler === "schon_im_bild",
|
|
`dasselbe Video auf beide Seiten geht nicht (${selbes.code})`);
|
|
|
|
const ohneZweites = await roh(CREW, "/workspace/api/reaktion/tauschen", {
|
|
method: "POST", keks: k.admin, rumpf: {},
|
|
});
|
|
ok(ohneZweites.code === 409 && ohneZweites.j?.fehler === "kein_zweites",
|
|
`ohne zweites Video gibt es nichts zu tauschen (${ohneZweites.code})`);
|
|
|
|
const gelegt = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
|
|
method: "POST", keks: k.admin, rumpf: { video: `https://youtu.be/${B}` },
|
|
});
|
|
ok(gelegt.code === 200 && gelegt.j?.zweit?.video === B,
|
|
`eine ganze Adresse wird verstanden (${gelegt.j?.zweit?.video})`);
|
|
|
|
/* ES GEHT NICHT AN ALLE. Was daneben vorbereitet ist, ist die
|
|
Rueckhand -- die verraet man nicht vorher. */
|
|
const beimGast = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(beimGast.j?.zweit === null, "ein Zuschauer sieht das zweite Video nicht");
|
|
{
|
|
const quelle = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8");
|
|
const zeile = quelle.match(/const \{([^}]*)\} = l;/);
|
|
ok((zeile ? zeile[1] : "").includes("zweit"),
|
|
"und es steht auch nicht im Rundruf");
|
|
}
|
|
|
|
/* ---- Hin und her, und jedes merkt sich seine Stelle ---- */
|
|
const hin = await roh(CREW, "/workspace/api/reaktion/tauschen", {
|
|
method: "POST", keks: k.admin, rumpf: { sekunde: 87.5 },
|
|
});
|
|
ok(hin.code === 200 && hin.j?.video === B, `getauscht: jetzt laeuft B (${hin.j?.video})`);
|
|
ok(hin.j?.zweit?.video === A && Math.abs((hin.j?.zweit?.sekunde || 0) - 87.5) < 0.01,
|
|
`und A liegt bei 87,5 s bereit (${hin.j?.zweit?.sekunde})`);
|
|
ok(Math.abs((hin.j?.sekunde || 0) - 0) < 0.01,
|
|
`B faengt bei 0 an -- es lief noch nie (${hin.j?.sekunde})`);
|
|
|
|
const zurueck = await roh(CREW, "/workspace/api/reaktion/tauschen", {
|
|
method: "POST", keks: k.admin, rumpf: { sekunde: 31 },
|
|
});
|
|
ok(zurueck.code === 200 && zurueck.j?.video === A, `zurueck: wieder A (${zurueck.j?.video})`);
|
|
ok(Math.abs((zurueck.j?.sekunde || 0) - 87.5) < 0.01,
|
|
`und zwar GENAU DORT, wo es aufgehoert hat: ${zurueck.j?.sekunde} s`);
|
|
ok(Math.abs((zurueck.j?.zweit?.sekunde || 0) - 31) < 0.01,
|
|
`B merkt sich jetzt 31 s (${zurueck.j?.zweit?.sekunde})`);
|
|
|
|
/* GEGENPROBE: Ohne die gemerkte Stelle waere „hin und her" ein
|
|
Neustart von vorn -- und dann schaltet niemand zweimal. */
|
|
ok((zurueck.j?.sekunde || 0) > 1,
|
|
"Gegenprobe: die Stelle ist nicht einfach null");
|
|
|
|
/* ---- Wegraeumen ---- */
|
|
const weg = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
|
|
method: "POST", keks: k.admin, rumpf: { video: "" },
|
|
});
|
|
ok(weg.code === 200 && weg.j?.zweit === null, `ein leeres Feld raeumt es weg (${weg.code})`);
|
|
const danach = await roh(CREW, "/workspace/api/reaktion/tauschen", {
|
|
method: "POST", keks: k.admin, rumpf: {},
|
|
});
|
|
ok(danach.code === 409, `und danach gibt es nichts mehr zu tauschen (${danach.code})`);
|
|
|
|
/* ---- Und die Seite? ---- */
|
|
{
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
const buehne = readFileSync(join(HIER, "..", "workspace", "assets", "js", "buehne.js"), "utf8");
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
|
|
ok(html.includes('id="tempo"') && html.includes('id="v-tauschen"')
|
|
&& html.includes('id="p-zweit"'),
|
|
"die Regie hat Tempo, Umschalter und das Feld fuers zweite Video");
|
|
/* BEIDE SPIELER MUESSEN DAS TEMPO NACHZIEHEN -- der im Saal und
|
|
der in OBS. Zieht nur einer nach, laeuft der Stream anders als
|
|
der Saal, und das faellt erst nach Minuten auf. */
|
|
for (const [name, quelle] of [["die Seite", js], ["die Buehne", buehne]]) {
|
|
ok(/setPlaybackRate/.test(quelle), `${name} stellt die Geschwindigkeit`);
|
|
/* Und nach einem Videowechsel nochmal: YouTube setzt sie beim
|
|
Laden auf 1 zurueck. */
|
|
ok(/loadVideoById[\s\S]{0,400}tempoNachziehen/.test(quelle),
|
|
`${name} zieht sie auch nach einem Videowechsel nach`);
|
|
ok(/getAvailablePlaybackRates/.test(quelle),
|
|
`${name} setzt keinen Wert, den der Spieler nicht kann`);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/* =======================================================================
|
|
18. DIE REGIELEISTE UEBER DEM SAAL
|
|
|
|
Filipe, 28.09.2026: „ich haette das gerne oben also nicht in dieser
|
|
kachel sondern ueber der kachel wo die videos laufen werden."
|
|
|
|
Geprueft wird hier, was sich aus dem Quelltext beweisen laesst --
|
|
Lage, Verdrahtung, Versprechen. Wie hoch sie ist und ob die Marke
|
|
sitzt, misst `mess-reaktion` im Browser; das kann kein Quelltext.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 18. Die Regieleiste ===");
|
|
{
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
|
|
const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
|
|
/* ---- SIE STEHT UEBER DEM SAAL, NICHT DARIN ----
|
|
Die Reihenfolge im Dokument ist die Aussage. Waere sie wieder im
|
|
Pult, stuende sie weiter unten -- und niemandem fiele es auf,
|
|
solange sie „irgendwo" da ist. */
|
|
const iLeiste = html.indexOf('id="regieleiste"');
|
|
const iKino = html.indexOf('id="teil-live"');
|
|
const iPult = html.indexOf('id="pult"');
|
|
ok(iLeiste > 0, "es gibt die Regieleiste");
|
|
ok(iLeiste < iKino, `sie steht vor dem Kino (${iLeiste} < ${iKino})`);
|
|
ok(iLeiste < iPult, "und vor dem Pult");
|
|
ok(html.indexOf('id="reiter"') < iPult,
|
|
"die Register stehen nicht mehr im Pult");
|
|
|
|
/* ---- JEDES REGISTER ZEIGT AUF EINE TAFEL, DIE ES GIBT ----
|
|
`aria-controls` ist ein Versprechen an Hilfsmittel. Zeigt es ins
|
|
Leere, sagt der Vorleser eine Tafel an, die niemand oeffnen
|
|
kann -- und gemerkt haette es nie jemand. */
|
|
const reiter = [...html.matchAll(/data-tafel="([a-z]+)"[^>]*aria-controls="([^"]+)"/g)]
|
|
.concat([...html.matchAll(/aria-controls="([^"]+)"[^>]*data-tafel="([a-z]+)"/g)]
|
|
.map((m) => [m[0], m[2], m[1]]));
|
|
ok(reiter.length >= 7, `${reiter.length} Register gefunden`);
|
|
const ohneTafel = reiter.filter(([, , ziel]) => !html.includes(`id="${ziel}"`));
|
|
ok(ohneTafel.length === 0,
|
|
ohneTafel.length
|
|
? `ZEIGT INS LEERE: ${ohneTafel.map((x) => x[2]).join(", ")}`
|
|
: "jedes Register zeigt auf eine Tafel, die es gibt");
|
|
ok(!html.includes('id="tafel-gibtesnicht"'),
|
|
"Gegenprobe: eine erfundene Tafel gibt es nicht");
|
|
|
|
/* ---- DAS VERSPRECHEN VON `role=tablist` ----
|
|
Steht die Rolle im Markup und loest das Programm sie nicht ein,
|
|
sagt ein Vorleser etwas an, was nicht stimmt. Das ist schlimmer
|
|
als gar keine Rolle. */
|
|
ok(/id="reiter"[^>]*role="tablist"/.test(html), "die Leiste ist eine Registerleiste");
|
|
/* Die Anfuehrungszeichen bleiben draussen -- im Quelltext steht
|
|
`'Home'`, in dieser Pruefung stand zuerst `"Home"`, und daran
|
|
ist sie gescheitert. Eine Pruefung, die die Schreibweise der
|
|
Anfuehrungszeichen mitprueft, prueft die falsche Sache. */
|
|
for (const taste of ["ArrowRight", "ArrowLeft", "Home", "End"]) {
|
|
ok(js.includes(taste), `das Programm kennt „${taste}"`);
|
|
}
|
|
ok(/tabIndex\s*=\s*gewaehlt \? 0 : -1/.test(js),
|
|
"genau ein Register liegt in der Tabulatorfolge");
|
|
|
|
/* ---- EIN REGISTER MACHT DIE ZUGEKLAPPTE REGIE AUF ----
|
|
Seit die Leiste oben steht, kann das Pult zu sein. Ohne diese
|
|
Zeile waere jedes Register bei zugeklappter Regie ein Knopf,
|
|
bei dem sichtbar nichts passiert. */
|
|
ok(/pult\.dataset\.auf = 'ja'/.test(js) && /reiterWechseln/.test(js),
|
|
"ein Register klappt die Regie auf");
|
|
|
|
/* ---- DIE MARKE UND IHRE ZWEITE ANZEIGE ----
|
|
Was gerechnet wird, braucht etwas daneben, das nicht rechnet. */
|
|
ok(html.includes('id="reiter-marke"'), "es gibt die gleitende Marke");
|
|
ok(/offsetLeft/.test(js) && /offsetWidth/.test(js) && /offsetHeight/.test(js),
|
|
"sie wird aus gemessenen Werten gesetzt, nicht gerechnet");
|
|
const knopfRegel = css.match(/\.reiter__knopf\[aria-selected="true"\] \{[^}]*\}/);
|
|
ok(!!knopfRegel && /color:/.test(knopfRegel[0]),
|
|
"das gewaehlte Register traegt ausserdem eine eigene Farbe");
|
|
|
|
/* ---- SIE GEHOERT NICHT IN DEN STREAM ----
|
|
Der Buehnenmodus zeigt nur, was Zuschauer sehen sollen. Eine
|
|
Regieleiste im Stream waere die Bedienung im Bild. */
|
|
ok(/body\[data-nur="buehne"\][^{]*\.regieleiste/.test(css.replace(/\s+/g, " ")),
|
|
"im Buehnenmodus ist sie weg");
|
|
|
|
/* ---- NUR EINE STELLE SAGT, WIE HOCH DIE ZEILEN SIND ----
|
|
|
|
Bis zum 28.09. standen zwei Angaben in derselben Datei; die
|
|
spaetere gewann und gab der Leiste null Pixel.
|
|
|
|
DIESE PRUEFUNG WAR ZU ENG UND HAT DESHALB DAS FALSCHE GEPRUEFT.
|
|
Sie verlangte, dass die Zeile mit `.saal` BEGINNT -- und hat
|
|
damit `body[data-nur="buehne"] .saal { grid-template-rows: … }`
|
|
nie gesehen. Die stand die ganze Zeit da, gab dem Saal im
|
|
Buehnenmodus eine einzige Zeile, und das auf Zeile 2 genagelte
|
|
Video landete in einer impliziten: Leinwand null Pixel hoch, im
|
|
Stream ein schwarzes Bild. Gemeldet hat es `mess-buehne`, nicht
|
|
diese Pruefung -- sie war gruen und wertlos.
|
|
|
|
Jetzt zaehlt JEDE Regel, in deren Auswahl `.saal` vorkommt. */
|
|
/* ==== NICHT „WIE VIELE", SONDERN „WIE VIELE ZEILEN" ==========
|
|
|
|
Diese Pruefung zaehlte Regeln: „genau eine darf die Zeilen des
|
|
Saals setzen." Das war die Faustregel zum Befund -- aber nicht
|
|
die Frage, um die es geht. Eine zweite FASSUNG (Handy,
|
|
aufgeklappte Regie) ist richtig und noetig; sie setzt die
|
|
Zeilen neu UND verteilt die Kinder neu. Der Fehler war eine
|
|
zweite ANGABE, die die Zeilenzahl VERKLEINERT und die Kinder
|
|
stehen laesst: Der Buehnenmodus gab dem Saal eine Zeile,
|
|
waehrend `#teil-live` auf Zeile 2 genagelt war -- Leinwand null
|
|
Pixel hoch.
|
|
|
|
Also: Jede Regel, deren Gegenstand der Saal IST, muss gleich
|
|
viele Zeilen setzen. Regeln, die nur zufaellig `.saal` im
|
|
Vorfahrenpfad haben (`.saal … .pult { … }`), zaehlen nicht --
|
|
sie beantworten eine andere Frage. */
|
|
/* `[^{}]*` FUER DEN RUMPF UND NICHT `[^}]*` -- der Unterschied
|
|
ist ein Blindfleck: Mit `[^}]*` verschluckt der Rumpf die
|
|
oeffnende Klammer eines `@media`, und jede Regel INNERHALB
|
|
eines Medienblocks faellt aus der Pruefung. Gemessen: Sie
|
|
meldete „alle 1 Fassungen", obwohl zwei dastanden. Ein Fehler
|
|
im Medienblock -- und genau dort steht die Handyansicht --
|
|
waere unbemerkt durchgegangen. */
|
|
const saalRegeln = (text) => [...text.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
|
.filter((m) => /grid-template-rows/.test(m[2]))
|
|
/* Der GEGENSTAND einer Auswahl ist ihr letzter Teil. Bei
|
|
`.saal .pult` ist das `.pult` -- und dessen Zeilen gehen den
|
|
Saal nichts an. */
|
|
.filter((m) => {
|
|
const teile = m[1].trim().split(/\s+|>/).filter(Boolean);
|
|
return /\.saal/.test(teile[teile.length - 1] || "");
|
|
})
|
|
.map((m) => ({
|
|
wahl: m[1].trim(),
|
|
zeilen: (m[2].match(/grid-template-rows:\s*([^;]+)/) || [, ""])[1]
|
|
.trim().split(/\s+(?![^(]*\))/).filter(Boolean).length,
|
|
}));
|
|
|
|
const saal = saalRegeln(css);
|
|
const zahlen = [...new Set(saal.map((x) => x.zeilen))];
|
|
ok(saal.length >= 1 && zahlen.length === 1,
|
|
zahlen.length === 1
|
|
? `alle ${saal.length} Fassungen des Saals haben ${zahlen[0]} Zeilen`
|
|
: `UNGLEICH VIELE ZEILEN: ` + saal.map((x) => `${x.wahl} → ${x.zeilen}`).join(" / "));
|
|
|
|
/* GEGENPROBE: Eine eingeschobene Fassung mit WENIGER Zeilen muss
|
|
auffallen -- genau die war der Buehnenmodus, und genau die hat
|
|
die alte Pruefung nicht gesehen. */
|
|
{
|
|
const erfunden = 'body[data-nur="probe"] .saal { grid-template-rows: minmax(0, 1fr); }';
|
|
const mit = saalRegeln(css + erfunden);
|
|
ok([...new Set(mit.map((x) => x.zeilen))].length === 2,
|
|
`Gegenprobe: eine Fassung mit weniger Zeilen faellt auf `
|
|
+ `(${[...new Set(mit.map((x) => x.zeilen))].join(" und ")})`);
|
|
}
|
|
|
|
/* ---- UND SIE GEHOERT NUR DENEN, DIE SENDEN ---- */
|
|
ok(/leiste\.hidden = !lage\.ich\.host/.test(js),
|
|
"nur wer die Sendung fuehrt, sieht sie");
|
|
}
|
|
|
|
|
|
/* =======================================================================
|
|
19. GROESSE UND ECKE DER KAMERAFENSTER
|
|
|
|
Filipe, 28.09.2026: „und danach perfektionierst du auch die
|
|
verschiedenen groessen von kamera und so."
|
|
|
|
Die Anordnung sagt das VERHAELTNIS von Video und Kameras. Was
|
|
fehlte, sind die zwei Dinge daneben: wie gross genau, und wo.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 19. Groesse und Ecke der Kamerafenster ===");
|
|
{
|
|
const { KAMERA_GROESSEN, KAMERA_ECKEN } = await import("./reaktion-tabellen.js");
|
|
|
|
/* ---- DIE LISTEN SELBST ----
|
|
Sechs Stufen statt eines Schiebers: Ein Schieber verspricht eine
|
|
stufenlose Einstellung, und dann sucht mitten in der Sendung
|
|
jemand nach „dem richtigen Wert", statt zu senden. */
|
|
ok(KAMERA_GROESSEN.length >= 4 && KAMERA_GROESSEN.includes(1),
|
|
`es gibt ${KAMERA_GROESSEN.length} Groessen: ${KAMERA_GROESSEN.join(", ")}`);
|
|
ok(KAMERA_GROESSEN.every((g) => g >= 0.5 && g <= 2),
|
|
"und alle liegen zwischen halb und doppelt");
|
|
/* Aufsteigend, weil die Knoepfe in dieser Reihenfolge stehen. Eine
|
|
Reihe, in der 1,5 vor 0,8 steht, sieht aus wie ein Fehler und
|
|
ist keiner -- man sucht trotzdem danach. */
|
|
ok(KAMERA_GROESSEN.every((g, i) => i === 0 || g > KAMERA_GROESSEN[i - 1]),
|
|
"die Groessen stehen aufsteigend");
|
|
ok(KAMERA_ECKEN.length === 4 && ["ol", "or", "ul", "ur"].every((e) => KAMERA_ECKEN.includes(e)),
|
|
`und vier Ecken: ${KAMERA_ECKEN.join(", ")}`);
|
|
|
|
/* ---- DIE VORGABE DER DATENBANK MUSS IN DER LISTE STEHEN ----
|
|
Sonst startet eine frische Anlage mit einem Wert, den der Server
|
|
selbst ablehnen wuerde -- und der erste Klick auf einen anderen
|
|
Knopf waere die einzige Gelegenheit, es zu merken. */
|
|
{
|
|
const tab = readFileSync(join(HIER, "reaktion-tabellen.js"), "utf8");
|
|
const vGross = tab.match(/kamera_gross REAL NOT NULL DEFAULT ([\d.]+)/);
|
|
const vEcke = tab.match(/kamera_ecke TEXT NOT NULL DEFAULT '([a-z]+)'/);
|
|
ok(!!vGross && KAMERA_GROESSEN.includes(Number(vGross[1])),
|
|
`die Vorgabe der Spalte (${vGross?.[1]}) steht in der Liste`);
|
|
ok(!!vEcke && KAMERA_ECKEN.includes(vEcke[1]),
|
|
`und die Vorgabe der Ecke (${vEcke?.[1]}) auch`);
|
|
}
|
|
|
|
/* ---- JEDE GROESSE LAESST SICH STELLEN ---- */
|
|
for (const g of KAMERA_GROESSEN) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: { kamera_gross: g },
|
|
});
|
|
ok(a.code === 200 && a.j?.kamera_gross === g, `Groesse ${g} (${a.code})`);
|
|
}
|
|
for (const e of KAMERA_ECKEN) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: { kamera_ecke: e },
|
|
});
|
|
ok(a.code === 200 && a.j?.kamera_ecke === e, `Ecke ${e} (${a.code})`);
|
|
}
|
|
|
|
/* ---- UND WAS NICHT IN DER LISTE STEHT, WIRD ABGELEHNT ----
|
|
Die Gegenprobe zu oben. Eine Pruefung, die nur bestaetigt, dass
|
|
das Erlaubte geht, bestaetigt nichts ueber die Grenze. */
|
|
const zuGross = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: { kamera_gross: 8 },
|
|
});
|
|
ok(zuGross.code === 400 && zuGross.j?.fehler === "kameragroesse_unklar",
|
|
`achtfach wird abgelehnt (${zuGross.code}/${zuGross.j?.fehler})`);
|
|
const quatschE = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: { kamera_ecke: "mitte" },
|
|
});
|
|
ok(quatschE.code === 400 && quatschE.j?.fehler === "ecke_unklar",
|
|
`eine erfundene Ecke wird abgelehnt (${quatschE.code}/${quatschE.j?.fehler})`);
|
|
const leer = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin, rumpf: {},
|
|
});
|
|
ok(leer.code === 400 && leer.j?.fehler === "nichts_zu_tun",
|
|
`ein Aufruf ohne Inhalt loest keinen Rundruf aus (${leer.code})`);
|
|
const fremd = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.modi, rumpf: { kamera_gross: 2 },
|
|
});
|
|
ok(fremd.code === 403, `ein Modi stellt die Groesse nicht (${fremd.code})`);
|
|
|
|
/* ---- DREI EINSTELLUNGEN IN EINEM AUFRUF ----
|
|
Das ist der Grund, warum es EIN Weg ist. Drei getrennte Aufrufe
|
|
waeren drei Rundrufe, und zwischen ihnen saehen die Zusehenden
|
|
eine Mischung: neue Anordnung, alte Ecke. */
|
|
const alles = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin,
|
|
rumpf: { layout: "gleich", kamera_gross: 1.5, kamera_ecke: "ol" },
|
|
});
|
|
ok(alles.code === 200 && alles.j?.layout === "gleich"
|
|
&& alles.j?.kamera_gross === 1.5 && alles.j?.kamera_ecke === "ol",
|
|
`Anordnung, Groesse und Ecke in einem Zug (${alles.code})`);
|
|
/* UND EINE FALSCHE ANGABE AENDERT GAR NICHTS. Sonst waere eine
|
|
abgelehnte Ecke eine halb durchgefuehrte Umstellung -- die
|
|
Anordnung stuende neu, die Ecke alt, und die Fehlermeldung
|
|
saehe aus, als sei nichts passiert. */
|
|
const halb = await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin,
|
|
rumpf: { layout: "kino", kamera_ecke: "diagonal" },
|
|
});
|
|
const danach = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(halb.code === 400 && danach.j?.layout === "gleich",
|
|
`eine falsche Angabe laesst auch das Gute stehen (${danach.j?.layout})`);
|
|
|
|
/* ---- DER STAND TRAEGT BEIDES UND DIE AUSWAHL DAZU ----
|
|
Die Regie baut ihre Knoepfe daraus. Baute sie sie aus einer
|
|
eigenen Liste, waere das eine zweite Liste, die irgendwann von
|
|
der ersten abweicht -- und dann steht ein Knopf da, den der
|
|
Server ablehnt. */
|
|
await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin,
|
|
rumpf: { layout: "kino", kamera_gross: 1, kamera_ecke: "ur" },
|
|
});
|
|
const stand = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(stand.j?.kamera_gross === 1 && stand.j?.kamera_ecke === "ur",
|
|
"der Stand traegt Groesse und Ecke");
|
|
ok(Array.isArray(stand.j?.kamera_groessen)
|
|
&& stand.j.kamera_groessen.length === KAMERA_GROESSEN.length
|
|
&& Array.isArray(stand.j?.kamera_ecken)
|
|
&& stand.j.kamera_ecken.length === 4,
|
|
"und die Auswahl kommt vom Server, nicht aus dem Browser");
|
|
|
|
/* ---- AUCH DIE ZUSEHENDEN BEKOMMEN ES ----
|
|
Es ist Teil des Bildes, nicht der Regie. Wer es nicht bekaeme,
|
|
saehe die Fenster woanders als der, der darueber redet. */
|
|
const gast = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(gast.j?.kamera_gross === 1 && gast.j?.kamera_ecke === "ur",
|
|
"auch ein Zuschauer bekommt Groesse und Ecke");
|
|
|
|
/* ---- WAS DER QUELLTEXT HERGIBT ---------------------------------- */
|
|
{
|
|
const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
const mld = readFileSync(join(HIER, "..", "workspace", "assets", "js", "meldung.js"), "utf8");
|
|
|
|
/* ==== KOMMENTARE RAUS, BEVOR IRGENDETWAS GEZAEHLT WIRD ========
|
|
Beim ersten Lauf meldete die Pruefung „1x steht eine feste
|
|
Breite am Fenster" -- und fand sie in einem Kommentar, der
|
|
genau davor warnt und die entfernte Zeile zitiert. Ein
|
|
Werkzeug, das bei jedem Lauf meckert, wird nach dem zweiten
|
|
Mal weggeklickt, zusammen mit dem echten Befund darin.
|
|
Gezaehlt werden Regeln, nicht Prosa. */
|
|
const regeln = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
|
|
/* JEDE ECKE BRAUCHT EINE REGEL -- sonst ist sie ein Knopf, der
|
|
nichts tut. */
|
|
const ohneEcke = KAMERA_ECKEN.filter((e) => !regeln.includes(`[data-ecke="${e}"]`));
|
|
ok(ohneEcke.length === 0,
|
|
ohneEcke.length ? `OHNE REGEL: ${ohneEcke.join(", ")}` : "jede Ecke hat eine eigene Regel");
|
|
ok(!regeln.includes('[data-ecke="mitte"]'),
|
|
"Gegenprobe: eine erfundene Ecke hat keine");
|
|
|
|
/* ---- EINE BREITE, EINE STELLE ----
|
|
Bis zum 28.09. stand die Breite eines Kamerafensters zweimal im
|
|
Stilblatt: als Grundregel und noch einmal fuer „Kameras gross".
|
|
Wer sie einstellbar macht, muesste beide mitdenken, und die
|
|
zweite wird vergessen. Das ist im selben Haus am selben Tag
|
|
zweimal passiert (die Saalzeilen, die Tafeln). */
|
|
const breiten = [...regeln.matchAll(/\.kamera\s*\{[^}]*?\bwidth:\s*clamp\(/g)];
|
|
ok(breiten.length === 0,
|
|
breiten.length
|
|
? `${breiten.length}x steht eine feste Breite am Fenster statt an --kam`
|
|
: "die Breite eines Fensters steht nur als --kam da");
|
|
ok(/--kam:\s*calc\([\s\S]*?var\(--kam-gross\)/.test(regeln),
|
|
"und der Faktor der Regie geht dort ein");
|
|
/* UND GERECHNET WIRD NUR EINMAL. `--kam-grund` darf jede
|
|
Anordnung und jede Bildschirmbreite fuer sich setzen -- die
|
|
Rechnung daraus steht an einer Stelle. Wer sie ein zweites Mal
|
|
hinschreibt, verliert den Faktor der Regie genau dort, wo er
|
|
es nicht sieht. Am 28.09. war das der Handy-Block. */
|
|
const rechnungen = [...regeln.matchAll(/--kam:\s*/g)];
|
|
ok(rechnungen.length === 1,
|
|
`die Breite wird ${rechnungen.length}x gerechnet (soll: 1x)`);
|
|
|
|
/* ---- EIN DECKEL, DER NICHT VOM FAKTOR ABHAENGT ----
|
|
Bei zweifacher Groesse auf einem schmalen Bildschirm deckte ein
|
|
einzelnes Fenster sonst die halbe Leinwand zu. Die Messung
|
|
nebenan prueft das am Bild; hier steht, dass es ueberhaupt
|
|
einen Deckel gibt. */
|
|
const stapel = regeln.match(/\.kameras \{[^}]*\}/);
|
|
ok(!!stapel && /max-width:\s*\d+%/.test(stapel[0]),
|
|
"der Stapel hat einen Deckel in Prozent der Leinwand");
|
|
|
|
/* ---- DIE REGIE BAUT AUS DER SERVERLISTE ---- */
|
|
ok(/lage\.kamera_groessen/.test(js) && /lage\.kamera_ecken/.test(js),
|
|
"die Regie baut ihre Knoepfe aus der Liste des Servers");
|
|
ok(!/\[\s*0\.6\s*,\s*0\.8\s*,\s*1\s*,/.test(js),
|
|
"Gegenprobe: sie haelt keine zweite Liste vor");
|
|
|
|
/* ---- EIN KNOPF, DER NICHTS TUT, WIRD NICHT ANGEBOTEN ----
|
|
Die Ecke wirkt nur bei „Video gross"; sonst stehen die Fenster
|
|
mittig oder fuellen die Flaeche. */
|
|
ok(/k\.disabled = !eckeGilt/.test(js),
|
|
"ausserhalb von „Video gross“ ist die Ecke nicht zu stellen");
|
|
/* UND DIE GROESSE BEI „NUR VIDEO“: Dort gibt es gar keine
|
|
Kamerafenster. Wer dann auf 2x drueckt, sieht nichts passieren
|
|
und sucht den Fehler bei sich. */
|
|
ok(/k\.disabled = !kamerasDa/.test(js),
|
|
"und bei „Nur Video“ ist auch die Groesse nicht zu stellen");
|
|
ok(/kamecke-hinweis/.test(js), "und die Regie sagt auch, warum");
|
|
|
|
/* ---- DER RUNDRUF BRINGT DAS GANZE BILD ----
|
|
Sonst saehen die Zusehenden die neue Anordnung mit der alten
|
|
Ecke, bis zufaellig etwas anderes den ganzen Stand nachzieht. */
|
|
ok(/was === 'layout'/.test(js) && /d\.kamera_gross/.test(js) && /d\.kamera_ecke/.test(js),
|
|
"die Meldung „layout“ bringt Anordnung, Groesse und Ecke");
|
|
|
|
/* ---- UND DIE ABLEHNUNG HAT EINEN DEUTSCHEN SATZ ----
|
|
Ein Fehlerkuerzel auf dem Bildschirm ist keine Antwort. */
|
|
for (const wort of ["kameragroesse_unklar", "ecke_unklar", "nichts_zu_tun"]) {
|
|
ok(mld.includes(wort), `„${wort}" hat einen deutschen Satz`);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/* =======================================================================
|
|
20. DER REGIEPLATZ UND DAS TEILEN DES BILDSCHIRMS
|
|
|
|
Filipe, 28.09.2026:
|
|
„kann das nicht bissl aufgeteilt sein auf links und rechts und
|
|
eine barre unten in der mitte."
|
|
„waere es nicht einfach eine bildschirm uebertragung zu
|
|
installieren und es zu perfektionieren?"
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 20. Regieplatz und Bildschirm ===");
|
|
{
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
|
|
const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
const regeln = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
|
|
/* ==== KEINE KENNUNG DARF BEIM UMBAU VERLORENGEHEN ==============
|
|
|
|
Das ist die wichtigste Pruefung dieses Abschnitts. Der
|
|
Regieplatz hat die halbe Sendung-Tafel neu sortiert, und jedes
|
|
`$('...')` im Programm zeigt auf ein Element im HTML. Faellt
|
|
eines heraus, ist es KEIN Fehler beim Laden -- `$()` gibt `null`
|
|
zurueck, und der Fehler erscheint erst, wenn jemand den Knopf
|
|
drueckt. Also mitten in einer Sendung.
|
|
|
|
Gezaehlt wird gegen die Kennungen im HTML. Was das Programm
|
|
selbst anlegt, steht darunter namentlich -- eine Liste von
|
|
Ausnahmen, die man SIEHT, ist besser als eine Pruefung, die
|
|
schweigt. */
|
|
{
|
|
const selbstGebaut = new Set([
|
|
/* Diese legt das Programm an oder sie stehen in anderen
|
|
Dateien der Kopfleiste. */
|
|
]);
|
|
const gebraucht = [...js.matchAll(/\$\('([a-z0-9-]+)'\)/g)].map((m) => m[1]);
|
|
const einmalig = [...new Set(gebraucht)].filter((x) => !selbstGebaut.has(x));
|
|
const fehlend = einmalig.filter((x) => !html.includes(`id="${x}"`));
|
|
ok(einmalig.length > 40, `das Programm greift auf ${einmalig.length} Kennungen zu`);
|
|
ok(fehlend.length === 0,
|
|
fehlend.length
|
|
? `ES FEHLEN IM HTML: ${fehlend.join(", ")}`
|
|
: "und jede davon gibt es im HTML");
|
|
/* Gegenprobe: Eine erfundene Kennung muss auffallen -- sonst
|
|
koennte die Liste auch leer sein und die Pruefung gruen. */
|
|
ok(!html.includes('id="gibtesnicht"'),
|
|
"Gegenprobe: eine erfundene Kennung steht in keinem HTML");
|
|
}
|
|
|
|
/* ---- LINKS, RECHTS UND EINE LEISTE UNTEN ---- */
|
|
ok(/laeuft sendung[\s\S]{0,60}laeuft bereit/.test(regeln),
|
|
"der Regieplatz hat links und rechts eine Kachel");
|
|
/* ==== UND DIE REGIE STEHT LINKS NEBEN DEM VIDEO ==============
|
|
Filipe: „anstatt alles nur unten zu haben wieso nicht rechts
|
|
und links noch ausnutzen um mehr ueberblick zu haben." */
|
|
ok(/"pult kino"/.test(regeln) && /"transport transport"/.test(regeln),
|
|
"aufgeklappt steht die Regie links, das Video daneben, der Transport unten");
|
|
ok(/:has\(\.pult\[data-auf="ja"\]\)/.test(regeln),
|
|
"und zugeklappt bleibt sie die schmale Leiste von vorher");
|
|
/* ==== ES MUESSEN KACHELN SEIN UND KEINE SPALTEN ==============
|
|
Filipe hat die erste Fassung zerrissen: „ich wollte was hoch
|
|
professionelles wo rechts links und unten kacheln sind wo alles
|
|
drin ist." Zwei Spalten mit einer Trennlinie sind eine Teilung,
|
|
keine Gliederung -- und das sieht man einer CSS-Regel an:
|
|
Rahmen, eigener Grund, ein Kopf. */
|
|
const kachel = regeln.match(/\.regiekachel \{[^{}]*\}/);
|
|
ok(!!kachel && /border:/.test(kachel[0]) && /background:/.test(kachel[0]),
|
|
"eine Regiekachel hat Rahmen und eigenen Grund");
|
|
ok((html.match(/class="regiekachel/g) || []).length >= 3,
|
|
`es gibt ${(html.match(/class="regiekachel/g) || []).length} Kacheln`);
|
|
/* ==== UND DIE LINKE IST NIE LEER ==============================
|
|
Sie zeigte nur einen Titel; ohne Video stand dort ein schwarzes
|
|
Loch neben einer vollen Spalte. Das Vorschaubild fuellt sie --
|
|
und wenn auch das fehlt, der Satz, der sagt, was zu tun ist. */
|
|
ok(html.includes('id="laufbild"') && html.includes('id="spur-bild"'),
|
|
"die linke Kachel hat ein Vorschaubild");
|
|
ok(/laufbild-leer/.test(js) && /trag rechts eine YouTube-Adresse ein/.test(html),
|
|
"und ohne Video sagt sie, was zu tun ist");
|
|
/* ==== JEDE FASSUNG BRAUCHT ALLE DREI KACHELN ================
|
|
Auch hier zaehlte die Pruefung Regeln -- und meldete einen
|
|
Fehler, sobald eine dritte Fassung dazukam (schmale Spalte
|
|
neben dem Video). Die Zahl der Fassungen ist aber nicht die
|
|
Frage. Die Frage ist, ob eine davon eine Kachel VERGISST: Dann
|
|
verschwindet sie in genau dieser Ansicht, und gemerkt haette
|
|
es niemand. */
|
|
const platz = [...regeln.matchAll(/([^{}]*\.regieplatz[^{}]*)\{([^{}]*)\}/g)]
|
|
.filter((m) => /grid-template-areas/.test(m[2]));
|
|
ok(platz.length >= 2, `${platz.length} Fassungen ordnen den Regieplatz`);
|
|
const ohneKachel = platz.filter((m) => !["laeuft", "sendung", "bereit"]
|
|
.every((x) => m[2].includes(x)));
|
|
ok(ohneKachel.length === 0,
|
|
ohneKachel.length
|
|
? `EINE FASSUNG VERGISST EINE KACHEL: ${ohneKachel.map((m) => m[1].trim()).join(" / ")}`
|
|
: "und jede davon nennt alle drei Kacheln");
|
|
|
|
/* Die Leiste traegt den ganzen Transport -- und zwar in EINER. */
|
|
/* ==== DER TRANSPORT LIEGT UNTER DEM SAAL, NICHT IM REGISTER
|
|
Er lag im Register „Sendung" und war damit weg, sobald jemand
|
|
auf „Ton" oder „Spenden" ging. Der Play-Knopf ist aber der
|
|
eine, den man mitten in einer Sendung blind treffen muss. */
|
|
const iPultEnde = html.indexOf('id="transport"');
|
|
ok(iPultEnde > html.indexOf('id="pult"'),
|
|
"der Transport steht hinter dem Pult");
|
|
ok(iPultEnde < html.indexOf("</main>"),
|
|
"und noch im Saal -- nicht in einem Register");
|
|
const iLeiste = html.indexOf('id="transport"');
|
|
const iEnde = html.indexOf("</main>", iLeiste);
|
|
const inLeiste = html.slice(iLeiste, iEnde);
|
|
for (const k of ["v-band", "v-zeit", "v-zurueck", "v-spiel", "v-vor", "v-naechstes", "tempo"]) {
|
|
ok(inLeiste.includes(`id="${k}"`), `„${k}" liegt in der Transportleiste`);
|
|
}
|
|
ok(!inLeiste.includes('id="p-titel"'),
|
|
"und die Eingabefelder liegen NICHT darin");
|
|
|
|
/* ---- DER BILDSCHIRM ---- */
|
|
ok(/getDisplayMedia/.test(js), "die Seite kann den Bildschirm holen");
|
|
ok(/replaceTrack/.test(js),
|
|
"und tauscht die Bildspur aus, statt eine zweite Leitung auszuhandeln");
|
|
ok(/audio:\s*false/.test(js),
|
|
"der Rechnerton kommt NICHT mit -- sonst waere alles doppelt");
|
|
ok(/addEventListener\('ended'/.test(js),
|
|
"der eigene Stopp-Knopf des Browsers wird gehoert");
|
|
ok(/bildschirmSpur \|\| meinStrom\.getVideoTracks\(\)\[0\]/.test(js),
|
|
"auch wer waehrenddessen dazukommt, bekommt den Bildschirm");
|
|
ok(/\.kamera\[data-bildschirm="ja"\] video/.test(regeln)
|
|
&& /object-fit:\s*contain/.test(regeln),
|
|
"ein geteilter Bildschirm wird nicht zugeschnitten");
|
|
ok(html.includes('id="s-teilen"'), "es gibt den Knopf in der Senderleiste");
|
|
|
|
/* ==== UND DIE WAHRHEIT STEHT AN DER BEDIENUNG ==================
|
|
Netflix und Disney+ bleiben beim Teilen schwarz -- das ist ihr
|
|
Kopierschutz und keine Einstellung. Wer das erst erfaehrt,
|
|
nachdem im Stream zehn Minuten ein schwarzes Rechteck stand,
|
|
erfaehrt es zu spaet. Deshalb ist der Hinweis Teil der Seite
|
|
und nicht Teil einer Hilfe, die niemand oeffnet. */
|
|
ok(/Netflix[\s\S]{0,80}schwarz/.test(html),
|
|
"und die Seite sagt, dass Netflix schwarz bleibt");
|
|
ok(/öffentliche Wiedergabe/.test(html),
|
|
"und warum ein Film nicht weitergesendet wird");
|
|
|
|
/* ---- AM SERVER ---- */
|
|
{
|
|
const an = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.admin, rumpf: { bildschirm: true },
|
|
});
|
|
ok(an.code === 200 && an.j?.ich?.bildschirm === true,
|
|
`der Host meldet das Teilen (${an.code})`);
|
|
const beiAnderen = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(beiAnderen.j?.host?.bildschirm === true,
|
|
"und alle sehen es an seinem Fenster");
|
|
const aus = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.admin, rumpf: { bildschirm: false },
|
|
});
|
|
ok(aus.code === 200 && aus.j?.ich?.bildschirm === false,
|
|
`und das Beenden auch (${aus.code})`);
|
|
const fremd = await roh(CREW, "/workspace/api/reaktion/senden", {
|
|
method: "POST", keks: k.gast, rumpf: { bildschirm: true },
|
|
});
|
|
ok(fremd.code === 403,
|
|
`wer nicht sendet, teilt auch nicht (${fremd.code})`);
|
|
}
|
|
}
|
|
|
|
|
|
/* =======================================================================
|
|
21. ALLES ZURUECKSETZEN
|
|
|
|
Filipe, 28.09.2026: „brauch ich auch noch einen button wo ich alles
|
|
easy zuruecksetzen kann."
|
|
|
|
Die wichtigste Frage dieses Abschnitts ist nicht, ob es leert --
|
|
sondern ob es das Richtige STEHEN LAESST. Ein Zuruecksetzen, das
|
|
eine bezahlte Spende mitnimmt, ist keine Bequemlichkeit, sondern
|
|
ein Datenverlust mit Knopf.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 21. Alles zuruecksetzen ===");
|
|
{
|
|
/* Erst etwas hinstellen, das verschwinden soll -- und etwas, das
|
|
bleiben muss. Ohne beides prueft man ein leeres Haus.
|
|
|
|
==== „VORBEREITUNG" UND NICHT „ZU" -- UND DAS IST DER PUNKT
|
|
|
|
Beim ersten Lauf stand hier „zu", und die Pruefung meldete
|
|
danach „der Chatverlauf steht nicht mehr". Der Befund war
|
|
richtig und die Pruefung trotzdem falsch: Eine Sendung zu
|
|
BEENDEN loescht den Live-Chat seit jeher mit Absicht (sonst
|
|
finge die naechste mit den Rufen der letzten an). Sie hat also
|
|
erst selbst geloescht und dann gefragt, ob noch etwas da ist --
|
|
und haette dieselbe Antwort auch gegeben, wenn das
|
|
Zuruecksetzen den Chat mitnaehme.
|
|
|
|
In „vorbereitung" bleibt der Chat stehen, und erst dann sagt
|
|
die Antwort etwas ueber das Zuruecksetzen. */
|
|
await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.admin, rumpf: { text: "Der bleibt stehen" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Wird geleert", video: "dQw4w9WgXcQ", vorschau: "https://x/y.jpg" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/liste", {
|
|
method: "POST", keks: k.admin, rumpf: { video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/layout", {
|
|
method: "POST", keks: k.admin,
|
|
rumpf: { layout: "gleich", kamera_gross: 2, kamera_ecke: "ol" },
|
|
});
|
|
/* ==== ES MUSS AUCH ETWAS DA SEIN, DAS BLEIBEN KANN ===========
|
|
Beim ersten Lauf stand hier „die Dogen sind unberuehrt (0
|
|
Staende)". Null bleibt aber auch dann null, wenn das
|
|
Zuruecksetzen sie mitnaehme -- die Zeile war gruen und wertlos.
|
|
Die Zahl der geprueften Dinge gehoert in die BEDINGUNG und
|
|
nicht nur in den Meldetext. */
|
|
await roh(CREW, "/workspace/api/spenden", {
|
|
method: "POST", keks: k.admin,
|
|
rumpf: { dogen_mille: 500, name: "Nachtfalter", person_id: IDS.gast },
|
|
});
|
|
const vorher = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
const spendenVorher = await roh(CREW, "/workspace/api/spenden", { keks: k.admin });
|
|
const dogenVorher = spendenVorher.j?.staende?.length || 0;
|
|
const letzteVorher = spendenVorher.j?.letzte?.length || 0;
|
|
ok(!!vorher.j?.titel && (vorher.j?.liste || []).length > 0,
|
|
`vorher steht etwas da (Titel „${vorher.j?.titel}", `
|
|
+ `${vorher.j?.liste?.length} in der Warteschlange)`);
|
|
|
|
const weg = await roh(CREW, "/workspace/api/reaktion/zuruecksetzen", {
|
|
method: "POST", keks: k.admin,
|
|
});
|
|
ok(weg.code === 200, `zuruecksetzen geht (${weg.code})`);
|
|
ok(weg.j?.titel === "" && weg.j?.video === "",
|
|
"Titel und Video sind leer");
|
|
ok((weg.j?.liste || []).length === 0, "die Warteschlange ist leer");
|
|
ok((weg.j?.gaeste || []).length === 0, "die Gaesteliste ist leer");
|
|
ok(weg.j?.layout === "kino" && weg.j?.kamera_gross === 1
|
|
&& weg.j?.kamera_ecke === "ur" && weg.j?.tempo === 1,
|
|
`das Bild steht wieder auf Vorgabe (${weg.j?.layout}, `
|
|
+ `${weg.j?.kamera_gross}x, ${weg.j?.kamera_ecke})`);
|
|
ok(weg.j?.stand === "zu", "und der Saal ist zu");
|
|
|
|
/* ==== UND DAS WICHTIGERE: WAS BLEIBEN MUSS, BLEIBT ============ */
|
|
const spendenNachher = await roh(CREW, "/workspace/api/spenden", { keks: k.admin });
|
|
ok(dogenVorher > 0 && letzteVorher > 0,
|
|
`es gab vorher etwas zu verlieren (${dogenVorher} Staende, `
|
|
+ `${letzteVorher} Spenden)`);
|
|
ok(dogenVorher > 0 && (spendenNachher.j?.staende?.length || 0) === dogenVorher,
|
|
`die Dogen der Leute sind unberuehrt (${dogenVorher} Staende)`);
|
|
ok(letzteVorher > 0 && (spendenNachher.j?.letzte?.length || 0) === letzteVorher,
|
|
`und die Spenden auch (${letzteVorher} gezeigte)`);
|
|
const chat = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin });
|
|
ok((chat.j?.beitraege || []).some((b) => b.text === "Der bleibt stehen"),
|
|
`der Chatverlauf steht noch (${chat.j?.beitraege?.length} Beitraege)`);
|
|
|
|
/* ---- NICHT WAEHREND EINER SENDUNG ----
|
|
Mitten im Live zurueckzusetzen zieht allen das Video unter den
|
|
Fuessen weg -- und zwar ohne Rueckweg. */
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin, rumpf: { titel: "Laeuft", video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "live" },
|
|
});
|
|
const imLive = await roh(CREW, "/workspace/api/reaktion/zuruecksetzen", {
|
|
method: "POST", keks: k.admin,
|
|
});
|
|
ok(imLive.code === 409 && imLive.j?.fehler === "laeuft_noch",
|
|
`waehrend der Sendung wird abgelehnt (${imLive.code})`);
|
|
const nochDa = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(nochDa.j?.titel === "Laeuft",
|
|
"und es ist dabei auch wirklich nichts passiert");
|
|
|
|
/* ---- UND NUR DER HOST ---- */
|
|
const fremd = await roh(CREW, "/workspace/api/reaktion/zuruecksetzen", {
|
|
method: "POST", keks: k.modi,
|
|
});
|
|
ok(fremd.code === 403, `ein Modi setzt nichts zurueck (${fremd.code})`);
|
|
|
|
/* ---- DER KNOPF SAGT VORHER, WAS ER TUT ----
|
|
Ein „Bist du sicher?" ohne die Liste ist keine Frage, sondern
|
|
eine Huerde: Man klickt sie weg und erfaehrt erst danach, was
|
|
fehlt. */
|
|
{
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
ok(html.includes('id="p-zuruecksetzen"'), "es gibt den Knopf");
|
|
ok(/frageNach\(\{[\s\S]{0,600}?bleibt:/.test(js),
|
|
"und er sagt vorher, was BLEIBT -- nicht nur, was verschwindet");
|
|
ok(/Spenden[\s\S]{0,80}Dogen[\s\S]{0,120}unberührt/.test(js),
|
|
"und nennt Spenden und Dogen dabei ausdruecklich");
|
|
ok(/p-zuruecksetzen'\)\.disabled = laeuftGerade/.test(js),
|
|
"im Live ist er grau");
|
|
}
|
|
|
|
await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "live" },
|
|
});
|
|
}
|
|
|
|
/* =======================================================================
|
|
DIE EINLADUNG -- geht sie raus, und an die Richtigen?
|
|
=======================================================================
|
|
|
|
Filipe, 29.09.2026: „dogfather und alle anderen sollen die
|
|
benachrichtigungen perfekt von dieser seite hier bekommen."
|
|
|
|
Bis zu diesem Tag verliess die Reaction das Haus nie. Sie rief beim
|
|
Einschalten `melden("reaktion", ...)`, und das laeuft ueber den
|
|
Ereignisstrom -- also NUR zu Leuten, die die Seite ohnehin offen
|
|
haben. Wer nicht hinschaut, erfuhr nichts.
|
|
|
|
GEPRUEFT WIRD DAS VERHALTEN, NICHT DER QUELLTEXT. Eine Textsuche
|
|
nach „benachrichtige" waere gruen gewesen, sobald das Wort irgendwo
|
|
steht -- auch in einem Zweig, den nichts aufruft. Hier geht die
|
|
Sendung wirklich auf Sendung, und danach wird nachgesehen, was in
|
|
`push_verschickt` steht.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== Die Einladung ===");
|
|
{
|
|
const { createServer } = await import("node:http");
|
|
const { createECDH } = await import("node:crypto");
|
|
|
|
/* Ein Push-Dienst, der alles annimmt. Er muss nichts entschluesseln
|
|
-- gefragt ist, WER eine bekommt. Dass der Inhalt unterwegs heil
|
|
bleibt, beweist pruef-push-weg mit einem nachgebauten Browser. */
|
|
const dienstPort = await eigenerPort(import.meta, "pruef-reaktion (Push-Dienst)", 1);
|
|
const dienst = createServer((q, a) => {
|
|
q.on("data", () => {});
|
|
q.on("end", () => { a.writeHead(201); a.end(); });
|
|
});
|
|
await new Promise((r) => dienst.listen(dienstPort, "127.0.0.1", r));
|
|
|
|
/* Erst zu -- sonst waere der naechste Schritt kein UEBERGANG nach
|
|
live, und eingeladen wird nur beim Uebergang. */
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
|
|
/* UND DANN EIN NEUES VIDEO. Seit dem 30.09. raeumt das Beenden die
|
|
Sendungsdaten weg -- ohne Video gibt es kein „auf Sendung" (400
|
|
kein_video). Genau das tut ein Mensch auch: Nach der einen
|
|
Sendung traegt er fuer die naechste ein neues Video ein. */
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Die naechste Sendung", video: "dQw4w9WgXcQ" },
|
|
});
|
|
|
|
const db2 = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
/* Je ein Geraet fuer JEDEN -- auch fuer die, die keine Einladung
|
|
bekommen sollen. Ohne ihr Geraet waere „hat nichts bekommen" die
|
|
Antwort auf die falsche Frage: Man wuesste nicht, ob die Auswahl
|
|
greift oder ob nur kein Telefon da war. */
|
|
for (const rolle of ["admin", "hand", "linke", "modi", "gast", "gast2", "creator"]) {
|
|
const kk = createECDH("prime256v1");
|
|
kk.generateKeys();
|
|
db2.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt)
|
|
VALUES (?,?,?,?,?)`)
|
|
.run(IDS[rolle], `http://127.0.0.1:${dienstPort}/push/${rolle}`,
|
|
kk.getPublicKey().toString("base64url"),
|
|
randomBytes(16).toString("base64url"), nun);
|
|
}
|
|
db2.close();
|
|
|
|
const anSendung = await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
ok(anSendung.code === 200, `auf Sendung (${anSendung.code})`);
|
|
|
|
/* Die Einladung wird losgeschickt und nicht abgewartet -- der Knopf
|
|
„Auf Sendung" soll sofort reagieren. Also hier warten, wo Warten
|
|
nichts kostet. */
|
|
await new Promise((r) => setTimeout(r, 2500));
|
|
|
|
const lies = () => {
|
|
const db3 = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const z = db3.prepare(
|
|
`SELECT person_id FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).all();
|
|
db3.close();
|
|
return new Set(z.map((x) => x.person_id));
|
|
};
|
|
const bekommen = lies();
|
|
|
|
const sollen = [["hand", "die rechte Hand"], ["linke", "die linke Hand"],
|
|
["modi", "ein Modi"], ["gast", "die Community"], ["gast2", "noch jemand aus der Community"]];
|
|
for (const [rolle, wort] of sollen) {
|
|
ok(bekommen.has(IDS[rolle]), `${wort} wird eingeladen`);
|
|
}
|
|
|
|
/* WER EINGESCHALTET HAT, BEKOMMT KEINE. Er hat gerade den Knopf
|
|
gedrueckt; eine Einladung zu etwas, das man selbst aufgemacht
|
|
hat, ist Rauschen. */
|
|
ok(!bekommen.has(IDS.admin),
|
|
"wer eingeschaltet hat, bekommt keine Einladung");
|
|
|
|
/* DAS ANDERE HAUS BLEIBT AUSSEN VOR. Fuer eine Creatorin gibt es
|
|
`reaktion.html` nicht -- sie bekaeme eine Meldung, die sie auf
|
|
ihre Startseite wirft. Eine Benachrichtigung, die nirgendwohin
|
|
fuehrt, ist schlimmer als keine. */
|
|
ok(!bekommen.has(IDS.creator),
|
|
"die Agentur bekommt keine Einladung ins Crew-Kino");
|
|
|
|
ok(bekommen.size === 5,
|
|
`genau fuenf Einladungen, nicht mehr (${bekommen.size})`);
|
|
|
|
/* ---- Die Bremse gegen den Neustart ---------------------------
|
|
Wer aus Versehen auf „Vorbereitung" und gleich wieder auf „Auf
|
|
Sendung" drueckt, laedt sonst zweimal ein. Fuenf Menschen
|
|
bekaemen zwei Meldungen fuer dieselbe Sendung -- der schnellste
|
|
Weg, dass jemand die Art abschaltet, und dann fehlt auch die
|
|
naechste. */
|
|
const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const vorher = db4.prepare(
|
|
`SELECT COUNT(*) AS n FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).get().n;
|
|
db4.close();
|
|
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } });
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
|
|
const db5 = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const nachher = db5.prepare(
|
|
`SELECT COUNT(*) AS n FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).get().n;
|
|
db5.close();
|
|
ok(nachher === vorher,
|
|
`ein Neustart laedt nicht noch einmal ein (${vorher} -> ${nachher})`);
|
|
|
|
await new Promise((r) => dienst.close(r));
|
|
}
|
|
|
|
/* =======================================================================
|
|
BEENDET HEISST LEER -- aber nicht vergessen
|
|
=======================================================================
|
|
|
|
Filipe, 30.09.2026, mit einem Bild des Pults („Titel der Sendung:
|
|
Test / YouTube: bm0sK2-KkW8 / Beginn: 29.09.2026 18:36"):
|
|
„wenn eine sendung beendet wurde soll alles automatisch
|
|
verschwinden. also von daten die da stehen von dem video."
|
|
|
|
Vorher blieb alles stehen. Beim naechsten Aufmachen stand dort der
|
|
Titel der letzten Sendung und ein Beginn, der in der Vergangenheit
|
|
liegt.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== Beendet heisst leer ===");
|
|
{
|
|
/* Die Sendung laeuft an dieser Stelle noch (aus dem Abschnitt
|
|
darueber). Erst werden die Felder gefuellt, damit es ueberhaupt
|
|
etwas zu leeren gibt. */
|
|
const beginn = new Date(Date.now() + 3600_000).toISOString();
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Die letzte Sendung", video: "dQw4w9WgXcQ",
|
|
vorschau: "https://example.invalid/bild.jpg", beginnt_am: beginn },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
|
|
method: "POST", keks: k.admin, rumpf: { video: "bm0sK2-KkW8" },
|
|
});
|
|
|
|
/* DIE VORAUSSETZUNG WIRD HERGESTELLT UND GEPRUEFT.
|
|
Ohne das misst dieser Abschnitt bei der kleinsten Aenderung
|
|
weiter oben etwas ganz anderes -- und meldet es als Befund. */
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
|
|
/* ---- DIE GEGENPROBE STEHT VORNE ----------------------------
|
|
Ohne sie bestuende die Pruefung darunter auch dann, wenn nie
|
|
etwas in den Feldern stand. */
|
|
const vorher = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(vorher.j?.titel === "Die letzte Sendung" && !!vorher.j?.video,
|
|
`vorher stehen Titel und Video im Pult ("${vorher.j?.titel}")`);
|
|
ok(!!vorher.j?.beginnt_am, "und ein Beginn");
|
|
ok(!!vorher.j?.zweit?.video, `und ein zweites Video (${vorher.j?.zweit?.video})`);
|
|
ok(vorher.j?.stand === "live",
|
|
`und die Sendung laeuft wirklich -- sonst misst dieser Abschnitt `
|
|
+ `etwas anderes (${vorher.j?.stand})`);
|
|
|
|
const anAus = await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
|
|
ok(anAus.code === 200, `die Sendung wird beendet (${anAus.code})`);
|
|
|
|
const nachher = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(!nachher.j?.titel, `der Titel ist weg ("${nachher.j?.titel}")`);
|
|
ok(!nachher.j?.video, `das Video ist weg ("${nachher.j?.video}")`);
|
|
ok(!nachher.j?.vorschau, "das Vorschaubild ist weg");
|
|
ok(!nachher.j?.beginnt_am, `der Beginn ist weg (${nachher.j?.beginnt_am})`);
|
|
ok(!nachher.j?.zweit?.video, "das zweite Video ist weg");
|
|
ok(!nachher.j?.laeuft && !nachher.j?.sekunde, "und die Stelle steht auf null");
|
|
|
|
/* ---- WEG VOM SCHREIBTISCH, NICHT AUS DER WELT ---------------
|
|
Der Verlaufseintrag wird VOR dem Leeren geschrieben. Schoebe
|
|
jemand das Leeren nach vorn, stuende dort ein leerer Titel --
|
|
und es faellt erst auf, wenn jemand nachsehen will, was letzte
|
|
Woche lief. */
|
|
const dbv = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const letzte = dbv.prepare(
|
|
"SELECT titel, video FROM reaktion_verlauf ORDER BY id DESC LIMIT 1").get();
|
|
dbv.close();
|
|
ok(letzte?.titel === "Die letzte Sendung",
|
|
`im Verlauf steht sie weiter ("${letzte?.titel}")`);
|
|
ok(letzte?.video === "dQw4w9WgXcQ", `samt Video (${letzte?.video})`);
|
|
|
|
/* ---- WER NUR VORBEREITET HAT, BEHAELT SEINE EINGABEN --------
|
|
Eine Entscheidung, und deshalb steht sie hier: Aus der
|
|
Vorbereitung zumachen heisst „ich hoere auf vorzubereiten",
|
|
nicht „ich habe gesendet". Ohne diese Zeile nimmt eine spaetere,
|
|
gut gemeinte Vereinfachung („leeren wir doch bei jedem zu") die
|
|
Eingaben unbemerkt weg. */
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Nur vorbereitet", video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } });
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
|
|
const probe = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(probe.j?.titel === "Nur vorbereitet",
|
|
`wer nur vorbereitet hat, behaelt seine Eingaben ("${probe.j?.titel}")`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
DER SPENDER-GLANZ IM CHAT (08.10.2026)
|
|
|
|
Filipe: „die leute die spenden gemacht haben sollen auch für eine
|
|
kurze zeit speziell aussehen je nach höhe von spende."
|
|
|
|
Geprueft wird hier der WEG: Traegt ein Chatbeitrag die Auskunft,
|
|
und steht sie nur bei dem, der wirklich gegeben hat? Dass die
|
|
Stufen stimmen und dass der Glanz ablaeuft, prueft `pruef-spenden`
|
|
-- dort steht die Leiter.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 18. Spender im Chat ===");
|
|
{
|
|
const { db } = await import("./workspace.js");
|
|
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Glanzprobe", video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
for (const rolle of ["admin", "gast", "gast2"]) {
|
|
await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] });
|
|
}
|
|
|
|
await roh(CREW, "/workspace/api/reaktion/chat",
|
|
{ method: "POST", keks: k.gast2, rumpf: { text: "Ich gebe gleich was" } });
|
|
await roh(CREW, "/workspace/api/reaktion/chat",
|
|
{ method: "POST", keks: k.admin, rumpf: { text: "Ich nicht" } });
|
|
|
|
/* ==== DIE GEGENPROBE AN DERSELBEN PERSON ======================
|
|
|
|
Beim ersten Lauf stand hier „kein Beitrag traegt einen Glanz" --
|
|
und das war rot, zu Recht: Abschnitt 16 legt fuer `gast` eine
|
|
Spende an, und die glaenzte noch. Die Pruefung hat damit
|
|
bewiesen, dass der Glanz funktioniert, und sich selbst dabei
|
|
fuer kaputt erklaert.
|
|
|
|
Richtig ist eine Gegenprobe, die NUR von dem abhaengt, was
|
|
dieser Abschnitt selbst tut: dieselbe Person vorher und
|
|
nachher. */
|
|
const ohne = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast2 });
|
|
const vorSpende = (ohne.j?.beitraege || []).find((b) => b.person_id === IDS.gast2);
|
|
ok(!!vorSpende && vorSpende.spende === null,
|
|
"Gegenprobe: vor der Spende traegt sein Beitrag keinen Glanz");
|
|
|
|
/* Eine gezeigte Spende -- direkt in die Tabelle, weil hier der
|
|
CHAT geprueft wird und nicht der Weg einer Spende. Die
|
|
Stufenleiter steht ab Werk auf 5/15/45 Minuten. */
|
|
db().prepare(`INSERT INTO spenden
|
|
(betrag_cent, waehrung, name, gruss, person_id, herkunft, stand, erstellt, gezeigt_am)
|
|
VALUES (?,?,?,?,?,'hand','bestaetigt',?,?)`)
|
|
.run(2000, "EUR", "Lenny", "", IDS.gast2,
|
|
new Date().toISOString(), new Date().toISOString());
|
|
|
|
const mit = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast2 });
|
|
const meiner = (mit.j?.beitraege || []).find((b) => b.person_id === IDS.gast2);
|
|
const fremder = (mit.j?.beitraege || []).find((b) => b.person_id === IDS.admin);
|
|
ok(!!meiner?.spende, "wer gespendet hat, traegt den Glanz am Beitrag");
|
|
ok(/^#[0-9a-fA-F]{6}$/.test(meiner?.spende?.farbe || ""),
|
|
`mit einer echten Farbe (${meiner?.spende?.farbe})`);
|
|
ok(!!meiner?.spende?.stufe, `und dem Namen der Stufe (${meiner?.spende?.stufe})`);
|
|
ok(meiner?.spende?.noch_ms > 0,
|
|
`und einer Restdauer statt einer Uhrzeit (${meiner?.spende?.noch_ms} ms)`);
|
|
ok(fremder?.spende === null, "und der Nachbar daneben nicht");
|
|
|
|
/* ==== AUCH DER EINZELNE NEUE BEITRAG TRAEGT IHN ==============
|
|
Es gibt zwei Ausgaenge (Liste beim Laden, Meldung beim
|
|
Schreiben). Wer nur einen pflegt, hat den Glanz nach dem
|
|
Neuladen -- und beim Schreiben nicht. */
|
|
const neu = await roh(CREW, "/workspace/api/reaktion/chat",
|
|
{ method: "POST", keks: k.gast2, rumpf: { text: "Noch einer" } });
|
|
ok(!!neu.j?.spende,
|
|
"auch ein frisch geschriebener Beitrag bringt ihn gleich mit");
|
|
|
|
/* ==== UND DER ROLLENSCHLUESSEL BLEIBT DRAUSSEN ===============
|
|
Dieselbe Regel wie am 07.10.2026: Was der Saal nicht wissen
|
|
muss, verlaesst das Haus nicht. Der Glanz traegt Farbe, Namen
|
|
und Dauer -- keine Personennummer eines anderen, keinen Betrag
|
|
in Geld. */
|
|
ok(meiner?.spende?.dogen_mille > 0 && meiner?.spende?.cent === undefined,
|
|
"er traegt Dogen und kein Geld");
|
|
|
|
await roh(CREW, "/workspace/api/reaktion/stand",
|
|
{ method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
|
|
}
|
|
|
|
/* =======================================================================
|
|
19. WAS AUF DER SEITE STEHEN MUSS (08.10.2026)
|
|
|
|
Drei Umbauten dieses Tages haben Bedienteile, die es vorher nicht
|
|
gab. Eine Funktion, die nur im Programm existiert und nicht im
|
|
Dokument, ist nicht da -- und gemerkt wird es in einer Sendung.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 19. Vorraum, Geraete, zweites Video ===");
|
|
{
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
|
|
const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
|
|
const css = readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
|
|
|
|
/* ---- Der Vorraum ---- */
|
|
ok(/id="vorraum"/.test(html), "der Vorraum steht in der Seite");
|
|
/* ZWEI AUSGAENGE. Ein Vorhang, aus dem nur ein Weg herausfuehrt,
|
|
ist keine Frage -- und er stuende ausgerechnet vor jemandem, der
|
|
gerade nur zuschauen wollte. */
|
|
ok(/id="vorraum-ja"/.test(html) && /id="vorraum-nein"/.test(html),
|
|
"und er hat zwei Ausgaenge: dabei sein und lieber nicht");
|
|
ok(/id="vorraum-kamera"/.test(html) && /id="vorraum-mikro"/.test(html),
|
|
"Kamera und Mikro lassen sich VORHER waehlen");
|
|
ok(/id="vorraum-video"/.test(html) && /muted/.test(html),
|
|
"das eigene Bild laeuft darin -- stumm");
|
|
ok(/vorraumZu\(true\)/.test(js),
|
|
"und beim Ablehnen wird die Kamera wirklich angehalten");
|
|
ok(/\.vorraum\b/.test(css), "der Vorraum hat eine Regel im Stilblatt");
|
|
|
|
/* ---- Die Geraete ---- */
|
|
ok(/id="geraete"/.test(html), "das Register „Ton“ hat einen Geraeteteil");
|
|
ok(/id="s-geraete"/.test(html) && /id="geraetefenster"/.test(html),
|
|
"und auch Gaeste kommen daran -- ueber die Senderleiste");
|
|
/* ==== DER EIGENTLICHE FUND VOM 08.10.2026 ====================
|
|
Hier stand fest `echoCancellation: true, noiseSuppression: true`.
|
|
Ueber einem Mischpult ist das falsch: Der Duo hat Gate,
|
|
Kompressor und EQ schon gemacht, und die Echo-Unterdrueckung
|
|
des Browsers duckt danach, sobald von der Gegenseite Ton kommt.
|
|
Wenn diese Zeile je wieder fest dasteht, ist der Pultmodus
|
|
wirkungslos -- und es faellt erst im Stream auf. */
|
|
ok(!/audio:\s*\{\s*echoCancellation:\s*true/.test(js),
|
|
"Gegenprobe: die Echo-Unterdrueckung steht nicht mehr fest auf an");
|
|
ok(/echoCancellation:\s*!pult/.test(js),
|
|
"sie haengt am Pultmodus");
|
|
ok(/autoGainControl:\s*!pult/.test(js),
|
|
"und die Lautstaerke-Automatik ebenso");
|
|
ok(/OverconstrainedError/.test(js),
|
|
"ein Geraet, das nicht mehr da ist, hat einen eigenen Ausgang");
|
|
ok(/setSinkId/.test(js), "und der Ausgabeweg laesst sich waehlen");
|
|
|
|
/* ---- Das zweite Video ---- */
|
|
ok(/id="zweitbild"/.test(html) && /id="zweit-titel"/.test(html),
|
|
"das zweite Video wird gezeigt und nicht nur eingetippt");
|
|
ok(/id="zweit-stelle"/.test(html),
|
|
"samt der gemerkten Stelle -- sie ist der Grund fuer den zweiten Platz");
|
|
ok(/id="p-zweit-tausch"/.test(html),
|
|
"und es laesst sich von hier aus umschalten");
|
|
ok(/dataset\.was = 'bereit'/.test(js),
|
|
"aus der Warteschlange laesst sich eines bereitlegen");
|
|
|
|
/* ---- Die fertigen Saetze und der Platz ---- */
|
|
ok(/id="geben-satzreihe"/.test(html),
|
|
"die fertigen Saetze stehen im Spendenkasten");
|
|
ok(/id="saetze"/.test(html) && /id="saetze-sichern"/.test(html),
|
|
"und die Leitung pflegt sie im Register „Gestaltung“");
|
|
ok(/id="platzwahl"/.test(html), "der Platz der Karte laesst sich stellen");
|
|
|
|
/* ---- Der Werkstoff ---- */
|
|
ok(/--w-hoch:/.test(css) && /--w-tief:/.test(css),
|
|
"die Seite hat eine Hoehensprache (was obenauf liegt, was vertieft ist)");
|
|
ok(/:focus-visible/.test(css),
|
|
"und wer mit der Tastatur bedient, wird gesehen");
|
|
}
|
|
|
|
/* =======================================================================
|
|
20. DAS FOYER (08.10.2026)
|
|
|
|
Filipe: „diese kachel soll nicht mehr geschlossen sein, ABER wenn
|
|
man drauf drückt geht zuerst eine seite auf mit reihen/kategorien.
|
|
links eine reihe wo die nächsten geplanten videos schon bereit
|
|
stehen. wan und umd wie viel uhr … und das soll geschlossen sein
|
|
außer für vanvan und dogfather. in der mitte sollen die leute dan
|
|
wenn eine sendung läuft drauf drücken können und der sendung
|
|
beitreten … und dan rechts … da können die leute videos
|
|
reinschicken und nur ich und vanvan sehen sie."
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 20. Das Foyer: Plan, Eintritt, Vorschlaege ===");
|
|
{
|
|
/* ---- Die Seite selbst ---- */
|
|
for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) {
|
|
const s = await roh(CREW, "/workspace/reaktion-foyer.html", { keks: k[rolle] });
|
|
ok(s.code === 200, `${rolle}: kommt ins Foyer (${s.code})`);
|
|
}
|
|
{
|
|
const kAgentur = await anmelden("admin", AGENTUR);
|
|
const a = await roh(AGENTUR, "/workspace/reaktion-foyer.html", { keks: kAgentur });
|
|
ok(!!kAgentur && a.code === 404, `und die Agentur kennt es nicht (${a.code})`);
|
|
}
|
|
/* DIE KACHEL ZEIGT AUFS FOYER UND NICHT MEHR IN DEN SAAL. Zeigte
|
|
sie weiter auf `reaktion.html`, gaebe es das Foyer zwar -- nur
|
|
fuehrte kein Weg hin. */
|
|
{
|
|
const w = await import("./workspace.js");
|
|
const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW);
|
|
const kachel = (alle || []).find((b) => b.name === "Reaction");
|
|
ok(kachel?.ziel === "reaktion-foyer.html",
|
|
`die Kachel fuehrt ins Foyer (${kachel?.ziel})`);
|
|
ok(kachel?.live === true,
|
|
"und traegt weiterhin ihren Zustand (grau / bernstein / rot)");
|
|
/* ==== UND DER SAAL BLEIBT TROTZDEM ERREICHBAR ==============
|
|
Er hat seit heute KEINE Kachel mehr, und die Adressregel vom
|
|
15.09. verlangt eine. Ohne den Eintrag in
|
|
OHNE_KACHEL_UEBERALL liefe der Knopf „Jetzt beitreten" auf
|
|
die Startseite -- wortlos. Genau so ist diese Pruefung beim
|
|
Umhaengen rot geworden. */
|
|
ok(w.OHNE_KACHEL_UEBERALL.has("/workspace/reaktion.html"),
|
|
"und der Saal ist ohne Kachel trotzdem erreichbar");
|
|
}
|
|
|
|
/* ---- Der Termin an der Warteschlange ---- */
|
|
await roh(CREW, "/workspace/api/reaktion/zuruecksetzen", { method: "POST", keks: k.admin });
|
|
const angehaengt = await roh(CREW, "/workspace/api/reaktion/liste", {
|
|
method: "POST", keks: k.admin, rumpf: { video: "dQw4w9WgXcQ" },
|
|
});
|
|
const zeile = (angehaengt.j?.liste || [])[0];
|
|
ok(!!zeile, `ein Video liegt bereit (${angehaengt.code})`);
|
|
ok(zeile?.wann === "", `und es hat zuerst KEINEN Termin ("${zeile?.wann}")`);
|
|
|
|
const termin = new Date(Date.now() + 86400000).toISOString();
|
|
const gesetzt = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, {
|
|
method: "PUT", keks: k.admin, rumpf: { wann: termin },
|
|
});
|
|
ok(gesetzt.code === 200
|
|
&& (gesetzt.j?.liste || []).find((z) => z.id === zeile.id)?.wann === termin,
|
|
`der Termin laesst sich setzen (${gesetzt.code})`);
|
|
|
|
const leer = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, {
|
|
method: "PUT", keks: k.admin, rumpf: { wann: "" },
|
|
});
|
|
ok(leer.code === 200
|
|
&& (leer.j?.liste || []).find((z) => z.id === zeile.id)?.wann === "",
|
|
"und wieder wegnehmen -- leer ist ein gueltiger Wert und der Normalfall");
|
|
await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, {
|
|
method: "PUT", keks: k.admin, rumpf: { wann: termin },
|
|
});
|
|
|
|
const quatsch = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, {
|
|
method: "PUT", keks: k.admin, rumpf: { wann: "irgendwann mal" },
|
|
});
|
|
ok(quatsch.code === 400 && quatsch.j?.fehler === "termin_unklar",
|
|
`ein freier Text wird abgelehnt (${quatsch.code}/${quatsch.j?.fehler})`);
|
|
|
|
for (const rolle of ["linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, {
|
|
method: "PUT", keks: k[rolle], rumpf: { wann: termin },
|
|
});
|
|
ok(a.code === 403, `${rolle}: setzt keinen Termin (${a.code})`);
|
|
}
|
|
|
|
/* ==== UND DIE GANZE REIHE SIEHT NUR DIE LEITUNG ==============
|
|
Filipe: „und das soll geschlossen sein außer für vanvan und
|
|
dogfather." Die Reihe im Foyer entsteht aus `liste` -- wer die
|
|
nicht bekommt, bekommt die Reihe nicht. */
|
|
{
|
|
const beiDenZweien = await roh(CREW, "/workspace/api/reaktion", { keks: k.hand });
|
|
ok(Array.isArray(beiDenZweien.j?.liste) && beiDenZweien.j.liste.length > 0,
|
|
"die rechte Hand bekommt den Plan");
|
|
for (const rolle of ["linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] });
|
|
ok(a.j?.liste === null, `${rolle}: bekommt ihn NICHT (${a.j?.liste})`);
|
|
}
|
|
}
|
|
|
|
/* ---- Die Vorschlaege ---- */
|
|
const v1 = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.gast,
|
|
rumpf: { video: "https://www.youtube.com/watch?v=9bZkp7q19f0", notiz: "Das waere was" },
|
|
});
|
|
ok(v1.code === 201, `ein Zuschauer darf anraten (${v1.code})`);
|
|
ok(v1.j?.vorschlag?.video === "9bZkp7q19f0",
|
|
`aus der Adresse wird die Kennung (${v1.j?.vorschlag?.video})`);
|
|
|
|
/* EINER JE PERSON UND MINUTE -- gegen den Doppelklick. */
|
|
const vSchnell = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.gast, rumpf: { video: "bm0sK2-KkW8" },
|
|
});
|
|
ok(vSchnell.code === 429, `zweimal gleich hintereinander nicht (${vSchnell.code})`);
|
|
|
|
const vDoppelt = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.gast2, rumpf: { video: "9bZkp7q19f0" },
|
|
});
|
|
ok(vDoppelt.code === 409 && vDoppelt.j?.fehler === "vorschlag_doppelt",
|
|
`dasselbe Video nicht zweimal offen (${vDoppelt.code}/${vDoppelt.j?.fehler})`);
|
|
|
|
const vBereit = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.modi, rumpf: { video: "dQw4w9WgXcQ" },
|
|
});
|
|
ok(vBereit.code === 409 && vBereit.j?.fehler === "steht_schon_bereit",
|
|
`und nichts, was schon bereitliegt (${vBereit.code}/${vBereit.j?.fehler})`);
|
|
|
|
/* ==== WER SIEHT WAS -- DIE EIGENTLICHE FRAGE =================
|
|
Filipe: „nur ich und vanvan sehen sie." */
|
|
{
|
|
const beiDogi = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.admin });
|
|
ok(beiDogi.j?.darf_entscheiden === true && (beiDogi.j?.vorschlaege || []).length === 1,
|
|
`DogFather sieht alle (${beiDogi.j?.vorschlaege?.length})`);
|
|
ok(beiDogi.j?.vorschlaege?.[0]?.name === "Nachtfalter",
|
|
`mit dem Namen dessen, der ihn geschickt hat (${beiDogi.j?.vorschlaege?.[0]?.name})`);
|
|
ok(beiDogi.j?.offen === 1, `und wie viele offen sind (${beiDogi.j?.offen})`);
|
|
|
|
const beiVanVan = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.hand });
|
|
ok((beiVanVan.j?.vorschlaege || []).length === 1, "die rechte Hand auch");
|
|
|
|
/* DER EINSENDER SIEHT SEINEN EIGENEN -- und zwar OHNE Namen:
|
|
Dort staende er selbst, und eine Zeile, die einem den eigenen
|
|
Namen vorliest, ist Platz ohne Auskunft. */
|
|
const beiIhr = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast });
|
|
ok(beiIhr.j?.darf_entscheiden === false && (beiIhr.j?.vorschlaege || []).length === 1,
|
|
"der Einsender sieht seinen eigenen");
|
|
ok(beiIhr.j?.vorschlaege?.[0]?.name === undefined,
|
|
"aber keinen Namen");
|
|
ok(beiIhr.j?.offen === null, "und nicht, wie viele insgesamt offen sind");
|
|
|
|
/* UND EIN DRITTER SIEHT GAR NICHTS. Das ist der Satz, um den es
|
|
geht. */
|
|
const fremd = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast2 });
|
|
ok((fremd.j?.vorschlaege || []).length === 0,
|
|
`wer nichts geschickt hat, sieht nichts (${fremd.j?.vorschlaege?.length})`);
|
|
const modi = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.modi });
|
|
ok((modi.j?.vorschlaege || []).length === 0,
|
|
"auch ein Modi nicht -- Moderation ist etwas anderes als Leitung");
|
|
}
|
|
|
|
/* ---- Entscheiden ---- */
|
|
const offene = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.admin });
|
|
const vid = offene.j.vorschlaege[0].id;
|
|
for (const rolle of ["linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, {
|
|
method: "POST", keks: k[rolle],
|
|
});
|
|
ok(a.code === 403, `${rolle}: entscheidet nicht (${a.code})`);
|
|
}
|
|
|
|
const vorListe = (await roh(CREW, "/workspace/api/reaktion", { keks: k.admin }))
|
|
.j?.liste?.length || 0;
|
|
const genommen = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, {
|
|
method: "POST", keks: k.admin,
|
|
});
|
|
ok(genommen.code === 200 && (genommen.j?.liste || []).length === vorListe + 1,
|
|
`uebernehmen legt es in die Warteschlange (${vorListe} -> ${genommen.j?.liste?.length})`);
|
|
ok((genommen.j?.liste || []).some((z) => z.video === "9bZkp7q19f0"),
|
|
"und zwar genau dieses Video");
|
|
|
|
const nochmal = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, {
|
|
method: "POST", keks: k.admin,
|
|
});
|
|
ok(nochmal.code === 409 && nochmal.j?.fehler === "schon_entschieden",
|
|
`zweimal uebernehmen geht nicht (${nochmal.code}/${nochmal.j?.fehler})`);
|
|
|
|
/* DER EINSENDER ERFAEHRT, WAS DARAUS WURDE. Ohne das waere das
|
|
Feld ein Loch, in das man etwas wirft. */
|
|
{
|
|
const beiIhr = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast });
|
|
ok(beiIhr.j?.vorschlaege?.[0]?.stand === "uebernommen",
|
|
`und sie sieht, dass er drankommt (${beiIhr.j?.vorschlaege?.[0]?.stand})`);
|
|
}
|
|
|
|
/* ---- Ablehnen und Zurueckziehen ---- */
|
|
{
|
|
const neu = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.gast2, rumpf: { video: "bm0sK2-KkW8" },
|
|
});
|
|
ok(neu.code === 201, `noch ein Vorschlag (${neu.code})`);
|
|
const id2 = neu.j.vorschlag.id;
|
|
|
|
/* Ein Fremder kann ihn nicht wegnehmen -- auch nicht den
|
|
offenen. */
|
|
const fremdWeg = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}`, {
|
|
method: "DELETE", keks: k.gast,
|
|
});
|
|
ok(fremdWeg.code === 403 && fremdWeg.j?.fehler === "nicht_deiner",
|
|
`ein Fremder nimmt ihn nicht weg (${fremdWeg.code}/${fremdWeg.j?.fehler})`);
|
|
|
|
const abgelehnt = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}/ablehnen`, {
|
|
method: "POST", keks: k.admin,
|
|
});
|
|
ok(abgelehnt.code === 200, `ablehnen geht (${abgelehnt.code})`);
|
|
const danach = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast2 });
|
|
ok(danach.j?.vorschlaege?.[0]?.stand === "abgelehnt",
|
|
"und er sieht es -- die Zeile bleibt stehen");
|
|
|
|
/* ==== WAS ENTSCHIEDEN IST, ZIEHT ER NICHT ZURUECK ==========
|
|
Sonst verschwaende die Spur, dass ueberhaupt entschieden
|
|
wurde -- und derselbe Vorschlag saehe beim naechsten Mal aus
|
|
wie der erste. */
|
|
const zurueck = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}`, {
|
|
method: "DELETE", keks: k.gast2,
|
|
});
|
|
ok(zurueck.code === 403,
|
|
`einen abgelehnten zieht der Einsender nicht zurueck (${zurueck.code})`);
|
|
/* GEGENPROBE: einen OFFENEN schon. Ohne sie bewiese das Obige
|
|
nur, dass Loeschen ueberhaupt nicht geht. */
|
|
const frisch = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.linke, rumpf: { video: "kJQP7kiw5Fk" },
|
|
});
|
|
const weg = await roh(CREW, `/workspace/api/reaktion/vorschlag/${frisch.j.vorschlag.id}`, {
|
|
method: "DELETE", keks: k.linke,
|
|
});
|
|
ok(frisch.code === 201 && weg.code === 200,
|
|
`Gegenprobe: seinen eigenen OFFENEN schon (${weg.code})`);
|
|
}
|
|
|
|
/* ---- Die Grenze je Person ---- */
|
|
{
|
|
const { db } = await import("./workspace.js");
|
|
const { VORSCHLAEGE_JE_PERSON } = await import("./reaktion-tabellen.js");
|
|
/* Zurueckdatieren statt warten: Eine Pruefung, die 45 Sekunden
|
|
je Vorschlag wartet, wird beim naechsten Mal uebersprungen. */
|
|
const alt = () => db().prepare("UPDATE reaktion_vorschlaege SET erstellt = ?"
|
|
+ " WHERE person_id = ?").run(new Date(Date.now() - 600_000).toISOString(), IDS.modi);
|
|
|
|
const kennungen = ["aqz-KE-bpKQ", "ZZ5LpwO-An4", "e-ORhEE9VVg", "YQHsXMglC9A", "fJ9rUzIMcZQ"];
|
|
let letzte = null;
|
|
for (const v of kennungen) {
|
|
alt();
|
|
letzte = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.modi, rumpf: { video: v },
|
|
});
|
|
if (letzte.code !== 201) break;
|
|
}
|
|
alt();
|
|
const einerZuViel = await roh(CREW, "/workspace/api/reaktion/vorschlag", {
|
|
method: "POST", keks: k.modi, rumpf: { video: "L_jWHffIx5E" },
|
|
});
|
|
ok(einerZuViel.code === 409 && einerZuViel.j?.fehler === "zu_viele_vorschlaege",
|
|
`hoechstens ${VORSCHLAEGE_JE_PERSON} offene je Person `
|
|
+ `(${einerZuViel.code}/${einerZuViel.j?.fehler})`);
|
|
}
|
|
|
|
/* ---- Und was auf der Seite stehen muss ---- */
|
|
{
|
|
const html = readFileSync(join(HIER, "..", "workspace", "reaktion-foyer.html"), "utf8");
|
|
const js = readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "js", "reaktion-foyer.js"), "utf8");
|
|
const css = readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "css", "foyer.css"), "utf8");
|
|
ok(/id="reihe-plan"/.test(html) && /id="reihe-saal"/.test(html)
|
|
&& /id="reihe-raten"/.test(html),
|
|
"die drei Reihen stehen in der Seite");
|
|
ok(/id="saal-knopf"/.test(html) && /href="reaktion\.html"/.test(html),
|
|
"und die Mitte fuehrt in den Saal");
|
|
ok(/<a class="saal-knopf"/.test(html),
|
|
"der Weg hinein ist ein Link und kein Knopf -- mittlere Maustaste, neuer Reiter");
|
|
ok(/id="raten-form"/.test(html), "rechts kann man etwas anraten");
|
|
/* DER PLAN DARF NICHT AN DER ROLLE HAENGEN, SONDERN AN DEN DATEN.
|
|
Eine zweite Rollenliste im Browser waere die, die irgendwann
|
|
von der echten abweicht. */
|
|
ok(!/rolle\s*===\s*['"]admin/.test(js),
|
|
"das Foyer fuehrt keine eigene Rollenliste");
|
|
ok(/Array\.isArray\(lage\?\.liste\)/.test(js),
|
|
"es fragt, ob die Auskunft gekommen ist");
|
|
ok(/visibilityState/.test(js),
|
|
"und fragt nicht nach, wenn der Reiter im Hintergrund liegt");
|
|
ok(/foyer-reihen/.test(css) && /grid-template-columns/.test(css),
|
|
"das Stilblatt kennt die drei Reihen");
|
|
ok(/@media \(max-width: 900px\)/.test(css) && /order: 1/.test(css),
|
|
"am Handy steht die Mitte oben");
|
|
|
|
/* ---- Und „geschlossen" steht nicht mehr an der Kachel ---- */
|
|
const start = readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "css", "start.css"), "utf8");
|
|
ok(!/content:\s*"geschlossen"/.test(start),
|
|
"die Kachel sagt nicht mehr „geschlossen“");
|
|
ok(/content:\s*"gleich"/.test(start) && /content:\s*"● live"/.test(start),
|
|
"Gegenprobe: „gleich“ und „live“ stehen weiterhin da");
|
|
}
|
|
}
|
|
|
|
/* =======================================================================
|
|
21. DAS REGIE-TOR (08.10.2026)
|
|
|
|
Filipe: „unter den [Reihen] fehlt eine kachel, die viel kraesser und
|
|
spezieller aussehen soll, wo nur dogfather oder vanvan reinkommen.
|
|
mit ihren zugangscodes fuer die seite." Und auf die Rueckfrage nach
|
|
der Staerke des Schlosses: „punkt 2 aber die soll jeder sehen aber
|
|
nur vanvan und dogfather sollen da rein kommen bitte."
|
|
|
|
ZWEI HAELFTEN, UND BEIDE WERDEN GEMESSEN: dass JEDER die Kachel
|
|
sieht, und dass nur die zwei HINEINKOMMEN. Eine davon allein waere
|
|
keine Aussage.
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 21. Das Regie-Tor ===");
|
|
{
|
|
/* Eigener Griff auf die Datenbank: `d` oben ist nach dem Anlegen
|
|
der Personen geschlossen, und jeder Abschnitt, der nachsieht,
|
|
oeffnet seinen eigenen. */
|
|
const db = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
/* ---- Wer was zu sehen bekommt ---- */
|
|
for (const rolle of ["gast", "modi", "linke"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k[rolle] });
|
|
ok(a.code === 200 && a.j?.darf === false,
|
|
`${rolle}: sieht das Tor, darf aber nicht hinein (${a.code}, darf=${a.j?.darf})`);
|
|
/* ES VERRAET NICHTS. Wer nicht hinein darf, bekommt auch nicht
|
|
zu wissen, ob gerade jemand drin ist. */
|
|
ok(a.j?.offen === false && a.j?.bis === null,
|
|
`${rolle}: und erfaehrt nichts ueber den Zustand dahinter`);
|
|
}
|
|
for (const rolle of ["admin", "hand"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k[rolle] });
|
|
ok(a.code === 200 && a.j?.darf === true && a.j?.offen === false,
|
|
`${rolle}: darf hinein, Tor ist noch zu (darf=${a.j?.darf}, offen=${a.j?.offen})`);
|
|
}
|
|
|
|
/* ---- Wer nicht darf, erzeugt KEINEN Fehlversuch ----------------
|
|
Sonst koennte irgendwer im Haus die Bremse fuer seine eigene IP
|
|
vollaufen lassen und sich damit von der ANMELDUNG aussperren --
|
|
beide zaehlen in derselben Tabelle. */
|
|
{
|
|
const vorher = db.prepare("SELECT COUNT(*) AS n FROM versuche").get().n;
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "POST", keks: k.gast, rumpf: { code: CODES.admin } });
|
|
ok(a.code === 403 && a.j?.fehler === "regie_gehoert_den_zweien",
|
|
`ein Gast wird abgewiesen (${a.code} ${a.j?.fehler})`);
|
|
const nachher = db.prepare("SELECT COUNT(*) AS n FROM versuche").get().n;
|
|
ok(nachher === vorher,
|
|
`und sein Versuch zaehlt nicht gegen die Bremse (${vorher} -> ${nachher})`);
|
|
/* UND ZWAR AUCH DANN NICHT, WENN ER DEN RICHTIGEN CODE HAETTE.
|
|
Oben stand `CODES.admin` -- der Gast hat also den gueltigen
|
|
Code eines Hosts geschickt und kommt trotzdem nicht durch.
|
|
Die Rolle entscheidet VOR dem Code. */
|
|
const z = db.prepare("SELECT COUNT(*) AS n FROM reaktion_regie").get().n;
|
|
ok(z === 0, `und niemand steht in der Freigabetabelle (${z})`);
|
|
}
|
|
|
|
/* ---- Falscher Code ---- */
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "POST", keks: k.admin, rumpf: { code: "CODE-XXX-999999" } });
|
|
ok(a.code === 401 && a.j?.fehler === "code_falsch",
|
|
`ein falscher Code wird abgewiesen (${a.code} ${a.j?.fehler})`);
|
|
const b = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin });
|
|
ok(b.j?.offen === false, "und das Tor bleibt zu");
|
|
}
|
|
|
|
/* ---- DER CODE DER ANDEREN OEFFNET DIE EIGENE TUER NICHT --------
|
|
Die Anmeldung geht alle Personen einer ROLLE durch; dort ist der
|
|
Code die Kennung. Hier waere das falsch: VanVans Code wuerde
|
|
DogFathers Tuer oeffnen, und im Protokoll stuende, ER sei
|
|
hineingegangen. */
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "POST", keks: k.admin, rumpf: { code: CODES.hand } });
|
|
ok(a.code === 401 && a.j?.fehler === "code_falsch",
|
|
`VanVans Code oeffnet DogFathers Tuer nicht (${a.code})`);
|
|
}
|
|
|
|
/* ---- Richtiger Code ---- */
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "POST", keks: k.admin, rumpf: { code: CODES.admin } });
|
|
ok(a.code === 200 && a.j?.offen === true,
|
|
`der eigene Code oeffnet (${a.code}, offen=${a.j?.offen})`);
|
|
/* ZWOELF STUNDEN, und die Zahl kommt aus der Datei -- nicht aus
|
|
dieser Pruefung. Eine abgeschriebene Zahl waere die, die beim
|
|
naechsten Umbau stehen bleibt. */
|
|
const t = await import("./reaktion-tabellen.js");
|
|
const stunden = (new Date(a.j.bis) - Date.now()) / 3600_000;
|
|
ok(Math.abs(stunden - t.REGIE_FREI_STUNDEN) < 0.2,
|
|
`und zwar fuer ${t.REGIE_FREI_STUNDEN} Stunden (gemessen ${stunden.toFixed(2)})`);
|
|
ok(a.j?.stunden === t.REGIE_FREI_STUNDEN,
|
|
"die Seite erfaehrt die Dauer vom Server und rechnet sie nicht selbst");
|
|
}
|
|
|
|
/* ---- Die Freigabe haengt an der PERSON, nicht an der Rolle ---- */
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.hand });
|
|
ok(a.j?.darf === true && a.j?.offen === false,
|
|
`VanVans Tuer ist ihre eigene und noch zu (offen=${a.j?.offen})`);
|
|
}
|
|
|
|
/* ---- Und sie gilt ueber eine neue Anfrage hinweg ---- */
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin });
|
|
ok(a.j?.offen === true, "beim naechsten Nachsehen steht sie offen");
|
|
}
|
|
|
|
/* ---- Abgelaufen heisst zu, und die Zeile wird weggeraeumt ------
|
|
Zurueckdatiert statt zwoelf Stunden gewartet. */
|
|
{
|
|
db.prepare("UPDATE reaktion_regie SET bis = ? WHERE person_id = ?")
|
|
.run(new Date(Date.now() - 60_000).toISOString(), IDS.admin);
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin });
|
|
ok(a.j?.offen === false, "eine abgelaufene Freigabe gilt nicht mehr");
|
|
const n = db.prepare("SELECT COUNT(*) AS n FROM reaktion_regie WHERE person_id = ?")
|
|
.get(IDS.admin).n;
|
|
ok(n === 0, `und die Zeile ist weg statt nur ignoriert (${n})`);
|
|
}
|
|
|
|
/* ---- Wieder abschliessen ---- */
|
|
{
|
|
await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "POST", keks: k.admin, rumpf: { code: CODES.admin } });
|
|
const auf = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin });
|
|
ok(auf.j?.offen === true, "Gegenprobe: aufgeschlossen ist sie wirklich offen");
|
|
const zu = await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "DELETE", keks: k.admin });
|
|
ok(zu.code === 200 && zu.j?.offen === false,
|
|
`und sie laesst sich wieder abschliessen (${zu.code})`);
|
|
}
|
|
|
|
/* ---- Die Bremse ------------------------------------------------
|
|
Acht Fehlversuche je IP in zehn Minuten, gemeinsam mit der
|
|
Anmeldung gezaehlt. Geprueft wird, DASS sie greift -- und danach
|
|
wird die Tabelle geleert, damit die naechsten Abschnitte nicht
|
|
gegen eine volle Bremse laufen. */
|
|
{
|
|
let gebremst = 0;
|
|
let letzter = 0;
|
|
for (let i = 0; i < 12; i++) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "POST", keks: k.admin, rumpf: { code: `CODE-FAL-00000${i}` } });
|
|
letzter = a.code;
|
|
if (a.code === 429) gebremst++;
|
|
}
|
|
ok(gebremst > 0, `nach genug Fehlversuchen bremst sie (${gebremst} von 12, zuletzt ${letzter})`);
|
|
/* GEGENPROBE: Nach dem Leeren geht es wieder -- sonst bewiese die
|
|
Zeile darueber nur, dass irgendwann 429 kommt, und nicht, dass
|
|
es an der Bremse lag. */
|
|
db.prepare("DELETE FROM versuche").run();
|
|
const b = await roh(CREW, "/workspace/api/reaktion/regie",
|
|
{ method: "POST", keks: k.admin, rumpf: { code: CODES.admin } });
|
|
ok(b.code === 200, `und nach dem Zuruecksetzen geht es wieder (${b.code})`);
|
|
await roh(CREW, "/workspace/api/reaktion/regie", { method: "DELETE", keks: k.admin });
|
|
}
|
|
|
|
/* ---- Was auf der Seite steht ---- */
|
|
{
|
|
const html = readFileSync(
|
|
join(HIER, "..", "workspace", "reaktion-foyer.html"), "utf8");
|
|
const js = readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "js", "reaktion-foyer.js"), "utf8");
|
|
const css = readFileSync(
|
|
join(HIER, "..", "workspace", "assets", "css", "foyer.css"), "utf8");
|
|
ok(/id="regie-tor"/.test(html), "das Tor steht in der Seite");
|
|
ok(/<button[^>]*class="regie-tor"/.test(html),
|
|
"und ist ein Knopf und kein Link -- er fuehrt erst nach dem Code irgendwohin");
|
|
ok(/type="password"/.test(html),
|
|
"das Codefeld zeigt nichts an (Bildschirm teilen)");
|
|
ok(/id="regie-vorhang"/.test(html) && html.indexOf("id=\"regie-vorhang\"") > html.indexOf("</main>"),
|
|
"der Vorhang steht NEBEN main -- sonst laege er unter der Kopfleiste");
|
|
ok(!/rolle\s*===\s*['"]admin/.test(js),
|
|
"das Tor fuehrt keine eigene Rollenliste, es fragt den Server");
|
|
ok(/regie\?\.darf/.test(js), "es fragt, ob die Auskunft es erlaubt");
|
|
ok(/prefers-reduced-motion/.test(css),
|
|
"wer weniger Bewegung eingestellt hat, bekommt das Licht ohne Lauf");
|
|
ok(/\.regie-tor\[data-darf="nein"\]/.test(css),
|
|
"und wer nicht hinein darf, sieht es gedaempft");
|
|
}
|
|
}
|
|
|
|
/* =======================================================================
|
|
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(`<script src="assets/js/${datei}`);
|
|
ok(einhaenger("getippt.js") > 0
|
|
&& einhaenger("getippt.js") < einhaenger("reaktion.js"),
|
|
"getippt.js wird VOR reaktion.js eingehaengt");
|
|
|
|
/* ==== DER ALTE NOTBEHELF DARF NICHT ZURUECKKOMMEN ===========
|
|
`document.activeElement !== feld` war die Fassung, die Filipes
|
|
Text verschluckt hat: Sie schuetzt nur, solange der Finger im
|
|
Feld steht. Taucht sie wieder auf, ist der Fehler wieder da --
|
|
und zwar lautlos. */
|
|
ok(!/activeElement\s*!==\s*\$\(/.test(js),
|
|
"der alte Fokus-Notbehelf steht nicht mehr in der Regie");
|
|
ok(/Getippt\.binde\(/.test(js) && /Getippt\.gespeichert\(/.test(js),
|
|
"stattdessen wird gebunden und nach dem Speichern freigegeben");
|
|
/* Dieselbe Falle ein zweites Mal: `!/localStorage/` schlug an,
|
|
weil das WORT im erklaerenden Kommentar von getippt.js steht
|
|
(„liegt im Speicher und NICHT in localStorage"). Gefragt ist,
|
|
ob es BENUTZT wird -- also der Aufruf, nicht das Wort. */
|
|
ok(!/localStorage\.(get|set|remove)Item/.test(getippt),
|
|
"das Gemerkte ueberlebt die Sitzung NICHT -- so hat Filipe es gesagt");
|
|
ok(/offen\.delete/.test(getippt) && /isConnected/.test(getippt),
|
|
"und es zaehlt keine Felder mit, die es nicht mehr gibt");
|
|
}
|
|
dbP.close();
|
|
}
|
|
|
|
melde("");
|
|
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
|
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
|
|
process.exit(fehler === 0 ? 0 : 1);
|