Parcourir la source

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

main
leiyun il y a 1 semaine
Parent
révision
8282db4dfa
35 fichiers modifiés avec 3890 ajouts et 0 suppressions
  1. +3
    -0
      reports/animation-capabilities-20260912/.gitignore
  2. BIN
     
  3. BIN
     
  4. BIN
     
  5. BIN
     
  6. BIN
     
  7. BIN
     
  8. BIN
     
  9. BIN
     
  10. BIN
     
  11. BIN
     
  12. BIN
     
  13. BIN
     
  14. BIN
     
  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
     
  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
     
  26. BIN
     
  27. BIN
     
  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 Voir le fichier

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














+ 3236
- 0
reports/animation-capabilities-20260912/fixture.json
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 41
- 0
reports/animation-capabilities-20260912/guide-checks.json Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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


+ 10
- 0
reports/animation-capabilities-20260912/summary.json Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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




+ 1
- 0
reports/guide-template-actions-20260912/index.html Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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

Chargement…
Annuler
Enregistrer