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:
parent
d71a37f84a
commit
f4dbc5cc6c
4 changed files with 251 additions and 38 deletions
152
test/self-listener-fsm.test.js
Normal file
152
test/self-listener-fsm.test.js
Normal 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);
|
||||
|
|
@ -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){
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue