Procházet zdrojové kódy

test(guide): add formal workflow regression and screenshot report

main
leiyun před 1 týdnem
rodič
revize
a04bf0c618
44 změnil soubory, kde provedl 856 přidání a 0 odebrání
  1. +3
    -0
      reports/guide-formal-20260910/.gitignore
  2. binární
     
  3. binární
     
  4. binární
     
  5. binární
     
  6. binární
     
  7. binární
     
  8. binární
     
  9. binární
     
  10. binární
     
  11. binární
     
  12. binární
     
  13. binární
     
  14. binární
     
  15. binární
     
  16. binární
     
  17. binární
     
  18. binární
     
  19. binární
     
  20. +15
    -0
      reports/guide-formal-20260910/create.json
  21. +70
    -0
      reports/guide-formal-20260910/final.json
  22. binární
     
  23. +9
    -0
      reports/guide-formal-20260910/index.html
  24. +17
    -0
      reports/guide-formal-20260910/media.json
  25. +25
    -0
      reports/guide-formal-20260910/offline-browser.json
  26. +36
    -0
      reports/guide-formal-20260910/offline.json
  27. +69
    -0
      reports/guide-formal-20260910/permissions.json
  28. +30
    -0
      reports/guide-formal-20260910/publish.json
  29. +53
    -0
      reports/guide-formal-20260910/release.json
  30. binární
     
  31. +6
    -0
      reports/guide-formal-20260910/report-validation.json
  32. +55
    -0
      reports/guide-formal-20260910/review.json
  33. +14
    -0
      reports/guide-formal-20260910/scenes.json
  34. +17
    -0
      reports/guide-formal-20260910/template.json
  35. +20
    -0
      reports/guide-formal-20260910/upload.json
  36. +70
    -0
      tools/guide-formal-final.mjs
  37. +77
    -0
      tools/guide-formal-live.mjs
  38. +48
    -0
      tools/guide-formal-media.mjs
  39. +20
    -0
      tools/guide-formal-offline.mjs
  40. +36
    -0
      tools/guide-formal-permissions.mjs
  41. +46
    -0
      tools/guide-formal-report.mjs
  42. +56
    -0
      tools/guide-formal-review.mjs
  43. +36
    -0
      tools/guide-formal-scenes.mjs
  44. +28
    -0
      tools/guide-formal-template.mjs

+ 3
- 0
reports/guide-formal-20260910/.gitignore Zobrazit soubor

@@ -0,0 +1,3 @@
# 本地实测导出包及已修复失败尝试,仅保留在当前设备供追溯。
formal-offline.zip
*failure*.png

binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


binární
Zobrazit soubor


+ 15
- 0
reports/guide-formal-20260910/create.json Zobrazit soubor

@@ -0,0 +1,15 @@
{
"checks": [
{
"name": "新建请求实际进入内容服务",
"pass": true,
"detail": "真实 HTTP 请求成功;响应内容已脱敏。"
},
{
"name": "正式后台可查询新工程",
"pass": true,
"detail": ""
}
],
"errors": []
}

+ 70
- 0
reports/guide-formal-20260910/final.json Zobrazit soubor

@@ -0,0 +1,70 @@
{
"checks": [
{
"name": "重新打开恢复上次第三页阅读位置",
"pass": true,
"detail": ""
},
{
"name": "笔记编辑保存成功",
"pass": true,
"detail": ""
},
{
"name": "笔记删除成功",
"pass": true,
"detail": ""
},
{
"name": "取消收藏写回服务器",
"pass": true,
"detail": ""
},
{
"name": "动态阅读同步第四页和百分百页进度",
"pass": true,
"detail": ""
},
{
"name": "教学只读模式允许目录定位和选择动画",
"pass": true,
"detail": ""
},
{
"name": "教学动态版式切换卡车步骤",
"pass": true,
"detail": ""
},
{
"name": "浏览器完成阅读并更新状态",
"pass": true,
"detail": ""
},
{
"name": "HTTP 面板实际 PUT 阅读记录",
"pass": true,
"detail": ""
},
{
"name": "HTTP 面板旧版本返回 409",
"pass": true,
"detail": ""
},
{
"name": "错误 HTTP 方法明确返回 405",
"pass": true,
"detail": ""
},
{
"name": "正式模板库可用",
"pass": true,
"detail": ""
},
{
"name": "无浏览器脚本异常",
"pass": true,
"detail": ""
}
],
"errors": []
}

binární
Zobrazit soubor


+ 9
- 0
reports/guide-formal-20260910/index.html
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 17
- 0
reports/guide-formal-20260910/media.json Zobrazit soubor

@@ -0,0 +1,17 @@
{
"projectId": "414",
"checks": [
{
"name": "图片与视频上传、截图引用保存后重开保持",
"pass": true
},
{
"name": "OFD 预览读取受控图片和截图",
"pass": true
},
{
"name": "正式视频通过受控字节恢复并实际播放",
"pass": true
}
]
}

+ 25
- 0
reports/guide-formal-20260910/offline-browser.json Zobrazit soubor

@@ -0,0 +1,25 @@
{
"checks": [
{
"name": "15-offline-fox-scene",
"pass": true,
"detail": "实测场景-狐狸野外训练区-20260905.glb · 186 个网格 · 拖拽旋转 / 滚轮缩放"
},
{
"name": "16-offline-truck-scene",
"pass": true,
"detail": "实测场景-牛奶卡车检验区-20260905.glb · 37 个网格 · 拖拽旋转 / 滚轮缩放"
},
{
"name": "离线资源不依赖业务服务器或公网",
"pass": true,
"detail": ""
},
{
"name": "无脚本异常",
"pass": true,
"detail": ""
}
],
"errors": []
}

+ 36
- 0
reports/guide-formal-20260910/offline.json Zobrazit soubor

@@ -0,0 +1,36 @@
{
"integrity": "passed",
"fixedCameraRuntime": true,
"zipBytes": 71380256,
"sha256": "7227683a98f98e48aa31c3839fcb63782572fe6a9d9f7f48949fbae07e64f19b",
"models": [
{
"file": "assets/models/model-001.glb",
"size": 162852,
"validGlb": true,
"hasFox": true,
"hasMilkTruck": false
},
{
"file": "assets/models/model-002.glb",
"size": 68091456,
"validGlb": true,
"hasFox": false,
"hasMilkTruck": false
},
{
"file": "assets/models/model-003.glb",
"size": 318276,
"validGlb": true,
"hasFox": false,
"hasMilkTruck": true
},
{
"file": "assets/models/model-004.glb",
"size": 1034616,
"validGlb": true,
"hasFox": false,
"hasMilkTruck": true
}
]
}

+ 69
- 0
reports/guide-formal-20260910/permissions.json Zobrazit soubor

