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 { data?: T } interface UploadedAssetCommand { code: string name: string type: string storageUri: string mimeType: string sizeBytes: number sha256: string metadata: Record status: string sortOrder: number uploadTicket?: string } interface ModelDocument { schema?: string version?: number modelResource?: { assetCode?: string; storageUri?: string; sha256?: string } model?: { objects?: Record } 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, operation: string): Promise { const response = await responsePromise expect(response.ok(), `${operation}应返回成功状态,实际 HTTP ${response.status()}`).toBeTruthy() return response } async function waitForContentList(page: Page): Promise { 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 { 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 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 { 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 { 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 { 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 { 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 { 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((resolve) => { releaseResponse = resolve }) const serverStored = new Promise((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 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[0], session: ContentApiSession, projectId: string, detail: ContentDetailRecord, uploaded: UploadedAssetCommand, ): Promise { 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 未完成。', ) } } })