Filipe: „die videos muessen perfekt laufen wenn ich die reaktions
mache."
GEMESSEN, NICHT VERMUTET. mess-reaktion hat einen neuen Abschnitt:
Er liest den Spielstand am ECHTEN <video>-Element in beiden Browsern,
rechnet beide auf denselben Augenblick um und sagt, wie weit Host und
Zuschauerin auseinanderliegen. Die vorhandene Pruefung fragte nur, ob
Start und Stopp ANKOMMEN -- zwei Videos koennen beide laufen und
trotzdem zwanzig Sekunden auseinander sein.
vorher nachher
Im Lauf 0,12 s -0,04 s
Wer dazukommt 6,91 s 0,26 s
Uhr des Geraets 30 s vor -29,97 s 0,26 s
DER SCHLIMMSTE FUND: DIE UHR DES TELEFONS
Der Zuschauer rechnete `Date.now() - stand.gesendet`. `gesendet` kam
vom SERVER, `Date.now()` vom Geraet -- zwei Uhren, die nie jemand
verglichen hat. Wessen Telefon dreissig Sekunden vorgeht, schaute
29,97 s weiter als alle anderen. Kein langsames Netz, kein schwaches
Handy. Oertlich faellt das nie auf: Auf einem Rechner sind beide
Uhren dieselbe.
Jetzt geht ein ALTER hinaus statt einer Uhrzeit -- die Differenz
zweier SERVERzeiten. Der Empfaenger braucht dafuer gar keine Uhr,
nur eine Stoppuhr (`performance.now()`), und die haelt Zeitumstellung
und Aufwachen aus dem Ruhezustand aus.
WEITER
* WER DAZUKOMMT, STEIGT RICHTIG EIN. Der gespeicherte Stand war so
alt wie der letzte Takt des Hosts; neue Spalte `sekunde_am` sagt,
wann er galt.
* DAS TEMPO GEHT IN DIE HOCHRECHNUNG EIN. Bei 2x laeuft die Videouhr
doppelt so schnell -- das fehlte ganz.
* JEDER RECHNET ALLE ZWEI SEKUNDEN SELBST NACH statt nur auf Zuruf.
Wer stockte, hing bis zu fuenf Sekunden hinterher.
* DER VORLAUF NACH EINEM SPRUNG WIRD GEREGELT, nicht geschaetzt --
gemessen wird der Rest, nicht die Ladezeit (siehe unten).
* PUFFERN IST AUCH IN DER TRANSPORTLEISTE KEINE PAUSE. Der
Start-Stopp-Knopf rief beim Puffern `playVideo()` -- wer Pause
drueckte, bekam „weiter". Lampe und Knopfbild flackerten bei jedem
Stocken. Die Lektion vom 29.09. war nur an einer Stelle eingeloest.
* DAS TEMPO MELDET UEBER `hostMelden()` statt ein zweites Mal von
Hand -- es hielt Puffern fuer Stillstand und hielt damit beim
Umschalten alle an.
* EIN VIDEO-RUNDRUF STATT DREI ABSCHRIFTEN. Beim Einbau hatte ich
zuerst nur eine nachgezogen; das Tauschen der beiden Videos haette
still die alte Rechnung weiterverschickt.
DREI EIGENE FEHLER UNTERWEGS, alle im Text festgehalten
1. ZWEI FUNKTIONEN HIESSEN `springen`. Meine „spring AUF die Stelle"
wurde von der vorhandenen „spring UM so viele Sekunden"
ueberschrieben -- lautlos, rueckwirkend fuer die ganze Datei. Ein
Zuschauer bei Sekunde 100, dem „geh auf 101" gesagt wird, waere
bei 201 gelandet. DREI BROWSERLAEUFE HABEN ES NICHT GEFUNDEN:
oertlich blieb der Abstand unter der Sprungschwelle, die Zeile
lief kein einziges Mal. Gefunden hat es erst eine Frage an die
ganze Datei -- die steht jetzt als Pruefung drin und wurde
nachgestellt (rot).
2. DER VORLAUF LERNTE NUR NACH OBEN. Gemessen wurde die Ladezeit
nach einem Sprung; ein Sprung in geladenes Material dauert aber
gar nicht, meldet nichts und lehrt nichts. Ergebnis: -0,95 s
stabil. Jetzt wird das ERGEBNIS geregelt statt der Ursache.
3. DER AUSREISSER VON 39,84 s WAR MEINE EIGENE MESSUNG -- der Block
davor schickt eine erfundene Position. Jetzt wird abgeklungen und
in Messreihenfolge ausgegeben.
GEPRUEFT
pruef-reaktion 421/0 (14 neue, darunter der Riegel gegen die
Rueckkehr der Uhrenrechnung und die Wache gegen doppelte
Funktionsnamen -- beide nachgestellt), mess-reaktion ohne ein
einziges ACHTUNG, pruef-buehne 38/0, pruef-struktur, pruef-
zwischenspeicher 34/0.
Schemaaenderung: ADD COLUMN sekunde_am. Datenbank vorher gesichert
und geprueft (integrity_check ok, 20 Personen).
Co-Authored-By: Claude Opus 5 <[email protected]>
2825 lines
135 KiB
JavaScript
2825 lines
135 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})`);
|
|
|
|
const a = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast },
|
|
});
|
|
ok(a.code === 201 && a.j?.gaeste?.length === 1, `Gast 1 dazu (${a.code})`);
|
|
ok(a.j.gaeste[0].platz === 1, `er bekommt Platz 1 (${a.j?.gaeste?.[0]?.platz})`);
|
|
|
|
const b = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
|
|
});
|
|
ok(b.code === 201 && b.j?.gaeste?.length === 2, `Gast 2 dazu (${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 (${c.code}/${c.j?.fehler})`);
|
|
|
|
/* 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. */
|
|
const neu = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
|
|
});
|
|
const plaetze = (neu.j?.gaeste || []).map((x) => x.platz).sort();
|
|
ok(plaetze.join(",") === "1,2",
|
|
`der frei gewordene Platz wird wiederverwendet (${plaetze.join(",")})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
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);
|
|
const kachel = (alle || []).find((b) => b.ziel === "reaktion.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/.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] });
|
|
}
|
|
await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
|
|
});
|
|
|
|
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. */
|
|
const abzeichen = [...js.matchAll(/^ {4}(admin|hand|linke|modi): \['([^']+)', '([^']+)'\],/gm)];
|
|
ok(abzeichen.length === 4, `vier Rollen tragen ein Abzeichen (${abzeichen.length})`);
|
|
for (const [, rolle, wort, marke] of abzeichen) {
|
|
ok(wort.length >= 3 && wort.length <= 6, `„${wort}" fuer ${rolle} ist kurz genug`);
|
|
ok(css.includes(`.beitrag__rolle[data-rolle="${marke}"]`),
|
|
`und „${marke}" hat eine eigene Farbe`);
|
|
}
|
|
/* GEGENPROBE: Kann diese Pruefung „nein" sagen? */
|
|
ok(!css.includes('.beitrag__rolle[data-rolle="gibtesnicht"]'),
|
|
"Gegenprobe: eine erfundene Marke hat keine Regel");
|
|
|
|
/* 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 [, , wort] of abzeichen) {
|
|
ok(!rangwoerter.includes(wort), `„${wort}" ist kein Rang`);
|
|
}
|
|
|
|
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}")`);
|
|
}
|
|
|
|
melde("");
|
|
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
|
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
|
|
process.exit(fehler === 0 ? 0 : 1);
|