@@ -0,0 +1,69 @@
{
"checks": [
{
"name": "学员不能进入内容编辑接口",
"pass": true,
"detail": ""
},
{
"name": "学员可以阅读正式发布版本",
"pass": true,
"detail": ""
},
{
"name": "学员投影没有文件存储 URI",
"pass": true,
"detail": ""
},
{
"name": "学员看不到其他用户的笔记和收藏",
"pass": true,
"detail": ""
},
{
"name": "学员能保存本人的阅读记录",
"pass": true,
"detail": ""
},
{
"name": "学员写入不覆盖教员记录",
"pass": true,
"detail": ""
},
{
"name": "学员不能查看教员聚合统计",
"pass": true,
"detail": ""
},
{
"name": "开始阅读产生正式记录",
"pass": true,
"detail": "success"
},
{
"name": "阅读完成记录成功",
"pass": true,
"detail": "success"
},
{
"name": "学员反馈写入正式服务",
"pass": true,
"detail": ""
},
{
"name": "教员回复并处理反馈",
"pass": true,
"detail": "success"
},
{
"name": "学员通过教学接口下载 GLB",
"pass": true,
"detail": ""
},
{
"name": "临时测试学员已清理",
"pass": true,
"detail": "success"
}
]
}

+ 30
- 0
reports/guide-formal-20260910/publish.json Zobrazit soubor

@@ -0,0 +1,30 @@
{
"checks": [
{
"name": "正式发布事务完成",
"pass": true,
"detail": "真实 HTTP 请求成功;响应内容已脱敏。"
},
{
"name": "教学指导书列表出现正式发布工程",
"pass": true,
"detail": ""
},
{
"name": "发布版本提供 OFD 与离线 ZIP",
"pass": true,
"detail": ""
},
{
"name": "下载正式交付物 OFD",
"pass": true,
"detail": ""
},
{
"name": "下载正式交付物 OFFLINE_HTML",
"pass": true,
"detail": ""
}
],
"errors": []
}

+ 53
- 0
reports/guide-formal-20260910/release.json Zobrazit soubor

@@ -0,0 +1,53 @@
{
"projectId": "412",
"versionId": "472",
"versionCode": "V3.0.0",
"status": "PUBLISHED",
"dependencies": [
{
"projectId": "160",
"versionId": "463"
},
{
"projectId": "159",
"versionId": "209"
}
],
"assets": [
{
"name": "09-Fox.glb",
"sizeBytes": 162852,
"type": "MODEL_FILE"
},
{
"name": "08-CesiumMilkTruck.glb",
"sizeBytes": 369980,
"type": "MODEL_FILE"
},
{
"name": "capture.jpeg",
"sizeBytes": 25174,
"type": "IMAGE"
},
{
"name": "实测场景-狐狸野外训练区-20260905.glb",
"sizeBytes": 81483944,
"type": "MODEL_FILE"
},
{
"name": "实测场景-牛奶卡车检验区-20260905.glb",
"sizeBytes": 1330380,
"type": "MODEL_FILE"
},
{
"name": "正式闭环-狐狸与卡车指导书-20260910_V3.0.0.ofd",
"sizeBytes": 53551,
"type": "DOCUMENT"
},
{
"name": "正式闭环-狐狸与卡车指导书-20260910_V3.0.0_动态版式离线包.zip",
"sizeBytes": 71380256,
"type": "DOCUMENT"
}
]
}

binární
Zobrazit soubor


+ 6
- 0
reports/guide-formal-20260910/report-validation.json Zobrazit soubor

@@ -0,0 +1,6 @@
{
"images": 17,
"brokenImages": [],
"lanPageLoaded": true,
"viewport": "1440x1000"
}

+ 55
- 0
reports/guide-formal-20260910/review.json Zobrazit soubor

@@ -0,0 +1,55 @@
{
"checks": [
{
"name": "发布版本提供两份正式交付物",
"pass": true,
"detail": ""
},
{
"name": "阅读位置、收藏、笔记真实写入",
"pass": true,
"detail": ""
},
{
"name": "旧乐观锁版本被拒绝",
"pass": true,
"detail": ""
},
{
"name": "旧发布版本不能覆盖当前阅读记录",
"pass": true,
"detail": ""
},
{
"name": "收藏和笔记进入真实统计",
"pass": true,
"detail": ""
},
{
"name": "浏览器笔记保存请求成功",
"pass": true,
"detail": ""
},
{
"name": "浏览器翻页位置已同步",
"pass": true,
"detail": ""
},
{
"name": "教学动态阅读显示三维画布",
"pass": true,
"detail": ""
},
{
"name": "对外接口面板返回真实工程数据",
"pass": true,
"detail": ""
},
{
"name": "浏览器无脚本异常",
"pass": true,
"detail": ""
}
],
"errors": []
}

+ 14
- 0
reports/guide-formal-20260910/scenes.json Zobrazit soubor

@@ -0,0 +1,14 @@
{
"checks": [
{
"name": "新修订保持原发布版本可阅读",
"pass": true,
"detail": ""
},
{
"name": "狐狸与卡车场景固定版本已保存",
"pass": true,
"detail": "{\"old\":\"469\",\"published\":\"469\",\"assets\":7,\"bindings\":[\"160@463\",\"159@209\"],\"version\":12}"
}
]
}

+ 17
- 0
reports/guide-formal-20260910/template.json Zobrazit soubor

@@ -0,0 +1,17 @@
{
"projectId": "413",
"checks": [
{
"name": "模板新建实际进入正式内容服务",
"pass": true
},
{
"name": "模板初稿进入编辑器并保存",
"pass": true
},
{
"name": "模板章节和步骤不丢失",
"pass": true
}
]
}

+ 20
- 0
reports/guide-formal-20260910/upload.json Zobrazit soubor

@@ -0,0 +1,20 @@
{
"checks": [
{
"name": "上传 09-Fox.glb",
"pass": true,
"detail": "真实 HTTP 请求成功;响应内容已脱敏。"
},
{
"name": "上传 08-CesiumMilkTruck.glb",
"pass": true,
"detail": "真实 HTTP 请求成功;响应内容已脱敏。"
},
{
"name": "刷新后受控模型资源恢复显示",
"pass": true,
"detail": ""
}
],
"errors": []
}

+ 70
- 0
tools/guide-formal-final.mjs Zobrazit soubor

