zebra-spaces: lay down FSM framework + publishSpec + unit tests
First step of the state-machine refactor. Same self-contained pattern
as the rest of the page — FSMs live inline in web/zebra-spaces.html so
the page-integrity stamp keeps working, and the tests extract them with
the same regex/brace-match technique web-protocol.test.js already uses
(page = source of truth, tests track the page).
Added:
- createFSM(spec): minimal state machine. spec.states[name] has optional
entry/exit hooks and an .on table mapping events → target (string) or
{ target, action }. Observers fire after each transition with
{ state, prev, ev, ctx }. No async in transitions; effects belong in
observers (which can call send() to advance the machine).
- publishSpec: pure transition table for the publish flow.
off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
│ FAILED │ FAILED │ STOP/LOST
▼ ▼ ▼
off stopping ◀──── stopping ──┘
│ DONE
▼
off
One instance per kind (mic / screen / camera). FAILED in negotiating
goes to stopping (not off) so any acquired stream/pc gets torn down.
- test/zebra-fsm.test.js: 23 unit tests covering framework semantics +
publishSpec happy path + error/cancel paths + illegal-transition
no-ops. Function-constructor scope handles const-leak; bare eval()
doesn't expose const declarations to the harness.
- Makefile: test-fsm target + included in test-all.
Next: SubscribeFSM, CallFSM, RemoteTileFSM. Then wire each into the
imperative call sites progressively, replacing the firefighting code.
This commit is contained in:
parent
f71e9e74c4
commit
ebda460574
3 changed files with 409 additions and 3 deletions
258
test/zebra-fsm.test.js
Normal file
258
test/zebra-fsm.test.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
#!/usr/bin/env node
|
||||
/* zebra-spaces state-machine tests — extract the FSM framework + each
|
||||
* machine spec from web/zebra-spaces.html and drive synthetic events
|
||||
* through them, asserting the transition table.
|
||||
*
|
||||
* node test/zebra-fsm.test.js
|
||||
*
|
||||
* Tracks the shipped code exactly: the page IS the source of truth, the
|
||||
* tests just splice the relevant blocks out (same pattern as
|
||||
* web-protocol.test.js). When a new FSM is added to zebra-spaces.html,
|
||||
* add an extract() + a test block here. */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8');
|
||||
|
||||
/* lift a top-level function or const decl out of zebra-spaces.html by
|
||||
* locating its head, then brace-matching to the closing }. Returns the
|
||||
* full literal so it can be eval'd into a sandbox. */
|
||||
function extract(re){
|
||||
const m = src.match(re);
|
||||
if (!m) throw new Error('could not find ' + re + ' in zebra-spaces.html');
|
||||
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; } }
|
||||
}
|
||||
/* for `const NAME = { ... };` we want the trailing semicolon too */
|
||||
if (src[j] === ';') j++;
|
||||
return src.slice(m.index, j);
|
||||
}
|
||||
|
||||
const createFSMSrc = extract(/function createFSM\(/);
|
||||
const publishSpecSrc = extract(/const publishSpec = /);
|
||||
|
||||
/* 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 + '\nreturn { createFSM, publishSpec };'
|
||||
);
|
||||
const { createFSM, publishSpec } = 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('createFSM:');
|
||||
|
||||
test('starts in initial state', () => {
|
||||
const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } });
|
||||
eq(m.state, 'a');
|
||||
});
|
||||
|
||||
test('transitions via send', () => {
|
||||
const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } });
|
||||
truthy(m.send('GO'));
|
||||
eq(m.state, 'b');
|
||||
});
|
||||
|
||||
test('refuses unknown events', () => {
|
||||
const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } });
|
||||
eq(m.send('NOPE'), false);
|
||||
eq(m.state, 'a');
|
||||
});
|
||||
|
||||
test('refuses transitions to unknown states', () => {
|
||||
const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'ghost' } } } });
|
||||
eq(m.send('GO'), false);
|
||||
eq(m.state, 'a');
|
||||
});
|
||||
|
||||
test('fires entry and exit hooks in correct order', () => {
|
||||
const log = [];
|
||||
const m = createFSM({
|
||||
initial: 'a',
|
||||
states: {
|
||||
a: { entry: () => log.push('a-enter'), exit: () => log.push('a-exit'), on: { GO: 'b' } },
|
||||
b: { entry: () => log.push('b-enter') },
|
||||
},
|
||||
});
|
||||
m.start();
|
||||
m.send('GO');
|
||||
eq(JSON.stringify(log), JSON.stringify(['a-enter', 'a-exit', 'b-enter']));
|
||||
});
|
||||
|
||||
test('runs action between exit and entry', () => {
|
||||
const log = [];
|
||||
const m = createFSM({
|
||||
initial: 'a',
|
||||
states: {
|
||||
a: { exit: () => log.push('a-exit'), on: { GO: { target: 'b', action: () => log.push('act') } } },
|
||||
b: { entry: () => log.push('b-enter') },
|
||||
},
|
||||
});
|
||||
m.send('GO');
|
||||
eq(JSON.stringify(log), JSON.stringify(['a-exit', 'act', 'b-enter']));
|
||||
});
|
||||
|
||||
test('action mutates context', () => {
|
||||
const m = createFSM({
|
||||
initial: 'a',
|
||||
context: { count: 0 },
|
||||
states: {
|
||||
a: { on: { BUMP: { target: 'a', action: (ctx) => { ctx.count++; } } } },
|
||||
},
|
||||
});
|
||||
m.send('BUMP'); m.send('BUMP'); m.send('BUMP');
|
||||
eq(m.context.count, 3);
|
||||
});
|
||||
|
||||
test('observers fire after transitions and see prev + ev', () => {
|
||||
const seen = [];
|
||||
const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } });
|
||||
m.observe(({ state, prev, ev }) => seen.push({ state, prev, ev: ev && ev.type }));
|
||||
m.start();
|
||||
m.send('GO');
|
||||
/* first notification is the start (prev=null, ev=null); then GO */
|
||||
eq(seen.length, 2);
|
||||
eq(seen[0].state, 'a'); eq(seen[0].prev, null); eq(seen[0].ev, null);
|
||||
eq(seen[1].state, 'b'); eq(seen[1].prev, 'a'); eq(seen[1].ev, 'GO');
|
||||
});
|
||||
|
||||
test('observer error in one does not block others', () => {
|
||||
const m = createFSM({ initial: 'a', states: { a: { on: { GO: 'b' } }, b: {} } });
|
||||
m.observe(() => { throw new Error('boom'); });
|
||||
let other = 0;
|
||||
m.observe(() => { other++; });
|
||||
m.start();
|
||||
m.send('GO');
|
||||
truthy(other > 0, 'second observer still fires');
|
||||
});
|
||||
|
||||
console.log('publishSpec — happy path:');
|
||||
|
||||
test('starts in off', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
eq(m.state, 'off');
|
||||
});
|
||||
|
||||
test('off → acquiring on START', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START');
|
||||
eq(m.state, 'acquiring');
|
||||
});
|
||||
|
||||
test('acquiring → negotiating on ACQUIRED, stream lands in ctx', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START');
|
||||
const stream = { id: 'fake-stream' };
|
||||
m.send('ACQUIRED', { stream });
|
||||
eq(m.state, 'negotiating');
|
||||
eq(m.context.stream, stream);
|
||||
});
|
||||
|
||||
test('negotiating → live on NEGOTIATED, pc + peerID land in ctx', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START');
|
||||
m.send('ACQUIRED', { stream: {} });
|
||||
const pc = { id: 'fake-pc' };
|
||||
m.send('NEGOTIATED', { pc, peerID: 'peer-123' });
|
||||
eq(m.state, 'live');
|
||||
eq(m.context.pc, pc);
|
||||
eq(m.context.peerID, 'peer-123');
|
||||
});
|
||||
|
||||
test('live → stopping on STOP', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START'); m.send('ACQUIRED', { stream: {} });
|
||||
m.send('NEGOTIATED', { pc: {}, peerID: 'p' });
|
||||
m.send('STOP');
|
||||
eq(m.state, 'stopping');
|
||||
});
|
||||
|
||||
test('live → stopping on LOST (track ended)', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START'); m.send('ACQUIRED', { stream: {} });
|
||||
m.send('NEGOTIATED', { pc: {}, peerID: 'p' });
|
||||
m.send('LOST');
|
||||
eq(m.state, 'stopping');
|
||||
});
|
||||
|
||||
test('stopping → off on DONE clears stream/pc/peerID', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START'); m.send('ACQUIRED', { stream: { id: 's' } });
|
||||
m.send('NEGOTIATED', { pc: { id: 'p' }, peerID: 'pid' });
|
||||
m.send('STOP');
|
||||
m.send('DONE');
|
||||
eq(m.state, 'off');
|
||||
eq(m.context.stream, null);
|
||||
eq(m.context.pc, null);
|
||||
eq(m.context.peerID, null);
|
||||
});
|
||||
|
||||
console.log('publishSpec — error + cancel paths:');
|
||||
|
||||
test('acquiring + FAILED → off, error stored', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START');
|
||||
m.send('FAILED', { error: 'NotAllowedError' });
|
||||
eq(m.state, 'off');
|
||||
eq(m.context.lastError, 'NotAllowedError');
|
||||
});
|
||||
|
||||
test('acquiring + STOP → off (user cancelled before media acquired)', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START');
|
||||
m.send('STOP');
|
||||
eq(m.state, 'off');
|
||||
});
|
||||
|
||||
test('negotiating + FAILED → stopping (so any acquired stream/pc gets torn down)', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START');
|
||||
m.send('ACQUIRED', { stream: { id: 's' } });
|
||||
m.send('FAILED', { error: 'sfu 403' });
|
||||
eq(m.state, 'stopping');
|
||||
eq(m.context.lastError, 'sfu 403');
|
||||
});
|
||||
|
||||
test('negotiating + STOP → stopping (user cancelled mid-publish)', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START'); m.send('ACQUIRED', { stream: {} });
|
||||
m.send('STOP');
|
||||
eq(m.state, 'stopping');
|
||||
});
|
||||
|
||||
console.log('publishSpec — illegal transitions are no-ops:');
|
||||
|
||||
test('off + ACQUIRED is a no-op (must START first)', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
eq(m.send('ACQUIRED', { stream: {} }), false);
|
||||
eq(m.state, 'off');
|
||||
});
|
||||
|
||||
test('live + ACQUIRED is a no-op (already past acquire)', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START'); m.send('ACQUIRED', { stream: {} });
|
||||
m.send('NEGOTIATED', { pc: {}, peerID: 'p' });
|
||||
eq(m.send('ACQUIRED', { stream: {} }), false);
|
||||
eq(m.state, 'live');
|
||||
});
|
||||
|
||||
test('stopping + STOP is a no-op (already on the way out)', () => {
|
||||
const m = createFSM(publishSpec);
|
||||
m.send('START'); m.send('ACQUIRED', { stream: {} });
|
||||
m.send('NEGOTIATED', { pc: {}, peerID: 'p' });
|
||||
m.send('STOP');
|
||||
eq(m.send('STOP'), false);
|
||||
eq(m.state, 'stopping');
|
||||
});
|
||||
|
||||
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Loading…
Add table
Add a link
Reference in a new issue