diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js index 9f0c6a5..22fd1c4 100644 --- a/test/zebra-fsm.test.js +++ b/test/zebra-fsm.test.js @@ -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); diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index c455da8..fe2f1dd 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -971,6 +971,91 @@ const remoteTileSpec = { }, }; +/* ================================================================== + * CallFSM — the user's overall lifecycle in a space. Orchestrates + * (but doesn't replace) the per-leg FSMs above: a Joined call has + * exactly one SubscribeFSM and zero-to-three PublishFSMs running + * underneath; the runtime starts/stops them on entry/exit of joined. + * + * 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 + * fires re-entry of joined so observers can swap mesh/sub/publish + * topology without inventing a new state per role permutation. */ +const callSpec = { + initial: 'idle', + context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, lastError: null }, + states: { + idle: { + entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; }, + on: { + ENTER: { + target: 'connecting', + action: (ctx, ev) => { if (ev.payload){ ctx.code = ev.payload.code || ''; ctx.handle = ev.payload.handle || ''; } }, + }, + }, + }, + connecting: { + on: { + WELCOME: { + target: 'joined', + action: (ctx, ev) => { if (ev.payload){ ctx.uuid = ev.payload.uuid || ''; ctx.role = ev.payload.role || ''; } }, + }, + FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } }, + BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } }, + LEAVE: 'idle', + }, + }, + joined: { + on: { + /* ROLE_CHANGE re-enters joined so observers get an event to act + * on (swap mesh peers, start/stop publish, etc.) */ + ROLE_CHANGE: { + target: 'joined', + action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; }, + }, + WS_DROPPED: 'reconnecting', + LEAVE: 'leaving', + BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } }, + }, + }, + reconnecting: { + /* signal-server WebSocket dropped; the SubscribeFSM + publish + * legs stay alive because WebRTC PCs are independent of the WS. + * On WELCOME (a re-issued one after the WS comes back) we land + * back in joined and ROLE_CHANGE may follow. */ + on: { + WELCOME: { + target: 'joined', + action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; }, + }, + FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } }, + LEAVE: 'leaving', + BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } }, + }, + }, + leaving: { + on: { DONE: 'idle' }, + }, + booted: { + /* user has been removed from the room. ACK transitions back to + * idle so the entry screen comes back; the room's auto-rejoin + * sessionStorage key is cleared by the runtime when entering booted. */ + on: { ACK: 'idle' }, + }, + }, +}; + /* ================================================================== * identity — ed25519 keypair, persisted in localStorage as JWK. * @@ -3039,8 +3124,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');