Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

269 wiersze
17 KiB

  1. import path from 'node:path'
  2. import { expect, test, type Frame, type Page } from '@playwright/test'
  3. import { call, closeSession, evidence, fixture, openEditor, readyFrame, saveEditor, selectMesh, shot, signIn, work, writeFixture } from './model-editor-closure-helpers'
  4. // These tests own independent server copies. No shared fox/truck draft is edited.
  5. // All editor changes use real DOM inputs/buttons/pointers; evaluate() only reads state.
  6. test.setTimeout(360_000)
  7. test.afterEach(async ({ page }) => { await closeSession(page) })
  8. async function copyFor(token: string, name: string) {
  9. const sourceId = String(fixture().originals.fox.id)
  10. const source = await call(`tran/v1/content/projects/${sourceId}`, token)
  11. expect(source.status, source.message).toBe(200)
  12. const copied = await call(`tran/v1/content/projects/${sourceId}/copy`, token, {
  13. name: `闭环补充-${name}-${Date.now().toString(36)}`, version: source.data.project.version,
  14. })
  15. expect(copied.status, copied.message).toBe(200)
  16. const id = String(copied.data.project.id)
  17. expect([sourceId, '156', '157']).not.toContain(id)
  18. const data = fixture()
  19. data.extraProjectIds = [...new Set([...(data.extraProjectIds || []), id])]
  20. data.navigationCopies = { ...(data.navigationCopies || {}), [name]: id }
  21. writeFixture(data)
  22. return id
  23. }
  24. async function persisted(page: Page, frame: Frame, token: string, id: string) {
  25. const response = page.waitForResponse(response => response.request().method() === 'PUT'
  26. && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`), { timeout: 60_000 })
  27. await saveEditor(frame)
  28. expect((await response).status()).toBe(200)
  29. const saved = await call(`tran/v1/content/projects/${id}`, token)
  30. expect(saved.status, saved.message).toBe(200)
  31. return saved.data
  32. }
  33. async function activate(frame: Frame, tab: string) {
  34. await frame.locator(`[data-bottom-tab="${tab}"]`).click()
  35. await expect(frame.locator(`[data-mode="${tab}"]`)).toHaveClass(/active/)
  36. }
  37. test('补充资源库:搜索清空、真实卡片切换与刷新保留', async ({ page }, info) => {
  38. const token = await signIn(page), id = await copyFor(token, '资源库')
  39. let frame = await openEditor(page, id)
  40. const original = await frame.evaluate(() => {
  41. const e = (window as any).editorApp
  42. return { id: e.activeDescriptor.id, name: e.activeDescriptor.name, code: e.activeDescriptor.assetCode }
  43. })
  44. await frame.locator('[data-left-tab="assets"]').click()
  45. const search = frame.locator('#left-search')
  46. await search.fill(original.name)
  47. await expect(frame.locator(`[data-model="${original.id}"]`)).toBeVisible()
  48. await search.fill('NO-MATCH-资源搜索-193746')
  49. await expect(frame.locator('.asset-search-empty')).toBeVisible()
  50. await expect(frame.locator('.asset-card:visible')).toHaveCount(0)
  51. await search.fill('')
  52. await expect(frame.locator('.asset-search-empty')).toBeHidden()
  53. expect(await frame.locator('.asset-card:visible').count()).toBeGreaterThan(3)
  54. await shot(page, info, '41-resource-search-restored')
  55. // A small controlled model gives the real catalog a second registered asset.
  56. await frame.locator('#model-file-input').setInputFiles(path.resolve(work, '../../文档/素材/01-Box.glb'))
  57. await frame.waitForFunction(() => {
  58. const e = (window as any).editorApp
  59. return e.activeDescriptor?.sourceName === '01-Box.glb' && e.apiState === 'ready' && !e.apiImportBusy && !e.apiCoverSavePending
  60. }, undefined, { timeout: 90_000 })
  61. const boxId = await frame.evaluate(() => (window as any).editorApp.activeDescriptor.id)
  62. expect(boxId).not.toBe(original.id)
  63. await expect(frame.locator(`[data-model="${original.id}"]`)).toBeVisible()
  64. const switched = page.waitForResponse(response => response.request().method() === 'PUT'
  65. && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`), { timeout: 60_000 })
  66. await frame.locator(`[data-model="${original.id}"]`).click()
  67. expect((await switched).status()).toBe(200)
  68. await expect.poll(() => frame.evaluate(() => {
  69. const e = (window as any).editorApp
  70. return { id: e.activeDescriptor.id, ready: e.apiState === 'ready' && !e.apiSession.savePending }
  71. })).toEqual({ id: original.id, ready: true })
  72. await page.reload(); frame = await readyFrame(page)
  73. const restored = await frame.evaluate(() => {
  74. const e = (window as any).editorApp; let meshes = 0
  75. e.modelPivot.traverse((o: any) => { if (o.isMesh) meshes++ })
  76. return { id: e.activeDescriptor.id, code: e.activeDescriptor.assetCode, meshes }
  77. })
  78. expect(restored.id).toBe(original.id)
  79. expect(restored.code).toBe(original.code)
  80. expect(restored.meshes).toBeGreaterThan(0)
  81. await shot(page, info, '42-resource-card-switch-reopened')
  82. evidence('41-resource-search-switch', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  83. featureIds: ['resource.search', 'resource.catalog'], projectId: id, original, boxId, restored,
  84. scope: '真实搜索与资源卡片点击、服务端保存、刷新后原模型网格;副本独立登记待清理。' })
  85. })
  86. test('补充环境与树:内置三环境保存重开、节点计数与折叠展开', async ({ page }, info) => {
  87. const token = await signIn(page), id = await copyFor(token, '内置环境')
  88. let frame = await openEditor(page, id)
  89. const snapshots: unknown[] = []
  90. for (const [index, type] of ['workshop', 'outdoor', 'depot'].entries()) {
  91. await frame.locator('[data-left-tab="assets"]').click()
  92. await frame.locator('#left-search').fill('')
  93. await frame.locator(`[data-environment-action="builtin"][data-environment-type="${type}"]`).click()
  94. await frame.waitForFunction(type => {
  95. const e = (window as any).editorApp
  96. return e.outdoorSceneMeta.environmentType === type && e.outdoorSceneMeta.mode === 'placeholder'
  97. && e.loading.classList.contains('hidden')
  98. }, type, { timeout: 90_000 })
  99. const saved = await persisted(page, frame, token, id)
  100. expect(saved.currentVersion.content.environment.environmentType).toBe(type)
  101. await page.reload(); frame = await readyFrame(page)
  102. const environment = await frame.evaluate(() => {
  103. const e = (window as any).editorApp; let nodes = 0, meshes = 0
  104. e.outdoorRoot.traverse((o: any) => { nodes++; if (o.isMesh) meshes++ })
  105. return { type: e.outdoorSceneMeta.environmentType, mode: e.outdoorSceneMeta.mode,
  106. sourceKind: e.outdoorRoot.userData.sourceKind, nodes, meshes, id: e.outdoorRoot.uuid }
  107. })
  108. expect(environment.type).toBe(type)
  109. expect(environment.mode).toBe('placeholder')
  110. expect(environment.meshes).toBeGreaterThan(0)
  111. if (type === 'depot') expect(environment.sourceKind).toBe('builtin-gltf')
  112. await frame.locator('[data-left-tab="scene"]').click()
  113. await frame.locator('#left-search').fill('')
  114. const beforeCount = await frame.locator('.tree-row').count()
  115. const rootRow = frame.locator(`[data-object-id="${environment.id}"]`)
  116. await rootRow.locator('.tree-caret').click()
  117. expect(await frame.locator('.tree-row').count()).toBeLessThan(beforeCount)
  118. await rootRow.locator('.tree-caret').click()
  119. await expect(frame.locator('.tree-row')).toHaveCount(beforeCount)
  120. const actualCount = await frame.evaluate(() => {
  121. const e = (window as any).editorApp; let count = 0
  122. e.getEditableRoots().forEach((root: any) => root.traverse(() => count++))
  123. return count
  124. })
  125. await expect(frame.locator('#scene-count')).toHaveText(`${actualCount} 对象`)
  126. await shot(page, info, `${43 + index}-builtin-${type}-reopened`)
  127. snapshots.push({ ...environment, shownRows: beforeCount, totalNodes: actualCount })
  128. }
  129. const beforeLeave = await persisted(page, frame, token, id)
  130. expect(beforeLeave.currentVersion.content.environment.environmentType).toBe('depot')
  131. await page.getByRole('link', { name: '模型制作', exact: true }).click()
  132. await expect(page).toHaveURL(/\/content\/models$/)
  133. await expect(page.locator('.el-message-box').filter({ hasText: '当前模型有未保存修改' })).toBeHidden()
  134. const afterLeave = await call(`tran/v1/content/projects/${id}`, token)
  135. expect(afterLeave.status, afterLeave.message).toBe(200)
  136. expect(afterLeave.data.project.version).toBe(beforeLeave.project.version)
  137. expect(afterLeave.data.currentVersion.content.environment.environmentType).toBe('depot')
  138. await shot(page, info, '66-depot-saved-return-list')
  139. evidence('43-builtin-environments-tree', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  140. featureIds: ['environment.builtin', 'tree.browse', 'tree.expand', 'project.leave'], projectId: id, snapshots,
  141. depotSavedLeave: { promptedForUnsavedChanges: false, version: afterLeave.data.project.version },
  142. scope: '真实切换、保存和刷新三种环境;树计数与展开折叠;装备车库保存后返回列表不误报未保存。树220行上限仍属已说明边界。' })
  143. })
  144. test('补充视口与工作区:鼠标镜头、聚焦、面板、预览导航、日志与自动保存', async ({ page }, info) => {
  145. const token = await signIn(page), id = await copyFor(token, '视口导航')
  146. let frame = await openEditor(page, id)
  147. const renamed = `闭环导航名称-${id}`
  148. const renamedResponse = page.waitForResponse(response => response.request().method() === 'PUT'
  149. && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`))
  150. await frame.locator('#project-name').fill(renamed)
  151. await frame.locator('#project-name').press('Enter')
  152. expect((await renamedResponse).status()).toBe(200)
  153. const mesh = await selectMesh(frame)
  154. const identity = await frame.evaluate(() => {
  155. const e = (window as any).editorApp
  156. return { selectedId: e.selected.userData.editorId, localPosition: e.selected.position.toArray() }
  157. })
  158. expect(identity.selectedId).toBe(mesh.id)
  159. await frame.locator('[data-selection-scope="whole"]').click()
  160. expect(await frame.evaluate(() => {
  161. const e = (window as any).editorApp
  162. return e.selectionScope === 'whole' && e.selected === e.modelPivot
  163. })).toBe(true)
  164. await selectMesh(frame)
  165. expect(await frame.evaluate(() => (window as any).editorApp.selectionScope)).toBe('part')
  166. await expect(frame.locator('.property-row').filter({ hasText: '标识' }).locator('input')).toHaveAttribute('readonly', '')
  167. await expect(frame.locator('.property-row').filter({ hasText: '流程绑定' }).locator('input')).toHaveAttribute('readonly', '')
  168. await frame.locator('[data-inspector-tab="components"]').click()
  169. await expect(frame.locator('.component-card').filter({ hasText: 'Transform' })).toBeVisible()
  170. await expect(frame.locator('.component-card').filter({ hasText: 'MeshRenderer' })).toBeVisible()
  171. await expect(frame.locator('.component-card').filter({ hasText: 'Shadow' })).toBeVisible()
  172. expect(await frame.evaluate(() => (window as any).editorApp.selected.isMesh)).toBe(true)
  173. await frame.locator('[data-inspector-tab="properties"]').click()
  174. await frame.locator('[data-action="focus"]').first().click()
  175. const camera = () => frame.evaluate(() => {
  176. const e = (window as any).editorApp
  177. return { position: e.camera.position.toArray(), target: e.orbit.target.toArray(), distance: e.camera.position.distanceTo(e.orbit.target) }
  178. })
  179. const focused = await camera()
  180. expect(focused.distance).toBeGreaterThan(0)
  181. // Focus target is the object's bounds center, which may differ from its origin.
  182. // The orbit target itself must be centered and inside the camera frustum.
  183. const targetScreen = await frame.evaluate(() => {
  184. const e = (window as any).editorApp
  185. return e.orbit.target.clone().project(e.camera).toArray()
  186. })
  187. expect(Math.abs(targetScreen[0])).toBeLessThan(0.01)
  188. expect(Math.abs(targetScreen[1])).toBeLessThan(0.01)
  189. const canvas = frame.locator('#render-host canvas'), box = await canvas.boundingBox()
  190. expect(box).not.toBeNull()
  191. const x = box!.x + box!.width * 0.16, y = box!.y + box!.height * 0.74
  192. await page.mouse.move(x, y); await page.mouse.down()
  193. await page.mouse.move(x + 90, y - 20, { steps: 12 }); await page.mouse.up()
  194. await expect.poll(async () => JSON.stringify((await camera()).position)).not.toBe(JSON.stringify(focused.position))
  195. const rotated = await camera()
  196. await page.mouse.move(x, y); await page.mouse.down({ button: 'right' })
  197. await page.mouse.move(x + 45, y + 25, { steps: 10 }); await page.mouse.up({ button: 'right' })
  198. await expect.poll(async () => JSON.stringify((await camera()).target)).not.toBe(JSON.stringify(rotated.target))
  199. const panned = await camera()
  200. await page.mouse.move(x, y); await page.mouse.wheel(0, -220)
  201. await expect.poll(async () => (await camera()).distance).toBeLessThan(panned.distance)
  202. expect(await frame.evaluate(() => (window as any).editorApp.selected.position.toArray())).toEqual(identity.localPosition)
  203. const panelModes = []
  204. for (const mode of ['timeline', 'blueprint', 'curve', 'console']) {
  205. await activate(frame, mode)
  206. panelModes.push(await frame.evaluate(() => (window as any).editorApp.bottomMode))
  207. }
  208. expect(panelModes).toEqual(['timeline', 'blueprint', 'curve', 'console'])
  209. await expect(frame.locator('#console-root .log-row').first()).toBeVisible()
  210. const logCount = await frame.evaluate(() => (window as any).editorApp.consoleRows.length)
  211. expect(logCount).toBeGreaterThan(0)
  212. await expect(frame.locator('#log-badge')).toHaveText(String(logCount))
  213. const splitter = await frame.locator('#splitter-h').boundingBox()
  214. const beforeHeight = await frame.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bottom-h'))
  215. await page.mouse.move(splitter!.x + splitter!.width / 2, splitter!.y + splitter!.height / 2)
  216. await page.mouse.down(); await page.mouse.move(splitter!.x + splitter!.width / 2, splitter!.y - 35, { steps: 8 }); await page.mouse.up()
  217. expect(await frame.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bottom-h'))).not.toBe(beforeHeight)
  218. await activate(frame, 'timeline')
  219. await canvas.click({ position: { x: 15, y: 120 } })
  220. await page.keyboard.press('Tab')
  221. await expect(frame.locator('[data-mode="blueprint"]')).toHaveClass(/active/)
  222. await activate(frame, 'timeline')
  223. await frame.locator('#timeline-root [data-action="stop"]').click()
  224. const duration = await frame.evaluate(() => (window as any).editorApp.timeline.duration)
  225. await frame.locator('[data-action="preview"]').first().click()
  226. await frame.locator('[data-preview-action="next"]').click()
  227. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(Math.min(duration, 2))
  228. await frame.locator('[data-preview-action="previous"]').click()
  229. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0)
  230. await shot(page, info, '46-preview-navigation')
  231. await page.keyboard.press('Escape')
  232. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.preview)).toBe(false)
  233. await selectMesh(frame)
  234. const autoValue = `AUTO-SAVE-${id}`
  235. const automaticResponse = page.waitForResponse(response => response.request().method() === 'PUT'
  236. && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`)
  237. && String(response.request().postData() || '').includes(autoValue), { timeout: 55_000 })
  238. await frame.locator('#custom-resource-code').fill(autoValue)
  239. await frame.locator('#custom-resource-code').press('Tab')
  240. // Do not click save: this explicitly waits for the real 30-second timer.
  241. expect((await automaticResponse).status()).toBe(200)
  242. await expect.poll(() => frame.evaluate(() => !(window as any).editorApp.apiSession.savePending)).toBe(true)
  243. await page.reload(); frame = await readyFrame(page)
  244. await selectMesh(frame)
  245. await expect(frame.locator('#project-name')).toHaveValue(renamed)
  246. await expect(frame.locator('#custom-resource-code')).toHaveValue(autoValue)
  247. await expect.poll(() => frame.locator('#fps-status').innerText()).toMatch(/^[1-9]\d*$/)
  248. const stats = await frame.evaluate(() => ({ objects: document.querySelector('#object-status')?.textContent,
  249. triangles: document.querySelector('#triangle-status')?.textContent, fps: document.querySelector('#fps-status')?.textContent }))
  250. expect(stats.objects).toMatch(/\d+ 节点 \/ \d+ 网格/)
  251. expect(Number(stats.triangles?.replaceAll(',', ''))).toBeGreaterThan(0)
  252. await activate(frame, 'console')
  253. await shot(page, info, '47-navigation-autosave-log')
  254. evidence('46-navigation-panels-autosave', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  255. featureIds: ['project.name', 'project.autosave', 'view.orbit', 'view.focus', 'view.panels', 'view.preview-step', 'feedback.log', 'feedback.stats', 'property.identity', 'property.binding', 'selection.scope', 'component.inspect'],
  256. projectId: id, focused, rotated, panned, focusTargetScreen: targetScreen, panelModes, logCount, stats,
  257. scope: '真实鼠标镜头操作;聚焦只验证相机目标;真实30秒自动保存,无测试加速定时器;统计内存估值未当作性能结论。' })
  258. })