You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

180 line
14 KiB

  1. import { test, expect, type Page } from '@playwright/test'
  2. import { signIn, freshScene, openEditor, ready, diagnostics, action, save, shot, evidence, closeSession } from './scene-editor-closure-helpers'
  3. test.afterEach(async ({ page }) => closeSession(page))
  4. const field = (page: Page, name: string) => page.locator(`[data-inspector-field="${name}"]`)
  5. const object = (d: any, id: string) => d.objects.find((o: any) => o.sceneId === id)
  6. const distance = (a: number[], b: number[]) => Math.hypot(...a.map((v, i) => v - b[i]!))
  7. async function settle(page: Page) {
  8. let previous = ''
  9. await expect.poll(async () => {
  10. const d = await diagnostics(page)
  11. const current = [...d.cameraPosition, ...d.cameraTarget].map((n: number) => n.toFixed(3)).join(',')
  12. const same = previous === current; previous = current; return same
  13. }).toBe(true)
  14. }
  15. // Only project existing scene/gizmo geometry to pixels. All mutations below use
  16. // browser pointer/keyboard actions; this hook exposes no editor or write methods.
  17. async function installProjection(page: Page) {
  18. await page.route('**/src/features/guide-legacy/demo-src/scene-editor.js*', async route => {
  19. const response = await route.fetch()
  20. const body = await response.text()
  21. await route.fulfill({ response, body: body + `\n
  22. globalThis.__scenePointerRead = (kind, wanted) => {
  23. const e = activeSceneEditor; if (!e) return null;
  24. if(kind === 'axis') return e.transform.axis;
  25. const rect = e.renderer.domElement.getBoundingClientRect();
  26. const project = v => { const p = v.clone().project(e.camera); return {x:rect.x+(p.x+1)*rect.width/2,y:rect.y+(1-p.y)*rect.height/2,z:p.z}; };
  27. const visible = n => { for(let p=n;p;p=p.parent) if(!p.visible)return false; return true; };
  28. const candidates=[];
  29. if(kind==='gizmo') {
  30. const group=e.transform._gizmo.picker[e.transform.mode];
  31. group.traverse(n=>{
  32. if(n.name!==wanted || !n.geometry?.attributes?.position || !n.visible)return;
  33. const a=n.geometry.attributes.position, box=new THREE.Box3();
  34. for(let i=0;i<a.count;i++)box.expandByPoint(new THREE.Vector3().fromBufferAttribute(a,i));
  35. candidates.push(project(box.getCenter(new THREE.Vector3()).applyMatrix4(n.matrixWorld)));
  36. const stride=Math.max(1,Math.floor(a.count/48));
  37. for(let i=0;i<a.count;i+=stride)candidates.push(project(new THREE.Vector3().fromBufferAttribute(a,i).applyMatrix4(n.matrixWorld)));
  38. });
  39. } else {
  40. const root=e.objects.find(n=>n.userData.sceneId===wanted);
  41. root?.traverse(n=>{
  42. if(!n.isMesh || !visible(n) || !n.geometry?.attributes?.position)return;
  43. const a=n.geometry.attributes.position,box=new THREE.Box3();
  44. for(let i=0;i<a.count;i++)box.expandByPoint(new THREE.Vector3().fromBufferAttribute(a,i));
  45. const p=project(box.getCenter(new THREE.Vector3()).applyMatrix4(n.matrixWorld));
  46. const ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2((p.x-rect.x)/rect.width*2-1,-(p.y-rect.y)/rect.height*2+1),e.camera);
  47. const picked=chooseSceneSelection(ray.intersectObjects(e.objects.filter(n=>n.visible),true),e.objects);
  48. if(picked===root || picked?.userData.sceneOwnerId===wanted)candidates.push({...p,sceneId:picked.userData.sceneId});
  49. });
  50. }
  51. return candidates.filter(p=>p.z>-1&&p.z<1&&p.x>rect.x+15&&p.x<rect.right-15&&p.y>rect.y+65&&p.y<rect.bottom-15);
  52. };` })
  53. })
  54. }
  55. async function dragGizmo(page: Page, axis: string, dx: number, dy: number) {
  56. await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
  57. const candidates = await page.evaluate(axis => (window as any).__scenePointerRead('gizmo', axis), axis)
  58. let hit: any
  59. for (const point of candidates) {
  60. await page.mouse.move(point.x, point.y)
  61. if (await page.evaluate(() => (window as any).__scenePointerRead('axis')) === axis) { hit = point; break }
  62. }
  63. expect(hit, `visible ${axis} gizmo can be picked by a real mouse`).toBeTruthy()
  64. await page.mouse.down(); await page.mouse.move(hit.x + dx, hit.y + dy, { steps: 18 }); await page.mouse.up()
  65. }
  66. async function theme(page: Page, label: string) {
  67. await page.getByRole('button', { name: '界面与导航设置' }).click()
  68. const drawer = page.locator('.el-drawer').filter({ hasText: '界面与导航设置' })
  69. await drawer.locator('.theme-options button').filter({ hasText: label }).click()
  70. await drawer.locator('.el-drawer__close-btn').click()
  71. await expect(drawer).toBeHidden()
  72. }
  73. test('真实相机操控、视口拾取、预览禁写和两尺寸主题可读性', async ({ page }, info) => {
  74. await installProjection(page)
  75. const token = await signIn(page), copy = await freshScene(token, 'fox', '视口主题')
  76. await openEditor(page, copy.project.id)
  77. const initial = await diagnostics(page), fox = initial.objects.find((o: any) => o.targetProjectId === '157')
  78. await page.locator(`#se-tree [data-object-id="${fox.sceneId}"]`).click(); await action(page, 'focus').click(); await settle(page)
  79. const canvas = page.locator('#se-viewport > canvas'), box = (await canvas.boundingBox())!
  80. const start = { x: box.x + box.width * .38, y: box.y + box.height * .66 }
  81. const beforeOrbit = await diagnostics(page)
  82. await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + 95, start.y - 38, { steps: 18 }); await page.mouse.up(); await settle(page)
  83. const orbited = await diagnostics(page); expect(distance(orbited.cameraPosition, beforeOrbit.cameraPosition)).toBeGreaterThan(.1)
  84. await page.mouse.move(start.x, start.y); await page.mouse.down({ button: 'right' }); await page.mouse.move(start.x + 70, start.y - 30, { steps: 18 }); await page.mouse.up({ button: 'right' }); await settle(page)
  85. const panned = await diagnostics(page); expect(distance(panned.cameraTarget, orbited.cameraTarget)).toBeGreaterThan(.1)
  86. await page.mouse.wheel(0, -250); await settle(page)
  87. const zoomed = await diagnostics(page); expect(distance(zoomed.cameraPosition, zoomed.cameraTarget)).toBeLessThan(distance(panned.cameraPosition, panned.cameraTarget))
  88. await action(page, 'top').click(); await settle(page)
  89. expect((await diagnostics(page)).cameraPosition).not.toEqual(zoomed.cameraPosition)
  90. await page.locator('[data-camera="perspective"]').click(); await settle(page)
  91. expect(distance((await diagnostics(page)).cameraPosition, zoomed.cameraPosition)).toBeLessThan(.02)
  92. const grid = (await diagnostics(page)).gridVisible; await action(page, 'grid').click(); expect((await diagnostics(page)).gridVisible).toBe(!grid); await action(page, 'grid').click()
  93. await page.locator(`#se-tree [data-object-id="${fox.sceneId}"]`).click(); await action(page, 'focus').click(); await settle(page)
  94. const points = await page.evaluate(id => (window as any).__scenePointerRead('mesh', id), fox.sceneId)
  95. expect(points.length).toBeGreaterThan(0); await page.mouse.click(points[0].x, points[0].y)
  96. expect((await diagnostics(page)).selectedSceneId).toBe(points[0].sceneId)
  97. await theme(page, '暗色')
  98. await shot(page, info, '40-viewport-dark-1920')
  99. await page.setViewportSize({ width: 1440, height: 1000 }); await theme(page, '军工绿')
  100. const inspectorBounds = (await page.locator('.se-right').boundingBox())!
  101. expect(inspectorBounds.x + inspectorBounds.width).toBeLessThanOrEqual(1440)
  102. await expect.poll(async () => { const b = (await canvas.boundingBox())!; return b.x + b.width <= inspectorBounds.x }).toBe(true)
  103. const colors = await page.locator('[data-inspector-field="x"]').evaluate(el => ({ text: getComputedStyle(el).color, background: getComputedStyle(el).backgroundColor }))
  104. expect(colors.text).not.toBe(colors.background)
  105. await shot(page, info, '41-viewport-light-1440')
  106. await page.setViewportSize({ width: 1920, height: 1080 })
  107. const beforePreview = await diagnostics(page)
  108. await action(page, 'preview').click(); expect((await diagnostics(page)).previewing).toBe(true)
  109. await expect(action(page, 'save')).toBeDisabled(); await expect(field(page, 'name')).toBeDisabled()
  110. await page.keyboard.press('Delete'); await page.keyboard.press('w'); await page.keyboard.press('e'); await page.keyboard.press('r')
  111. expect((await diagnostics(page)).objects).toEqual(beforePreview.objects)
  112. await shot(page, info, '42-fox-preview-original-material')
  113. await page.keyboard.press('Escape'); expect((await diagnostics(page)).previewing).toBe(false); await expect(action(page, 'save')).toBeEnabled()
  114. evidence('viewport-camera-theme', { status: 'PASS', kind: '真实UI+只读投影', projectId: copy.project.id, featureIds: ['view.orbit','view.pan','view.zoom','view.top','view.perspective','view.grid','view.focus','selection.viewport','selection.highlight','preview.enter','preview.exit','preview.lock','lifecycle.resize-theme'], colors, screenshots: ['40-viewport-dark-1920','41-viewport-light-1440','42-fox-preview-original-material'] })
  115. })
  116. test('真实变换手柄移动旋转缩放、吸附与保存重开', async ({ page }, info) => {
  117. await installProjection(page)
  118. const token = await signIn(page), copy = await freshScene(token, 'truck', '变换手柄')
  119. await openEditor(page, copy.project.id)
  120. await page.locator('[data-left-tab="scenes"]').click(); await page.locator('[data-preset-id="workshop-1"]').click(); await ready(page)
  121. await page.locator('[data-left-tab="assets"]').click(); await page.locator('[data-asset-id="equipment"]').dblclick()
  122. const added = (await diagnostics(page)).objects[0]; expect(added).toBeTruthy()
  123. await action(page, 'focus').click(); await settle(page)
  124. await page.keyboard.press('w'); expect((await diagnostics(page)).transformMode).toBe('translate')
  125. await dragGizmo(page, 'X', 85, -12)
  126. const moved = object(await diagnostics(page), added.sceneId); expect(moved.position).not.toEqual(added.position); expect(moved.position[0] * 2).toBeCloseTo(Math.round(moved.position[0] * 2), 4)
  127. await page.keyboard.press('e'); expect((await diagnostics(page)).transformMode).toBe('rotate')
  128. await dragGizmo(page, 'Y', 70, 45)
  129. const rotated = object(await diagnostics(page), added.sceneId); expect(rotated.rotation).not.toEqual(moved.rotation); expect(rotated.rotation[1] / (Math.PI / 12)).toBeCloseTo(Math.round(rotated.rotation[1] / (Math.PI / 12)), 4)
  130. await action(page, 'snap').click(); await expect(page.locator('#se-status-snap')).toHaveText('关闭')
  131. await page.keyboard.press('r'); expect((await diagnostics(page)).transformMode).toBe('scale')
  132. await dragGizmo(page, 'X', 70, -20)
  133. expect(object(await diagnostics(page), added.sceneId).scale[0]).toBe(.000001)
  134. await action(page, 'undo').click()
  135. await expect.poll(async () => object(await diagnostics(page), added.sceneId).scale).toEqual(rotated.scale)
  136. await dragGizmo(page, 'X', -30, 10)
  137. const scaled = object(await diagnostics(page), added.sceneId); expect(scaled.scale).not.toEqual(rotated.scale); expect(scaled.scale.every((v: number) => v > 0)).toBe(true)
  138. await action(page, 'snap').click(); await expect(page.locator('#se-status-snap')).toHaveText('0.5m')
  139. await shot(page, info, '43-real-transform-gizmo')
  140. await save(page); await page.reload(); await ready(page)
  141. const reopened = object(await diagnostics(page), added.sceneId)
  142. expect(reopened.position).toEqual(scaled.position); expect(reopened.rotation).toEqual(scaled.rotation); expect(reopened.scale).toEqual(scaled.scale)
  143. await expect(page.getByText('旧版本恢复稿', { exact: false })).toBeHidden()
  144. evidence('viewport-transform', { status: 'PASS', kind: '真实UI手柄+只读几何投影定位', projectId: copy.project.id, featureIds: ['transform.translate','transform.rotate','transform.scale','transform.keys','transform.snap'], before: added, after: reopened, screenshots: ['43-real-transform-gizmo'] })
  145. })
  146. test('缓存标签暂停键盘、草稿恢复以及关闭取消和保存失败留页', async ({ page }, info) => {
  147. const token = await signIn(page), copy = await freshScene(token, 'fox', '缓存关闭')
  148. await openEditor(page, copy.project.id)
  149. const fox = (await diagnostics(page)).objects.find((o: any) => o.targetProjectId === '157')
  150. await page.locator(`#se-tree [data-object-id="${fox.sceneId}"]`).click()
  151. await field(page, 'name').fill('缓存未保存狐狸'); await field(page, 'name').press('Tab')
  152. const dirty = await diagnostics(page); expect(dirty.dirty).toBe(true)
  153. await page.locator('.page-tab-link').filter({ hasText: '工作总览' }).click()
  154. await expect.poll(async () => (await diagnostics(page))?.paused).toBe(true)
  155. await page.keyboard.press('Delete'); await page.keyboard.press('w'); await page.keyboard.press('e'); await page.keyboard.press('r'); await page.keyboard.press('Control+z')
  156. expect((await diagnostics(page)).objects).toEqual(dirty.objects)
  157. await page.locator('.page-tab-link').filter({ hasText: '数字场景编辑' }).click(); await ready(page)
  158. expect((await diagnostics(page)).paused).toBe(false); expect((await diagnostics(page)).dirty).toBe(true); await expect(field(page, 'name')).toHaveValue('缓存未保存狐狸')
  159. await page.locator('.page-tab.active .page-tab-close').click()
  160. await expect(page.getByRole('dialog')).toBeVisible(); await page.keyboard.press('Escape')
  161. await expect(page.locator('.se-shell')).toBeVisible(); expect((await diagnostics(page)).dirty).toBe(true)
  162. await page.route(`**/api/tran/v1/content/projects/${copy.project.id}`, async route => {
  163. if (route.request().method() === 'PUT') return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'SERVICE_UNAVAILABLE', message: '视口关闭回归:模拟保存失败', data: null }) })
  164. return route.continue()
  165. })
  166. await page.locator('.page-tab.active .page-tab-close').click()
  167. await page.getByRole('button', { name: '关闭标签', exact: true }).click()
  168. await page.getByRole('button', { name: '保存并离开', exact: true }).click()
  169. await expect(page.locator('.se-toast.warn').filter({ hasText: '模拟保存失败' })).toBeVisible()
  170. await expect(page.locator('.se-shell')).toBeVisible(); expect((await diagnostics(page)).dirty).toBe(true)
  171. await expect(page.locator('.page-tab-link').filter({ hasText: '数字场景编辑' })).toBeVisible()
  172. await shot(page, info, '44-cache-close-save-failure')
  173. evidence('viewport-cache-close', { status: 'PASS', kind: '真实UI缓存/关闭+浏览器模拟503拒绝写入', projectId: copy.project.id, featureIds: ['lifecycle.cache'], screenshot: '44-cache-close-save-failure', originalDraftPreserved: true })
  174. })