zebra-spaces: RemoteTileFSM — formalises frozen-thumb fix from f71e9e7
Third state machine. One instance per incoming screen/camera track,
keyed by kind+pubHex. Codifies the lifecycle:
inactive ──TRACK_ARRIVED──▶ receiving ──MUTED──▶ muted
▲ │
│ UNMUTED │ PRUNE / ENDED
└────────────────────┤
▼
removed
{receiving, muted} + ENDED → removed
* + LEFT → removed
MUTED is a debounce gate, not a deletion: UNMUTED within the runtime's
~1.5s window cancels the prune and stays receiving (transient network
blip). PRUNE fires from the runtime's setTimeout if still muted.
ENDED skips the debounce. LEFT (peer-left) wipes the tile from any
live state. TRACK_ARRIVED in receiving/muted swaps to the new stream
(publisher re-shared before our prune fired).
Removed is terminal — a re-share spins up a fresh FSM. entry into
removed nulls ctx.stream so the runtime can drop refs.
+ 15 unit tests covering happy path, debounce semantics, ENDED
short-circuit, LEFT from every state, re-share refresh, removed
terminality. test-fsm now reports 53 passed.
This commit is contained in:
parent
57013ec9ad
commit
2d75cd7547
2 changed files with 206 additions and 8 deletions
|
|
@ -29,17 +29,18 @@ function extract(re){
|
|||
return src.slice(m.index, j);
|
||||
}
|
||||
|
||||
const createFSMSrc = extract(/function createFSM\(/);
|
||||
const publishSpecSrc = extract(/const publishSpec = /);
|
||||
const subscribeSpecSrc = extract(/const subscribeSpec = /);
|
||||
const createFSMSrc = extract(/function createFSM\(/);
|
||||
const publishSpecSrc = extract(/const publishSpec = /);
|
||||
const subscribeSpecSrc = extract(/const subscribeSpec = /);
|
||||
const remoteTileSpecSrc = extract(/const remoteTileSpec = /);
|
||||
|
||||
/* 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 +
|
||||
'\nreturn { createFSM, publishSpec, subscribeSpec };'
|
||||
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + remoteTileSpecSrc +
|
||||
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec };'
|
||||
);
|
||||
const { createFSM, publishSpec, subscribeSpec } = harness();
|
||||
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec } = harness();
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function test(name, fn){
|
||||
|
|
@ -389,5 +390,136 @@ test('stopping + RENEG / LOST / CONNECTED are all no-ops', () => {
|
|||
eq(m.state, 'stopping');
|
||||
});
|
||||
|
||||
console.log('remoteTileSpec — happy path:');
|
||||
|
||||
test('starts in inactive', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
eq(m.state, 'inactive');
|
||||
});
|
||||
|
||||
test('inactive + TRACK_ARRIVED → receiving, stream stored', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
const stream = { id: 'remote-stream' };
|
||||
m.send('TRACK_ARRIVED', { stream });
|
||||
eq(m.state, 'receiving');
|
||||
eq(m.context.stream, stream);
|
||||
});
|
||||
|
||||
test('receiving + MUTED → muted', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('MUTED');
|
||||
eq(m.state, 'muted');
|
||||
});
|
||||
|
||||
console.log('remoteTileSpec — mute is a debounce, not a kill:');
|
||||
|
||||
test('muted + UNMUTED → receiving (transient network blip recovers)', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('MUTED');
|
||||
m.send('UNMUTED');
|
||||
eq(m.state, 'receiving');
|
||||
});
|
||||
|
||||
test('muted + PRUNE → removed (debounce window expired, still muted)', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('MUTED');
|
||||
m.send('PRUNE');
|
||||
eq(m.state, 'removed');
|
||||
});
|
||||
|
||||
test('after recovery, mute → unmute again keeps the tile alive', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('MUTED'); m.send('UNMUTED');
|
||||
m.send('MUTED'); m.send('UNMUTED');
|
||||
eq(m.state, 'receiving');
|
||||
});
|
||||
|
||||
console.log('remoteTileSpec — ENDED skips debounce:');
|
||||
|
||||
test('receiving + ENDED → removed directly', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('ENDED');
|
||||
eq(m.state, 'removed');
|
||||
});
|
||||
|
||||
test('muted + ENDED → removed directly', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('MUTED');
|
||||
m.send('ENDED');
|
||||
eq(m.state, 'removed');
|
||||
});
|
||||
|
||||
console.log('remoteTileSpec — LEFT wipes from any live state:');
|
||||
|
||||
test('inactive + LEFT → removed', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('LEFT');
|
||||
eq(m.state, 'removed');
|
||||
});
|
||||
|
||||
test('receiving + LEFT → removed', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('LEFT');
|
||||
eq(m.state, 'removed');
|
||||
});
|
||||
|
||||
test('muted + LEFT → removed', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('MUTED');
|
||||
m.send('LEFT');
|
||||
eq(m.state, 'removed');
|
||||
});
|
||||
|
||||
console.log('remoteTileSpec — publisher re-share refreshes stream:');
|
||||
|
||||
test('receiving + TRACK_ARRIVED swaps to new stream', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
const a = { id: 'a' }, b = { id: 'b' };
|
||||
m.send('TRACK_ARRIVED', { stream: a });
|
||||
m.send('TRACK_ARRIVED', { stream: b });
|
||||
eq(m.state, 'receiving');
|
||||
eq(m.context.stream, b);
|
||||
});
|
||||
|
||||
test('muted + TRACK_ARRIVED → receiving with new stream', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
const a = { id: 'a' }, b = { id: 'b' };
|
||||
m.send('TRACK_ARRIVED', { stream: a });
|
||||
m.send('MUTED');
|
||||
m.send('TRACK_ARRIVED', { stream: b });
|
||||
eq(m.state, 'receiving');
|
||||
eq(m.context.stream, b);
|
||||
});
|
||||
|
||||
console.log('remoteTileSpec — removed is terminal:');
|
||||
|
||||
test('removed + every event is a no-op (need a fresh FSM)', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: {} });
|
||||
m.send('ENDED');
|
||||
eq(m.state, 'removed');
|
||||
eq(m.send('TRACK_ARRIVED', { stream: {} }), false);
|
||||
eq(m.send('MUTED'), false);
|
||||
eq(m.send('UNMUTED'), false);
|
||||
eq(m.send('PRUNE'), false);
|
||||
eq(m.send('LEFT'), false);
|
||||
eq(m.state, 'removed');
|
||||
});
|
||||
|
||||
test('entry into removed nulls the stream so the runtime can drop refs', () => {
|
||||
const m = createFSM(remoteTileSpec);
|
||||
m.send('TRACK_ARRIVED', { stream: { id: 's' } });
|
||||
m.send('ENDED');
|
||||
eq(m.context.stream, null);
|
||||
});
|
||||
|
||||
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue