|
- import { createHash } from 'node:crypto'
- import fs from 'node:fs'
- import { fileURLToPath } from 'node:url'
-
- import type { Locator, Page, Response, TestInfo } from '@playwright/test'
-
- import {
- closeContentApiSession,
- type CleanupRecord,
- type ContentApiSession,
- type ContentDetailRecord,
- openContentApiSession,
- readContentDetail,
- removeContentProject,
- } from './content-editor-api'
- import { captureScreenshot, expect, test } from './fixtures'
- import { chooseSelectOption, confirmMessageBox, fillFormItem, loginAsAdmin, visibleDialog } from './helpers'
-
- const runToken = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
- const upperToken = runToken.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-16)
- const defaultExcavatorPath = fileURLToPath(new URL('../../unreal_tran_web/public/models/excavator-a.glb', import.meta.url))
- const excavatorPath = process.env.E2E_EXCAVATOR_GLB?.trim() || defaultExcavatorPath
-
- const data = {
- prefix: `UTE2E-3D-${upperToken}`,
- model: {
- path: '/content/models',
- routeSegment: 'models',
- singular: '模型',
- code: `E2E_MODEL_${upperToken}`.slice(0, 64),
- name: `UTE2E 挖掘机模型 ${upperToken}`,
- category: '整机装备',
- description: `Playwright 三维模型编辑器验收数据 ${upperToken}`,
- },
- scene: {
- path: '/content/scenes',
- routeSegment: 'scenes',
- singular: '场景',
- code: `E2E_SCENE_${upperToken}`.slice(0, 64),
- name: `UTE2E 数字车间 ${upperToken}`,
- category: '维修车间',
- description: `Playwright 数字车间场景编辑器验收数据 ${upperToken}`,
- },
- sceneDeviceId: `EQ-E2E-${upperToken}`.slice(0, 64),
- modelPositionX: 1.25,
- scenePositionX: 2.5,
- } as const
-
- type ProjectSeed = typeof data.model | typeof data.scene
-
- interface ApiEnvelope<T> {
- data?: T
- }
-
- interface UploadedAssetCommand {
- code: string
- name: string
- type: string
- storageUri: string
- mimeType: string
- sizeBytes: number
- sha256: string
- metadata: Record<string, unknown>
- status: string
- sortOrder: number
- uploadTicket?: string
- }
-
- interface ModelDocument {
- schema?: string
- version?: number
- modelResource?: { assetCode?: string; storageUri?: string; sha256?: string }
- model?: { objects?: Record<string, { name?: string; position?: number[]; deleted?: boolean }> }
- timeline?: { tracks?: Array<{ keyframes?: unknown[] }> }
- blueprint?: { nodes?: unknown[]; edges?: unknown[] }
- }
-
- interface SceneObjectDocument {
- id?: string
- name?: string
- type?: string
- deleted?: boolean
- visible?: boolean
- position?: number[]
- targetProjectId?: string
- targetVersionId?: string
- assetCode?: string
- assetUrl?: string
- semantic?: { deviceId?: string }
- }
-
- interface SceneDocument {
- schema?: string
- version?: string
- objects?: SceneObjectDocument[]
- }
-
- const responsePath = (response: Response) => new URL(response.url()).pathname
-
- const isProjectMutation = (response: Response, method: 'PUT' | 'POST', projectId: string, suffix = '') => (
- response.request().method() === method
- && responsePath(response) === `/api/tran/v1/content/projects/${projectId}${suffix}`
- )
-
- const waitForProjectUpdate = (page: Page, projectId: string) => page.waitForResponse(
- (response) => isProjectMutation(response, 'PUT', projectId),
- { timeout: 60_000 },
- )
-
- async function expectSuccessfulResponse(responsePromise: Promise<Response>, operation: string): Promise<Response> {
- const response = await responsePromise
- expect(response.ok(), `${operation}应返回成功状态,实际 HTTP ${response.status()}`).toBeTruthy()
- return response
- }
-
- async function waitForContentList(page: Page): Promise<void> {
- await expect(page.locator('.content-table-panel')).toBeVisible()
- await expect(page.locator('.content-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 20_000 })
- await expect(page.locator('.content-projects-page > .el-alert--error:visible')).toHaveCount(0)
- }
-
- async function createContentProject(page: Page, seed: ProjectSeed): Promise<string> {
- await page.goto(seed.path)
- await waitForContentList(page)
- await page.getByRole('button', { name: `新建${seed.singular}`, exact: true }).click()
-
- const dialog = visibleDialog(page)
- await expect(dialog).toContainText(`新建${seed.singular}`)
- await fillFormItem(dialog, '项目编码', seed.code)
- await fillFormItem(dialog, '项目名称', seed.name)
- await chooseSelectOption(page, dialog, '业务分类', seed.category)
- await fillFormItem(dialog, '项目说明', seed.description)
-
- const createdResponsePromise = page.waitForResponse((response) => (
- response.request().method() === 'POST'
- && responsePath(response) === '/api/tran/v1/content/projects'
- ), { timeout: 30_000 })
- await dialog.getByRole('button', { name: '创建并编辑', exact: true }).click()
- const createdResponse = await expectSuccessfulResponse(createdResponsePromise, `新建${seed.singular}`)
- const body = await createdResponse.json() as ApiEnvelope<ContentDetailRecord>
- const projectId = String(body.data?.project.id ?? '')
- expect(projectId, `新建${seed.singular}后应返回工程 ID`).not.toBe('')
- await expect(page).toHaveURL(new RegExp(`/content/${seed.routeSegment}/${projectId}/edit(?:\\?|$)`), { timeout: 20_000 })
- return projectId
- }
-
- async function waitForModelEditor(page: Page): Promise<Locator> {
- const editor = page.locator('.model-editor-view')
- await expect(editor).toBeVisible({ timeout: 30_000 })
- await expect(editor.locator('.editor-shell')).toBeVisible({ timeout: 30_000 })
- await expect(editor.locator('.render-host canvas')).toBeVisible({ timeout: 30_000 })
- await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 60_000 })
- await expect(page.locator('iframe')).toHaveCount(0)
- return editor
- }
-
- async function waitForSceneEditor(page: Page): Promise<Locator> {
- const editor = page.locator('.scene-workspace')
- await expect(editor).toBeVisible({ timeout: 30_000 })
- await expect(editor.locator('.viewport-host canvas.scene-editor-canvas')).toBeVisible({ timeout: 60_000 })
- await expect(editor.locator('.loading-overlay')).toHaveCount(0, { timeout: 60_000 })
- await expect(page.locator('iframe')).toHaveCount(0)
- return editor
- }
-
- const modelTreeRow = (editor: Locator, name: string) => editor
- .locator('#scene-tree .tree-row')
- .filter({ hasText: name })
- .first()
-
- const sceneTreeRow = (editor: Locator, name: string) => editor
- .locator('.scene-tree .tree-row')
- .filter({ hasText: name })
- .first()
-
- async function saveModel(page: Page, editor: Locator, projectId: string): Promise<void> {
- const responsePromise = waitForProjectUpdate(page, projectId)
- await editor.locator('[data-action="save"]').click()
- await expectSuccessfulResponse(responsePromise, '保存模型工程')
- await expect(editor.locator('.toast.success').filter({ hasText: '工程已保存到服务端' }).last()).toBeVisible({ timeout: 20_000 })
- }
-
- async function saveScene(page: Page, editor: Locator, projectId: string): Promise<void> {
- const responsePromise = waitForProjectUpdate(page, projectId)
- await editor.getByTitle('保存工程 Ctrl+S').click()
- await expectSuccessfulResponse(responsePromise, '保存场景工程')
- await expect(page.locator('.el-message--success').filter({ hasText: /保存成功|工程已保存/ }).last()).toBeVisible({ timeout: 20_000 })
- }
-
- async function uploadExcavator(
- page: Page,
- editor: Locator,
- projectId: string,
- expectedHash: string,
- expectedSize: number,
- testInfo: TestInfo,
- ): Promise<UploadedAssetCommand> {
- const uploadPattern = `**/api/tran/v1/content/projects/${projectId}/assets/upload`
- let releaseResponse: () => void = () => undefined
- let notifyStored: (() => void) | null = null
- let notifyFailed: ((error: Error) => void) | null = null
- const responseHold = new Promise<void>((resolve) => { releaseResponse = resolve })
- const serverStored = new Promise<void>((resolve, reject) => {
- notifyStored = resolve
- notifyFailed = reject
- })
-
- await page.route(uploadPattern, async (route) => {
- try {
- const response = await route.fetch()
- if (!response.ok()) throw new Error(`资源上传接口返回 HTTP ${response.status()}`)
- notifyStored?.()
- await responseHold
- await route.fulfill({ response })
- } catch (error) {
- notifyFailed?.(error instanceof Error ? error : new Error(String(error)))
- await route.abort('failed').catch(() => undefined)
- }
- })
-
- const uploadResponsePromise = page.waitForResponse(
- (response) => isProjectMutation(response, 'POST', projectId, '/assets/upload'),
- { timeout: 60_000 },
- )
- const automaticSavePromise = waitForProjectUpdate(page, projectId)
-
- try {
- await editor.locator('#model-file-input').setInputFiles(excavatorPath)
- await serverStored
- await expect(editor.locator('#loading-progress')).toContainText(
- /正在(?:保存装备模型|上传).*(?:MB|%)/,
- { timeout: 20_000 },
- )
- await captureScreenshot(page, testInfo, 'three-model-real-glb-upload-progress')
- releaseResponse()
-
- const uploadResponse = await expectSuccessfulResponse(uploadResponsePromise, '上传真实 excavator-a.glb')
- const body = await uploadResponse.json() as ApiEnvelope<UploadedAssetCommand>
- const asset = body.data
- expect(asset, '上传接口应返回可直接用于资产命令的元数据').toBeTruthy()
- expect(asset!.type).toBe('MODEL_FILE')
- expect(asset!.status).toBe('READY')
- expect(asset!.sizeBytes).toBe(expectedSize)
- expect(asset!.sha256).toBe(expectedHash)
- expect(asset!.storageUri).toBe(`content://sha256/${expectedHash}`)
- expect(asset!.uploadTicket, '新上传资源应返回与工程绑定的短期凭证').toMatch(/^v1\./)
-
- await expectSuccessfulResponse(automaticSavePromise, '上传后自动保存模型工程')
- await expect(editor.locator('.toast.success').filter({ hasText: /导入成功,刷新后可继续编辑/ }).last()).toBeVisible({ timeout: 60_000 })
- await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 60_000 })
- return asset!
- } finally {
- releaseResponse()
- await page.unroute(uploadPattern)
- }
- }
-
- function sumTimelineKeys(document: ModelDocument): number {
- return document.timeline?.tracks?.reduce((sum, track) => sum + (track.keyframes?.length ?? 0), 0) ?? 0
- }
-
- async function assertStoredAsset(
- request: Parameters<typeof readContentDetail>[0],
- session: ContentApiSession,
- projectId: string,
- detail: ContentDetailRecord,
- uploaded: UploadedAssetCommand,
- ): Promise<void> {
- const stored = detail.currentVersion.assets.find((asset) => asset.code === uploaded.code)
- expect(stored, '工程保存后应产生带数据库 ID 的 READY 资产').toBeTruthy()
- expect(stored!.status).toBe('READY')
- expect(stored!.storageUri).toBe(uploaded.storageUri)
- expect(stored!.sha256).toBe(uploaded.sha256)
- expect(stored!.sizeBytes).toBe(uploaded.sizeBytes)
-
- const head = await request.head(
- `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`,
- { headers: session.headers },
- )
- expect(head.status()).toBe(200)
- expect(head.headers()['accept-ranges']).toBe('bytes')
- expect(head.headers()['content-length']).toBe(String(uploaded.sizeBytes))
- expect(head.headers().etag).toBe(`"${uploaded.sha256}"`)
-
- const partial = await request.get(
- `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`,
- { headers: { ...session.headers, Range: 'bytes=0-63' } },
- )
- expect(partial.status()).toBe(206)
- expect(partial.headers()['content-range']).toBe(`bytes 0-63/${uploaded.sizeBytes}`)
- expect(partial.headers()['content-length']).toBe('64')
- expect((await partial.body()).byteLength).toBe(64)
-
- const invalidRange = await request.get(
- `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`,
- { headers: { ...session.headers, Range: `bytes=${uploaded.sizeBytes}-` } },
- )
- expect(invalidRange.status()).toBe(416)
- expect(invalidRange.headers()['content-range']).toBe(`bytes */${uploaded.sizeBytes}`)
- }
-
- function markdownReport(
- completed: string[],
- cleanup: CleanupRecord[],
- projectIds: { model: string | null; scene: string | null },
- passed: boolean,
- ): string {
- const cleanupRows = cleanup.length
- ? cleanup.map((item) => `| ${item.kind} | ${item.code} | ${item.id ?? '未创建'} | ${item.outcome} |`).join('\n')
- : '| — | — | — | 未执行 |'
- return `# 三维编辑器 E2E 执行摘要
-
- - 执行状态:${passed ? '业务断言通过' : '业务断言未完成'}
- - 模型工程 ID:${projectIds.model ?? '未创建'}
- - 场景工程 ID:${projectIds.scene ?? '未创建'}
- - 测试模型:excavator-a.glb(报告不记录本机绝对路径)
- - 凭据与访问令牌:未写入报告、截图或附件
-
- ## 已完成检查
-
- ${completed.length ? completed.map((item) => `- ${item}`).join('\n') : '- 尚无完整步骤'}
-
- ## 自动化边界
-
- - Three.js 视口原生 canvas 与无 iframe 由 DOM 直接断言。
- - 视口 gizmo 的像素拖拽受相机、模型包围盒与显卡时序影响,不作为稳定 E2E 接口;变换改由同一编辑器属性面板操作,并在保存后通过服务端工程 JSON 二次核对。
- - 蓝图连线的自由画布拖拽不作为本用例的稳定入口;本用例通过蓝图工具栏新增节点,并核对节点集合刷新后仍存在。时间轴通过 DOM 轨道定位并新增关键帧。
- - 文件上传通过真实 GLB、上传进度 UI、SHA-256/长度、READY 状态、服务端 HEAD 元数据和刷新恢复联合验收。
-
- ## 测试数据清理
-
- | 类型 | 编码 | ID | 结果 |
- | --- | --- | --- | --- |
- ${cleanupRows}
-
- > 服务端工程采用软删除;内容寻址对象可能被其它版本复用,因此清理工程不会强制删除共享物理文件。
- `
- }
-
- test('装备模型与数字车间编辑器可原生编辑、保存、发布并恢复', async ({ page, request }, testInfo) => {
- test.setTimeout(300_000)
-
- let apiSession: ContentApiSession | null = null
- let modelProjectId: string | null = null
- let sceneProjectId: string | null = null
- let testFailure: unknown
- const completed: string[] = []
- const cleanup: CleanupRecord[] = []
-
- try {
- expect(fs.existsSync(excavatorPath), '真实测试模型 excavator-a.glb 必须存在').toBeTruthy()
- const modelSize = fs.statSync(excavatorPath).size
- const modelHash = createHash('sha256').update(fs.readFileSync(excavatorPath)).digest('hex')
- expect(modelSize).toBeGreaterThan(0)
-
- apiSession = await openContentApiSession(request)
- await loginAsAdmin(page)
-
- await test.step('新建模型工程并确认原生 Three.js 画布', async () => {
- modelProjectId = await createContentProject(page, data.model)
- const editor = await waitForModelEditor(page)
- await expect(editor.locator('.brand-copy')).toContainText('装备模型编辑工具')
- completed.push('模型编辑器无 iframe,开放 Shadow DOM 内原生 canvas 正常显示')
- await captureScreenshot(page, testInfo, 'three-model-native-canvas')
- })
-
- let uploadedAsset: UploadedAssetCommand
- await test.step('上传真实 excavator-a.glb,显示进度并自动保存到服务端', async () => {
- const editor = await waitForModelEditor(page)
- uploadedAsset = await uploadExcavator(page, editor, modelProjectId!, modelHash, modelSize, testInfo)
- const detail = await readContentDetail(request, apiSession!, modelProjectId!)
- await assertStoredAsset(request, apiSession!, modelProjectId!, detail, uploadedAsset)
- completed.push('真实 GLB 上传进度、内容寻址 SHA-256、READY 资产及服务端文件 HEAD 元数据通过')
- })
-
- let modelPartName = ''
- let modelPartKey = ''
- await test.step('模型部件软删除保存,刷新后恢复', async () => {
- let editor = await waitForModelEditor(page)
- await editor.locator('[data-selection-scope="part"]').click()
- const candidate = editor.locator('#scene-tree .tree-row:has(.tree-delete)').first()
- await expect(candidate, '真实 GLB 应至少包含一个可安全软删除的普通节点').toBeVisible({ timeout: 30_000 })
- modelPartName = (await candidate.locator('.tree-label').textContent())?.trim() ?? ''
- expect(modelPartName).not.toBe('')
- await candidate.locator('.tree-label').click()
- modelPartKey = await editor.locator('#inspector .property-section').first().locator('.property-row').nth(1).locator('input').inputValue()
- expect(modelPartKey).not.toBe('')
-
- await editor.locator('[data-object-delete]').click()
- await expect(editor.locator('#inspector')).toContainText('已软删除')
- await saveModel(page, editor, modelProjectId!)
-
- await page.reload()
- editor = await waitForModelEditor(page)
- await editor.locator('[data-action="toggle-deleted"]').click()
- const deletedRow = modelTreeRow(editor, modelPartName)
- await expect(deletedRow).toHaveClass(/is-deleted/)
- await deletedRow.locator('.tree-label').click()
- await expect(editor.locator('[data-object-delete]')).toContainText('恢复部件')
- await captureScreenshot(page, testInfo, 'three-model-soft-delete-persisted')
- await editor.locator('[data-object-delete]').click()
- await expect(modelTreeRow(editor, modelPartName)).not.toHaveClass(/is-deleted/)
- completed.push('模型部件软删除写入服务端,刷新后仍为删除态,并可从 UI 恢复')
- })
-
- let expectedTimelineKeys = 0
- let expectedBlueprintNodes = 0
- await test.step('编辑模型变换、时间轴与蓝图,保存并刷新恢复', async () => {
- let editor = await waitForModelEditor(page)
- const selectedRow = modelTreeRow(editor, modelPartName)
- await selectedRow.locator('.tree-label').click()
- const positionX = editor.locator('[data-vector="position"][data-axis="x"]')
- await positionX.fill(String(data.modelPositionX))
- await positionX.press('Tab')
-
- await editor.locator('[data-bottom-tab="timeline"]').click()
- const lane = editor.locator('.timeline-lane').first()
- await expect(lane).toBeVisible()
- const laneBox = await lane.boundingBox()
- expect(laneBox, '时间轴轨道应有可交互尺寸').toBeTruthy()
- await lane.click({ position: { x: Math.max(20, laneBox!.width / 2), y: Math.max(2, laneBox!.height / 2) } })
- const keys = editor.locator('.timeline-keyframe')
- const keyCountBefore = await keys.count()
- await editor.locator('[data-action="add-key"]').click()
- await expect(keys).toHaveCount(keyCountBefore + 1)
- expectedTimelineKeys = keyCountBefore + 1
-
- await editor.locator('[data-bottom-tab="blueprint"]').click()
- const blueprintNodes = editor.locator('.bp-node')
- const nodeCountBefore = await blueprintNodes.count()
- await editor.locator('.bp-toolbar [data-command="add"]').click()
- await expect(blueprintNodes).toHaveCount(nodeCountBefore + 1)
- expectedBlueprintNodes = nodeCountBefore + 1
- await captureScreenshot(page, testInfo, 'three-model-transform-timeline-blueprint')
- await saveModel(page, editor, modelProjectId!)
-
- await page.reload()
- editor = await waitForModelEditor(page)
- await modelTreeRow(editor, modelPartName).locator('.tree-label').click()
- await expect(editor.locator('[data-vector="position"][data-axis="x"]')).toHaveValue(data.modelPositionX.toFixed(3))
- await editor.locator('[data-bottom-tab="timeline"]').click()
- await expect(editor.locator('.timeline-keyframe')).toHaveCount(expectedTimelineKeys)
- await editor.locator('[data-bottom-tab="blueprint"]').click()
- await expect(editor.locator('.bp-node')).toHaveCount(expectedBlueprintNodes)
-
- const detail = await readContentDetail(request, apiSession!, modelProjectId!)
- const document = detail.currentVersion.content as ModelDocument
- expect(document.schema).toBe('digital-twin-editor-project')
- expect(document.version).toBe(4)
- expect(document.modelResource?.assetCode).toBe(uploadedAsset.code)
- expect(document.modelResource?.storageUri).toBe(uploadedAsset.storageUri)
- const storedPart = document.model?.objects?.[modelPartKey]
- expect(storedPart, '服务端工程 JSON 应保存被编辑部件').toBeTruthy()
- expect(storedPart!.deleted).toBeFalsy()
- expect(storedPart!.position?.[0]).toBeCloseTo(data.modelPositionX, 3)
- expect(sumTimelineKeys(document)).toBe(expectedTimelineKeys)
- expect(document.blueprint?.nodes?.length).toBe(expectedBlueprintNodes)
- completed.push('选择、变换、软删除恢复、时间轴关键帧、蓝图节点均保存并刷新恢复')
- await captureScreenshot(page, testInfo, 'three-model-saved-and-restored')
- })
-
- let publishedModelVersionId = ''
- await test.step('发布模型并成为场景目录资源', async () => {
- const editor = await waitForModelEditor(page)
- await editor.locator('[data-action="publish"]').click()
- const publishDialog = editor.locator('#publish-modal.open')
- await expect(publishDialog).toBeVisible()
- await expect(publishDialog.locator('.publish-check.failed')).toHaveCount(0)
- const publishResponsePromise = page.waitForResponse(
- (response) => isProjectMutation(response, 'POST', modelProjectId!, '/publish'),
- { timeout: 60_000 },
- )
- await publishDialog.locator('[data-action="publish-model-asset"]').click()
- await expectSuccessfulResponse(publishResponsePromise, '发布模型工程')
- await expect(editor.locator('.toast.success').filter({ hasText: '已发布' }).last()).toBeVisible({ timeout: 30_000 })
-
- const detail = await readContentDetail(request, apiSession!, modelProjectId!)
- expect(detail.project.status).toBe('PUBLISHED')
- expect(detail.project.publishedVersionId).toBe(detail.currentVersion.id)
- publishedModelVersionId = detail.currentVersion.id
- completed.push('模型发布成功并具有精确 publishedVersionId,可进入场景发布目录')
- await captureScreenshot(page, testInfo, 'three-model-published')
- })
-
- let workshopPartName = ''
- await test.step('新建场景,切换真实车间并验证部件软删除刷新恢复', async () => {
- sceneProjectId = await createContentProject(page, data.scene)
- let editor = await waitForSceneEditor(page)
- completed.push('场景编辑器无 iframe,原生 scene-editor-canvas 正常显示')
-
- await editor.locator('.panel-tabs button').filter({ hasText: '模板' }).click()
- await editor.locator('.template-card').filter({ hasText: '真实维修车间' }).click()
- await confirmMessageBox(page, '替换场景')
- await editor.locator('.panel-tabs button').filter({ hasText: '场景树' }).click()
- await expect.poll(() => editor.locator('.tree-row.child').count(), {
- message: '真实维修车间应展开大量可编辑部件',
- timeout: 60_000,
- }).toBeGreaterThanOrEqual(70)
-
- const part = editor.locator('.tree-row.child').first()
- workshopPartName = (await part.locator('b').textContent())?.trim() ?? ''
- expect(workshopPartName).not.toBe('')
- await part.click()
- await editor.locator('.danger-zone button.danger').click()
- await expect(sceneTreeRow(editor, workshopPartName)).toHaveClass(/deleted/)
- await saveScene(page, editor, sceneProjectId!)
-
- await page.reload()
- editor = await waitForSceneEditor(page)
- const deletedPart = sceneTreeRow(editor, workshopPartName)
- await expect(deletedPart).toHaveClass(/deleted/)
- await deletedPart.click()
- await expect(editor.locator('.restore-notice')).toContainText('软删除状态会随工程保存')
- await captureScreenshot(page, testInfo, 'three-scene-workshop-part-deleted-after-refresh')
- await editor.locator('.restore-notice button').click()
- await expect(sceneTreeRow(editor, workshopPartName)).not.toHaveClass(/deleted/)
- completed.push('真实维修车间部件软删除保存,刷新不复活,并可显式恢复')
- })
-
- await test.step('从已发布模型目录放置模型、编辑变换并保存刷新', async () => {
- let editor = await waitForSceneEditor(page)
- await editor.locator('.panel-tabs button').filter({ hasText: '资源库' }).click()
- const publishedModelCard = editor.locator('.asset-card').filter({ hasText: data.model.name })
- await expect(publishedModelCard, '场景资源库应出现刚发布的模型').toBeVisible({ timeout: 30_000 })
- await publishedModelCard.dblclick()
- await editor.locator('.panel-tabs button').filter({ hasText: '场景树' }).click()
-
- const placedModel = sceneTreeRow(editor, data.model.name)
- await expect(placedModel).toBeVisible({ timeout: 60_000 })
- await expect(editor.locator('.loading-overlay')).toHaveCount(0, { timeout: 60_000 })
- await placedModel.click()
- const transformSection = editor.locator('.property-section').filter({ hasText: '变换' }).first()
- const scenePositionX = transformSection.locator('.vector-field').first().locator('input').first()
- await scenePositionX.fill(String(data.scenePositionX))
- await scenePositionX.press('Tab')
-
- await editor.locator('.inspector-tabs button').filter({ hasText: '语义' }).click()
- const semanticSection = editor.locator('.property-section').filter({ hasText: '业务语义' }).first()
- const deviceInput = semanticSection.locator('label').filter({ hasText: '设备编号' }).locator('input')
- await deviceInput.fill(data.sceneDeviceId)
- await deviceInput.press('Tab')
- const reference = editor.locator('.reference-card')
- await expect(reference).toContainText(modelProjectId!)
- await expect(reference).toContainText(publishedModelVersionId)
- await expect(reference).not.toContainText('blob:')
- await captureScreenshot(page, testInfo, 'three-scene-published-model-reference')
-
- await saveScene(page, editor, sceneProjectId!)
- await page.reload()
- editor = await waitForSceneEditor(page)
- await sceneTreeRow(editor, data.model.name).click()
- const restoredTransform = editor.locator('.property-section').filter({ hasText: '变换' }).first()
- await expect(restoredTransform.locator('.vector-field').first().locator('input').first()).toHaveValue(String(data.scenePositionX))
- await expect(sceneTreeRow(editor, workshopPartName)).not.toHaveClass(/deleted/)
- await editor.locator('.inspector-tabs button').filter({ hasText: '语义' }).click()
- await expect(editor.locator('.reference-card')).toContainText(publishedModelVersionId)
-
- const detail = await readContentDetail(request, apiSession!, sceneProjectId!)
- const document = detail.currentVersion.content as SceneDocument
- expect(document.schema).toBe('unreal-tran.scene')
- expect(document.version).toBe('2.0')
- const referencedModel = document.objects?.find((item) => item.targetProjectId === modelProjectId)
- expect(referencedModel, '场景 JSON 应保存已发布模型目录引用').toBeTruthy()
- expect(referencedModel!.targetVersionId).toBe(publishedModelVersionId)
- expect(referencedModel!.assetCode).toBe(uploadedAsset.code)
- expect(referencedModel!.assetUrl).toMatch(/^content:\/\/sha256\/[a-f0-9]{64}$/)
- expect(referencedModel!.assetUrl).not.toContain('blob:')
- expect(referencedModel!.position?.[0]).toBeCloseTo(data.scenePositionX, 3)
- expect(referencedModel!.semantic?.deviceId).toBe(data.sceneDeviceId)
- const restoredWorkshopPart = document.objects?.find((item) => item.name === workshopPartName && item.type === 'workshop-part')
- expect(restoredWorkshopPart?.deleted).toBeFalsy()
- expect(restoredWorkshopPart?.visible).toBeTruthy()
-
- const dependency = detail.currentVersion.dependencies.find((item) => item.relationType === 'SCENE_MODEL')
- expect(dependency, '场景版本应生成 SCENE_MODEL 精确依赖').toBeTruthy()
- expect(dependency!.targetProjectId).toBe(modelProjectId)
- expect(dependency!.targetVersionId).toBe(publishedModelVersionId)
- expect(dependency!.required).toBeTruthy()
- completed.push('发布模型目录引用、放置、变换、设备语义、精确版本依赖均保存并刷新恢复')
- await captureScreenshot(page, testInfo, 'three-scene-saved-and-restored')
- })
-
- await test.step('执行八项场景校验并发布', async () => {
- const editor = await waitForSceneEditor(page)
- await editor.getByRole('button', { name: '发布检查', exact: true }).click()
- const validation = editor.locator('.validation-grid button')
- await expect(validation).toHaveCount(8)
- await expect(editor.locator('.validation-grid .fail')).toHaveCount(0)
- await captureScreenshot(page, testInfo, 'three-scene-eight-checks-passed')
-
- const publishResponsePromise = page.waitForResponse(
- (response) => isProjectMutation(response, 'POST', sceneProjectId!, '/publish'),
- { timeout: 60_000 },
- )
- await editor.locator('.publish-button').click()
- await expectSuccessfulResponse(publishResponsePromise, '发布场景工程')
- await expect(page.locator('.el-message--success').filter({ hasText: /场景发布完成|当前工程版本已发布/ }).last()).toBeVisible({ timeout: 30_000 })
- const detail = await readContentDetail(request, apiSession!, sceneProjectId!)
- expect(detail.project.status).toBe('PUBLISHED')
- expect(detail.project.publishedVersionId).toBe(detail.currentVersion.id)
- completed.push('场景八项校验全部通过并发布,服务端状态为 PUBLISHED')
- await captureScreenshot(page, testInfo, 'three-scene-published')
- })
- } catch (error) {
- testFailure = error
- } finally {
- if (apiSession) {
- cleanup.push(await removeContentProject(request, apiSession, '场景工程', sceneProjectId, data.scene.code))
- cleanup.push(await removeContentProject(request, apiSession, '模型工程', modelProjectId, data.model.code))
- } else {
- cleanup.push({ kind: '场景工程', id: sceneProjectId, code: data.scene.code, outcome: sceneProjectId ? 'failed' : 'not-created', message: 'API 会话未建立' })
- cleanup.push({ kind: '模型工程', id: modelProjectId, code: data.model.code, outcome: modelProjectId ? 'failed' : 'not-created', message: 'API 会话未建立' })
- }
- await closeContentApiSession(request, apiSession)
-
- const cleanupFailure = cleanup.find((item) => item.outcome === 'failed')
- await testInfo.attach('三维编辑器测试数据清理结果', {
- body: Buffer.from(`${JSON.stringify({
- 测试前缀: data.prefix,
- 清理顺序: ['场景工程', '模型工程'],
- 结果: cleanup,
- 说明: '服务端工程为软删除;内容寻址物理对象可跨版本复用,不做破坏性强删。',
- }, null, 2)}\n`, 'utf8'),
- contentType: 'application/json; charset=utf-8',
- })
- await testInfo.attach('三维编辑器E2E执行摘要', {
- body: Buffer.from(markdownReport(
- completed,
- cleanup,
- { model: modelProjectId, scene: sceneProjectId },
- testFailure === undefined,
- ), 'utf8'),
- contentType: 'text/markdown; charset=utf-8',
- })
-
- if (testFailure || cleanupFailure) {
- throw new AggregateError(
- [testFailure, cleanupFailure ? new Error(`测试数据清理失败:${cleanupFailure.kind} ${cleanupFailure.code}`) : undefined]
- .filter((item): item is unknown => item !== undefined),
- cleanupFailure ? '三维编辑器 E2E 未完成,且存在测试数据清理失败。' : '三维编辑器 E2E 未完成。',
- )
- }
- }
- })
|