Pārlūkot izejas kodu

test: 补充动画能力闭环验证与截图报告

main
leiyun pirms 1 nedēļas
vecāks
revīzija
8282db4dfa
35 mainītis faili ar 3890 papildinājumiem un 0 dzēšanām
  1. +3
    -0
      reports/animation-capabilities-20260912/.gitignore
  2. Binārs
     
  3. Binārs
     
  4. Binārs
     
  5. Binārs
     
  6. Binārs
     
  7. Binārs
     
  8. Binārs
     
  9. Binārs
     
  10. Binārs
     
  11. Binārs
     
  12. Binārs
     
  13. Binārs
     
  14. Binārs
     
  15. +3236
    -0
      reports/animation-capabilities-20260912/fixture.json
  16. +41
    -0
      reports/animation-capabilities-20260912/guide-checks.json
  17. +22
    -0
      reports/animation-capabilities-20260912/index.html
  18. +47
    -0
      reports/animation-capabilities-20260912/model-checks.json
  19. +14
    -0
      reports/animation-capabilities-20260912/offline-checks.json
  20. Binārs
     
  21. +10
    -0
      reports/animation-capabilities-20260912/summary.json
  22. +67
    -0
      reports/animation-capabilities-20260912/teaching-checks.json
  23. +20
    -0
      reports/animation-capabilities-20260912/teaching-inspection.json
  24. +24
    -0
      reports/animation-capabilities-20260912/training-editor-checks.json
  25. Binārs
     
  26. Binārs
     
  27. Binārs
     
  28. +1
    -0
      reports/guide-template-actions-20260912/index.html
  29. +26
    -0
      reports/guide-template-actions-20260912/results.json
  30. +70
    -0
      tools/animation-capabilities-live.mjs
  31. +36
    -0
      tools/animation-capabilities-report.mjs
  32. +80
    -0
      tools/animation-guide-live.mjs
  33. +42
    -0
      tools/animation-offline-live.mjs
  34. +114
    -0
      tools/animation-teaching-live.mjs
  35. +37
    -0
      tools/animation-training-editor-live.mjs

+ 3
- 0
reports/animation-capabilities-20260912/.gitignore Parādīt failu

@@ -0,0 +1,3 @@
offline/
*-failure.png
failure.png

Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


+ 3236
- 0
reports/animation-capabilities-20260912/fixture.json
Failā izmaiņas netiks attēlotas, jo tās ir par lielu
Parādīt failu


+ 41
- 0
reports/animation-capabilities-20260912/guide-checks.json Parādīt failu

@@ -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": []
}

+ 22
- 0
reports/animation-capabilities-20260912/index.html Parādīt failu

@@ -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>

+ 47
- 0
reports/animation-capabilities-20260912/model-checks.json Parādīt failu

@@ -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": []
}

+ 14
- 0
reports/animation-capabilities-20260912/offline-checks.json Parādīt failu

@@ -0,0 +1,14 @@
{
"checks": [
{
"name": "下载的离线包可播放命名组合动作,画布发生变化并可暂停",
"pass": true
},
{
"name": "断开业务服务与公网后仍可加载模型、引擎和动画",
"pass": true
}
],
"errors": [],
"external": []
}

Binārs
Parādīt failu


+ 10
- 0
reports/animation-capabilities-20260912/summary.json Parādīt failu

@@ -0,0 +1,10 @@
{
"date": "2026-09-12",
"passed": 14,
"failed": 0,
"backendTests": 18,
"prototypeContractTests": 5,
"webTests": 4,
"build": "passed",
"ddlChanges": false
}

+ 67
- 0
reports/animation-capabilities-20260912/teaching-checks.json Parādīt failu

@@ -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": []
}

+ 20
- 0
reports/animation-capabilities-20260912/teaching-inspection.json Parādīt failu

@@ -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"
}

+ 24
- 0
reports/animation-capabilities-20260912/training-editor-checks.json Parādīt failu

@@ -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": []
}

Binārs
Parādīt failu


Binārs
Parādīt failu


Binārs
Parādīt failu


+ 1
- 0
reports/guide-template-actions-20260912/index.html Parādīt failu

@@ -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>

+ 26
- 0
reports/guide-template-actions-20260912/results.json Parādīt failu

@@ -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
}
]

+ 70
- 0
tools/animation-capabilities-live.mjs Parādīt failu

@@ -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();}

+ 36
- 0
tools/animation-capabilities-report.mjs Parādīt failu

@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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`);

+ 80
- 0
tools/animation-guide-live.mjs Parādīt failu

@@ -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();}

+ 42
- 0
tools/animation-offline-live.mjs Parādīt failu

@@ -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));}

+ 114
- 0
tools/animation-teaching-live.mjs Parādīt failu

@@ -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();}

+ 37
- 0
tools/animation-training-editor-live.mjs Parādīt failu

@@ -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();}

Notiek ielāde…
Atcelt
Saglabāt