25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

511 satır
37 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import assert from 'node:assert/strict'
  4. import { chromium, expect } from '@playwright/test'
  5. import { login, call } from './role-permissions-live.mjs'
  6. const report = path.resolve('reports/guide-closure-20260910')
  7. const work = path.resolve('../../.codex-tmp/guide-closure-20260910')
  8. fs.mkdirSync(report, { recursive:true }); fs.mkdirSync(work, { recursive:true })
  9. const phase = process.argv[2] || 'create'
  10. const fixtureFile = path.join(work, 'fixture.json')
  11. const fixture = fs.existsSync(fixtureFile) ? JSON.parse(fs.readFileSync(fixtureFile, 'utf8')) : {}
  12. const checks = [], errors = [], requests = []
  13. const browser = await chromium.launch({ channel:'msedge', headless:true, args:['--enable-unsafe-swiftshader'] })
  14. const session = await login(phase === 'reader' ? 'admin' : 'root')
  15. const stateFile = path.join(work, 'browser.json')
  16. const context = await browser.newContext({ viewport:{ width:1920, height:1080 }, ...(fs.existsSync(stateFile) ? { storageState:stateFile } : {}) })
  17. await context.addInitScript(s => {
  18. sessionStorage.setItem('unreal-tran:web:access-token:v1', s.accessToken)
  19. sessionStorage.setItem('unreal-tran:web:refresh-token:v1', s.refreshToken)
  20. localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ themeMode:'military' }))
  21. }, session)
  22. const page = await context.newPage()
  23. page.on('pageerror', e => errors.push(e.message))
  24. page.on('request', r => { if (r.url().includes('/api/tran/')) requests.push({ method:r.method(), path:new URL(r.url()).pathname }) })
  25. const check = (name, pass, detail='') => { checks.push({ name, status:pass ? 'PASS':'FAIL', detail }); console.log(`${pass ? 'PASS':'FAIL'} ${name}`) }
  26. const shot = async name => { await page.screenshot({ path:path.join(report, `${name}.png`), animations:'disabled' }) }
  27. const open = async url => { await page.goto(`http://127.0.0.1:6180${url}`); await expect(page.locator('.legacy-guide-page')).toBeVisible({ timeout:60000 }) }
  28. const state = async () => page.evaluate(async id => (await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id)?.state, fixture.id)
  29. try {
  30. if (phase === 'create') {
  31. await open('/content/ofd')
  32. await page.getByRole('button', { name:'新建指导书', exact:true }).click()
  33. await page.locator('[data-wizard="name"]').fill('闭环实测-狐狸巡检作业指导-20260910')
  34. await page.locator('[data-wizard="code"]').fill('REVIEW-GUIDE-20260910')
  35. await page.locator('[data-wizard="equipment"]').fill('狐狸模型测试装备')
  36. await page.locator('[data-wizard="owner"]').fill('闭环验证')
  37. await page.locator('[data-action="wizard-next"]').click()
  38. await page.locator('[data-action="wizard-template"][data-template="__blank__"]').click()
  39. await page.locator('[data-action="wizard-next"]').click()
  40. await shot('01-create-wizard')
  41. await page.locator('[data-action="wizard-next"]').click()
  42. await page.waitForURL(/\/content\/ofd\/[^/]+\/studio\?view=analysis/)
  43. fixture.id = page.url().match(/\/ofd\/([^/]+)\/studio/)[1]
  44. fs.writeFileSync(fixtureFile, JSON.stringify(fixture))
  45. await expect(page.locator('.ofd-view-analysis')).toBeVisible()
  46. const saved = await state()
  47. check('通过界面从空白模板创建指导书', saved?.project?.name.includes('闭环实测') && saved.chapters.length === 1)
  48. check('创建调用正式内容接口', requests.some(r=>r.method==='POST' && r.path.endsWith('/content/projects')), '当前页面使用浏览器本地工程库')
  49. const server = await call(`tran/v1/content/projects?type=GUIDE&keyword=${encodeURIComponent('闭环实测-狐狸巡检作业指导-20260910')}`, session.accessToken)
  50. check('新建工程出现在正式后端列表', server.status===200 && server.data.records.some(r=>r.name.includes('闭环实测-狐狸')))
  51. await shot('02-planning')
  52. await page.locator('[data-action="goto"][data-view="editor"]').first().click()
  53. await expect(page.locator('.ofd-view-editor')).toBeVisible()
  54. await shot('03-editor-baseline')
  55. }
  56. if (phase === 'inspect') {
  57. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  58. await expect(page.locator('.ofd-view-editor')).toBeVisible()
  59. const dom = await page.locator('.ofd-app').evaluate(el=>({
  60. 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})),
  61. 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)})),
  62. }))
  63. fs.writeFileSync(path.join(work,'dom.json'),JSON.stringify(dom,null,2))
  64. console.log(JSON.stringify(dom,null,2))
  65. }
  66. if (phase === 'edit') {
  67. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  68. const first = (await state()).chapters[0].steps[0].id
  69. fixture.firstStep = first
  70. for (const [field,value] of Object.entries({title:'狐狸巡检目标识别',summary:'定位狐狸模型,观察目标外观,并逐项记录本次巡检结果。',tools:'测试模型、观察记录表',safety:'仅使用测试模型,不作为真实装备维修规程。',acceptance:'模型显示完整,能够识别并记录目标。'})) {
  71. await page.locator(`[data-step-field="${field}"]`).fill(value)
  72. await page.locator(`[data-step-field="${field}"]`).blur()
  73. }
  74. await page.locator('[data-action="save"]').click()
  75. await expect.poll(async()=> (await state()).chapters[0].steps[0].title).toBe('狐狸巡检目标识别')
  76. check('步骤标题、说明、工具、安全、验收编辑保存', (await state()).chapters[0].steps[0].summary.includes('逐项记录'))
  77. await page.reload(); await expect(page.locator('[data-step-field="title"]')).toHaveValue('狐狸巡检目标识别')
  78. check('刷新后恢复已保存的本地内容', true)
  79. const before = (await state()).chapters.flatMap(c=>c.steps).length
  80. await page.locator('[data-action="add-step"]').click()
  81. await page.locator('[data-step-field="title"]').fill('牛奶卡车状态检查')
  82. await page.locator('[data-step-field="title"]').blur()
  83. await page.locator('[data-action="save"]').click()
  84. check('新增步骤后保存', (await state()).chapters.flatMap(c=>c.steps).length===before+1)
  85. await page.locator('[data-action="page-add-for-step"]').last().click()
  86. await page.locator('[data-action="save"]').click()
  87. const pages=(await state()).documentPages.length
  88. await page.locator('[data-action="page-duplicate"]').click()
  89. await page.locator('[data-action="save"]').click()
  90. check('正文续页和复制页面保存', (await state()).documentPages.length===pages+1)
  91. await page.locator('[data-action="undo"]').click(); await page.locator('[data-action="save"]').click()
  92. check('撤销复制恢复页数', (await state()).documentPages.length===pages)
  93. await page.locator('[data-action="redo"]').click(); await page.locator('[data-action="save"]').click()
  94. check('重做复制恢复页数', (await state()).documentPages.length===pages+1)
  95. await shot('04-edit-pages')
  96. for (const tab of ['three','motion','annotation','formula','layout']) {
  97. const button=page.locator(`[data-action="property-tab"][data-property-tab="${tab}"]`)
  98. if (await button.count()) { await button.click(); check(`属性面板 ${tab} 打开`,true) }
  99. }
  100. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材/09-Fox.glb'))
  101. await expect.poll(async()=> (await state()).resources.some(r=>r.name.includes('Fox')), {timeout:30000}).toBe(true)
  102. check('导入真实 Fox.glb 文件并登记本地资源',true)
  103. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click()
  104. await shot('05-import-fox')
  105. 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}})))
  106. fs.writeFileSync(path.join(work,'resource-actions.json'),JSON.stringify(actions,null,2))
  107. fs.writeFileSync(fixtureFile,JSON.stringify(fixture))
  108. }
  109. if (phase === 'reader') {
  110. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  111. await expect(page.locator('.ofd-view-editor')).toBeVisible()
  112. check('查看角色的正文编辑控件禁用',await page.locator('[data-step-field="title"]').isDisabled())
  113. check('查看角色可以使用导出菜单',await page.locator('[data-field="export-target"]').isEnabled())
  114. check('查看角色可以使用页码和缩放',await page.locator('[data-field="canvas-page"]').isEnabled() && await page.locator('[data-field="canvas-zoom-choice"]').isEnabled())
  115. const before=JSON.stringify(await state())
  116. const download=page.waitForEvent('download')
  117. await page.locator('[data-field="export-target"]').selectOption('ofd')
  118. await (await download).saveAs(path.join(report,'reader-export.ofd'))
  119. await page.waitForTimeout(700)
  120. check('只读用户实际导出 OFD 且不修改工程',before===JSON.stringify(await state()))
  121. await shot('reader-before')
  122. }
  123. if (phase === 'missing') {
  124. const unknown='REVIEW-NOT-EXIST-20260910'
  125. await open(`/content/ofd/${unknown}/studio?view=editor`)
  126. await page.waitForTimeout(1200)
  127. const exists=await page.evaluate(async id=>Boolean((await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id)),unknown)
  128. check('访问不存在的指导书不会自动新建',!exists)
  129. await shot('missing-before')
  130. if(exists) await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.deleteOFDProject(id),unknown)
  131. }
  132. if (phase === 'media') {
  133. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  134. // Playwright storageState does not preserve Blob prototypes across contexts.
  135. // Re-import real bytes, then test reload inside this context below.
  136. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材/09-Fox.glb'))
  137. await page.waitForTimeout(1000)
  138. await page.reload()
  139. await expect(page.locator('.ofd-view-editor')).toBeVisible()
  140. await page.locator('[data-action="select-step"]').first().click()
  141. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click()
  142. await page.locator('[data-action="library-insert"][data-kind="model"]').filter({hasText:'09-Fox.glb'}).first().click()
  143. await page.locator('[data-action="save"]').click()
  144. check('模型插入步骤并保存关联', (await state()).chapters[0].steps[0].mediaBlocks?.some(b=>b.assetId))
  145. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click()
  146. await page.locator('[data-action="library-insert"][data-kind="animation"]').filter({hasText:'Run'}).first().click()
  147. await page.locator('[data-action="save"]').click()
  148. check('关联导入模型的真实动画', JSON.stringify((await state()).chapters[0].steps[0]).includes('Run'))
  149. await page.locator('[data-action="media-open"]').first().click()
  150. await expect(page.locator('.ofd-media-panel canvas').first()).toBeVisible({timeout:30000})
  151. await expect(page.locator('.ofd-media-panel')).not.toContainText('模型暂不可用', {timeout:30000})
  152. await page.waitForTimeout(2500)
  153. await shot('06-fox-model-panel')
  154. await page.locator('[data-action="media-view"][data-view="all"]').click()
  155. await page.waitForTimeout(1500)
  156. await shot('06b-fox-framed')
  157. 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}})))
  158. fs.writeFileSync(path.join(work,'media-controls.json'),JSON.stringify(controls,null,2))
  159. check('三维模型放大面板渲染画布',true)
  160. await page.locator('[data-action="media-close"]').click()
  161. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'属性'}).click()
  162. for (const [text, tab] of Object.entries({'三维':'model','动画':'animation','标注':'annotation','公式':'formula','版式':'layout','内容':'content'})) {
  163. await page.locator(`[data-action="property-tab"][data-tab="${tab}"]`).click()
  164. check(`步骤属性「${text}」可打开`,true)
  165. await shot(`property-${text}`)
  166. }
  167. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材/08-CesiumMilkTruck.glb'))
  168. await expect.poll(async()=> (await state()).resources.some(r=>r.name.includes('MilkTruck')), {timeout:30000}).toBe(true)
  169. await page.locator('[data-action="select-step"]').last().click()
  170. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click()
  171. await page.locator('[data-action="library-insert"][data-kind="model"]').filter({hasText:'MilkTruck'}).first().click()
  172. await page.locator('[data-action="save"]').click()
  173. check('第二步骤绑定牛奶卡车模型',JSON.stringify((await state()).chapters.flatMap(c=>c.steps).at(-1)).includes('MilkTruck'))
  174. await shot('07-milk-truck-step')
  175. }
  176. if (phase === 'export') {
  177. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  178. for (const file of ['09-Fox.glb','08-CesiumMilkTruck.glb']) {
  179. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',file))
  180. await page.waitForTimeout(900)
  181. }
  182. for (const target of ['project','requirement','plan','pdf','ofd','offline']) {
  183. try {
  184. const waiting=page.waitForEvent('download',{timeout:90000})
  185. await page.locator('[data-field="export-target"]').selectOption(target)
  186. const download=await waiting
  187. const file=path.join(report, `export-${target}${path.extname(download.suggestedFilename())}`)
  188. await download.saveAs(file)
  189. check(`导出 ${target} 下载文件`,fs.statSync(file).size>0)
  190. if(target==='project') fixture.exportFile=file
  191. } catch(error) { check(`导出 ${target}`,false,error.message); await shot(`export-${target}-failure`) }
  192. }
  193. fs.writeFileSync(fixtureFile,JSON.stringify(fixture))
  194. await page.locator('[data-action="open-health"]').click()
  195. await shot('08-preflight')
  196. const details=await page.locator('.ofd-app').innerText()
  197. fs.writeFileSync(path.join(work,'preflight.txt'),details)
  198. await page.locator('[data-action="close-workspace-dialog"].ofd-icon-btn').click()
  199. await page.locator('[data-action="open-publish"]').click()
  200. await shot('09-publish-panel')
  201. 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}})))
  202. fs.writeFileSync(path.join(work,'publish-actions.json'),JSON.stringify(actions,null,2))
  203. }
  204. if (phase === 'content') {
  205. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  206. await page.locator('[data-action="select-step"]').first().click()
  207. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'属性'}).click()
  208. await page.locator('[data-action="property-tab"][data-tab="formula"]').click()
  209. await page.locator('[data-action="formula-add"]').click()
  210. await page.locator('[data-formula-field="source"]').last().fill('F = p \\times A')
  211. await page.locator('[data-formula-field="source"]').last().blur()
  212. await page.locator('[data-formula-field="caption"]').last().fill('闭环验证公式,仅用于排版测试')
  213. await page.locator('[data-formula-field="caption"]').last().blur()
  214. await page.locator('[data-action="save"]').click()
  215. check('公式与说明保存', (await state()).chapters[0].steps[0].formulas.some(f=>f.caption.includes('闭环验证')))
  216. await shot('10-formula')
  217. await page.locator('[data-action="ribbon-tab"]').filter({hasText:'插入'}).click()
  218. 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))
  219. await shot('11-insert-tools')
  220. await page.locator('[data-action="open-publish"]').click()
  221. await shot('09-publish-panel')
  222. await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="publish"]').click()
  223. await expect.poll(async()=>(await state()).lifecycle.status).toBe('published')
  224. check('发布保存本地正式版本',true)
  225. fixture.publishedTitle=(await state()).chapters[0].steps[0].title
  226. await shot('12-published')
  227. fs.writeFileSync(path.join(work,'published-state.json'),JSON.stringify(await state()))
  228. fs.writeFileSync(fixtureFile,JSON.stringify(fixture))
  229. }
  230. if (phase === 'revision') {
  231. await open(`/content/ofd/${fixture.id}/studio?view=publish`)
  232. await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="edit"]').click()
  233. await expect.poll(async()=>(await state()).lifecycle.status).toBe('draft')
  234. await page.locator('[data-action="goto"][data-view="editor"]').click()
  235. await page.locator('[data-action="select-step"]').first().click()
  236. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'属性'}).click()
  237. await page.locator('[data-action="property-tab"][data-tab="content"]').click()
  238. await page.locator('[data-step-field="title"]').fill('狐狸巡检目标识别(修订)')
  239. await page.locator('[data-step-field="title"]').blur()
  240. await page.locator('[data-action="save"]').click()
  241. const edited=await state()
  242. check('修订不改变旧的正式版本快照',edited.lifecycle.publishedSnapshot.chapters[0].steps[0].title===fixture.publishedTitle && edited.chapters[0].steps[0].title!==fixture.publishedTitle)
  243. // 清理由重复运行测试产生的重叠媒体块,通过用户可用的 JSON 导入入口回灌。
  244. const bundle={schema:'',state:edited}
  245. Object.assign(bundle,JSON.parse(fs.readFileSync(path.join(report,'export-project.json'),'utf8')),{state:edited})
  246. for (const c of edited.chapters) for (const s of c.steps) {
  247. s.mediaBlocks=(s.mediaBlocks||[]).filter(b=>b.kind==='model').slice(-1)
  248. for (const b of s.mediaBlocks) { b.poster='';b.posterAssetSignature='';b.camera=null }
  249. }
  250. const cleanFile=path.join(work,'roundtrip.json');fs.writeFileSync(cleanFile,JSON.stringify(bundle))
  251. await page.locator('#ofd-project-file').setInputFiles(cleanFile)
  252. await expect.poll(async()=>(await state()).chapters[0].steps[0].mediaBlocks.length).toBe(1)
  253. check('工程 JSON 导入恢复步骤与资源关联',true)
  254. for (const file of ['09-Fox.glb','08-CesiumMilkTruck.glb']) {
  255. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',file));await page.waitForTimeout(1200)
  256. }
  257. await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible()
  258. for (let i=0;i<2;i++) {
  259. await page.locator('[data-action="select-step"]').nth(i).click()
  260. const b=(await state()).chapters[0].steps[i].mediaBlocks[0]
  261. await page.locator(`[data-action="canvas-block"][data-block-id="step:${(await state()).chapters[0].steps[i].id}:media:${b.id}"]`).click()
  262. await page.locator('[data-action="media-open"]').first().click()
  263. await expect(page.locator('.ofd-media-panel canvas')).toBeVisible()
  264. await page.waitForTimeout(1300)
  265. await shot(i?'14-truck-panel':'13-fox-panel')
  266. const before=await page.locator('.ofd-media-panel canvas').screenshot()
  267. await page.locator('[data-action="media-play"]').click()
  268. await page.waitForTimeout(380)
  269. const after=await page.locator('.ofd-media-panel canvas').screenshot()
  270. check(`${i?'卡车':'狐狸'}动画真实改变渲染帧`,!before.equals(after))
  271. await page.locator('[data-action="media-apply"]').click()
  272. await page.locator('[data-action="save"]').click()
  273. check(`${i?'卡车':'狐狸'}截取当前角度保存纸面图片`,(await state()).chapters[0].steps[i].mediaBlocks[0].poster.startsWith('data:image/jpeg'))
  274. }
  275. await shot('15-clean-editor')
  276. await page.locator('[data-action="open-publish"]').click()
  277. await page.locator('[data-publish="note"]').fill('狐狸与牛奶卡车模型闭环验证')
  278. await page.locator('[data-publish="note"]').blur()
  279. await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="publish"]').click()
  280. await expect.poll(async()=>(await state()).lifecycle.status).toBe('published')
  281. check('新修订再次发布并留存历史版本',(await state()).versions.filter(v=>v.immutable).length>=2)
  282. await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="withdraw"]').click()
  283. await expect.poll(async()=>(await state()).lifecycle.status).toBe('withdrawn')
  284. check('撤回本地正式版本',true)
  285. await shot('16-withdrawn')
  286. }
  287. if (phase === 'templates') {
  288. await open('/content/ofd/templates')
  289. const ids=await page.locator('[data-guide-tpl-action="select"]').evaluateAll(els=>els.map(e=>e.dataset.guideTplId))
  290. check('四类模板列表',ids.length===4)
  291. for (let i=0;i<ids.length;i++) {
  292. const id=ids[i]
  293. await page.locator(`[data-guide-tpl-action="preview"][data-guide-tpl-id="${id}"]`).click()
  294. await expect(page.locator('.guide-tpl-ofd-pages')).toBeVisible()
  295. check(`模板 ${i+1} 静态版式预览`,true)
  296. await page.locator('[data-guide-tpl-mode="interactive"]').click()
  297. await page.locator('[data-guide-tpl-action="preview-step"]').last().click()
  298. check(`模板 ${i+1} 动态目录切换`,await page.locator('[data-guide-tpl-action="preview-step"]').last().evaluate(e=>e.classList.contains('active')))
  299. if (i===0) {
  300. await shot('17-template-preview')
  301. for (const format of ['pdf','ofd']) {
  302. const wait=page.waitForEvent('download');await page.locator(`[data-guide-tpl-action="download"][data-guide-tpl-format="${format}"]`).click()
  303. await (await wait).saveAs(path.join(report,`template.${format}`));check(`模板下载 ${format}`,true)
  304. }
  305. }
  306. await page.locator('[data-guide-tpl-action="close-preview"][aria-label="关闭"]').click()
  307. }
  308. await page.locator('[data-guide-tpl-action="create"]').click()
  309. await page.locator('[data-wizard="name"]').fill('闭环实测-牛奶卡车指导书-20260910')
  310. await page.locator('[data-wizard="code"]').fill('REVIEW-GUIDE-TRUCK-20260910')
  311. await page.locator('[data-wizard="equipment"]').fill('牛奶卡车测试模型')
  312. await page.locator('[data-action="wizard-next"]').click()
  313. await page.locator('[data-action="wizard-next"]').click()
  314. await page.waitForURL(/view=analysis/)
  315. fixture.templateId=page.url().match(/\/ofd\/([^/]+)\/studio/)[1]
  316. const created=await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId)
  317. check('选用模板真正生成章节步骤',created.project.templateId===ids.at(-1) && created.chapters.length===4 && created.chapters.flatMap(c=>c.steps).length===9)
  318. check('新文档不混入演示版本和反馈',!created.versions.length && !created.feedback.length && !created.lifecycle.audit.length)
  319. check('新文档不混入其他装备参数与来源',!created.project.sourcePackage && !created.partsCatalog.length && !created.technicalParameters.length)
  320. await shot('18-new-template-guide')
  321. fs.writeFileSync(fixtureFile,JSON.stringify(fixture))
  322. await open('/content/ofd')
  323. await expect(page.locator('.el-table__body-wrapper tbody tr').first()).toContainText('牛奶卡车指导书')
  324. check('列表默认按创建时间倒序',true)
  325. await shot('19-list-created-order')
  326. }
  327. if (phase === 'api') {
  328. await open('/content/ofd/api')
  329. await page.locator('[data-field="api-project"]').selectOption(fixture.id)
  330. const ids=await page.locator('[data-action="api-endpoint"]').evaluateAll(es=>es.map(e=>e.dataset.id))
  331. check('对外接口展示九个操作',ids.length===9)
  332. // 写操作只操作隔离浏览器中的本轮测试文档,生命周期已单独实测。
  333. for (const id of ids) {
  334. await page.locator(`[data-action="api-endpoint"][data-id="${id}"]`).click()
  335. if (/lifecycle|workflow|step-update|resource-register/.test(id)) continue
  336. await page.locator('[data-action="api-run"]').click()
  337. await page.waitForTimeout(300)
  338. const response=await page.locator('.ofd-api-result').innerText()
  339. fs.writeFileSync(path.join(work,`api-${id}.txt`),response)
  340. check(`本地接口调度 ${id} 返回成功`,/^20\d/.test(response),response.slice(0,80))
  341. }
  342. check('接口面板未发送真实 HTTP 业务请求',requests.length===0,'这里通过本地函数调度,需要接入正式 API 才可外部调用')
  343. await shot('20-api-local')
  344. }
  345. if (phase === 'offline') {
  346. const external=[]
  347. page.on('console',msg=>{if(msg.type()==='error') console.log('BROWSER',msg.text())})
  348. page.on('requestfailed',r=>console.log('NETWORK',r.url(),r.failure()))
  349. await context.route('**/*',r=>{
  350. if (new URL(r.request().url()).host==='127.0.0.1:18766') return r.continue()
  351. external.push(r.request().url());return r.abort()
  352. })
  353. await page.goto('http://127.0.0.1:18766')
  354. await page.locator('[data-action="start"]').click()
  355. await page.locator('[data-action="next"]').click()
  356. check('离线包章节步骤可以阅读与切换',await page.locator('.reader-panel:visible').count()>0)
  357. await shot('21-offline-reading')
  358. await page.locator('[data-action="open-model-library"]').click()
  359. 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})))
  360. await expect(page.locator('[data-model-stage] canvas')).toBeVisible({timeout:30000})
  361. await expect(page.locator('[data-model-status]')).toContainText(/个网格/, {timeout:30000})
  362. for (let i=0;i<2;i++) {
  363. await page.locator('[data-model-select]').selectOption(String(i))
  364. await page.waitForTimeout(1400)
  365. await shot(`22-offline-model-${i}`)
  366. const before=await page.locator('[data-model-stage] canvas').screenshot()
  367. await page.locator('[data-model-action="play-animation"]').click();await page.waitForTimeout(500)
  368. const after=await page.locator('[data-model-stage] canvas').screenshot()
  369. check(`离线模型 ${i+1} 动画可播放`,!before.equals(after))
  370. await page.locator('[data-model-action="pause-animation"]').click()
  371. for (const action of ['zoom-in','zoom-out','rotate','toggle-transparent','highlight','explode','reset']) await page.locator(`[data-model-action="${action}"]`).click()
  372. check(`离线模型 ${i+1} 观察操作可执行`,true)
  373. }
  374. check('断开公网和业务服务后没有外部依赖请求',external.length===0,external.join('\n'))
  375. check('离线个人批注与收藏入口',await page.getByText(/收藏|添加笔记|保存笔记/).count()>0,'当前离线阅读器未提供原始需求中的个人笔记、收藏和学习进度回传')
  376. await page.setViewportSize({width:390,height:844});await shot('23-offline-mobile')
  377. }
  378. if (phase === 'layout') {
  379. await open(`/content/ofd/${fixture.templateId}/studio?view=editor`)
  380. await page.waitForTimeout(700)
  381. console.log('LOAD',await page.locator('.el-message').allTextContents(),await page.locator('.legacy-guide-page').innerText().then(t=>t.slice(0,100)))
  382. const get=async()=>page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId)
  383. await page.locator('[data-action="select-step"]:visible').first().click()
  384. await page.locator('[data-action="ribbon-tab"][data-tab="insert"]').click()
  385. await page.locator('[data-field="canvas-insert"]').selectOption('table')
  386. await page.locator('[data-action="table-cell"]').first().dblclick()
  387. await page.locator('[data-inline-field="cell"]').fill('检测项目')
  388. await page.locator('[data-inline-field="cell"]').blur()
  389. await page.locator('[data-action="ribbon-tab"][data-tab="home"]').click()
  390. await page.locator('[data-action="table-op"][data-op="merge-selection"]').click()
  391. await page.locator('[data-action="table-op"][data-op="split-cell"]').click()
  392. await page.locator('[data-field="table-border-style"]').selectOption('dashed')
  393. await page.locator('[data-action="table-op"][data-op="border-apply"]').click()
  394. await page.locator('[data-action="save"]').click()
  395. check('插入表格、填写单元格、合并拆分与边框保存',JSON.stringify(await get()).includes('检测项目'))
  396. await shot('24-table-edit')
  397. await page.locator('[data-action="ribbon-tab"][data-tab="insert"]').click()
  398. await page.locator('[data-field="canvas-insert"]').selectOption('text')
  399. await page.locator('[data-action="save"]').click()
  400. const stored=JSON.stringify(await get())
  401. check('插入正文自由块后保存',stored.includes('extraBlocks'))
  402. await page.locator('[data-action="ribbon-tab"][data-tab="review"]').click()
  403. await page.locator('[data-action="canvas-tool"][data-tool="rect"]').click()
  404. const canvas=await page.locator('[data-action="annotate-canvas"]').boundingBox()
  405. await page.mouse.move(canvas.x+170,canvas.y+150)
  406. await page.mouse.down();await page.mouse.move(canvas.x+280,canvas.y+220,{steps:8});await page.mouse.up()
  407. await page.locator('[data-action="save"]').click()
  408. check('二维框标注保存',JSON.stringify(await get()).includes('markups'))
  409. await shot('25-markup')
  410. await page.locator('[data-action="canvas-tab"][data-tab="interactive"]').click()
  411. await page.locator('[data-field="interactive-search"]').fill('牛奶卡车')
  412. await expect(page.locator('.ofd-ia-search-results')).toBeVisible()
  413. check('动态阅读检索步骤',true)
  414. await page.locator('[data-action="interactive-search-clear"]').click()
  415. await page.locator('[data-action="interactive-next"]').click()
  416. await shot('26-interactive-search')
  417. await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible()
  418. check('表格、自由块、二维标注重开后保留',JSON.stringify(await get()).includes('检测项目') && JSON.stringify(await get()).includes('markups'))
  419. await open('/content/ofd')
  420. await page.getByPlaceholder('搜索指导书标题、编号或作者').fill('REVIEW-GUIDE-TRUCK-20260910')
  421. await page.getByRole('button',{name:'搜索',exact:true}).click()
  422. check('列表按编号检索',await page.locator('.el-table__body-wrapper tbody tr').count()>=1)
  423. await page.getByRole('button',{name:'重置',exact:true}).click()
  424. check('重置恢复创建时间排序',await page.getByRole('combobox',{name:'指导书排序'}).getAttribute('aria-label')==='指导书排序')
  425. }
  426. if (phase === 'final-document') {
  427. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  428. const current=await state()
  429. const bundle=JSON.parse(fs.readFileSync(path.join(report,'export-project.json'),'utf8'));bundle.state=current
  430. current.documentPages=current.documentPages.filter(p=>p.contentMode!=='continuation')
  431. for (const c of current.chapters) for (const [i,s] of c.steps.entries()) {
  432. s.image='';s.modelRef.camera=null
  433. s.summary=i?'确认牛奶卡车测试模型完整显示,播放车轮动画并记录测试结果。':s.summary
  434. s.safety='测试素材仅用于功能验证。';s.acceptance='模型与动画可查看,记录保存后能够重新打开。'
  435. for (const b of s.mediaBlocks) { b.camera=null;b.poster='';b.posterAssetSignature='' }
  436. }
  437. const file=path.join(work,'final-document.json');fs.writeFileSync(file,JSON.stringify(bundle))
  438. await page.locator('#ofd-project-file').setInputFiles(file)
  439. await page.waitForTimeout(800)
  440. for (const file of ['09-Fox.glb','08-CesiumMilkTruck.glb']) {
  441. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',file));await page.waitForTimeout(1000)
  442. }
  443. await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible()
  444. for (let i=0;i<2;i++) {
  445. await page.locator('[data-action="select-step"]').nth(i).click()
  446. const s=(await state()).chapters[0].steps[i],b=s.mediaBlocks[0]
  447. await page.locator(`[data-action="canvas-block"][data-block-id="step:${s.id}:media:${b.id}"]`).click()
  448. await page.locator('[data-action="media-open"]').first().click();await page.waitForTimeout(1300)
  449. await page.locator('[data-action="media-view"][data-view="all"]').click();await page.waitForTimeout(250)
  450. await shot(i?'28-final-truck':'27-final-fox')
  451. await page.locator('[data-action="media-apply"]').click();await page.locator('[data-action="save"]').click()
  452. check(`${i?'卡车':'狐狸'}按实际尺寸取景后保存`,(await state()).chapters[0].steps[i].mediaBlocks[0].poster.startsWith('data:image/jpeg'))
  453. }
  454. await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible()
  455. check('两个模型的取景图片重开后保留',(await state()).chapters[0].steps.every(s=>s.mediaBlocks[0].poster.startsWith('data:image/jpeg')))
  456. }
  457. if (phase === 'resources') {
  458. await open(`/content/ofd/${fixture.templateId}/studio?view=editor`)
  459. await page.locator('[data-action="select-step"]:visible').first().click()
  460. await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../.codex-tmp/guide-test-video.mp4'))
  461. await page.waitForTimeout(700)
  462. await page.locator('[data-action="right-dock-tab"]').filter({hasText:'资源'}).click()
  463. await page.locator('[data-action="library-insert"][data-kind="resource"]').filter({hasText:'guide-test-video'}).first().click()
  464. await page.locator('[data-action="media-open"]').first().click()
  465. const video=page.locator('[data-media-video]')
  466. await expect.poll(()=>video.evaluate(v=>v.readyState)).toBeGreaterThanOrEqual(2)
  467. const start=await video.evaluate(v=>{v.play();return v.currentTime})
  468. await page.waitForTimeout(450)
  469. check('导入本地视频并在原生播放器实际播放',await video.evaluate(v=>v.currentTime)>start)
  470. await page.locator('[data-action="video-capture-poster"]').click()
  471. await shot('29-video-poster')
  472. await page.locator('[data-action="media-close"]').click()
  473. await page.locator('[data-action="save"]').click()
  474. const doc=await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId)
  475. check('视频当前帧海报保存到步骤',doc.chapters.flatMap(c=>c.steps).some(s=>s.mediaBlocks.some(b=>b.kind==='video' && b.poster.startsWith('data:image/jpeg'))))
  476. const original=JSON.stringify(doc)
  477. await page.locator('#ofd-project-file').setInputFiles({name:'broken.json',mimeType:'application/json',buffer:Buffer.from('{broken')})
  478. await page.waitForTimeout(500)
  479. const after=await page.evaluate(async id=>(await import('/src/features/guide-legacy/runtime.ts')).legacyGuideContentStore.getOFDProject(id).state,fixture.templateId)
  480. check('非法工程 JSON 被拒绝且原工程保留',JSON.stringify(after)===original)
  481. await shot('30-invalid-import')
  482. const content=await call('tran/v1/content/projects?type=GUIDE&keyword=REVIEW-GUIDE-20260910',session.accessToken)
  483. const teaching=await call('tran/v1/teaching/guides?keyword=REVIEW-GUIDE-20260910',session.accessToken)
  484. check('编制工程同步到正式内容库',content.status===200 && (content.data?.records||[]).some(r=>r.code==='REVIEW-GUIDE-20260910'),'实测仅写入浏览器本地库;正式内容库查询 HTTP '+content.status)
  485. check('编制发布结果进入教学指导书列表',teaching.status===200 && (teaching.data?.records||[]).some(r=>r.code==='REVIEW-GUIDE-20260910'),'教学查询 HTTP '+teaching.status)
  486. }
  487. if (phase === 'viewport-probe') {
  488. await open(`/content/ofd/${fixture.id}/studio?view=editor`)
  489. const bytes=[...fs.readFileSync(path.resolve('../../文档/素材/09-Fox.glb'))]
  490. console.log(await page.evaluate(async bytes=>{
  491. const {createGuideViewport}=await import('/src/features/guide-legacy/demo-src/ofd-viewport.js')
  492. const host=document.createElement('div'); host.style.cssText='position:fixed;inset:100px;background:white;z-index:99999';document.body.append(host)
  493. const v=createGuideViewport(); window.reviewViewport=v;v.attach(host);await v.load('review-fox',{bytes:new Uint8Array(bytes)})
  494. return {scene:v.describeScene(),fog:v.scene.fog,camera:v.camera.position.toArray(),target:v.controls.target.toArray(),stats:v.loadStats}
  495. },bytes))
  496. await page.waitForTimeout(1000); await shot('viewport-probe')
  497. await page.evaluate(()=>{window.reviewViewport.scene.fog=null;window.reviewViewport.invalidate()})
  498. await page.waitForTimeout(500);await shot('viewport-without-fog')
  499. 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()}}))
  500. }
  501. } catch(error) {
  502. await shot(`${phase}-failure`).catch(()=>{})
  503. check(`${phase} 脚本执行`, false, error.message)
  504. process.exitCode=1
  505. } finally {
  506. if (!['offline','viewport-probe','reader'].includes(phase)) await context.storageState({ path:stateFile, indexedDB:true })
  507. fs.writeFileSync(path.join(report, `${phase}.json`), JSON.stringify({ checks, errors, requests }, null, 2))
  508. await browser.close()
  509. }