zebra-spaces: formalize self-listener as FSM — pure spec + observer-driven side effects + 12 unit tests

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
This commit is contained in:
Russell Ballestrini 2026-06-04 13:10:29 -04:00
parent d71a37f84a
commit f4dbc5cc6c
No known key found for this signature in database
4 changed files with 251 additions and 38 deletions

View file

@ -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);

View file

@ -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){