import { test, expect, type Page } from '@playwright/test' import { signIn, freshScene, openEditor, ready, diagnostics, action, save, shot, evidence, closeSession } from './scene-editor-closure-helpers' test.afterEach(async ({ page }) => closeSession(page)) const field = (page: Page, name: string) => page.locator(`[data-inspector-field="${name}"]`) const object = (d: any, id: string) => d.objects.find((o: any) => o.sceneId === id) const distance = (a: number[], b: number[]) => Math.hypot(...a.map((v, i) => v - b[i]!)) async function settle(page: Page) { let previous = '' await expect.poll(async () => { const d = await diagnostics(page) const current = [...d.cameraPosition, ...d.cameraTarget].map((n: number) => n.toFixed(3)).join(',') const same = previous === current; previous = current; return same }).toBe(true) } // Only project existing scene/gizmo geometry to pixels. All mutations below use // browser pointer/keyboard actions; this hook exposes no editor or write methods. async function installProjection(page: Page) { await page.route('**/src/features/guide-legacy/demo-src/scene-editor.js*', async route => { const response = await route.fetch() const body = await response.text() await route.fulfill({ response, body: body + `\n globalThis.__scenePointerRead = (kind, wanted) => { const e = activeSceneEditor; if (!e) return null; if(kind === 'axis') return e.transform.axis; const rect = e.renderer.domElement.getBoundingClientRect(); const project = v => { const p = v.clone().project(e.camera); return {x:rect.x+(p.x+1)*rect.width/2,y:rect.y+(1-p.y)*rect.height/2,z:p.z}; }; const visible = n => { for(let p=n;p;p=p.parent) if(!p.visible)return false; return true; }; const candidates=[]; if(kind==='gizmo') { const group=e.transform._gizmo.picker[e.transform.mode]; group.traverse(n=>{ if(n.name!==wanted || !n.geometry?.attributes?.position || !n.visible)return; const a=n.geometry.attributes.position, box=new THREE.Box3(); for(let i=0;in.userData.sceneId===wanted); root?.traverse(n=>{ if(!n.isMesh || !visible(n) || !n.geometry?.attributes?.position)return; const a=n.geometry.attributes.position,box=new THREE.Box3(); for(let i=0;in.visible),true),e.objects); if(picked===root || picked?.userData.sceneOwnerId===wanted)candidates.push({...p,sceneId:picked.userData.sceneId}); }); } return candidates.filter(p=>p.z>-1&&p.z<1&&p.x>rect.x+15&&p.xrect.y+65&&p.y new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) const candidates = await page.evaluate(axis => (window as any).__scenePointerRead('gizmo', axis), axis) let hit: any for (const point of candidates) { await page.mouse.move(point.x, point.y) if (await page.evaluate(() => (window as any).__scenePointerRead('axis')) === axis) { hit = point; break } } expect(hit, `visible ${axis} gizmo can be picked by a real mouse`).toBeTruthy() await page.mouse.down(); await page.mouse.move(hit.x + dx, hit.y + dy, { steps: 18 }); await page.mouse.up() } async function theme(page: Page, label: string) { await page.getByRole('button', { name: '界面与导航设置' }).click() const drawer = page.locator('.el-drawer').filter({ hasText: '界面与导航设置' }) await drawer.locator('.theme-options button').filter({ hasText: label }).click() await drawer.locator('.el-drawer__close-btn').click() await expect(drawer).toBeHidden() } test('真实相机操控、视口拾取、预览禁写和两尺寸主题可读性', async ({ page }, info) => { await installProjection(page) const token = await signIn(page), copy = await freshScene(token, 'fox', '视口主题') await openEditor(page, copy.project.id) const initial = await diagnostics(page), fox = initial.objects.find((o: any) => o.targetProjectId === '157') await page.locator(`#se-tree [data-object-id="${fox.sceneId}"]`).click(); await action(page, 'focus').click(); await settle(page) const canvas = page.locator('#se-viewport > canvas'), box = (await canvas.boundingBox())! const start = { x: box.x + box.width * .38, y: box.y + box.height * .66 } const beforeOrbit = await diagnostics(page) await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + 95, start.y - 38, { steps: 18 }); await page.mouse.up(); await settle(page) const orbited = await diagnostics(page); expect(distance(orbited.cameraPosition, beforeOrbit.cameraPosition)).toBeGreaterThan(.1) await page.mouse.move(start.x, start.y); await page.mouse.down({ button: 'right' }); await page.mouse.move(start.x + 70, start.y - 30, { steps: 18 }); await page.mouse.up({ button: 'right' }); await settle(page) const panned = await diagnostics(page); expect(distance(panned.cameraTarget, orbited.cameraTarget)).toBeGreaterThan(.1) await page.mouse.wheel(0, -250); await settle(page) const zoomed = await diagnostics(page); expect(distance(zoomed.cameraPosition, zoomed.cameraTarget)).toBeLessThan(distance(panned.cameraPosition, panned.cameraTarget)) await action(page, 'top').click(); await settle(page) expect((await diagnostics(page)).cameraPosition).not.toEqual(zoomed.cameraPosition) await page.locator('[data-camera="perspective"]').click(); await settle(page) expect(distance((await diagnostics(page)).cameraPosition, zoomed.cameraPosition)).toBeLessThan(.02) const grid = (await diagnostics(page)).gridVisible; await action(page, 'grid').click(); expect((await diagnostics(page)).gridVisible).toBe(!grid); await action(page, 'grid').click() await page.locator(`#se-tree [data-object-id="${fox.sceneId}"]`).click(); await action(page, 'focus').click(); await settle(page) const points = await page.evaluate(id => (window as any).__scenePointerRead('mesh', id), fox.sceneId) expect(points.length).toBeGreaterThan(0); await page.mouse.click(points[0].x, points[0].y) expect((await diagnostics(page)).selectedSceneId).toBe(points[0].sceneId) await theme(page, '暗色') await shot(page, info, '40-viewport-dark-1920') await page.setViewportSize({ width: 1440, height: 1000 }); await theme(page, '军工绿') const inspectorBounds = (await page.locator('.se-right').boundingBox())! expect(inspectorBounds.x + inspectorBounds.width).toBeLessThanOrEqual(1440) await expect.poll(async () => { const b = (await canvas.boundingBox())!; return b.x + b.width <= inspectorBounds.x }).toBe(true) const colors = await page.locator('[data-inspector-field="x"]').evaluate(el => ({ text: getComputedStyle(el).color, background: getComputedStyle(el).backgroundColor })) expect(colors.text).not.toBe(colors.background) await shot(page, info, '41-viewport-light-1440') await page.setViewportSize({ width: 1920, height: 1080 }) const beforePreview = await diagnostics(page) await action(page, 'preview').click(); expect((await diagnostics(page)).previewing).toBe(true) await expect(action(page, 'save')).toBeDisabled(); await expect(field(page, 'name')).toBeDisabled() await page.keyboard.press('Delete'); await page.keyboard.press('w'); await page.keyboard.press('e'); await page.keyboard.press('r') expect((await diagnostics(page)).objects).toEqual(beforePreview.objects) await shot(page, info, '42-fox-preview-original-material') await page.keyboard.press('Escape'); expect((await diagnostics(page)).previewing).toBe(false); await expect(action(page, 'save')).toBeEnabled() evidence('viewport-camera-theme', { status: 'PASS', kind: '真实UI+只读投影', projectId: copy.project.id, featureIds: ['view.orbit','view.pan','view.zoom','view.top','view.perspective','view.grid','view.focus','selection.viewport','selection.highlight','preview.enter','preview.exit','preview.lock','lifecycle.resize-theme'], colors, screenshots: ['40-viewport-dark-1920','41-viewport-light-1440','42-fox-preview-original-material'] }) }) test('真实变换手柄移动旋转缩放、吸附与保存重开', async ({ page }, info) => { await installProjection(page) const token = await signIn(page), copy = await freshScene(token, 'truck', '变换手柄') await openEditor(page, copy.project.id) await page.locator('[data-left-tab="scenes"]').click(); await page.locator('[data-preset-id="workshop-1"]').click(); await ready(page) await page.locator('[data-left-tab="assets"]').click(); await page.locator('[data-asset-id="equipment"]').dblclick() const added = (await diagnostics(page)).objects[0]; expect(added).toBeTruthy() await action(page, 'focus').click(); await settle(page) await page.keyboard.press('w'); expect((await diagnostics(page)).transformMode).toBe('translate') await dragGizmo(page, 'X', 85, -12) const moved = object(await diagnostics(page), added.sceneId); expect(moved.position).not.toEqual(added.position); expect(moved.position[0] * 2).toBeCloseTo(Math.round(moved.position[0] * 2), 4) await page.keyboard.press('e'); expect((await diagnostics(page)).transformMode).toBe('rotate') await dragGizmo(page, 'Y', 70, 45) const rotated = object(await diagnostics(page), added.sceneId); expect(rotated.rotation).not.toEqual(moved.rotation); expect(rotated.rotation[1] / (Math.PI / 12)).toBeCloseTo(Math.round(rotated.rotation[1] / (Math.PI / 12)), 4) await action(page, 'snap').click(); await expect(page.locator('#se-status-snap')).toHaveText('关闭') await page.keyboard.press('r'); expect((await diagnostics(page)).transformMode).toBe('scale') await dragGizmo(page, 'X', 70, -20) expect(object(await diagnostics(page), added.sceneId).scale[0]).toBe(.000001) await action(page, 'undo').click() await expect.poll(async () => object(await diagnostics(page), added.sceneId).scale).toEqual(rotated.scale) await dragGizmo(page, 'X', -30, 10) const scaled = object(await diagnostics(page), added.sceneId); expect(scaled.scale).not.toEqual(rotated.scale); expect(scaled.scale.every((v: number) => v > 0)).toBe(true) await action(page, 'snap').click(); await expect(page.locator('#se-status-snap')).toHaveText('0.5m') await shot(page, info, '43-real-transform-gizmo') await save(page); await page.reload(); await ready(page) const reopened = object(await diagnostics(page), added.sceneId) expect(reopened.position).toEqual(scaled.position); expect(reopened.rotation).toEqual(scaled.rotation); expect(reopened.scale).toEqual(scaled.scale) await expect(page.getByText('旧版本恢复稿', { exact: false })).toBeHidden() evidence('viewport-transform', { status: 'PASS', kind: '真实UI手柄+只读几何投影定位', projectId: copy.project.id, featureIds: ['transform.translate','transform.rotate','transform.scale','transform.keys','transform.snap'], before: added, after: reopened, screenshots: ['43-real-transform-gizmo'] }) }) test('缓存标签暂停键盘、草稿恢复以及关闭取消和保存失败留页', async ({ page }, info) => { const token = await signIn(page), copy = await freshScene(token, 'fox', '缓存关闭') await openEditor(page, copy.project.id) const fox = (await diagnostics(page)).objects.find((o: any) => o.targetProjectId === '157') await page.locator(`#se-tree [data-object-id="${fox.sceneId}"]`).click() await field(page, 'name').fill('缓存未保存狐狸'); await field(page, 'name').press('Tab') const dirty = await diagnostics(page); expect(dirty.dirty).toBe(true) await page.locator('.page-tab-link').filter({ hasText: '工作总览' }).click() await expect.poll(async () => (await diagnostics(page))?.paused).toBe(true) await page.keyboard.press('Delete'); await page.keyboard.press('w'); await page.keyboard.press('e'); await page.keyboard.press('r'); await page.keyboard.press('Control+z') expect((await diagnostics(page)).objects).toEqual(dirty.objects) await page.locator('.page-tab-link').filter({ hasText: '数字场景编辑' }).click(); await ready(page) expect((await diagnostics(page)).paused).toBe(false); expect((await diagnostics(page)).dirty).toBe(true); await expect(field(page, 'name')).toHaveValue('缓存未保存狐狸') await page.locator('.page-tab.active .page-tab-close').click() await expect(page.getByRole('dialog')).toBeVisible(); await page.keyboard.press('Escape') await expect(page.locator('.se-shell')).toBeVisible(); expect((await diagnostics(page)).dirty).toBe(true) await page.route(`**/api/tran/v1/content/projects/${copy.project.id}`, async route => { if (route.request().method() === 'PUT') return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'SERVICE_UNAVAILABLE', message: '视口关闭回归:模拟保存失败', data: null }) }) return route.continue() }) await page.locator('.page-tab.active .page-tab-close').click() await page.getByRole('button', { name: '关闭标签', exact: true }).click() await page.getByRole('button', { name: '保存并离开', exact: true }).click() await expect(page.locator('.se-toast.warn').filter({ hasText: '模拟保存失败' })).toBeVisible() await expect(page.locator('.se-shell')).toBeVisible(); expect((await diagnostics(page)).dirty).toBe(true) await expect(page.locator('.page-tab-link').filter({ hasText: '数字场景编辑' })).toBeVisible() await shot(page, info, '44-cache-close-save-failure') evidence('viewport-cache-close', { status: 'PASS', kind: '真实UI缓存/关闭+浏览器模拟503拒绝写入', projectId: copy.project.id, featureIds: ['lifecycle.cache'], screenshot: '44-cache-close-save-failure', originalDraftPreserved: true }) })