feat(platform): expose lifecycle-safe UI observation
This commit is contained in:
@@ -304,6 +304,332 @@ function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEn
|
||||
return { runtime, wire, scene, clock, factoryConfigs, config };
|
||||
}
|
||||
|
||||
test('UI observes canonical commits and unsubscribe is idempotent', async () => {
|
||||
const { runtime, wire } = makeRuntime();
|
||||
const seen: unknown[] = [];
|
||||
const off = runtime.subscribeState(
|
||||
state => { seen.push(state); },
|
||||
error => { throw error; },
|
||||
);
|
||||
assert.equal(seen.length, 0);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.equal(seen[seen.length - 1], runtime.state);
|
||||
off();
|
||||
off();
|
||||
const count = seen.length;
|
||||
runtime.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
assert.equal(seen.length, count);
|
||||
runtime.stop();
|
||||
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /stopped/);
|
||||
});
|
||||
|
||||
test('a failing UI observer reports its original error once while other observers continue', async () => {
|
||||
const { runtime, wire } = makeRuntime();
|
||||
const failure = { source: 'render' };
|
||||
const reported: unknown[] = [];
|
||||
let failingCalls = 0;
|
||||
const healthyStates: unknown[] = [];
|
||||
runtime.subscribeState(
|
||||
() => {
|
||||
failingCalls += 1;
|
||||
throw failure;
|
||||
},
|
||||
error => { reported.push(error); },
|
||||
);
|
||||
runtime.subscribeState(
|
||||
state => { healthyStates.push(state); },
|
||||
error => { throw error; },
|
||||
);
|
||||
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
|
||||
assert.equal(failingCalls, 1);
|
||||
assert.deepEqual(reported, [failure]);
|
||||
assert.equal(reported[0], failure);
|
||||
assert.equal(healthyStates[0], runtime.state);
|
||||
|
||||
runtime.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
assert.equal(failingCalls, 1);
|
||||
assert.equal(healthyStates[healthyStates.length - 1], runtime.state);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
test('an onError failure enters fatal with both UI error identities and cancels observers', async () => {
|
||||
const { runtime, wire, scene } = makeRuntime();
|
||||
const listenerFailure = { source: 'listener' };
|
||||
const onErrorFailure = { source: 'onError' };
|
||||
let laterObserverCalls = 0;
|
||||
runtime.subscribeState(
|
||||
() => { throw listenerFailure; },
|
||||
() => { throw onErrorFailure; },
|
||||
);
|
||||
runtime.subscribeState(
|
||||
() => { laterObserverCalls += 1; },
|
||||
error => { throw error; },
|
||||
);
|
||||
await runtime.start();
|
||||
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
|
||||
assert.equal(laterObserverCalls, 0);
|
||||
assert.equal(wire.stopCalls, 1);
|
||||
const fatal = scene.calls.find(call => call.name === 'fatal')?.value;
|
||||
assert.ok(fatal instanceof Error);
|
||||
const errors = (fatal as Error & { readonly errors: readonly unknown[] }).errors;
|
||||
assert.equal(errors.length, 2);
|
||||
assert.equal(errors[0], listenerFailure);
|
||||
assert.equal(errors[1], onErrorFailure);
|
||||
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /fatal/);
|
||||
});
|
||||
|
||||
for (const terminal of ['stop', 'fatal', 'kick'] as const) {
|
||||
test(`${terminal} invalidates a late Store delivery and rejects new UI observers`, async () => {
|
||||
const originalSubscribe = PlatformStore.prototype.subscribe;
|
||||
let retainedListener: Parameters<PlatformStore['subscribe']>[0] | null = null;
|
||||
PlatformStore.prototype.subscribe = function retainFirstListener(listener) {
|
||||
retainedListener ??= listener;
|
||||
return originalSubscribe.call(this, listener);
|
||||
};
|
||||
|
||||
try {
|
||||
const setup = makeRuntime();
|
||||
let calls = 0;
|
||||
setup.runtime.subscribeState(
|
||||
() => { calls += 1; },
|
||||
error => { throw error; },
|
||||
);
|
||||
await setup.runtime.start();
|
||||
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
const callsBeforeTermination = calls;
|
||||
setup.wire.onAction = (name) => {
|
||||
if (name !== 'stop') return;
|
||||
const listener = retainedListener;
|
||||
assert.ok(listener);
|
||||
listener(setup.runtime.state, setup.runtime.state);
|
||||
};
|
||||
|
||||
if (terminal === 'stop') {
|
||||
setup.runtime.stop();
|
||||
} else if (terminal === 'fatal') {
|
||||
setup.scene.onCall = (name) => {
|
||||
if (name === 'reconnect') throw new Error('reconnect render failed');
|
||||
};
|
||||
setup.wire.emit({ type: 'slow' });
|
||||
} else {
|
||||
setup.runtime.login(ACCOUNT);
|
||||
setup.wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' },
|
||||
} });
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
calls,
|
||||
callsBeforeTermination + (terminal === 'fatal' ? 1 : 0),
|
||||
'observers must be inactive before terminal cleanup callbacks',
|
||||
);
|
||||
const callsAtTermination = calls;
|
||||
const lateListener = retainedListener as Parameters<PlatformStore['subscribe']>[0] | null;
|
||||
assert.ok(lateListener);
|
||||
lateListener(setup.runtime.state, setup.runtime.state);
|
||||
assert.equal(calls, callsAtTermination);
|
||||
assert.throws(
|
||||
() => setup.runtime.subscribeState(() => {}, () => {}),
|
||||
new RegExp(terminal === 'kick' ? 'kicked' : terminal),
|
||||
);
|
||||
} finally {
|
||||
PlatformStore.prototype.subscribe = originalSubscribe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('stop during a Store commit cancels later observers and prevents scene revival', async () => {
|
||||
const setup = makeRuntime();
|
||||
await setup.runtime.start();
|
||||
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
let laterObserverCalls = 0;
|
||||
let resubscribeFailure: unknown;
|
||||
setup.runtime.subscribeState(
|
||||
() => {
|
||||
setup.runtime.stop();
|
||||
resubscribeFailure = thrownValue(
|
||||
() => setup.runtime.subscribeState(() => {}, () => {}),
|
||||
);
|
||||
},
|
||||
error => { throw error; },
|
||||
);
|
||||
setup.runtime.subscribeState(
|
||||
() => { laterObserverCalls += 1; },
|
||||
error => { throw error; },
|
||||
);
|
||||
const sceneCallsBefore = setup.scene.calls.length;
|
||||
|
||||
setup.wire.emit({ type: 'slow' });
|
||||
|
||||
assert.equal(laterObserverCalls, 0);
|
||||
assert.match(String(resubscribeFailure), /stopped/);
|
||||
assert.equal(setup.scene.calls.length, sceneCallsBefore);
|
||||
assert.equal(setup.wire.stopCalls, 1);
|
||||
});
|
||||
|
||||
test('listener and onError failures after stopping escape the Store boundary without reviving fatal', async () => {
|
||||
const setup = makeRuntime();
|
||||
await setup.runtime.start();
|
||||
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
const listenerFailure = { source: 'listener-after-stop' };
|
||||
const onErrorFailure = { source: 'onError-after-stop' };
|
||||
const reported: unknown[] = [];
|
||||
setup.runtime.subscribeState(
|
||||
() => {
|
||||
setup.runtime.stop();
|
||||
throw listenerFailure;
|
||||
},
|
||||
error => {
|
||||
reported.push(error);
|
||||
setup.runtime.stop();
|
||||
throw onErrorFailure;
|
||||
},
|
||||
);
|
||||
const sceneCallsBefore = setup.scene.calls.length;
|
||||
|
||||
const thrown = thrownValue(() => { setup.wire.emit({ type: 'slow' }); });
|
||||
|
||||
assert.deepEqual(reported, [listenerFailure]);
|
||||
assert.ok(thrown instanceof Error);
|
||||
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
|
||||
assert.equal(errors.length, 2);
|
||||
assert.equal(errors[0], listenerFailure);
|
||||
assert.equal(errors[1], onErrorFailure);
|
||||
assert.equal(setup.scene.calls.length, sceneCallsBefore);
|
||||
assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0);
|
||||
assert.equal(setup.wire.stopCalls, 1);
|
||||
assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/);
|
||||
});
|
||||
|
||||
test('stopped observer errors survive a later active-room publish failure', async () => {
|
||||
const setup = makeRuntime();
|
||||
await setup.runtime.start();
|
||||
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
setup.runtime.login(ACCOUNT);
|
||||
const roomLogin = fixture('player-login-room.json');
|
||||
(roomLogin.players as unknown[])[2] = {
|
||||
playerid: 430512,
|
||||
nickname: '同桌玩家',
|
||||
avatar: 'http://b',
|
||||
sex: 0,
|
||||
bean: 10,
|
||||
isprepare: 0,
|
||||
onstate: 0,
|
||||
};
|
||||
setup.wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: roomLogin,
|
||||
} });
|
||||
const listenerFailure = { source: 'room-listener-after-stop' };
|
||||
const onErrorFailure = { source: 'room-onError-after-stop' };
|
||||
setup.runtime.subscribeState(
|
||||
() => {
|
||||
setup.runtime.stop();
|
||||
throw listenerFailure;
|
||||
},
|
||||
() => { throw onErrorFailure; },
|
||||
);
|
||||
|
||||
const thrown = thrownValue(() => {
|
||||
setup.wire.emit({ type: 'message', message: {
|
||||
route: 'room', rpc: 'player_prepare', data: { seat: 2 },
|
||||
} });
|
||||
});
|
||||
|
||||
assert.ok(thrown instanceof Error);
|
||||
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
|
||||
assert.equal(errors.length, 2);
|
||||
const observerFailure = errors[0];
|
||||
assert.ok(observerFailure instanceof Error);
|
||||
const observerErrors = (
|
||||
observerFailure as Error & { readonly errors: readonly unknown[] }
|
||||
).errors;
|
||||
assert.equal(observerErrors[0], listenerFailure);
|
||||
assert.equal(observerErrors[1], onErrorFailure);
|
||||
assert.match(String(errors[1]), /disposed game session/i);
|
||||
assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0);
|
||||
assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/);
|
||||
});
|
||||
|
||||
test('UI observers receive each committed snapshot even when a listener commits reentrantly', async () => {
|
||||
const setup = makeRuntime();
|
||||
const delivered: unknown[] = [];
|
||||
let firstCommit: unknown;
|
||||
let latestDuringFirstCommit: unknown;
|
||||
let reentered = false;
|
||||
setup.runtime.subscribeState(
|
||||
state => {
|
||||
if (reentered) return;
|
||||
reentered = true;
|
||||
firstCommit = state;
|
||||
setup.wire.emit({ type: 'slow' });
|
||||
latestDuringFirstCommit = setup.runtime.state;
|
||||
},
|
||||
error => { throw error; },
|
||||
);
|
||||
setup.runtime.subscribeState(
|
||||
state => { delivered.push(state); },
|
||||
error => { throw error; },
|
||||
);
|
||||
await setup.runtime.start();
|
||||
|
||||
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
|
||||
assert.notEqual(firstCommit, latestDuringFirstCommit);
|
||||
assert.equal(delivered[0], firstCommit);
|
||||
assert.equal(delivered[1], latestDuringFirstCommit);
|
||||
assert.deepEqual(
|
||||
delivered.map(state => (state as { app: { phase: string } }).app.phase),
|
||||
['connected', 'slow'],
|
||||
);
|
||||
setup.runtime.stop();
|
||||
});
|
||||
|
||||
test('cancelling and subscribing observers during a commit cannot alter that delivery snapshot', async () => {
|
||||
const setup = makeRuntime();
|
||||
let cancelledCalls = 0;
|
||||
const replacementPhases: string[] = [];
|
||||
let changedObservers = false;
|
||||
let offCancelled = (): void => {};
|
||||
setup.runtime.subscribeState(
|
||||
() => {
|
||||
if (changedObservers) return;
|
||||
changedObservers = true;
|
||||
offCancelled();
|
||||
setup.runtime.subscribeState(
|
||||
state => { replacementPhases.push(state.app.phase); },
|
||||
error => { throw error; },
|
||||
);
|
||||
},
|
||||
error => { throw error; },
|
||||
);
|
||||
offCancelled = setup.runtime.subscribeState(
|
||||
() => { cancelledCalls += 1; },
|
||||
error => { throw error; },
|
||||
);
|
||||
await setup.runtime.start();
|
||||
|
||||
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.equal(cancelledCalls, 0);
|
||||
assert.deepEqual(replacementPhases, []);
|
||||
|
||||
setup.wire.emit({ type: 'slow' });
|
||||
assert.equal(cancelledCalls, 0);
|
||||
assert.deepEqual(replacementPhases, ['slow']);
|
||||
setup.runtime.stop();
|
||||
});
|
||||
|
||||
test('a denied join retains the lobby root, reports the raw outcome and permits a valid retry', async () => {
|
||||
const { runtime, wire, scene } = makeRuntime();
|
||||
await runtime.start();
|
||||
|
||||
Reference in New Issue
Block a user