Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein Ding, das mitten im Stream kaputtgehen kann. Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md: Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource` verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge: der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar), und ein Zustands-Schnappschuss beim Verbinden. Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur ueber EventSub. GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten. gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif PRIVMSG #abdulive_ :[Idk GIF by Respective] Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn. Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]" im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094 Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse eben nicht im Text steht. Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind: - Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der Ausblend-Animation aus dem DOM verschwinden, und lief in eine Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag eingefroren. - Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe gueltig aus und wuerde den falschen Textteil ersetzen. Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht, sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus. build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket 404 statt Overlay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
118 lines
5.0 KiB
TypeScript
118 lines
5.0 KiB
TypeScript
// Die Testknoepfe sind das einzige Werkzeug, mit dem sich das Overlay
|
|
// ausserhalb eines Streams pruefen laesst. Geht hier etwas kaputt, merkt man
|
|
// es ausgerechnet dann nicht, wenn man es am noetigsten braucht.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
|
|
|
test('zu jedem Knopf gibt es ein Ereignis', () => {
|
|
// Ein Knopf ohne Ereignis tut beim Druecken nichts und sieht dabei aus wie
|
|
// ein kaputtes Overlay.
|
|
for (const k of TEST_KNOEPFE) {
|
|
if (k.art === 'chat-loeschen') continue; // braucht eine Nachricht, siehe unten
|
|
assert.ok(testEvent(k.art), `${k.art} liefert kein Ereignis`);
|
|
}
|
|
});
|
|
|
|
test('jeder Knopf kommt nur einmal vor', () => {
|
|
const arten = TEST_KNOEPFE.map((k) => k.art);
|
|
assert.equal(new Set(arten).size, arten.length);
|
|
});
|
|
|
|
test('die Einblendungen tragen die Felder, die die Seite anzeigt', () => {
|
|
const raid = testEvent('raid') as { kind: string; type: string; amount: number };
|
|
assert.equal(raid.kind, 'alert');
|
|
assert.equal(raid.type, 'raid');
|
|
assert.ok(raid.amount > 0, 'ohne Zuschauerzahl steht "mit undefined Zuschauern" im Bild');
|
|
|
|
const gift = testEvent('subgift') as { recipient?: string };
|
|
assert.ok(gift.recipient, 'ohne Beschenkten fehlt die zweite Zeile');
|
|
|
|
const resub = testEvent('resub') as { months?: number };
|
|
assert.ok(resub.months && resub.months > 0);
|
|
});
|
|
|
|
test('"letzte löschen" braucht eine Nachricht und trifft genau die', () => {
|
|
assert.equal(testEvent('chat-loeschen'), null, 'ohne Chat gibt es nichts zu loeschen');
|
|
assert.deepEqual(testEvent('chat-loeschen', 'abc'), { kind: 'clear', messageId: 'abc' });
|
|
});
|
|
|
|
test('"Chat leeren" loescht alles, nicht nur eine Nachricht', () => {
|
|
assert.deepEqual(testEvent('chat-leeren'), { kind: 'clear' });
|
|
});
|
|
|
|
test('Chatnachrichten haben eine eigene ID', () => {
|
|
// Zwei Nachrichten mit derselben ID wuerden sich beim Loeschen gegenseitig
|
|
// mitnehmen.
|
|
const a = testEvent('chat') as { id: string };
|
|
const b = testEvent('chat') as { id: string };
|
|
assert.notEqual(a.id, b.id);
|
|
});
|
|
|
|
test('das Emote im Test ist im Text auch enthalten', () => {
|
|
// Sonst wird nichts ersetzt und der Knopf prueft die Emote-Anzeige nicht.
|
|
const ev = testEvent('chat-emote') as { text: string; emotes: Array<{ name: string }> };
|
|
assert.equal(ev.emotes.length, 1);
|
|
assert.ok(ev.text.includes(ev.emotes[0]!.name));
|
|
});
|
|
|
|
test('gesperrt ist nur, was einen falschen Stand setzt - nicht der Weg zurueck', () => {
|
|
// Eine Einblendung blitzt auf und ist weg. Ein erfundener Stream-Zustand
|
|
// bleibt in der Statusleiste stehen, bis die naechste Abfrage ihn
|
|
// geraderueckt - und das dauert bis zu einer Minute.
|
|
//
|
|
// "Zuruecksetzen" darf dabei nie gesperrt sein: sonst kommt man aus einem
|
|
// erfundenen Zustand ohne Neustart nicht mehr heraus, und auf einem Hub ohne
|
|
// Broadcaster-Token raeumt ueberhaupt niemand hinterher auf.
|
|
const gesperrt = TEST_KNOEPFE.filter((k) => k.nurOffline).map((k) => k.art);
|
|
assert.deepEqual(gesperrt, ['live-an']);
|
|
|
|
const zurueck = TEST_KNOEPFE.find((k) => k.art === 'live-aus');
|
|
assert.equal(zurueck?.nurOffline, undefined, 'der Weg zurueck bleibt immer offen');
|
|
});
|
|
|
|
test('der Stand-Knopf setzt einen anzeigbaren Stream', () => {
|
|
const an = testEvent('live-an') as
|
|
{ kind: string; live: boolean; title: string; startedAt: string | null };
|
|
assert.equal(an.kind, 'stream');
|
|
assert.equal(an.live, true);
|
|
assert.ok(an.title.length > 0);
|
|
assert.ok(an.startedAt && Date.parse(an.startedAt) < Date.now(), 'Laufzeit muss positiv sein');
|
|
|
|
const aus = testEvent('live-aus') as { live: boolean; startedAt: string | null };
|
|
assert.equal(aus.live, false);
|
|
assert.equal(aus.startedAt, null);
|
|
});
|
|
|
|
test('unbekannte Knoepfe liefern nichts, statt etwas zu erfinden', () => {
|
|
assert.equal(testEvent('gibtsnicht' as TestArt), null);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- Kanalpunkte
|
|
|
|
test('eine Belohnung wird nie als Abo eingeblendet', () => {
|
|
// Genau das stand hier einmal: die Konfetti-Belohnung hat die Abo-
|
|
// Einblendung wiederverwendet und "Neues Abo" ins Bild geschrieben. Eine
|
|
// Falschmeldung im Stream, die in der Aufzeichnung bleibt.
|
|
const e = testEvent('reward') as { kind: string; type: string; reward?: string };
|
|
assert.equal(e.kind, 'alert');
|
|
assert.equal(e.type, 'reward');
|
|
assert.notEqual(e.type, 'sub');
|
|
assert.ok(e.reward, 'ohne Titel weiss niemand, welche Belohnung gemeint ist');
|
|
});
|
|
|
|
test('Konfetti ist ein eigenes Ereignis, keine Nebenwirkung einer Einblendung', () => {
|
|
// So muss die Effektseite nicht wissen, welche Belohnung was ausloest.
|
|
const e = testEvent('reward-konfetti') as { kind: string; staerke: number };
|
|
assert.equal(e.kind, 'konfetti');
|
|
assert.ok(e.staerke > 0);
|
|
});
|
|
|
|
test('der Vorlese-Knopf schickt Text, den die Seite auch sprechen kann', () => {
|
|
const e = testEvent('tts') as { kind: string; text: string; name: string };
|
|
assert.equal(e.kind, 'tts');
|
|
assert.ok(e.text.length > 0);
|
|
assert.ok(e.name.length > 0);
|
|
});
|