@@ -0,0 +1,70 @@
import fs from 'node:fs'
import path from 'node:path'
import {chromium,expect} from '@playwright/test'
import {login,call} from './role-permissions-live.mjs'
const report=path.resolve('reports/guide-formal-20260910'), fixture=JSON.parse(fs.readFileSync('../../.codex-tmp/guide-formal/fixture.json'))
const session=await login('root'), token=session.accessToken, checks=[],errors=[]
const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
const context=await browser.newContext({viewport:{width:1920,height:1080}})
await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message))
const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
const shot=name=>page.screenshot({path:path.join(report,name+'.png')})
const openReader=async()=>{
await page.goto('http://127.0.0.1:6180/teaching/guides')
await page.getByPlaceholder('搜索指导书名称或编码').fill('正式闭环-狐狸与卡车指导书-20260910')
await page.getByPlaceholder('搜索指导书名称或编码').press('Enter')
await expect(page.locator('.guide-card-v2')).toHaveCount(1)
await page.locator('.guide-card-v2 button').first().click()
await expect(page.locator('.legacy-published-reader .ofd-preview-pager')).toBeVisible({timeout:60000})
}
try{
const state=(await call(`tran/v1/teaching/guides/${fixture.id}/reader-state`,token)).data
await call(`tran/v1/teaching/guides/${fixture.id}/reader-state`,token,{...state,pageId:'page:2',pageIndex:2,pageCount:4,favorite:true,notes:[{id:'final-1',pageId:'page:1',text:'定位核对记录'},{id:'final-2',pageId:'page:2',text:'编辑与删除测试记录'}]},'PUT')
await openReader()
await expect(page.locator('.legacy-published-reader .ofd-preview-pager')).toContainText('3 / 4')
check('重新打开恢复上次第三页阅读位置',true)
await page.getByRole('button',{name:/笔记 2/}).click()
const note=page.locator('.reader-note').last()
await note.getByRole('button',{name:'编辑',exact:true}).click()
await page.getByPlaceholder('记录当前页的阅读笔记').fill('已修改:复查牛奶卡车后确认记录。')
const edited=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
await page.getByRole('button',{name:'保存修改',exact:true}).click();check('笔记编辑保存成功',(await edited).ok())
await expect(note).toContainText('已修改')
const removed=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
await note.getByRole('button',{name:'删除',exact:true}).click();check('笔记删除成功',(await removed).ok())
await expect(page.locator('.reader-note')).toHaveCount(1)
await page.locator('.el-drawer__close-btn:visible').click()
const unfavorite=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
await page.getByRole('button',{name:'取消收藏',exact:true}).click();check('取消收藏写回服务器',(await unfavorite).ok())
await page.locator('.legacy-published-reader [data-action="preview-tab"][data-tab="interactive"]').click()
await page.locator('.legacy-published-reader [data-action="interactive-next"]').click()
await page.locator('.legacy-published-reader canvas').first().scrollIntoViewIfNeeded()
await page.waitForTimeout(3000);await shot('11-reader-truck')
const position=await call(`tran/v1/teaching/guides/${fixture.id}/reader-state`,token)
check('动态阅读同步第四页和百分百页进度',position.data?.pageIndex===3&&position.data?.progress===100)
await expect(page.locator('.legacy-published-reader [data-action="interactive-step"]').first()).toBeEnabled()
await expect(page.locator('.legacy-published-reader [data-model-clip-select]')).toBeEnabled()
check('教学只读模式允许目录定位和选择动画',true)
check('教学动态版式切换卡车步骤',await page.locator('.legacy-published-reader').textContent().then(t=>t.includes('牛奶卡车检查')))
if(await page.getByRole('button',{name:'标记阅读完成',exact:true}).count())await page.getByRole('button',{name:'标记阅读完成',exact:true}).click()
await expect(page.getByRole('button',{name:'已完成阅读',exact:true})).toBeDisabled()
await shot('12-reading-completed');check('浏览器完成阅读并更新状态',true)
await page.goto('http://127.0.0.1:6180/content/ofd/api')
await page.locator('.guide-http-page nav button').filter({hasText:'保存笔记、收藏与进度'}).click()
await page.locator('.guide-http-page .el-select').first().click()
await page.getByRole('option').filter({hasText:'正式闭环-狐狸与卡车指导书-20260910'}).click()
await page.getByRole('button',{name:'载入当前工程版本参数',exact:true}).click()
await expect(page.getByRole('textbox',{name:'请求 JSON'})).toHaveValue(/versionId/)
const saved=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
await page.getByRole('button',{name:'执行调用',exact:true}).click();check('HTTP 面板实际 PUT 阅读记录',(await saved).ok())
const conflict=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
await page.getByRole('button',{name:'执行调用',exact:true}).click();check('HTTP 面板旧版本返回 409',(await conflict).status()===409)
await shot('13-http-conflict')
const method=await call(`tran/v1/teaching/guides/${fixture.id}/reading/start`,token,{},'POST')
check('错误 HTTP 方法明确返回 405',method.status===405)
await page.goto('http://127.0.0.1:6180/content/ofd/templates')
await expect(page.locator('.template-card').first()).toBeVisible({timeout:30000})
await shot('14-formal-templates');check('正式模板库可用',true)
}catch(e){check('最终浏览器回归',false,e.message);await shot('final-failure')}
finally{check('无浏览器脚本异常',errors.length===0,errors.join('\n'));fs.writeFileSync(path.join(report,'final.json'),JSON.stringify({checks,errors},null,2));await browser.close()}

+ 77
- 0
tools/guide-formal-live.mjs Zobrazit soubor

@@ -0,0 +1,77 @@
import fs from 'node:fs'
import path from 'node:path'
import { chromium, expect } from '@playwright/test'
import { login, call } from './role-permissions-live.mjs'
const report=path.resolve('reports/guide-formal-20260910'),work=path.resolve('../../.codex-tmp/guide-formal')
fs.mkdirSync(report,{recursive:true});fs.mkdirSync(work,{recursive:true})
const phase=process.argv[2]||'create', fixturePath=path.join(work,'fixture.json')
const fixture=fs.existsSync(fixturePath)?JSON.parse(fs.readFileSync(fixturePath)):{}
const session=await login('root'),browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
const context=await browser.newContext({viewport:{width:1920,height:1080}})
await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
const page=await context.newPage(),checks=[],errors=[]
page.on('pageerror',e=>errors.push(e.message))
page.on('response',r=>{if(r.status()>=400) console.log('HTTP',r.status(),new URL(r.url()).pathname)})
await page.addInitScript(()=>{new MutationObserver(()=>{for(const el of document.querySelectorAll('.ofd-toast,.el-message'))if(!el.dataset.reported){el.dataset.reported='true';console.log('NOTICE',el.textContent)}}).observe(document,{childList:true,subtree:true})})
page.on('console',m=>{if(m.text().startsWith('NOTICE'))console.log(m.text())})
const shot=async name=>page.screenshot({path:path.join(report,name+'.png')})
const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
try{
if(phase==='create'){
await page.goto('http://127.0.0.1:6180/content/ofd')
await page.getByRole('button',{name:'新建指导书',exact:true}).click()
await page.locator('[data-wizard="name"]').fill('正式闭环-狐狸与卡车指导书-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()
const response=page.waitForResponse(r=>r.url().endsWith('/content/projects')&&r.request().method()==='POST')
await page.locator('[data-action="wizard-next"]').click()
const created=await response;check('新建请求实际进入内容服务',created.status()===200,await created.text())
await page.waitForURL(/\/ofd\/\d+\/studio/)
fixture.id=page.url().match(/\/ofd\/(\d+)\/studio/)[1];fs.writeFileSync(fixturePath,JSON.stringify(fixture))
const server=await call(`tran/v1/content/projects/${fixture.id}`,session.accessToken)
check('正式后台可查询新工程',server.status===200)
await shot('01-formal-created')
}
if(phase==='publish'){
await page.goto(`http://127.0.0.1:6180/content/ofd/${fixture.id}/studio?view=editor`)
await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
await page.locator('[data-action="save"]').click();await page.waitForTimeout(700)
await page.locator('[data-action="open-publish"]').click()
const response=page.waitForResponse(r=>r.url().endsWith('/publish')&&r.request().method()==='POST',{timeout:600000})
await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="publish"]').click()
const result=await response;check('正式发布事务完成',result.ok(),(await result.text()).slice(0,400))
await page.waitForTimeout(700);await shot('04-formal-published')
const guides=await call('tran/v1/teaching/guides?keyword='+encodeURIComponent('正式闭环-狐狸与卡车指导书-20260910'),session.accessToken)
check('教学指导书列表出现正式发布工程',guides.data?.records?.some(r=>r.projectId===fixture.id))
const assets=await call(`tran/v1/teaching/guides/${fixture.id}/deliveries`,session.accessToken)
check('发布版本提供 OFD 与离线 ZIP',assets.data?.length===2)
for(const format of ['OFD','OFFLINE_HTML']){
const result=await context.request.get(`http://127.0.0.1:6180/api/tran/v1/teaching/guides/${fixture.id}/delivery?format=${format}`,{headers:{Authorization:`Bearer ${session.accessToken}`},timeout:180000})
check('下载正式交付物 '+format,result.ok());if(result.ok())fs.writeFileSync(path.join(report,format==='OFD'?'formal.ofd':'formal-offline.zip'),await result.body())
}
}
if(phase==='upload'){
await page.goto(`http://127.0.0.1:6180/content/ofd/${fixture.id}/studio?view=editor`)
await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
for(const name of ['09-Fox.glb','08-CesiumMilkTruck.glb']){
const wait=page.waitForResponse(r=>r.url().includes('/assets/upload')&&r.request().method()==='POST')
await page.locator('#ofd-resource-file').setInputFiles(path.resolve('../../文档/素材',name))
const response=await wait;check('上传 '+name,response.ok(),(await response.text()).slice(0,300))
await page.waitForTimeout(1800)
}
await page.locator('[data-action="save"]').click();await page.waitForTimeout(1200)
await shot('02-formal-assets')
await page.reload();await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
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:'Fox'}).first().click()
await page.locator('[data-action="media-open"]').first().click()
await expect(page.locator('.ofd-media-panel canvas')).toBeVisible({timeout:30000})
await page.waitForTimeout(1200);await shot('03-formal-fox')
check('刷新后受控模型资源恢复显示',true)
}
}catch(e){check(phase,false,e.message);console.log(await page.locator('.legacy-guide-page').evaluate(el=>el.__vueParentComponent?.setupState?.mountError).catch(()=>''));await shot(phase+'-failure').catch(()=>{})}
finally{fs.writeFileSync(path.join(report,phase+'.json'),JSON.stringify({checks,errors},null,2));await browser.close()}

