| @@ -0,0 +1,3 @@ | |||
| offline/ | |||
| *-failure.png | |||
| failure.png | |||
| @@ -0,0 +1,41 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "指导书通过正式接口固定引用模型版本并持久化动画元数据", | |||
| "pass": true, | |||
| "resource": { | |||
| "id": "416@479", | |||
| "tracks": 4, | |||
| "clips": 2, | |||
| "overrides": 11 | |||
| } | |||
| }, | |||
| { | |||
| "name": "正式指导书视口加载命名动作并还原组合运动与材质状态", | |||
| "pass": true, | |||
| "detail": { | |||
| "clips": [ | |||
| "Wheels", | |||
| "旋出与清洁演示", | |||
| "阀门复位演示" | |||
| ], | |||
| "initial": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "final": [ | |||
| -1.5407439555097887e-33, | |||
| 0.5, | |||
| -1.6554003917764114e-18 | |||
| ], | |||
| "state": 0.2 | |||
| } | |||
| }, | |||
| { | |||
| "name": "正式发布并下载带动画的离线指导书", | |||
| "pass": true | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,22 @@ | |||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>动画能力实施与测试报告 · 2026-09-12</title> | |||
| <style>body{margin:0;background:#edf3f3;color:#173c40;font:15px/1.8 "Microsoft YaHei",sans-serif}main{max-width:1200px;margin:32px auto;padding:0 24px 60px}header,section{background:white;border:1px solid #cddede;border-radius:12px;padding:26px;margin:20px 0}h1{font-size:28px;margin:0}h2{font-size:21px;margin:0 0 14px}h3{font-size:17px;margin:12px 0}p{margin:10px 0}a{color:#087d6c}table{border-collapse:collapse;width:100%}th,td{padding:12px;text-align:left;border:1px solid #d8e6e5;vertical-align:top}th{background:#eff6f5}.pass{color:#087954;font-weight:bold}.meta{color:#567677}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.grid figure{margin:0;border:1px solid #cddede;border-radius:8px;overflow:hidden}.grid img{display:block;width:100%}.grid figcaption{padding:12px}.badge{display:inline-block;background:#e4f6ed;border-radius:6px;padding:5px 12px;color:#087954}code{background:#f1f5f5;padding:2px 5px;overflow-wrap:anywhere}@media(max-width:800px){.grid{grid-template-columns:1fr}main{padding:0 12px}section{padding:18px}}</style> | |||
| <main><header><p class="meta">数字车间 · 功能实施 / 真实接口 / 浏览器端到端</p><h1>模型动画能力实施与测试报告</h1><p>2026-09-12 · <span class="badge">14 项端到端检查通过</span></p><p>按已确认范围补齐命名动作、组合运动、材质状态、流向讲解和跨模块复用。使用独立的牛奶卡车测试工程验证,不修改原始模型和既有业务任务。</p></header> | |||
| <section><h2>已实施能力</h2><table><tr><th>能力</th><th>实现与使用</th></tr> | |||
| <tr><td>动作片段与机械组合运动</td><td>时间轴设置命名起止范围;旋转中心使用部件局部坐标;位移与 XYZ 旋转叠加,关键帧错开形成顺序动作;支持保持末帧和回到起点。蓝图按名称播放片段。</td></tr> | |||
| <tr><td>状态与材质</td><td>新增缩放、显隐、不透明度、颜色、材质状态轨道;材质副本隔离,不污染其他实例。录制与保存保留中性基准,旋转中心偏移不会被重复写入位移关键帧。</td></tr> | |||
| <tr><td>回路流向</td><td>配置路径点、颜色、速度、流点数量、正向/反向/停止;可关联标量轨道控制开通。用于工作原理讲解。</td></tr> | |||
| <tr><td>跨模块复用</td><td>训练编辑器从正式目录选动作,保存步骤绑定;教学任务固定模型/场景/训练版本;指导书在线模型与离线 ZIP 使用同一动画求值规则。原生 GLB 动画仍可选择。</td></tr></table></section> | |||
| <section><h2>实测结果</h2><h3>模型编辑与发布</h3><table><tr><th>检查</th><th>结果</th></tr><tr><td>真实工程保存组合旋转、位移、材质状态和流向轨道</td><td class="pass">通过</td></tr><tr><td>刷新重开后动作片段、复位基准和流向配置恢复</td><td class="pass">通过</td></tr><tr><td>正式发布固定动画模型版本</td><td class="pass">通过</td></tr></table><p class="meta"><a href="model-checks.json">查看原始检查记录</a> · 浏览器脚本异常 0 条</p><h3>指导书在线使用与交付</h3><table><tr><th>检查</th><th>结果</th></tr><tr><td>指导书通过正式接口固定引用模型版本并持久化动画元数据</td><td class="pass">通过</td></tr><tr><td>正式指导书视口加载命名动作并还原组合运动与材质状态</td><td class="pass">通过</td></tr><tr><td>正式发布并下载带动画的离线指导书</td><td class="pass">通过</td></tr></table><p class="meta"><a href="guide-checks.json">查看原始检查记录</a> · 浏览器脚本异常 0 条</p><h3>离线播放</h3><table><tr><th>检查</th><th>结果</th></tr><tr><td>下载的离线包可播放命名组合动作,画布发生变化并可暂停</td><td class="pass">通过</td></tr><tr><td>断开业务服务与公网后仍可加载模型、引擎和动画</td><td class="pass">通过</td></tr></table><p class="meta"><a href="offline-checks.json">查看原始检查记录</a> · 浏览器脚本异常 0 条</p><h3>正式教学视口</h3><table><tr><th>检查</th><th>结果</th></tr><tr><td>真实教学任务固定模型版本、动作、材质、枢轴和流向配置</td><td class="pass">通过</td></tr><tr><td>正式教学预览实际播放指定片段并保持末帧,四条轨道全部绑定</td><td class="pass">通过</td></tr><tr><td>切换步骤后播放复位片段,结束恢复起点并关闭流向</td><td class="pass">通过</td></tr><tr><td>同一教学视口原生 GLB 动画与自编片段切换兼容(浏览器运行时检查)</td><td class="pass">通过</td></tr></table><p class="meta"><a href="teaching-checks.json">查看原始检查记录</a> · 浏览器脚本异常 0 条</p><h3>训练编辑器绑定</h3><table><tr><th>检查</th><th>结果</th></tr><tr><td>训练编辑器从正式模型库选择命名动作并通过真实保存接口持久化</td><td class="pass">通过</td></tr><tr><td>训练编辑草稿不会覆盖已创建任务的固定发布快照</td><td class="pass">通过</td></tr></table><p class="meta"><a href="training-editor-checks.json">查看原始检查记录</a> · 浏览器脚本异常 0 条</p> | |||
| <p>补充验证:前端类型检查及生产构建通过;生成的模型编辑器 public/dist 文件哈希一致。后端 18 条校验、快照与考试提示隔离用例通过;原型 5 项合同测试、Web 4 项场景/缓存/落地测试通过。构建仅有现有的大分块提示。</p> | |||
| <p class="meta">测试采用真实登录、GLB、保存/发布/下载接口。部分配置和视口内部状态由 Playwright 调用页面模块核对;教学原生 GLB 切换为浏览器运行时兼容检查。离线用例阻断业务接口及公网请求,外部请求数为 0。</p></section> | |||
| <section><h2>可继续使用的测试数据</h2><table><tr><th>数据</th><th>标识</th><th>入口</th></tr> | |||
| <tr><td>组合动作模型</td><td>416 · 固定模型版本 479</td><td><a href="http://127.0.0.1:6180/content/models/416/edit">打开模型编辑器</a></td></tr> | |||
| <tr><td>测试场景</td><td>418 · 版本 481</td><td><a href="http://127.0.0.1:6180/content/scenes/418/edit">打开场景</a></td></tr> | |||
| <tr><td>两步骤测试训练</td><td>419</td><td><a href="http://127.0.0.1:6180/content/training-projects/419/legacy">打开训练编排</a></td></tr> | |||
| <tr><td>固定快照教学任务</td><td>55 · 训练版本 482</td><td><a href="http://127.0.0.1:6180/teaching/virtual-training/preview/55">打开教学预览</a></td></tr> | |||
| <tr><td>动画指导书</td><td>417</td><td><a href="http://127.0.0.1:6180/content/ofd/417/studio?view=editor">打开指导书</a> · <a href="animation-guide.zip">下载实测离线包</a></td></tr></table> | |||
| <p>训练当前保留实测保存的草稿;已有任务仍使用旧发布快照,这正是版本隔离测试验证的行为。试验性教学任务未产生学员成绩。</p></section> | |||
| <section><h2>使用顺序与边界</h2><ol><li>模型编辑器时间轴添加轨道,选择部件、属性及关键帧;进入「动作与轨道设置」配置片段、枢轴、材质状态或流向。</li><li>保存并发布模型,在场景选择固定版本;训练的「步骤动画」选择正式动作并保存。</li><li>发布训练、下发新任务后在教学端使用;指导书关联模型片段后可在线播放或导出离线 ZIP。</li></ol> | |||
| <p>本次没有数据库表改动。未加入新的密钥文件、独立服务或认证方式。代码尚未提交 Git。</p> | |||
| <p>污渍/清洁通过颜色和粗糙度等 PBR 参数表达;贴图擦除、软管/密封圈弹性变形、真实流体压力计算、整片视频导出不在本次实施范围。表格中的具体装备动作仍需配置部件、路径和时序,本报告不代表已经制作完成全部动画素材。</p></section> | |||
| <section><h2>浏览器截图</h2><p class="meta">点击图片查看原图。</p><div class="grid"><figure><a href="01-animation-settings.png"><img loading="lazy" src="01-animation-settings.png" alt="动作与轨道设置:范围、结束处理、路径和阀门联动"></a><figcaption>动作与轨道设置:范围、结束处理、路径和阀门联动</figcaption></figure><figure><a href="02-editor-end-pose.png"><img loading="lazy" src="02-editor-end-pose.png" alt="模型编辑器中的组合动作"></a><figcaption>模型编辑器中的组合动作</figcaption></figure><figure><a href="03-model-reopened.png"><img loading="lazy" src="03-model-reopened.png" alt="刷新重开后恢复动画配置"></a><figcaption>刷新重开后恢复动画配置</figcaption></figure><figure><a href="05-guide-model-animation.png"><img loading="lazy" src="05-guide-model-animation.png" alt="正式指导书模型视口"></a><figcaption>正式指导书模型视口</figcaption></figure><figure><a href="07-offline-before.png"><img loading="lazy" src="07-offline-before.png" alt="离线包加载模型"></a><figcaption>离线包加载模型</figcaption></figure><figure><a href="08-offline-animation.png"><img loading="lazy" src="08-offline-animation.png" alt="离线片段播放及暂停"></a><figcaption>离线片段播放及暂停</figcaption></figure><figure><a href="09-teaching-animation.png"><img loading="lazy" src="09-teaching-animation.png" alt="教学步骤:保持末帧、清洁状态和流向开通"></a><figcaption>教学步骤:保持末帧、清洁状态和流向开通</figcaption></figure><figure><a href="10-teaching-reset.png"><img loading="lazy" src="10-teaching-reset.png" alt="教学步骤:播放结束复位并关闭流向"></a><figcaption>教学步骤:播放结束复位并关闭流向</figcaption></figure><figure><a href="11-training-action-library.png"><img loading="lazy" src="11-training-action-library.png" alt="训练编辑器读取正式动作目录"></a><figcaption>训练编辑器读取正式动作目录</figcaption></figure><figure><a href="12-training-action-bound.png"><img loading="lazy" src="12-training-action-bound.png" alt="通过页面绑定并保存动作"></a><figcaption>通过页面绑定并保存动作</figcaption></figure></div></section></main></html> | |||
| @@ -0,0 +1,47 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "真实工程保存组合旋转、位移、材质状态和流向轨道", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "刷新重开后动作片段、复位基准和流向配置恢复", | |||
| "pass": true, | |||
| "detail": { | |||
| "a": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "b": [ | |||
| -1.5407439555097887e-33, | |||
| 0.5, | |||
| -1.6554003917764114e-18 | |||
| ], | |||
| "clips": [ | |||
| { | |||
| "id": "work", | |||
| "name": "旋出与清洁演示", | |||
| "start": 0, | |||
| "end": 4, | |||
| "finish": "hold" | |||
| }, | |||
| { | |||
| "id": "reset", | |||
| "name": "阀门复位演示", | |||
| "start": 0, | |||
| "end": 4, | |||
| "finish": "reset" | |||
| } | |||
| ], | |||
| "flows": 1 | |||
| } | |||
| }, | |||
| { | |||
| "name": "正式发布固定动画模型版本", | |||
| "pass": true, | |||
| "versionId": "479" | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "下载的离线包可播放命名组合动作,画布发生变化并可暂停", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "断开业务服务与公网后仍可加载模型、引擎和动画", | |||
| "pass": true | |||
| } | |||
| ], | |||
| "errors": [], | |||
| "external": [] | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| { | |||
| "date": "2026-09-12", | |||
| "passed": 14, | |||
| "failed": 0, | |||
| "backendTests": 18, | |||
| "prototypeContractTests": 5, | |||
| "webTests": 4, | |||
| "build": "passed", | |||
| "ddlChanges": false | |||
| } | |||
| @@ -0,0 +1,67 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "真实教学任务固定模型版本、动作、材质、枢轴和流向配置", | |||
| "pass": true, | |||
| "modelVersionId": "479", | |||
| "assignmentId": "55" | |||
| }, | |||
| { | |||
| "name": "正式教学预览实际播放指定片段并保持末帧,四条轨道全部绑定", | |||
| "pass": true, | |||
| "detail": { | |||
| "status": "已加载发布训练场景 · 1 个对象", | |||
| "time": 4, | |||
| "bindings": 4, | |||
| "unresolved": 0, | |||
| "wheel": [ | |||
| -1.5407439555097887e-33, | |||
| 0.5, | |||
| -1.6554003917764114e-18 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 0.2, | |||
| "actualRoughness": 0.2, | |||
| "flowVisible": true, | |||
| "step": "animation-step" | |||
| } | |||
| }, | |||
| { | |||
| "name": "切换步骤后播放复位片段,结束恢复起点并关闭流向", | |||
| "pass": true, | |||
| "detail": { | |||
| "status": "已加载发布训练场景 · 1 个对象", | |||
| "time": 0, | |||
| "bindings": 4, | |||
| "unresolved": 0, | |||
| "wheel": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 0.95, | |||
| "actualRoughness": 0.95, | |||
| "flowVisible": false, | |||
| "step": "reset-step" | |||
| } | |||
| }, | |||
| { | |||
| "name": "同一教学视口原生 GLB 动画与自编片段切换兼容(浏览器运行时检查)", | |||
| "pass": true, | |||
| "detail": { | |||
| "name": "Wheels", | |||
| "time": 0.8832000000000001 | |||
| } | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| { | |||
| "status": "已加载发布训练场景 · 1 个对象", | |||
| "time": 4, | |||
| "bindings": 4, | |||
| "unresolved": 0, | |||
| "wheel": [ | |||
| -1.5407439555097887e-33, | |||
| 0.5, | |||
| -1.6554003917764114e-18 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 0.2, | |||
| "actualRoughness": 0.2, | |||
| "flowVisible": true, | |||
| "step": "animation-step" | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "训练编辑器从正式模型库选择命名动作并通过真实保存接口持久化", | |||
| "pass": true, | |||
| "binding": { | |||
| "assetId": "animation-content-model-416-479-work", | |||
| "name": "动画能力验收-组合动作与流向-20260912 · 旋出与清洁演示", | |||
| "modelAssetId": "content-model-416-479", | |||
| "clipName": "旋出与清洁演示", | |||
| "sourceStart": 0, | |||
| "finish": "hold", | |||
| "duration": 4, | |||
| "trackCount": 4, | |||
| "revision": 1 | |||
| } | |||
| }, | |||
| { | |||
| "name": "训练编辑草稿不会覆盖已创建任务的固定发布快照", | |||
| "pass": true | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>模板库操作区样式修复</title><style>body{margin:24px auto;max-width:1200px;padding:0 20px;font:16px/1.7 system-ui;color:#204740;background:#f4f8f7}section{background:white;border:1px solid #cbded8;border-radius:10px;padding:20px;margin:20px 0}img{width:100%;height:auto}a{color:#087b66}</style><h1>模板库操作区样式修复</h1><section><p>2026-09-12 · 开发环境 · 内部维护账号 · Edge / Playwright</p><p>原因:操作区默认 opacity:0、max-height:0,仅在 hover / focus-within 展开;展开的高度限制也裁切了按钮底边。</p><p>修复:预览与下载始终显示,操作区按内容自然撑高,移除悬停折叠及移动端残留高度限制。</p><p>验证:1920、1440、750 宽度下操作区可见、按钮未裁切、悬停前后卡片高度稳定;下载菜单可打开,明暗主题均已检查。无业务数据写入。pnpm run build 与 git diff --check 通过。</p><a href="results.json">检查结果 JSON</a></section><section><h2>1920 · 默认显示全部操作</h2><a href="1920.png"><img src="1920.png" alt="1920 宽度模板卡片操作始终可见"></a></section><section><h2>1440 · 窄桌面</h2><a href="1440.png"><img src="1440.png" alt="1440 宽度模板卡片操作"></a></section><section><h2>暗色主题</h2><a href="dark.png"><img src="dark.png" alt="暗色主题操作区"></a></section></html> | |||
| @@ -0,0 +1,26 @@ | |||
| [ | |||
| { | |||
| "width": 1920, | |||
| "normalVisible": true, | |||
| "buttonsNotClipped": true, | |||
| "hoverHeightStable": true | |||
| }, | |||
| { | |||
| "width": 1440, | |||
| "normalVisible": true, | |||
| "buttonsNotClipped": true, | |||
| "hoverHeightStable": true | |||
| }, | |||
| { | |||
| "width": 750, | |||
| "normalVisible": true, | |||
| "buttonsNotClipped": true, | |||
| "hoverHeightStable": true | |||
| }, | |||
| { | |||
| "downloadMenuOpens": true | |||
| }, | |||
| { | |||
| "darkActionsVisible": true | |||
| } | |||
| ] | |||
| @@ -0,0 +1,70 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test'; | |||
| import {login,call} from './role-permissions-live.mjs'; | |||
| const report=path.resolve('reports/animation-capabilities-20260912');fs.mkdirSync(report,{recursive:true}); | |||
| const fixturePath=path.join(report,'fixture.json'); | |||
| const fixture=fs.existsSync(fixturePath)?JSON.parse(fs.readFileSync(fixturePath,'utf8')):{}; | |||
| const save=()=>fs.writeFileSync(fixturePath,JSON.stringify(fixture,null,2)); | |||
| const session=await login('root'); | |||
| if(!fixture.modelId){ | |||
| const source=await call('tran/v1/content/projects/156',session.accessToken);assert.equal(source.status,200); | |||
| const copy=await call('tran/v1/content/projects/156/copy',session.accessToken,{name:'动画能力验收-组合动作与流向-20260912',version:source.data.project.version}); | |||
| assert.equal(copy.status,200,copy.message);fixture.modelId=copy.data.project.id;save(); | |||
| } | |||
| 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(),checks=[],errors=[]; | |||
| page.on('pageerror',error=>errors.push(error.message)); | |||
| const shot=async name=>page.screenshot({path:path.join(report,name+'.png')}); | |||
| try { | |||
| await page.goto(`http://127.0.0.1:6180/content/models/${fixture.modelId}/edit`); | |||
| await expect(page.locator('iframe')).toBeVisible({timeout:60000}); | |||
| const frame=await page.locator('iframe').contentFrame(); | |||
| await expect(frame.locator('#scene-count')).toBeVisible({timeout:60000}); | |||
| const editorFrame=page.frames().find(f=>f.url().includes('legacy-model-editor')); | |||
| await editorFrame.waitForFunction(()=>window.editorApp?.apiSession?.project && window.editorApp?.modelTargets?.size>0,{timeout:60000}); | |||
| const detail=await editorFrame.evaluate(async()=>{ | |||
| const app=window.editorApp;app.timeline.pause(); | |||
| const body=[...app.modelTargets.entries()].find(([,object])=>object.name==='Cesium_Milk_Truck_1'); | |||
| const wheel=[...app.modelTargets.entries()].find(([,object])=>object.name==='Wheels'); | |||
| if(!body||!wheel)throw new Error('Fixture body/wheel not found'); | |||
| const keys=(a,b)=>[{id:crypto.randomUUID(),time:0,value:a},{id:crypto.randomUUID(),time:4,value:b}]; | |||
| const track=(id,name,targetId,property,a,b,extra={})=>({id,name,targetId,property,keyframes:keys(a,b),...extra}); | |||
| const timeline={version:1,duration:4,currentTime:0,loop:false,zoom:1,clips:[{id:'work',name:'旋出与清洁演示',start:0,end:4,finish:'hold'},{id:'reset',name:'阀门复位演示',start:0,end:4,finish:'reset'}],tracks:[ | |||
| track('rotate','部件旋出',wheel[0],'rotation.x',0,Math.PI,{pivot:[0,.1,0]}), | |||
| track('move','旋出位移',wheel[0],'position',[0,0,0],[0,.3,0]), | |||
| track('state','污渍到清洁',body[0],'material.state',0,1,{states:[{name:'污渍',color:'#795548',roughness:.95},{name:'清洁',color:'#dce8ed',roughness:.2}]}), | |||
| track('flow','回路流向',body[0],'flow',1,-1,{flow:{points:[[0,0,.5],[1,0,.5],[1,1,.5]],color:'#16b9f3',radius:.045,count:10,speed:.6,gateTrackId:'state',openValue:1}}), | |||
| ]}; | |||
| app.timeline.loadJSON(timeline,{pushHistory:true});app.timeline.seek(4); | |||
| const saved=await app.saveProject();if(!saved)throw new Error('Model save failed'); | |||
| return {bodyId:body[0],wheelId:wheel[0],timeline:saved.content?.timeline || timeline,pose:wheel[1].position.toArray(),version:saved.version}; | |||
| }); | |||
| fixture.detail=detail;save(); | |||
| checks.push({name:'真实工程保存组合旋转、位移、材质状态和流向轨道',pass:true}); | |||
| await editorFrame.evaluate(()=>{window.editorApp.setBottomMode?.('timeline');window.editorApp.timeline.selectedTrackId='flow';window.editorApp.timeline.render();}); | |||
| await frame.locator('[data-action="animation-settings"]').click(); | |||
| await expect(frame.locator('.animation-settings')).toBeVisible();await shot('01-animation-settings'); | |||
| await frame.locator('.animation-settings [data-close]').first().click(); | |||
| await shot('02-editor-end-pose'); | |||
| const latest=await call(`tran/v1/content/projects/${fixture.modelId}`,session.accessToken); | |||
| assert.equal(latest.data.currentVersion.content.timeline.clips.length,2); | |||
| fixture.savedDocument=latest.data.currentVersion.content;save(); | |||
| await page.reload();await expect(page.locator('iframe')).toBeVisible(); | |||
| const reopened=page.frames().find(f=>f.url().includes('legacy-model-editor')); | |||
| await reopened.waitForFunction(()=>window.editorApp?.apiSession?.project && window.editorApp?.modelTargets?.size>0,{timeout:60000}); | |||
| const replay=await reopened.evaluate(()=>{const app=window.editorApp;app.timeline.seek(0);const a=app.modelTargets.get(app.timeline.getTrack('move').targetId).position.toArray();app.timeline.seek(4);const b=app.modelTargets.get(app.timeline.getTrack('move').targetId).position.toArray();return {a,b,clips:app.timeline.clips,flows:app.animationRuntime.flows.length};}); | |||
| assert.notDeepEqual(replay.a,replay.b);assert.equal(replay.flows,1);assert.equal(replay.clips.length,2); | |||
| checks.push({name:'刷新重开后动作片段、复位基准和流向配置恢复',pass:true,detail:replay}); | |||
| await shot('03-model-reopened'); | |||
| const published=await reopened.evaluate(async()=>{const result=await window.editorApp.publishToSceneLibrary();return Boolean(result);}); | |||
| const result=await call(`tran/v1/content/projects/${fixture.modelId}`,session.accessToken); | |||
| assert.equal(result.data.project.status,'PUBLISHED',JSON.stringify({published,status:result.data.project.status})); | |||
| fixture.modelVersionId=result.data.project.publishedVersionId;save(); | |||
| checks.push({name:'正式发布固定动画模型版本',pass:true,versionId:fixture.modelVersionId}); | |||
| await shot('04-model-published'); | |||
| } catch(error){checks.push({name:'模型动作端到端',pass:false,error:error.message});process.exitCode=1;console.error(error.message);await shot('failure').catch(()=>{});} | |||
| finally {fs.writeFileSync(path.join(report,'model-checks.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 assert from 'node:assert/strict'; | |||
| const root=path.resolve('reports/animation-capabilities-20260912'); | |||
| const sections=[['model','模型编辑与发布'],['guide','指导书在线使用与交付'],['offline','离线播放'],['teaching','正式教学视口'],['training-editor','训练编辑器绑定']]; | |||
| const escape=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |||
| const results=sections.map(([key,name])=>({key,name,...JSON.parse(fs.readFileSync(path.join(root,key+'-checks.json'),'utf8'))})); | |||
| for(const result of results){assert.ok(result.checks.every(c=>c.pass),result.key);assert.equal(result.errors.length,0,result.key);} | |||
| const f=JSON.parse(fs.readFileSync(path.join(root,'fixture.json'),'utf8')); | |||
| const shots=[['01-animation-settings','动作与轨道设置:范围、结束处理、路径和阀门联动'],['02-editor-end-pose','模型编辑器中的组合动作'],['03-model-reopened','刷新重开后恢复动画配置'],['05-guide-model-animation','正式指导书模型视口'],['07-offline-before','离线包加载模型'],['08-offline-animation','离线片段播放及暂停'],['09-teaching-animation','教学步骤:保持末帧、清洁状态和流向开通'],['10-teaching-reset','教学步骤:播放结束复位并关闭流向'],['11-training-action-library','训练编辑器读取正式动作目录'],['12-training-action-bound','通过页面绑定并保存动作']]; | |||
| const total=results.reduce((n,r)=>n+r.checks.length,0); | |||
| const html=`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>动画能力实施与测试报告 · 2026-09-12</title> | |||
| <style>body{margin:0;background:#edf3f3;color:#173c40;font:15px/1.8 "Microsoft YaHei",sans-serif}main{max-width:1200px;margin:32px auto;padding:0 24px 60px}header,section{background:white;border:1px solid #cddede;border-radius:12px;padding:26px;margin:20px 0}h1{font-size:28px;margin:0}h2{font-size:21px;margin:0 0 14px}h3{font-size:17px;margin:12px 0}p{margin:10px 0}a{color:#087d6c}table{border-collapse:collapse;width:100%}th,td{padding:12px;text-align:left;border:1px solid #d8e6e5;vertical-align:top}th{background:#eff6f5}.pass{color:#087954;font-weight:bold}.meta{color:#567677}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.grid figure{margin:0;border:1px solid #cddede;border-radius:8px;overflow:hidden}.grid img{display:block;width:100%}.grid figcaption{padding:12px}.badge{display:inline-block;background:#e4f6ed;border-radius:6px;padding:5px 12px;color:#087954}code{background:#f1f5f5;padding:2px 5px;overflow-wrap:anywhere}@media(max-width:800px){.grid{grid-template-columns:1fr}main{padding:0 12px}section{padding:18px}}</style> | |||
| <main><header><p class="meta">数字车间 · 功能实施 / 真实接口 / 浏览器端到端</p><h1>模型动画能力实施与测试报告</h1><p>2026-09-12 · <span class="badge">${total} 项端到端检查通过</span></p><p>按已确认范围补齐命名动作、组合运动、材质状态、流向讲解和跨模块复用。使用独立的牛奶卡车测试工程验证,不修改原始模型和既有业务任务。</p></header> | |||
| <section><h2>已实施能力</h2><table><tr><th>能力</th><th>实现与使用</th></tr> | |||
| <tr><td>动作片段与机械组合运动</td><td>时间轴设置命名起止范围;旋转中心使用部件局部坐标;位移与 XYZ 旋转叠加,关键帧错开形成顺序动作;支持保持末帧和回到起点。蓝图按名称播放片段。</td></tr> | |||
| <tr><td>状态与材质</td><td>新增缩放、显隐、不透明度、颜色、材质状态轨道;材质副本隔离,不污染其他实例。录制与保存保留中性基准,旋转中心偏移不会被重复写入位移关键帧。</td></tr> | |||
| <tr><td>回路流向</td><td>配置路径点、颜色、速度、流点数量、正向/反向/停止;可关联标量轨道控制开通。用于工作原理讲解。</td></tr> | |||
| <tr><td>跨模块复用</td><td>训练编辑器从正式目录选动作,保存步骤绑定;教学任务固定模型/场景/训练版本;指导书在线模型与离线 ZIP 使用同一动画求值规则。原生 GLB 动画仍可选择。</td></tr></table></section> | |||
| <section><h2>实测结果</h2>${results.map(r=>`<h3>${escape(r.name)}</h3><table><tr><th>检查</th><th>结果</th></tr>${r.checks.map(c=>`<tr><td>${escape(c.name)}</td><td class="pass">通过</td></tr>`).join('')}</table><p class="meta"><a href="${r.key}-checks.json">查看原始检查记录</a> · 浏览器脚本异常 ${r.errors.length} 条</p>`).join('')} | |||
| <p>补充验证:前端类型检查及生产构建通过;生成的模型编辑器 public/dist 文件哈希一致。后端 18 条校验、快照与考试提示隔离用例通过;原型 5 项合同测试、Web 4 项场景/缓存/落地测试通过。构建仅有现有的大分块提示。</p> | |||
| <p class="meta">测试采用真实登录、GLB、保存/发布/下载接口。部分配置和视口内部状态由 Playwright 调用页面模块核对;教学原生 GLB 切换为浏览器运行时兼容检查。离线用例阻断业务接口及公网请求,外部请求数为 0。</p></section> | |||
| <section><h2>可继续使用的测试数据</h2><table><tr><th>数据</th><th>标识</th><th>入口</th></tr> | |||
| <tr><td>组合动作模型</td><td>${f.modelId} · 固定模型版本 ${f.modelVersionId}</td><td><a href="http://127.0.0.1:6180/content/models/${f.modelId}/edit">打开模型编辑器</a></td></tr> | |||
| <tr><td>测试场景</td><td>${f.animationSceneId} · 版本 ${f.animationSceneVersionId}</td><td><a href="http://127.0.0.1:6180/content/scenes/${f.animationSceneId}/edit">打开场景</a></td></tr> | |||
| <tr><td>两步骤测试训练</td><td>${f.animationTrainingId}</td><td><a href="http://127.0.0.1:6180/content/training-projects/${f.animationTrainingId}/legacy">打开训练编排</a></td></tr> | |||
| <tr><td>固定快照教学任务</td><td>${f.animationAssignmentId} · 训练版本 482</td><td><a href="http://127.0.0.1:6180/teaching/virtual-training/preview/${f.animationAssignmentId}">打开教学预览</a></td></tr> | |||
| <tr><td>动画指导书</td><td>${f.guideId}</td><td><a href="http://127.0.0.1:6180/content/ofd/${f.guideId}/studio?view=editor">打开指导书</a> · <a href="animation-guide.zip">下载实测离线包</a></td></tr></table> | |||
| <p>训练当前保留实测保存的草稿;已有任务仍使用旧发布快照,这正是版本隔离测试验证的行为。试验性教学任务未产生学员成绩。</p></section> | |||
| <section><h2>使用顺序与边界</h2><ol><li>模型编辑器时间轴添加轨道,选择部件、属性及关键帧;进入「动作与轨道设置」配置片段、枢轴、材质状态或流向。</li><li>保存并发布模型,在场景选择固定版本;训练的「步骤动画」选择正式动作并保存。</li><li>发布训练、下发新任务后在教学端使用;指导书关联模型片段后可在线播放或导出离线 ZIP。</li></ol> | |||
| <p>本次没有数据库表改动。未加入新的密钥文件、独立服务或认证方式。代码尚未提交 Git。</p> | |||
| <p>污渍/清洁通过颜色和粗糙度等 PBR 参数表达;贴图擦除、软管/密封圈弹性变形、真实流体压力计算、整片视频导出不在本次实施范围。表格中的具体装备动作仍需配置部件、路径和时序,本报告不代表已经制作完成全部动画素材。</p></section> | |||
| <section><h2>浏览器截图</h2><p class="meta">点击图片查看原图。</p><div class="grid">${shots.map(([file,title])=>`<figure><a href="${file}.png"><img loading="lazy" src="${file}.png" alt="${escape(title)}"></a><figcaption>${escape(title)}</figcaption></figure>`).join('')}</div></section></main></html>`; | |||
| fs.writeFileSync(path.join(root,'index.html'),html); | |||
| fs.writeFileSync(path.join(root,'summary.json'),JSON.stringify({date:'2026-09-12',passed:total,failed:0,backendTests:18,prototypeContractTests:5,webTests:4,build:'passed',ddlChanges:false},null,2)); | |||
| console.log(`Report generated: ${total} checks passed`); | |||
| @@ -0,0 +1,80 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test'; | |||
| import {login,call} from './role-permissions-live.mjs'; | |||
| const report=path.resolve('reports/animation-capabilities-20260912'),fixturePath=path.join(report,'fixture.json'); | |||
| const fixture=JSON.parse(fs.readFileSync(fixturePath,'utf8')),session=await login('root'); | |||
| const save=()=>fs.writeFileSync(fixturePath,JSON.stringify(fixture,null,2)); | |||
| 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(),checks=[],errors=[]; | |||
| page.on('pageerror',e=>errors.push(e.message)); | |||
| const shot=async name=>page.screenshot({path:path.join(report,name+'.png')}); | |||
| try { | |||
| await page.goto('http://127.0.0.1:6180/content/ofd');await expect(page.locator('.legacy-guide-page')).toBeVisible({timeout:60000}); | |||
| if(!fixture.guideId){ | |||
| fixture.guide=await page.evaluate(async({modelId,versionId,candidateId})=>{ | |||
| const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts'); | |||
| const {blankGuideState}=await import('/src/features/guide-legacy/formalDocument.ts'); | |||
| const {createMediaBlock}=await import('/src/features/guide-legacy/demo-src/ofd-media.js'); | |||
| const server=new FormalGuideStore(),state=blankGuideState(); | |||
| state.project.name='动画能力验收指导书-20260912';state.project.owner='功能验收';state.project.equipment='牛奶卡车动画测试'; | |||
| const record=candidateId?await server.load(candidateId):await server.createOFDProject({title:state.project.name,state}); | |||
| await server.loadCatalog(); | |||
| const key=`${modelId}@${versionId}`; | |||
| const step=record.state.chapters[0].steps[0];step.title='旋出、清洁和流向演示';step.summary='选择动作片段,观察车轮组合运动、污渍到清洁状态变化以及回路开通后的流向。'; | |||
| step.modelRef={assetId:key,modelId:key,nodeName:'Wheels'}; | |||
| step.animationRef={clipName:'旋出与清洁演示',start:0,end:4,loop:false};step.animation='旋出与清洁演示'; | |||
| step.mediaBlocks=[createMediaBlock({kind:'model',assetId:key,assetName:'组合动作演示',clipName:'旋出与清洁演示',start:0,end:4})]; | |||
| const saved=await server.saveOFDProjectState(record.id,record.state); | |||
| const resource=saved.state.resources.find(r=>r.id===key); | |||
| server.dispose();return {id:record.id,resource:{id:resource.id,tracks:resource.timeline?.tracks.length,clips:resource.timeline?.clips.length,overrides:Object.keys(resource.modelOverrides||{}).length}}; | |||
| },{modelId:fixture.modelId,versionId:fixture.modelVersionId,candidateId:fixture.guideCandidateId}); | |||
| fixture.guideId=fixture.guide.id;save(); | |||
| } | |||
| assert.equal(fixture.guide.resource.tracks,4);assert.equal(fixture.guide.resource.clips,2); | |||
| checks.push({name:'指导书通过正式接口固定引用模型版本并持久化动画元数据',pass:true,resource:fixture.guide.resource}); | |||
| await page.goto(`http://127.0.0.1:6180/content/ofd/${fixture.guideId}/studio?view=editor`); | |||
| await expect(page.locator('.ofd-view-editor')).toBeVisible({timeout:60000}); | |||
| await page.locator('[data-action="select-step"]').first().click(); | |||
| await page.locator('[data-action="canvas-block"][data-block-id*="media:"]').first().click(); | |||
| await page.locator('[data-action="media-open"]').first().click(); | |||
| await expect(page.locator('.ofd-media-panel canvas')).toBeVisible({timeout:60000}); | |||
| await shot('05-guide-model-animation'); | |||
| console.log('MEDIA_BUTTONS',await page.locator('.ofd-media-panel button').allTextContents()); | |||
| const outcome=await page.evaluate(async({guideId,modelId,versionId})=>{ | |||
| const {FormalGuideStore}=await import('/src/features/guide-legacy/formalStore.ts'); | |||
| const {createGuideViewport}=await import('/src/features/guide-legacy/demo-src/ofd-viewport.js'); | |||
| const server=new FormalGuideStore();await server.load(guideId); | |||
| const asset=server.record.state.resources.find(r=>r.id===`${modelId}@${versionId}`); | |||
| const viewport=createGuideViewport(); | |||
| const blob=(await server.assetStore.get(asset.assetKey)).blob; | |||
| await viewport.load(asset.id,blob,asset); | |||
| const clips=viewport.clips.map(c=>c.name),wheel=viewport.findModelNode('Wheels'); | |||
| viewport.playClip('旋出与清洁演示');viewport.pause(); | |||
| const initial=wheel.position.toArray(); | |||
| // Seek through the same mixer transport used by reader controls. | |||
| const {createAnimationRuntimeTools}=await import('/src/features/guide-legacy/demo-src/animation-runtime.js'); | |||
| const THREE=await import('/node_modules/.vite/deps/three.js'); | |||
| const tools=createAnimationRuntimeTools(THREE);tools.apply(viewport.editorTimeline,4); | |||
| const final=wheel.position.toArray(),state=viewport.editorTimeline.materials.values().next().value.clones[0].roughness; | |||
| viewport.dispose();server.dispose();return {clips,initial,final,state}; | |||
| },{guideId:fixture.guideId,modelId:fixture.modelId,versionId:fixture.modelVersionId}); | |||
| assert.ok(outcome.clips.includes('旋出与清洁演示'));assert.ok(outcome.clips.includes('阀门复位演示'));assert.notDeepEqual(outcome.initial,outcome.final);assert.equal(outcome.state,.2); | |||
| checks.push({name:'正式指导书视口加载命名动作并还原组合运动与材质状态',pass:true,detail:outcome}); | |||
| // Publish using the page's normal delivery pipeline. | |||
| await page.keyboard.press('Escape'); | |||
| const close=page.locator('[data-action="media-close"]');if(await close.count())await close.first().click(); | |||
| await page.locator('[data-action="save"]').click();await page.waitForTimeout(700); | |||
| await page.locator('[data-action="open-publish"]').click(); | |||
| const wait=page.waitForResponse(r=>r.url().endsWith('/publish')&&r.request().method()==='POST',{timeout:180000}); | |||
| await page.locator('[data-action="lifecycle-action"][data-lifecycle-action="publish"]').click(); | |||
| const published=await wait;assert.ok(published.ok(),(await published.text()).slice(0,300)); | |||
| await shot('06-guide-published'); | |||
| const response=await context.request.get(`http://127.0.0.1:6180/api/tran/v1/teaching/guides/${fixture.guideId}/delivery?format=OFFLINE_HTML`,{headers:{Authorization:`Bearer ${session.accessToken}`},timeout:120000}); | |||
| assert.ok(response.ok());fs.writeFileSync(path.join(report,'animation-guide.zip'),await response.body()); | |||
| checks.push({name:'正式发布并下载带动画的离线指导书',pass:true}); | |||
| }catch(error){checks.push({name:'指导书动画端到端',pass:false,error:error.message});process.exitCode=1;console.error(error.message);await shot('guide-failure').catch(()=>{});} | |||
| finally{fs.writeFileSync(path.join(report,'guide-checks.json'),JSON.stringify({checks,errors},null,2));await browser.close();} | |||
| @@ -0,0 +1,42 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import http from 'node:http'; | |||
| import {execFileSync} from 'node:child_process'; | |||
| import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test'; | |||
| const report=path.resolve('reports/animation-capabilities-20260912'),root=path.join(report,'offline'); | |||
| execFileSync('python',['-c',`import zipfile,pathlib | |||
| root=pathlib.Path(${JSON.stringify(root)}).resolve() | |||
| with zipfile.ZipFile(${JSON.stringify(path.join(report,'animation-guide.zip'))}) as archive: | |||
| for entry in archive.infolist(): | |||
| target=(root/entry.filename).resolve() | |||
| if not target.is_relative_to(root): raise ValueError('Invalid archive path') | |||
| archive.extract(entry,root) | |||
| `]); | |||
| const mime={'.html':'text/html','.js':'text/javascript','.wasm':'application/wasm','.glb':'model/gltf-binary','.css':'text/css','.png':'image/png','.jpg':'image/jpeg'}; | |||
| const server=http.createServer((req,res)=>{const file=path.resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname).replace(/\/$/,'/index.html'));if(!file.startsWith(root+path.sep)||!fs.existsSync(file)){res.writeHead(404).end();return;}res.setHeader('Content-Type',mime[path.extname(file)]||'application/octet-stream');fs.createReadStream(file).pipe(res);}); | |||
| await new Promise(resolve=>server.listen(18779,'127.0.0.1',resolve)); | |||
| const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); | |||
| const page=await browser.newPage({viewport:{width:1440,height:1000}}),checks=[],errors=[],external=[]; | |||
| page.on('pageerror',e=>errors.push(e.message)); | |||
| await page.route('**/*',route=>{const url=new URL(route.request().url());if(url.origin!=='http://127.0.0.1:18779'&&!['data:','blob:'].includes(url.protocol)){external.push(url.origin);return route.abort();}return route.continue();}); | |||
| try{ | |||
| await page.goto('http://127.0.0.1:18779/'); | |||
| await page.locator('[data-jump="1"]').first().click(); | |||
| await page.locator('[data-action="open-model"]').first().click(); | |||
| await expect(page.locator('[data-model-status]')).not.toContainText(/正在加载|失败|未加载/,{timeout:60000}); | |||
| await expect(page.locator('[data-model-stage] canvas')).toBeVisible({timeout:60000}); | |||
| await page.locator('[data-model-stage] canvas').scrollIntoViewIfNeeded(); | |||
| const canvas=page.locator('[data-model-stage] canvas'); | |||
| const before=await canvas.screenshot();await page.screenshot({path:path.join(report,'07-offline-before.png')}); | |||
| await page.locator('[data-model-action="play-animation"]').click(); | |||
| await expect(page.locator('[data-model-status]')).toContainText('旋出与清洁演示'); | |||
| await page.waitForTimeout(1800);const after=await canvas.screenshot(); | |||
| assert.notDeepEqual(before,after,'Offline canvas must change while the authored action plays'); | |||
| await page.locator('[data-model-action="pause-animation"]').click(); | |||
| await page.screenshot({path:path.join(report,'08-offline-animation.png')}); | |||
| checks.push({name:'下载的离线包可播放命名组合动作,画布发生变化并可暂停',pass:true}); | |||
| assert.equal(external.length,0);assert.equal(errors.length,0,errors.join('\n')); | |||
| checks.push({name:'断开业务服务与公网后仍可加载模型、引擎和动画',pass:true}); | |||
| }catch(error){checks.push({name:'离线动画端到端',pass:false,error:error.message});process.exitCode=1;console.error(error.message);await page.screenshot({path:path.join(report,'offline-failure.png')}).catch(()=>{});} | |||
| finally{fs.writeFileSync(path.join(report,'offline-checks.json'),JSON.stringify({checks,errors,external},null,2));await browser.close();await new Promise(resolve=>server.close(resolve));} | |||
| @@ -0,0 +1,114 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test'; | |||
| import {login,call} from './role-permissions-live.mjs'; | |||
| const report=path.resolve('reports/animation-capabilities-20260912'),file=path.join(report,'fixture.json'); | |||
| const fixture=JSON.parse(fs.readFileSync(file,'utf8')),session=await login('root'); | |||
| const save=()=>fs.writeFileSync(file,JSON.stringify(fixture,null,2)); | |||
| const api=async(url,body,method)=>{const r=await call('tran/v1/'+url,session.accessToken,body,method);assert.equal(r.status,200,`${url}: ${r.message}`);return r.data;}; | |||
| 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(),checks=[],errors=[];page.on('pageerror',e=>errors.push(e.message)); | |||
| try { | |||
| await page.goto('http://127.0.0.1:6180/content/scenes'); | |||
| if(!fixture.animationSceneId){ | |||
| const content=await page.evaluate(async f=>{ | |||
| const {createEmptySceneDocument,createObjectDocument}=await import('/src/features/editors/scene/presets.ts'); | |||
| const scene=createEmptySceneDocument(),object=createObjectDocument('box'); | |||
| scene.name='动画能力验收场景-20260912'; | |||
| Object.assign(object,{id:'animation-truck',type:'model',name:'组合动作牛奶卡车',targetProjectId:f.modelId,targetVersionId:f.modelVersionId,assetCode:f.savedDocument.modelResource.assetCode,assetId:f.savedDocument.modelId}); | |||
| object.semantic.modelId=f.savedDocument.modelId;object.semantic.deviceId='ANIM-TRUCK';object.semantic.interactionId='animation-truck';object.semantic.operable=true; | |||
| scene.objects=[object];scene.rendering.cameraPosition=[6,4,7];scene.rendering.cameraTarget=[0,1,0]; | |||
| return scene; | |||
| },fixture); | |||
| const created=await api('content/projects',{type:'SCENE',name:content.name,categoryCode:'WORKSHOP',content,dependencies:[{targetProjectId:fixture.modelId,targetVersionId:fixture.modelVersionId,relationType:'SCENE_MODEL',required:true}],assets:[]}); | |||
| fixture.animationSceneId=created.project.id;save(); | |||
| } | |||
| let scene=await api(`content/projects/${fixture.animationSceneId}`); | |||
| if(scene.project.status!=='PUBLISHED'&&!scene.currentVersion.content.objects[0].semantic.deviceId){ | |||
| scene.currentVersion.content.objects[0].semantic.deviceId='ANIM-TRUCK'; | |||
| scene=await api(`content/projects/${fixture.animationSceneId}`,{type:'SCENE',name:scene.project.name,categoryCode:'WORKSHOP',content:scene.currentVersion.content,version:scene.project.version,dependencies:scene.dependencies,assets:[]},'PUT'); | |||
| } | |||
| if(scene.project.status!=='PUBLISHED')await api(`content/projects/${fixture.animationSceneId}/publish`,{version:scene.project.version}); | |||
| const publishedScene=await api(`content/projects/${fixture.animationSceneId}`);fixture.animationSceneVersionId=publishedScene.project.publishedVersionId;save(); | |||
| if(!fixture.animationTrainingId){ | |||
| const content=await page.evaluate(async({f,scene})=>{ | |||
| const {createTrainingProject}=await import('/src/features/guide-legacy/demo-src/training-project-io.js'); | |||
| const doc=createTrainingProject();doc.name='动画能力验收训练-20260912';doc.task.title=doc.name; | |||
| const step=doc.steps[0];step.id='animation-step';step.code='animation-step';step.title='旋出、清洁与回路流向';step.targetId='animation-truck';step.targetName='组合动作牛奶卡车';step.duration=4; | |||
| step.animationBinding={assetId:`animation-${f.modelId}@${f.modelVersionId}-work`,modelAssetId:`${f.modelId}@${f.modelVersionId}`,clipName:'旋出与清洁演示',sourceStart:0,finish:'hold',duration:4}; | |||
| doc.steps=[step,{...structuredClone(step),id:'reset-step',code:'reset-step',title:'播放后回到起点',animationBinding:{...step.animationBinding,clipName:'阀门复位演示',finish:'reset'}}]; | |||
| doc.steps.forEach(step=>{step.stepCode=step.code;step.actionCode='CHECK-'+step.code;}); | |||
| doc.scenario.sceneResource={id:`${f.animationSceneId}@${f.animationSceneVersionId}`,projectId:f.animationSceneId,versionId:f.animationSceneVersionId,name:scene.name,snapshot:scene}; | |||
| doc.scenario.visibleParts=['animation-truck'];doc.scenario.operableParts=['animation-truck']; | |||
| doc.resources.models=[{id:`${f.modelId}@${f.modelVersionId}`,modelId:f.savedDocument.modelId,targetProjectId:f.modelId,targetVersionId:f.modelVersionId,timeline:f.savedDocument.timeline}]; | |||
| return doc; | |||
| },{f:fixture,scene:publishedScene.currentVersion.content}); | |||
| const created=await api('content/projects',{type:'TRAINING',name:content.name,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content,dependencies:[{targetProjectId:fixture.animationSceneId,targetVersionId:fixture.animationSceneVersionId,relationType:'TRAINING_SCENE',required:true}],assets:[]}); | |||
| fixture.animationTrainingId=created.project.id;save(); | |||
| } | |||
| let training=await api(`content/projects/${fixture.animationTrainingId}`); | |||
| if(training.project.status!=='PUBLISHED'&&training.currentVersion.content.steps[0].actionCode==='CHECK'){ | |||
| training.currentVersion.content.steps.forEach(step=>{step.stepCode=step.code;step.actionCode='CHECK-'+step.code;}); | |||
| training=await api(`content/projects/${fixture.animationTrainingId}`,{type:'TRAINING',name:training.project.name,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content:training.currentVersion.content,version:training.project.version,dependencies:training.dependencies,assets:[]},'PUT'); | |||
| } | |||
| if(training.project.status!=='PUBLISHED')await api(`content/projects/${fixture.animationTrainingId}/publish`,{version:training.project.version}); | |||
| const published=await api(`content/projects/${fixture.animationTrainingId}`);fixture.animationTrainingVersionId=published.project.publishedVersionId;save(); | |||
| if(!fixture.animationAssignmentId){ | |||
| const a=await api('teaching/assignments',{code:'ANIM-CAP-'+Date.now(),name:'动画能力验收任务-20260912',description:'开发环境动画播放验证',channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'DEMO',audienceType:'COMMON',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:false,contentProjectId:fixture.animationTrainingId,contentVersionId:fixture.animationTrainingVersionId,members:[]}); | |||
| fixture.animationAssignmentId=a.id;save(); | |||
| } | |||
| const assignment=await api(`teaching/assignments/${fixture.animationAssignmentId}`); | |||
| fixture.animationAssignmentContentVersionId=assignment.contentVersionId;save(); | |||
| const object=assignment.definitionSnapshot.teachingScene.objects.find(o=>o.targetProjectId===fixture.modelId); | |||
| assert.equal(object.modelTimeline.clips.length,2);assert.equal(object.modelTimeline.tracks.length,4);assert.ok(Object.keys(object.modelOverrides).length>0); | |||
| assert.equal(object.targetVersionId,fixture.modelVersionId); | |||
| checks.push({name:'真实教学任务固定模型版本、动作、材质、枢轴和流向配置',pass:true,modelVersionId:object.targetVersionId,assignmentId:assignment.id}); | |||
| await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/preview/${assignment.id}`); | |||
| await expect(page.locator('.teaching-three-scene canvas')).toBeVisible({timeout:60000}); | |||
| await page.getByRole('button',{name:'适应',exact:true}).click(); | |||
| await page.waitForTimeout(6500); | |||
| // Inspect the real component's runtime, not a second test renderer. | |||
| const inspect=()=>page.evaluate(()=>{ | |||
| const host=document.querySelector('.teaching-three-scene');let instance=host?.__vueParentComponent; | |||
| while(instance&&!instance.type.__name?.includes('TeachingRunScene'))instance=instance.parent; | |||
| const state=instance?.devtoolsRawSetupState; | |||
| if(!state)throw new Error('Teaching scene setup unavailable'); | |||
| const entry=state.timelineRuntimes[0],runtime=entry?.runtime; | |||
| const wheel=runtime?.bindings.find(b=>b.track.id==='move')?.object; | |||
| const material=runtime?.materials.values().next().value; | |||
| const mesh=runtime?.materials.keys().next().value; | |||
| return {status:state.sceneStatus?.value || state.sceneStatus,time:runtime?.time,bindings:runtime?.bindings.length,unresolved:runtime?.unresolved, | |||
| wheel:wheel?.position.toArray(),base:wheel&&runtime.bases.get(wheel).position,roughness:material?.clones[0].roughness, | |||
| actualRoughness:mesh?.material.roughness,flowVisible:runtime?.flows[0]?.dots.some(dot=>dot.visible),step:state.props.currentStepCode}; | |||
| }); | |||
| const state=await inspect();assert.doesNotMatch(String(state.status),/失败/); | |||
| assert.equal(state.bindings,4);assert.equal(state.unresolved,0);assert.equal(state.time,4);assert.equal(state.roughness,.2);assert.equal(state.actualRoughness,.2);assert.equal(state.flowVisible,true);assert.notDeepEqual(state.wheel,state.base); | |||
| await page.screenshot({path:path.join(report,'09-teaching-animation.png')}); | |||
| checks.push({name:'正式教学预览实际播放指定片段并保持末帧,四条轨道全部绑定',pass:true,detail:state}); | |||
| await page.getByRole('button',{name:'引导完成',exact:true}).click(); | |||
| await page.getByRole('button',{name:'下一步',exact:true}).click(); | |||
| await page.waitForTimeout(6500); | |||
| const reset=await inspect();assert.equal(reset.step,'reset-step');assert.equal(reset.time,0);assert.equal(reset.roughness,.95);assert.equal(reset.flowVisible,false);assert.deepEqual(reset.wheel,reset.base); | |||
| await page.screenshot({path:path.join(report,'10-teaching-reset.png')}); | |||
| checks.push({name:'切换步骤后播放复位片段,结束恢复起点并关闭流向',pass:true,detail:reset}); | |||
| const native=await page.evaluate(async()=>{ | |||
| let instance=document.querySelector('.teaching-three-scene').__vueParentComponent; | |||
| while(!instance.type.__name?.includes('TeachingRunScene'))instance=instance.parent; | |||
| const s=instance.devtoolsRawSetupState,entry=s.nativePlayers[0]; | |||
| if(!entry?.clips.length)throw new Error('Fixture native GLB clip missing'); | |||
| const clip=entry.clips[0],step=s.definitionSteps().find(step=>s.stepCodeOf(step)===s.props.currentStepCode); | |||
| const binding=step.animationBinding,previous=binding.clipName; | |||
| binding.clipName=clip.name;s.syncStepTimelines(); | |||
| await new Promise(resolve=>setTimeout(resolve,900)); | |||
| const time=entry.mixer.clipAction(clip).time; | |||
| binding.clipName=previous;s.syncStepTimelines(); | |||
| return {name:clip.name,time}; | |||
| }); | |||
| assert.ok(native.time>0);checks.push({name:'同一教学视口原生 GLB 动画与自编片段切换兼容(浏览器运行时检查)',pass:true,detail:native}); | |||
| fs.writeFileSync(path.join(report,'teaching-inspection.json'),JSON.stringify(state,null,2)); | |||
| assert.equal(errors.length,0,errors.join('\n')); | |||
| }catch(error){checks.push({name:'教学动画端到端',pass:false,error:error.message});process.exitCode=1;console.error(error.message);await page.screenshot({path:path.join(report,'teaching-failure.png')}).catch(()=>{});} | |||
| finally{fs.writeFileSync(path.join(report,'teaching-checks.json'),JSON.stringify({checks,errors},null,2));await browser.close();} | |||
| @@ -0,0 +1,37 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test'; | |||
| import {login,call} from './role-permissions-live.mjs'; | |||
| const report=path.resolve('reports/animation-capabilities-20260912'),f=JSON.parse(fs.readFileSync(path.join(report,'fixture.json'),'utf8')); | |||
| 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)); | |||
| try{ | |||
| await page.goto(`http://127.0.0.1:6180/content/training-projects/${f.animationTrainingId}/legacy`); | |||
| const open=page.locator('[data-action="open-content-resources"][data-resource-scope="animation"]'); | |||
| await expect(open).toBeVisible({timeout:60000});await open.click(); | |||
| const key=`animation-content-model-${f.modelId}-${f.modelVersionId}-work`; | |||
| const button=page.locator(`[data-action="bind-content-resource"][data-resource-id="${key}"]`); | |||
| await expect(button).toBeVisible({timeout:60000}); | |||
| await expect(page.locator(`[data-resource-id="animation-content-model-${f.modelId}-${f.modelVersionId}-reset"]`)).toBeVisible(); | |||
| await button.scrollIntoViewIfNeeded();await page.screenshot({path:path.join(report,'11-training-action-library.png')}); | |||
| if(await button.innerText()==='取消绑定')await button.click(); | |||
| await button.click(); | |||
| await page.locator('[data-action="close-dialog"]').last().click(); | |||
| await expect(page.locator('.te-step-animation-binding')).toContainText('旋出与清洁演示'); | |||
| const save=page.locator('[data-action="save"]');console.log('SAVE_COUNT',await save.count()); | |||
| const wait=page.waitForResponse(r=>r.request().method()==='PUT'&&r.url().includes(`/content/projects/${f.animationTrainingId}`),{timeout:60000}); | |||
| await save.click();const response=await wait;assert.ok(response.ok(),(await response.text()).slice(0,200)); | |||
| const result=await call(`tran/v1/content/projects/${f.animationTrainingId}`,session.accessToken); | |||
| const binding=result.data.currentVersion.content.steps[0].animationBinding; | |||
| assert.equal(binding.clipName,'旋出与清洁演示');assert.equal(binding.modelAssetId,`content-model-${f.modelId}-${f.modelVersionId}`); | |||
| checks.push({name:'训练编辑器从正式模型库选择命名动作并通过真实保存接口持久化',pass:true,binding}); | |||
| await page.screenshot({path:path.join(report,'12-training-action-bound.png')}); | |||
| const assignment=await call(`tran/v1/teaching/assignments/${f.animationAssignmentId}`,session.accessToken); | |||
| assert.equal(assignment.data.contentVersionId,f.animationAssignmentContentVersionId || '482'); | |||
| checks.push({name:'训练编辑草稿不会覆盖已创建任务的固定发布快照',pass:true}); | |||
| assert.equal(errors.length,0,errors.join('\n')); | |||
| }catch(error){checks.push({name:'训练动画绑定端到端',pass:false,error:error.message});process.exitCode=1;console.error(error.message);await page.screenshot({path:path.join(report,'training-editor-failure.png')}).catch(()=>{});} | |||
| finally{fs.writeFileSync(path.join(report,'training-editor-checks.json'),JSON.stringify({checks,errors},null,2));await browser.close();} | |||