import fs from 'node:fs' import path from 'node:path' import assert from 'node:assert/strict' import { chromium, expect } from '@playwright/test' import { login, call } from './role-permissions-live.mjs' const report = path.resolve('reports/guide-closure-20260910') const work = path.resolve('../../.codex-tmp/guide-closure-20260910') fs.mkdirSync(report, { recursive:true }); fs.mkdirSync(work, { recursive:true }) const phase = process.argv[2] || 'create' const fixtureFile = path.join(work, 'fixture.json') const fixture = fs.existsSync(fixtureFile) ? JSON.parse(fs.readFileSync(fixtureFile, 'utf8')) : {} const checks = [], errors = [], requests = [] const browser = await chromium.launch({ channel:'msedge', headless:true, args:['--enable-unsafe-swiftshader'] }) const session = await login(phase === 'reader' ? 'admin' : 'root') const stateFile = path.join(work, 'browser.json') const context = await browser.newContext({ viewport:{ width:1920, height:1080 }, ...(fs.existsSync(stateFile) ? { storageState:stateFile } : {}) }) await context.addInitScript(s => { sessionStorage.setItem('unreal-tran:web:access-token:v1', s.accessToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', s.refreshToken) localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ themeMode:'military' })) }, session) const page = await context.newPage() page.on('pageerror', e => errors.push(e.message)) page.on('request', r => { if (r.url().includes('/api/tran/')) requests.push({ method:r.method(), path:new URL(r.url()).pathname }) }) const check = (name, pass, detail='') => { checks.push({ name, status:pass ? 'PASS':'FAIL', detail }); console.log(`${pass ? 'PASS':'FAIL'} ${name}`) } const shot = async name => { await page.screenshot({ path:path.join(report, `${name}.png`), animations:'disabled' }) } const open = async url => { await page.goto(`http://127.0.0.1:6180${url}`); await expect(page.locator('.legacy-guide-page')).toBeVisible({ timeout:60000 }) } const state = async () => page.evaluate(async id => (await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id)?.state, fixture.id) try { if (phase === 'create') { await open('/content/ofd') await page.getByRole('button', { name:'新建指导书', exact:true }).click() await page.locator('[data-wizard="name"]').fill('闭环实测-狐狸巡检作业指导-20260910') await page.locator('[data-wizard="code"]').fill('REVIEW-GUIDE-20260910') await page.locator('[data-wizard="equipment"]').fill('狐狸模型测试装备') await page.locator('[data-wizard="owner"]').fill('闭环验证') await page.locator('[data-action="wizard-next"]').click() await page.locator('[data-action="wizard-template"][data-template="__blank__"]').click() await page.locator('[data-action="wizard-next"]').click() await shot('01-create-wizard') await page.locator('[data-action="wizard-next"]').click() await page.waitForURL(/\/content\/ofd\/[^/]+\/studio\?view=analysis/) fixture.id = page.url().match(/\/ofd\/([^/]+)\/studio/)[1] fs.writeFileSync(fixtureFile, JSON.stringify(fixture)) await expect(page.locator('.ofd-view-analysis')).toBeVisible() const saved = await state() check('通过界面从空白模板创建指导书', saved?.project?.name.includes('闭环实测') && saved.chapters.length === 1) check('创建调用正式内容接口', requests.some(r=>r.method==='POST' && r.path.endsWith('/content/projects')), '当前页面使用浏览器本地工程库') const server = await call(`tran/v1/content/projects?type=GUIDE&keyword=${encodeURIComponent('闭环实测-狐狸巡检作业指导-20260910')}`, session.accessToken) check('新建工程出现在正式后端列表', server.status===200 && server.data.records.some(r=>r.name.includes('闭环实测-狐狸'))) await shot('02-planning') await page.locator('[data-action="goto"][data-view="editor"]').first().click() await expect(page.locator('.ofd-view-editor')).toBeVisible() await shot('03-editor-baseline') } if (phase === 'inspect') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) await expect(page.locator('.ofd-view-editor')).toBeVisible() const dom = await page.locator('.ofd-app').evaluate(el=>({ actions:[...el.querySelectorAll('[data-action]')].map(x=>({action:x.dataset.action,text:(x.innerText||x.getAttribute('title')||'').slice(0,60),view:x.dataset.view,field:x.dataset.field})), fields:[...el.querySelectorAll('input,textarea,select')].map(x=>({field:x.dataset.field,stepField:x.dataset.stepField,projectField:x.dataset.projectField,placeholder:x.getAttribute('placeholder'),type:x.type,value:x.type==='password'?'':x.value.slice(0,80)})), })) fs.writeFileSync(path.join(work,'dom.json'),JSON.stringify(dom,null,2)) console.log(JSON.stringify(dom,null,2)) } if (phase === 'edit') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) const first = (await state()).chapters[0].steps[0].id fixture.firstStep = first for (const [field,value] of Object.entries({title:'狐狸巡检目标识别',summary:'定位狐狸模型,观察目标外观,并逐项记录本次巡检结果。',tools:'测试模型、观察记录表',safety:'仅使用测试模型,不作为真实装备维修规程。',acceptance:'模型显示完整,能够识别并记录目标。'})) { await page.locator(`[data-step-field="${field}"]`).fill(value) await page.locator(`[data-step-field="${field}"]`).blur() } await page.locator('[data-action="save"]').click() await expect.poll(async()=> (await state()).chapters[0].steps[0].title).toBe('狐狸巡检目标识别') check('步骤标题、说明、工具、安全、验收编辑保存', (await state()).chapters[0].steps[0].summary.includes('逐项记录')) await page.reload(); await expect(page.locator('[data-step-field="title"]')).toHaveValue('狐狸巡检目标识别') check('刷新后恢复已保存的本地内容', true) const before = (await state()).chapters.flatMap(c=>c.steps).length await page.locator('[data-action="add-step"]').click() await page.locator('[data-step-field="title"]').fill('牛奶卡车状态检查') await page.locator('[data-step-field="title"]').blur() await page.locator('[data-action="save"]').click() check('新增步骤后保存', (await state()).chapters.flatMap(c=>c.steps).length===before+1) await page.locator('[data-action="page-add-for-step"]').last().click() await page.locator('[data-action="save"]').click() const pages=(await state()).documentPages.length await page.locator('[data-action="page-duplicate"]').click() await page.locator('[data-action="save"]').click() check('正文续页和复制页面保存', (await state()).documentPages.length===pages+1) await page.locator('[data-action="undo"]').click(); await page.locator('[data-action="save"]').click() check('撤销复制恢复页数', (await state()).documentPages.length===pages) await page.locator('[data-action="redo"]').click(); await page.locator('[data-action="save"]').click() check('重做复制恢复页数', (await state()).documentPages.length===pages+1) await shot('04-edit-pages') for (const tab of ['three','motion','annotation','formula','layout']) { const button=page.locator(`[data-action="property-tab"][data-property-tab="${tab}"]`) if (await button.count()) { await button.click(); check(`属性面板 ${tab} 打开`,true) } } await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材/09-Fox.glb')) await expect.poll(async()=> (await state()).resources.some(r=>r.name.includes('Fox')), {timeout:30000}).toBe(true) check('导入真实 Fox.glb 文件并登记本地资源',true) await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click() await shot('05-import-fox') const actions=await page.locator('.ofd-app').evaluate(el=>[...el.querySelectorAll('[data-action]')].map(x=>({action:x.dataset.action,text:(x.innerText||x.title||'').slice(0,50),data:{...x.dataset}}))) fs.writeFileSync(path.join(work,'resource-actions.json'),JSON.stringify(actions,null,2)) fs.writeFileSync(fixtureFile,JSON.stringify(fixture)) } if (phase === 'reader') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) await expect(page.locator('.ofd-view-editor')).toBeVisible() check('查看角色的正文编辑控件禁用',await page.locator('[data-step-field="title"]').isDisabled()) check('查看角色可以使用导出菜单',await page.locator('[data-field="export-target"]').isEnabled()) check('查看角色可以使用页码和缩放',await page.locator('[data-field="canvas-page"]').isEnabled() && await page.locator('[data-field="canvas-zoom-choice"]').isEnabled()) const before=JSON.stringify(await state()) const download=page.waitForEvent('download') await page.locator('[data-field="export-target"]').selectOption('ofd') await (await download).saveAs(path.join(report,'reader-export.ofd')) await page.waitForTimeout(700) check('只读用户实际导出 OFD 且不修改工程',before===JSON.stringify(await state())) await shot('reader-before') } if (phase === 'missing') { const unknown='REVIEW-NOT-EXIST-20260910' await open(`/content/ofd/${unknown}/studio?view=editor`) await page.waitForTimeout(1200) const exists=await page.evaluate(async id=>Boolean((await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id)),unknown) check('访问不存在的指导书不会自动新建',!exists) await shot('missing-before') if(exists) await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.deleteOFDProject(id),unknown) } if (phase === 'media') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) // Playwright storageState does not preserve Blob prototypes across contexts. // Re-import real bytes, then test reload inside this context below. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材/09-Fox.glb')) await page.waitForTimeout(1000) await page.reload() await expect(page.locator('.ofd-view-editor')).toBeVisible() await page.locator('[data-action="select-step"]').first().click() await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click() await page.locator('[data-action="library-insert"][data-kind="model"]').filter({hasText:'09-Fox.glb'}).first().click() await page.locator('[data-action="save"]').click() check('模型插入步骤并保存关联', (await state()).chapters[0].steps[0].mediaBlocks?.some(b=>b.assetId)) await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click() await page.locator('[data-action="library-insert"][data-kind="animation"]').filter({hasText:'Run'}).first().click() await page.locator('[data-action="save"]').click() check('关联导入模型的真实动画', JSON.stringify((await state()).chapters[0].steps[0]).includes('Run')) await page.locator('[data-action="media-open"]').first().click() await expect(page.locator('.ofd-media-panel canvas').first()).toBeVisible({timeout:30000}) await expect(page.locator('.ofd-media-panel')).not.toContainText('模型暂不可用', {timeout:30000}) await page.waitForTimeout(2500) await shot('06-fox-model-panel') await page.locator('[data-action="media-view"][data-view="all"]').click() await page.waitForTimeout(1500) await shot('06b-fox-framed') const controls=await page.locator('.ofd-media-panel').evaluate(el=>[...el.querySelectorAll('[data-action]')].map(x=>({action:x.dataset.action,text:(x.innerText||x.title||'').slice(0,60),data:{...x.dataset}}))) fs.writeFileSync(path.join(work,'media-controls.json'),JSON.stringify(controls,null,2)) check('三维模型放大面板渲染画布',true) await page.locator('[data-action="media-close"]').click() await page.locator('[data-action="right-dock-tab"]').filter({hasText:'属性'}).click() for (const [text, tab] of Object.entries({'三维':'model','动画':'animation','标注':'annotation','公式':'formula','版式':'layout','内容':'content'})) { await page.locator(`[data-action="property-tab"][data-tab="${tab}"]`).click() check(`步骤属性「${text}」可打开`,true) await shot(`property-${text}`) } await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材/08-CesiumMilkTruck.glb')) await expect.poll(async()=> (await state()).resources.some(r=>r.name.includes('MilkTruck')), {timeout:30000}).toBe(true) await page.locator('[data-action="select-step"]').last().click() await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click() await page.locator('[data-action="library-insert"][data-kind="model"]').filter({hasText:'MilkTruck'}).first().click() await page.locator('[data-action="save"]').click() check('第二步骤绑定牛奶卡车模型',JSON.stringify((await state()).chapters.flatMap(c=>c.steps).at(-1)).includes('MilkTruck')) await shot('07-milk-truck-step') } if (phase === 'export') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) for (const file of ['09-Fox.glb','08-CesiumMilkTruck.glb']) { await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',file)) await page.waitForTimeout(900) } for (const target of ['project','requirement','plan','pdf','ofd','offline']) { try { const waiting=page.waitForEvent('download',{timeout:90000}) await page.locator('[data-field="export-target"]').selectOption(target) const download=await waiting const file=path.join(report, `export-${target}${path.extname(download.suggestedFilename())}`) await download.saveAs(file) check(`导出 ${target} 下载文件`,fs.statSync(file).size>0) if(target==='project') fixture.exportFile=file } catch(error) { check(`导出 ${target}`,false,error.message); await shot(`export-${target}-failure`) } } fs.writeFileSync(fixtureFile,JSON.stringify(fixture)) await page.locator('[data-action="open-health"]').click() await shot('08-preflight') const details=await page.locator('.ofd-app').innerText() fs.writeFileSync(path.join(work,'preflight.txt'),details) await page.locator('[data-action="close-workspace-dialog"].ofd-icon-btn').click() await page.locator('[data-action="open-publish"]').click() await shot('09-publish-panel') const actions=await page.locator('.ofd-app').evaluate(el=>[...el.querySelectorAll('[data-action]')].map(x=>({action:x.dataset.action,text:(x.innerText||x.title||'').slice(0,60),data:{...x.dataset}}))) fs.writeFileSync(path.join(work,'publish-actions.json'),JSON.stringify(actions,null,2)) } if (phase === 'content') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) await page.locator('[data-action="select-step"]').first().click() await page.locator('[data-action="right-dock-tab"]').filter({hasText:'属性'}).click() await page.locator('[data-action="property-tab"][data-tab="formula"]').click() await page.locator('[data-action="formula-add"]').click() await page.locator('[data-formula-field="source"]').last().fill('F = p \\times A') await page.locator('[data-formula-field="source"]').last().blur() await page.locator('[data-formula-field="caption"]').last().fill('闭环验证公式,仅用于排版测试') await page.locator('[data-formula-field="caption"]').last().blur() await page.locator('[data-action="save"]').click() check('公式与说明保存', (await state()).chapters[0].steps[0].formulas.some(f=>f.caption.includes('闭环验证'))) await shot('10-formula') await page.locator('[data-action="ribbon-tab"]').filter({hasText:'插入'}).click() fs.writeFileSync(path.join(work,'insert-dom.json'), JSON.stringify(await page.locator('.ofd-app').evaluate(el=>({actions:[...el.querySelectorAll('[data-action]')].map(x=>({...x.dataset,text:(x.innerText||x.title||'').slice(0,50)})),inputs:[...el.querySelectorAll('input,select,textarea')].map(x=>({id:x.id,...x.dataset,type:x.type,value:x.value.slice(0,40)}))})),null,2)) await shot('11-insert-tools') await page.locator('[data-action="open-publish"]').click() await shot('09-publish-panel') await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="publish"]').click() await expect.poll(async()=>(await state()).lifecycle.status).toBe('published') check('发布保存本地正式版本',true) fixture.publishedTitle=(await state()).chapters[0].steps[0].title await shot('12-published') fs.writeFileSync(path.join(work,'published-state.json'),JSON.stringify(await state())) fs.writeFileSync(fixtureFile,JSON.stringify(fixture)) } if (phase === 'revision') { await open(`/content/ofd/${fixture.id}/studio?view=publish`) await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="edit"]').click() await expect.poll(async()=>(await state()).lifecycle.status).toBe('draft') await page.locator('[data-action="goto"][data-view="editor"]').click() await page.locator('[data-action="select-step"]').first().click() await page.locator('[data-action="right-dock-tab"]').filter({hasText:'属性'}).click() await page.locator('[data-action="property-tab"][data-tab="content"]').click() await page.locator('[data-step-field="title"]').fill('狐狸巡检目标识别(修订)') await page.locator('[data-step-field="title"]').blur() await page.locator('[data-action="save"]').click() const edited=await state() check('修订不改变旧的正式版本快照',edited.lifecycle.publishedSnapshot.chapters[0].steps[0].title===fixture.publishedTitle && edited.chapters[0].steps[0].title!==fixture.publishedTitle) // 清理由重复运行测试产生的重叠媒体块,通过用户可用的 JSON 导入入口回灌。 const bundle={schema:'',state:edited} Object.assign(bundle,JSON.parse(fs.readFileSync(path.join(report,'export-project.json'),'utf8')),{state:edited}) for (const c of edited.chapters) for (const s of c.steps) { s.mediaBlocks=(s.mediaBlocks||[]).filter(b=>b.kind==='model').slice(-1) for (const b of s.mediaBlocks) { b.poster='';b.posterAssetSignature='';b.camera=null } } const cleanFile=path.join(work,'roundtrip.json');fs.writeFileSync(cleanFile,JSON.stringify(bundle)) await page.locator('#ofd-project-file').setInputFiles(cleanFile) await expect.poll(async()=>(await state()).chapters[0].steps[0].mediaBlocks.length).toBe(1) check('工程 JSON 导入恢复步骤与资源关联',true) for (const file of ['09-Fox.glb','08-CesiumMilkTruck.glb']) { await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',file));await page.waitForTimeout(1200) } await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible() for (let i=0;i<2;i++) { await page.locator('[data-action="select-step"]').nth(i).click() const b=(await state()).chapters[0].steps[i].mediaBlocks[0] await page.locator(`[data-action="canvas-block"][data-block-id="step:${(await state()).chapters[0].steps[i].id}:media:${b.id}"]`).click() await page.locator('[data-action="media-open"]').first().click() await expect(page.locator('.ofd-media-panel canvas')).toBeVisible() await page.waitForTimeout(1300) await shot(i?'14-truck-panel':'13-fox-panel') const before=await page.locator('.ofd-media-panel canvas').screenshot() await page.locator('[data-action="media-play"]').click() await page.waitForTimeout(380) const after=await page.locator('.ofd-media-panel canvas').screenshot() check(`${i?'卡车':'狐狸'}动画真实改变渲染帧`,!before.equals(after)) await page.locator('[data-action="media-apply"]').click() await page.locator('[data-action="save"]').click() check(`${i?'卡车':'狐狸'}截取当前角度保存纸面图片`,(await state()).chapters[0].steps[i].mediaBlocks[0].poster.startsWith('data:image/jpeg')) } await shot('15-clean-editor') await page.locator('[data-action="open-publish"]').click() await page.locator('[data-publish="note"]').fill('狐狸与牛奶卡车模型闭环验证') await page.locator('[data-publish="note"]').blur() await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="publish"]').click() await expect.poll(async()=>(await state()).lifecycle.status).toBe('published') check('新修订再次发布并留存历史版本',(await state()).versions.filter(v=>v.immutable).length>=2) await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="withdraw"]').click() await expect.poll(async()=>(await state()).lifecycle.status).toBe('withdrawn') check('撤回本地正式版本',true) await shot('16-withdrawn') } if (phase === 'templates') { await open('/content/ofd/templates') const ids=await page.locator('[data-guide-tpl-action="select"]').evaluateAll(els=>els.map(e=>e.dataset.guideTplId)) check('四类模板列表',ids.length===4) for (let i=0;ie.classList.contains('active'))) if (i===0) { await shot('17-template-preview') for (const format of ['pdf','ofd']) { const wait=page.waitForEvent('download');await page.locator(`[data-guide-tpl-action="download"][data-guide-tpl-format="${format}"]`).click() await (await wait).saveAs(path.join(report,`template.${format}`));check(`模板下载 ${format}`,true) } } await page.locator('[data-guide-tpl-action="close-preview"][aria-label="关闭"]').click() } await page.locator('[data-guide-tpl-action="create"]').click() await page.locator('[data-wizard="name"]').fill('闭环实测-牛奶卡车指导书-20260910') await page.locator('[data-wizard="code"]').fill('REVIEW-GUIDE-TRUCK-20260910') await page.locator('[data-wizard="equipment"]').fill('牛奶卡车测试模型') await page.locator('[data-action="wizard-next"]').click() await page.locator('[data-action="wizard-next"]').click() await page.waitForURL(/view=analysis/) fixture.templateId=page.url().match(/\/ofd\/([^/]+)\/studio/)[1] const created=await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId) check('选用模板真正生成章节步骤',created.project.templateId===ids.at(-1) && created.chapters.length===4 && created.chapters.flatMap(c=>c.steps).length===9) check('新文档不混入演示版本和反馈',!created.versions.length && !created.feedback.length && !created.lifecycle.audit.length) check('新文档不混入其他装备参数与来源',!created.project.sourcePackage && !created.partsCatalog.length && !created.technicalParameters.length) await shot('18-new-template-guide') fs.writeFileSync(fixtureFile,JSON.stringify(fixture)) await open('/content/ofd') await expect(page.locator('.el-table__body-wrapper tbody tr').first()).toContainText('牛奶卡车指导书') check('列表默认按创建时间倒序',true) await shot('19-list-created-order') } if (phase === 'api') { await open('/content/ofd/api') await page.locator('[data-field="api-project"]').selectOption(fixture.id) const ids=await page.locator('[data-action="api-endpoint"]').evaluateAll(es=>es.map(e=>e.dataset.id)) check('对外接口展示九个操作',ids.length===9) // 写操作只操作隔离浏览器中的本轮测试文档,生命周期已单独实测。 for (const id of ids) { await page.locator(`[data-action="api-endpoint"][data-id="${id}"]`).click() if (/lifecycle|workflow|step-update|resource-register/.test(id)) continue await page.locator('[data-action="api-run"]').click() await page.waitForTimeout(300) const response=await page.locator('.ofd-api-result').innerText() fs.writeFileSync(path.join(work,`api-${id}.txt`),response) check(`本地接口调度 ${id} 返回成功`,/^20\d/.test(response),response.slice(0,80)) } check('接口面板未发送真实 HTTP 业务请求',requests.length===0,'这里通过本地函数调度,需要接入正式 API 才可外部调用') await shot('20-api-local') } if (phase === 'offline') { const external=[] page.on('console',msg=>{if(msg.type()==='error') console.log('BROWSER',msg.text())}) page.on('requestfailed',r=>console.log('NETWORK',r.url(),r.failure())) await context.route('**/*',r=>{ if (new URL(r.request().url()).host==='127.0.0.1:18766') return r.continue() external.push(r.request().url());return r.abort() }) await page.goto('http://127.0.0.1:18766') await page.locator('[data-action="start"]').click() await page.locator('[data-action="next"]').click() check('离线包章节步骤可以阅读与切换',await page.locator('.reader-panel:visible').count()>0) await shot('21-offline-reading') await page.locator('[data-action="open-model-library"]').click() console.log('OFFLINE DOM',await page.evaluate(()=>({scripts:[...document.scripts].map(s=>({type:s.type,src:s.src,size:s.textContent.length})),status:document.querySelector('[data-model-status]')?.textContent}))) await expect(page.locator('[data-model-stage] canvas')).toBeVisible({timeout:30000}) await expect(page.locator('[data-model-status]')).toContainText(/个网格/, {timeout:30000}) for (let i=0;i<2;i++) { await page.locator('[data-model-select]').selectOption(String(i)) await page.waitForTimeout(1400) await shot(`22-offline-model-${i}`) const before=await page.locator('[data-model-stage] canvas').screenshot() await page.locator('[data-model-action="play-animation"]').click();await page.waitForTimeout(500) const after=await page.locator('[data-model-stage] canvas').screenshot() check(`离线模型 ${i+1} 动画可播放`,!before.equals(after)) await page.locator('[data-model-action="pause-animation"]').click() for (const action of ['zoom-in','zoom-out','rotate','toggle-transparent','highlight','explode','reset']) await page.locator(`[data-model-action="${action}"]`).click() check(`离线模型 ${i+1} 观察操作可执行`,true) } check('断开公网和业务服务后没有外部依赖请求',external.length===0,external.join('\n')) check('离线个人批注与收藏入口',await page.getByText(/收藏|添加笔记|保存笔记/).count()>0,'当前离线阅读器未提供原始需求中的个人笔记、收藏和学习进度回传') await page.setViewportSize({width:390,height:844});await shot('23-offline-mobile') } if (phase === 'layout') { await open(`/content/ofd/${fixture.templateId}/studio?view=editor`) await page.waitForTimeout(700) console.log('LOAD',await page.locator('.el-message').allTextContents(),await page.locator('.legacy-guide-page').innerText().then(t=>t.slice(0,100))) const get=async()=>page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId) await page.locator('[data-action="select-step"]:visible').first().click() await page.locator('[data-action="ribbon-tab"][data-tab="insert"]').click() await page.locator('[data-field="canvas-insert"]').selectOption('table') await page.locator('[data-action="table-cell"]').first().dblclick() await page.locator('[data-inline-field="cell"]').fill('检测项目') await page.locator('[data-inline-field="cell"]').blur() await page.locator('[data-action="ribbon-tab"][data-tab="home"]').click() await page.locator('[data-action="table-op"][data-op="merge-selection"]').click() await page.locator('[data-action="table-op"][data-op="split-cell"]').click() await page.locator('[data-field="table-border-style"]').selectOption('dashed') await page.locator('[data-action="table-op"][data-op="border-apply"]').click() await page.locator('[data-action="save"]').click() check('插入表格、填写单元格、合并拆分与边框保存',JSON.stringify(await get()).includes('检测项目')) await shot('24-table-edit') await page.locator('[data-action="ribbon-tab"][data-tab="insert"]').click() await page.locator('[data-field="canvas-insert"]').selectOption('text') await page.locator('[data-action="save"]').click() const stored=JSON.stringify(await get()) check('插入正文自由块后保存',stored.includes('extraBlocks')) await page.locator('[data-action="ribbon-tab"][data-tab="review"]').click() await page.locator('[data-action="canvas-tool"][data-tool="rect"]').click() const canvas=await page.locator('[data-action="annotate-canvas"]').boundingBox() await page.mouse.move(canvas.x+170,canvas.y+150) await page.mouse.down();await page.mouse.move(canvas.x+280,canvas.y+220,{steps:8});await page.mouse.up() await page.locator('[data-action="save"]').click() check('二维框标注保存',JSON.stringify(await get()).includes('markups')) await shot('25-markup') await page.locator('[data-action="canvas-tab"][data-tab="interactive"]').click() await page.locator('[data-field="interactive-search"]').fill('牛奶卡车') await expect(page.locator('.ofd-ia-search-results')).toBeVisible() check('动态阅读检索步骤',true) await page.locator('[data-action="interactive-search-clear"]').click() await page.locator('[data-action="interactive-next"]').click() await shot('26-interactive-search') await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible() check('表格、自由块、二维标注重开后保留',JSON.stringify(await get()).includes('检测项目') && JSON.stringify(await get()).includes('markups')) await open('/content/ofd') await page.getByPlaceholder('搜索指导书标题、编号或作者').fill('REVIEW-GUIDE-TRUCK-20260910') await page.getByRole('button',{name:'搜索',exact:true}).click() check('列表按编号检索',await page.locator('.el-table__body-wrapper tbody tr').count()>=1) await page.getByRole('button',{name:'重置',exact:true}).click() check('重置恢复创建时间排序',await page.getByRole('combobox',{name:'指导书排序'}).getAttribute('aria-label')==='指导书排序') } if (phase === 'final-document') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) const current=await state() const bundle=JSON.parse(fs.readFileSync(path.join(report,'export-project.json'),'utf8'));bundle.state=current current.documentPages=current.documentPages.filter(p=>p.contentMode!=='continuation') for (const c of current.chapters) for (const [i,s] of c.steps.entries()) { s.image='';s.modelRef.camera=null s.summary=i?'确认牛奶卡车测试模型完整显示,播放车轮动画并记录测试结果。':s.summary s.safety='测试素材仅用于功能验证。';s.acceptance='模型与动画可查看,记录保存后能够重新打开。' for (const b of s.mediaBlocks) { b.camera=null;b.poster='';b.posterAssetSignature='' } } const file=path.join(work,'final-document.json');fs.writeFileSync(file,JSON.stringify(bundle)) await page.locator('#ofd-project-file').setInputFiles(file) await page.waitForTimeout(800) for (const file of ['09-Fox.glb','08-CesiumMilkTruck.glb']) { await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',file));await page.waitForTimeout(1000) } await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible() for (let i=0;i<2;i++) { await page.locator('[data-action="select-step"]').nth(i).click() const s=(await state()).chapters[0].steps[i],b=s.mediaBlocks[0] await page.locator(`[data-action="canvas-block"][data-block-id="step:${s.id}:media:${b.id}"]`).click() await page.locator('[data-action="media-open"]').first().click();await page.waitForTimeout(1300) await page.locator('[data-action="media-view"][data-view="all"]').click();await page.waitForTimeout(250) await shot(i?'28-final-truck':'27-final-fox') await page.locator('[data-action="media-apply"]').click();await page.locator('[data-action="save"]').click() check(`${i?'卡车':'狐狸'}按实际尺寸取景后保存`,(await state()).chapters[0].steps[i].mediaBlocks[0].poster.startsWith('data:image/jpeg')) } await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible() check('两个模型的取景图片重开后保留',(await state()).chapters[0].steps.every(s=>s.mediaBlocks[0].poster.startsWith('data:image/jpeg'))) } if (phase === 'resources') { await open(`/content/ofd/${fixture.templateId}/studio?view=editor`) await page.locator('[data-action="select-step"]:visible').first().click() await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../.codex-tmp/guide-test-video.mp4')) await page.waitForTimeout(700) await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click() await page.locator('[data-action="library-insert"][data-kind="resource"]').filter({hasText:'guide-test-video'}).first().click() await page.locator('[data-action="media-open"]').first().click() const video=page.locator('[data-media-video]') await expect.poll(()=>video.evaluate(v=>v.readyState)).toBeGreaterThanOrEqual(2) const start=await video.evaluate(v=>{v.play();return v.currentTime}) await page.waitForTimeout(450) check('导入本地视频并在原生播放器实际播放',await video.evaluate(v=>v.currentTime)>start) await page.locator('[data-action="video-capture-poster"]').click() await shot('29-video-poster') await page.locator('[data-action="media-close"]').click() await page.locator('[data-action="save"]').click() const doc=await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId) check('视频当前帧海报保存到步骤',doc.chapters.flatMap(c=>c.steps).some(s=>s.mediaBlocks.some(b=>b.kind==='video' && b.poster.startsWith('data:image/jpeg')))) const original=JSON.stringify(doc) await page.locator('#ofd-project-file').setInputFiles({name:'broken.json',mimeType:'application/json',buffer:Buffer.from('{broken')}) await page.waitForTimeout(500) const after=await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId) check('非法工程 JSON 被拒绝且原工程保留',JSON.stringify(after)===original) await shot('30-invalid-import') const content=await call('tran/v1/content/projects?type=GUIDE&keyword=REVIEW-GUIDE-20260910',session.accessToken) const teaching=await call('tran/v1/teaching/guides?keyword=REVIEW-GUIDE-20260910',session.accessToken) check('编制工程同步到正式内容库',content.status===200 && (content.data?.records||[]).some(r=>r.code==='REVIEW-GUIDE-20260910'),'实测仅写入浏览器本地库;正式内容库查询 HTTP '+content.status) check('编制发布结果进入教学指导书列表',teaching.status===200 && (teaching.data?.records||[]).some(r=>r.code==='REVIEW-GUIDE-20260910'),'教学查询 HTTP '+teaching.status) } if (phase === 'viewport-probe') { await open(`/content/ofd/${fixture.id}/studio?view=editor`) const bytes=[...fs.readFileSync(path.resolve('../../文档/素材/09-Fox.glb'))] console.log(await page.evaluate(async bytes=>{ const {createGuideViewport}=await import('/src/features/guide-legacy/demo-src/ofd-viewport.js') const host=document.createElement('div'); host.style.cssText='position:fixed;inset:100px;background:white;z-index:99999';document.body.append(host) const v=createGuideViewport(); window.reviewViewport=v;v.attach(host);await v.load('review-fox',{bytes:new Uint8Array(bytes)}) return {scene:v.describeScene(),fog:v.scene.fog,camera:v.camera.position.toArray(),target:v.controls.target.toArray(),stats:v.loadStats} },bytes)) await page.waitForTimeout(1000); await shot('viewport-probe') await page.evaluate(()=>{window.reviewViewport.scene.fog=null;window.reviewViewport.invalidate()}) await page.waitForTimeout(500);await shot('viewport-without-fog') console.log(await page.evaluate(()=>{const v=window.reviewViewport;return {stats:v.renderer.info.render,disposed:v.disposed,needsRender:v.needsRender,camera:v.camera.position.toArray(),target:v.controls.target.toArray(),bounds:v.modelBox().getSize(new v.camera.position.constructor()).toArray()}})) } } catch(error) { await shot(`${phase}-failure`).catch(()=>{}) check(`${phase} 脚本执行`, false, error.message) process.exitCode=1 } finally { if (!['offline','viewport-probe','reader'].includes(phase)) await context.storageState({ path:stateFile, indexedDB:true }) fs.writeFileSync(path.join(report, `${phase}.json`), JSON.stringify({ checks, errors, requests }, null, 2)) await browser.close() }