+ 48
- 0
tools/guide-formal-media.mjs Zobrazit soubor

@@ -0,0 +1,48 @@
import fs from 'node:fs'
import {chromium,expect} from '@playwright/test'
import {login} from './role-permissions-live.mjs'
const session=await login('root'),checks=[]
const browser=await chromium.launch({channel:'msedge',headless:true})
const page=await browser.newPage({viewport:{width:1920,height:1080}})
await page.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
let id
try{
await page.goto('http://127.0.0.1:6180/content/ofd')
await expect(page.getByRole('button',{name:'新建指导书',exact:true})).toBeVisible()
const result=process.env.GUIDE_MEDIA_ID ? await page.evaluate(async id=>{
const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts')
const store=new FormalGuideStore();await store.load(id)
const step=store.record.state.chapters[0].steps[0]
const valid=step.mediaBlocks.every(b=>b.kind==='video'?!!b.poster:!!b.imageSource)&&!JSON.stringify(step).includes('blob:')
store.dispose();return {id,valid}
},process.env.GUIDE_MEDIA_ID) : await page.evaluate(async()=>{
const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts')
const store=new FormalGuideStore();await store.createOFDProject({title:'正式图片视频资源回归-20260911'})
const canvas=document.createElement('canvas');canvas.width=400;canvas.height=240
const ctx=canvas.getContext('2d');ctx.fillStyle='#168a72';ctx.fillRect(0,0,400,240);ctx.fillStyle='white';ctx.font='24px sans-serif';ctx.fillText('Formal image resource',45,130)
const png=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'))
const image=await store.assetStore.put(png,{name:'guide-image.png'})
const video=await store.assetStore.put(await(await fetch('/videos/physical-training-real-main-720p.mp4')).blob(),{name:'guide-video.mp4'})
const state=structuredClone(store.record.state)
state.resources=[{...image,id:image.assetKey,name:'guide-image.png',type:'图片',category:'image',resourceUrl:`asset://${image.assetKey}`,blob:undefined},{...video,id:video.assetKey,name:'guide-video.mp4',type:'视频',category:'video',resourceUrl:`asset://${video.assetKey}`,blob:undefined}]
const step=state.chapters[0].steps[0];step.image=`asset://${image.assetKey}`
step.mediaBlocks=[{id:'image-block',kind:'image',imageSource:`asset://${image.assetKey}`,x:120,y:62,width:72,height:58},{id:'video-block',kind:'video',assetId:video.assetKey,assetName:'guide-video.mp4',poster:canvas.toDataURL('image/png'),x:120,y:130,width:72,height:58}]
await store.saveOFDProjectState(store.project.id,state)
const id=store.project.id;store.dispose();const loaded=new FormalGuideStore();await loaded.load(id)
const saved=loaded.record.state.chapters[0].steps[0]
const valid=saved.mediaBlocks.every(b=>b.kind==='video'?!!b.poster:!!b.imageSource)&&!JSON.stringify(saved).includes('blob:')
loaded.dispose();return {id,valid}
})
id=result.id;checks.push({name:'图片与视频上传、截图引用保存后重开保持',pass:result.valid})
await page.goto(`http://127.0.0.1:6180/content/ofd/${id}/studio?view=preview`)
await expect(page.locator('.ofd-preview-pager')).toBeVisible({timeout:60000})
checks.push({name:'OFD 预览读取受控图片和截图',pass:!(await page.locator('.ofd-analysis-empty').allTextContents()).some(t=>t.includes('失败'))})
await page.locator('[data-action="preview-tab"][data-tab="interactive"]').click()
const video=page.locator('.ofd-ia-media-item.video video')
await expect(video).toHaveAttribute('src',/^blob:/,{timeout:30000})
await expect.poll(()=>video.evaluate(v=>v.readyState)).toBeGreaterThanOrEqual(2)
await video.evaluate(v=>v.play());await expect.poll(()=>video.evaluate(v=>v.currentTime)).toBeGreaterThan(0)
await video.scrollIntoViewIfNeeded();await page.screenshot({path:'reports/guide-formal-20260910/18-formal-video.png'})
checks.push({name:'正式视频通过受控字节恢复并实际播放',pass:true})
}catch(e){checks.push({name:'图片视频资源闭环',pass:false,detail:e.message});process.exitCode=1;await page.screenshot({path:'reports/guide-formal-20260910/media-failure.png'})}
finally{console.log(checks);fs.writeFileSync('reports/guide-formal-20260910/media.json',JSON.stringify({projectId:id,checks},null,2));await browser.close()}

+ 20
- 0
tools/guide-formal-offline.mjs Zobrazit soubor

