import { expect, test } from '@playwright/test'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; // Opt-in real source-derived actor, not a constant fixture. Run against the Vite dev server. const policyPath = process.env.GO2_PRETRAINED_NAV_POLICY; test('真实warm-start策略连续行走超过20秒并换目标,不重载策略或自动启用', async ({ page, }, testInfo) => { test.skip( !policyPath, '设置GO2_PRETRAINED_NAV_POLICY为真实47→81/97 warm-start导出,并运行Vite dev :4173', ); const xml = readFileSync( resolve('training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml'), 'utf8', ) .replace(/]*\/>/g, '') .replace(/]*\bmesh="[^"]*"[^>]*\/>/g, ''); const policy = Array.from(readFileSync(policyPath!)); await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174'); const result = await page.evaluate( async ({ xml, policy }) => { const adapterPath = '/src/simulation/PhysicsAdapter.ts'; const deploymentPath = '/src/rl/deployment.ts'; const navigationPath = '/src/rl/tasks/go2ObstacleAvoidance.ts'; const { MainThreadPhysicsAdapter } = (await import( adapterPath )) as typeof import('../src/simulation/PhysicsAdapter'); const { readPolicyDeployment } = (await import( deploymentPath )) as typeof import('../src/rl/deployment'); const { obstacleNavigation } = (await import( navigationPath )) as typeof import('../src/rl/tasks/go2ObstacleAvoidance'); const bytes = new Uint8Array(policy), data = new TextEncoder().encode(xml); const deployment = readPolicyDeployment(bytes)!; const adapter = new MainThreadPhysicsAdapter(); try { await adapter.load( { id: 'real-pretrained', name: 'go2', files: [ { path: 'go2.xml', data, size: data.length, source: 'file', mimeType: 'text/xml' }, ], entries: [{ path: 'go2.xml', format: 'mjcf', label: 'Go2' }], maps: [], totalBytes: data.length, }, 'go2.xml', { trainingDeployment: deployment, trainingPolicy: { data: bytes, path: 'user-warmstart.onnx' }, }, ); const session = adapter.session!; const originalPolicy = (session as unknown as { rlPolicy: object }).rlPolicy; const initial = adapter.snapshot()!; const samples: { time: number; x: number; y: number; z: number; enabled?: boolean; error?: string; }[] = []; const run = async (seconds: number) => { const target = Number(session.data.time) + seconds; while (Number(session.data.time) < target) { for (let i = 0; i < 10; i++) adapter.singleStep(); await new Promise((resolve) => setTimeout(resolve, 0)); if (!adapter.snapshot()?.rlPolicy?.enabled) break; } const s = adapter.snapshot()!; samples.push({ time: s.time, x: s.qpos[0], y: s.qpos[1], z: s.qpos[2], enabled: s.rlPolicy?.enabled, error: s.rlPolicy?.error, }); return s; }; const before = await run(21); const navBefore = obstacleNavigation( before.qpos.slice(0, 3), before.qpos.slice(3, 7), before.rlPolicy!.navigation!.target, deployment.terrain!.size, deployment.navigation?.speed, ); // Nearby diagonal target exercises both heading and velocity command; no pose teleport. adapter.setNavigationTarget([before.qpos[0] - 2, before.qpos[1] + 2]); const changed = adapter.snapshot()!; const navChanged = obstacleNavigation( changed.qpos.slice(0, 3), changed.qpos.slice(3, 7), changed.rlPolicy!.navigation!.target, deployment.terrain!.size, deployment.navigation?.speed, ); const after = await run(4); adapter.setRLPolicyEnabled(false); adapter.setNavigationTarget([0, 0]); const disabled = adapter.snapshot()!; return { initial: { time: initial.time, qpos: initial.qpos }, before: { time: before.time, qpos: before.qpos, ctrl: before.ctrl, status: before.rlPolicy, }, after: { time: after.time, qpos: after.qpos, ctrl: after.ctrl, status: after.rlPolicy }, samples, navBefore, navChanged, sameSession: adapter.session === session, samePolicy: (session as unknown as { rlPolicy: object }).rlPolicy === originalPolicy, heldControlUnchangedOnTarget: changed.ctrl.every((v, i) => v === before.ctrl[i]), countBeforeTarget: before.rlPolicy!.inferenceCount, countAfterTarget: changed.rlPolicy!.inferenceCount, disabledAfterNewTarget: !disabled.rlPolicy!.enabled, }; } finally { adapter.dispose(); } }, { xml, policy }, ); const evidence = testInfo.outputPath('real-navigation-measurements.json'); writeFileSync(evidence, JSON.stringify(result, null, 2)); await testInfo.attach('real-navigation-measurements.json', { path: evidence, contentType: 'application/json', }); expect(result.before.time).toBeGreaterThan(20); expect(result.before.status?.enabled).toBe(true); expect(result.after.status?.enabled).toBe(true); expect( Math.hypot( result.before.qpos[0] - result.initial.qpos[0], result.before.qpos[1] - result.initial.qpos[1], ), ).toBeGreaterThan(0.5); expect(result.navChanged.command).not.toEqual(result.navBefore.command); expect(result.navChanged.targetError).not.toEqual(result.navBefore.targetError); expect(result.after.ctrl).not.toEqual(result.before.ctrl); expect(result.after.status!.navigation!.distance).toBeLessThan(2 * Math.SQRT2); expect(result.countAfterTarget).toBe(result.countBeforeTarget); expect(result.after.status!.inferenceCount).toBeGreaterThan(result.countBeforeTarget); expect(result.sameSession).toBe(true); expect(result.samePolicy).toBe(true); expect(result.heldControlUnchangedOnTarget).toBe(true); expect(result.disabledAfterNewTarget).toBe(true); }); test('用户原始47维ONNX通过普通Flat面板加载,不新增点击导航模式', async ({ page }) => { const source = process.env.GO2_PRETRAINED_FLAT_POLICY; const robotXml = process.env.GO2_PRETRAINED_FLAT_XML; test.skip(!source || !robotXml, '设置原始47维ONNX与同源带actuator的机器人XML路径'); const xml = readFileSync(robotXml!, 'utf8') .replace(/]*\/>/g, '') .replace(/]*\bmesh="[^"]*"[^>]*\/>/g, ''); await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(xml) }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); const section = tools.getByRole('button', { name: /ONNX 策略运行/ }); if ((await section.getAttribute('aria-expanded')) === 'false') await section.click(); const chooser = page.waitForEvent('filechooser'); await tools.getByRole('button', { name: '导入 ONNX' }).click(); await (await chooser).setFiles(source!); await expect .poll( async () => (await tools.getByText('47 / 12').count()) + (await page.getByRole('button', { name: '技术详情', exact: true }).count()), ) .toBeGreaterThan(0); const details = page.getByRole('button', { name: '技术详情', exact: true }); if (await details.isVisible()) { await details.click(); throw new Error((await page.getByRole('alert').allTextContents()).join('\n')); } await expect(tools.getByText('47 / 12')).toBeVisible({ timeout: 30_000 }); const enable = tools.getByRole('button', { name: '启用', exact: true }); if (await enable.isVisible()) await enable.click(); const play = page.getByRole('button', { name: '▶ 播放', exact: true }); if (await play.isVisible()) await play.click(); await expect .poll(async () => Number( (await tools.getByText('推理次数', { exact: true }).locator('..').textContent())?.replace( /\D/g, '', ), ), ) .toBeGreaterThan(2); await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveCount(0); });