選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

251 行
18 KiB

  1. import { test, expect, type Page } from '@playwright/test'
  2. import {
  3. action, call, closeSession, diagnostics, evidence, exportDocument,
  4. freshScene, openEditor, ready, save, shot, signIn,
  5. } from './scene-editor-closure-helpers'
  6. const templates = [
  7. { id: 'depot-1', name: '真实装备车库', type: '车间', count: 1, builtIn: 'vehicle-depot' },
  8. { id: 'terrain-1', name: '真实野外训练场', type: '室外', count: 4, builtIn: 'field-terrain' },
  9. { id: 'workshop-1', name: '综合检验车间', type: '车间', count: 0 },
  10. { id: 'workshop-real', name: '演示维修车间(可拆分)', type: '车间', count: 74, builtIn: 'maintenance-workshop' },
  11. { id: 'workshop-2', name: '总成拆卸车间', type: '车间', count: 3, types: ['equipment', 'workbench', 'shelf'] },
  12. { id: 'workshop-3', name: '部件分解车间', type: '车间', count: 3, types: ['workbench', 'shelf', 'camera'] },
  13. { id: 'workshop-4', name: '清洗检测车间', type: '车间', count: 4, types: ['diagnostic', 'workbench', 'camera', 'safety-zone'] },
  14. { id: 'workshop-5', name: '复装调试车间', type: '车间', count: 4, types: ['diagnostic', 'workbench', 'light', 'worker'] },
  15. { id: 'outdoor-1', name: '室外综合训练场', type: '室外', count: 3, types: ['equipment', 'safety-zone', 'light'] },
  16. ]
  17. const basics = ['diagnostic', 'equipment', 'workbench', 'shelf', 'safety-zone', 'camera', 'worker', 'light']
  18. const tab = (page: Page, name: string) => page.locator(`[data-left-tab="${name}"]`)
  19. const card = (page: Page, id: string) => page.locator(`[data-preset-id="${id}"]`)
  20. const field = (page: Page, name: string) => page.locator(`[data-inspector-field="${name}"]`)
  21. async function selectTemplate(page: Page, id: string) {
  22. await tab(page, 'scenes').click()
  23. await page.locator('#se-search').fill('')
  24. await card(page, id).click()
  25. const dialog = page.getByRole('dialog', { name: '切换场景模板' })
  26. if (await dialog.isVisible()) await dialog.getByRole('button', { name: '放弃修改并切换' }).click()
  27. await expect.poll(async () => {
  28. const d = await diagnostics(page)
  29. return d?.currentPreset?.id === id && d.pendingLoads === 0
  30. }, { timeout: 120_000 }).toBe(true)
  31. await expect(card(page, id)).toHaveClass(/active/)
  32. }
  33. async function getSaved(token: string, id: string) {
  34. const response = await call(`tran/v1/content/projects/${id}`, token)
  35. expect(response.status).toBe(200)
  36. expect(response.data.currentVersion).toBeTruthy()
  37. return response.data.currentVersion
  38. }
  39. async function realDrag(page: Page, source: ReturnType<Page['locator']>, x = 0.48, y = 0.57) {
  40. await source.scrollIntoViewIfNeeded()
  41. const start = await source.boundingBox(), target = await page.locator('#se-viewport canvas').boundingBox()
  42. expect(start).not.toBeNull(); expect(target).not.toBeNull()
  43. await page.mouse.move(start!.x + start!.width / 2, start!.y + start!.height / 2)
  44. await page.mouse.down()
  45. await page.mouse.move(start!.x + start!.width / 2 + 12, start!.y + start!.height / 2, { steps: 3 })
  46. await page.mouse.move(target!.x + target!.width * x, target!.y + target!.height * y, { steps: 12 })
  47. await page.mouse.up()
  48. }
  49. async function clearSelectionForScreenshot(page: Page) {
  50. const canvas = page.locator('#se-viewport canvas'), box = await canvas.boundingBox()
  51. expect(box).not.toBeNull()
  52. await canvas.click({ position: { x: box!.width - 28, y: 95 } })
  53. await expect.poll(async () => (await diagnostics(page)).selectedSceneId).toBe('')
  54. await expect(page.locator('.se-toast, .el-message')).toHaveCount(0)
  55. }
  56. test.afterEach(async ({ page }) => { await closeSession(page) })
  57. for (const [index, preset] of templates.entries()) {
  58. test(`场景模板实际加载、保存重开:${preset.name}`, async ({ page }, info) => {
  59. const token = await signIn(page), created = await freshScene(token, 'fox', preset.id)
  60. const id = created.project.id
  61. await openEditor(page, id)
  62. await selectTemplate(page, preset.id)
  63. const loaded = await diagnostics(page)
  64. expect(loaded.currentPreset).toEqual({ id: preset.id, name: preset.name, type: preset.type })
  65. expect(loaded.objectCount).toBe(preset.count)
  66. if (preset.types) expect(loaded.objects.map((o: any) => o.type).sort()).toEqual([...preset.types].sort())
  67. await expect(page.locator('#se-preset-badge')).toContainText(`${preset.type}环境`)
  68. await save(page)
  69. const saved = await getSaved(token, id)
  70. expect(saved.content.presetId).toBe(preset.id)
  71. expect(saved.content.environment.presetId).toBe(preset.id)
  72. if (preset.builtIn) expect(saved.content.objects.some((o: any) => o.builtinAssetId === preset.builtIn)).toBe(true)
  73. await page.reload(); await ready(page)
  74. const reopened = await diagnostics(page)
  75. expect(reopened.currentPreset.id).toBe(preset.id)
  76. expect(reopened.objectCount).toBe(preset.count)
  77. expect(reopened.dirty).toBe(false)
  78. await tab(page, 'scenes').click()
  79. await expect(card(page, preset.id)).toHaveClass(/active/)
  80. await expect(page.locator('#se-preset-badge')).toContainText(`${preset.type}环境`)
  81. const name = `${20 + index}-template-${preset.id}`
  82. await shot(page, info, name)
  83. evidence(`template-${preset.id}`, { kind: 'e2e', status: 'PASS', testTitle: info.title,
  84. featureIds: [`template.${preset.id}`], projectId: id, presetId: preset.id,
  85. objectCount: reopened.objectCount, bounds: reopened.contentBounds,
  86. scope: '真实模板按钮、选中标签、环境字段、种子对象、PUT 后刷新重开;截图验证对应模板实际画面。', screenshots: [`${name}.png`] })
  87. })
  88. }
  89. test('模板搜索与未保存切换确认保留和放弃', async ({ page }, info) => {
  90. const token = await signIn(page), created = await freshScene(token, 'fox', 'template-confirm')
  91. await openEditor(page, created.project.id)
  92. const original = await diagnostics(page), root = original.objects.find((o: any) => o.targetProjectId)
  93. await page.locator(`[data-object-id="${root.sceneId}"]`).first().click()
  94. await field(page, 'name').fill('模板确认保留对象'); await field(page, 'name').press('Tab')
  95. await tab(page, 'scenes').click()
  96. await page.locator('#se-search').fill('清洗')
  97. await expect(page.locator('.se-scene-card:visible')).toHaveCount(1)
  98. await expect(card(page, 'workshop-4')).toBeVisible()
  99. await page.locator('#se-search').fill('无匹配模板-closure')
  100. await expect(page.locator('.se-scene-card:visible')).toHaveCount(0)
  101. await page.locator('#se-search').fill(''); await expect(page.locator('.se-scene-card:visible')).toHaveCount(9)
  102. await card(page, 'workshop-1').click()
  103. const dialog = page.getByRole('dialog', { name: '切换场景模板' })
  104. await expect(dialog).toBeVisible()
  105. await dialog.getByRole('button', { name: '继续编辑' }).click()
  106. expect((await diagnostics(page)).currentPreset.id).toBe(original.currentPreset.id)
  107. expect((await diagnostics(page)).objects.some((o: any) => o.name === '模板确认保留对象')).toBe(true)
  108. await card(page, 'workshop-1').click(); await dialog.getByRole('button', { name: '放弃修改并切换' }).click()
  109. await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(0)
  110. await expect(card(page, 'workshop-1')).toHaveClass(/active/)
  111. await save(page)
  112. const saved = await getSaved(token, created.project.id)
  113. expect(saved.content.presetId).toBe('workshop-1')
  114. await shot(page, info, '29-template-confirm-empty')
  115. evidence('template-confirm', { kind: 'e2e', status: 'PASS', testTitle: info.title,
  116. featureIds: ['panel.left-tabs', 'search.templates', 'template.switch-cancel'], projectId: created.project.id,
  117. scope: '中文/无匹配/清空搜索;真实取消保留对象,真实确认放弃并切换空白模板后保存。', screenshots: ['29-template-confirm-empty.png'] })
  118. })
  119. test('八种基础资源逐项放置、真实拖放、搜索和保存重开', async ({ page }, info) => {
  120. const token = await signIn(page), created = await freshScene(token, 'fox', 'basic-assets')
  121. await openEditor(page, created.project.id); await selectTemplate(page, 'workshop-1')
  122. await tab(page, 'assets').click()
  123. await page.locator('#se-search').fill('检修工作台')
  124. await expect(page.locator('.se-asset:visible')).toHaveCount(1)
  125. await expect(page.locator('[data-asset-id="workbench"]')).toBeVisible()
  126. await page.locator('#se-search').fill('无匹配资源-closure')
  127. await expect(page.locator('.se-asset:visible')).toHaveCount(0)
  128. await page.locator('#se-search').fill('')
  129. for (const [index, id] of basics.entries()) {
  130. await page.locator(`[data-asset-id="${id}"]`).dblclick()
  131. await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(index + 1)
  132. await field(page, 'x').fill(String((index % 4) * 4 - 6)); await field(page, 'x').press('Tab')
  133. await field(page, 'z').fill(String(Math.floor(index / 4) * 5 - 2)); await field(page, 'z').press('Tab')
  134. }
  135. await realDrag(page, page.locator('[data-asset-id="workbench"]'), 0.7, 0.65)
  136. await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(9)
  137. const placed = await diagnostics(page), dragged = placed.objects.at(-1)
  138. expect(dragged.type).toBe('workbench')
  139. expect(dragged.position.some((v: number) => Math.abs(v) > 0.1)).toBe(true)
  140. for (const axis of [0, 2]) expect(dragged.position[axis] * 2).toBeCloseTo(Math.round(dragged.position[axis] * 2), 5)
  141. await expect(page.locator('#se-drop-hint')).not.toHaveClass(/show/)
  142. expect(new Set(placed.objects.map((o: any) => o.sceneId)).size).toBe(9)
  143. expect(new Set(placed.objects.map((o: any) => o.deviceId)).size).toBe(9)
  144. await save(page); await page.reload(); await ready(page)
  145. const reopened = await diagnostics(page)
  146. expect(reopened.objects.map((o: any) => ({ type: o.type, position: o.position, deviceId: o.deviceId })))
  147. .toEqual(placed.objects.map((o: any) => ({ type: o.type, position: o.position, deviceId: o.deviceId })))
  148. await tab(page, 'outline').click()
  149. await expect(page.locator('.se-object-summary button')).toHaveCount(6)
  150. await expect(page.locator('.se-object-summary-note')).toContainText('共 9 个对象,此处显示前 6 个')
  151. const firstSummary = page.locator('.se-object-summary button').first()
  152. const firstSummaryId = await firstSummary.getAttribute('data-object-id')
  153. await firstSummary.click()
  154. expect((await diagnostics(page)).selectedSceneId).toBe(firstSummaryId)
  155. await expect(field(page, 'name')).toHaveValue(reopened.objects.find((o: any) => o.sceneId === firstSummaryId).name)
  156. await shot(page, info, '30-eight-basic-assets-reopened')
  157. evidence('basic-assets', { kind: 'e2e', status: 'PASS', testTitle: info.title,
  158. featureIds: [...basics.map(id => `asset.${id}`), 'assets.basic-double-click', 'assets.basic-drop', 'search.assets', 'panel.objects'],
  159. projectId: created.project.id, dragged: { position: dragged.position, type: dragged.type },
  160. scope: '8 种基础资源分别双击放置并分开布置;真实指针拖入第 9 个工作台、吸附检查、唯一编号、保存刷新一致。照明为现有资源外观,不冒称连接真实灯控。', screenshots: ['30-eight-basic-assets-reopened.png'] })
  161. })
  162. test('两种整体场地加载、重复放置阻止与显式删除后替换', async ({ page }, info) => {
  163. const token = await signIn(page), created = await freshScene(token, 'fox', 'venue-assets')
  164. await openEditor(page, created.project.id); await selectTemplate(page, 'workshop-1')
  165. await tab(page, 'assets').click()
  166. await page.locator('[data-asset-id="vehicle-depot"]').dblclick()
  167. await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 1 && d.pendingLoads === 0 }, { timeout: 120000 }).toBe(true)
  168. const depot = await exportDocument(page)
  169. expect(depot.objects.some((o: any) => o.builtinAssetId === 'vehicle-depot')).toBe(true)
  170. await page.locator('[data-asset-id="field-terrain"]').dblclick()
  171. expect((await diagnostics(page)).objectCount).toBe(1)
  172. expect((await exportDocument(page)).objects.some((o: any) => o.builtinAssetId === 'field-terrain')).toBe(false)
  173. await save(page); await page.reload(); await ready(page)
  174. await shot(page, info, '31-venue-depot-reopened')
  175. const root = (await diagnostics(page)).objects[0]
  176. await page.locator(`[data-object-id="${root.sceneId}"]`).first().click()
  177. await action(page, 'delete').click()
  178. await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(0)
  179. await tab(page, 'assets').click(); await page.locator('[data-asset-id="field-terrain"]').dblclick()
  180. await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 1 && d.pendingLoads === 0 }, { timeout: 120000 }).toBe(true)
  181. await save(page); await page.reload(); await ready(page)
  182. const saved = await getSaved(token, created.project.id)
  183. expect(saved.content.objects.some((o: any) => o.builtinAssetId === 'field-terrain')).toBe(true)
  184. expect(saved.content.objects.some((o: any) => o.builtinAssetId === 'vehicle-depot')).toBe(false)
  185. const terrain = await diagnostics(page)
  186. expect(terrain.contentBounds.size[1]).toBeGreaterThan(1)
  187. await shot(page, info, '32-venue-terrain-reopened')
  188. evidence('venue-assets', { kind: 'e2e', status: 'PASS', testTitle: info.title,
  189. featureIds: ['asset.vehicle-depot', 'asset.field-terrain', 'object.delete'], projectId: created.project.id,
  190. scope: '车库和起伏地形真实加载并分别保存重开;已有整体场地时阻止叠加,显式删除旧场地后才能替换。', screenshots: ['31-venue-depot-reopened.png', '32-venue-terrain-reopened.png'] })
  191. })
  192. test('模型成果狐狸卡车双击与真实拖入、树搜索和部件隐藏重开', async ({ page }, info) => {
  193. const token = await signIn(page), created = await freshScene(token, 'truck', 'published-model-assets')
  194. await openEditor(page, created.project.id); await selectTemplate(page, 'workshop-1')
  195. await tab(page, 'assets').click()
  196. const fox = page.locator('.se-editor-asset').filter({ hasText: /狐狸/ }).first()
  197. const truck = page.locator('.se-editor-asset').filter({ hasText: /牛奶卡车/ }).first()
  198. await expect(fox).toContainText('已发布'); await expect(truck).toContainText('已发布')
  199. const foxProjectId = await fox.getAttribute('data-target-project-id'), foxVersionId = await fox.getAttribute('data-target-version-id')
  200. const truckProjectId = await truck.getAttribute('data-target-project-id'), truckVersionId = await truck.getAttribute('data-target-version-id')
  201. await fox.dblclick()
  202. await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 1 && d.pendingLoads === 0 }).toBe(true)
  203. await field(page, 'x').fill('-3'); await field(page, 'x').press('Tab')
  204. await action(page, 'focus').click()
  205. await clearSelectionForScreenshot(page)
  206. await shot(page, info, '33-fox-placed-visible')
  207. await realDrag(page, truck, 0.7, 0.6)
  208. await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 2 && d.pendingLoads === 0 }).toBe(true)
  209. await field(page, 'x').fill('3'); await field(page, 'x').press('Tab')
  210. await action(page, 'focus').click()
  211. const placed = await diagnostics(page), foxRoot = placed.objects.find((o: any) => String(o.targetProjectId) === foxProjectId)
  212. const truckRoot = placed.objects.find((o: any) => String(o.targetProjectId) === truckProjectId)
  213. expect(String(foxRoot.targetVersionId)).toBe(foxVersionId); expect(String(truckRoot.targetVersionId)).toBe(truckVersionId)
  214. expect(foxRoot.parts.length).toBeGreaterThan(0); expect(truckRoot.parts.length).toBeGreaterThan(0)
  215. await tab(page, 'outline').click()
  216. await page.locator('#se-search').fill(truckRoot.name)
  217. await expect(page.locator(`.se-tree-row[data-object-id="${truckRoot.sceneId}"]`)).toBeVisible()
  218. await expect(page.locator(`.se-tree-row[data-object-id="${foxRoot.sceneId}"]`)).toHaveCount(0)
  219. const part = truckRoot.parts.find((p: any) => p.capabilities.includes('transform'))
  220. expect(part).toBeTruthy()
  221. await page.locator('#se-search').fill(part.name)
  222. await expect(page.locator(`.se-tree-row[data-object-id="${part.sceneId}"]`)).toBeVisible()
  223. await page.locator('#se-search').fill('无匹配对象-closure'); await expect(page.locator('.se-tree-row')).toHaveCount(0)
  224. await page.locator('#se-search').fill('')
  225. await page.locator(`.se-tree-row[data-object-id="${part.sceneId}"]`).click()
  226. await expect(field(page, 'visible')).toBeEnabled(); await field(page, 'visible').uncheck()
  227. await expect.poll(async () => (await diagnostics(page)).objects.find((o: any) => o.sceneId === truckRoot.sceneId).parts.find((p: any) => p.sceneId === part.sceneId).visible).toBe(false)
  228. await save(page); await page.reload(); await ready(page)
  229. const reopened = await diagnostics(page), savedTruck = reopened.objects.find((o: any) => o.sceneId === truckRoot.sceneId)
  230. expect(savedTruck.parts.find((p: any) => p.sceneId === part.sceneId).visible).toBe(false)
  231. await page.locator(`.se-tree-row[data-object-id="${part.sceneId}"]`).click()
  232. await expect(field(page, 'visible')).not.toBeChecked()
  233. // Restore the deliberately hidden body part so the retained screenshot is a complete model.
  234. await field(page, 'visible').check(); await save(page)
  235. await page.locator(`.se-tree-row[data-object-id="${truckRoot.sceneId}"]`).click(); await action(page, 'focus').click()
  236. await clearSelectionForScreenshot(page)
  237. await shot(page, info, '34-truck-placed-part-restored')
  238. const saved = await getSaved(token, created.project.id)
  239. expect(saved.dependencies.filter((d: any) => d.kind === 'SCENE_MODEL' || d.relationType === 'SCENE_MODEL').length).toBe(2)
  240. evidence('published-model-assets', { kind: 'e2e', status: 'PASS', testTitle: info.title,
  241. featureIds: ['assets.catalog', 'assets.model-double-click', 'assets.model-drop', 'search.tree', 'part.source', 'property.visible'],
  242. projectId: created.project.id, sources: [{ projectId: foxProjectId, versionId: foxVersionId }, { projectId: truckProjectId, versionId: truckVersionId }],
  243. scope: 'API 已发布目录,狐狸双击和卡车真实拖入;固定来源版本、树根/部件搜索;卡车刚体部件隐藏保存重开,最后恢复可见。基线树没有折叠按钮,不将搜索伪称折叠。', screenshots: ['33-fox-placed-visible.png', '34-truck-placed-part-restored.png'] })
  244. })