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`. --> - - + + - +