import { test, expect } from '@playwright/test' import fs from 'node:fs' import path from 'node:path' import { signIn, freshScene, openEditor, ready, diagnostics, action, save, exportDocument, shot, evidence, closeSession, call, report, registerProject } from './scene-editor-closure-helpers' test.afterEach(async ({ page }) => closeSession(page)) const field = (page: any, name: string) => page.locator(`[data-inspector-field="${name}"]`) const select = async (page: any, id: string) => page.locator(`#se-tree [data-object-id="${id}"]`).click() const rootModel = (d: any, id: string) => d.objects.find((o: any) => o.targetProjectId === id) const byId = (d: any, id: string) => d.objects.find((o: any) => o.sceneId === id) const setField = async (page: any, name: string, value: string) => { await field(page, name).fill(value); await field(page, name).press('Tab') } test('狐狸:真实细分权限、全部属性、清空绑定、蒙皮保护、相机与封面保存重开', async ({ page }, info) => { const token = await signIn(page), copy = await freshScene(token, 'fox', '属性封面'), id = copy.project.id await openEditor(page, id) const before = await diagnostics(page), fox = rootModel(before, '157') expect(before.readOnly).toBe(false); expect(fox.parts.length).toBeGreaterThan(0) await expect(action(page, 'new')).toBeEnabled(); await expect(action(page, 'save')).toBeEnabled() await expect(page.locator('.se-project')).toHaveAttribute('title', `当前场景:${copy.project.name}`) await action(page, 'project').click() await select(page, fox.sceneId); await action(page, 'focus').click() await shot(page, info, '01-fox-actual-scene') await setField(page, 'name', '闭环验收狐狸目标') for (const [name, value] of [['x','-6.3'],['y','0.25'],['z','1.2'],['rx','5.5'],['ry','-24.5'],['rz','3.2']]) await setField(page, name, value) for (const [name, value] of [['sx','0.036'],['sy','0.04'],['sz','0.038']]) await setField(page, name, value) await setField(page, 'sx', '0'); expect(byId(await diagnostics(page), fox.sceneId).scale[0]).toBeGreaterThan(0) await setField(page, 'sx', '-1'); expect(byId(await diagnostics(page), fox.sceneId).scale[0]).toBeGreaterThan(0) await setField(page, 'sx', '0.036') await field(page, 'visible').uncheck(); expect(byId(await diagnostics(page), fox.sceneId).visible).toBe(false) await field(page, 'visible').check() await field(page, 'operable').uncheck(); expect(byId(await diagnostics(page), fox.sceneId).operable).toBe(false) await field(page, 'operable').check() for (const semantic of ['实训装备','工位设施','安全区域','采集设备','训练人员','环境设施']) { await field(page, 'semantic').selectOption(semantic) expect(byId(await diagnostics(page), fox.sceneId).semantic).toBe(semantic) } await field(page, 'semantic').selectOption('训练人员') await setField(page, 'deviceId', ''); await setField(page, 'modelId', '') await page.locator('[data-bottom-tab="binding"]').click() await expect(page.locator('#se-bottom-content')).toContainText('训练人员') await expect(page.locator('#se-bottom-content')).toContainText('未设置') await save(page) const saved = (await call(`tran/v1/content/projects/${id}`, token)).data const sourceObject = saved.currentVersion.content.objects.find((o: any) => o.sceneId === fox.sceneId) expect(sourceObject.deviceId).toBe(''); expect(sourceObject.modelId).toBe('') expect(saved.project.coverUri).toMatch(/^(content|fsvc):/) const cover = saved.currentVersion.assets.find((a: any) => a.code === 'SCENE_COVER') expect(cover.storageUri).toBe(saved.project.coverUri) const image = await page.request.get(`/api/tran/v1/content/projects/${id}/assets/${cover.id}/content`, { headers: { Authorization: `Bearer ${token}` } }) expect(image.ok()).toBe(true) fs.mkdirSync(path.join(report, 'covers'), { recursive: true }); fs.writeFileSync(path.join(report, 'covers/fox.jpg'), await image.body()) const storedCamera = saved.currentVersion.content.rendering.camera await page.reload(); await ready(page) const reopened = await diagnostics(page), restored = byId(reopened, fox.sceneId) expect(restored.position).toEqual(sourceObject.position); expect(restored.scale).toEqual(sourceObject.scale) expect(restored.rotation).toEqual(sourceObject.rotation); expect(restored.deviceId).toBe(''); expect(restored.modelId).toBe('') for (let i=0;i<3;i++) expect(reopened.cameraPosition[i]).toBeCloseTo(storedCamera.position[i], 4) await select(page, fox.sceneId) const part = restored.parts[0]; await select(page, part.sceneId) await expect(field(page, 'x')).toBeDisabled(); await expect(field(page, 'visible')).toBeDisabled() await expect(page.locator('#se-inspector')).toContainText('蒙皮部件仅支持查看与高亮') await action(page, 'delete').click(); expect(byId(await diagnostics(page), fox.sceneId).parts[0].sceneDeleted).toBe(false) await shot(page, info, '02-fox-reopened-skin-protection') evidence('fox-properties-cover', { projectId:id, original:before, savedObject:sourceObject, cover:{ id:cover.id,storageUri:cover.storageUri,sizeBytes:cover.sizeBytes }, reopened }) }) test('牛奶卡车:刚性部件独立变换、软删除、历史与保存基线一致', async ({ page }, info) => { const token = await signIn(page), copy = await freshScene(token, 'truck', '部件历史'), id = copy.project.id await openEditor(page, id) for(const mode of ['full','xray','hidden']) { const control=page.locator(`[data-garage-enclosure-mode="${mode}"]`) await control.click();await expect(control).toHaveAttribute('aria-pressed','true') const document=await exportDocument(page) expect(document.objects.find((o:any)=>o.name==='车库').enclosureMode).toBe(mode) await shot(page,info,`07-garage-${mode}`) } await save(page);await page.reload();await ready(page) await expect(page.locator('[data-garage-enclosure-mode="hidden"]')).toHaveAttribute('aria-pressed','true') const originalCount=(await diagnostics(page)).objectCount await page.locator('[data-left-tab="assets"]').click() await page.locator('[data-editor-asset-id][data-target-project-id="156"]').dblclick() await expect.poll(async()=>(await diagnostics(page)).objectCount).toBe(originalCount+1);await ready(page) await page.locator('[data-left-tab="outline"]').click() const initial = await diagnostics(page), truck = rootModel(initial, '156') const secondTruck=initial.objects.filter((o:any)=>o.targetProjectId==='156')[1] expect(secondTruck.sceneId).not.toBe(truck.sceneId) await select(page, truck.sceneId); await action(page, 'focus').click() const part = truck.parts.find((p: any) => p.capabilities.includes('transform')) expect(part).toBeTruthy(); await select(page, part.sceneId) await expect(field(page, 'x')).toBeEnabled() await setField(page, 'name', '独立部件验收') await setField(page, 'x', String(Number((part.position[0]+0.3).toFixed(3)))) const changed = await diagnostics(page) expect(byId(changed,truck.sceneId).position).toEqual(truck.position) await save(page) await action(page, 'delete').click() expect(byId(await diagnostics(page),truck.sceneId).parts.find((p:any)=>p.sceneId===part.sceneId).sceneDeleted).toBe(true) await action(page, 'undo').click(); await ready(page) await expect.poll(async()=>byId(await diagnostics(page),truck.sceneId).parts.find((p:any)=>p.sceneId===part.sceneId).sceneDeleted).toBe(false) await page.locator('.se-project').focus(); await page.keyboard.press('Control+Shift+Z') await expect.poll(async()=>byId(await diagnostics(page),truck.sceneId).parts.find((p:any)=>p.sceneId===part.sceneId).sceneDeleted).toBe(true) await page.keyboard.press('Control+z') await expect.poll(async()=>byId(await diagnostics(page),truck.sceneId).parts.find((p:any)=>p.sceneId===part.sceneId).sceneDeleted).toBe(false) await select(page, truck.sceneId); await setField(page,'name','保存后的卡车B'); await save(page) await action(page,'undo').click() await expect.poll(async()=> (await diagnostics(page)).dirty).toBe(true) await action(page,'redo').click(); await expect.poll(async()=> (await diagnostics(page)).dirty).toBe(false) await select(page, truck.sceneId) await field(page,'name').fill('输入框自身撤销'); await field(page,'name').press('Control+z') expect((await diagnostics(page)).objectCount).toBe(initial.objectCount) await field(page,'name').press('Tab'); await setField(page,'name','卡车部件闭环完成') await field(page,'name').press('Control+s'); await expect.poll(async()=> (await diagnostics(page)).dirty).toBe(false) await page.reload(); await ready(page) const reopened = await diagnostics(page), savedPart=byId(reopened,truck.sceneId).parts.find((p:any)=>p.sceneId===part.sceneId) expect(reopened.objects.map((o:any)=>o.sceneId)).toEqual(initial.objects.map((o:any)=>o.sceneId)) expect(byId(reopened,secondTruck.sceneId).parts).toEqual(secondTruck.parts) expect(savedPart.position).toEqual(byId(changed,truck.sceneId).parts.find((p:any)=>p.sceneId===part.sceneId).position) expect(savedPart.name).toBe('独立部件验收') await select(page, truck.sceneId); await action(page,'focus').click() await shot(page,info,'03-truck-part-history-reopened') evidence('truck-part-history',{projectId:id,partId:part.sceneId,initial,changed,reopened}) }) test('新建独立场景:取消、失败、保护原工程以及成功新 ID', async ({ page },info) => { const token=await signIn(page), copy=await freshScene(token,'fox','新建'), id=copy.project.id await openEditor(page,id); const initial=await diagnostics(page), fox=rootModel(initial,'157') await select(page,fox.sceneId); await setField(page,'name','新建前未保存狐狸') await action(page,'new').click(); await page.getByRole('button',{name:'取消',exact:true}).click() expect(page.url()).toContain(`/${id}/edit`); expect(byId(await diagnostics(page),fox.sceneId).name).toBe('新建前未保存狐狸') const endpoint='**/api/tran/v1/content/projects' await page.route(endpoint,route=>route.request().method()==='POST'?route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:503,message:'验收模拟创建暂不可用'})}):route.continue()) await action(page,'new').click(); await page.locator('.el-message-box__input input').fill('场景闭环-新建失败演练') await page.getByRole('button',{name:'创建并编辑',exact:true}).click() await page.getByRole('button',{name:'保存并离开',exact:true}).click() await expect(page.locator('.el-message--error')).toContainText('创建暂不可用') expect(page.url()).toContain(`/${id}/edit`); expect(byId(await diagnostics(page),fox.sceneId).name).toBe('新建前未保存狐狸') await expect(page.locator('.el-message--error')).toHaveCount(0) await setField(page,'name','新建失败仍可恢复的狐狸') const recovery = () => page.evaluate(projectId => Object.entries(localStorage) .filter(([key]) => key.includes(`edit3dv4:scene-api-recovery:${projectId}:session:`)) .map(([,value]) => JSON.parse(value)), id) await expect.poll(async()=>(await recovery()).length).toBeGreaterThan(0) await action(page,'new').click(); await page.locator('.el-message-box__input input').fill('场景闭环-放弃后创建失败演练') await page.getByRole('button',{name:'创建并编辑',exact:true}).click() await page.getByRole('button',{name:'放弃修改',exact:true}).click() await expect(page.locator('.el-message--error')).toContainText('创建暂不可用') expect((await diagnostics(page)).dirty).toBe(true) expect((await recovery())[0].document.objects.find((o:any)=>o.sceneId===fox.sceneId).name).toBe('新建失败仍可恢复的狐狸') expect((await call(`tran/v1/content/projects/${id}`,token)).data.currentVersion.content.objects.find((o:any)=>o.sceneId===fox.sceneId).name).toBe('新建前未保存狐狸') await shot(page,info,'04-new-failure-original-preserved'); await page.unroute(endpoint) await action(page,'new').click(); await page.locator('.el-message-box__input input').fill('场景闭环-真正新建工程') const response=page.waitForResponse(r=>r.url().endsWith('/api/tran/v1/content/projects')&&r.request().method()==='POST') await page.getByRole('button',{name:'创建并编辑',exact:true}).click() await page.getByRole('button',{name:'放弃修改',exact:true}).click() const result=await(await response).json(); registerProject(result.data.project) const newId=result.data.project.id; expect(newId).not.toBe(id) await expect(page).toHaveURL(new RegExp(`/content/scenes/${newId}/edit`)); await ready(page); await save(page) expect(await recovery()).toEqual([]) await page.reload(); await ready(page); await shot(page,info,'05-new-separate-scene') await openEditor(page,id) expect(byId(await diagnostics(page),fox.sceneId).name).toBe('新建前未保存狐狸') expect((await diagnostics(page)).dirty).toBe(false) await openEditor(page,newId) const previous=(await call(`tran/v1/content/projects/${id}`,token)).data expect(previous.currentVersion.content.objects.find((o:any)=>o.sceneId===fox.sceneId).name).toBe('新建前未保存狐狸') evidence('new-project',{sourceId:id,createdId:newId,sourceName:previous.project.name,current:await diagnostics(page)}) }) test('只读管理员:可浏览预览与导出,模板/资源/键盘/接口不能改场景', async ({ page },info) => { const token=await signIn(page,'admin') await openEditor(page,'160'); const before=await diagnostics(page) expect(before.readOnly).toBe(true) for(const name of ['new','open','save','undo','redo','snap','delete','publish']) await expect(action(page,name)).toBeDisabled() await page.locator('[data-left-tab="scenes"]').click() await expect(page.locator('[data-preset-id]').first()).toBeDisabled() await page.locator('[data-left-tab="assets"]').click() await expect(page.locator('[data-import-model]')).toBeDisabled() expect(await page.locator('[data-editor-asset-id]').first().getAttribute('draggable')).toBe('false') await page.locator('[data-left-tab="outline"]').click(); const fox=rootModel(before,'157'); await select(page,fox.sceneId) await expect(field(page,'name')).toBeDisabled(); await action(page,'focus').click() await action(page,'preview').click(); expect((await diagnostics(page)).previewing).toBe(true) await page.keyboard.press('Delete'); await page.keyboard.press('w'); await page.keyboard.press('Control+z') expect((await diagnostics(page)).objects).toEqual(before.objects) await page.keyboard.press('Escape'); expect((await diagnostics(page)).previewing).toBe(false) const exported=await exportDocument(page); expect(exported.objects.length).toBe(before.objectCount) const original=(await call('tran/v1/content/projects/160',token)).data const denied=await call('tran/v1/content/projects/160',token,{...original.project,content:original.currentVersion.content, dependencies:original.currentVersion.dependencies,assets:original.currentVersion.assets},'PUT'); expect(denied.status).toBe(403) await shot(page,info,'06-readonly-scene-permissions') evidence('readonly',{before,after:await diagnostics(page),directWriteStatus:denied.status}) })