Files
Mujoco_WASM/web_platform/src/robot/RobotRuntime.test.ts
T
chenlin 3ad29356c9
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
2026-09-20 14:42:30 +08:00

119 lines
4.4 KiB
TypeScript

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();
});
});