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.
 
 
 
 

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