From f4dbc5cc6c3a751cb68891045f24c12c1823a0c6 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 13:10:29 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20formalize=20self-listener=20as?= =?UTF-8?q?=20FSM=20=E2=80=94=20pure=20spec=20+=20observer-driven=20side?= =?UTF-8?q?=20effects=20+=2012=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fox 2026-06-04 directive: every system should be a state machine with unit + integration + functional test coverage. Implicit-state defects keep biting (kicked-listener-UI-still-green, two-kick race, cohost-toggle-kills-phone, audio-wedge-no-recovery). Starting the formalization with the most-broken-today system: self-listener mode. Spec (selfListenerSpec): off ──ENABLE / TOGGLE──▶ on on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off Sits next to publishSpec, subscribeSpec, callSpec, remoteTileSpec in zebra-spaces.html. Composed by wireZebraMachines() into roomMachines.selfListener. UNMUTE edge encodes fox's invariant: "unmuting should seamlessly switch them back to the now of the conversation webrtc mesh" — if the user clicks unmute while on, they implicitly drop back to off. Side effects (mic mute, streamMode enrolment, remoteAudio muting) move out of enableSelfListenerMode/disableSelfListenerMode (deleted) into runSelfListenerEnable / runSelfListenerDisable, called by an observer attached to the FSM. Pure spec stays Node-testable; the runtime drives the actual audio plumbing from observed transitions. Boolean selfListenerMode flag deleted. window.selfListenerMode is now a getter against the FSM state — single source of truth, no drift possible. All callers (toggle-button click, mute-unmute, peer-joined, role-demote, leave) now dispatch FSM events instead of calling helpers directly. Tests in test/self-listener-fsm.test.js: - starts in off - TOGGLE / ENABLE / DISABLE transitions - UNMUTE drops to off (the fox-invariant) - UNMUTE / CLEAR while off is no-op - DEMOTED drops to off - CLEAR drops to off - unknown event refuses - observer fires on real transitions with prev/state - runtime observer skips prev===state edges Existing test/zebra-fsm.test.js updated to extract+expose selfListenerSpec alongside the other specs (the wireZebraMachines extract is the integration test). Makefile gets test-self-listener target + slot in test-all. All test suites green: - self-listener: 12 / 12 - zebra-fsm: 83 / 83 - mod-actions: 6 / 6 - web-protocol: 3348 / 3348 - multi-peer-mesh: 8 / 8 - video-track-removal: 18 / 18 --- Makefile | 12 ++- test/self-listener-fsm.test.js | 152 +++++++++++++++++++++++++++++++++ test/zebra-fsm.test.js | 7 +- web/zebra-spaces.html | 118 +++++++++++++++++-------- 4 files changed, 251 insertions(+), 38 deletions(-) create mode 100644 test/self-listener-fsm.test.js diff --git a/Makefile b/Makefile index cf6053f..82b8e17 100644 --- a/Makefile +++ b/Makefile @@ -53,6 +53,14 @@ test-mesh: test-mod-actions: @node test/mod-action-serializer.test.js +# SelfListenerFSM — pins the state-machine contract for the speaker/ +# cohost/host "switch myself to the buffered HTTP listener stream" +# toggle. Extracts createFSM + selfListenerSpec from the live page so +# the spec can't drift from shipped transitions (off↔on with +# TOGGLE/ENABLE/DISABLE/UNMUTE/DEMOTED/CLEAR edges). +test-self-listener: + @node test/self-listener-fsm.test.js + # zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live # server flow. The live-server tier auto-runs when proxy.unturf.com sits # alongside this checkout AND has a Go toolchain — we build the relay binary @@ -70,7 +78,7 @@ test-zebra-spaces: ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \ rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc -test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-zebra-spaces +test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-self-listener test-zebra-spaces @echo "--- unit ---" @./test/unit @echo "--- integration ---" @@ -87,6 +95,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide @node test/multi-peer-mesh.test.js @echo "--- mod-action serializer ---" @node test/mod-action-serializer.test.js + @echo "--- self-listener FSM ---" + @node test/self-listener-fsm.test.js @echo "--- zebra-spaces ---" @$(MAKE) -s test-zebra-spaces diff --git a/test/self-listener-fsm.test.js b/test/self-listener-fsm.test.js new file mode 100644 index 0000000..4faaa2f --- /dev/null +++ b/test/self-listener-fsm.test.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/* SelfListenerFSM tests — pins the state-machine contract for the + * speaker/cohost/host "switch myself to the buffered HTTP listener + * stream" toggle that fox introduced 2026-06-04. Driven by extracted + * source from web/zebra-spaces.html so the spec can't drift from + * shipped behavior. + * + * node test/self-listener-fsm.test.js + */ +const fs = require('fs'); +const path = require('path'); +const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8'); + +function extract(re){ + const m = src.match(re); + if (!m) throw new Error('could not find ' + re); + let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i; + for (; j < src.length; j++){ + if (src[j] === '{') depth++; + else if (src[j] === '}') { if (--depth === 0) { j++; break; } } + } + if (src[j] === ';') j++; + return src.slice(m.index, j); +} + +const createFSMSrc = extract(/function createFSM\(/); +const selfListenerSpecSrc = extract(/const selfListenerSpec = /); + +const harness = new Function( + createFSMSrc + '\n' + selfListenerSpecSrc + '\nreturn { createFSM, selfListenerSpec };' +); +const { createFSM, selfListenerSpec } = harness(); + +let pass = 0, fail = 0; +function test(name, fn){ + try { fn(); console.log(' ✓ ' + name); pass++; } + catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; } +} +function eq(a, b, msg){ + if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b)); +} +function truthy(v, msg){ if (!v) throw new Error(msg || 'expected truthy'); } + +console.log('SelfListenerFSM:'); + +test('starts in off state', () => { + const m = createFSM(selfListenerSpec); + eq(m.state, 'off'); +}); + +test('initial enrolledCount is 0', () => { + const m = createFSM(selfListenerSpec); + eq(m.context.enrolledCount, 0); +}); + +test('TOGGLE off→on, then off→on→off', () => { + const m = createFSM(selfListenerSpec); + truthy(m.send('TOGGLE')); + eq(m.state, 'on'); + truthy(m.send('TOGGLE')); + eq(m.state, 'off'); +}); + +test('ENABLE off→on; DISABLE on→off', () => { + const m = createFSM(selfListenerSpec); + truthy(m.send('ENABLE')); + eq(m.state, 'on'); + truthy(m.send('DISABLE')); + eq(m.state, 'off'); +}); + +test('UNMUTE while on drops to off (fox: "unmuting should seamlessly switch back")', () => { + const m = createFSM(selfListenerSpec); + m.send('ENABLE'); + eq(m.state, 'on'); + truthy(m.send('UNMUTE')); + eq(m.state, 'off'); +}); + +test('UNMUTE while off is a no-op self-transition (still off)', () => { + const m = createFSM(selfListenerSpec); + // self-transitions still count as "transitioned"; the spec defines + // them so the runtime can observe the event without surprise. + m.send('UNMUTE'); + eq(m.state, 'off'); +}); + +test('DEMOTED while on drops to off (role transition listener)', () => { + const m = createFSM(selfListenerSpec); + m.send('ENABLE'); + truthy(m.send('DEMOTED')); + eq(m.state, 'off'); +}); + +test('CLEAR while on drops to off (leave room)', () => { + const m = createFSM(selfListenerSpec); + m.send('ENABLE'); + truthy(m.send('CLEAR')); + eq(m.state, 'off'); +}); + +test('CLEAR while off is a no-op (idempotent on leave)', () => { + const m = createFSM(selfListenerSpec); + m.send('CLEAR'); + eq(m.state, 'off'); +}); + +test('unknown event refuses transition', () => { + const m = createFSM(selfListenerSpec); + eq(m.send('NOPE'), false); + eq(m.state, 'off'); +}); + +test('observer fires on every real transition with prev/state', () => { + const m = createFSM(selfListenerSpec); + const seen = []; + m.observe(({ state, prev, ev }) => seen.push({ state, prev, ev: ev && ev.type })); + m.send('TOGGLE'); + m.send('TOGGLE'); + m.send('TOGGLE'); + // first observer call is the "started" notification (prev=null, ev=null) + // subsequent calls are actual transitions. + const real = seen.filter(s => s.ev !== null); + eq(real.length, 3); + eq(real[0].prev, 'off'); eq(real[0].state, 'on'); eq(real[0].ev, 'TOGGLE'); + eq(real[1].prev, 'on'); eq(real[1].state, 'off'); eq(real[1].ev, 'TOGGLE'); + eq(real[2].prev, 'off'); eq(real[2].state, 'on'); eq(real[2].ev, 'TOGGLE'); +}); + +test('runtime observer pattern: prev===state edges (UNMUTE while off) skipped by guard', () => { + /* The runtime observer (in zebra-spaces.html) skips transitions + * where state === prev to avoid spurious side-effect fires. This + * test pins the convention: UNMUTE while off DOES emit an event, + * but with state===prev so the runtime guard filters it. */ + const m = createFSM(selfListenerSpec); + const realTransitions = []; + m.observe(({ state, prev }) => { + if (prev === null || state === prev) return; // matches runtime guard + realTransitions.push({ from: prev, to: state }); + }); + m.send('UNMUTE'); // off → off, filtered out + m.send('ENABLE'); // off → on, kept + m.send('UNMUTE'); // on → off, kept + m.send('CLEAR'); // off → off, filtered out + eq(realTransitions.length, 2); + eq(realTransitions[0].from, 'off'); eq(realTransitions[0].to, 'on'); + eq(realTransitions[1].from, 'on'); eq(realTransitions[1].to, 'off'); +}); + +console.log(''); +console.log('passed: ' + pass + ' failed: ' + fail); +process.exit(fail ? 1 : 0); diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js index 673e945..6a57906 100644 --- a/test/zebra-fsm.test.js +++ b/test/zebra-fsm.test.js @@ -34,16 +34,17 @@ const publishSpecSrc = extract(/const publishSpec = /); const subscribeSpecSrc = extract(/const subscribeSpec = /); const remoteTileSpecSrc = extract(/const remoteTileSpec = /); const callSpecSrc = extract(/const callSpec = /); +const selfListenerSpecSrc = extract(/const selfListenerSpec = /); const wireMachinesSrc = extract(/function wireZebraMachines\(/); /* Function-constructor scope so `const` declarations are visible at the * harness's `return` — they would NOT leak through a bare `eval()`. */ const harness = new Function( createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + - remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + wireMachinesSrc + - '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines };' + remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + selfListenerSpecSrc + '\n' + wireMachinesSrc + + '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines };' ); -const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines } = harness(); +const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines } = harness(); let pass = 0, fail = 0; function test(name, fn){ diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 8e7b5dd..ae7faaf 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1248,18 +1248,61 @@ const callSpec = { }; /* ================================================================== - * wireZebraMachines — orchestrator. Composes one CallFSM, one - * SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of - * RemoteTileFSMs into a coherent room. Observers wire transitions - * between machines; no side effects in this layer — the page's - * runtime attaches its OWN observers on top to drive actual WebRTC - * and DOM work. That separation keeps this function fully testable - * in Node with synthetic events. + * SelfListenerFSM — a speaker / cohost / host who's flipped their + * own row's stream toggle to consume the room via the buffered HTTP + * Ogg/Opus path instead of the live WebRTC mesh. * - * Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */ + * off ──ENABLE / TOGGLE──▶ on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off + * + * The UNMUTE edge encodes fox's invariant: "unmuting should + * seamlessly switch them back to the now of the conversation + * webrtc mesh" — if the user clicks unmute while in on, they + * implicitly want to go back to the live path. + * + * Pure spec — side effects (mic mute, streamMode population, + * remoteAudio muting) live in the runtime's observer attached to + * this FSM. Keeps it testable in Node. */ +const selfListenerSpec = { + initial: 'off', + context: { enrolledCount: 0 }, + states: { + off: { + on: { + TOGGLE: 'on', + ENABLE: 'on', + UNMUTE: 'off', /* no-op self-transition for symmetry */ + DEMOTED: 'off', + CLEAR: 'off', + }, + }, + on: { + on: { + TOGGLE: 'off', + DISABLE: 'off', + UNMUTE: 'off', + DEMOTED: 'off', + CLEAR: 'off', + }, + }, + }, +}; + +/* ================================================================== + * wireZebraMachines — orchestrator. Composes one CallFSM, one + * SubscribeFSM, three PublishFSMs (mic/screen/camera), one + * SelfListenerFSM, and a Map of RemoteTileFSMs into a coherent + * room. Observers wire transitions between machines; no side + * effects in this layer — the page's runtime attaches its OWN + * observers on top to drive actual WebRTC and DOM work. That + * separation keeps this function fully testable in Node with + * synthetic events. + * + * Returns { call, sub, pubs, selfListener, remoteTiles, tileFor, + * tileLeft }. */ function wireZebraMachines(){ const call = createFSM(callSpec); const sub = createFSM(subscribeSpec); + const selfListener = createFSM(selfListenerSpec); const pubs = { mic: createFSM(publishSpec), screen: createFSM(publishSpec), @@ -1315,7 +1358,7 @@ function wireZebraMachines(){ } }); - return { call, sub, pubs, remoteTiles, tileFor, tileLeft }; + return { call, sub, pubs, selfListener, remoteTiles, tileFor, tileLeft }; } /* ================================================================== @@ -4172,7 +4215,7 @@ async function onRoleChanged(prev, next){ /* self-listener flag is meaningless once we're a listener (the * row's stream toggle disappears); flip it OFF so its streamMode * entries get torn down cleanly with the rest of our state. */ - if (selfListenerMode) disableSelfListenerMode(); + roomMachines.selfListener.send('DEMOTED'); dropMic(); muted = false; await sfuUnpublish(); await sfuUnpublishScreen(); @@ -4492,7 +4535,14 @@ let listenerOutputMuted = true; * Opus path instead of the live WebRTC mesh. Auto-mutes their mic so * they can't talk into a delayed stream (they'd be 2-4s behind the * conversation); unmuting toggles them back to WebRTC seamlessly. */ -let selfListenerMode = false; +/* selfListenerMode is now a derived getter against the SelfListenerFSM + * (roomMachines.selfListener). Single source of truth — direct writes + * to the boolean would drift from the FSM state, defeating the point. + * All transitions go through .send('TOGGLE' | 'ENABLE' | 'DISABLE' | + * 'UNMUTE' | 'DEMOTED' | 'CLEAR'); side effects ride an observer + * attached during room setup (see selfListenerObserver below). */ +function selfListenerMode_get(){ return roomMachines.selfListener.state === 'on'; } +Object.defineProperty(window, 'selfListenerMode', { get: selfListenerMode_get }); /* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener * phones — Firefox Android refuses autoplay on every fresh