import assert from 'node:assert/strict'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import * as THREE from '../web/react/node_modules/three/build/three.module.js'; const playwright = process.env.PLAYWRIGHT_MODULE ? await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) : await import('playwright'); const browser = await (playwright.default ?? playwright).chromium.launch({ executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'], }); const output = new URL('../coverage/glb-machine-manager/model-selection/', import.meta.url); await mkdir(output, { recursive: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); page.setDefaultTimeout(15000); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); const report = { status: 'running', checks: [], errors }; const c = (id) => page.locator(`[data-axis-control="${id}"]`); const preview = page.locator('[data-machine-manager-preview]'); const rows = () => page.locator('[data-machine-model-id]'); const row = (id) => page.locator(`[data-machine-model-id="${id}"]`); const modelCount = (n) => page.waitForFunction((n) => document.querySelectorAll('[data-machine-model-id]').length === n && document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false', n); const selected = (id) => page.waitForFunction((id) => document.querySelector(`[data-machine-model-id="${id}"] [aria-pressed="true"]`) && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerSelectedModel === id, id); // Small independent GLBs give identical filenames but distinct import identities. function fixture(x) { const geometry = new THREE.BoxGeometry(.22, .22, .22).toNonIndexed(); const position = geometry.getAttribute('position'); const binary = Buffer.from(position.array.buffer); const json = { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ name: 'fixture', translation: [x, 0, .3], children: [1] }, { name: 'solid', mesh: 0 }], meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }], materials: [{ doubleSided: true, pbrMetallicRoughness: { baseColorFactor: [.15, .6, .5, 1] } }], accessors: [{ bufferView: 0, componentType: 5126, count: position.count, type: 'VEC3', min: [-.11, -.11, -.11], max: [.11, .11, .11] }], bufferViews: [{ buffer: 0, byteLength: binary.length }], buffers: [{ byteLength: binary.length }] }; const raw = Buffer.from(JSON.stringify(json)); const payload = Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4, 32)]); const header = Buffer.alloc(20); header.writeUInt32LE(0x46546c67, 0); header.writeUInt32LE(2, 4); header.writeUInt32LE(28 + payload.length + binary.length, 8); header.writeUInt32LE(payload.length, 12); header.writeUInt32LE(0x4e4f534a, 16); const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(binary.length, 0); binHeader.writeUInt32LE(0x004e4942, 4); geometry.dispose(); return { name: 'same-name.glb', mimeType: 'model/gltf-binary', buffer: Buffer.concat([header, payload, binHeader, binary]) }; } try { await page.goto(process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5173/'); await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false'); await c('machine-manager-settings').click(); assert.equal(await c('machine-model-delete-selected').isDisabled(), true); await c('machine-manager-files').setInputFiles([fixture(-.35), fixture(.35)]); await modelCount(2); const [first, second] = await rows().evaluateAll((items) => items.map((el) => el.dataset.machineModelId)); assert.notEqual(first, second); await selected(first); await row(second).locator('[data-axis-control="machine-model-select"]').click(); await selected(second); await c('machine-tree-search').fill('no-matching-node'); await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first); assert.equal(await c('machine-tree-search').inputValue(), ''); assert.equal(await preview.getAttribute('data-machine-manager-selection-visible'), 'true'); report.checks.push('same filenames retain unique selection; model selection clears search and highlights preview'); const cameraState = JSON.parse(await preview.getAttribute('data-machine-manager-camera')); const canvas = preview.locator('canvas'), box = await canvas.boundingBox(); const camera = new THREE.PerspectiveCamera(42, box.width / box.height, .005, 100); camera.position.fromArray(cameraState.position); camera.up.fromArray(cameraState.up); camera.lookAt(new THREE.Vector3(...cameraState.target)); camera.updateMatrixWorld(true); const ndc = new THREE.Vector3(.35, 0, .3).project(camera); const point = { x: box.x + (ndc.x + 1) * box.width / 2, y: box.y + (1 - ndc.y) * box.height / 2 }; await page.mouse.click(point.x, point.y); await selected(second); await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first); await page.mouse.move(point.x, point.y); await page.mouse.down(); await page.mouse.move(point.x + 40, point.y + 20, { steps: 4 }); await page.mouse.move(point.x, point.y, { steps: 4 }); await page.mouse.up(); await selected(first); report.checks.push('viewport click picks correct import; orbit drag returning to start does not select'); await row(second).locator('[data-axis-control="machine-model-delete"]').click(); await modelCount(1); await selected(first); await c('machine-manager-undo').click(); await modelCount(2); await selected(second); await c('machine-model-delete-selected').click(); await modelCount(1); await selected(first); await c('machine-manager-undo').click(); await modelCount(2); await selected(second); await c('machine-manager-redo').click(); await modelCount(1); await selected(first); report.checks.push('delete unselected/selected import by ID; undo restores and selects it; redo repairs selection'); await c('machine-manager-undo').click(); await modelCount(2); // The second imported part was parented to the first by the assembly editor. await row(first).locator('[data-axis-control="machine-model-select"]').click(); await c('machine-model-delete-selected').click(); await modelCount(1); await selected(second); assert.equal(await c('machine-manager-parent').inputValue(), ''); await c('machine-manager-undo').click(); await modelCount(2); await selected(first); await row(second).locator('[data-axis-control="machine-model-select"]').click(); assert.equal(await c('machine-manager-parent').inputValue(), first); report.checks.push('deleting parent import preserves separately imported child; undo restores parent link'); await c('machine-model-delete-selected').click(); await modelCount(1); await c('machine-model-delete-selected').click(); await modelCount(0); assert.equal(await c('machine-model-delete-selected').isDisabled(), true); assert.equal(await page.locator('.machine-preview-empty').isVisible(), true); await c('machine-manager-undo').click(); await modelCount(1); await selected(first); await c('machine-manager-redo').click(); await modelCount(0); report.checks.push('last deletion clears preview; undo restores selection from empty state'); const m1 = fileURLToPath(new URL('../glb/m1.glb', import.meta.url)); const bytes = await readFile(m1); await c('machine-manager-files').setInputFiles({ name: 'm1.glb', mimeType: 'model/gltf-binary', buffer: bytes }); await modelCount(1); const m1id = await rows().first().getAttribute('data-machine-model-id'); await selected(m1id); assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5); await c('machine-model-delete-selected').click(); await modelCount(0); await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id); assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5); await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').check(); assert.equal(await c('machine-model-delete-selected').isDisabled(), true); await c('machine-manager-tab-structure').click(); assert.equal(await c('machine-model-delete').isDisabled(), true); await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').uncheck(); await c('machine-manager-tab-structure').click(); report.checks.push('real m1 deletion/undo preserves all five bindings; live follow locks deletion'); await page.screenshot({ path: fileURLToPath(new URL('desktop.png', output)) }); for (const [width, height] of [[1154, 520], [720, 900]]) { await page.setViewportSize({ width, height }); await c('machine-model-select').click(); await selected(m1id); await c('machine-model-delete').click(); await modelCount(0); await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id); } assert.deepEqual(errors, []); report.checks.push('selection and deletion reachable on short and narrow viewports'); report.status = 'verified'; } catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); await page.screenshot({ path: fileURLToPath(new URL('failure.png', output)) }).catch(() => {}); throw error; } finally { await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n'); await browser.close(); } console.log(JSON.stringify(report));