@@ -0,0 +1,20 @@
import fs from 'node:fs'
import {chromium,expect} from '@playwright/test'
const report='reports/guide-formal-20260910',checks=[],errors=[],external=[]
const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
const page=await browser.newPage({viewport:{width:1440,height:1000}})
page.on('pageerror',e=>errors.push(e.message))
await page.route('**/*',route=>{const url=new URL(route.request().url());if(url.hostname!=='127.0.0.1'&&!['blob:','data:'].includes(url.protocol)){external.push(url.origin);return route.abort()}return route.continue()})
try{
await page.goto('http://127.0.0.1:18769/')
await page.locator('[data-action="open-model-library"]').click()
await page.locator('[data-model-select]').scrollIntoViewIfNeeded()
for(const [index,name] of [['1','15-offline-fox-scene'],['3','16-offline-truck-scene']]){
await page.locator('[data-model-select]').selectOption(index)
await expect(page.locator('[data-model-status]')).not.toContainText(/正在|尚未|失败/,{timeout:90000})
await page.waitForTimeout(2000)
await page.screenshot({path:`${report}/${name}.png`})
checks.push({name,pass:true,detail:await page.locator('[data-model-status]').textContent()})
}
}catch(e){checks.push({name:'离线场景浏览器读取',pass:false,detail:e.message});process.exitCode=1}
finally{checks.push({name:'离线资源不依赖业务服务器或公网',pass:external.length===0,detail:external.join(',')},{name:'无脚本异常',pass:errors.length===0,detail:errors.join('\n')});console.log(checks);fs.writeFileSync(`${report}/offline-browser.json`,JSON.stringify({checks,errors},null,2));await browser.close()}

+ 36
- 0
tools/guide-formal-permissions.mjs Zobrazit soubor

@@ -0,0 +1,36 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
import {login,call} from './role-permissions-live.mjs'
const fixture=JSON.parse(fs.readFileSync('../../.codex-tmp/guide-formal/fixture.json')),session=await login('root'),token=session.accessToken,checks=[]
const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
let user
try{
const roles=(await call('auth/v1/roles/all',token)).data,role=(roles.records||roles).find(r=>r.code==='student')
const me=(await call('auth/v1/auth/me',token)).data,password='G!'+crypto.randomBytes(7).toString('hex'),username='guide_'+crypto.randomBytes(5).toString('hex')
const created=await call('auth/v1/users',token,{username,password,displayName:'作业指导闭环测试学员',departmentId:me.departmentId,roleIds:[role.id],defaultRoleId:role.id,enabled:true,remark:'UTE2E-GUIDE-FORMAL'})
if(created.status!==200)throw new Error('测试学员创建失败 '+created.message)
user=created.data
const logged=await call('auth/v1/auth/login',null,{userId:user.id,password,roleCode:'student',rememberMe:false})
const actor=logged.data?.accessToken
if(!actor)throw new Error('测试学员登录失败 '+logged.status+' '+logged.message)
const base=`tran/v1/teaching/guides/${fixture.id}`
const content=await call(`tran/v1/content/projects/${fixture.id}`,actor);check('学员不能进入内容编辑接口',content.status===403)
const detail=await call(base,actor);check('学员可以阅读正式发布版本',detail.status===200)
check('学员投影没有文件存储 URI',!JSON.stringify(detail.data).includes('fsvc://'))
const state=(await call(`${base}/reader-state`,actor)).data;check('学员看不到其他用户的笔记和收藏',state?.notes.length===0&&state.favorite===false)
const saved=await call(`${base}/reader-state`,actor,{...state,pageId:'page:0',pageIndex:0,pageCount:3,favorite:true,notes:[{id:'student-note',pageId:'page:0',text:'学员本人私人笔记'}]},'PUT');check('学员能保存本人的阅读记录',saved.status===200)
const owner=(await call(`${base}/reader-state`,token)).data;check('学员写入不覆盖教员记录',!owner.notes.some(n=>n.id==='student-note'))
const stats=await call(`${base}/reader-statistics`,actor);check('学员不能查看教员聚合统计',stats.status===403)
const start=await call(`${base}/reading/start`,actor,{},'PUT');check('开始阅读产生正式记录',start.status===200,start.message)
const complete=await call(`${base}/reading/complete`,actor,{},'PUT');check('阅读完成记录成功',complete.status===200,complete.message)
const feedback=await call(`${base}/feedback`,actor,{content:'实测反馈:狐狸和卡车指导书资源已加载,请复核说明。'});check('学员反馈写入正式服务',feedback.status===200)
if(feedback.data?.id){const managed=await call(`${base}/feedback/${feedback.data.id}`,token,{version:feedback.data.version,status:'HANDLED',reply:'已通过正式接口复核。'},'PUT');check('教员回复并处理反馈',managed.status===200,managed.message)}
const asset=detail.data?.assets.find(a=>a.type==='MODEL_FILE')
const response=await fetch(`http://127.0.0.1:6180/api/${base}/assets/${asset.assetCode}/content`,{headers:{Authorization:`Bearer ${actor}`}})
const bytes=await response.arrayBuffer();check('学员通过教学接口下载 GLB',response.ok&&new TextDecoder().decode(bytes.slice(0,4))==='glTF')
await call('auth/v1/auth/logout',actor,{})
}catch(e){check('权限与阅读反馈流程',false,e.message)}
finally{
if(user?.id){const current=await call(`auth/v1/users/${user.id}`,token);const removed=await call(`auth/v1/users/${user.id}`,token,{version:current.data.version},'DELETE');check('临时测试学员已清理',removed.status===200,removed.message)}
fs.writeFileSync('reports/guide-formal-20260910/permissions.json',JSON.stringify({checks},null,2))
}

+ 46
- 0
tools/guide-formal-report.mjs Zobrazit soubor

