import { test, expect } from '@playwright/test' import path from 'node:path' import { signIn, freshScene, openEditor, ready, diagnostics, action, save, exportDocument, shot, evidence, closeSession, call, work } from './scene-editor-closure-helpers' test.afterEach(async ({ page }) => closeSession(page)) const choose = async (page:any,id:string) => page.locator(`#se-tree [data-object-id="${id}"]`).click() const rename = async (page:any,value:string) => { await page.locator('[data-inspector-field="name"]').fill(value); await page.locator('[data-inspector-field="name"]').press('Tab') } const uploadJSON = (page:any,content:unknown) => page.locator('#se-project-file').setInputFiles({name:'scene-review.json',mimeType:'application/json',buffer:Buffer.from(typeof content==='string'?content:JSON.stringify(content))}) test('工程 JSON 与小 GLB:真实上传、损坏拒绝、引用失败回滚、相机参数往返', async ({ page },info) => { const token=await signIn(page), copy=await freshScene(token,'truck','导入导出'), id=copy.project.id await openEditor(page,id); const initial=await diagnostics(page), exported=await exportDocument(page) expect(exported.schema).toBe('edit3dv4.scene'); expect(JSON.stringify(exported)).not.toContain('blob:') for(const invalid of ['{invalid', {schema:'another.kind',objects:[]}]) { await uploadJSON(page,invalid); await expect(page.locator('.se-toast.warn').last()).toBeVisible() expect((await diagnostics(page)).objects).toEqual(initial.objects) } const missing=structuredClone(exported), model=missing.objects.find((o:any)=>o.targetProjectId==='156') model.assetCode='SCENE-REVIEW-NOT-EXIST' await uploadJSON(page,missing) await expect(page.locator('.se-toast.warn').last()).toContainText(/失败/) await ready(page); expect((await diagnostics(page)).objects).toEqual(initial.objects) await shot(page,info,'10-invalid-reference-rolled-back') await expect(page.locator('.se-toast.warn')).toHaveCount(0) await page.locator('#se-model-file').setInputFiles({name:'broken.glb',mimeType:'model/gltf-binary',buffer:Buffer.from('broken')}) await expect(page.locator('.se-toast.warn').last()).toContainText('模型导入失败'); await ready(page) expect((await diagnostics(page)).objectCount).toBe(initial.objectCount) await expect(page.locator('.se-toast.warn')).toHaveCount(0) const uploadEndpoint=`**/api/tran/v1/content/projects/${id}/assets/upload` await page.route(uploadEndpoint,route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:503,message:'验收模拟资源上传失败'})})) const asset=path.resolve(work,'../../文档/素材/01-Box.glb') await page.locator('#se-model-file').setInputFiles(asset) await expect(page.locator('.se-toast.warn').last()).toContainText(/模型导入失败.*503/); await ready(page) expect((await diagnostics(page)).objectCount).toBe(initial.objectCount); await page.unroute(uploadEndpoint) await expect(page.locator('.se-toast.warn')).toHaveCount(0) await page.locator('#se-model-file').setInputFiles(asset) await expect.poll(async()=> (await diagnostics(page)).objectCount,{timeout:60000}).toBe(initial.objectCount+1) await ready(page) const loaded=await diagnostics(page), added=loaded.objects.find((o:any)=>!initial.objects.some((b:any)=>b.sceneId===o.sceneId)) expect(added.assetCode).toBeTruthy(); await choose(page,added.sceneId); await action(page,'focus').click() await save(page); await shot(page,info,'11-small-glb-real-upload') const document=await exportDocument(page) document.rendering.camera.position=[12,10,16]; document.rendering.camera.target=[0,1,0] document.rendering.toneMappingExposure=0.9;document.rendering.fog={color:0xaabbcc,near:60,far:240} document.rendering.lights.hemisphere.intensity=1.4;document.rendering.lights.directional.intensity=2.9 await uploadJSON(page,document); await ready(page) await expect.poll(async()=> (await diagnostics(page)).dirty).toBe(true) await save(page); await page.reload(); await ready(page) const reopened=await diagnostics(page) expect(reopened.objectCount).toBe(initial.objectCount+1) for(let i=0;i<3;i++) expect(reopened.cameraPosition[i]).toBeCloseTo(document.rendering.camera.position[i],3) const after=(await call(`tran/v1/content/projects/${id}`,token)).data expect(after.currentVersion.content.rendering.toneMappingExposure).toBe(0.9) expect(after.currentVersion.content.rendering.fog).toEqual(document.rendering.fog) expect(after.currentVersion.content.rendering.lights.hemisphere.intensity).toBe(1.4) const savedAsset=after.currentVersion.assets.find((a:any)=>a.code===added.assetCode) expect(savedAsset.type).toBe('MODEL_FILE'); expect(savedAsset.status).toBe('READY') const savedObject=after.currentVersion.content.objects.find((o:any)=>o.sceneId===added.sceneId) expect(savedObject.resourceUrl).toMatch(/^content:\/\/asset\/scene\//) await shot(page,info,'12-json-glb-reopened') evidence('json-glb-roundtrip',{projectId:id,initial,added,savedObject,reopened,rendering:after.currentVersion.content.rendering}) }) test('保存失败与封面降级:503 保留草稿,封面上传失败保留旧图,恢复后可重试', 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=initial.objects.find((o:any)=>o.targetProjectId==='157') await choose(page,fox.sceneId); await rename(page,'失败后继续保存的狐狸') const putEndpoint=`**/api/tran/v1/content/projects/${id}` await page.route(putEndpoint,route=>route.request().method()==='PUT'?route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:503,message:'验收模拟保存服务不可用'})}):route.continue()) await action(page,'save').click(); await expect(page.locator('.se-toast.warn').last()).toContainText(/保存失败/) expect((await diagnostics(page)).dirty).toBe(true) expect((await call(`tran/v1/content/projects/${id}`,token)).data.project.version).toBe(copy.project.version) await shot(page,info,'13-save-failure-keeps-draft'); await page.unroute(putEndpoint) const uploadEndpoint=`**/api/tran/v1/content/projects/${id}/assets/upload` await page.route(uploadEndpoint,route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:503,message:'验收模拟封面上传失败'})})) await action(page,'save').click(); await expect(page.locator('.se-toast.warn').last()).toContainText(/封面/) await expect.poll(async()=> (await diagnostics(page)).dirty).toBe(false) const degraded=(await call(`tran/v1/content/projects/${id}`,token)).data expect(degraded.project.coverUri).toBe(copy.project.coverUri) expect(degraded.currentVersion.content.objects.find((o:any)=>o.sceneId===fox.sceneId).name).toBe('失败后继续保存的狐狸') await shot(page,info,'14-cover-failure-keeps-old-image'); await page.unroute(uploadEndpoint) await rename(page,'网络恢复后的狐狸'); await save(page); await page.reload(); await ready(page) const recovered=await diagnostics(page); expect(recovered.objects.find((o:any)=>o.sceneId===fox.sceneId).name).toBe('网络恢复后的狐狸') await shot(page,info,'15-save-retry-success') evidence('save-cover-failure',{projectId:id,originalVersion:copy.project.version,degradedVersion:degraded.project.version,coverPreserved:true,recovered}) }) test('同版本恢复与 409:真实未保存草稿重载、服务器新版本保护与隔离下载', async ({ page },info) => { const token=await signIn(page), copy=await freshScene(token,'truck','恢复冲突'), id=copy.project.id await openEditor(page,id); const first=await diagnostics(page), truck=first.objects.find((o:any)=>o.targetProjectId==='156') await choose(page,truck.sceneId); await rename(page,'真实未保存卡车恢复') await expect.poll(()=>page.evaluate(()=>Object.keys(localStorage).filter(k=>k.includes('scene')&&k.includes('recovery')).length)).toBeGreaterThan(0) await page.reload() await expect(page.getByRole('button',{name:/恢复.*(副本|草稿|修改)|使用恢复/})).toBeVisible({timeout:60000}) await page.getByRole('button',{name:/恢复.*(副本|草稿|修改)|使用恢复/}).click(); await ready(page) expect((await diagnostics(page)).dirty).toBe(true) expect((await call(`tran/v1/content/projects/${id}`,token)).data.project.version).toBe(copy.project.version) await shot(page,info,'16-real-unsaved-recovery'); await save(page) const current=(await call(`tran/v1/content/projects/${id}`,token)).data await choose(page,truck.sceneId); await rename(page,'旧会话冲突中的卡车') const external=await call(`tran/v1/content/projects/${id}`,token,{ ...current.project, name:current.project.name, content:current.currentVersion.content, assets:current.currentVersion.assets, dependencies:current.currentVersion.dependencies, version:current.project.version, },'PUT') expect(external.status).toBe(200) await action(page,'save').click(); await expect(page.locator('.se-toast.warn').last()).toContainText(/其他人|版本|冲突/) expect((await diagnostics(page)).dirty).toBe(true) await shot(page,info,'17-conflict-does-not-overwrite') await page.reload(); await expect(page.locator('.el-message-box')).toBeVisible({timeout:60000}) const prompt=await page.locator('.el-message-box').innerText() await shot(page,info,'18-stale-recovery-explicit-decision') const downloadPromise=page.waitForEvent('download') await page.getByRole('button',{name:/下载/}).last().click(); const download=await downloadPromise const file=path.join(work,`scene-conflict-${id}.json`); await download.saveAs(file) await ready(page) expect((await diagnostics(page)).objects.find((o:any)=>o.sceneId===truck.sceneId).name).toBe('真实未保存卡车恢复') evidence('real-recovery-conflict',{projectId:id,prompt,originalVersion:copy.project.version,newVersion:external.data.project.version,recovered:await diagnostics(page)}) }) test('发布:前端绑定校验、保存与发布失败阻断、真实固定版本供训练目录使用', async ({ page },info) => { const token=await signIn(page), copy=await freshScene(token,'truck','发布'), id=copy.project.id await openEditor(page,id); const initial=await diagnostics(page), truck=initial.objects.find((o:any)=>o.targetProjectId==='156') await choose(page,truck.sceneId); const input=page.locator('[data-inspector-field="deviceId"]') await input.fill(''); await input.press('Tab'); await action(page,'verify').click() await expect(page.locator('#se-bottom-content')).toContainText(/设备/) await action(page,'publish').click(); await expect(page.locator('.se-toast.warn').last()).toContainText(/检查未通过/) expect((await call(`tran/v1/content/projects/${id}`,token)).data.project.status).toBe('DRAFT') await input.fill('SCENE-TRUCK-CLOSURE'); await input.press('Tab') const endpoint=`**/api/tran/v1/content/projects/${id}/publish` await page.route(endpoint,route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:503,message:'验收模拟发布失败'})})) await action(page,'publish').click(); await expect(page.locator('.se-toast.warn').last()).toContainText(/发布失败/) expect((await call(`tran/v1/content/projects/${id}`,token)).data.project.status).toBe('DRAFT') await shot(page,info,'19-publish-failure-stays-draft'); await page.unroute(endpoint) await action(page,'publish').click(); await expect(page.locator('#se-save-state')).toHaveText('已发布',{timeout:60000}) const published=(await call(`tran/v1/content/projects/${id}`,token)).data expect(published.project.status).toBe('PUBLISHED'); expect(published.project.publishedVersionId).toBe(published.currentVersion.id) const modelDependency=published.currentVersion.dependencies.find((d:any)=>d.targetProjectId==='156') expect(modelDependency.targetVersionId).toBe(truck.targetVersionId); expect(modelDependency.required).toBe(true) const catalog=await call('tran/v1/content/catalog?type=SCENE&size=100',token) expect(catalog.status).toBe(200) const row=(catalog.data.records||catalog.data).find((r:any)=>r.projectId===id) expect(row).toBeTruthy() await shot(page,info,'20-scene-published-fixed-version') await action(page,'back').click();await expect(page).toHaveURL(/\/content\/scenes(?:\?|$)/) await page.getByPlaceholder('搜索场景名称或编码').fill(copy.project.name) await page.getByRole('button',{name:'搜索',exact:true}).click() await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0) await expect(page.locator('.asset-row-list > article')).toHaveCount(1) const card=page.locator('.asset-row-list > article').filter({hasText:copy.project.name}) await expect(card).toHaveCount(1);await expect(card).toContainText('已发布') await expect.poll(()=>card.locator('img').evaluate((img:any)=>img.complete&&img.naturalWidth>0)).toBe(true) await shot(page,info,'21-published-scene-cover-in-list') evidence('publish-fixed-version',{projectId:id,versionId:published.currentVersion.id,dependency:modelDependency,catalog:row,coverUri:published.project.coverUri}) })