|
- 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秒自动保存,无测试加速定时器;统计内存估值未当作性能结论。' })
- })
|