import fixture from '../../../contracts/fixtures/single-joint.json'; import { RobotRuntime } from './RobotRuntime'; import type { RobotAdapter } from './types'; import { validateDescriptor, validateValues } from './validation'; function setup() { const descriptor = validateDescriptor(fixture.descriptor); let clock = 0, paused = false, measured = 0.12; const adapter: RobotAdapter = { describe: () => descriptor, readObservation: () => ({ 'slider.position': measured }), validateAction: (v) => validateValues(v, descriptor.actionChannels, true), applyAction: vi.fn((v) => validateValues(v, descriptor.actionChannels, true)), safeStop: vi.fn(), reset: () => { measured = 0; }, dispose: vi.fn(), }; const fault = vi.fn(() => { paused = true; }); const runtime = new RobotRuntime( adapter, 'sim-test', () => paused, fault, () => clock, ); runtime.authorize(); const identity = runtime.claim('lease-test'); const action = (seq = 1) => ({ ...fixture.validAction, ...identity, actionSeq: seq }); return { runtime, adapter, fault, action, tick: (ms: number) => { clock += ms; }, pause: () => { paused = true; }, }; } describe('RobotRuntime bounded mailbox', () => { it('applies only at a step, confirms after the step, observations are not targets', async () => { const s = setup(); let confirmed = false; const ack = s.runtime.enqueue(s.action()).then((r) => { confirmed = true; return r; }); expect(s.adapter.applyAction).not.toHaveBeenCalled(); expect(s.runtime.beforeStep()).toBe(true); expect(s.adapter.applyAction).toHaveBeenCalledOnce(); expect(confirmed).toBe(false); s.runtime.afterStep(0.002); expect((await ack).values['slider.position']).toBe(0.3); expect(s.runtime.latest()?.values['slider.position']).toBe(0.12); expect(s.runtime.latest()?.appliedActionSeq).toBe(1); }); it('only newest pending target survives and old sequences cannot replay', async () => { const s = setup(); const old = s.runtime.enqueue(s.action()).catch((e) => e.code); const next = s.runtime.enqueue(s.action(2)); expect(await old).toBe('SUPERSEDED'); await expect(s.runtime.enqueue(s.action())).rejects.toMatchObject({ code: 'STALE' }); s.runtime.beforeStep(); s.runtime.afterStep(0.002); expect((await next).actionSeq).toBe(2); }); it('wall clock watchdog cancels stale actions before the first resumed step', async () => { const s = setup(); const ack = s.runtime.enqueue(s.action()).catch((e) => e.code); s.tick(501); expect(s.runtime.beforeStep()).toBe(false); expect(await ack).toBe('DISCONNECTED'); expect(s.adapter.applyAction).not.toHaveBeenCalled(); expect(s.fault).toHaveBeenCalledOnce(); expect(s.runtime.status().enabled).toBe(false); await expect(s.runtime.enqueue(s.action(2))).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); it('a socket event delivered after freeze cannot renew an expired lease', async () => { const s = setup(); s.runtime.capture(0); s.tick(501); expect(s.runtime.status().observationAgeMs).toBe(501); await expect(s.runtime.enqueue(s.action())).rejects.toMatchObject({ code: 'TIMEOUT' }); expect(s.adapter.applyAction).not.toHaveBeenCalled(); expect(s.fault).toHaveBeenCalledOnce(); expect(s.runtime.status().enabled).toBe(false); }); it('reset/dispose reject outstanding requests and invalidate epoch', async () => { const s = setup(); const pending = s.runtime.enqueue(s.action()).catch((e) => e.code); s.runtime.reset(); expect(await pending).toBe('DISCONNECTED'); expect(s.runtime.status().modelEpoch).toBe(1); s.runtime.authorize(); s.runtime.claim('new-lease'); await expect(s.runtime.enqueue(s.action(2))).rejects.toMatchObject({ code: 'STALE' }); s.runtime.dispose(); s.runtime.dispose(); expect(s.adapter.dispose).toHaveBeenCalledOnce(); }); it('paused/multiple owners denied and nonowner revocation does not write', async () => { const s = setup(); expect(() => s.runtime.claim('other')).toThrow(/已有/); s.pause(); await expect(s.runtime.enqueue(s.action())).rejects.toMatchObject({ code: 'PAUSED' }); vi.mocked(s.adapter.safeStop).mockClear(); s.runtime.revoke('not owner', false); expect(s.adapter.safeStop).not.toHaveBeenCalled(); }); });