From 69724240521537ee6fdc6a34d53dcd4209524ecb Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 14:14:52 -0400 Subject: [PATCH] =?UTF-8?q?test/zebra-fsm:=20MuteFSM=20transition=20tests?= =?UTF-8?q?=20=E2=80=94=2014=20new=20cases=20(102=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: initial state, TOGGLE, FORCE_MUTE (incl. override-while-off), AUTO_MUTE, AUTO_UNMUTE, RESTORE_MUTED, RESTORE_UNMUTED, ROLE_PROMOTED, ctx.source pinning per event, observer notification on every transition, mod-mute idempotency, and the documented invariant that the FSM itself does NOT enforce "mod-mute is sticky" — policy lives at the call site. 102/0 passing. --- test/zebra-fsm.test.js | 120 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js index 0d55911..d28d53c 100644 --- a/test/zebra-fsm.test.js +++ b/test/zebra-fsm.test.js @@ -902,5 +902,125 @@ test('full round trip across the wire', () => { eq(listener.sub.state, 'stopping'); }); +console.log('MuteFSM: transitions, ctx.source tracking, observer correctness:'); + +test('mute: initial state is on (unmuted UI = mic open)', () => { + const mute = createFSM(muteSpec); + eq(mute.state, 'on'); + eq(mute.context.source, null); +}); + +test('TOGGLE on → off: source = self', () => { + const mute = createFSM(muteSpec); + mute.send('TOGGLE'); + eq(mute.state, 'off'); + eq(mute.context.source, 'self'); +}); + +test('TOGGLE off → on: clears ctx.source via on.entry', () => { + const mute = createFSM(muteSpec); + mute.send('TOGGLE'); /* off, source=self */ + mute.send('TOGGLE'); /* back to on */ + eq(mute.state, 'on'); + eq(mute.context.source, null); +}); + +test('FORCE_MUTE on → off: source = mod', () => { + const mute = createFSM(muteSpec); + mute.send('FORCE_MUTE'); + eq(mute.state, 'off'); + eq(mute.context.source, 'mod'); +}); + +test('FORCE_MUTE while already off overwrites source to mod', () => { + const mute = createFSM(muteSpec); + mute.send('TOGGLE'); /* off, source=self */ + mute.send('FORCE_MUTE'); /* still off, mod overrides */ + eq(mute.state, 'off'); + eq(mute.context.source, 'mod'); +}); + +test('AUTO_MUTE on → off: source = self-listener', () => { + const mute = createFSM(muteSpec); + mute.send('AUTO_MUTE'); + eq(mute.state, 'off'); + eq(mute.context.source, 'self-listener'); +}); + +test('AUTO_UNMUTE off → on regardless of source', () => { + const mute = createFSM(muteSpec); + mute.send('AUTO_MUTE'); /* off, source=self-listener */ + mute.send('AUTO_UNMUTE'); /* back to on */ + eq(mute.state, 'on'); + eq(mute.context.source, null); +}); + +test('RESTORE_MUTED off → off: source pinned to self (sessionStorage restore)', () => { + const mute = createFSM(muteSpec); + mute.send('RESTORE_MUTED'); + eq(mute.state, 'off'); + eq(mute.context.source, 'self'); +}); + +test('RESTORE_UNMUTED on → on: idempotent fresh-session', () => { + const mute = createFSM(muteSpec); + mute.send('RESTORE_UNMUTED'); + eq(mute.state, 'on'); + eq(mute.context.source, null); +}); + +test('RESTORE_UNMUTED off → on: page reload picks up unmuted', () => { + const mute = createFSM(muteSpec); + mute.send('FORCE_MUTE'); + mute.send('RESTORE_UNMUTED'); + eq(mute.state, 'on'); + eq(mute.context.source, null); +}); + +test('ROLE_PROMOTED on → off: source = self (listener becomes speaker muted)', () => { + const mute = createFSM(muteSpec); + mute.send('ROLE_PROMOTED'); + eq(mute.state, 'off'); + eq(mute.context.source, 'self'); +}); + +test('observer fires on every distinct transition', () => { + const mute = createFSM(muteSpec); + const seen = []; + mute.observe(({ state, prev, ctx, ev }) => { + if (prev === null || state === prev) return; + seen.push(prev + '→' + state + ':' + (ctx.source || '∅') + ':' + (ev && ev.type)); + }); + mute.send('TOGGLE'); /* on → off:self */ + mute.send('TOGGLE'); /* off → on:∅ */ + mute.send('AUTO_MUTE'); /* on → off:self-listener */ + mute.send('AUTO_UNMUTE'); /* off → on:∅ */ + eq(seen.length, 4); + eq(seen[0], 'on→off:self:TOGGLE'); + eq(seen[1], 'off→on:∅:TOGGLE'); + eq(seen[2], 'on→off:self-listener:AUTO_MUTE'); + eq(seen[3], 'off→on:∅:AUTO_UNMUTE'); +}); + +test('mod-forced mute survives an unrelated event', () => { + const mute = createFSM(muteSpec); + mute.send('FORCE_MUTE'); + mute.send('FORCE_MUTE'); /* idempotent — still off, still mod */ + eq(mute.state, 'off'); + eq(mute.context.source, 'mod'); +}); + +test('user TOGGLE off a mod-forced mute returns to on (no enforcement at FSM level)', () => { + /* The mute FSM intentionally does not enforce "mod-mute is sticky" — that + * policy lives at the call site (only the mod's signed signed-mute message + * applies the FSM event). If a TOGGLE event ever does reach the FSM, it + * unmutes. Documented invariant. */ + const mute = createFSM(muteSpec); + mute.send('FORCE_MUTE'); /* off:mod */ + mute.send('TOGGLE'); /* on:∅ */ + eq(mute.state, 'on'); + eq(mute.context.source, null); +}); + console.log('\n' + pass + ' passed, ' + fail + ' failed'); process.exit(fail === 0 ? 0 : 1);