@@ -0,0 +1,46 @@
import fs from 'node:fs'
import path from 'node:path'
const dir='reports/guide-formal-20260910'
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
// 报告只保留结论,不携带完整接口载荷、内部存储地址或上传凭据。
for(const file of ['create.json','upload.json','publish.json']){
const data=JSON.parse(fs.readFileSync(path.join(dir,file),'utf8'))
for(const check of data.checks||[])if(check.detail?.startsWith('{'))check.detail='真实 HTTP 请求成功;响应内容已脱敏。'
fs.writeFileSync(path.join(dir,file),JSON.stringify(data,null,2))
}
const suites=[['create.json','正式工程新建'],['upload.json','模型资源上传与恢复'],['scenes.json','固定场景版本与修订'],['publish.json','正式发布与交付物'],['review.json','教学阅读及真实 HTTP 查询'],['permissions.json','权限、私人记录与反馈'],['final.json','阅读、笔记、进度及 HTTP 写入'],['template.json','正式模板新建'],['media.json','图片、视频及截图引用'],['offline-browser.json','正式离线包运行']]
const results=suites.map(([file,title])=>({file,title,...JSON.parse(fs.readFileSync(path.join(dir,file),'utf8'))}))
const checks=results.flatMap(r=>r.checks),passed=checks.filter(c=>c.pass).length
const release=JSON.parse(fs.readFileSync(path.join(dir,'release.json'),'utf8'))
const offline=JSON.parse(fs.readFileSync(path.join(dir,'offline.json'),'utf8'))
const shots=[
['01-formal-created.png','正式指导书新建,写入内容服务'],
['02-formal-assets.png','狐狸、牛奶卡车 GLB 上传至正式资源存储'],
['03-formal-fox.png','刷新后狐狸模型恢复显示'],
['04-formal-published.png','V3.0.0 正式发布及交付物'],
['05-reader-notes.png','教学阅读:笔记与收藏'],
['06-reader-layout.png','教学阅读:OFD 版式'],
['09-reader-model.png','教学动态阅读:狐狸模型'],
['10-two-scene-resources.png','两个已发布场景固定版本引用'],
['11-reader-truck.png','教学动态阅读:牛奶卡车'],
['12-reading-completed.png','完成阅读,进度与统计更新'],
['08-http-integration.png','对外接口:真实 HTTP 查询'],
['13-http-conflict.png','对外接口:旧版本写入返回 409'],
['14-formal-templates.png','正式模板库'],
['17-template-editor.png','模板生成章节与步骤并保存'],
['15-offline-fox-scene.png','离线包:场景 160 当前发布内容(维修车间与装备)'],
['16-offline-truck-scene.png','离线包:牛奶卡车场景'],
['18-formal-video.png','图片与截图引用保持,视频实际播放'],
]
const html=`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>作业指导正式接口与教学闭环 · 实测报告</title>
<style>*{box-sizing:border-box}body{margin:0;background:#eff5f4;color:#203e42;font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1200px;margin:auto;padding:32px 24px}header,section{background:#fff;border:1px solid #d4e4e0;border-radius:12px;padding:24px;margin-bottom:22px}header{border-top:5px solid #10856e}h1{margin:0 0 10px;font-size:28px}h2{font-size:21px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}p{margin:8px 0}a{color:#087b66}nav{display:flex;gap:20px;flex-wrap:wrap;margin-top:15px}.metrics{display:flex;gap:12px;flex-wrap:wrap;margin:20px 0 0}.metric{background:#edf8f4;border-radius:8px;padding:12px 20px}.metric b{font-size:24px;color:#087b66;display:block}.note{background:#fff7e8;border-left:4px solid #e7a337;padding:12px 16px;margin-top:16px}table{width:100%;border-collapse:collapse;font-size:14px}th,td{border:1px solid #dce7e4;text-align:left;padding:10px 12px;vertical-align:top}th{background:#edf6f3}code{overflow-wrap:anywhere}.pass{color:#078064;font-weight:700}.fail{color:#ba3434}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:18px}figure{margin:0;border:1px solid #dce7e4;border-radius:8px;overflow:hidden}figure img{display:block;width:100%;height:auto}figcaption{padding:10px 14px;background:#f4f8f7}.subtle{color:#637b7d;font-size:13px}li{margin:7px 0}@media(max-width:760px){main{padding:16px 10px}.gallery{grid-template-columns:1fr}header,section{padding:16px}table{font-size:12px}th,td{padding:7px}}</style>
<main><header><p class="subtle">开发环境 · 2026-09-10 — 2026-09-11 · 顺序实施与回归</p><h1>作业指导正式接口与教学闭环</h1><p>已完成编辑器接入、教学阅读记录、真实 HTTP 联调三项实施。正式发布工程 ${esc(release.projectId)},版本 ${esc(release.versionCode)}(版本 ID ${esc(release.versionId)})。</p><div class="metrics"><div class="metric"><b>${passed}/${checks.length}</b>E2E 检查点通过</div><div class="metric"><b>391</b>后端测试通过</div><div class="metric"><b>17</b>关键截图</div><div class="metric"><b>3</b>保留复核工程</div></div><nav><a href="#scope">实施内容</a><a href="#tests">测试结果</a><a href="#screenshots">截图</a><a href="#delivery">交付与迁移</a><a href="#limits">复核说明</a></nav></header>
<section id="scope"><h2>实施内容</h2><table><tr><th>顺序</th><th>落地结果</th><th>验证方式</th></tr><tr><td>1. 内容、资源、版本</td><td>完整编辑文档写入正式内容服务;上传资源进入受控存储;模板、模型和场景从正式接口加载;保存、发布、历史版本及恢复使用服务端版本。场景资源固定到发布版本,修改草稿不改变已发布教学内容。</td><td>新建、模板生成、保存重开、GLB/图片/视频上传、固定场景引用、修订与发布、OFD 和离线 ZIP 实际下载。</td></tr><tr><td>2. 教学阅读闭环</td><td>教学端消费正式发布文档与受控资源;阅读位置、笔记、收藏按用户和发布版本独立保存;记录开始/完成、反馈与处理,提供授权聚合统计。</td><td>翻页恢复、笔记增改删、收藏取消、动态步骤同步进度、学员权限及数据隔离、反馈回复、完成记录。</td></tr><tr><td>3. 真实 HTTP 联调</td><td>接口页面通过统一 HTTP 客户端调用 Gateway/业务服务,展示真实响应。涵盖内容、资源、版本、教学、个人阅读记录、统计与交付物。</td><td>浏览器实际 GET/PUT;旧版本返回 409;错误方法返回 405;未经授权内容和统计接口返回 403。</td></tr></table><p>接口沿用平台统一登录会话与权限体系。第三方系统需取得平台会话;数字人开放应用 APPID 不用于这组作业指导接口。</p>
<h3>本轮修复</h3><ul><li>完整文档校验及正式资源 URI 保留;教学投影转换为受控下载地址,避免文件存储地址直接暴露或刷新后丢失。</li><li>大 GLB 流式下载被默认 30 秒中断;异步流响应超时调整为可配置的 30 分钟。</li><li>保存与发布并发造成旧版本冲突;发布前等待自动保存队列完成。</li><li>阅读器初始化零页记录、只读目录/动画选择被拦截、动态页进度未同步、笔记删除后编辑框残留。</li><li>离线模型切换后的矩阵与镜头适配错误,导致场景偏出画面。</li><li>图片、视频海报的正式 URI 被过滤;视频完成加载后动态预览未刷新。已验证受控视频实际播放。</li><li>发布弹窗与动态白纸内容的暗色主题文字对比度。</li></ul></section>
<section id="tests"><h2>测试结果</h2><p>浏览器为 Edge / Playwright,主要视口 1920×1080。管理操作使用内部维护账号;教学权限使用临时学员账号,测试后已清理。HTTP 通过 Web 6180 → Gateway 6100 → Tran 6102。</p><p><code>mvn -pl ut_tran -am test package</code>:391 项通过,0 失败、0 错误、0 跳过。<br><code>pnpm run build</code>:类型检查、Vite 构建与模型编辑器发布资源校验通过;保留既有 chunk 大小及运行时资源 URL 警告。</p>${results.map(s=>`<h3>${esc(s.title)} <a class="subtle" href="${s.file}">JSON 证据</a></h3><table><tr><th>检查点</th><th style="width:80px">结果</th></tr>${s.checks.map(c=>`<tr><td>${esc(c.name)}${c.detail?`<div class="subtle">${esc(c.detail)}</div>`:''}</td><td class="${c.pass?'pass':'fail'}">${c.pass?'通过':'失败'}</td></tr>`).join('')}</table>`).join('')}<p class="subtle">检查点来自分阶段脚本,覆盖关键链路,不表示所有边缘输入均已穷举。create/scenes 等早期证据对应创建及 V1→V2 阶段;最新发布、阅读和离线回归对应 V3。</p></section>
<section id="screenshots"><h2>关键截图</h2><p>点击图片查看原图。文件名中的“fox-scene”沿用测试场景名称,不代表该场景当前实际包含狐狸。</p><div class="gallery">${shots.map(([file,title])=>`<figure><a href="${file}" target="_blank"><img src="${file}" alt="${esc(title)}" loading="lazy"></a><figcaption>${esc(title)}</figcaption></figure>`).join('')}</div></section>
<section id="delivery"><h2>交付与迁移</h2><p><a href="formal.ofd">下载实测正式 OFD</a> · <a href="formal-offline.zip">下载实测正式离线 ZIP(本地文件)</a> · <a href="release.json">发布摘要</a> · <a href="offline.json">ZIP 完整性证据</a></p><p>离线 ZIP:${Number(offline.zipBytes).toLocaleString('zh-CN')} 字节;4 个 GLB 文件通过头部与长度检查。从正式 V3 下载后解压并运行,禁止访问业务服务及公网仍能加载两个场景。镜头修复代码已经包含在正式交付包中。</p><p class="subtle">SHA-256:<code>${esc(offline.sha256)}</code></p><table><tr><th>开发环境迁移</th><th>说明</th></tr><tr><td>20260826_017_teaching_guide_reading_closed_loop.sql</td><td>补执行既有阅读与反馈表迁移,先回滚试执行再正式执行。</td></tr><tr><td>20260826_018_content_guide_template_library.sql</td><td>补执行既有正式模板库迁移,保留 4 个模板。</td></tr><tr><td>20260911_001_guide_reader_state.sql</td><td>新增按用户/项目/发布版本隔离的阅读位置、笔记、收藏及进度表;完整 DDL、数据库设计、迁移记录和 Wiki 已同步。</td></tr></table><p>其他环境部署前先检查既有表,避免重复执行。新增流下载配置 <code>UT_CONTENT_STREAM_TIMEOUT</code> 默认 <code>30m</code>。本轮不代表其他环境已完成数据库迁移。</p></section>
<section id="limits"><h2>复核说明与保留数据</h2><ul><li>工程 412:正式闭环-狐狸与卡车指导书-20260910,已发布 V3.0.0 / 472。</li><li>工程 413:正式模板新建回归-20260911,保留草稿。</li><li>工程 414:正式图片视频资源回归-20260911,保留草稿。</li><li>临时学员账号已清理,正式测试阅读及反馈历史保留。阅读页进度与“完成阅读”分别记录,不等同于考核成绩。</li><li>既有内部维护账号头像存在单独的 404,属于原账号头像资源问题;本轮指导书资源、视频及交付下载检查通过。没有将该请求计入“无网络错误”的结论。</li><li>本轮未自动提交或推送 Git。大型测试 ZIP 仅保留在本地报告目录,不纳入 Git。</li></ul><div class="note">需要核对的数据差异:场景 160@463 名为“实测场景-狐狸野外训练区-20260905”,当前发布内容实际是维修车间与装备,不包含狐狸。已按其固定发布版本验证,未修改源场景;狐狸模型在指导书独立步骤中验证通过。牛奶卡车场景使用 159@209。</div><p>三项实施均在本次授权范围内,没有新增待批准的大幅功能调整。旧浏览器本地工程如需迁移,使用显式导入新工程;无法从服务端恢复的本地文件需重新上传。</p></section></main></html>`
fs.writeFileSync(path.join(dir,'index.html'),html)
console.log(`Report generated: ${passed}/${checks.length} checks, ${shots.length} screenshots`)
if(passed!==checks.length)process.exitCode=1

