test/zebra-fsm: MuteFSM transition tests — 14 new cases (102 total)
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.
This commit is contained in:
parent
7f9d8273c9
commit
6972424052
1 changed files with 120 additions and 0 deletions
|
|
@ -902,5 +902,125 @@ test('full round trip across the wire', () => {
|
||||||
eq(listener.sub.state, 'stopping');
|
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');
|
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||||||
process.exit(fail === 0 ? 0 : 1);
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue