import assert from 'node:assert/strict'; import { readFile, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import { createHash } from 'node:crypto'; const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); const output = new URL('../coverage/glb-machine/gantry/', import.meta.url); const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); const control = (id) => page.locator(`[data-axis-control="${id}"]`); const settled = () => page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false'); const report = { report_id: 'gantry-machine-browser-v1', status: 'running', examples: [], layouts: [] }; try { await page.goto('http://127.0.0.1:5174/'); await settled(); for (const id of ['ac', 'bc', 'ac-gantry', 'bc-gantry']) { await control('machine-glb-settings').click(); await control(`machine-glb-${id}`).click(); await settled(); const s = await page.evaluate(async (id) => { const api = await import('/src/imported-machine.ts'), parser = await import('/src/machine-urdf.ts'); const catalog = await (await fetch('/machines/manifest.json')).json(), entry = catalog.machines.find((e) => e.id === id); const buffer = await (await fetch(`/machines/${entry.filename}`)).arrayBuffer(); const asset = await api.loadImportedMachine(buffer, entry.filename), structure = asset.structure; if (asset.hash !== document.querySelector('.xyzac-machine-view').dataset.machineGlb) throw new Error('UI example differs from file'); const model = api.buildImportedMachine(asset); const gantry = id.endsWith('gantry'); if (structure.profile !== (gantry ? 'trt-xyz-gantry/1' : 'trt-xyz-head/1')) throw new Error('wrong profile'); const find = (c) => structure.joints.find((j) => j.coordinate === c); if (find('X').parent !== structure.base || find('Y').parent !== find('X').child || find('Z').parent !== find('Y').child) throw new Error('bad XYZ chain'); if (find(structure.coordinates[3]).parent !== structure.base || find('C').parent !== find(structure.coordinates[3]).child) throw new Error('bad rotary chain'); if (gantry && (structure.tool !== find('Z').child || structure.links.length !== 7)) throw new Error('tool must be Z link'); const rejections = []; if (gantry) for (const [name, xml] of [ ['wrong-tool', structure.urdf.replace('tool="z_slide"', 'tool="y_slide"')], ['wrong-profile', structure.urdf.replace('trt-xyz-gantry/1', 'trt-xyz-head/1')], ['rotary-on-xyz', structure.urdf.replace('', '')], ]) { let rejected = false; try { parser.parseMachineUrdf(xml); } catch { rejected = true; } if (!rejected) throw new Error(`accepted ${name}`); rejections.push(name); } const nodes = []; if (gantry) model.machine.traverse((n) => { if (n.name.startsWith('moving_bridge_chord_') || n.name.startsWith('gantry_y_carriage_') || n.name.startsWith('gantry_z_ram_') || n.name.startsWith('gantry_post_')) nodes.push(n); }); const state = { offsets: { ...structure.offsets, tool_offset: 0 }, joints: [0, 0, 0, 0, 0] }; const capture = () => nodes.map((n) => ({ name: n.name, parent: n.parent.name, position: n.getWorldPosition(model.toolTip.position.clone()).toArray() })); model.update(state); const initial = capture(); model.update({ ...state, joints: [20, 10, 5, 0, 0] }); const moved = capture(); for (let i = 0; i < nodes.length; i++) { const name = nodes[i].name; const expected = name.startsWith('gantry_post_') ? [0, 0, 0] : name.startsWith('moving_bridge_chord_') ? [.2, 0, 0] : name.startsWith('gantry_y_carriage_') ? [.2, 0, .1] : [.2, .05, .1]; moved[i].position.forEach((v, k) => { if (Math.abs(v - initial[i].position[k] - expected[k]) > 1e-8) throw new Error(`wrong branch motion ${name}`); }); } const tableBefore = model.joints.rotate.getWorldPosition(model.toolTip.position.clone()).toArray(); model.update(state); const tableAfter = model.joints.rotate.getWorldPosition(model.toolTip.position.clone()).toArray(); if (tableBefore.some((v, k) => Math.abs(v - tableAfter[k]) > 1e-8)) throw new Error('table moved with XYZ'); model.dispose(); api.disposeMachineScene(asset.scene); return { entry, profile: structure.profile, links: structure.links, tool: structure.tool, initial, moved, rejections }; }, id); const name = id.endsWith('gantry') ? `下载 ${id.slice(0, 2).toUpperCase()}桁架型` : `下载 ${id.toUpperCase()}`; const downloadEvent = page.waitForEvent('download'); await page.getByRole('link', { name, exact: true }).click(); const download = await downloadEvent; assert.equal(createHash('sha256').update(await readFile(await download.path())).digest('hex'), s.entry.sha256); await page.keyboard.press('Escape'); await page.locator('.axis-preview-machine').screenshot({ path: new URL(`${id}.png`, output).pathname }); report.examples.push(s); } for (const [width, height] of [[1440, 1000], [390, 844]]) { await page.setViewportSize({ width, height }); await control('machine-glb-settings').click(); const b = await page.locator('.model-dialog button, .model-dialog a').evaluateAll((es) => es.map((e) => { const r = e.getBoundingClientRect(); return { x: r.x, right: r.right, y: r.y, bottom: r.bottom }; })); assert.ok(b.every((r) => r.x >= 0 && r.right <= width && r.y >= 0 && r.bottom <= height)); await page.screenshot({ path: new URL(`dialog-${width}.png`, output).pathname }); await page.keyboard.press('Escape'); report.layouts.push({ width, height, controls: b.length }); } assert.deepEqual(errors, []); report.status = 'verified'; } catch (e) { report.status = 'failed'; report.error = String(e.stack ?? e); await page.screenshot({ path: new URL('failure.png', output).pathname }); throw e; } finally { report.browser_errors = errors; await writeFile(new URL('catalog.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); } console.log(JSON.stringify({ report_id: report.report_id, status: report.status, examples: report.examples.map((e) => e.entry.id) }));