import { test, expect, type Page } from '@playwright/test' import { action, call, closeSession, diagnostics, evidence, exportDocument, freshScene, openEditor, ready, save, shot, signIn, } from './scene-editor-closure-helpers' const templates = [ { id: 'depot-1', name: '真实装备车库', type: '车间', count: 1, builtIn: 'vehicle-depot' }, { id: 'terrain-1', name: '真实野外训练场', type: '室外', count: 4, builtIn: 'field-terrain' }, { id: 'workshop-1', name: '综合检验车间', type: '车间', count: 0 }, { id: 'workshop-real', name: '演示维修车间(可拆分)', type: '车间', count: 74, builtIn: 'maintenance-workshop' }, { id: 'workshop-2', name: '总成拆卸车间', type: '车间', count: 3, types: ['equipment', 'workbench', 'shelf'] }, { id: 'workshop-3', name: '部件分解车间', type: '车间', count: 3, types: ['workbench', 'shelf', 'camera'] }, { id: 'workshop-4', name: '清洗检测车间', type: '车间', count: 4, types: ['diagnostic', 'workbench', 'camera', 'safety-zone'] }, { id: 'workshop-5', name: '复装调试车间', type: '车间', count: 4, types: ['diagnostic', 'workbench', 'light', 'worker'] }, { id: 'outdoor-1', name: '室外综合训练场', type: '室外', count: 3, types: ['equipment', 'safety-zone', 'light'] }, ] const basics = ['diagnostic', 'equipment', 'workbench', 'shelf', 'safety-zone', 'camera', 'worker', 'light'] const tab = (page: Page, name: string) => page.locator(`[data-left-tab="${name}"]`) const card = (page: Page, id: string) => page.locator(`[data-preset-id="${id}"]`) const field = (page: Page, name: string) => page.locator(`[data-inspector-field="${name}"]`) async function selectTemplate(page: Page, id: string) { await tab(page, 'scenes').click() await page.locator('#se-search').fill('') await card(page, id).click() const dialog = page.getByRole('dialog', { name: '切换场景模板' }) if (await dialog.isVisible()) await dialog.getByRole('button', { name: '放弃修改并切换' }).click() await expect.poll(async () => { const d = await diagnostics(page) return d?.currentPreset?.id === id && d.pendingLoads === 0 }, { timeout: 120_000 }).toBe(true) await expect(card(page, id)).toHaveClass(/active/) } async function getSaved(token: string, id: string) { const response = await call(`tran/v1/content/projects/${id}`, token) expect(response.status).toBe(200) expect(response.data.currentVersion).toBeTruthy() return response.data.currentVersion } async function realDrag(page: Page, source: ReturnType, x = 0.48, y = 0.57) { await source.scrollIntoViewIfNeeded() const start = await source.boundingBox(), target = await page.locator('#se-viewport canvas').boundingBox() expect(start).not.toBeNull(); expect(target).not.toBeNull() await page.mouse.move(start!.x + start!.width / 2, start!.y + start!.height / 2) await page.mouse.down() await page.mouse.move(start!.x + start!.width / 2 + 12, start!.y + start!.height / 2, { steps: 3 }) await page.mouse.move(target!.x + target!.width * x, target!.y + target!.height * y, { steps: 12 }) await page.mouse.up() } async function clearSelectionForScreenshot(page: Page) { const canvas = page.locator('#se-viewport canvas'), box = await canvas.boundingBox() expect(box).not.toBeNull() await canvas.click({ position: { x: box!.width - 28, y: 95 } }) await expect.poll(async () => (await diagnostics(page)).selectedSceneId).toBe('') await expect(page.locator('.se-toast, .el-message')).toHaveCount(0) } test.afterEach(async ({ page }) => { await closeSession(page) }) for (const [index, preset] of templates.entries()) { test(`场景模板实际加载、保存重开:${preset.name}`, async ({ page }, info) => { const token = await signIn(page), created = await freshScene(token, 'fox', preset.id) const id = created.project.id await openEditor(page, id) await selectTemplate(page, preset.id) const loaded = await diagnostics(page) expect(loaded.currentPreset).toEqual({ id: preset.id, name: preset.name, type: preset.type }) expect(loaded.objectCount).toBe(preset.count) if (preset.types) expect(loaded.objects.map((o: any) => o.type).sort()).toEqual([...preset.types].sort()) await expect(page.locator('#se-preset-badge')).toContainText(`${preset.type}环境`) await save(page) const saved = await getSaved(token, id) expect(saved.content.presetId).toBe(preset.id) expect(saved.content.environment.presetId).toBe(preset.id) if (preset.builtIn) expect(saved.content.objects.some((o: any) => o.builtinAssetId === preset.builtIn)).toBe(true) await page.reload(); await ready(page) const reopened = await diagnostics(page) expect(reopened.currentPreset.id).toBe(preset.id) expect(reopened.objectCount).toBe(preset.count) expect(reopened.dirty).toBe(false) await tab(page, 'scenes').click() await expect(card(page, preset.id)).toHaveClass(/active/) await expect(page.locator('#se-preset-badge')).toContainText(`${preset.type}环境`) const name = `${20 + index}-template-${preset.id}` await shot(page, info, name) evidence(`template-${preset.id}`, { kind: 'e2e', status: 'PASS', testTitle: info.title, featureIds: [`template.${preset.id}`], projectId: id, presetId: preset.id, objectCount: reopened.objectCount, bounds: reopened.contentBounds, scope: '真实模板按钮、选中标签、环境字段、种子对象、PUT 后刷新重开;截图验证对应模板实际画面。', screenshots: [`${name}.png`] }) }) } test('模板搜索与未保存切换确认保留和放弃', async ({ page }, info) => { const token = await signIn(page), created = await freshScene(token, 'fox', 'template-confirm') await openEditor(page, created.project.id) const original = await diagnostics(page), root = original.objects.find((o: any) => o.targetProjectId) await page.locator(`[data-object-id="${root.sceneId}"]`).first().click() await field(page, 'name').fill('模板确认保留对象'); await field(page, 'name').press('Tab') await tab(page, 'scenes').click() await page.locator('#se-search').fill('清洗') await expect(page.locator('.se-scene-card:visible')).toHaveCount(1) await expect(card(page, 'workshop-4')).toBeVisible() await page.locator('#se-search').fill('无匹配模板-closure') await expect(page.locator('.se-scene-card:visible')).toHaveCount(0) await page.locator('#se-search').fill(''); await expect(page.locator('.se-scene-card:visible')).toHaveCount(9) await card(page, 'workshop-1').click() const dialog = page.getByRole('dialog', { name: '切换场景模板' }) await expect(dialog).toBeVisible() await dialog.getByRole('button', { name: '继续编辑' }).click() expect((await diagnostics(page)).currentPreset.id).toBe(original.currentPreset.id) expect((await diagnostics(page)).objects.some((o: any) => o.name === '模板确认保留对象')).toBe(true) await card(page, 'workshop-1').click(); await dialog.getByRole('button', { name: '放弃修改并切换' }).click() await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(0) await expect(card(page, 'workshop-1')).toHaveClass(/active/) await save(page) const saved = await getSaved(token, created.project.id) expect(saved.content.presetId).toBe('workshop-1') await shot(page, info, '29-template-confirm-empty') evidence('template-confirm', { kind: 'e2e', status: 'PASS', testTitle: info.title, featureIds: ['panel.left-tabs', 'search.templates', 'template.switch-cancel'], projectId: created.project.id, scope: '中文/无匹配/清空搜索;真实取消保留对象,真实确认放弃并切换空白模板后保存。', screenshots: ['29-template-confirm-empty.png'] }) }) test('八种基础资源逐项放置、真实拖放、搜索和保存重开', async ({ page }, info) => { const token = await signIn(page), created = await freshScene(token, 'fox', 'basic-assets') await openEditor(page, created.project.id); await selectTemplate(page, 'workshop-1') await tab(page, 'assets').click() await page.locator('#se-search').fill('检修工作台') await expect(page.locator('.se-asset:visible')).toHaveCount(1) await expect(page.locator('[data-asset-id="workbench"]')).toBeVisible() await page.locator('#se-search').fill('无匹配资源-closure') await expect(page.locator('.se-asset:visible')).toHaveCount(0) await page.locator('#se-search').fill('') for (const [index, id] of basics.entries()) { await page.locator(`[data-asset-id="${id}"]`).dblclick() await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(index + 1) await field(page, 'x').fill(String((index % 4) * 4 - 6)); await field(page, 'x').press('Tab') await field(page, 'z').fill(String(Math.floor(index / 4) * 5 - 2)); await field(page, 'z').press('Tab') } await realDrag(page, page.locator('[data-asset-id="workbench"]'), 0.7, 0.65) await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(9) const placed = await diagnostics(page), dragged = placed.objects.at(-1) expect(dragged.type).toBe('workbench') expect(dragged.position.some((v: number) => Math.abs(v) > 0.1)).toBe(true) for (const axis of [0, 2]) expect(dragged.position[axis] * 2).toBeCloseTo(Math.round(dragged.position[axis] * 2), 5) await expect(page.locator('#se-drop-hint')).not.toHaveClass(/show/) expect(new Set(placed.objects.map((o: any) => o.sceneId)).size).toBe(9) expect(new Set(placed.objects.map((o: any) => o.deviceId)).size).toBe(9) await save(page); await page.reload(); await ready(page) const reopened = await diagnostics(page) expect(reopened.objects.map((o: any) => ({ type: o.type, position: o.position, deviceId: o.deviceId }))) .toEqual(placed.objects.map((o: any) => ({ type: o.type, position: o.position, deviceId: o.deviceId }))) await tab(page, 'outline').click() await expect(page.locator('.se-object-summary button')).toHaveCount(6) await expect(page.locator('.se-object-summary-note')).toContainText('共 9 个对象,此处显示前 6 个') const firstSummary = page.locator('.se-object-summary button').first() const firstSummaryId = await firstSummary.getAttribute('data-object-id') await firstSummary.click() expect((await diagnostics(page)).selectedSceneId).toBe(firstSummaryId) await expect(field(page, 'name')).toHaveValue(reopened.objects.find((o: any) => o.sceneId === firstSummaryId).name) await shot(page, info, '30-eight-basic-assets-reopened') evidence('basic-assets', { kind: 'e2e', status: 'PASS', testTitle: info.title, featureIds: [...basics.map(id => `asset.${id}`), 'assets.basic-double-click', 'assets.basic-drop', 'search.assets', 'panel.objects'], projectId: created.project.id, dragged: { position: dragged.position, type: dragged.type }, scope: '8 种基础资源分别双击放置并分开布置;真实指针拖入第 9 个工作台、吸附检查、唯一编号、保存刷新一致。照明为现有资源外观,不冒称连接真实灯控。', screenshots: ['30-eight-basic-assets-reopened.png'] }) }) test('两种整体场地加载、重复放置阻止与显式删除后替换', async ({ page }, info) => { const token = await signIn(page), created = await freshScene(token, 'fox', 'venue-assets') await openEditor(page, created.project.id); await selectTemplate(page, 'workshop-1') await tab(page, 'assets').click() await page.locator('[data-asset-id="vehicle-depot"]').dblclick() await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 1 && d.pendingLoads === 0 }, { timeout: 120000 }).toBe(true) const depot = await exportDocument(page) expect(depot.objects.some((o: any) => o.builtinAssetId === 'vehicle-depot')).toBe(true) await page.locator('[data-asset-id="field-terrain"]').dblclick() expect((await diagnostics(page)).objectCount).toBe(1) expect((await exportDocument(page)).objects.some((o: any) => o.builtinAssetId === 'field-terrain')).toBe(false) await save(page); await page.reload(); await ready(page) await shot(page, info, '31-venue-depot-reopened') const root = (await diagnostics(page)).objects[0] await page.locator(`[data-object-id="${root.sceneId}"]`).first().click() await action(page, 'delete').click() await expect.poll(async () => (await diagnostics(page)).objectCount).toBe(0) await tab(page, 'assets').click(); await page.locator('[data-asset-id="field-terrain"]').dblclick() await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 1 && d.pendingLoads === 0 }, { timeout: 120000 }).toBe(true) await save(page); await page.reload(); await ready(page) const saved = await getSaved(token, created.project.id) expect(saved.content.objects.some((o: any) => o.builtinAssetId === 'field-terrain')).toBe(true) expect(saved.content.objects.some((o: any) => o.builtinAssetId === 'vehicle-depot')).toBe(false) const terrain = await diagnostics(page) expect(terrain.contentBounds.size[1]).toBeGreaterThan(1) await shot(page, info, '32-venue-terrain-reopened') evidence('venue-assets', { kind: 'e2e', status: 'PASS', testTitle: info.title, featureIds: ['asset.vehicle-depot', 'asset.field-terrain', 'object.delete'], projectId: created.project.id, scope: '车库和起伏地形真实加载并分别保存重开;已有整体场地时阻止叠加,显式删除旧场地后才能替换。', screenshots: ['31-venue-depot-reopened.png', '32-venue-terrain-reopened.png'] }) }) test('模型成果狐狸卡车双击与真实拖入、树搜索和部件隐藏重开', async ({ page }, info) => { const token = await signIn(page), created = await freshScene(token, 'truck', 'published-model-assets') await openEditor(page, created.project.id); await selectTemplate(page, 'workshop-1') await tab(page, 'assets').click() const fox = page.locator('.se-editor-asset').filter({ hasText: /狐狸/ }).first() const truck = page.locator('.se-editor-asset').filter({ hasText: /牛奶卡车/ }).first() await expect(fox).toContainText('已发布'); await expect(truck).toContainText('已发布') const foxProjectId = await fox.getAttribute('data-target-project-id'), foxVersionId = await fox.getAttribute('data-target-version-id') const truckProjectId = await truck.getAttribute('data-target-project-id'), truckVersionId = await truck.getAttribute('data-target-version-id') await fox.dblclick() await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 1 && d.pendingLoads === 0 }).toBe(true) await field(page, 'x').fill('-3'); await field(page, 'x').press('Tab') await action(page, 'focus').click() await clearSelectionForScreenshot(page) await shot(page, info, '33-fox-placed-visible') await realDrag(page, truck, 0.7, 0.6) await expect.poll(async () => { const d = await diagnostics(page); return d.objectCount === 2 && d.pendingLoads === 0 }).toBe(true) await field(page, 'x').fill('3'); await field(page, 'x').press('Tab') await action(page, 'focus').click() const placed = await diagnostics(page), foxRoot = placed.objects.find((o: any) => String(o.targetProjectId) === foxProjectId) const truckRoot = placed.objects.find((o: any) => String(o.targetProjectId) === truckProjectId) expect(String(foxRoot.targetVersionId)).toBe(foxVersionId); expect(String(truckRoot.targetVersionId)).toBe(truckVersionId) expect(foxRoot.parts.length).toBeGreaterThan(0); expect(truckRoot.parts.length).toBeGreaterThan(0) await tab(page, 'outline').click() await page.locator('#se-search').fill(truckRoot.name) await expect(page.locator(`.se-tree-row[data-object-id="${truckRoot.sceneId}"]`)).toBeVisible() await expect(page.locator(`.se-tree-row[data-object-id="${foxRoot.sceneId}"]`)).toHaveCount(0) const part = truckRoot.parts.find((p: any) => p.capabilities.includes('transform')) expect(part).toBeTruthy() await page.locator('#se-search').fill(part.name) await expect(page.locator(`.se-tree-row[data-object-id="${part.sceneId}"]`)).toBeVisible() await page.locator('#se-search').fill('无匹配对象-closure'); await expect(page.locator('.se-tree-row')).toHaveCount(0) await page.locator('#se-search').fill('') await page.locator(`.se-tree-row[data-object-id="${part.sceneId}"]`).click() await expect(field(page, 'visible')).toBeEnabled(); await field(page, 'visible').uncheck() await expect.poll(async () => (await diagnostics(page)).objects.find((o: any) => o.sceneId === truckRoot.sceneId).parts.find((p: any) => p.sceneId === part.sceneId).visible).toBe(false) await save(page); await page.reload(); await ready(page) const reopened = await diagnostics(page), savedTruck = reopened.objects.find((o: any) => o.sceneId === truckRoot.sceneId) expect(savedTruck.parts.find((p: any) => p.sceneId === part.sceneId).visible).toBe(false) await page.locator(`.se-tree-row[data-object-id="${part.sceneId}"]`).click() await expect(field(page, 'visible')).not.toBeChecked() // Restore the deliberately hidden body part so the retained screenshot is a complete model. await field(page, 'visible').check(); await save(page) await page.locator(`.se-tree-row[data-object-id="${truckRoot.sceneId}"]`).click(); await action(page, 'focus').click() await clearSelectionForScreenshot(page) await shot(page, info, '34-truck-placed-part-restored') const saved = await getSaved(token, created.project.id) expect(saved.dependencies.filter((d: any) => d.kind === 'SCENE_MODEL' || d.relationType === 'SCENE_MODEL').length).toBe(2) evidence('published-model-assets', { kind: 'e2e', status: 'PASS', testTitle: info.title, featureIds: ['assets.catalog', 'assets.model-double-click', 'assets.model-drop', 'search.tree', 'part.source', 'property.visible'], projectId: created.project.id, sources: [{ projectId: foxProjectId, versionId: foxVersionId }, { projectId: truckProjectId, versionId: truckVersionId }], scope: 'API 已发布目录,狐狸双击和卡车真实拖入;固定来源版本、树根/部件搜索;卡车刚体部件隐藏保存重开,最后恢复可见。基线树没有折叠按钮,不将搜索伪称折叠。', screenshots: ['33-fox-placed-visible.png', '34-truck-placed-part-restored.png'] }) })