+ 56
- 0
tools/guide-formal-review.mjs Zobrazit soubor

@@ -0,0 +1,56 @@
import fs from 'node:fs'
import path from 'node:path'
import {chromium,expect} from '@playwright/test'
import {login,call} from './role-permissions-live.mjs'
const report=path.resolve('reports/guide-formal-20260910'),work=path.resolve('../../.codex-tmp/guide-formal')
const fixture=JSON.parse(fs.readFileSync(path.join(work,'fixture.json'))),checks=[],errors=[]
const session=await login('root'),token=session.accessToken
const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
const base=`tran/v1/teaching/guides/${fixture.id}`
const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
const context=await browser.newContext({viewport:{width:1920,height:1080}})
await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message))
await page.addInitScript(()=>{new MutationObserver(()=>{for(const el of document.querySelectorAll('.ofd-toast,.el-message'))if(!el.dataset.reported){el.dataset.reported='true';console.log('NOTICE',el.textContent)}}).observe(document,{childList:true,subtree:true})})
page.on('console',m=>{if(m.text().startsWith('NOTICE'))console.log(m.text())})
page.on('response',r=>{if(r.status()>=400)console.log('HTTP',r.status(),new URL(r.url()).pathname)})
const shot=async name=>page.screenshot({path:path.join(report,name+'.png'),fullPage:false})
try{
const assets=await call(`${base}/deliveries`,token);check('发布版本提供两份正式交付物',assets.data?.length===2)
for(const format of (process.argv.includes('--browser-only')?[]:['OFD','OFFLINE_HTML'])){const r=await context.request.get(`http://127.0.0.1:6180/api/${base}/delivery?format=${format}`,{headers:{Authorization:`Bearer ${token}`},timeout:180000});check('统一文件服务交付物下载 '+format,r.ok());if(r.ok())fs.writeFileSync(path.join(report,format==='OFD'?'formal.ofd':'formal-offline.zip'),await r.body())}
const before=(await call(`${base}/reader-state`,token)).data
const command={...before,pageId:'page:1',pageIndex:1,pageCount:3,favorite:true,notes:[{id:'formal-note',pageId:'page:1',text:'正式版本笔记:核对狐狸模型与卡车资源,注意安全检查。'}]}
const saved=await call(`${base}/reader-state`,token,command,'PUT');check('阅读位置、收藏、笔记真实写入',saved.status===200&&saved.data.favorite&&saved.data.notes.length===1)
const stale=await call(`${base}/reader-state`,token,command,'PUT');check('旧乐观锁版本被拒绝',stale.status===409)
const release=await call(`${base}/reader-state`,token,{...command,versionId:'1'},'PUT');check('旧发布版本不能覆盖当前阅读记录',release.status===409)
const stats=await call(`${base}/reader-statistics`,token);check('收藏和笔记进入真实统计',stats.data?.favorites>=1&&stats.data?.notes>=1)
await page.goto('http://127.0.0.1:6180/teaching/guides')
await page.getByPlaceholder('搜索指导书名称或编码').fill('正式闭环-狐狸与卡车指导书-20260910')
await page.getByPlaceholder('搜索指导书名称或编码').press('Enter')
await expect(page.locator('.guide-card-v2')).toHaveCount(1)
await page.locator('.guide-card-v2 button').first().click()
await expect(page.locator('.legacy-published-reader .ofd-app')).toBeVisible({timeout:60000})
await expect(page.getByRole('button',{name:'取消收藏',exact:true})).toBeVisible()
await page.getByRole('button',{name:/笔记 1/}).click()
await expect(page.locator('.reader-note')).toContainText('正式版本笔记')
await shot('05-reader-notes')
await page.getByPlaceholder('记录当前页的阅读笔记').fill('浏览器实测新增笔记:页码和内容由正式服务保存。')
const noteResponse=page.waitForResponse(r=>r.url().endsWith('/reader-state')&&r.request().method()==='PUT')
await page.getByRole('button',{name:'添加笔记',exact:true}).click()
check('浏览器笔记保存请求成功',(await noteResponse).ok())
await page.locator('.el-drawer__close-btn:visible').click()
await page.locator('.legacy-published-reader [data-action="preview-next"]').click()
await page.waitForTimeout(1300);await shot('06-reader-layout')
const persisted=await call(`${base}/reader-state`,token);check('浏览器翻页位置已同步',persisted.data?.pageIndex===2)
await page.locator('.legacy-published-reader [data-action="preview-tab"][data-tab="interactive"]').click()
await page.waitForTimeout(3500);await shot('07-reader-interactive')
await page.locator('.legacy-published-reader canvas').first().scrollIntoViewIfNeeded()
await page.waitForTimeout(1200);await shot('09-reader-model')
check('教学动态阅读显示三维画布',await page.locator('.legacy-published-reader canvas').count()>0)
await page.goto('http://127.0.0.1:6180/content/ofd/api')
await page.getByRole('button',{name:'执行调用',exact:true}).click()
await expect(page.locator('.guide-http-page pre')).toContainText('records')
await shot('08-http-integration')
check('对外接口面板返回真实工程数据',await page.locator('.guide-http-page pre').textContent().then(t=>t.includes('GUIDE-')))
}catch(e){check('浏览器闭环流程',false,e.message);await shot('failure-review')}
finally{check('浏览器无脚本异常',errors.length===0,errors.join('\n'));fs.writeFileSync(path.join(report,'review.json'),JSON.stringify({checks,errors},null,2));await browser.close()}

