| @@ -0,0 +1,3 @@ | |||||
| # 本地实测导出包及已修复失败尝试,仅保留在当前设备供追溯。 | |||||
| formal-offline.zip | |||||
| *failure*.png | |||||
| @@ -0,0 +1,15 @@ | |||||
| { | |||||
| "checks": [ | |||||
| { | |||||
| "name": "新建请求实际进入内容服务", | |||||
| "pass": true, | |||||
| "detail": "真实 HTTP 请求成功;响应内容已脱敏。" | |||||
| }, | |||||
| { | |||||
| "name": "正式后台可查询新工程", | |||||
| "pass": true, | |||||
| "detail": "" | |||||
| } | |||||
| ], | |||||
| "errors": [] | |||||
| } | |||||
| @@ -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": [] | |||||
| } | |||||
| @@ -0,0 +1,17 @@ | |||||
| { | |||||
| "projectId": "414", | |||||
| "checks": [ | |||||
| { | |||||
| "name": "图片与视频上传、截图引用保存后重开保持", | |||||
| "pass": true | |||||
| }, | |||||
| { | |||||
| "name": "OFD 预览读取受控图片和截图", | |||||
| "pass": true | |||||
| }, | |||||
| { | |||||
| "name": "正式视频通过受控字节恢复并实际播放", | |||||
| "pass": true | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -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": [] | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -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": [] | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,6 @@ | |||||
| { | |||||
| "images": 17, | |||||
| "brokenImages": [], | |||||
| "lanPageLoaded": true, | |||||
| "viewport": "1440x1000" | |||||
| } | |||||
| @@ -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": [] | |||||
| } | |||||
| @@ -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}" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,17 @@ | |||||
| { | |||||
| "projectId": "413", | |||||
| "checks": [ | |||||
| { | |||||
| "name": "模板新建实际进入正式内容服务", | |||||
| "pass": true | |||||
| }, | |||||
| { | |||||
| "name": "模板初稿进入编辑器并保存", | |||||
| "pass": true | |||||
| }, | |||||
| { | |||||
| "name": "模板章节和步骤不丢失", | |||||
| "pass": true | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -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": [] | |||||
| } | |||||
| @@ -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()} | |||||
| @@ -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()} | |||||
| @@ -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()} | |||||
| @@ -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()} | |||||
| @@ -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)) | |||||
| } | |||||
| @@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 | |||||
| @@ -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()} | |||||
| @@ -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()} | |||||
| @@ -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()} | |||||