您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

645 行
31 KiB

  1. import { createHash } from 'node:crypto'
  2. import fs from 'node:fs'
  3. import { fileURLToPath } from 'node:url'
  4. import type { Locator, Page, Response, TestInfo } from '@playwright/test'
  5. import {
  6. closeContentApiSession,
  7. type CleanupRecord,
  8. type ContentApiSession,
  9. type ContentDetailRecord,
  10. openContentApiSession,
  11. readContentDetail,
  12. removeContentProject,
  13. } from './content-editor-api'
  14. import { captureScreenshot, expect, test } from './fixtures'
  15. import { chooseSelectOption, confirmMessageBox, fillFormItem, loginAsAdmin, visibleDialog } from './helpers'
  16. const runToken = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
  17. const upperToken = runToken.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-16)
  18. const defaultExcavatorPath = fileURLToPath(new URL('../../unreal_tran_web/public/models/excavator-a.glb', import.meta.url))
  19. const excavatorPath = process.env.E2E_EXCAVATOR_GLB?.trim() || defaultExcavatorPath
  20. const data = {
  21. prefix: `UTE2E-3D-${upperToken}`,
  22. model: {
  23. path: '/content/models',
  24. routeSegment: 'models',
  25. singular: '模型',
  26. code: `E2E_MODEL_${upperToken}`.slice(0, 64),
  27. name: `UTE2E 挖掘机模型 ${upperToken}`,
  28. category: '整机装备',
  29. description: `Playwright 三维模型编辑器验收数据 ${upperToken}`,
  30. },
  31. scene: {
  32. path: '/content/scenes',
  33. routeSegment: 'scenes',
  34. singular: '场景',
  35. code: `E2E_SCENE_${upperToken}`.slice(0, 64),
  36. name: `UTE2E 数字车间 ${upperToken}`,
  37. category: '维修车间',
  38. description: `Playwright 数字车间场景编辑器验收数据 ${upperToken}`,
  39. },
  40. sceneDeviceId: `EQ-E2E-${upperToken}`.slice(0, 64),
  41. modelPositionX: 1.25,
  42. scenePositionX: 2.5,
  43. } as const
  44. type ProjectSeed = typeof data.model | typeof data.scene
  45. interface ApiEnvelope<T> {
  46. data?: T
  47. }
  48. interface UploadedAssetCommand {
  49. code: string
  50. name: string
  51. type: string
  52. storageUri: string
  53. mimeType: string
  54. sizeBytes: number
  55. sha256: string
  56. metadata: Record<string, unknown>
  57. status: string
  58. sortOrder: number
  59. uploadTicket?: string
  60. }
  61. interface ModelDocument {
  62. schema?: string
  63. version?: number
  64. modelResource?: { assetCode?: string; storageUri?: string; sha256?: string }
  65. model?: { objects?: Record<string, { name?: string; position?: number[]; deleted?: boolean }> }
  66. timeline?: { tracks?: Array<{ keyframes?: unknown[] }> }
  67. blueprint?: { nodes?: unknown[]; edges?: unknown[] }
  68. }
  69. interface SceneObjectDocument {
  70. id?: string
  71. name?: string
  72. type?: string
  73. deleted?: boolean
  74. visible?: boolean
  75. position?: number[]
  76. targetProjectId?: string
  77. targetVersionId?: string
  78. assetCode?: string
  79. assetUrl?: string
  80. semantic?: { deviceId?: string }
  81. }
  82. interface SceneDocument {
  83. schema?: string
  84. version?: string
  85. objects?: SceneObjectDocument[]
  86. }
  87. const responsePath = (response: Response) => new URL(response.url()).pathname
  88. const isProjectMutation = (response: Response, method: 'PUT' | 'POST', projectId: string, suffix = '') => (
  89. response.request().method() === method
  90. && responsePath(response) === `/api/tran/v1/content/projects/${projectId}${suffix}`
  91. )
  92. const waitForProjectUpdate = (page: Page, projectId: string) => page.waitForResponse(
  93. (response) => isProjectMutation(response, 'PUT', projectId),
  94. { timeout: 60_000 },
  95. )
  96. async function expectSuccessfulResponse(responsePromise: Promise<Response>, operation: string): Promise<Response> {
  97. const response = await responsePromise
  98. expect(response.ok(), `${operation}应返回成功状态,实际 HTTP ${response.status()}`).toBeTruthy()
  99. return response
  100. }
  101. async function waitForContentList(page: Page): Promise<void> {
  102. await expect(page.locator('.content-table-panel')).toBeVisible()
  103. await expect(page.locator('.content-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 20_000 })
  104. await expect(page.locator('.content-projects-page > .el-alert--error:visible')).toHaveCount(0)
  105. }
  106. async function createContentProject(page: Page, seed: ProjectSeed): Promise<string> {
  107. await page.goto(seed.path)
  108. await waitForContentList(page)
  109. await page.getByRole('button', { name: `新建${seed.singular}`, exact: true }).click()
  110. const dialog = visibleDialog(page)
  111. await expect(dialog).toContainText(`新建${seed.singular}`)
  112. await fillFormItem(dialog, '项目编码', seed.code)
  113. await fillFormItem(dialog, '项目名称', seed.name)
  114. await chooseSelectOption(page, dialog, '业务分类', seed.category)
  115. await fillFormItem(dialog, '项目说明', seed.description)
  116. const createdResponsePromise = page.waitForResponse((response) => (
  117. response.request().method() === 'POST'
  118. && responsePath(response) === '/api/tran/v1/content/projects'
  119. ), { timeout: 30_000 })
  120. await dialog.getByRole('button', { name: '创建并编辑', exact: true }).click()
  121. const createdResponse = await expectSuccessfulResponse(createdResponsePromise, `新建${seed.singular}`)
  122. const body = await createdResponse.json() as ApiEnvelope<ContentDetailRecord>
  123. const projectId = String(body.data?.project.id ?? '')
  124. expect(projectId, `新建${seed.singular}后应返回工程 ID`).not.toBe('')
  125. await expect(page).toHaveURL(new RegExp(`/content/${seed.routeSegment}/${projectId}/edit(?:\\?|$)`), { timeout: 20_000 })
  126. return projectId
  127. }
  128. async function waitForModelEditor(page: Page): Promise<Locator> {
  129. const editor = page.locator('.model-editor-view')
  130. await expect(editor).toBeVisible({ timeout: 30_000 })
  131. await expect(editor.locator('.editor-shell')).toBeVisible({ timeout: 30_000 })
  132. await expect(editor.locator('.render-host canvas')).toBeVisible({ timeout: 30_000 })
  133. await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 60_000 })
  134. await expect(page.locator('iframe')).toHaveCount(0)
  135. return editor
  136. }
  137. async function waitForSceneEditor(page: Page): Promise<Locator> {
  138. const editor = page.locator('.scene-workspace')
  139. await expect(editor).toBeVisible({ timeout: 30_000 })
  140. await expect(editor.locator('.viewport-host canvas.scene-editor-canvas')).toBeVisible({ timeout: 60_000 })
  141. await expect(editor.locator('.loading-overlay')).toHaveCount(0, { timeout: 60_000 })
  142. await expect(page.locator('iframe')).toHaveCount(0)
  143. return editor
  144. }
  145. const modelTreeRow = (editor: Locator, name: string) => editor
  146. .locator('#scene-tree .tree-row')
  147. .filter({ hasText: name })
  148. .first()
  149. const sceneTreeRow = (editor: Locator, name: string) => editor
  150. .locator('.scene-tree .tree-row')
  151. .filter({ hasText: name })
  152. .first()
  153. async function saveModel(page: Page, editor: Locator, projectId: string): Promise<void> {
  154. const responsePromise = waitForProjectUpdate(page, projectId)
  155. await editor.locator('[data-action="save"]').click()
  156. await expectSuccessfulResponse(responsePromise, '保存模型工程')
  157. await expect(editor.locator('.toast.success').filter({ hasText: '工程已保存到服务端' }).last()).toBeVisible({ timeout: 20_000 })
  158. }
  159. async function saveScene(page: Page, editor: Locator, projectId: string): Promise<void> {
  160. const responsePromise = waitForProjectUpdate(page, projectId)
  161. await editor.getByTitle('保存工程 Ctrl+S').click()
  162. await expectSuccessfulResponse(responsePromise, '保存场景工程')
  163. await expect(page.locator('.el-message--success').filter({ hasText: /保存成功|工程已保存/ }).last()).toBeVisible({ timeout: 20_000 })
  164. }
  165. async function uploadExcavator(
  166. page: Page,
  167. editor: Locator,
  168. projectId: string,
  169. expectedHash: string,
  170. expectedSize: number,
  171. testInfo: TestInfo,
  172. ): Promise<UploadedAssetCommand> {
  173. const uploadPattern = `**/api/tran/v1/content/projects/${projectId}/assets/upload`
  174. let releaseResponse: () => void = () => undefined
  175. let notifyStored: (() => void) | null = null
  176. let notifyFailed: ((error: Error) => void) | null = null
  177. const responseHold = new Promise<void>((resolve) => { releaseResponse = resolve })
  178. const serverStored = new Promise<void>((resolve, reject) => {
  179. notifyStored = resolve
  180. notifyFailed = reject
  181. })
  182. await page.route(uploadPattern, async (route) => {
  183. try {
  184. const response = await route.fetch()
  185. if (!response.ok()) throw new Error(`资源上传接口返回 HTTP ${response.status()}`)
  186. notifyStored?.()
  187. await responseHold
  188. await route.fulfill({ response })
  189. } catch (error) {
  190. notifyFailed?.(error instanceof Error ? error : new Error(String(error)))
  191. await route.abort('failed').catch(() => undefined)
  192. }
  193. })
  194. const uploadResponsePromise = page.waitForResponse(
  195. (response) => isProjectMutation(response, 'POST', projectId, '/assets/upload'),
  196. { timeout: 60_000 },
  197. )
  198. const automaticSavePromise = waitForProjectUpdate(page, projectId)
  199. try {
  200. await editor.locator('#model-file-input').setInputFiles(excavatorPath)
  201. await serverStored
  202. await expect(editor.locator('#loading-progress')).toContainText(
  203. /正在(?:保存装备模型|上传).*(?:MB|%)/,
  204. { timeout: 20_000 },
  205. )
  206. await captureScreenshot(page, testInfo, 'three-model-real-glb-upload-progress')
  207. releaseResponse()
  208. const uploadResponse = await expectSuccessfulResponse(uploadResponsePromise, '上传真实 excavator-a.glb')
  209. const body = await uploadResponse.json() as ApiEnvelope<UploadedAssetCommand>
  210. const asset = body.data
  211. expect(asset, '上传接口应返回可直接用于资产命令的元数据').toBeTruthy()
  212. expect(asset!.type).toBe('MODEL_FILE')
  213. expect(asset!.status).toBe('READY')
  214. expect(asset!.sizeBytes).toBe(expectedSize)
  215. expect(asset!.sha256).toBe(expectedHash)
  216. expect(asset!.storageUri).toBe(`content://sha256/${expectedHash}`)
  217. expect(asset!.uploadTicket, '新上传资源应返回与工程绑定的短期凭证').toMatch(/^v1\./)
  218. await expectSuccessfulResponse(automaticSavePromise, '上传后自动保存模型工程')
  219. await expect(editor.locator('.toast.success').filter({ hasText: /导入成功,刷新后可继续编辑/ }).last()).toBeVisible({ timeout: 60_000 })
  220. await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 60_000 })
  221. return asset!
  222. } finally {
  223. releaseResponse()
  224. await page.unroute(uploadPattern)
  225. }
  226. }
  227. function sumTimelineKeys(document: ModelDocument): number {
  228. return document.timeline?.tracks?.reduce((sum, track) => sum + (track.keyframes?.length ?? 0), 0) ?? 0
  229. }
  230. async function assertStoredAsset(
  231. request: Parameters<typeof readContentDetail>[0],
  232. session: ContentApiSession,
  233. projectId: string,
  234. detail: ContentDetailRecord,
  235. uploaded: UploadedAssetCommand,
  236. ): Promise<void> {
  237. const stored = detail.currentVersion.assets.find((asset) => asset.code === uploaded.code)
  238. expect(stored, '工程保存后应产生带数据库 ID 的 READY 资产').toBeTruthy()
  239. expect(stored!.status).toBe('READY')
  240. expect(stored!.storageUri).toBe(uploaded.storageUri)
  241. expect(stored!.sha256).toBe(uploaded.sha256)
  242. expect(stored!.sizeBytes).toBe(uploaded.sizeBytes)
  243. const head = await request.head(
  244. `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`,
  245. { headers: session.headers },
  246. )
  247. expect(head.status()).toBe(200)
  248. expect(head.headers()['accept-ranges']).toBe('bytes')
  249. expect(head.headers()['content-length']).toBe(String(uploaded.sizeBytes))
  250. expect(head.headers().etag).toBe(`"${uploaded.sha256}"`)
  251. const partial = await request.get(
  252. `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`,
  253. { headers: { ...session.headers, Range: 'bytes=0-63' } },
  254. )
  255. expect(partial.status()).toBe(206)
  256. expect(partial.headers()['content-range']).toBe(`bytes 0-63/${uploaded.sizeBytes}`)
  257. expect(partial.headers()['content-length']).toBe('64')
  258. expect((await partial.body()).byteLength).toBe(64)
  259. const invalidRange = await request.get(
  260. `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`,
  261. { headers: { ...session.headers, Range: `bytes=${uploaded.sizeBytes}-` } },
  262. )
  263. expect(invalidRange.status()).toBe(416)
  264. expect(invalidRange.headers()['content-range']).toBe(`bytes */${uploaded.sizeBytes}`)
  265. }
  266. function markdownReport(
  267. completed: string[],
  268. cleanup: CleanupRecord[],
  269. projectIds: { model: string | null; scene: string | null },
  270. passed: boolean,
  271. ): string {
  272. const cleanupRows = cleanup.length
  273. ? cleanup.map((item) => `| ${item.kind} | ${item.code} | ${item.id ?? '未创建'} | ${item.outcome} |`).join('\n')
  274. : '| — | — | — | 未执行 |'
  275. return `# 三维编辑器 E2E 执行摘要
  276. - 执行状态:${passed ? '业务断言通过' : '业务断言未完成'}
  277. - 模型工程 ID:${projectIds.model ?? '未创建'}
  278. - 场景工程 ID:${projectIds.scene ?? '未创建'}
  279. - 测试模型:excavator-a.glb(报告不记录本机绝对路径)
  280. - 凭据与访问令牌:未写入报告、截图或附件
  281. ## 已完成检查
  282. ${completed.length ? completed.map((item) => `- ${item}`).join('\n') : '- 尚无完整步骤'}
  283. ## 自动化边界
  284. - Three.js 视口原生 canvas 与无 iframe 由 DOM 直接断言。
  285. - 视口 gizmo 的像素拖拽受相机、模型包围盒与显卡时序影响,不作为稳定 E2E 接口;变换改由同一编辑器属性面板操作,并在保存后通过服务端工程 JSON 二次核对。
  286. - 蓝图连线的自由画布拖拽不作为本用例的稳定入口;本用例通过蓝图工具栏新增节点,并核对节点集合刷新后仍存在。时间轴通过 DOM 轨道定位并新增关键帧。
  287. - 文件上传通过真实 GLB、上传进度 UI、SHA-256/长度、READY 状态、服务端 HEAD 元数据和刷新恢复联合验收。
  288. ## 测试数据清理
  289. | 类型 | 编码 | ID | 结果 |
  290. | --- | --- | --- | --- |
  291. ${cleanupRows}
  292. > 服务端工程采用软删除;内容寻址对象可能被其它版本复用,因此清理工程不会强制删除共享物理文件。
  293. `
  294. }
  295. test('装备模型与数字车间编辑器可原生编辑、保存、发布并恢复', async ({ page, request }, testInfo) => {
  296. test.setTimeout(300_000)
  297. let apiSession: ContentApiSession | null = null
  298. let modelProjectId: string | null = null
  299. let sceneProjectId: string | null = null
  300. let testFailure: unknown
  301. const completed: string[] = []
  302. const cleanup: CleanupRecord[] = []
  303. try {
  304. expect(fs.existsSync(excavatorPath), '真实测试模型 excavator-a.glb 必须存在').toBeTruthy()
  305. const modelSize = fs.statSync(excavatorPath).size
  306. const modelHash = createHash('sha256').update(fs.readFileSync(excavatorPath)).digest('hex')
  307. expect(modelSize).toBeGreaterThan(0)
  308. apiSession = await openContentApiSession(request)
  309. await loginAsAdmin(page)
  310. await test.step('新建模型工程并确认原生 Three.js 画布', async () => {
  311. modelProjectId = await createContentProject(page, data.model)
  312. const editor = await waitForModelEditor(page)
  313. await expect(editor.locator('.brand-copy')).toContainText('装备模型编辑工具')
  314. completed.push('模型编辑器无 iframe,开放 Shadow DOM 内原生 canvas 正常显示')
  315. await captureScreenshot(page, testInfo, 'three-model-native-canvas')
  316. })
  317. let uploadedAsset: UploadedAssetCommand
  318. await test.step('上传真实 excavator-a.glb,显示进度并自动保存到服务端', async () => {
  319. const editor = await waitForModelEditor(page)
  320. uploadedAsset = await uploadExcavator(page, editor, modelProjectId!, modelHash, modelSize, testInfo)
  321. const detail = await readContentDetail(request, apiSession!, modelProjectId!)
  322. await assertStoredAsset(request, apiSession!, modelProjectId!, detail, uploadedAsset)
  323. completed.push('真实 GLB 上传进度、内容寻址 SHA-256、READY 资产及服务端文件 HEAD 元数据通过')
  324. })
  325. let modelPartName = ''
  326. let modelPartKey = ''
  327. await test.step('模型部件软删除保存,刷新后恢复', async () => {
  328. let editor = await waitForModelEditor(page)
  329. await editor.locator('[data-selection-scope="part"]').click()
  330. const candidate = editor.locator('#scene-tree .tree-row:has(.tree-delete)').first()
  331. await expect(candidate, '真实 GLB 应至少包含一个可安全软删除的普通节点').toBeVisible({ timeout: 30_000 })
  332. modelPartName = (await candidate.locator('.tree-label').textContent())?.trim() ?? ''
  333. expect(modelPartName).not.toBe('')
  334. await candidate.locator('.tree-label').click()
  335. modelPartKey = await editor.locator('#inspector .property-section').first().locator('.property-row').nth(1).locator('input').inputValue()
  336. expect(modelPartKey).not.toBe('')
  337. await editor.locator('[data-object-delete]').click()
  338. await expect(editor.locator('#inspector')).toContainText('已软删除')
  339. await saveModel(page, editor, modelProjectId!)
  340. await page.reload()
  341. editor = await waitForModelEditor(page)
  342. await editor.locator('[data-action="toggle-deleted"]').click()
  343. const deletedRow = modelTreeRow(editor, modelPartName)
  344. await expect(deletedRow).toHaveClass(/is-deleted/)
  345. await deletedRow.locator('.tree-label').click()
  346. await expect(editor.locator('[data-object-delete]')).toContainText('恢复部件')
  347. await captureScreenshot(page, testInfo, 'three-model-soft-delete-persisted')
  348. await editor.locator('[data-object-delete]').click()
  349. await expect(modelTreeRow(editor, modelPartName)).not.toHaveClass(/is-deleted/)
  350. completed.push('模型部件软删除写入服务端,刷新后仍为删除态,并可从 UI 恢复')
  351. })
  352. let expectedTimelineKeys = 0
  353. let expectedBlueprintNodes = 0
  354. await test.step('编辑模型变换、时间轴与蓝图,保存并刷新恢复', async () => {
  355. let editor = await waitForModelEditor(page)
  356. const selectedRow = modelTreeRow(editor, modelPartName)
  357. await selectedRow.locator('.tree-label').click()
  358. const positionX = editor.locator('[data-vector="position"][data-axis="x"]')
  359. await positionX.fill(String(data.modelPositionX))
  360. await positionX.press('Tab')
  361. await editor.locator('[data-bottom-tab="timeline"]').click()
  362. const lane = editor.locator('.timeline-lane').first()
  363. await expect(lane).toBeVisible()
  364. const laneBox = await lane.boundingBox()
  365. expect(laneBox, '时间轴轨道应有可交互尺寸').toBeTruthy()
  366. await lane.click({ position: { x: Math.max(20, laneBox!.width / 2), y: Math.max(2, laneBox!.height / 2) } })
  367. const keys = editor.locator('.timeline-keyframe')
  368. const keyCountBefore = await keys.count()
  369. await editor.locator('[data-action="add-key"]').click()
  370. await expect(keys).toHaveCount(keyCountBefore + 1)
  371. expectedTimelineKeys = keyCountBefore + 1
  372. await editor.locator('[data-bottom-tab="blueprint"]').click()
  373. const blueprintNodes = editor.locator('.bp-node')
  374. const nodeCountBefore = await blueprintNodes.count()
  375. await editor.locator('.bp-toolbar [data-command="add"]').click()
  376. await expect(blueprintNodes).toHaveCount(nodeCountBefore + 1)
  377. expectedBlueprintNodes = nodeCountBefore + 1
  378. await captureScreenshot(page, testInfo, 'three-model-transform-timeline-blueprint')
  379. await saveModel(page, editor, modelProjectId!)
  380. await page.reload()
  381. editor = await waitForModelEditor(page)
  382. await modelTreeRow(editor, modelPartName).locator('.tree-label').click()
  383. await expect(editor.locator('[data-vector="position"][data-axis="x"]')).toHaveValue(data.modelPositionX.toFixed(3))
  384. await editor.locator('[data-bottom-tab="timeline"]').click()
  385. await expect(editor.locator('.timeline-keyframe')).toHaveCount(expectedTimelineKeys)
  386. await editor.locator('[data-bottom-tab="blueprint"]').click()
  387. await expect(editor.locator('.bp-node')).toHaveCount(expectedBlueprintNodes)
  388. const detail = await readContentDetail(request, apiSession!, modelProjectId!)
  389. const document = detail.currentVersion.content as ModelDocument
  390. expect(document.schema).toBe('digital-twin-editor-project')
  391. expect(document.version).toBe(4)
  392. expect(document.modelResource?.assetCode).toBe(uploadedAsset.code)
  393. expect(document.modelResource?.storageUri).toBe(uploadedAsset.storageUri)
  394. const storedPart = document.model?.objects?.[modelPartKey]
  395. expect(storedPart, '服务端工程 JSON 应保存被编辑部件').toBeTruthy()
  396. expect(storedPart!.deleted).toBeFalsy()
  397. expect(storedPart!.position?.[0]).toBeCloseTo(data.modelPositionX, 3)
  398. expect(sumTimelineKeys(document)).toBe(expectedTimelineKeys)
  399. expect(document.blueprint?.nodes?.length).toBe(expectedBlueprintNodes)
  400. completed.push('选择、变换、软删除恢复、时间轴关键帧、蓝图节点均保存并刷新恢复')
  401. await captureScreenshot(page, testInfo, 'three-model-saved-and-restored')
  402. })
  403. let publishedModelVersionId = ''
  404. await test.step('发布模型并成为场景目录资源', async () => {
  405. const editor = await waitForModelEditor(page)
  406. await editor.locator('[data-action="publish"]').click()
  407. const publishDialog = editor.locator('#publish-modal.open')
  408. await expect(publishDialog).toBeVisible()
  409. await expect(publishDialog.locator('.publish-check.failed')).toHaveCount(0)
  410. const publishResponsePromise = page.waitForResponse(
  411. (response) => isProjectMutation(response, 'POST', modelProjectId!, '/publish'),
  412. { timeout: 60_000 },
  413. )
  414. await publishDialog.locator('[data-action="publish-model-asset"]').click()
  415. await expectSuccessfulResponse(publishResponsePromise, '发布模型工程')
  416. await expect(editor.locator('.toast.success').filter({ hasText: '已发布' }).last()).toBeVisible({ timeout: 30_000 })
  417. const detail = await readContentDetail(request, apiSession!, modelProjectId!)
  418. expect(detail.project.status).toBe('PUBLISHED')
  419. expect(detail.project.publishedVersionId).toBe(detail.currentVersion.id)
  420. publishedModelVersionId = detail.currentVersion.id
  421. completed.push('模型发布成功并具有精确 publishedVersionId,可进入场景发布目录')
  422. await captureScreenshot(page, testInfo, 'three-model-published')
  423. })
  424. let workshopPartName = ''
  425. await test.step('新建场景,切换真实车间并验证部件软删除刷新恢复', async () => {
  426. sceneProjectId = await createContentProject(page, data.scene)
  427. let editor = await waitForSceneEditor(page)
  428. completed.push('场景编辑器无 iframe,原生 scene-editor-canvas 正常显示')
  429. await editor.locator('.panel-tabs button').filter({ hasText: '模板' }).click()
  430. await editor.locator('.template-card').filter({ hasText: '真实维修车间' }).click()
  431. await confirmMessageBox(page, '替换场景')
  432. await editor.locator('.panel-tabs button').filter({ hasText: '场景树' }).click()
  433. await expect.poll(() => editor.locator('.tree-row.child').count(), {
  434. message: '真实维修车间应展开大量可编辑部件',
  435. timeout: 60_000,
  436. }).toBeGreaterThanOrEqual(70)
  437. const part = editor.locator('.tree-row.child').first()
  438. workshopPartName = (await part.locator('b').textContent())?.trim() ?? ''
  439. expect(workshopPartName).not.toBe('')
  440. await part.click()
  441. await editor.locator('.danger-zone button.danger').click()
  442. await expect(sceneTreeRow(editor, workshopPartName)).toHaveClass(/deleted/)
  443. await saveScene(page, editor, sceneProjectId!)
  444. await page.reload()
  445. editor = await waitForSceneEditor(page)
  446. const deletedPart = sceneTreeRow(editor, workshopPartName)
  447. await expect(deletedPart).toHaveClass(/deleted/)
  448. await deletedPart.click()
  449. await expect(editor.locator('.restore-notice')).toContainText('软删除状态会随工程保存')
  450. await captureScreenshot(page, testInfo, 'three-scene-workshop-part-deleted-after-refresh')
  451. await editor.locator('.restore-notice button').click()
  452. await expect(sceneTreeRow(editor, workshopPartName)).not.toHaveClass(/deleted/)
  453. completed.push('真实维修车间部件软删除保存,刷新不复活,并可显式恢复')
  454. })
  455. await test.step('从已发布模型目录放置模型、编辑变换并保存刷新', async () => {
  456. let editor = await waitForSceneEditor(page)
  457. await editor.locator('.panel-tabs button').filter({ hasText: '资源库' }).click()
  458. const publishedModelCard = editor.locator('.asset-card').filter({ hasText: data.model.name })
  459. await expect(publishedModelCard, '场景资源库应出现刚发布的模型').toBeVisible({ timeout: 30_000 })
  460. await publishedModelCard.dblclick()
  461. await editor.locator('.panel-tabs button').filter({ hasText: '场景树' }).click()
  462. const placedModel = sceneTreeRow(editor, data.model.name)
  463. await expect(placedModel).toBeVisible({ timeout: 60_000 })
  464. await expect(editor.locator('.loading-overlay')).toHaveCount(0, { timeout: 60_000 })
  465. await placedModel.click()
  466. const transformSection = editor.locator('.property-section').filter({ hasText: '变换' }).first()
  467. const scenePositionX = transformSection.locator('.vector-field').first().locator('input').first()
  468. await scenePositionX.fill(String(data.scenePositionX))
  469. await scenePositionX.press('Tab')
  470. await editor.locator('.inspector-tabs button').filter({ hasText: '语义' }).click()
  471. const semanticSection = editor.locator('.property-section').filter({ hasText: '业务语义' }).first()
  472. const deviceInput = semanticSection.locator('label').filter({ hasText: '设备编号' }).locator('input')
  473. await deviceInput.fill(data.sceneDeviceId)
  474. await deviceInput.press('Tab')
  475. const reference = editor.locator('.reference-card')
  476. await expect(reference).toContainText(modelProjectId!)
  477. await expect(reference).toContainText(publishedModelVersionId)
  478. await expect(reference).not.toContainText('blob:')
  479. await captureScreenshot(page, testInfo, 'three-scene-published-model-reference')
  480. await saveScene(page, editor, sceneProjectId!)
  481. await page.reload()
  482. editor = await waitForSceneEditor(page)
  483. await sceneTreeRow(editor, data.model.name).click()
  484. const restoredTransform = editor.locator('.property-section').filter({ hasText: '变换' }).first()
  485. await expect(restoredTransform.locator('.vector-field').first().locator('input').first()).toHaveValue(String(data.scenePositionX))
  486. await expect(sceneTreeRow(editor, workshopPartName)).not.toHaveClass(/deleted/)
  487. await editor.locator('.inspector-tabs button').filter({ hasText: '语义' }).click()
  488. await expect(editor.locator('.reference-card')).toContainText(publishedModelVersionId)
  489. const detail = await readContentDetail(request, apiSession!, sceneProjectId!)
  490. const document = detail.currentVersion.content as SceneDocument
  491. expect(document.schema).toBe('unreal-tran.scene')
  492. expect(document.version).toBe('2.0')
  493. const referencedModel = document.objects?.find((item) => item.targetProjectId === modelProjectId)
  494. expect(referencedModel, '场景 JSON 应保存已发布模型目录引用').toBeTruthy()
  495. expect(referencedModel!.targetVersionId).toBe(publishedModelVersionId)
  496. expect(referencedModel!.assetCode).toBe(uploadedAsset.code)
  497. expect(referencedModel!.assetUrl).toMatch(/^content:\/\/sha256\/[a-f0-9]{64}$/)
  498. expect(referencedModel!.assetUrl).not.toContain('blob:')
  499. expect(referencedModel!.position?.[0]).toBeCloseTo(data.scenePositionX, 3)
  500. expect(referencedModel!.semantic?.deviceId).toBe(data.sceneDeviceId)
  501. const restoredWorkshopPart = document.objects?.find((item) => item.name === workshopPartName && item.type === 'workshop-part')
  502. expect(restoredWorkshopPart?.deleted).toBeFalsy()
  503. expect(restoredWorkshopPart?.visible).toBeTruthy()
  504. const dependency = detail.currentVersion.dependencies.find((item) => item.relationType === 'SCENE_MODEL')
  505. expect(dependency, '场景版本应生成 SCENE_MODEL 精确依赖').toBeTruthy()
  506. expect(dependency!.targetProjectId).toBe(modelProjectId)
  507. expect(dependency!.targetVersionId).toBe(publishedModelVersionId)
  508. expect(dependency!.required).toBeTruthy()
  509. completed.push('发布模型目录引用、放置、变换、设备语义、精确版本依赖均保存并刷新恢复')
  510. await captureScreenshot(page, testInfo, 'three-scene-saved-and-restored')
  511. })
  512. await test.step('执行八项场景校验并发布', async () => {
  513. const editor = await waitForSceneEditor(page)
  514. await editor.getByRole('button', { name: '发布检查', exact: true }).click()
  515. const validation = editor.locator('.validation-grid button')
  516. await expect(validation).toHaveCount(8)
  517. await expect(editor.locator('.validation-grid .fail')).toHaveCount(0)
  518. await captureScreenshot(page, testInfo, 'three-scene-eight-checks-passed')
  519. const publishResponsePromise = page.waitForResponse(
  520. (response) => isProjectMutation(response, 'POST', sceneProjectId!, '/publish'),
  521. { timeout: 60_000 },
  522. )
  523. await editor.locator('.publish-button').click()
  524. await expectSuccessfulResponse(publishResponsePromise, '发布场景工程')
  525. await expect(page.locator('.el-message--success').filter({ hasText: /场景发布完成|当前工程版本已发布/ }).last()).toBeVisible({ timeout: 30_000 })
  526. const detail = await readContentDetail(request, apiSession!, sceneProjectId!)
  527. expect(detail.project.status).toBe('PUBLISHED')
  528. expect(detail.project.publishedVersionId).toBe(detail.currentVersion.id)
  529. completed.push('场景八项校验全部通过并发布,服务端状态为 PUBLISHED')
  530. await captureScreenshot(page, testInfo, 'three-scene-published')
  531. })
  532. } catch (error) {
  533. testFailure = error
  534. } finally {
  535. if (apiSession) {
  536. cleanup.push(await removeContentProject(request, apiSession, '场景工程', sceneProjectId, data.scene.code))
  537. cleanup.push(await removeContentProject(request, apiSession, '模型工程', modelProjectId, data.model.code))
  538. } else {
  539. cleanup.push({ kind: '场景工程', id: sceneProjectId, code: data.scene.code, outcome: sceneProjectId ? 'failed' : 'not-created', message: 'API 会话未建立' })
  540. cleanup.push({ kind: '模型工程', id: modelProjectId, code: data.model.code, outcome: modelProjectId ? 'failed' : 'not-created', message: 'API 会话未建立' })
  541. }
  542. await closeContentApiSession(request, apiSession)
  543. const cleanupFailure = cleanup.find((item) => item.outcome === 'failed')
  544. await testInfo.attach('三维编辑器测试数据清理结果', {
  545. body: Buffer.from(`${JSON.stringify({
  546. 测试前缀: data.prefix,
  547. 清理顺序: ['场景工程', '模型工程'],
  548. 结果: cleanup,
  549. 说明: '服务端工程为软删除;内容寻址物理对象可跨版本复用,不做破坏性强删。',
  550. }, null, 2)}\n`, 'utf8'),
  551. contentType: 'application/json; charset=utf-8',
  552. })
  553. await testInfo.attach('三维编辑器E2E执行摘要', {
  554. body: Buffer.from(markdownReport(
  555. completed,
  556. cleanup,
  557. { model: modelProjectId, scene: sceneProjectId },
  558. testFailure === undefined,
  559. ), 'utf8'),
  560. contentType: 'text/markdown; charset=utf-8',
  561. })
  562. if (testFailure || cleanupFailure) {
  563. throw new AggregateError(
  564. [testFailure, cleanupFailure ? new Error(`测试数据清理失败:${cleanupFailure.kind} ${cleanupFailure.code}`) : undefined]
  565. .filter((item): item is unknown => item !== undefined),
  566. cleanupFailure ? '三维编辑器 E2E 未完成,且存在测试数据清理失败。' : '三维编辑器 E2E 未完成。',
  567. )
  568. }
  569. }
  570. })