+ 36
- 0
tools/guide-formal-scenes.mjs Zobrazit soubor

@@ -0,0 +1,36 @@
import fs from 'node:fs'
import path from 'node:path'
import {chromium,expect} from '@playwright/test'
import {login,call} from './role-permissions-live.mjs'
const report=path.resolve('reports/guide-formal-20260910'),fixture=JSON.parse(fs.readFileSync('../../.codex-tmp/guide-formal/fixture.json'))
const session=await login('root'),browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']})
const context=await browser.newContext({viewport:{width:1920,height:1080}})
await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
const page=await context.newPage(),checks=[]
const check=(name,pass,detail='')=>{checks.push({name,pass,detail});console.log(pass?'PASS':'FAIL',name);if(!pass)process.exitCode=1}
try{
await page.goto('http://127.0.0.1:6180/content/ofd')
await expect(page.getByRole('button',{name:'新建指导书',exact:true})).toBeVisible()
const result=await page.evaluate(async id=>{
const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts')
const store=new FormalGuideStore()
try{
await store.load(id);await store.loadCatalog();const old=store.project.publishedVersionId
if(store.project.status==='PUBLISHED')await store.transitionOFDProject(id,'edit',null)
const state=JSON.parse(JSON.stringify(store.record.state)),first=state.chapters[0].steps[0]
first.image='';first.title='狐狸模型巡检'
const truck=state.resources.find(r=>r.name?.includes('MilkTruck'))
const second={...JSON.parse(JSON.stringify(first)),id:'formal-truck-step',title:'牛奶卡车检查',image:'',modelRef:{...first.modelRef,assetId:truck.id,nodeName:''},mediaBlocks:[]}
state.chapters[0].steps=[first,second]
for(const [step,key] of [[first,'160@463'],[second,'159@209']])step.mediaBlocks=[{id:'scene-'+step.id,kind:'scene',assetId:key,title:step.title+'场景',x:20,y:180,width:170,height:80}]
await store.saveOFDProjectState(id,state)
return {old,published:store.project.publishedVersionId,assets:store.project.assets.length,bindings:Object.keys(store.record.state.sceneBindings||{}),version:store.project.version}
}finally{store.dispose()}
},fixture.id)
check('新修订保持原发布版本可阅读',result.old===result.published)
check('狐狸与卡车场景固定版本已保存',result.bindings.length===2,JSON.stringify(result))
await page.goto(`http://127.0.0.1:6180/content/ofd/${fixture.id}/studio`)
await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
await page.screenshot({path:path.join(report,'10-two-scene-resources.png')})
}catch(e){check('正式场景资源闭环',false,e.message);await page.screenshot({path:path.join(report,'failure-scenes.png')})}
finally{fs.writeFileSync(path.join(report,'scenes.json'),JSON.stringify({checks},null,2));await browser.close()}

+ 28
- 0
tools/guide-formal-template.mjs Zobrazit soubor

@@ -0,0 +1,28 @@
import fs from 'node:fs'
import {chromium,expect} from '@playwright/test'
import {login,call} from './role-permissions-live.mjs'
const session=await login('root'),checks=[]
const browser=await chromium.launch({channel:'msedge',headless:true})
const page=await browser.newPage({viewport:{width:1920,height:1080}})
await page.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session)
let id
try{
await page.goto('http://127.0.0.1:6180/content/ofd/templates')
await page.locator('.library-heading button').click()
await page.getByLabel('指导书名称',{exact:true}).fill('正式模板新建回归-20260911')
const created=page.waitForResponse(r=>r.url().includes('/guide-templates/')&&r.url().endsWith('/projects')&&r.request().method()==='POST')
await page.getByRole('button',{name:'创建并进入规划',exact:true}).click()
const response=await created;const body=await response.json();id=String(body.data.project.id)
checks.push({name:'模板新建实际进入正式内容服务',pass:response.ok()})
await page.waitForURL(/\/ofd\/\d+\/studio/)
await page.goto(`http://127.0.0.1:6180/content/ofd/${id}/studio?view=editor`)
await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000})
const before=await call(`tran/v1/content/projects/${id}`,session.accessToken)
const save=page.waitForResponse(r=>r.url().endsWith(`/projects/${id}`)&&r.request().method()==='PUT')
await page.locator('[data-action="save"]').click();const saved=await save
checks.push({name:'模板初稿进入编辑器并保存',pass:saved.ok()})
const after=await call(`tran/v1/content/projects/${id}`,session.accessToken)
checks.push({name:'模板章节和步骤不丢失',pass:JSON.stringify(before.data.currentVersion.content.chapters.map(c=>[c.id,c.title,c.steps.map(s=>[s.id,s.title])]))===JSON.stringify(after.data.currentVersion.content.chapters.map(c=>[c.id,c.title,c.steps.map(s=>[s.id,s.title])]))})
await page.screenshot({path:'reports/guide-formal-20260910/17-template-editor.png'})
}catch(e){checks.push({name:'模板编制闭环',pass:false,detail:e.message});process.exitCode=1;await page.screenshot({path:'reports/guide-formal-20260910/template-failure.png'})}
finally{console.log(checks);fs.writeFileSync('reports/guide-formal-20260910/template.json',JSON.stringify({projectId:id,checks},null,2));await browser.close()}

Načítá se…
Zrušit
Uložit