zebra-spaces: CallFSM — top-level join/leave/reconnect/boot lifecycle
Fourth state machine. Orchestrates the per-leg FSMs: idle ──ENTER──▶ connecting ──WELCOME──▶ joined ──LEAVE──▶ leaving ──DONE──▶ idle ▲ │ FAILED │ ▲ │ ▼ │ WS_DROPPED │ │ idle ▼ │ │ reconnecting ──WELCOME──▶ joined │ │ │ LEAVE / FAILED │ │ ▼ │ │ leaving ────────────────────────────── ┘ │ ▲ │ │ ACK └─────────────────────────────────── booted ◀── BOOTED ── (any live state) Role lives in ctx (host / cohost / speaker / listener). ROLE_CHANGE re-enters joined so observers fire on every promotion / demotion — that's how the runtime decides whether to start mic+publish or stop them, without needing a state per role permutation. reconnecting handles signal-WS drops without tearing down the SubscribeFSM or PublishFSMs (WebRTC PCs are independent of the WS). booted is the explicit terminal for being kicked + ACK returns to idle so the entry screen comes back. + 19 unit tests. test-fsm now 72 passed. Next step: the integration layer — observers on each FSM that drive the actual side effects, plus integration tests that compose multiple FSMs (a CallFSM with SubscribeFSM + RemoteTileFSMs) to assert the multi-machine interactions match what the live code does.
This commit is contained in:
parent
2d75cd7547
commit
a29baeee7e
2 changed files with 267 additions and 5 deletions
|
|
@ -33,14 +33,16 @@ const createFSMSrc = extract(/function createFSM\(/);
|
|||
const publishSpecSrc = extract(/const publishSpec = /);
|
||||
const subscribeSpecSrc = extract(/const subscribeSpec = /);
|
||||
const remoteTileSpecSrc = extract(/const remoteTileSpec = /);
|
||||
const callSpecSrc = extract(/const callSpec = /);
|
||||
|
||||
/* 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 +
|
||||
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec };'
|
||||
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' +
|
||||
remoteTileSpecSrc + '\n' + callSpecSrc +
|
||||
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec };'
|
||||
);
|
||||
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec } = harness();
|
||||
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec } = harness();
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function test(name, fn){
|
||||
|
|
@ -521,5 +523,180 @@ test('entry into removed nulls the stream so the runtime can drop refs', () => {
|
|||
eq(m.context.stream, null);
|
||||
});
|
||||
|
||||
console.log('callSpec — join / leave happy path:');
|
||||
|
||||
test('starts in idle', () => {
|
||||
const m = createFSM(callSpec);
|
||||
eq(m.state, 'idle');
|
||||
});
|
||||
|
||||
test('idle + ENTER → connecting, code+handle stored', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'test-room', handle: 'fox' });
|
||||
eq(m.state, 'connecting');
|
||||
eq(m.context.code, 'test-room');
|
||||
eq(m.context.handle, 'fox');
|
||||
});
|
||||
|
||||
test('connecting + WELCOME → joined, uuid + role stored', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'test', handle: 'fox' });
|
||||
m.send('WELCOME', { uuid: 'u-1', role: 'host' });
|
||||
eq(m.state, 'joined');
|
||||
eq(m.context.uuid, 'u-1');
|
||||
eq(m.context.role, 'host');
|
||||
});
|
||||
|
||||
test('joined + LEAVE → leaving → idle on DONE', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'host' });
|
||||
m.send('LEAVE');
|
||||
eq(m.state, 'leaving');
|
||||
m.send('DONE');
|
||||
eq(m.state, 'idle');
|
||||
});
|
||||
|
||||
test('idle entry clears uuid + role + bootedBy', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'host' });
|
||||
m.send('LEAVE'); m.send('DONE');
|
||||
eq(m.context.uuid, '');
|
||||
eq(m.context.role, '');
|
||||
eq(m.context.bootedBy, null);
|
||||
});
|
||||
|
||||
console.log('callSpec — role transitions stay in joined:');
|
||||
|
||||
test('joined + ROLE_CHANGE updates role and stays in joined', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('ROLE_CHANGE', { role: 'speaker' });
|
||||
eq(m.state, 'joined');
|
||||
eq(m.context.role, 'speaker');
|
||||
});
|
||||
|
||||
test('observers see ROLE_CHANGE as a transition even though state stays joined', () => {
|
||||
const m = createFSM(callSpec);
|
||||
let seenRole = '';
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.observe(({ state, ev }) => { if (ev && ev.type === 'ROLE_CHANGE') seenRole = m.context.role; });
|
||||
m.send('ROLE_CHANGE', { role: 'host' });
|
||||
eq(seenRole, 'host');
|
||||
});
|
||||
|
||||
console.log('callSpec — reconnect path:');
|
||||
|
||||
test('joined + WS_DROPPED → reconnecting', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'speaker' });
|
||||
m.send('WS_DROPPED');
|
||||
eq(m.state, 'reconnecting');
|
||||
});
|
||||
|
||||
test('reconnecting + WELCOME → joined (role may have changed during drop)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'host' });
|
||||
m.send('WS_DROPPED');
|
||||
m.send('WELCOME', { role: 'cohost' });
|
||||
eq(m.state, 'joined');
|
||||
eq(m.context.role, 'cohost');
|
||||
});
|
||||
|
||||
test('reconnecting + LEAVE → leaving (user gives up during outage)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'speaker' });
|
||||
m.send('WS_DROPPED');
|
||||
m.send('LEAVE');
|
||||
eq(m.state, 'leaving');
|
||||
});
|
||||
|
||||
console.log('callSpec — boot path:');
|
||||
|
||||
test('joined + BOOTED → booted, bootedBy captured', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('BOOTED', { by: 'host-uuid' });
|
||||
eq(m.state, 'booted');
|
||||
eq(m.context.bootedBy, 'host-uuid');
|
||||
});
|
||||
|
||||
test('connecting + BOOTED → booted (block-listed before welcome lands)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('BOOTED', { by: 'host' });
|
||||
eq(m.state, 'booted');
|
||||
});
|
||||
|
||||
test('booted + ACK → idle (acknowledge the notice, return to entry)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'listener' });
|
||||
m.send('BOOTED', { by: 'host' });
|
||||
m.send('ACK');
|
||||
eq(m.state, 'idle');
|
||||
});
|
||||
|
||||
test('reconnecting + BOOTED → booted (boot can fire during outage)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'speaker' });
|
||||
m.send('WS_DROPPED');
|
||||
m.send('BOOTED', { by: 'host' });
|
||||
eq(m.state, 'booted');
|
||||
});
|
||||
|
||||
console.log('callSpec — connect cancel + failure:');
|
||||
|
||||
test('connecting + LEAVE → idle (user backed out before welcome)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('LEAVE');
|
||||
eq(m.state, 'idle');
|
||||
});
|
||||
|
||||
test('connecting + FAILED → idle, error stored', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('FAILED', { error: 'ws upgrade 502' });
|
||||
eq(m.state, 'idle');
|
||||
eq(m.context.lastError, 'ws upgrade 502');
|
||||
});
|
||||
|
||||
console.log('callSpec — illegal transitions are no-ops:');
|
||||
|
||||
test('idle + WELCOME is a no-op (must ENTER first)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
eq(m.send('WELCOME', { uuid: 'u', role: 'host' }), false);
|
||||
eq(m.state, 'idle');
|
||||
});
|
||||
|
||||
test('joined + WELCOME is a no-op (already joined)', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'host' });
|
||||
eq(m.send('WELCOME', { uuid: 'other', role: 'listener' }), false);
|
||||
eq(m.state, 'joined');
|
||||
eq(m.context.uuid, 'u');
|
||||
});
|
||||
|
||||
test('leaving + every other event is a no-op', () => {
|
||||
const m = createFSM(callSpec);
|
||||
m.send('ENTER', { code: 'r', handle: 'h' });
|
||||
m.send('WELCOME', { uuid: 'u', role: 'host' });
|
||||
m.send('LEAVE');
|
||||
eq(m.send('ENTER', { code: 'x' }), false);
|
||||
eq(m.send('WELCOME', { uuid: 'y', role: 'speaker' }), false);
|
||||
eq(m.send('BOOTED', { by: 'host' }), false);
|
||||
eq(m.state, 'leaving');
|
||||
});
|
||||
|
||||
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue