diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 6ed5b052..4a411b8f 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -123,6 +123,17 @@ await roh("/workspace/api/reaktion", { }, }); +/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf + einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist + ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel, + Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */ +for (const adresse of ["https://youtu.be/9bZkp7q19f0", + "https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) { + await roh("/workspace/api/reaktion/liste", { + method: "POST", keks: k.admin, rumpf: { video: adresse }, + }); +} + const REDEN = [ ["gast", "Bin schon daaa đŸș"], ["gast2", "Ich auch! Hab extra Popcorn geholt"], @@ -301,6 +312,17 @@ for (const [name, m] of Object.entries(beiZuschauer)) { /* AUCH DER HOST MUSS SEINEN GAST SEHEN. Wenn nur die Zuschauer etwas sehen, redet der Host gegen eine schwarze Kachel -- und merkt nicht, wenn beim Gast die Kamera aus ist. */ +/* LAEUFT DAS VIDEO UEBERHAUPT? Dieselbe Falle wie bei den Kameras: + Etwas, das Ton macht, darf nicht von allein losgehen. */ +let videoLaeuft = 0; +for (const [wer, w] of [["Host", host.seite], ["Zuschauerin", zuschauer.seite]]) { + const v = await w.evaluate(() => ({ + stand: document.getElementById("video-platz")?.dataset.spieler || "(nichts)", + rahmen: !!document.querySelector("#video-platz iframe"), + })); + console.log(` Video bei ${wer.padEnd(12)} Rahmen=${v.rahmen} Zustand=${v.stand}`); + if (v.stand === "laeuft" || v.stand === "puffert") videoLaeuft++; +} const beimHost = await bildBreiten(host.seite); for (const [name, m] of Object.entries(beimHost)) { console.log(` Host sieht ${name.padEnd(12)} ` @@ -319,6 +341,13 @@ console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kom ein Video mit Ton nicht von allein startet. Beide Male sahen die Bildschirmfotos plausibel aus. */ let ausgang = 0; +/* LAEUFT DAS VIDEO? Ein Zuschauer hat keine Bedienung -- startet es + nicht von selbst, sitzt er eine Stunde vor einem Standbild und + kann nichts dagegen tun. Gemessen war genau das der Fall. */ +if (videoLaeuft < 2) { + console.log(` ACHTUNG: Das Video laeuft nur bei ${videoLaeuft} von 2.`); + ausgang = 1; +} const beimHostAn = Object.values(beimHost).filter((m) => m.breite > 0).length; if (beimHostAn < Object.keys(beimHost).length) { console.log(" ACHTUNG: Der Host sieht nicht alle Kameras."); @@ -336,10 +365,128 @@ if (stehen) { ausgang = 1; } -await host.seite.click("#pult-griff").catch(() => {}); -await host.seite.waitForTimeout(700); +/* ---- Die Regie --------------------------------------------------- + NICHT UMSCHALTEN, SONDERN HERSTELLEN. Ein Klick auf „Griff" heisst + „das Gegenteil von dem, was gerade ist" -- und setzt damit voraus, + dass die Messung den Zustand vorher kennt. Genau daran ist sie + einmal gescheitert: Seit das Pult aufgeklappt startet, hat der + Klick es ZUgemacht, und auf dem Bild war von der Regie nichts zu + sehen. */ +console.log(""); +console.log(" --- Die Regie ---"); +await host.seite.evaluate(() => { + const p = document.getElementById("pult"); + if (p && p.dataset.auf !== "ja") document.getElementById("pult-griff")?.click(); +}); +await host.seite.waitForTimeout(900); + +const regie = await host.seite.evaluate(() => { + const t = (id) => document.getElementById(id)?.textContent?.trim() || ""; + const sicht = (w) => { + const e = document.querySelector(w); + if (!e) return false; + const r = e.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + return { + auf: document.getElementById("pult")?.dataset.auf, + lampe: t("pult-wort"), + uhr: t("pult-zeit"), + dabei: t("z-dabei"), + sicht_zahl: t("z-sicht"), + last: t("z-last"), + spur: t("spur-titel"), + zeit: t("v-zeit"), + reiter: document.querySelectorAll("#reiter .reiter__knopf").length, + tafel_sichtbar: [...document.querySelectorAll(".pult-tafel")].filter((e) => !e.hidden).length, + regler: document.querySelectorAll("#regler .regler").length, + pegel: document.querySelectorAll("#regler .pegel").length, + knoepfe_sichtbar: ["p-vorbereiten", "p-starten", "p-beenden"] + .filter((id) => sicht("#" + id)).length, + /* WOHER KOMMT EIN UEBERLAUF? Die blosse Zahl „197 Pixel zum + Scrollen" schickt einen ins Raten. Die Hoehen daneben zeigen + in einer Zeile, welcher Kasten zu gross ist. */ + hoehen: (() => { + const h = (w) => { + const e = document.querySelector(w); + return e ? Math.round(e.getBoundingClientRect().height) : -1; + }; + return `fenster ${window.innerHeight} · saal ${h("#saal")}` + + ` · kino ${h("#teil-live")} · pult ${h("#pult")}` + + ` (leiste ${h(".pult__leiste")} + innen ${h("#pult-innen")})` + + ` · tafeln sichtbar ${[...document.querySelectorAll(".pult-tafel")] + .filter((e) => e.getBoundingClientRect().height > 0).length}` + + ` · innen rollt ${document.getElementById("pult-innen").scrollHeight}`; + })(), + kinder: [...document.getElementById("pult-innen").children].map((e) => { + const r = e.getBoundingClientRect(); + const c = getComputedStyle(e); + return `${e.className || e.tagName}:${Math.round(r.top)}+${Math.round(r.height)}` + + `/${c.display}/${c.position}`; + }).join(" "), + }; +}); +console.log(" " + regie.hoehen); +console.log(" " + regie.kinder); +console.log(` Lampe „${regie.lampe}" Uhr ${regie.uhr} ` + + `Sehen zu ${regie.dabei} mit Bild ${regie.sicht_zahl} Upload ${regie.last}`); +console.log(` Spur: „${regie.spur}" ${regie.zeit}`); +console.log(` ${regie.reiter} Register, ${regie.tafel_sichtbar} Tafel offen, ` + + `${regie.regler} Regler mit ${regie.pegel} Pegeln, ` + + `${regie.knoepfe_sichtbar}/3 grosse Knoepfe sichtbar`); + +/* JEDE DIESER ZAHLEN KANN STILL FALSCH SEIN -- und genau dann sieht + ein Bildschirmfoto trotzdem gut aus. Deshalb werden sie geprueft + und nicht nur ausgegeben. */ +if (regie.auf !== "ja") { + console.log(" ACHTUNG: Die Regie liess sich nicht aufklappen."); + ausgang = 1; +} +if (!/^\d+:\d\d/.test(regie.uhr) || regie.uhr === "0:00") { + console.log(` ACHTUNG: Die Laufzeit steht still (${regie.uhr}).`); + ausgang = 1; +} +if (!/kbit\/s|Mbit\/s/.test(regie.last)) { + console.log(` ACHTUNG: Der Upload wird nicht gemessen (${regie.last}).`); + ausgang = 1; +} +if (regie.reiter !== 5 || regie.tafel_sichtbar !== 1) { + console.log(` ACHTUNG: Die Register stimmen nicht (${regie.reiter} Reiter, ` + + `${regie.tafel_sichtbar} offen).`); + ausgang = 1; +} +if (regie.regler !== 4 || regie.pegel !== 4 || regie.knoepfe_sichtbar !== 3) { + console.log(" ACHTUNG: Regler, Pegel oder die grossen Knoepfe fehlen."); + ausgang = 1; +} + await vermessen(host.seite, "Host am Rechner"); await bild(host.seite, "3-live-host"); + +/* Und einmal die Warteschlange -- das ist der Teil, den Filipe + vorher benutzt, nicht waehrend der Sendung. */ +await host.seite.click('#reiter .reiter__knopf[data-tafel="liste"]').catch(() => {}); +await host.seite.waitForTimeout(600); +const warte = await host.seite.evaluate(() => ({ + zeilen: document.querySelectorAll("#warteliste .warte-zeile").length, + bilder: [...document.querySelectorAll(".warte-zeile__bild")] + .filter((b) => b.naturalWidth > 0).length, + titel: [...document.querySelectorAll(".warte-zeile__titel")] + .map((e) => e.textContent.trim()).join(" | "), + naechstes: document.getElementById("v-naechstes")?.disabled, +})); +console.log(` Warteschlange: ${warte.zeilen} Zeilen, ` + + `${warte.bilder} Vorschaubilder geladen, „Naechstes" ` + + `${warte.naechstes ? "grau" : "nutzbar"}`); +console.log(` ${warte.titel}`); +if (warte.zeilen !== 2 || warte.naechstes) { + console.log(" ACHTUNG: Die Warteschlange kommt nicht an."); + ausgang = 1; +} +await bild(host.seite, "7-live-regie-liste"); +await host.seite.click('#reiter .reiter__knopf[data-tafel="ton"]').catch(() => {}); +await host.seite.waitForTimeout(500); +await bild(host.seite, "8-live-regie-ton"); if (host.konsole.length) console.log(" KONSOLE:", host.konsole.slice(0, 4).join(" | ")); await vermessen(zuschauer.seite, "Zuschauerin am Rechner"); diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index 5a12fd43..3441881c 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -40,6 +40,11 @@ const PORT = await eigenerPort(import.meta, "die Reaction-Pruefung"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; +/* DREI STATT DREISSIG. Sonst muesste die Pruefung dreissig Videos + anhaengen, um den vollen Fall zu erreichen -- und ein Grenzfall, + den niemand prueft, weil das Pruefen zu lange dauert, ist ein + ungeprueftes Verhalten. */ +process.env.REAKTION_LISTE_MAX = "3"; const express = (await import("express")).default; const ec = express.response.cookie; @@ -627,10 +632,258 @@ melde("=== 10. Was um die Seite herum stimmen muss ==="); } /* ======================================================================= - 11. KEIN WEG HEISST ZWEIMAL GLEICH + 11. DIE WARTESCHLANGE + + Vorher gab es genau EIN Videofeld. Wer zwei Sachen hintereinander + schauen wollte, tippte mitten in der Sendung eine Adresse ein -- + vor Publikum, mit laufender Kamera. Ein Tippfehler war ein + schwarzes Rechteck. ======================================================================= */ melde(""); -melde("=== 11. Keine doppelten Wege ==="); +melde("=== 11. Die Warteschlange ==="); +{ + await roh(CREW, "/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: "zu" }, + }); + + /* ERFUNDENE KENNUNGEN, mit Absicht. Elf erlaubte Zeichen -- die + Form stimmt, das Video gibt es nicht. Damit laeuft nebenbei der + Fall mit, in dem YouTube keinen Titel herausrueckt: Der Eintrag + muss trotzdem entstehen, und im Titel muss ein LEERER Text + stehen, keine Erfindung. */ + const A = "aaaaaaaaaaa", B = "bbbbbbbbbbb", C = "ccccccccccc", D = "ddddddddddd"; + const haengen = (rolle, video) => roh(CREW, "/workspace/api/reaktion/liste", { + method: "POST", keks: k[rolle], rumpf: { video }, + }); + + for (const rolle of ["linke", "modi", "gast"]) { + const a = await haengen(rolle, A); + ok(a.code === 403, `${rolle}: haengt nichts an (${a.code})`); + } + + const mist = await haengen("admin", "das ist keine adresse"); + ok(mist.code === 400 && mist.j?.fehler === "video_unklar", + `eine kaputte Adresse wird abgelehnt (${mist.code}/${mist.j?.fehler})`); + + const eins = await roh(CREW, "/workspace/api/reaktion/liste", { + method: "POST", keks: k.admin, + rumpf: { video: `https://www.youtube.com/watch?v=${A}` }, + }); + ok(eins.code === 200 && eins.j?.liste?.length === 1, + `aus der Adresse wird ein Eintrag (${eins.code}, ${eins.j?.liste?.length})`); + ok(eins.j?.liste?.[0]?.video === A, + `und die Kennung stimmt (${eins.j?.liste?.[0]?.video})`); + ok(typeof eins.j?.liste?.[0]?.titel === "string", + "der Titel ist IMMER ein Text -- auch wenn YouTube nichts hergab " + + `(hier: „${eins.j?.liste?.[0]?.titel}")`); + + const doppelt = await haengen("admin", A); + ok(doppelt.code === 409 && doppelt.j?.fehler === "liste_doppelt", + `dasselbe Video nicht zweimal (${doppelt.code}/${doppelt.j?.fehler})`); + + /* Die rechte Hand raeumt genauso ein -- beide bereiten vor. */ + const zwei = await haengen("hand", B); + ok(zwei.code === 200 && zwei.j?.liste?.length === 2, + `die rechte Hand raeumt auch ein (${zwei.code})`); + const drei = await haengen("admin", C); + ok(drei.j?.liste?.map((z) => z.video).join(",") === [A, B, C].join(","), + `die Reihenfolge ist die des Anhaengens (${drei.j?.liste?.map((z) => z.video).join(",")})`); + + const voll = await haengen("admin", D); + ok(voll.code === 409 && voll.j?.fehler === "liste_voll", + `ueber der Grenze ist Schluss (${voll.code}/${voll.j?.fehler})`); + + /* ---- Schieben ---- */ + const idB = drei.j.liste[1].id; + const hoch = await roh(CREW, `/workspace/api/reaktion/liste/${idB}/schieben`, { + method: "POST", keks: k.hand, rumpf: { richtung: "hoch" }, + }); + ok(hoch.j?.liste?.map((z) => z.video).join(",") === [B, A, C].join(","), + `hoch tauscht mit dem Nachbarn (${hoch.j?.liste?.map((z) => z.video).join(",")})`); + const nochmal = await roh(CREW, `/workspace/api/reaktion/liste/${idB}/schieben`, { + method: "POST", keks: k.admin, rumpf: { richtung: "hoch" }, + }); + ok(nochmal.code === 200 + && nochmal.j?.liste?.map((z) => z.video).join(",") === [B, A, C].join(","), + "ganz oben noch einmal hoch aendert nichts und ist kein Fehler"); + const runter = await roh(CREW, `/workspace/api/reaktion/liste/${idB}/schieben`, { + method: "POST", keks: k.admin, rumpf: { richtung: "runter" }, + }); + ok(runter.j?.liste?.map((z) => z.video).join(",") === [A, B, C].join(","), + `und runter wieder zurueck (${runter.j?.liste?.map((z) => z.video).join(",")})`); + + /* ---- Wer die Liste sieht ---- */ + const h = await roh(CREW, "/workspace/api/reaktion", { keks: k.hand }); + ok(Array.isArray(h.j?.liste) && h.j.liste.length === 3, + `die zwei sehen die ganze Liste (${h.j?.liste?.length})`); + for (const rolle of ["linke", "modi", "gast"]) { + const z = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] }); + ok(z.j?.liste === null, `${rolle}: bekommt die Warteschlange NICHT`); + ok(z.j?.naechstes === A, + `${rolle}: was als Naechstes kommt, darf er wissen (${z.j?.naechstes})`); + } + + /* ---- Weiterschalten ---- */ + const w = await roh(CREW, "/workspace/api/reaktion/weiter", { + method: "POST", keks: k.admin, rumpf: {}, + }); + ok(w.code === 200 && w.j?.video === A, + `„Naechstes" holt das oberste ins Bild (${w.j?.video})`); + ok(w.j?.liste?.length === 2 && !w.j.liste.some((z) => z.video === A), + "und nimmt es aus der Liste heraus"); + ok(w.j?.laeuft === true && w.j?.sekunde === 0, + `es beginnt von vorn und laeuft (laeuft=${w.j?.laeuft}, sekunde=${w.j?.sekunde})`); + + const gezielt = await roh(CREW, "/workspace/api/reaktion/weiter", { + method: "POST", keks: k.hand, rumpf: { id: w.j.liste[1].id }, + }); + ok(gezielt.j?.video === C, + `„Jetzt" holt genau das gewaehlte, nicht das oberste (${gezielt.j?.video})`); + + const nurEins = await roh(CREW, "/workspace/api/reaktion/weiter", { + method: "POST", keks: k.gast, rumpf: {}, + }); + ok(nurEins.code === 403, `ein Zuschauer schaltet nicht weiter (${nurEins.code})`); + + await roh(CREW, "/workspace/api/reaktion/weiter", { method: "POST", keks: k.admin, rumpf: {} }); + const leer = await roh(CREW, "/workspace/api/reaktion/weiter", { + method: "POST", keks: k.admin, rumpf: {}, + }); + ok(leer.code === 409 && leer.j?.fehler === "liste_leer", + `eine leere Liste sagt das auch (${leer.code}/${leer.j?.fehler})`); + + /* Und der Titel des laufenden Videos steht in der Lage -- auch + dann, wenn er leer ist. Ein fehlendes Feld waere `undefined` + und wuerde in der Oberflaeche zu „undefined". */ + const jetzt = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast }); + ok(typeof jetzt.j?.video_titel === "string", + `der Titel des laufenden Videos ist immer ein Text („${jetzt.j?.video_titel}")`); +} + +/* ======================================================================= + 12. DIE REGIE + + Hier wird nicht das Aussehen geprueft -- das kann eine Maschine + nicht -- sondern zweierlei, das man beim Umbauen still kaputt + macht: + + A) JEDES `$('id')`, das die Oberflaeche anfasst, muss es in der + Seite auch GEBEN. Ein Tippfehler ergibt sonst `null`, und der + Fehler faellt erst auf, wenn jemand den Knopf drueckt -- + mitten in einer Sendung. + + B) Die drei grossen Knoepfe muessen AUSSERHALB des + einklappbaren Teils liegen. Wer mitten in der Sendung + „Beenden" drueckt, drueckt es, weil etwas passiert ist. Das + darf nicht hinter einer Bewegung liegen. + ======================================================================= */ +melde(""); +melde("=== 12. Die Regie ==="); +{ + const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8"); + const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8"); + + const vorhanden = new Set([...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1])); + /* Kommentare erst weg -- sonst zaehlt ein Beispiel im Fliesstext + als Zugriff. */ + const ohneNotiz = js.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); + const gesucht = [...new Set([...ohneNotiz.matchAll(/\$\('([a-z0-9-]+)'\)/g)].map((m) => m[1]))]; + const fehlend = gesucht.filter((n) => !vorhanden.has(n)); + ok(gesucht.length > 30 && fehlend.length === 0, + fehlend.length + ? `FEHLT in der Seite: ${fehlend.join(", ")}` + : `alle ${gesucht.length} angefassten Kennungen gibt es in der Seite`); + + /* GEGENPROBE: Kann diese Pruefung ueberhaupt „nein" sagen? Eine, + die immer bestaetigt, bestaetigt nichts. */ + ok(!vorhanden.has("gibt-es-nicht") + && ["p-starten", "gibt-es-nicht"].filter((n) => !vorhanden.has(n)).length === 1, + "Gegenprobe: eine erfundene Kennung wuerde auffallen"); + + /* Auch die Register muessen zusammenpassen. */ + /* Die Reihenfolge der Merkmale im Text ist beliebig -- gesucht + wird deshalb erst die Zeile mit `role="tab"` und darin das + Merkmal. Ein Muster, das eine bestimmte Reihenfolge erwartet, + bricht beim naechsten Umformatieren. */ + const reiter = [...html.matchAll(/]*role="tab"[^>]*>/g)] + .map((m) => (m[0].match(/data-tafel="([a-z]+)"/) || [])[1]) + .filter(Boolean); + const tafeln = [...html.matchAll(/class="pult-tafel" data-tafel="([a-z]+)"/g)].map((m) => m[1]); + ok(reiter.length === 5 && reiter.every((r) => tafeln.includes(r)), + `zu jedem der ${reiter.length} Register gibt es eine Tafel (${reiter.join(", ")})`); + const steuert = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((m) => m[1]); + ok(steuert.length > 0 && steuert.every((n) => vorhanden.has(n)), + `alle ${steuert.length} Verweise mit aria-controls zeigen auf etwas Vorhandenes`); + + /* B) Die grossen Knoepfe liegen ausserhalb des Klappteils. */ + const woLeiste = html.indexOf('class="pult__leiste"'); + const woInnen = html.indexOf('id="pult-innen"'); + const woBeenden = html.indexOf('id="p-beenden"'); + ok(woLeiste > 0 && woInnen > woLeiste, + "die Leiste steht vor dem einklappbaren Teil"); + ok(woBeenden > woLeiste && woBeenden < woInnen, + "der Beenden-Knopf liegt in der Leiste -- also auch dann erreichbar, " + + "wenn die Regie zugeklappt ist"); + + /* Und die Kuerzel, die das Programm bindet, stehen auch dabei. + Ein Kuerzel, das niemand kennt, ist keins. */ + const legende = (html.match(/

[\s\S]*?<\/p>/) || [""])[0]; + for (const [taste, wort] of [["Leertaste", "Leertaste"], ["M", "M"], ["N", "N"]]) { + ok(legende.includes(`${wort}`), `das Kuerzel ${taste} steht in der Legende`); + } + ok(/taste === 'm' \|\| taste === 'M'/.test(js) && /taste === 'n' \|\| taste === 'N'/.test(js), + "und das Programm hoert auch darauf"); + ok(/closest\('input, textarea, select/.test(js), + "wer in einem Feld tippt, tippt -- die Leertaste gehoert dann dem Text"); + + /* ==== KEIN KLASSENNAME ZWEIMAL ================================ + + reaktion.html laedt FUENF Stilvorlagen: gate.css, start.css, + reaktion.css, module.css, haus.css. Wer hier eine Klasse + erfindet, die es dort schon gibt, erbt deren Regeln -- lautlos. + + Gemessen am 28.09.2026: `.tafel` gehoert seit je der + Anmeldeseite und steht dort `position: absolute`. Die + Register-Tafel der Regie trug damit NICHTS zur Hoehe des Pults + bei (114 Pixel Raster, 294 Pixel Inhalt) und malte quer ueber + Register und Kuerzel hinaus. Auf dem Bildschirmfoto sah es aus + wie ein Anzeigefehler; in Wahrheit war es ein Namensstreit. + + Derselbe Fehler wie im August bei .knopf-still und .schalter -- + deshalb steht er ab jetzt in einer Pruefung und nicht in einem + Kommentar. */ + const klassenAus = (datei) => { + const text = readFileSync(join(HIER, "..", "workspace", "assets", "css", datei), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, ""); + return new Set([...text.matchAll(/(^|[\s,>+~(])\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/g)] + .map((m) => m[2])); + }; + const meine = klassenAus("reaktion.css"); + const streit = []; + for (const fremd of ["gate.css", "start.css", "module.css", "haus.css"]) { + for (const name of klassenAus(fremd)) if (meine.has(name)) streit.push(`${name} (${fremd})`); + } + ok(meine.size > 40 && streit.length === 0, + streit.length + ? `Namensstreit: ${streit.join(", ")}` + : `alle ${meine.size} Klassen aus reaktion.css sind nur dort vergeben`); + ok(klassenAus("gate.css").has("tafel"), + "Gegenprobe: der Sucher findet die Klasse, an der es lag (.tafel in gate.css)"); + + /* Der Pegel wird gemessen und nicht gemalt. */ + ok(/createAnalyser\(\)/.test(js) && /getByteTimeDomainData/.test(js), + "die Pegel kommen aus einer echten Messung"); + ok(/schluessel === 'video'/.test(js) && /eingestellte Lautstaerke|eingestellte LautstĂ€rke/.test(js), + "beim YouTube-Regler steht dabei, dass dort nichts gemessen werden kann"); + ok(/getStats\(\)/.test(js), + "der Upload wird an der Verbindung gemessen, nicht geschaetzt"); +} + +/* ======================================================================= + 13. KEIN WEG HEISST ZWEIMAL GLEICH + ======================================================================= */ +melde(""); +melde("=== 13. Keine doppelten Wege ==="); { /* Beim Notizblock hat genau das eine Stunde gekostet: `DELETE /workspace/api/notizen/:id` gab es schon woanders, und @@ -654,7 +907,7 @@ melde("=== 11. Keine doppelten Wege ==="); 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 >= 11, `die Reaction bringt ${reaktionswege} eigene Wege mit`); + ok(reaktionswege >= 15, `die Reaction bringt ${reaktionswege} eigene Wege mit`); } melde(""); diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js index 2911f187..f765806a 100644 --- a/server/reaktion-tabellen.js +++ b/server/reaktion-tabellen.js @@ -83,6 +83,18 @@ export const STAENDE = ["zu", "vorbereitung", "live"]; /** Die Anordnungen des Bildes. */ export const LAYOUTS = ["kino", "gleich", "kamera"]; +/** Hoechstens so viele Videos duerfen in der Warteschlange stehen. + * + * Die Zahl ist keine technische Grenze, sondern eine Ansage: Wer + * dreissig Videos einraeumt, plant keinen Abend mehr, sondern legt + * ein Archiv an -- und dafuer ist die Liste nicht gebaut. Sie waere + * danach zu lang, um sie im Pult zu ueberblicken. */ +/* Ueber die Umgebung veraenderbar -- damit die Pruefung den vollen + Fall in Sekunden erreicht und nicht dreissig Videos anhaengen muss. + Ein Grenzfall, den niemand prueft, weil das Pruefen zu lange + dauert, ist ein ungeprueftes Verhalten. */ +export const LISTE_MAX = Number(process.env.REAKTION_LISTE_MAX || 30); + export function reaktionTabellen(d) { d.exec(` /* Genau EINE Zeile. Die Sperre steht in der Tabelle und nicht im @@ -152,6 +164,33 @@ export function reaktionTabellen(d) { beitraege INTEGER NOT NULL DEFAULT 0 ); + /* ==== DIE WARTESCHLANGE ====================================== + + Was als Naechstes laeuft. Eine Zeile je Video, die Spalte „platz“ gibt + die Reihenfolge. + + SIE IST NICHT DASSELBE WIE DER VERLAUF. Der Verlauf sagt, was + gelaufen IST -- die Warteschlange, was laufen SOLL. Beides in + eine Tabelle zu legen waere sparsam und falsch: Ein Eintrag + muesste dann „schon gelaufen ja/nein" tragen, und jede Abfrage + im Haus muesste diese Frage mitstellen. Vergisst sie eine, + steht ein Video zweimal im Abend. + + DER TITEL STEHT HIER MIT DRIN und wird nicht jedes Mal neu bei + YouTube erfragt. Sonst haengt die Anzeige der Liste an einem + fremden Dienst: Ist er langsam, laedt das Pult langsam; ist er + weg, steht dort nichts. Einmal holen, hinlegen, fertig. */ + CREATE TABLE IF NOT EXISTS reaktion_liste ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + video TEXT NOT NULL, + titel TEXT NOT NULL DEFAULT '', + platz INTEGER NOT NULL DEFAULT 0, + von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + gesetzt_am TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_reaktion_liste_platz + ON reaktion_liste (platz, id); CREATE INDEX IF NOT EXISTS idx_reaktion_chat_zeit ON reaktion_chat (weg_am, id); CREATE INDEX IF NOT EXISTS idx_reaktion_dabei_gesehen @@ -166,4 +205,22 @@ export function reaktionTabellen(d) { d.prepare(`INSERT OR IGNORE INTO reaktion (id, stand, stand_seit, geaendert) VALUES (1, 'zu', ?, ?)`) .run(new Date().toISOString(), new Date().toISOString()); + + /* ==== NACHGEWACHSENE SPALTEN ==================================== + + `ALTER TABLE ... ADD COLUMN` und NICHT „Tabelle neu bauen und + umkopieren". Am 11.09.2026 hat genau dieses Umkopieren an + anderer Stelle drei Spalten mit Inhalt verschluckt, ohne + Fehlermeldung und bei unveraenderter Zeilenzahl -- weil die + Spaltenliste von Hand abgeschrieben war. Eine Liste, die + niemand pflegt, kann nicht veralten; `ADD COLUMN` braucht gar + keine. */ + const da = new Set(d.prepare("PRAGMA table_info(reaktion)").all().map((z) => z.name)); + if (!da.has("video_titel")) { + /* Der Name des laufenden Videos, einmal bei YouTube geholt. + Ohne ihn stuende im Pult und auf der Wartetafel nur die + elfstellige Kennung -- die sagt niemandem etwas. */ + d.exec("ALTER TABLE reaktion ADD COLUMN video_titel TEXT NOT NULL DEFAULT ''"); + console.log("[reaktion] Spalte video_titel angelegt."); + } } diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index 4fb429b8..8ecc1fd8 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -47,7 +47,7 @@ import { einstellung } from "./workspace.js"; import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; import { SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, - VERLAUF_TAGE, + VERLAUF_TAGE, LISTE_MAX, } from "./reaktion-tabellen.js"; export const reaktionRouter = express.Router(); @@ -135,6 +135,67 @@ function zuSchnell(personId) { const sendung = () => db().prepare("SELECT * FROM reaktion WHERE id = 1").get(); +/** Wie heisst das Video? — einmal bei YouTube nachfragen. + * + * UEBER oEmbed, NICHT UEBER DIE DATEN-API. oEmbed braucht keinen + * Schluessel, kein Konto und kein Kontingent; es ist genau dafuer + * da, dass eine fremde Seite einen Titel anzeigen darf. + * + * DREI AUSGAENGE, NICHT ZWEI. Geht der Abruf schief -- kein Netz, + * YouTube langsam, Video nicht oeffentlich --, kommt ein LEERER + * Titel zurueck, und die Oberflaeche zeigt dann die Kennung. Sie + * erfindet keinen Namen und behauptet auch nicht, es gaebe keinen. + * „Konnte nicht nachsehen" ist eine eigene Antwort. + * + * VIER SEKUNDEN UND DANN SCHLUSS. Das Anhaengen eines Videos darf + * nicht daran haengen, ob ein fremder Dienst gerade Zeit hat. Ohne + * Frist stuende Filipe vor einem Knopf, der sich dreht, und wuesste + * nicht, ob er noch einmal druecken soll. */ +async function videoTitel(kennung) { + if (!kennung) return ""; + try { + const adresse = "https://www.youtube.com/oembed?format=json&url=" + + encodeURIComponent("https://www.youtube.com/watch?v=" + kennung); + const a = await fetch(adresse, { signal: AbortSignal.timeout(4000) }); + if (!a.ok) return ""; + const j = await a.json(); + return String(j?.title || "").trim().slice(0, 160); + } catch (fehler) { + console.warn("[reaktion] Titel von YouTube:", fehler?.message); + return ""; + } +} + +/** Die Warteschlange, in ihrer Reihenfolge. */ +function listeHolen() { + return db().prepare("SELECT id, video, titel, platz FROM reaktion_liste" + + " ORDER BY platz, id").all(); +} + +/** Das naechste Video aus der Liste ins Bild holen. + * + * EINE STELLE FUER ZWEI KNOEPFE. „Naechstes" und „Jetzt spielen" + * tun dasselbe, nur mit unterschiedlicher Zeile -- zwei Kopien + * desselben Ablaufs waeren zwei Stellen, an denen man spaeter das + * Zuruecksetzen der Sekunde vergessen kann. */ +function weiterZu(zeile, personId) { + db().prepare("UPDATE reaktion SET video = ?, video_titel = ?, laeuft = 1," + + " sekunde = 0, geaendert = ? WHERE id = 1") + .run(zeile.video, zeile.titel, jetzt()); + db().prepare("DELETE FROM reaktion_liste WHERE id = ?").run(zeile.id); + protokolliere(personId, "reaktion_weiter", zeile.video, ""); + const s = sendung(); + /* ZWEI MELDUNGEN, UND BEIDE WERDEN GEBRAUCHT. + + „video" bringt jeden Player auf das neue Stueck -- das ist die + eilige. „reaktion" bringt die Liste und den Titel nach; sie + betrifft nur die Anzeige und darf einen Wimpernschlag spaeter + kommen. Nur die erste zu schicken hiesse: Das Video wechselt, + und im Pult steht weiter der alte Name. */ + melden("reaktion", { was: "video", video: s.video, laeuft: true, sekunde: 0, gesendet: Date.now() }); + return s; +} + function gaesteListe() { return db().prepare(` SELECT g.person_id AS id, g.stumm, g.platz, p.name, p.rolle @@ -176,6 +237,7 @@ function lage(person) { stand: s.stand, titel: s.titel, video: s.video, + video_titel: s.video_titel || "", vorschau: s.vorschau, beginnt_am: s.beginnt_am, layout: s.layout, @@ -219,6 +281,20 @@ function lage(person) { moderiert: MODERIERT.has(person.rolle), sicht: !!(dabei.find((d) => d.id === person.id)?.sicht), }, + /* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der + Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen + alles sehen, vorbereiten und einschakten koennen." */ + liste: istHost ? listeHolen() : null, + /* WAS ALS NAECHSTES KOMMT, DARF JEDER WISSEN -- und nur das. + Auf der Wartetafel steht damit etwas, das Lust macht, ohne den + ganzen Abend zu verraten. */ + naechstes: (() => { + const n = db().prepare("SELECT titel, video FROM reaktion_liste" + + " ORDER BY platz, id LIMIT 1").get(); + return n ? (n.titel || n.video) : null; + })(), + liste_zahl: db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0, + liste_max: LISTE_MAX, plaetze: SICHT_PLAETZE, gaeste_max: GAESTE_MAX, text_max: TEXT_MAX, @@ -246,7 +322,7 @@ reaktionRouter.get("/workspace/api/reaktion", (req, res) => { /** Der Host stellt ein: Titel, Video, Vorschaubild, Beginn. */ reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32kb" }), - (req, res) => { + async (req, res) => { try { const titel = String(req.body?.titel ?? "").trim().slice(0, 120); const video = videoKennung(String(req.body?.video ?? "")); @@ -276,9 +352,16 @@ reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32 (siehe /stand). Hier wird nur der leere Platz gefuellt, damit die Ankuendigung „Als Naechstes" jemanden nennen kann. */ const bisher = sendung(); - db().prepare(`UPDATE reaktion SET titel = ?, video = ?, vorschau = ?, - beginnt_am = ?, host_id = ?, geaendert = ? WHERE id = 1`) - .run(titel, video, vorschau, beginn, + /* DEN NAMEN DES VIDEOS NUR HOLEN, WENN ES EIN ANDERES IST. + Sonst faende bei jedem „Uebernehmen" -- auch wenn nur der + Beginn verschoben wurde -- eine Abfrage bei YouTube statt, + und das Speichern haengt an einem fremden Dienst. */ + const videoTitelNeu = (video && video !== bisher.video) + ? await videoTitel(video) + : (video ? (bisher.video_titel || "") : ""); + db().prepare(`UPDATE reaktion SET titel = ?, video = ?, video_titel = ?, + vorschau = ?, beginnt_am = ?, host_id = ?, geaendert = ? WHERE id = 1`) + .run(titel, video, videoTitelNeu, vorschau, beginn, bisher.host_id || req.person.id, jetzt()); const l = lage(req.person); @@ -517,9 +600,21 @@ reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ lim if (req.body?.video !== undefined && !video) { return res.status(400).json({ fehler: "video_unklar" }); } + const s0 = sendung(); if (video) { db().prepare("UPDATE reaktion SET video = ?, laeuft = ?, sekunde = ?, geaendert = ? WHERE id = 1") .run(video, laeuft, sekunde, jetzt()); + /* Der Name wird NACHGEREICHT und nicht abgewartet: Diese + Route laeuft waehrend der Sendung im Sekundentakt. Sie + darf unter keinen Umstaenden auf YouTube warten. */ + if (video !== s0.video) { + videoTitel(video).then((t) => { + try { + db().prepare("UPDATE reaktion SET video_titel = ? WHERE id = 1 AND video = ?") + .run(t, video); + } catch { /* dann steht dort die Kennung, und das ist ehrlich */ } + }); + } } else { db().prepare("UPDATE reaktion SET laeuft = ?, sekunde = ? WHERE id = 1") .run(laeuft, sekunde); @@ -540,6 +635,123 @@ reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ lim } }); +/* ---------- Die Warteschlange ------------------------------------------ + Vorher einraeumen, waehrend der Sendung nur weiterschalten. + + WARUM DAS EINE EIGENE SACHE IST UND NICHT „NOCH EIN TEXTFELD": + Bisher gab es genau ein Video. Wer zwei Sachen hintereinander + schauen wollte, musste mitten in der Sendung eine YouTube-Adresse + in ein Feld tippen -- vor Publikum, mit laufender Kamera, mit dem + Risiko eines Tippfehlers und damit eines schwarzen Rechtecks. + Jetzt ist das eine Vorbereitung und kein Handgriff im Live. */ + +reaktionRouter.post("/workspace/api/reaktion/liste", nurHost, express.json({ limit: "8kb" }), + async (req, res) => { + try { + const video = videoKennung(String(req.body?.video ?? "")); + if (!video) return res.status(400).json({ fehler: "video_unklar" }); + + const wie_viele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0; + if (wie_viele >= LISTE_MAX) return res.status(409).json({ fehler: "liste_voll" }); + + /* DASSELBE VIDEO NICHT ZWEIMAL. Nicht aus Ordnungsliebe: Wer + es zweimal anhaengt, hat es schlicht vergessen -- und merkt + es erst, wenn es zum zweiten Mal laeuft und alle es schon + kennen. */ + const schon = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?").get(video); + if (schon) return res.status(409).json({ fehler: "liste_doppelt" }); + + const titel = await videoTitel(video); + const letzter = db().prepare("SELECT MAX(platz) AS p FROM reaktion_liste").get()?.p || 0; + db().prepare("INSERT INTO reaktion_liste (video, titel, platz, von_id, gesetzt_am)" + + " VALUES (?,?,?,?,?)") + .run(video, titel, letzter + 1, req.person.id, jetzt()); + + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Liste anhaengen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.delete("/workspace/api/reaktion/liste/:id", nurHost, (req, res) => { + try { + db().prepare("DELETE FROM reaktion_liste WHERE id = ?").run(Number(req.params.id) || 0); + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Liste weg:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Eins hoch oder runter. + * + * GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim + * Durchnummerieren schreibt ein Klick jede Zeile der Liste neu; + * zwei Klicks gleichzeitig -- Filipe am Rechner, VanVan am Handy -- + * ergeben dann eine Reihenfolge, die keiner von beiden wollte. Ein + * Tausch beruehrt zwei Zeilen und ist in sich abgeschlossen. */ +reaktionRouter.post("/workspace/api/reaktion/liste/:id/schieben", nurHost, + express.json({ limit: "2kb" }), (req, res) => { + try { + const id = Number(req.params.id) || 0; + const hoch = String(req.body?.richtung ?? "hoch") === "hoch"; + const alle = listeHolen(); + const wo = alle.findIndex((z) => z.id === id); + if (wo < 0) return res.status(404).json({ fehler: "nicht_in_der_liste" }); + const nachbar = alle[hoch ? wo - 1 : wo + 1]; + if (!nachbar) return res.json(lage(req.person)); // schon ganz oben/unten + + /* `node:sqlite` kennt kein `.transaction()` -- das gibt es nur + in better-sqlite3. Hier wird von Hand geklammert, so wie im + ganzen Haus. Ohne Klammer koennte zwischen den beiden + Schreibvorgaengen gelesen werden, und dann haetten zwei + Zeilen kurz denselben Platz. */ + db().exec("BEGIN IMMEDIATE"); + try { + db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?") + .run(nachbar.platz, alle[wo].id); + db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?") + .run(alle[wo].platz, nachbar.id); + db().exec("COMMIT"); + } catch (schief) { + try { db().exec("ROLLBACK"); } catch { /* war schon zurueck */ } + throw schief; + } + + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Liste schieben:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Dieses hier jetzt -- oder schlicht das naechste. */ +reaktionRouter.post("/workspace/api/reaktion/weiter", nurHost, express.json({ limit: "2kb" }), + (req, res) => { + try { + const id = Number(req.body?.id) || 0; + const zeile = id + ? db().prepare("SELECT * FROM reaktion_liste WHERE id = ?").get(id) + : db().prepare("SELECT * FROM reaktion_liste ORDER BY platz, id LIMIT 1").get(); + if (!zeile) return res.status(409).json({ fehler: "liste_leer" }); + + weiterZu(zeile, req.person.id); + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Weiter:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }), (req, res) => { try { @@ -686,7 +898,7 @@ export function videoKennung(roh) { * ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja * nicht anzeigt. */ function oeffentlich(l) { - const { zuschauer, ich, ...rest } = l; + const { zuschauer, ich, liste, ...rest } = l; return rest; } diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 66ea1b7e..32561ef7 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`. --> - - + + - +