Filipe: "perfektionier das auch mit den videoos. pefektionier auch das
aussehen und das layout von der regie. ich will dass du das viel
hochwertiger und profissioneller machst."
DAS VIDEO LIEF BEI NIEMANDEM -- AUCH NICHT BEIM HOST
Gemessen ueber ein neues Merkmal am Rahmen: `onStateChange` ist nie
ausgeloest worden, bei keinem der drei Browser. Zwei Ursachen, die
sich gegenseitig verdeckt haben:
Ein Player mit Ton darf ohne Handlung des Menschen nicht losgehen.
Ohne `mute: 1` greift `playVideo()` nicht -- und ein Zuschauer hat
keine Bedienung (mit Absicht), haette also NIE eine Moeglichkeit
gehabt, es zu starten. Eine Stunde Standbild.
`onReady` tat `if (host) takt(); else folgen();`. Der Host hat damit
nur GEMELDET, wo er steht, und nie selbst begonnen. Er meldete
"laeuft nicht", und alle anderen folgten ihm brav ins Stehen.
Die Messung bricht ab jetzt ab, wenn der Player nicht bei beiden
laeuft. Ein gruener Haken ueber einem Standbild ist wertlos.
DIE WARTESCHLANGE
Vorher gab es genau EIN Videofeld. Wer zwei Sachen hintereinander
schauen wollte, tippte mitten in der Sendung eine YouTube-Adresse ein
-- vor Publikum, mit laufender Kamera, ein Tippfehler von einem
schwarzen Rechteck entfernt. Jetzt wird vorher eingeraeumt und im Live
nur weitergeschaltet: anhaengen, schieben, "Jetzt", "Naechstes".
Die Titel kommen von YouTube selbst (oEmbed, kein Schluessel, kein
Kontingent) und werden EINMAL geholt und hingelegt. Klappt der Abruf
nicht, steht dort die Kennung -- kein erfundener Name. Die Grenze ist
ueber die Umgebung veraenderbar, damit die Pruefung den vollen Fall in
Sekunden erreicht statt dreissig Videos anzuhaengen.
DIE REGIE
Vorher fuenf Kaesten untereinander in einem Bereich, der hoechstens
die halbe Bildschirmhoehe hat -- man sah fuenf halbe Dinge. Die drei
grossen Knoepfe lagen ganz unten, hinter acht Feldern und vier
Reglern. Wer mitten in der Sendung "Beenden" drueckt, drueckt es, weil
etwas passiert ist; das darf nicht hinter einer Bewegung liegen.
Eine Leiste, die IMMER steht: Lampe, Laufzeit, Zuschauer, wie viele
davon Bild bekommen, Gaeste, gemessener Upload -- und rechts die
drei Knoepfe.
Register statt Stapel: Sendung, Warteschlange, Ton, Gaeste, Bild.
Eine Videospur mit echter Bedienung: Pause, +/-10 s, Positionsband,
Restzeit, "Naechstes". Vorher musste man ins YouTube-Bild fassen --
und was dort passiert, passiert nur bei einem selbst.
PEGEL an den Reglern, aus einer echten Messung (AnalyserNode). Sie
beantworten die Frage, die man sich sonst erst nach der Sendung
stellt: "War mein Mikro ueberhaupt an?" Ein Regler auf 100 sagt
darueber nichts. Beim YouTube-Regler steht dabei, dass dort nichts
zu messen ist -- Ton aus einem fremden Rahmen laesst sich nicht
abgreifen, und ein erfundener Ausschlag waere schlimmer als keiner.
Der Upload wird an der Verbindung gemessen (`getStats`), nicht aus
"zwoelf mal 350 kbit/s" gerechnet.
Tastaturkuerzel -- und die Legende steht darunter. Ein Kuerzel, das
niemand kennt, ist keins.
VIER FEHLER, DIE NUR DIE MESSUNG GEZEIGT HAT
1. `.tafel` WAR SCHON VERGEBEN. Die Anmeldeseite hat diese Klasse und
setzt sie `position: absolute`; reaktion.html laedt beide
Stilvorlagen. Die Register-Tafel trug damit NICHTS zur Hoehe bei
(114 px Raster, 294 px Inhalt) und malte quer ueber Register und
Kuerzel -- 197 Pixel, um die die Seite ueberlief. Auf dem Bild sah
es aus wie ein Anzeigefehler; es war ein Namensstreit. Dritter
Fall dieser Art nach .knopf-still und .schalter, deshalb steht er
ab jetzt in einer Pruefung: keine Klasse aus reaktion.css darf in
gate/start/module/haus.css vorkommen.
2. `node:sqlite` kennt kein `.transaction()`. Von Hand geklammert.
3. Das Schild war auf dem Handy 10,24 px gross -- unter der
Hausgrenze von 11,5 px. Gefunden von `pruef-handy`, das die
GEZEICHNETE Groesse misst; die Stilvorlagen-Pruefung haette die
Zeile in der Medienabfrage durchgelassen.
4. Die Messung klickte blind auf den Griff des Pults ("umschalten")
und machte es damit ZU, seit es aufgeklappt startet. Sie stellt
den Zustand jetzt her, statt ihn umzuschalten.
UND DIE HARTNAECKIGSTE: DAS BILD DES GASTES KAM BEIM HOST NICHT AN
Erst in einem von drei Laeufen, dann in drei von drei -- und zwar
SCHLIMMER, nachdem ich einen Wachhund dagegen gebaut hatte. Vier
Ursachen, hintereinander gemessen statt geraten:
a) `kameraHolen()` wurde beim Host FUENFMAL betreten. Die Wache
`if (meinStrom) return` wirkt erst, wenn die Kamera DA ist --
solange die erste Anfrage laeuft, geht jede weitere als zweite
Anfrage an dasselbe Geraet. Eine blieb liegen, und mit ihr der
Anruf, der darauf wartete. Der Platz in `ruftGerade` blieb
belegt, und damit kam nie wieder eine Leitung zustande. Jetzt
bekommen alle dasselbe Versprechen zurueck.
b) Der Wachhund riss Leitungen ab, die gerade verhandelt wurden --
zwischen "Leitung angelegt" und "Angebot abgeschickt" liegen
drei await.
c) "Ruf mich an" brach dasselbe ab: Der Bittende weiss nicht, dass
es schon laeuft, und fragt alle drei Sekunden weiter.
d) Meine Reparatur von (b) und (c) war "signalingState !== stable
heisst: in Arbeit" -- ohne Uhr. Damit war eine Leitung, deren
Antwort nie ankommt, vor BEIDEN Aufraeumwegen sicher, dauerhaft.
Der Zuschauer bekam daraufhin gar nichts mehr; ich hatte den
Fehler nur von einer Seite auf die andere geschoben. "Wird
verhandelt" ist ein Zustand MIT DAUER und steht jetzt an genau
einer Stelle.
Dazu ein eigener Fehler beim Aufraeumen: Mit der Messspur ist
`ruftGerade` mit herausgefallen. Vier Laeufe ohne jedes Bild -- und
`node --check` sieht das nicht, eine fehlende Variable ist
syntaktisch tadellos.
GEMESSEN
mess-reaktion fuenf Laeufe hintereinander ohne Beanstandung;
beide Kameras 640 px bei Host UND Zuschauerin,
Video laeuft bei beiden, Upload 0,9 Mbit/s,
Warteschlange 2 Zeilen mit Vorschaubildern,
kein Ueberlauf (4 px statt 197)
pruef-reaktion 132 Punkte, 0 Fehler (vorher 88)
pruef-handy 180 Punkte, 0 Fehler
dazu gruen css-klassen 33, struktur 35, tippziele 11,
meldungen 8, kamera-richtlinie 10
SCHEMA: neue Tabelle `reaktion_liste`, neue Spalte
`reaktion.video_titel` (ALTER TABLE, keine Abschrift der Spalten).
917 lines
42 KiB
JavaScript
917 lines
42 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";
|
|
|
|
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 === "geschlossen",
|
|
`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]);
|
|
ok(reiter.length === 5 && reiter.every((r) => tafeln.includes(r)),
|
|
`zu jedem der ${reiter.length} Register gibt es 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");
|
|
|
|
/* Und die Kuerzel, die das Programm bindet, stehen auch dabei.
|
|
Ein Kuerzel, das niemand kennt, ist keins. */
|
|
const legende = (html.match(/<p class="kuerzel">[\s\S]*?<\/p>/) || [""])[0];
|
|
for (const [taste, wort] of [["Leertaste", "Leertaste"], ["M", "M"], ["N", "N"]]) {
|
|
ok(legende.includes(`<kbd>${wort}</kbd>`), `das Kuerzel ${taste} steht in der Legende`);
|
|
}
|
|
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 klassenAus = (datei) => {
|
|
const text = readFileSync(join(HIER, "..", "workspace", "assets", "css", datei), "utf8")
|
|
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
return new Set([...text.matchAll(/(^|[\s,>+~(])\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/g)]
|
|
.map((m) => m[2]));
|
|
};
|
|
const meine = klassenAus("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)");
|
|
|
|
/* 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. KEIN WEG HEISST ZWEIMAL GLEICH
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 13. 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 >= 15, `die Reaction bringt ${reaktionswege} eigene Wege mit`);
|
|
}
|
|
|
|
melde("");
|
|
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
|
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
|
|
process.exit(fehler === 0 ? 0 : 1);
|