diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs
index a1b18ffa..8f365aaf 100644
--- a/server/mess-reaktion.mjs
+++ b/server/mess-reaktion.mjs
@@ -396,6 +396,143 @@ await host.seite.evaluate(() => {
});
await host.seite.waitForTimeout(900);
+/* ---- Der Regieplatz: links, rechts und die Leiste unten ----------
+
+ Filipe: „kann das nicht bissl aufgeteilt sein auf links und rechts
+ und eine barre unten in der mitte."
+
+ GEMESSEN WIRD DIE LAGE UND NICHT DIE REGEL. Dass im Stilblatt
+ `grid-template-areas` steht, sagt nichts darueber, ob die rechte
+ Spalte auch wirklich rechts landet -- eine spaetere Regel, eine zu
+ breite Zelle, ein langer Titel, und sie steht darunter. Geprueft
+ wird deshalb an den Kanten:
+ linke Spalte endet, BEVOR die rechte anfaengt
+ die Leiste liegt UNTER beiden und ist so breit wie beide
+ Und zusaetzlich, dass nichts seitlich hinauslaeuft: Der Umbau hat
+ eine 16-rem-Spalte gegen einen Videotitel ohne Grenze gestellt. */
+{
+ const platz = await host.seite.evaluate(() => {
+ const k = (w) => {
+ const e = document.querySelector(w);
+ if (!e) return null;
+ const r = e.getBoundingClientRect();
+ return {
+ l: Math.round(r.left), r: Math.round(r.right),
+ o: Math.round(r.top), u: Math.round(r.bottom),
+ b: Math.round(r.width), h: Math.round(r.height),
+ };
+ };
+ const innen = document.getElementById("pult-innen");
+ return {
+ links: k(".regieplatz__seite:not(.regieplatz__seite--rechts)"),
+ rechts: k(".regieplatz__seite--rechts"),
+ leiste: k(".transport"),
+ spiel: k("#v-spiel"),
+ /* Laeuft etwas seitlich hinaus? `scrollWidth` groesser als die
+ sichtbare Breite heisst genau das. */
+ ueber: innen ? innen.scrollWidth - innen.clientWidth : -1,
+ /* ==== UND IST DIE LEISTE UEBERHAUPT ZU SEHEN? ============
+ Beim ersten Bau lag sie bei y=986 in einem 900 Pixel hohen
+ Fenster -- die Rechnung ging auf, und man kam trotzdem nur
+ mit Scrollen an den Play-Knopf. Geprueft wird deshalb
+ gegen den SICHTBAREN Ausschnitt der Regie, nicht gegen die
+ Seite. */
+ sichtbar: (() => {
+ const e = document.querySelector(".transport");
+ if (!e || !innen) return false;
+ const r = e.getBoundingClientRect();
+ const i = innen.getBoundingClientRect();
+ return r.bottom <= i.bottom + 1 && r.top >= i.top - 1;
+ })(),
+ /* ==== UND SIE IST DIE LETZTE ZEILE ========================
+ „Unten" laesst sich bei einer KLEBENDEN Leiste nicht mehr an
+ den Kanten ablesen: Beim Hochscrollen schwebt sie
+ absichtlich ueber dem unteren Teil der Spalten. Die Frage
+ „liegt sie unter beiden?" war die von vorher -- sie hat nach
+ der Reparatur einen Fehler gemeldet, wo das Verhalten
+ richtig ist.
+ Was „unten" wirklich heisst, steht in der Reihenfolge: Sie
+ kommt im Dokument NACH beiden Spalten. Das gilt beim
+ Scrollen wie beim Stillstand. */
+ letzte: (() => {
+ const e = document.querySelector(".transport");
+ const r = document.querySelector(".regieplatz__seite--rechts");
+ if (!e || !r) return false;
+ /* 4 = DOCUMENT_POSITION_FOLLOWING */
+ return !!(r.compareDocumentPosition(e) & 4);
+ })(),
+ klebt: (() => {
+ const e = document.querySelector(".transport");
+ return e ? getComputedStyle(e).position : "(?)";
+ })(),
+ fenster: window.innerHeight,
+ };
+ });
+ const p = platz;
+ console.log(` Regieplatz: links ${p.links?.l}..${p.links?.r} · `
+ + `rechts ${p.rechts?.l}..${p.rechts?.r} · `
+ + `Leiste ${p.leiste?.l}..${p.leiste?.r} bei y=${p.leiste?.o} (${p.leiste?.h} px hoch)`);
+ console.log(` Play-Knopf ${p.spiel?.b}x${p.spiel?.h} px · seitlich ueber ${p.ueber} px `
+ + `· Leiste ohne Scrollen sichtbar: ${p.sichtbar} `
+ + `(${p.klebt}, nach beiden Spalten: ${p.letzte})`);
+ if (!p.sichtbar) {
+ console.log(" ACHTUNG: Die Transportleiste ist nur mit Scrollen zu erreichen.");
+ ausgang = 1;
+ }
+ if (!p.links || !p.rechts || !p.leiste) {
+ console.log(" ACHTUNG: Der Regieplatz fehlt.");
+ ausgang = 1;
+ } else {
+ if (p.links.r > p.rechts.l + 1) {
+ console.log(" ACHTUNG: Links und rechts stehen nicht nebeneinander.");
+ ausgang = 1;
+ }
+ if (!p.letzte || p.klebt !== "sticky") {
+ console.log(` ACHTUNG: Die Leiste ist nicht die letzte Zeile `
+ + `(danach=${p.letzte}, position=${p.klebt}).`);
+ ausgang = 1;
+ }
+ /* Sie soll ueber die GANZE Breite gehen -- eine Leiste, die nur
+ so breit ist wie eine Spalte, ist keine Leiste unten in der
+ Mitte, sondern eine dritte Spalte. */
+ if (p.leiste.l > p.links.l + 1 || p.leiste.r < p.rechts.r - 1) {
+ console.log(" ACHTUNG: Die Leiste geht nicht ueber beide Spalten.");
+ ausgang = 1;
+ }
+ /* Der Play-Knopf ist der einzige, den man im Live blind treffen
+ muss. 44 px ist die Hausgrenze; er soll deutlich darueber
+ liegen. */
+ if ((p.spiel?.b || 0) < 52 || (p.spiel?.h || 0) < 52) {
+ console.log(" ACHTUNG: Der Play-Knopf ist zu klein zum Blindtreffen.");
+ ausgang = 1;
+ }
+ if (p.ueber > 1) {
+ console.log(` ACHTUNG: Der Regieplatz laeuft ${p.ueber} px seitlich hinaus.`);
+ ausgang = 1;
+ }
+ }
+
+ /* ---- Und der Knopf zum Teilen ---- */
+ const teilen = await host.seite.evaluate(() => {
+ const e = document.getElementById("s-teilen");
+ if (!e) return null;
+ const r = e.getBoundingClientRect();
+ return {
+ da: r.width > 0 && r.height > 0,
+ b: Math.round(r.width), h: Math.round(r.height),
+ wort: document.getElementById("s-teilen-wort")?.textContent || "",
+ gedrueckt: e.getAttribute("aria-pressed"),
+ };
+ });
+ console.log(` Bildschirm teilen: „${teilen?.wort}" ${teilen?.b}x${teilen?.h} px, `
+ + `gedrueckt=${teilen?.gedrueckt}`);
+ if (!teilen?.da || teilen.h < 40 || teilen.wort !== "Bildschirm") {
+ console.log(" ACHTUNG: Der Knopf zum Teilen fehlt oder ist zu klein.");
+ ausgang = 1;
+ }
+ await bild(host.seite, "23-regieplatz");
+}
+
const regie = await host.seite.evaluate(() => {
const t = (id) => document.getElementById(id)?.textContent?.trim() || "";
const sicht = (w) => {
@@ -639,17 +776,48 @@ const geben = await zuschauer.seite.evaluate(() => {
if (!b) return false;
return b.tagName.toLowerCase() === "img" ? b.naturalWidth > 0 : true;
}).length,
- /* Und nirgends ein Euro. */
- geld: /€|EUR|Euro/.test(k2?.textContent || ""),
+ /* ==== HIER MUSS DER PREIS STEHEN ==========================
+ Filipe: „die leute sollen nur sehen was dogen kosten. sonst
+ wissen die ja nicht wie viel es kosten wird."
+ Hier stand bis zum 28.09. das Gegenteil -- „nirgends ein
+ Euro". Das war fuer jede ANZEIGE richtig und fuer den einen
+ Kaufknopf falsch. */
+ preise: knoepfe.map((a) => a.querySelector(".geben__preis")?.textContent?.trim() || ""),
+ /* ==== UND SONST NIRGENDS AUF DER SEITE ====================
+ Die staerkste Form der Frage: den ganzen sichtbaren Text
+ nehmen, den Geben-Kasten herausschneiden und nachsehen, ob
+ irgendwo noch ein Eurozeichen uebrig ist. Eine Pruefung, die
+ nur den Kasten ansieht, uebersieht genau die Stelle, an der
+ es versehentlich landet. */
+ geldSonstwo: (() => {
+ const alles = document.body.textContent || "";
+ const kasten = k2?.textContent || "";
+ return /€|EUR/.test(alles.split(kasten).join(" "));
+ })(),
};
});
console.log(` Geben-Kasten: ${geben.knoepfe} Knoepfe (${geben.zahlen.join(" · ")}), `
- + `${geben.zeichen} mit Zeichen, Geld im Text: ${geben.geld}`);
-if (!geben.da || geben.knoepfe !== 4 || geben.zeichen !== 4 || geben.geld
+ + `${geben.zeichen} mit Zeichen`);
+console.log(` Preise am Knopf: ${geben.preise.join(" · ")} `
+ + `| Geld sonstwo auf der Seite: ${geben.geldSonstwo}`);
+if (!geben.da || geben.knoepfe !== 4 || geben.zeichen !== 4
|| geben.zahlen[0] !== "0,2") {
console.log(" ACHTUNG: Der Geben-Kasten zeigt nicht, was er soll.");
ausgang = 1;
}
+/* WER BEZAHLT, MUSS VORHER WISSEN, WAS ER ZAHLT. Vier Knoepfe, vier
+ Preise -- ein Knopf ohne Preis waere der eine, bei dem es niemand
+ merkt. */
+if (geben.preise.some((p) => !/\d/.test(p) || !p.includes("€"))) {
+ console.log(` ACHTUNG: An einem Knopf fehlt der Preis (${geben.preise.join(" | ")}).`);
+ ausgang = 1;
+}
+/* UND SONST NIRGENDS. Ein Zuschauer sieht den Preis dort, wo er
+ kauft -- und sonst nur Dogen. */
+if (geben.geldSonstwo) {
+ console.log(" ACHTUNG: Auf der Zuschauerseite steht Geld ausserhalb der Kaufknoepfe.");
+ ausgang = 1;
+}
/* ==== DIE PROBE AENDERT NUR DAS, WAS SIE AENDERN MUSS ============
Der Knopf ist ein Verweis mit `target="_blank"` und fuehrt zu
@@ -1837,8 +2005,38 @@ if (modiSicht !== 0 || abz.namensknoepfe < 3) {
/* DAS MENUE MUSS INS BILD PASSEN. Ein Menue, das unten oder rechts
heraussteht, ist auf dem Handy die Haelfte der Zeit nicht
bedienbar -- und man sieht es erst, wenn man es aufmacht. */
-await modi.seite.click("#chat-liste .beitrag:last-child .beitrag__name").catch(() => {});
-await modi.seite.waitForTimeout(400);
+/* ==== AUF DAS MENUE WARTEN, NICHT AUF DIE UHR ===================
+
+ Hier stand `click(...).catch(() => {})` und danach 400 ms Pause.
+ Beide Haelften waren falsch:
+
+ Der `.catch()` verschluckte einen fehlgeschlagenen Klick still.
+ Die Chatliste wird bei jeder Meldung neu gezeichnet, und trifft
+ das genau zwischen Auswahl und Klick, ist der Knoten weg.
+
+ Und 400 ms sind eine Wette. Gemessen: Derselbe Lauf war am
+ 28.09. dreimal gruen und beim vierten Mal rot -- geaendert
+ hatte sich am Programm nichts, nur an dieser Messung. Ein
+ Ergebnis, das ohne Codeaenderung kippt, ist ein Fehler der
+ Messung und kein Befund.
+
+ Jetzt wird auf das MERKMAL gewartet und bis zu dreimal neu
+ angesetzt -- und die Zahl der Versuche steht im Protokoll. Ein
+ stiller Wiederholungsversuch waere nur eine leisere Wette. */
+let menueVersuche = 0;
+let menueOffen = false;
+while (menueVersuche < 3 && !menueOffen) {
+ menueVersuche += 1;
+ await modi.seite.click("#chat-liste .beitrag:last-child .beitrag__name")
+ .catch(() => {});
+ menueOffen = await modi.seite.waitForSelector(".modmenue", { timeout: 3000 })
+ .then(() => true).catch(() => false);
+}
+if (menueVersuche > 1) {
+ console.log(` (Das Menue brauchte ${menueVersuche} Anlaeufe -- die Chatliste `
+ + `wurde dazwischen neu gezeichnet.)`);
+}
+await modi.seite.waitForTimeout(250);
const menue = await modi.seite.evaluate(() => {
const m = document.querySelector(".modmenue");
if (!m) return null;
diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs
index 5e018db8..71053b62 100644
--- a/server/pruef-reaktion.mjs
+++ b/server/pruef-reaktion.mjs
@@ -1895,6 +1895,265 @@ melde("=== 19. Groesse und Ecke der Kamerafenster ===");
}
}
+
+/* =======================================================================
+ 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(/grid-template-areas:\s*"links rechts"\s*"leiste leiste"/.test(regeln.replace(/\s+/g, " ")
+ .replace(/" "/g, '" "')) || /links rechts[\s\S]{0,40}leiste leiste/.test(regeln),
+ "der Regieplatz hat links, rechts und eine Leiste unten");
+ const platz = [...regeln.matchAll(/([^{}]*\.regieplatz[^{}]*)\{([^}]*)\}/g)]
+ .filter((m) => /grid-template-areas/.test(m[2]));
+ /* ZWEI REGELN FUER DIESELBE FRAGE SIND DIE GARANTIE, DASS EINE
+ FALSCH GEWINNT -- heute schon viermal passiert. Die Handyansicht
+ darf die Bereiche neu setzen; deshalb zwei, nicht eine. */
+ ok(platz.length === 2,
+ `${platz.length} Regeln ordnen den Regieplatz (Rechner und Handy)`);
+
+ /* Die Leiste traegt den ganzen Transport -- und zwar in EINER. */
+ const iLeiste = html.indexOf('class="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");
diff --git a/server/pruef-spenden.mjs b/server/pruef-spenden.mjs
index f4e60981..4d8d4e4a 100644
--- a/server/pruef-spenden.mjs
+++ b/server/pruef-spenden.mjs
@@ -670,22 +670,74 @@ melde("=== 9. Dogen, eigener Text und Punkte ===");
}
return raus;
};
- for (const [wer, keks] of [["die Leitung", k.admin], ["ein Zuschauer", k.gast]]) {
+ /* ==== ZWEI AUSNAHMEN, UND NUR DIESE ZWEI =====================
+
+ Am 28.09. stand hier „kein Geldfeld, fuer niemanden". Das war
+ fuer alles richtig, was ANZEIGE ist -- und falsch fuer die eine
+ Stelle, an der jemand KAUFT. Filipe: „die leute sollen nur
+ sehen was dogen kosten. sonst wissen die ja nicht wie viel es
+ kosten wird."
+
+ Erlaubt sind jetzt GENAU zwei Dinge, und die Pruefung nennt sie
+ beim Namen statt die Regel aufzuweichen:
+ `knoepfe[].cent` fuer alle -- der Preis am Kaufknopf
+ `kurs_cent_je_doge` nur fuer die Leitung
+ Alles andere bleibt verboten. Eine Ausnahmeliste, die man
+ aufzaehlen kann, ist eine Regel; „irgendwo darf Geld stehen"
+ waere keine. */
+ const ERLAUBT = new Set(["cent", "kurs_cent_je_doge"]);
+ for (const [wer, keks, kursErlaubt] of [
+ ["die Leitung", k.admin, true],
+ ["ein Zuschauer", k.gast, false],
+ ]) {
const a = await roh(CREW, "/workspace/api/spenden", { keks });
const felder = namen(a.j);
- const geld = [...felder].filter((n) => /cent|waehrung|euro|eur$/i.test(n));
+ const geld = [...felder].filter((n) => /cent|waehrung|euro|eur$/i.test(n))
+ .filter((n) => !ERLAUBT.has(n));
ok(geld.length === 0,
- geld.length ? `GELD IM STAND (${wer}): ${geld.join(", ")}`
- : `kein Geldfeld im Stand (${wer}, ${felder.size} Felder)`);
+ geld.length ? `UNERLAUBTES GELDFELD (${wer}): ${geld.join(", ")}`
+ : `kein unerlaubtes Geldfeld im Stand (${wer}, ${felder.size} Felder)`);
ok(felder.has("dogen_max_mille") && felder.has("gruss_max"),
"und die Grenzen stehen als Dogen und Zeichen darin");
- /* Ein Eurozeichen darf nirgends stehen -- auch nicht in einem
- Text, den irgendwer einmal eingetippt hat. */
- ok(!JSON.stringify(a.j).includes("€"), `kein Eurozeichen (${wer})`);
+
+ /* ---- Der Preis steht am Kaufknopf -- fuer alle ---- */
+ const knopf = a.j?.knoepfe?.[0];
+ ok(typeof knopf?.cent === "number" && knopf.cent > 0,
+ `der Preis steht am Knopf (${wer}: ${knopf?.cent} Cent)`);
+
+ /* ---- Und der Kurs nur bei der Leitung ----
+ Er ist der Generalschluessel: Wer ihn hat, kann JEDEN
+ Dogenwert in Geld umrechnen. Genau deshalb steckt die
+ Erlaubnis in den Daten und nicht in einem `if` der
+ Oberflaeche -- eine fehlende Zahl kann man nicht vergessen. */
+ const kurs = a.j?.kurs_cent_je_doge;
+ ok(kursErlaubt ? typeof kurs === "number" && kurs > 0 : kurs === null,
+ `der Kurs ${kursErlaubt ? "kommt mit" : "bleibt weg"} (${wer}: ${kurs})`);
+
+ /* Ausser an den Knoepfen darf nirgends ein Eurozeichen stehen --
+ auch nicht in einem Text, den jemand eingetippt hat. */
+ const ohneKnoepfe = { ...a.j, knoepfe: undefined };
+ ok(!JSON.stringify(ohneKnoepfe).includes("€"), `kein Eurozeichen (${wer})`);
}
ok([...namen({ a: [{ betrag_cent: 1 }] })].includes("betrag_cent"),
"Gegenprobe: ein Geldfeld wuerde gefunden");
+ /* ==== DIE KARTE TRAEGT WEITERHIN KEINEN CENT ==================
+ Sie geht auch an die Buehnentafel, und die steht im Stream. Ein
+ Betrag, der dort ankommt, waere Geld im Bild -- genau das, was
+ nirgends stehen soll. Der Preis gehoert an den Kaufknopf und
+ nirgendwo sonst. */
+ {
+ const probe = await roh(CREW, "/workspace/api/spenden/probe", {
+ method: "POST", keks: k.admin, rumpf: { dogen_mille: 500 },
+ });
+ const felder = namen(probe.j?.karte);
+ const geld = [...felder].filter((n) => /cent|waehrung|euro|eur$/i.test(n));
+ ok(geld.length === 0,
+ geld.length ? `GELD AUF DER KARTE: ${geld.join(", ")}`
+ : `die Karte traegt keinen Betrag (${felder.size} Felder)`);
+ }
+
/* ---- DIE KNOEPFE FUEHREN ZU EINEM EURO-BETRAG, ZEIGEN ABER DOGEN ----
Der Euro entsteht erst in der PayPal-Adresse, weil PayPal ihn
braucht. Das ist der einzige Ort, an dem er ueberhaupt noch
@@ -696,8 +748,19 @@ melde("=== 9. Dogen, eigener Text und Punkte ===");
ok(knopf?.mille === KNOEPFE_MILLE[0],
`der erste Knopf steht auf ${knopf?.mille} Tausendsteln`);
ok(knopf?.ziel?.endsWith("EUR"),
- `und fuehrt trotzdem zu einer Zahlung (${knopf?.ziel})`);
- ok(knopf?.cent === undefined, "der Euro-Betrag selbst kommt nicht mit");
+ `und fuehrt zu einer Zahlung (${knopf?.ziel})`);
+ /* ==== UND DER PREIS STEHT DABEI ============================
+ Hier stand bis zum 28.09. das Gegenteil: „der Euro-Betrag
+ selbst kommt nicht mit". Das war die Regel „nie Geld", bis
+ eine Stelle ihr widersprach -- die einzige, an der jemand
+ BEZAHLT. Wer auf „0,2 Dogen" drueckt, muss vorher wissen,
+ dass ihn das zwei Euro kostet; den Preis erst auf der
+ Zahlseite zu erfahren ist keiner.
+ Die Zahl muss zur Adresse PASSEN -- sonst steht am Knopf ein
+ Preis und bei PayPal ein anderer, und das merkt niemand
+ vorher. */
+ ok(knopf?.cent === milleInCent(knopf?.mille),
+ `und der Preis dazu (${knopf?.cent} Cent) passt zur Zahlung`);
}
/* ---- DER TEXT KOMMT VON DEM, DER GIBT ---- */
diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js
index 8af22ec8..53a53f54 100644
--- a/server/reaktion-tabellen.js
+++ b/server/reaktion-tabellen.js
@@ -360,6 +360,16 @@ export function reaktionTabellen(d) {
}
const dabei = new Set(
d.prepare("PRAGMA table_info(reaktion_dabei)").all().map((z) => z.name));
+ if (!dabei.has("bildschirm")) {
+ /* WER GERADE SEINEN BILDSCHIRM TEILT.
+ Es ist eine eigene Spalte und nicht „kamera_aus umgedreht":
+ Beim Teilen geht ein Bild hinaus, es ist nur ein anderes. Wer
+ beides in eine Spalte legte, koennte nachher nicht mehr
+ sagen, ob da ein schwarzes Fenster steht oder ein
+ Schreibtisch. */
+ d.exec("ALTER TABLE reaktion_dabei ADD COLUMN bildschirm INTEGER NOT NULL DEFAULT 0");
+ console.log("[reaktion] Spalte bildschirm angelegt.");
+ }
if (!dabei.has("kamera_aus")) {
/* Wer sein Bild gerade abgeschaltet hat.
AN DIESER TABELLE UND NICHT AN DEN GAESTEN: Hier steht, wer
diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js
index 728c63d0..e7fd5db4 100644
--- a/server/workspace-reaktion.js
+++ b/server/workspace-reaktion.js
@@ -220,7 +220,7 @@ function dabeiListe() {
const grenze = new Date(Date.now() - 120_000).toISOString();
db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze);
return db().prepare(`
- SELECT d.person_id AS id, d.sicht, d.seit, d.kamera_aus, d.mikro_aus,
+ SELECT d.person_id AS id, d.sicht, d.seit, d.kamera_aus, d.mikro_aus, d.bildschirm,
p.name, p.rolle
FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id
ORDER BY d.seit`).all();
@@ -277,13 +277,24 @@ function lage(person) {
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id);
if (!p) return null;
const z = dabei.find((d) => d.id === p.id);
- return { ...p, kamera_aus: !!z?.kamera_aus, mikro_aus: !!z?.mikro_aus };
+ return {
+ ...p,
+ kamera_aus: !!z?.kamera_aus,
+ mikro_aus: !!z?.mikro_aus,
+ bildschirm: !!z?.bildschirm,
+ };
})()
: null,
gaeste: gaeste.map((g) => ({
id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz,
kamera_aus: !!(dabei.find((d) => d.id === g.id)?.kamera_aus),
mikro_aus: !!(dabei.find((d) => d.id === g.id)?.mikro_aus),
+ /* WER TEILT, MUSS ERKENNBAR SEIN. Ohne diese Auskunft waere
+ ein geteilter Bildschirm ein Kamerafenster, in dem ploetzlich
+ ein Schreibtisch steht -- und das Fenster wuerde ihn
+ beschneiden, weil eine Kamera hochkant zugeschnitten wird
+ und ein Bildschirm nicht. */
+ bildschirm: !!(dabei.find((d) => d.id === g.id)?.bildschirm),
})),
chat_modus: s.chat_modus || "offen",
chat_modi: CHAT_MODI,
@@ -336,6 +347,7 @@ function lage(person) {
moderiert: MODERIERT.has(person.rolle),
sicht: !!(dabei.find((d) => d.id === person.id)?.sicht),
kamera_aus: !!(dabei.find((d) => d.id === person.id)?.kamera_aus),
+ bildschirm: !!(dabei.find((d) => d.id === person.id)?.bildschirm),
mikro_aus: !!(dabei.find((d) => d.id === person.id)?.mikro_aus),
darf_schreiben: darfSchreiben(person, s),
/* Der Grund steht daneben -- die Seite muss ihn nicht raten. */
@@ -462,6 +474,72 @@ reaktionRouter.get("/workspace/api/reaktion", (req, res) => {
}
});
+/* ======================================================================
+ ALLES ZURUECKSETZEN
+
+ Filipe: „brauch ich auch noch einen button wo ich alles easy
+ zuruecksetzen kann."
+
+ ---------------------------------------------------------------------
+ DER SCHREIBTISCH, NICHT DAS GEDAECHTNIS
+
+ Geleert wird, was zur naechsten Sendung gehoert: Titel, Video,
+ zweites Video, Vorschaubild, Beginn, Warteschlange, Gaesteliste --
+ und das Bild geht auf seine Vorgaben zurueck.
+
+ NICHT ANGETASTET wird, was Menschen getan haben: Spenden, die
+ Dogen der Leute, der Chatverlauf, die Massnahmen der Moderation.
+ Eine bezahlte Spende aus den Buechern zu nehmen waere eine
+ Faelschung; eine stillschweigend aufgehobene Sperre waere eine
+ Entscheidung, die niemand getroffen hat. „Alles" heisst hier
+ „alles, was ich selbst eingetragen habe".
+
+ NICHT WAEHREND EINER SENDUNG. Mitten im Live zurueckzusetzen
+ zieht allen das Video unter den Fuessen weg -- und zwar ohne dass
+ es rueckgaengig zu machen waere.
+ ====================================================================== */
+reaktionRouter.post("/workspace/api/reaktion/zuruecksetzen", nurHost, (req, res) => {
+ try {
+ const s = sendung();
+ if (s.stand === "live") return res.status(409).json({ fehler: "laeuft_noch" });
+
+ db().exec("BEGIN IMMEDIATE");
+ try {
+ /* DIE SPALTEN STEHEN EINZELN DA UND NICHT ALS „alles ausser".
+ Eine Liste von Ausnahmen waechst still mit jeder neuen
+ Spalte mit -- und dann loescht das Zuruecksetzen etwas, das
+ es nie loeschen sollte. Was hier fehlt, bleibt stehen, und
+ das ist die sichere Richtung. */
+ db().prepare(`UPDATE reaktion SET
+ stand = 'zu', titel = '', video = '', video_titel = '', vorschau = '',
+ beginnt_am = NULL, laeuft = 0, sekunde = 0, tempo = 1,
+ layout = 'kino', kamera_gross = 1, kamera_ecke = 'ur',
+ chat_modus = 'offen',
+ zweit_video = '', zweit_titel = '', zweit_sekunde = 0,
+ gestartet_am = NULL, stand_seit = ?, geaendert = ?
+ WHERE id = 1`).run(jetzt(), jetzt());
+ db().prepare("DELETE FROM reaktion_liste").run();
+ db().prepare("DELETE FROM reaktion_gaeste").run();
+ db().exec("COMMIT");
+ } catch (f) {
+ db().exec("ROLLBACK");
+ throw f;
+ }
+
+ protokolliere("reaktion_zurueckgesetzt", {
+ personId: req.person.id,
+ detail: "Pult geleert (Spenden, Dogen, Chat und Massnahmen unberuehrt)",
+ ip: echteIp(req),
+ });
+ const l = lage(req.person);
+ melden("reaktion", { was: "stand", lage: oeffentlich(l) });
+ res.json(l);
+ } catch (fehler) {
+ console.error("[reaktion] Zuruecksetzen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
/** Der Host stellt ein: Titel, Video, Vorschaubild, Beginn. */
reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32kb" }),
async (req, res) => {
@@ -1226,6 +1304,9 @@ reaktionRouter.post("/workspace/api/reaktion/senden", express.json({ limit: "2kb
if (req.body?.mikro_aus !== undefined) {
felder.push("mikro_aus = ?"); werte.push(req.body.mikro_aus ? 1 : 0);
}
+ if (req.body?.bildschirm !== undefined) {
+ felder.push("bildschirm = ?"); werte.push(req.body.bildschirm ? 1 : 0);
+ }
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
const geschrieben = db().prepare(
diff --git a/server/workspace-spenden.js b/server/workspace-spenden.js
index d19e105f..efa98d1b 100644
--- a/server/workspace-spenden.js
+++ b/server/workspace-spenden.js
@@ -45,7 +45,7 @@ import {
BETRAG_MAX_CENT, NAME_MAX, GRUSS_MAX, KNOEPFE_MILLE, SPENDEN_TAGE,
VORLAGEN, STUFEN_MAX, DAUER_MS_MIN, DAUER_MS_MAX,
GROESSE_MIN, GROESSE_MAX, BILD_MAX_BYTES,
- centInMille, milleInCent, ZEICHEN_NAME,
+ centInMille, milleInCent, ZEICHEN_NAME, CENT_JE_DOGE,
DOGEN_SAETZE, DOGEN_STUFEN, SATZ_NAME, muenzeAdresse,
} from "./spenden-tabellen.js";
@@ -443,12 +443,22 @@ spendenRouter.get("/workspace/api/spenden", (req, res) => {
try {
const pp = paypalAdresse();
const darf = GELD_ROLLEN.has(req.person.rolle);
- /* DER EURO ENTSTEHT ERST IN DER ADRESSE. `mitBetrag` braucht
- ihn, weil PayPal ihn braucht -- an den Browser geht nur die
- fertige Adresse und die Zahl in Dogen. */
+ /* ==== HIER DARF DER PREIS STEHEN -- UND NUR HIER ===========
+
+ Filipe: „die leute sollen nur sehen was dogen kosten. sonst
+ wissen die ja nicht wie viel es kosten wird."
+
+ Das ist die eine Stelle, an der jemand KAUFT. Wer auf „0,2
+ Dogen" drueckt, muss vorher wissen, dass ihn das zwei Euro
+ kostet. Ueberall sonst ist Geld eine Anzeige, hier ist es ein
+ Preis -- und ein Preis, den man erst auf der Zahlseite
+ erfaehrt, ist keiner. */
const knoepfe = pp
- ? KNOEPFE_MILLE.map((m) => ({ mille: m, ziel: mitBetrag(pp.ziel, milleInCent(m)) }))
- .filter((k) => k.ziel)
+ ? KNOEPFE_MILLE.map((m) => ({
+ mille: m,
+ cent: milleInCent(m),
+ ziel: mitBetrag(pp.ziel, milleInCent(m)),
+ })).filter((k) => k.ziel)
: [];
res.json({
paypal: pp ? { ziel: pp.ziel, knopf: pp.knopf } : null,
@@ -475,6 +485,23 @@ spendenRouter.get("/workspace/api/spenden", (req, res) => {
groesse_min: GROESSE_MIN, groesse_max: GROESSE_MAX,
bild_max_mb: Math.round(BILD_MAX_BYTES / 1024 / 1024),
},
+ /* ==== DER KURS GEHT NUR AN DIE LEITUNG ==================
+
+ Filipe: „damit ich auch immer weiss was es ist. aber nur
+ ich."
+
+ Mit dieser einen Zahl kann die Oberflaeche jeden Dogenwert
+ in Geld umrechnen -- an der Stufenleiter, beim Eintragen,
+ in den Listen. Ein Zuschauer bekommt sie nicht: Fuer ihn
+ ist der Preis an den Knoepfen die ganze Auskunft, die er
+ braucht, und alles darueber hinaus waere Geld auf dem
+ Bildschirm ohne Anlass.
+
+ EINE ZAHL UND NICHT DREISSIG EINZELUMRECHNUNGEN. Wer je
+ Zeile einen Cent mitschickt, hat dreissig Gelegenheiten,
+ eine zu vergessen -- und dann steht an einer Stelle ein
+ Preis von gestern. */
+ kurs_cent_je_doge: darf ? CENT_JE_DOGE : null,
/* Die Grenzen fuer die Felder -- in Dogen und in Zeichen. Sie
kommen vom Server, damit nicht zwei Zahlen dieselbe Frage
beantworten und eine davon veraltet. */
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 7b851ceb..be38ce9f 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+