import fs from 'node:fs'; import assert from 'node:assert/strict'; import { chromium, expect } from '@playwright/test'; import { login } from './role-permissions-live.mjs'; const baseUrl = process.env.BASE_URL || 'http://127.0.0.1:6180'; const viewport = { width: Number(process.env.WIDTH || 1920), height: Number(process.env.HEIGHT || 945) }; const dir = 'reports/bridge-label-placement-20260925' + (process.env.REPORT_SUFFIX || ''); fs.mkdirSync(dir, { recursive: true }); const session = await login('root'), checks = [], errors = []; const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--enable-unsafe-swiftshader'] }); const page = await browser.newPage({ viewport }); await page.addInitScript(session => { sessionStorage.setItem('unreal-tran:web:access-token:v1', session.accessToken); sessionStorage.setItem('unreal-tran:web:refresh-token:v1', session.refreshToken); localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ themeMode: 'light' })); }, session); // Read-only observation of the actual loaded geometry and DOM; never changes training state. await page.route(/\/src\/features\/virtual-training-legacy\/upstream\/views\/training\.js(?:\?|$)/, async route => { const response = await route.fetch(); await route.fulfill({ response, body: await response.text() + ` globalThis.__labelPlacement = () => { const e=activeTraining;if(!e?.sceneReady)return null; const canvas=e.canvas.getBoundingClientRect(),active=e.getActiveTargetId(); const nodes=[{node:e.partLabel,id:active},...[...e.partLabelLayer.querySelectorAll('[data-label-target]')].map(node=>({node,id:node.dataset.labelTarget}))]; const labels=nodes.map(({node,id})=>{ const o=e.parts.get(id),box=new THREE.Box3();if(!o)return null;o.updateWorldMatrix(true,true); o.traverse(m=>{if(!m.isMesh||m.userData.trainingPickProxy)return;m.geometry.computeBoundingBox();box.union(m.geometry.boundingBox.clone().applyMatrix4(m.matrixWorld));}); const p=e.worldToScreen(box.getCenter(new THREE.Vector3())),r=node.getBoundingClientRect(); return{id,text:node.textContent,visible:node.classList.contains('is-visible'),active:node===e.partLabel,anchor:{x:p.localX,y:p.localY},rect:{x:r.x-canvas.x,y:r.y-canvas.y,w:r.width,h:r.height}}; }).filter(Boolean); return {labels,intro:e.introductionActive,pending:!!e.viewerCameraTween,completed:[...e.session.completed],camera:e.camera.position.toArray()}; };` }); }); page.on('pageerror', error => errors.push(error.message)); const snap = () => page.evaluate(() => globalThis.__labelPlacement?.()); const settled = async () => { await expect.poll(async () => (await snap())?.pending, { timeout: 20000 }).toBe(false); await page.waitForTimeout(400); }; const overlap = (a, b) => a.x < b.x + b.w - 1 && a.x + a.w > b.x + 1 && a.y < b.y + b.h - 1 && a.y + a.h > b.y + 1; const check = async (id, view) => { await settled(); const state = await snap(), visible = state.labels.filter(l => l.visible); assert.ok(visible.length > 0, `${id}/${view}: labels are visible`); for (const [i, label] of visible.entries()) { const distance = Math.hypot(label.rect.x + label.rect.w / 2 - label.anchor.x, label.rect.y + label.rect.h / 2 - label.anchor.y); assert.ok(distance <= 130, `${id}/${view}/${label.id}: label moved ${distance.toFixed(1)}px from its part`); assert.ok(visible.slice(i + 1).every(other => !overlap(label.rect, other.rect)), `${id}/${view}: labels overlap`); } assert.deepEqual(state.completed, [], 'focusing and viewer tools cannot complete steps'); if (view === 'overview') { await page.waitForTimeout(400); const stable = (await snap()).labels.filter(l => l.visible); assert.deepEqual(stable.map(l => l.id), visible.map(l => l.id), 'stationary labels cannot flicker between layouts'); stable.forEach((label, i) => { assert.ok(Math.abs(label.rect.x - visible[i].rect.x) < 1 && Math.abs(label.rect.y - visible[i].rect.y) < 1, 'hidden label measurement cannot shift neighbouring labels'); }); } await page.screenshot({ path: `${dir}/task-${id}-${view}.png` }); checks.push({ task: id, view, ...state }); }; try { for (const id of (process.env.TASK_IDS || '86,87,88,89').split(',')) { await page.goto(`${baseUrl}/teaching/virtual-training/preview/${id}`); await expect.poll(async () => Boolean(await snap()), { timeout: 120000 }).toBe(true); await page.locator('[data-intro-command="skip"]').click(); if (!await page.locator('[data-action="toggle-part-labels"]').isVisible()) await page.locator('[data-viewer-action="tools"]').click(); await page.locator('[data-action="toggle-part-labels"]').click(); await check(id, 'overview'); const candidate = (await snap()).labels.find(l => l.visible && !l.active); if (candidate) await page.locator(`[data-label-target="${candidate.id}"]`).click(); else await page.locator('[data-role="part-label"]').click(); await check(id, 'focus'); await page.locator('[data-action="toggle-hide-others"]').click(); await check(id, 'hide-others'); await page.locator('[data-action="toggle-hide-others"]').click(); await page.locator('[data-action="reset-view"]').click(); await check(id, 'reset'); const canvas = await page.locator('.training-canvas').boundingBox(); await page.mouse.move(canvas.x + canvas.width * .65, canvas.y + canvas.height * .48); await page.mouse.down({ button: 'right' }); await page.mouse.move(canvas.x + canvas.width * .65 + 60, canvas.y + canvas.height * .48 + 12, { steps: 12 }); await page.mouse.up({ button: 'right' }); await check(id, 'moved'); console.log('PASS labels', id); } assert.deepEqual(errors, []); fs.writeFileSync(dir + '/result.json', JSON.stringify({ pass: true, baseUrl, viewport, accountRole: 'root', serverMutations: [], checks, errors }, null, 2)); fs.writeFileSync(dir + '/index.html', `
${baseUrl} · ${viewport.width} × ${viewport.height} · 教员预览(root)· 未创建任务或提交训练成绩。
标签中心距部件投影不超过128像素,互不重叠;检查聚焦、隐藏其他、复位和移动视角。
检查数据${checks.map(c => `