/.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 }, }); /* ---- 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(""), "und noch im Saal -- nicht in einem Register"); const iLeiste = html.indexOf('id="transport"'); const iEnde = html.indexOf("", 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" }, }); } melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); process.exit(fehler === 0 ? 0 : 1);