import path from 'node:path' import { expect, test, type Frame, type Page } from '@playwright/test' import { call, closeSession, evidence, fixture, openEditor, readyFrame, saveEditor, selectMesh, shot, signIn, work, writeFixture } from './model-editor-closure-helpers' // These tests own independent server copies. No shared fox/truck draft is edited. // All editor changes use real DOM inputs/buttons/pointers; evaluate() only reads state. test.setTimeout(360_000) test.afterEach(async ({ page }) => { await closeSession(page) }) async function copyFor(token: string, name: string) { const sourceId = String(fixture().originals.fox.id) const source = await call(`tran/v1/content/projects/${sourceId}`, token) expect(source.status, source.message).toBe(200) const copied = await call(`tran/v1/content/projects/${sourceId}/copy`, token, { name: `闭环补充-${name}-${Date.now().toString(36)}`, version: source.data.project.version, }) expect(copied.status, copied.message).toBe(200) const id = String(copied.data.project.id) expect([sourceId, '156', '157']).not.toContain(id) const data = fixture() data.extraProjectIds = [...new Set([...(data.extraProjectIds || []), id])] data.navigationCopies = { ...(data.navigationCopies || {}), [name]: id } writeFixture(data) return id } async function persisted(page: Page, frame: Frame, token: string, id: string) { const response = page.waitForResponse(response => response.request().method() === 'PUT' && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`), { timeout: 60_000 }) await saveEditor(frame) expect((await response).status()).toBe(200) const saved = await call(`tran/v1/content/projects/${id}`, token) expect(saved.status, saved.message).toBe(200) return saved.data } async function activate(frame: Frame, tab: string) { await frame.locator(`[data-bottom-tab="${tab}"]`).click() await expect(frame.locator(`[data-mode="${tab}"]`)).toHaveClass(/active/) } test('补充资源库:搜索清空、真实卡片切换与刷新保留', async ({ page }, info) => { const token = await signIn(page), id = await copyFor(token, '资源库') let frame = await openEditor(page, id) const original = await frame.evaluate(() => { const e = (window as any).editorApp return { id: e.activeDescriptor.id, name: e.activeDescriptor.name, code: e.activeDescriptor.assetCode } }) await frame.locator('[data-left-tab="assets"]').click() const search = frame.locator('#left-search') await search.fill(original.name) await expect(frame.locator(`[data-model="${original.id}"]`)).toBeVisible() await search.fill('NO-MATCH-资源搜索-193746') await expect(frame.locator('.asset-search-empty')).toBeVisible() await expect(frame.locator('.asset-card:visible')).toHaveCount(0) await search.fill('') await expect(frame.locator('.asset-search-empty')).toBeHidden() expect(await frame.locator('.asset-card:visible').count()).toBeGreaterThan(3) await shot(page, info, '41-resource-search-restored') // A small controlled model gives the real catalog a second registered asset. await frame.locator('#model-file-input').setInputFiles(path.resolve(work, '../../文档/素材/01-Box.glb')) await frame.waitForFunction(() => { const e = (window as any).editorApp return e.activeDescriptor?.sourceName === '01-Box.glb' && e.apiState === 'ready' && !e.apiImportBusy && !e.apiCoverSavePending }, undefined, { timeout: 90_000 }) const boxId = await frame.evaluate(() => (window as any).editorApp.activeDescriptor.id) expect(boxId).not.toBe(original.id) await expect(frame.locator(`[data-model="${original.id}"]`)).toBeVisible() const switched = page.waitForResponse(response => response.request().method() === 'PUT' && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`), { timeout: 60_000 }) await frame.locator(`[data-model="${original.id}"]`).click() expect((await switched).status()).toBe(200) await expect.poll(() => frame.evaluate(() => { const e = (window as any).editorApp return { id: e.activeDescriptor.id, ready: e.apiState === 'ready' && !e.apiSession.savePending } })).toEqual({ id: original.id, ready: true }) await page.reload(); frame = await readyFrame(page) const restored = await frame.evaluate(() => { const e = (window as any).editorApp; let meshes = 0 e.modelPivot.traverse((o: any) => { if (o.isMesh) meshes++ }) return { id: e.activeDescriptor.id, code: e.activeDescriptor.assetCode, meshes } }) expect(restored.id).toBe(original.id) expect(restored.code).toBe(original.code) expect(restored.meshes).toBeGreaterThan(0) await shot(page, info, '42-resource-card-switch-reopened') evidence('41-resource-search-switch', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['resource.search', 'resource.catalog'], projectId: id, original, boxId, restored, scope: '真实搜索与资源卡片点击、服务端保存、刷新后原模型网格;副本独立登记待清理。' }) }) test('补充环境与树:内置三环境保存重开、节点计数与折叠展开', async ({ page }, info) => { const token = await signIn(page), id = await copyFor(token, '内置环境') let frame = await openEditor(page, id) const snapshots: unknown[] = [] for (const [index, type] of ['workshop', 'outdoor', 'depot'].entries()) { await frame.locator('[data-left-tab="assets"]').click() await frame.locator('#left-search').fill('') await frame.locator(`[data-environment-action="builtin"][data-environment-type="${type}"]`).click() await frame.waitForFunction(type => { const e = (window as any).editorApp return e.outdoorSceneMeta.environmentType === type && e.outdoorSceneMeta.mode === 'placeholder' && e.loading.classList.contains('hidden') }, type, { timeout: 90_000 }) const saved = await persisted(page, frame, token, id) expect(saved.currentVersion.content.environment.environmentType).toBe(type) await page.reload(); frame = await readyFrame(page) const environment = await frame.evaluate(() => { const e = (window as any).editorApp; let nodes = 0, meshes = 0 e.outdoorRoot.traverse((o: any) => { nodes++; if (o.isMesh) meshes++ }) return { type: e.outdoorSceneMeta.environmentType, mode: e.outdoorSceneMeta.mode, sourceKind: e.outdoorRoot.userData.sourceKind, nodes, meshes, id: e.outdoorRoot.uuid } }) expect(environment.type).toBe(type) expect(environment.mode).toBe('placeholder') expect(environment.meshes).toBeGreaterThan(0) if (type === 'depot') expect(environment.sourceKind).toBe('builtin-gltf') await frame.locator('[data-left-tab="scene"]').click() await frame.locator('#left-search').fill('') const beforeCount = await frame.locator('.tree-row').count() const rootRow = frame.locator(`[data-object-id="${environment.id}"]`) await rootRow.locator('.tree-caret').click() expect(await frame.locator('.tree-row').count()).toBeLessThan(beforeCount) await rootRow.locator('.tree-caret').click() await expect(frame.locator('.tree-row')).toHaveCount(beforeCount) const actualCount = await frame.evaluate(() => { const e = (window as any).editorApp; let count = 0 e.getEditableRoots().forEach((root: any) => root.traverse(() => count++)) return count }) await expect(frame.locator('#scene-count')).toHaveText(`${actualCount} 对象`) await shot(page, info, `${43 + index}-builtin-${type}-reopened`) snapshots.push({ ...environment, shownRows: beforeCount, totalNodes: actualCount }) } const beforeLeave = await persisted(page, frame, token, id) expect(beforeLeave.currentVersion.content.environment.environmentType).toBe('depot') await page.getByRole('link', { name: '模型制作', exact: true }).click() await expect(page).toHaveURL(/\/content\/models$/) await expect(page.locator('.el-message-box').filter({ hasText: '当前模型有未保存修改' })).toBeHidden() const afterLeave = await call(`tran/v1/content/projects/${id}`, token) expect(afterLeave.status, afterLeave.message).toBe(200) expect(afterLeave.data.project.version).toBe(beforeLeave.project.version) expect(afterLeave.data.currentVersion.content.environment.environmentType).toBe('depot') await shot(page, info, '66-depot-saved-return-list') evidence('43-builtin-environments-tree', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['environment.builtin', 'tree.browse', 'tree.expand', 'project.leave'], projectId: id, snapshots, depotSavedLeave: { promptedForUnsavedChanges: false, version: afterLeave.data.project.version }, scope: '真实切换、保存和刷新三种环境;树计数与展开折叠;装备车库保存后返回列表不误报未保存。树220行上限仍属已说明边界。' }) }) test('补充视口与工作区:鼠标镜头、聚焦、面板、预览导航、日志与自动保存', async ({ page }, info) => { const token = await signIn(page), id = await copyFor(token, '视口导航') let frame = await openEditor(page, id) const renamed = `闭环导航名称-${id}` const renamedResponse = page.waitForResponse(response => response.request().method() === 'PUT' && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`)) await frame.locator('#project-name').fill(renamed) await frame.locator('#project-name').press('Enter') expect((await renamedResponse).status()).toBe(200) const mesh = await selectMesh(frame) const identity = await frame.evaluate(() => { const e = (window as any).editorApp return { selectedId: e.selected.userData.editorId, localPosition: e.selected.position.toArray() } }) expect(identity.selectedId).toBe(mesh.id) await frame.locator('[data-selection-scope="whole"]').click() expect(await frame.evaluate(() => { const e = (window as any).editorApp return e.selectionScope === 'whole' && e.selected === e.modelPivot })).toBe(true) await selectMesh(frame) expect(await frame.evaluate(() => (window as any).editorApp.selectionScope)).toBe('part') await expect(frame.locator('.property-row').filter({ hasText: '标识' }).locator('input')).toHaveAttribute('readonly', '') await expect(frame.locator('.property-row').filter({ hasText: '流程绑定' }).locator('input')).toHaveAttribute('readonly', '') await frame.locator('[data-inspector-tab="components"]').click() await expect(frame.locator('.component-card').filter({ hasText: 'Transform' })).toBeVisible() await expect(frame.locator('.component-card').filter({ hasText: 'MeshRenderer' })).toBeVisible() await expect(frame.locator('.component-card').filter({ hasText: 'Shadow' })).toBeVisible() expect(await frame.evaluate(() => (window as any).editorApp.selected.isMesh)).toBe(true) await frame.locator('[data-inspector-tab="properties"]').click() await frame.locator('[data-action="focus"]').first().click() const camera = () => frame.evaluate(() => { const e = (window as any).editorApp return { position: e.camera.position.toArray(), target: e.orbit.target.toArray(), distance: e.camera.position.distanceTo(e.orbit.target) } }) const focused = await camera() expect(focused.distance).toBeGreaterThan(0) // Focus target is the object's bounds center, which may differ from its origin. // The orbit target itself must be centered and inside the camera frustum. const targetScreen = await frame.evaluate(() => { const e = (window as any).editorApp return e.orbit.target.clone().project(e.camera).toArray() }) expect(Math.abs(targetScreen[0])).toBeLessThan(0.01) expect(Math.abs(targetScreen[1])).toBeLessThan(0.01) const canvas = frame.locator('#render-host canvas'), box = await canvas.boundingBox() expect(box).not.toBeNull() const x = box!.x + box!.width * 0.16, y = box!.y + box!.height * 0.74 await page.mouse.move(x, y); await page.mouse.down() await page.mouse.move(x + 90, y - 20, { steps: 12 }); await page.mouse.up() await expect.poll(async () => JSON.stringify((await camera()).position)).not.toBe(JSON.stringify(focused.position)) const rotated = await camera() await page.mouse.move(x, y); await page.mouse.down({ button: 'right' }) await page.mouse.move(x + 45, y + 25, { steps: 10 }); await page.mouse.up({ button: 'right' }) await expect.poll(async () => JSON.stringify((await camera()).target)).not.toBe(JSON.stringify(rotated.target)) const panned = await camera() await page.mouse.move(x, y); await page.mouse.wheel(0, -220) await expect.poll(async () => (await camera()).distance).toBeLessThan(panned.distance) expect(await frame.evaluate(() => (window as any).editorApp.selected.position.toArray())).toEqual(identity.localPosition) const panelModes = [] for (const mode of ['timeline', 'blueprint', 'curve', 'console']) { await activate(frame, mode) panelModes.push(await frame.evaluate(() => (window as any).editorApp.bottomMode)) } expect(panelModes).toEqual(['timeline', 'blueprint', 'curve', 'console']) await expect(frame.locator('#console-root .log-row').first()).toBeVisible() const logCount = await frame.evaluate(() => (window as any).editorApp.consoleRows.length) expect(logCount).toBeGreaterThan(0) await expect(frame.locator('#log-badge')).toHaveText(String(logCount)) const splitter = await frame.locator('#splitter-h').boundingBox() const beforeHeight = await frame.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bottom-h')) await page.mouse.move(splitter!.x + splitter!.width / 2, splitter!.y + splitter!.height / 2) await page.mouse.down(); await page.mouse.move(splitter!.x + splitter!.width / 2, splitter!.y - 35, { steps: 8 }); await page.mouse.up() expect(await frame.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bottom-h'))).not.toBe(beforeHeight) await activate(frame, 'timeline') await canvas.click({ position: { x: 15, y: 120 } }) await page.keyboard.press('Tab') await expect(frame.locator('[data-mode="blueprint"]')).toHaveClass(/active/) await activate(frame, 'timeline') await frame.locator('#timeline-root [data-action="stop"]').click() const duration = await frame.evaluate(() => (window as any).editorApp.timeline.duration) await frame.locator('[data-action="preview"]').first().click() await frame.locator('[data-preview-action="next"]').click() expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(Math.min(duration, 2)) await frame.locator('[data-preview-action="previous"]').click() expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0) await shot(page, info, '46-preview-navigation') await page.keyboard.press('Escape') await expect.poll(() => frame.evaluate(() => (window as any).editorApp.preview)).toBe(false) await selectMesh(frame) const autoValue = `AUTO-SAVE-${id}` const automaticResponse = page.waitForResponse(response => response.request().method() === 'PUT' && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`) && String(response.request().postData() || '').includes(autoValue), { timeout: 55_000 }) await frame.locator('#custom-resource-code').fill(autoValue) await frame.locator('#custom-resource-code').press('Tab') // Do not click save: this explicitly waits for the real 30-second timer. expect((await automaticResponse).status()).toBe(200) await expect.poll(() => frame.evaluate(() => !(window as any).editorApp.apiSession.savePending)).toBe(true) await page.reload(); frame = await readyFrame(page) await selectMesh(frame) await expect(frame.locator('#project-name')).toHaveValue(renamed) await expect(frame.locator('#custom-resource-code')).toHaveValue(autoValue) await expect.poll(() => frame.locator('#fps-status').innerText()).toMatch(/^[1-9]\d*$/) const stats = await frame.evaluate(() => ({ objects: document.querySelector('#object-status')?.textContent, triangles: document.querySelector('#triangle-status')?.textContent, fps: document.querySelector('#fps-status')?.textContent })) expect(stats.objects).toMatch(/\d+ 节点 \/ \d+ 网格/) expect(Number(stats.triangles?.replaceAll(',', ''))).toBeGreaterThan(0) await activate(frame, 'console') await shot(page, info, '47-navigation-autosave-log') evidence('46-navigation-panels-autosave', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.name', 'project.autosave', 'view.orbit', 'view.focus', 'view.panels', 'view.preview-step', 'feedback.log', 'feedback.stats', 'property.identity', 'property.binding', 'selection.scope', 'component.inspect'], projectId: id, focused, rotated, panned, focusTargetScreen: targetScreen, panelModes, logCount, stats, scope: '真实鼠标镜头操作;聚焦只验证相机目标;真实30秒自动保存,无测试加速定时器;统计内存估值未当作性能结论。' }) })