Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 

97 rader
6.8 KiB

  1. import fs from 'node:fs';
  2. import assert from 'node:assert/strict';
  3. import { chromium, expect } from '@playwright/test';
  4. import { login } from './role-permissions-live.mjs';
  5. const baseUrl = process.env.BASE_URL || 'http://127.0.0.1:6180';
  6. const viewport = { width: Number(process.env.WIDTH || 1920), height: Number(process.env.HEIGHT || 945) };
  7. const dir = 'reports/bridge-label-placement-20260925' + (process.env.REPORT_SUFFIX || '');
  8. fs.mkdirSync(dir, { recursive: true });
  9. const session = await login('root'), checks = [], errors = [];
  10. const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--enable-unsafe-swiftshader'] });
  11. const page = await browser.newPage({ viewport });
  12. await page.addInitScript(session => {
  13. sessionStorage.setItem('unreal-tran:web:access-token:v1', session.accessToken);
  14. sessionStorage.setItem('unreal-tran:web:refresh-token:v1', session.refreshToken);
  15. localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ themeMode: 'light' }));
  16. }, session);
  17. // Read-only observation of the actual loaded geometry and DOM; never changes training state.
  18. await page.route(/\/src\/features\/virtual-training-legacy\/upstream\/views\/training\.js(?:\?|$)/, async route => {
  19. const response = await route.fetch();
  20. await route.fulfill({ response, body: await response.text() + `
  21. globalThis.__labelPlacement = () => {
  22. const e=activeTraining;if(!e?.sceneReady)return null;
  23. const canvas=e.canvas.getBoundingClientRect(),active=e.getActiveTargetId();
  24. const nodes=[{node:e.partLabel,id:active},...[...e.partLabelLayer.querySelectorAll('[data-label-target]')].map(node=>({node,id:node.dataset.labelTarget}))];
  25. const labels=nodes.map(({node,id})=>{
  26. const o=e.parts.get(id),box=new THREE.Box3();if(!o)return null;o.updateWorldMatrix(true,true);
  27. o.traverse(m=>{if(!m.isMesh||m.userData.trainingPickProxy)return;m.geometry.computeBoundingBox();box.union(m.geometry.boundingBox.clone().applyMatrix4(m.matrixWorld));});
  28. const p=e.worldToScreen(box.getCenter(new THREE.Vector3())),r=node.getBoundingClientRect();
  29. 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}};
  30. }).filter(Boolean);
  31. return {labels,intro:e.introductionActive,pending:!!e.viewerCameraTween,completed:[...e.session.completed],camera:e.camera.position.toArray()};
  32. };` });
  33. });
  34. page.on('pageerror', error => errors.push(error.message));
  35. const snap = () => page.evaluate(() => globalThis.__labelPlacement?.());
  36. const settled = async () => {
  37. await expect.poll(async () => (await snap())?.pending, { timeout: 20000 }).toBe(false);
  38. await page.waitForTimeout(400);
  39. };
  40. 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;
  41. const check = async (id, view) => {
  42. await settled();
  43. const state = await snap(), visible = state.labels.filter(l => l.visible);
  44. assert.ok(visible.length > 0, `${id}/${view}: labels are visible`);
  45. for (const [i, label] of visible.entries()) {
  46. const distance = Math.hypot(label.rect.x + label.rect.w / 2 - label.anchor.x, label.rect.y + label.rect.h / 2 - label.anchor.y);
  47. assert.ok(distance <= 130, `${id}/${view}/${label.id}: label moved ${distance.toFixed(1)}px from its part`);
  48. assert.ok(visible.slice(i + 1).every(other => !overlap(label.rect, other.rect)), `${id}/${view}: labels overlap`);
  49. }
  50. assert.deepEqual(state.completed, [], 'focusing and viewer tools cannot complete steps');
  51. if (view === 'overview') {
  52. await page.waitForTimeout(400);
  53. const stable = (await snap()).labels.filter(l => l.visible);
  54. assert.deepEqual(stable.map(l => l.id), visible.map(l => l.id), 'stationary labels cannot flicker between layouts');
  55. stable.forEach((label, i) => {
  56. assert.ok(Math.abs(label.rect.x - visible[i].rect.x) < 1 && Math.abs(label.rect.y - visible[i].rect.y) < 1,
  57. 'hidden label measurement cannot shift neighbouring labels');
  58. });
  59. }
  60. await page.screenshot({ path: `${dir}/task-${id}-${view}.png` });
  61. checks.push({ task: id, view, ...state });
  62. };
  63. try {
  64. for (const id of (process.env.TASK_IDS || '86,87,88,89').split(',')) {
  65. await page.goto(`${baseUrl}/teaching/virtual-training/preview/${id}`);
  66. await expect.poll(async () => Boolean(await snap()), { timeout: 120000 }).toBe(true);
  67. await page.locator('[data-intro-command="skip"]').click();
  68. if (!await page.locator('[data-action="toggle-part-labels"]').isVisible()) await page.locator('[data-viewer-action="tools"]').click();
  69. await page.locator('[data-action="toggle-part-labels"]').click();
  70. await check(id, 'overview');
  71. const candidate = (await snap()).labels.find(l => l.visible && !l.active);
  72. if (candidate) await page.locator(`[data-label-target="${candidate.id}"]`).click();
  73. else await page.locator('[data-role="part-label"]').click();
  74. await check(id, 'focus');
  75. await page.locator('[data-action="toggle-hide-others"]').click();
  76. await check(id, 'hide-others');
  77. await page.locator('[data-action="toggle-hide-others"]').click();
  78. await page.locator('[data-action="reset-view"]').click();
  79. await check(id, 'reset');
  80. const canvas = await page.locator('.training-canvas').boundingBox();
  81. await page.mouse.move(canvas.x + canvas.width * .65, canvas.y + canvas.height * .48);
  82. await page.mouse.down({ button: 'right' });
  83. await page.mouse.move(canvas.x + canvas.width * .65 + 60, canvas.y + canvas.height * .48 + 12, { steps: 12 });
  84. await page.mouse.up({ button: 'right' });
  85. await check(id, 'moved');
  86. console.log('PASS labels', id);
  87. }
  88. assert.deepEqual(errors, []);
  89. fs.writeFileSync(dir + '/result.json', JSON.stringify({ pass: true, baseUrl, viewport, accountRole: 'root', serverMutations: [], checks, errors }, null, 2));
  90. fs.writeFileSync(dir + '/index.html', `<!doctype html><meta charset="utf-8"><title>部件标签位置检查</title><style>body{font:16px system-ui;max-width:1400px;margin:28px auto;background:#f4f8f7;color:#17453d}img{width:100%;border:1px solid #b9ceca}section{margin:24px 0}a{color:#078871}</style><h1>部件标签位置检查:通过</h1><p>${baseUrl} · ${viewport.width} × ${viewport.height} · 教员预览(root)· 未创建任务或提交训练成绩。</p><p>标签中心距部件投影不超过128像素,互不重叠;检查聚焦、隐藏其他、复位和移动视角。</p><a href="result.json">检查数据</a>${checks.map(c => `<section><h2>任务 ${c.task} · ${c.view}</h2><img src="task-${c.task}-${c.view}.png"></section>`).join('')}`);
  91. } catch (error) {
  92. await page.screenshot({ path: dir + '/failure.png' }).catch(() => {});
  93. fs.writeFileSync(dir + '/result.json', JSON.stringify({ pass: false, error: error.stack, checks, errors, state: await snap().catch(() => null) }, null, 2));
  94. throw error;
  95. } finally { await browser.close(); }