1 · 资料
- 参考《文档/维修训练内容设计.pdf》(6 页)与已有场景制作成果。按“场景 → 任务步骤 → 学习 / 训练 / 评估”的资料结构检查实现。
- 本次使用已发布的狐狸场景和牛奶卡车场景验证训练编排,确认真实模型可见、步骤目标来自绑定场景、保存及发布使用服务端版本。
diff --git a/.gitignore b/.gitignore index a646dba..64cd3dc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .env.* !.env.example reports/**/build.log +reports/**/.last-run.json artifacts/html-report/ artifacts/test-results/ artifacts/trace-inspect-*/ diff --git a/reports/training-colors-20260906/evidence.json b/reports/training-colors-20260906/evidence.json new file mode 100644 index 0000000..1b15127 --- /dev/null +++ b/reports/training-colors-20260906/evidence.json @@ -0,0 +1,1368 @@ +{ + "title": "高级编排配色修复", + "startedAt": "2026-09-05T16:08:32.307Z", + "checks": [ + { + "name": "当前图节点、说明、图标及时间轴前景对比", + "status": "PASS", + "measured": [ + { + "selector": "tone-blue", + "text": "步", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "训练步骤", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "同步基础步骤", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-violet", + "text": "◇", + "foreground": "rgb(255, 255, 255)", + "background": [ + 103, + 80, + 188 + ], + "contrast": 6.09 + }, + { + "selector": "B", + "text": "条件分支", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "按规则选择路径", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-cyan", + "text": "⌛", + "foreground": "rgb(255, 255, 255)", + "background": [ + 9, + 123, + 148 + ], + "contrast": 4.92 + }, + { + "selector": "B", + "text": "外部事件等待", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "等待设备或交互事件", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-orange", + "text": "障", + "foreground": "rgb(255, 255, 255)", + "background": [ + 153, + 84, + 21 + ], + "contrast": 5.78 + }, + { + "selector": "B", + "text": "故障注入", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "写入模拟故障参数", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-orange", + "text": "清", + "foreground": "rgb(255, 255, 255)", + "background": [ + 153, + 84, + 21 + ], + "contrast": 5.78 + }, + { + "selector": "B", + "text": "故障清除", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "恢复标准装备状态", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-red", + "text": "安", + "foreground": "rgb(255, 255, 255)", + "background": [ + 181, + 62, + 73 + ], + "contrast": 5.6 + }, + { + "selector": "B", + "text": "安全判定", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "检查安全前置条件", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-green", + "text": "分", + "foreground": "rgb(255, 255, 255)", + "background": [ + 18, + 120, + 91 + ], + "contrast": 5.44 + }, + { + "selector": "B", + "text": "评分节点", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "记录过程与结果得分", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-blue", + "text": "镜", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "相机控制", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "切换或跟随三维镜头", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-cyan", + "text": "声", + "foreground": "rgb(255, 255, 255)", + "background": [ + 9, + 123, + 148 + ], + "contrast": 4.92 + }, + { + "selector": "B", + "text": "音频播放", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "播放离线讲解音频", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "I", + "text": "▶", + "foreground": "rgb(255, 255, 255)", + "background": [ + 18, + 120, + 91 + ], + "contrast": 5.44 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "训练开始", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "流程唯一入口", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "步", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "识别狐狸巡检目标", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "同步步骤 · hydraulic-fault-step-01", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "步", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "完成狐狸状态检查", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "同步步骤 · hydraulic-fault-step-02", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "■", + "foreground": "rgb(255, 255, 255)", + "background": [ + 18, + 120, + 91 + ], + "contrast": 5.44 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "训练结束", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "汇总并结束训练", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "action", + "text": "动作 · rule-check", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "camera", + "text": "相机 · 跟随目标", + "foreground": "rgb(255, 255, 255)", + "background": [ + 103, + 80, + 188 + ], + "contrast": 6.09 + }, + { + "selector": "audio empty", + "text": "音频 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + }, + { + "selector": "animation empty", + "text": "动画 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + }, + { + "selector": "action", + "text": "动作 · rule-check", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "camera", + "text": "相机 · 跟随目标", + "foreground": "rgb(255, 255, 255)", + "background": [ + 103, + 80, + 188 + ], + "contrast": 6.09 + }, + { + "selector": "audio empty", + "text": "音频 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + }, + { + "selector": "animation empty", + "text": "动画 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + } + ] + }, + { + "name": "全部六类节点色调(本地增加节点,未保存)", + "status": "PASS", + "minimumContrast": 4.92, + "measured": [ + { + "selector": "tone-blue", + "text": "步", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "训练步骤", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "同步基础步骤", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-violet", + "text": "◇", + "foreground": "rgb(255, 255, 255)", + "background": [ + 103, + 80, + 188 + ], + "contrast": 6.09 + }, + { + "selector": "B", + "text": "条件分支", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "按规则选择路径", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-cyan", + "text": "⌛", + "foreground": "rgb(255, 255, 255)", + "background": [ + 9, + 123, + 148 + ], + "contrast": 4.92 + }, + { + "selector": "B", + "text": "外部事件等待", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "等待设备或交互事件", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-orange", + "text": "障", + "foreground": "rgb(255, 255, 255)", + "background": [ + 153, + 84, + 21 + ], + "contrast": 5.78 + }, + { + "selector": "B", + "text": "故障注入", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "写入模拟故障参数", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-orange", + "text": "清", + "foreground": "rgb(255, 255, 255)", + "background": [ + 153, + 84, + 21 + ], + "contrast": 5.78 + }, + { + "selector": "B", + "text": "故障清除", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "恢复标准装备状态", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-red", + "text": "安", + "foreground": "rgb(255, 255, 255)", + "background": [ + 181, + 62, + 73 + ], + "contrast": 5.6 + }, + { + "selector": "B", + "text": "安全判定", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "检查安全前置条件", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-green", + "text": "分", + "foreground": "rgb(255, 255, 255)", + "background": [ + 18, + 120, + 91 + ], + "contrast": 5.44 + }, + { + "selector": "B", + "text": "评分节点", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "记录过程与结果得分", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-blue", + "text": "镜", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "相机控制", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "切换或跟随三维镜头", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "tone-cyan", + "text": "声", + "foreground": "rgb(255, 255, 255)", + "background": [ + 9, + 123, + 148 + ], + "contrast": 4.92 + }, + { + "selector": "B", + "text": "音频播放", + "foreground": "rgb(23, 52, 79)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 12.34 + }, + { + "selector": "SMALL", + "text": "播放离线讲解音频", + "foreground": "rgb(82, 107, 128)", + "background": [ + 249, + 251, + 253 + ], + "contrast": 5.37 + }, + { + "selector": "I", + "text": "▶", + "foreground": "rgb(255, 255, 255)", + "background": [ + 18, + 120, + 91 + ], + "contrast": 5.44 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "训练开始", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "流程唯一入口", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "步", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "识别狐狸巡检目标", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "同步步骤 · hydraulic-fault-step-01", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "步", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "完成狐狸状态检查", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "同步步骤 · hydraulic-fault-step-02", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "■", + "foreground": "rgb(255, 255, 255)", + "background": [ + 18, + 120, + 91 + ], + "contrast": 5.44 + }, + { + "selector": "SMALL", + "text": "流程", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "训练结束", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "汇总并结束训练", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "◇", + "foreground": "rgb(255, 255, 255)", + "background": [ + 103, + 80, + 188 + ], + "contrast": 6.09 + }, + { + "selector": "SMALL", + "text": "逻辑", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "条件分支", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "按规则选择路径", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "⌛", + "foreground": "rgb(255, 255, 255)", + "background": [ + 9, + 123, + 148 + ], + "contrast": 4.92 + }, + { + "selector": "SMALL", + "text": "逻辑", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "外部事件等待", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "等待设备或交互事件", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "障", + "foreground": "rgb(255, 255, 255)", + "background": [ + 153, + 84, + 21 + ], + "contrast": 5.78 + }, + { + "selector": "SMALL", + "text": "故障", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "故障注入", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "写入模拟故障参数", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "I", + "text": "安", + "foreground": "rgb(255, 255, 255)", + "background": [ + 181, + 62, + 73 + ], + "contrast": 5.6 + }, + { + "selector": "SMALL", + "text": "判定", + "foreground": "rgb(82, 107, 128)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 5.57 + }, + { + "selector": "B", + "text": "安全判定", + "foreground": "rgb(23, 52, 79)", + "background": [ + 255, + 255, + 255 + ], + "contrast": 12.8 + }, + { + "selector": "tfa-node-meta", + "text": "检查安全前置条件", + "foreground": "rgb(82, 107, 128)", + "background": [ + 246, + 249, + 252 + ], + "contrast": 5.27 + }, + { + "selector": "action", + "text": "动作 · rule-check", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "camera", + "text": "相机 · 跟随目标", + "foreground": "rgb(255, 255, 255)", + "background": [ + 103, + 80, + 188 + ], + "contrast": 6.09 + }, + { + "selector": "audio empty", + "text": "音频 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + }, + { + "selector": "animation empty", + "text": "动画 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + }, + { + "selector": "action", + "text": "动作 · rule-check", + "foreground": "rgb(255, 255, 255)", + "background": [ + 36, + 108, + 166 + ], + "contrast": 5.57 + }, + { + "selector": "camera", + "text": "相机 · 跟随目标", + "foreground": "rgb(255, 255, 255)", + "background": [ + 103, + 80, + 188 + ], + "contrast": 6.09 + }, + { + "selector": "audio empty", + "text": "音频 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + }, + { + "selector": "animation empty", + "text": "动画 · 未绑定", + "foreground": "rgb(67, 93, 113)", + "background": [ + 220, + 231, + 238 + ], + "contrast": 5.49 + } + ] + }, + { + "name": "宿主配色 military 下高级编排可读性", + "status": "PASS", + "minimumContrast": 4.92 + }, + { + "name": "宿主配色 technology 下高级编排可读性", + "status": "PASS", + "minimumContrast": 4.92 + }, + { + "name": "宿主配色 graphite 下高级编排可读性", + "status": "PASS", + "minimumContrast": 4.92 + }, + { + "name": "宿主配色 dark 下高级编排可读性", + "status": "PASS", + "minimumContrast": 4.92 + }, + { + "name": "系统暗色偏好下高级编排可读性", + "status": "PASS" + }, + { + "name": "选中、自动排布、时间轴播放/停止", + "status": "PASS" + }, + { + "name": "局域网6180真实页面及6100代理", + "status": "PASS", + "minimumContrast": 4.92 + } + ], + "screenshots": [ + "screenshots/before.png", + "screenshots/after.png", + "screenshots/after-layout.png", + "screenshots/theme-military.png", + "screenshots/theme-technology.png", + "screenshots/theme-graphite.png", + "screenshots/theme-dark.png", + "screenshots/lan-after.png" + ], + "pageErrors": [], + "status": "PASS", + "finishedAt": "2026-09-05T16:10:38.435Z", + "build": { + "command": "pnpm run build", + "status": "PASS" + }, + "scope": "仅高级编排CSS。浏览器禁止内容业务写请求,节点添加/排布仅为本地样式检查。编辑器沿用固定浅色画布,分别切换四种宿主主题属性和系统暗色偏好验证。" +} \ No newline at end of file diff --git a/reports/training-colors-20260906/index.html b/reports/training-colors-20260906/index.html new file mode 100644 index 0000000..d6e018b --- /dev/null +++ b/reports/training-colors-20260906/index.html @@ -0,0 +1 @@ +
2026-09-06 · 浏览器复核 9 项通过 · 构建通过
原有 tone-* 强制背景覆盖节点白底,造成蓝绿底上的灰字难以辨认。现恢复浅底深字,类型色用于图标与边框;同时提高节点说明、节点库和时间轴文字对比。
当前检查的文字样式最低对比度 4.92:1。修改仅涉及 training-flow-authoring.css。
仅高级编排CSS。浏览器禁止内容业务写请求,节点添加/排布仅为本地样式检查。编辑器沿用固定浅色画布,分别切换四种宿主主题属性和系统暗色偏好验证。

局域网实际页面;截图时在本地使用自动排布,不写入训练数据。





狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布
2026-09-05 · http://127.0.0.1:6180 · Microsoft Edge / Playwright · 教员编排与独立浏览器只读重开
证据组:3/3 通过;明确检查:通过 9,失败 0,待验证 0;已校验截图 19 张。
| 训练 ID | 名称 | 状态 | 当前版本 | 发布版本 | 场景 ID | 固定场景版本 | 步骤数 | 总分 |
|---|---|---|---|---|---|---|---|---|
| 161 | 实测训练-狐狸野外巡检-已验证-20260905 | PUBLISHED | 213 | 213 | 160 | 210 | 2 | 100 |
| 162 | 实测训练-牛奶卡车进场检查-已验证-20260905 | PUBLISHED | 214 | 214 | 159 | 209 | 2 | 100 |
{
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "213",
+ "publishedVersionId": "213",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2098",
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "sizeBytes": 52518,
+ "image": "screenshots/fox-final-cover.jpg"
+ }
+}{
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "214",
+ "publishedVersionId": "214",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2100",
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "sizeBytes": 42163,
+ "image": "screenshots/truck-final-cover.jpg"
+ }
+}保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。
证据组:3/3 通过;明确检查:通过 9,失败 0,待验证 0;已校验截图 19 张。
过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。
{
+ "title": "使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/02-published-scenes.png",
+ "screenshots/20-training-list-final.png"
+ ],
+ "details": {
+ "projects": [
+ {
+ "id": "161",
+ "status": "PUBLISHED",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100
+ },
+ {
+ "id": "162",
+ "status": "PUBLISHED",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100
+ }
+ ]
+ }
+}{
+ "status": "PASS",
+ "title": "狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布",
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "publishedVersionId": "211",
+ "coverId": "2095",
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "target": "scene:OBJ-1788605916565-6",
+ "stepCode": "VIRTUAL-S01",
+ "actionCode": "VIRTUAL-A01",
+ "score": 50
+ },
+ {
+ "title": "检查狐狸外观并确认状态",
+ "target": "scene:OBJ-1788605916565-6",
+ "stepCode": "VIRTUAL-S02",
+ "actionCode": "VIRTUAL-A02",
+ "score": 50
+ }
+ ],
+ "errors": [],
+ "failed": [],
+ "screenshots": [
+ "screenshots/09-fox-advanced-timeline.png",
+ "screenshots/10-fox-preflight.png"
+ ]
+}{
+ "status": "PASS",
+ "title": "狐狸三维点击选目标、调整视角并截图发布、异常恢复",
+ "id": "161",
+ "publishedVersionId": "213",
+ "pick": {
+ "x": 1065.015625,
+ "y": 750.5,
+ "targetId": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26"
+ },
+ "cameraBefore": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "cameraAfter": [
+ -0.9408733025235376,
+ 5.534741198620379,
+ 15.26632686671827
+ ],
+ "cover": {
+ "id": "2098",
+ "sizeBytes": 52518,
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "equalsCurrentViewport": true
+ },
+ "errors": [],
+ "screenshots": [
+ "screenshots/16-fox-mesh-picking.png",
+ "screenshots/17-fox-final-published.png"
+ ]
+}{
+ "status": "PASS",
+ "title": "卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布",
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "publishedVersionId": "214",
+ "coverId": "2100",
+ "pick": {
+ "x": 1065.015625,
+ "y": 750.5,
+ "targetId": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee"
+ },
+ "stepCount": 2,
+ "totalScore": 100,
+ "errors": [],
+ "screenshots": [
+ "screenshots/18-truck-mesh-picking.png",
+ "screenshots/19-truck-final-published.png"
+ ]
+}{
+ "title": "真实UI训练编排与发布实测",
+ "status": "PASS",
+ "finishedAt": "2026-09-05T15:23:29.909Z",
+ "projects": [
+ {
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "213",
+ "publishedVersionId": "213",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2098",
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "sizeBytes": 52518,
+ "image": "screenshots/fox-final-cover.jpg"
+ }
+ },
+ {
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "214",
+ "publishedVersionId": "214",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2100",
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "sizeBytes": 42163,
+ "image": "screenshots/truck-final-cover.jpg"
+ }
+ }
+ ],
+ "checks": [
+ {
+ "title": "使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/02-published-scenes.png",
+ "screenshots/20-training-list-final.png"
+ ],
+ "details": {
+ "projects": [
+ {
+ "id": "161",
+ "status": "PUBLISHED",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100
+ },
+ {
+ "id": "162",
+ "status": "PUBLISHED",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100
+ }
+ ]
+ }
+ },
+ {
+ "status": "PASS",
+ "title": "狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布",
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "publishedVersionId": "211",
+ "coverId": "2095",
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "target": "scene:OBJ-1788605916565-6",
+ "stepCode": "VIRTUAL-S01",
+ "actionCode": "VIRTUAL-A01",
+ "score": 50
+ },
+ {
+ "title": "检查狐狸外观并确认状态",
+ "target": "scene:OBJ-1788605916565-6",
+ "stepCode": "VIRTUAL-S02",
+ "actionCode": "VIRTUAL-A02",
+ "score": 50
+ }
+ ],
+ "errors": [],
+ "failed": [],
+ "screenshots": [
+ "screenshots/09-fox-advanced-timeline.png",
+ "screenshots/10-fox-preflight.png"
+ ]
+ },
+ {
+ "status": "PASS",
+ "title": "狐狸三维点击选目标、调整视角并截图发布、异常恢复",
+ "id": "161",
+ "publishedVersionId": "213",
+ "pick": {
+ "x": 1065.015625,
+ "y": 750.5,
+ "targetId": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26"
+ },
+ "cameraBefore": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "cameraAfter": [
+ -0.9408733025235376,
+ 5.534741198620379,
+ 15.26632686671827
+ ],
+ "cover": {
+ "id": "2098",
+ "sizeBytes": 52518,
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "equalsCurrentViewport": true
+ },
+ "errors": [],
+ "screenshots": [
+ "screenshots/16-fox-mesh-picking.png",
+ "screenshots/17-fox-final-published.png"
+ ]
+ },
+ {
+ "status": "PASS",
+ "title": "卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布",
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "publishedVersionId": "214",
+ "coverId": "2100",
+ "pick": {
+ "x": 1065.015625,
+ "y": 750.5,
+ "targetId": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee"
+ },
+ "stepCount": 2,
+ "totalScore": 100,
+ "errors": [],
+ "screenshots": [
+ "screenshots/18-truck-mesh-picking.png",
+ "screenshots/19-truck-final-published.png"
+ ]
+ }
+ ],
+ "commands": [
+ {
+ "command": "pnpm run build",
+ "status": "PASS",
+ "details": "vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。"
+ },
+ {
+ "command": "pnpm run test:training-legacy",
+ "status": "PASS",
+ "details": "训练原型迁移和野外场景共享定义两个合同均通过。"
+ },
+ {
+ "command": "node --test tools/training-cover.test.mjs tools/training-scene-api.test.mjs tools/training-model-placement.test.mjs tools/training-scene-targets.test.mjs tools/training-project-metadata.test.mjs",
+ "status": "PASS",
+ "details": "48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。"
+ },
+ {
+ "command": "node tools/training-scene-visibility.e2e.mjs",
+ "status": "PASS",
+ "details": "独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。"
+ }
+ ],
+ "limitations": [
+ "本次未创建教学下发任务,也未验证学员端完成训练;现有“预览训练”入口仍提示先发布再从虚实训练下发任务。",
+ "未配置讲解音频和必选安全前置条件,编辑器校验给出非阻断提醒;两条为轻量素材功能测试,不作为正式维修教学内容。",
+ "狐狸源场景实际已更新至V3(版本210),使用目录当时的真实发布版;未修改两个源场景或源模型。",
+ "训练封面截取保存时的当前视角;卡车封面通过现有“隐藏外壳”按钮隐藏围护结构,立柱仍遮挡部分车身。这是继承的源场景及视角,不是模型未加载;重新打开时仍按固定场景还原相机。",
+ "本轮没有新增需确认的功能变更,截图封面按本次明确授权实现;未改变数据库结构,无DDL变更。"
+ ],
+ "pageErrors": []
+}{
+ "title": "狐狸野外巡检:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/fox-independent-reopen.png",
+ "screenshots/fox-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 1,
+ "visibleMeshes": 1,
+ "skinnedMeshes": 1,
+ "position": [
+ -7.5,
+ 0.10930174852949677,
+ 0.3440109341705351
+ ],
+ "scale": [
+ 0.03616375932570189,
+ 0.03616375932570189,
+ 0.03616375932570189
+ ],
+ "finiteBounds": true,
+ "size": [
+ 0.9658796100519353,
+ 2.811899945787403,
+ 6
+ ],
+ "camera": {
+ "position": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "target": [
+ -7.512232860974557,
+ 1.4059499759307126,
+ -2.2204460492503126e-16
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 827
+ },
+ "scene": {
+ "projectId": "160",
+ "versionId": "210"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605873329-2",
+ "name": "维修装备主体 02",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6",
+ "name": "狐狸野外巡检目标",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
+ "name": "fox",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ },
+ {
+ "title": "检查狐狸外观并确认状态",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2098",
+ "bytes": 52518,
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+}{
+ "title": "牛奶卡车进场检查:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/truck-independent-reopen.png",
+ "screenshots/truck-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 5,
+ "visibleMeshes": 5,
+ "skinnedMeshes": 0,
+ "position": [
+ 4.2,
+ 0.20617168998977797,
+ -0.4356763541543951
+ ],
+ "scale": [
+ 1.2323086149245381,
+ 1.2323086149245381,
+ 1.2323086149245381
+ ],
+ "finiteBounds": true,
+ "size": [
+ 3.4406057210321332,
+ 3.390913184034896,
+ 6.000000000000001
+ ],
+ "camera": {
+ "position": [
+ 10.565439077211714,
+ 5.0456876852867705,
+ 7.37050840519251
+ ],
+ "target": [
+ 4.2,
+ 1.695456592017448,
+ 0
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 11669
+ },
+ "scene": {
+ "projectId": "159",
+ "versionId": "209"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605199171-2",
+ "name": "牛奶卡车检验主装备",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
+ "name": "Yup2Zup",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
+ "name": "Cesium_Milk_Truck",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
+ "name": "Cesium_Milk_Truck_1",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
+ "name": "Cesium_Milk_Truck_2",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
+ "name": "Cesium_Milk_Truck_3",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
+ "name": "Node",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
+ "name": "Wheels",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
+ "name": "Node001",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
+ "name": "Wheels001",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别牛奶卡车检验目标",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 50
+ },
+ {
+ "title": "检查卡车外观并确认状态",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2100",
+ "bytes": 42163,
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+}{
+ "title": "独立浏览器重开、真实模型可见性及封面验证",
+ "status": "PASS",
+ "startedAt": "2026-09-05T15:23:16.479Z",
+ "checks": [
+ {
+ "title": "狐狸野外巡检:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/fox-independent-reopen.png",
+ "screenshots/fox-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 1,
+ "visibleMeshes": 1,
+ "skinnedMeshes": 1,
+ "position": [
+ -7.5,
+ 0.10930174852949677,
+ 0.3440109341705351
+ ],
+ "scale": [
+ 0.03616375932570189,
+ 0.03616375932570189,
+ 0.03616375932570189
+ ],
+ "finiteBounds": true,
+ "size": [
+ 0.9658796100519353,
+ 2.811899945787403,
+ 6
+ ],
+ "camera": {
+ "position": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "target": [
+ -7.512232860974557,
+ 1.4059499759307126,
+ -2.2204460492503126e-16
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 827
+ },
+ "scene": {
+ "projectId": "160",
+ "versionId": "210"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605873329-2",
+ "name": "维修装备主体 02",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6",
+ "name": "狐狸野外巡检目标",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
+ "name": "fox",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ },
+ {
+ "title": "检查狐狸外观并确认状态",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2098",
+ "bytes": 52518,
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ },
+ {
+ "title": "牛奶卡车进场检查:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/truck-independent-reopen.png",
+ "screenshots/truck-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 5,
+ "visibleMeshes": 5,
+ "skinnedMeshes": 0,
+ "position": [
+ 4.2,
+ 0.20617168998977797,
+ -0.4356763541543951
+ ],
+ "scale": [
+ 1.2323086149245381,
+ 1.2323086149245381,
+ 1.2323086149245381
+ ],
+ "finiteBounds": true,
+ "size": [
+ 3.4406057210321332,
+ 3.390913184034896,
+ 6.000000000000001
+ ],
+ "camera": {
+ "position": [
+ 10.565439077211714,
+ 5.0456876852867705,
+ 7.37050840519251
+ ],
+ "target": [
+ 4.2,
+ 1.695456592017448,
+ 0
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 11669
+ },
+ "scene": {
+ "projectId": "159",
+ "versionId": "209"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605199171-2",
+ "name": "牛奶卡车检验主装备",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
+ "name": "Yup2Zup",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
+ "name": "Cesium_Milk_Truck",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
+ "name": "Cesium_Milk_Truck_1",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
+ "name": "Cesium_Milk_Truck_2",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
+ "name": "Cesium_Milk_Truck_3",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
+ "name": "Node",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
+ "name": "Wheels",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
+ "name": "Node001",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
+ "name": "Wheels001",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别牛奶卡车检验目标",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 50
+ },
+ {
+ "title": "检查卡车外观并确认状态",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2100",
+ "bytes": 42163,
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ }
+ ],
+ "projects": [
+ {
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "213",
+ "publishedVersionId": "213",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2098",
+ "image": "screenshots/fox-final-cover.jpg"
+ }
+ },
+ {
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "214",
+ "publishedVersionId": "214",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2100",
+ "image": "screenshots/truck-final-cover.jpg"
+ }
+ }
+ ],
+ "finishedAt": "2026-09-05T15:24:01.659Z"
+}{
+ "title": "封面上传503:训练内容仍保存并保留原封面",
+ "status": "PASS",
+ "details": {
+ "beforeVersion": 6,
+ "afterVersion": 7,
+ "sameCover": true,
+ "stepCount": 2
+ },
+ "screenshot": "screenshots/12-cover-failure-preserves-old.png"
+}{
+ "title": "保存409:不覆盖服务端内容和封面,并保持未保存提示",
+ "status": "PASS",
+ "details": {
+ "version": 7,
+ "putAttempts": 1,
+ "sameCover": true
+ },
+ "screenshot": "screenshots/13-save-conflict.png"
+}{
+ "title": "切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入",
+ "status": "PASS",
+ "details": {
+ "retainedSceneProjectId": "160",
+ "blockedDownloads": 1,
+ "putCount": 0
+ },
+ "screenshots": [
+ "screenshots/14-binding-disables-save.png",
+ "screenshots/15-binding-failure-retains-scene.png"
+ ]
+}{
+ "title": "真实浏览器异常分支验证(定向注入HTTP失败)",
+ "status": "PASS",
+ "checks": [
+ {
+ "title": "封面上传503:训练内容仍保存并保留原封面",
+ "status": "PASS",
+ "details": {
+ "beforeVersion": 6,
+ "afterVersion": 7,
+ "sameCover": true,
+ "stepCount": 2
+ },
+ "screenshot": "screenshots/12-cover-failure-preserves-old.png"
+ },
+ {
+ "title": "保存409:不覆盖服务端内容和封面,并保持未保存提示",
+ "status": "PASS",
+ "details": {
+ "version": 7,
+ "putAttempts": 1,
+ "sameCover": true
+ },
+ "screenshot": "screenshots/13-save-conflict.png"
+ },
+ {
+ "title": "切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入",
+ "status": "PASS",
+ "details": {
+ "retainedSceneProjectId": "160",
+ "blockedDownloads": 1,
+ "putCount": 0
+ },
+ "screenshots": [
+ "screenshots/14-binding-disables-save.png",
+ "screenshots/15-binding-failure-retains-scene.png"
+ ]
+ }
+ ]
+}vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。
训练原型迁移和野外场景共享定义两个合同均通过。
48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。
独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。















这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。
{
+ "title": "真实UI训练编排与发布实测",
+ "status": "PASS",
+ "finishedAt": "2026-09-05T15:23:29.909Z",
+ "projects": [
+ {
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "213",
+ "publishedVersionId": "213",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2098",
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "sizeBytes": 52518,
+ "image": "screenshots/fox-final-cover.jpg"
+ }
+ },
+ {
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "214",
+ "publishedVersionId": "214",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2100",
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "sizeBytes": 42163,
+ "image": "screenshots/truck-final-cover.jpg"
+ }
+ }
+ ],
+ "checks": [
+ {
+ "title": "使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/02-published-scenes.png",
+ "screenshots/20-training-list-final.png"
+ ],
+ "details": {
+ "projects": [
+ {
+ "id": "161",
+ "status": "PUBLISHED",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100
+ },
+ {
+ "id": "162",
+ "status": "PUBLISHED",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100
+ }
+ ]
+ }
+ },
+ {
+ "status": "PASS",
+ "title": "狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布",
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "publishedVersionId": "211",
+ "coverId": "2095",
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "target": "scene:OBJ-1788605916565-6",
+ "stepCode": "VIRTUAL-S01",
+ "actionCode": "VIRTUAL-A01",
+ "score": 50
+ },
+ {
+ "title": "检查狐狸外观并确认状态",
+ "target": "scene:OBJ-1788605916565-6",
+ "stepCode": "VIRTUAL-S02",
+ "actionCode": "VIRTUAL-A02",
+ "score": 50
+ }
+ ],
+ "errors": [],
+ "failed": [],
+ "screenshots": [
+ "screenshots/09-fox-advanced-timeline.png",
+ "screenshots/10-fox-preflight.png"
+ ]
+ },
+ {
+ "status": "PASS",
+ "title": "狐狸三维点击选目标、调整视角并截图发布、异常恢复",
+ "id": "161",
+ "publishedVersionId": "213",
+ "pick": {
+ "x": 1065.015625,
+ "y": 750.5,
+ "targetId": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26"
+ },
+ "cameraBefore": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "cameraAfter": [
+ -0.9408733025235376,
+ 5.534741198620379,
+ 15.26632686671827
+ ],
+ "cover": {
+ "id": "2098",
+ "sizeBytes": 52518,
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "equalsCurrentViewport": true
+ },
+ "errors": [],
+ "screenshots": [
+ "screenshots/16-fox-mesh-picking.png",
+ "screenshots/17-fox-final-published.png"
+ ]
+ },
+ {
+ "status": "PASS",
+ "title": "卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布",
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "publishedVersionId": "214",
+ "coverId": "2100",
+ "pick": {
+ "x": 1065.015625,
+ "y": 750.5,
+ "targetId": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee"
+ },
+ "stepCount": 2,
+ "totalScore": 100,
+ "errors": [],
+ "screenshots": [
+ "screenshots/18-truck-mesh-picking.png",
+ "screenshots/19-truck-final-published.png"
+ ]
+ }
+ ],
+ "commands": [
+ {
+ "command": "pnpm run build",
+ "status": "PASS",
+ "details": "vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。"
+ },
+ {
+ "command": "pnpm run test:training-legacy",
+ "status": "PASS",
+ "details": "训练原型迁移和野外场景共享定义两个合同均通过。"
+ },
+ {
+ "command": "node --test tools/training-cover.test.mjs tools/training-scene-api.test.mjs tools/training-model-placement.test.mjs tools/training-scene-targets.test.mjs tools/training-project-metadata.test.mjs",
+ "status": "PASS",
+ "details": "48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。"
+ },
+ {
+ "command": "node tools/training-scene-visibility.e2e.mjs",
+ "status": "PASS",
+ "details": "独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。"
+ }
+ ],
+ "limitations": [
+ "本次未创建教学下发任务,也未验证学员端完成训练;现有“预览训练”入口仍提示先发布再从虚实训练下发任务。",
+ "未配置讲解音频和必选安全前置条件,编辑器校验给出非阻断提醒;两条为轻量素材功能测试,不作为正式维修教学内容。",
+ "狐狸源场景实际已更新至V3(版本210),使用目录当时的真实发布版;未修改两个源场景或源模型。",
+ "训练封面截取保存时的当前视角;卡车封面通过现有“隐藏外壳”按钮隐藏围护结构,立柱仍遮挡部分车身。这是继承的源场景及视角,不是模型未加载;重新打开时仍按固定场景还原相机。",
+ "本轮没有新增需确认的功能变更,截图封面按本次明确授权实现;未改变数据库结构,无DDL变更。"
+ ],
+ "pageErrors": []
+}
+
+{
+ "title": "独立浏览器重开、真实模型可见性及封面验证",
+ "status": "PASS",
+ "startedAt": "2026-09-05T15:23:16.479Z",
+ "checks": [
+ {
+ "title": "狐狸野外巡检:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/fox-independent-reopen.png",
+ "screenshots/fox-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 1,
+ "visibleMeshes": 1,
+ "skinnedMeshes": 1,
+ "position": [
+ -7.5,
+ 0.10930174852949677,
+ 0.3440109341705351
+ ],
+ "scale": [
+ 0.03616375932570189,
+ 0.03616375932570189,
+ 0.03616375932570189
+ ],
+ "finiteBounds": true,
+ "size": [
+ 0.9658796100519353,
+ 2.811899945787403,
+ 6
+ ],
+ "camera": {
+ "position": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "target": [
+ -7.512232860974557,
+ 1.4059499759307126,
+ -2.2204460492503126e-16
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 827
+ },
+ "scene": {
+ "projectId": "160",
+ "versionId": "210"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605873329-2",
+ "name": "维修装备主体 02",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6",
+ "name": "狐狸野外巡检目标",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
+ "name": "fox",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ },
+ {
+ "title": "检查狐狸外观并确认状态",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2098",
+ "bytes": 52518,
+ "sha256": "7b7c41e441e525e151ad4bd46fe3b01c249282368331f04cebe6a0eac0141267",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ },
+ {
+ "title": "牛奶卡车进场检查:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/truck-independent-reopen.png",
+ "screenshots/truck-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 5,
+ "visibleMeshes": 5,
+ "skinnedMeshes": 0,
+ "position": [
+ 4.2,
+ 0.20617168998977797,
+ -0.4356763541543951
+ ],
+ "scale": [
+ 1.2323086149245381,
+ 1.2323086149245381,
+ 1.2323086149245381
+ ],
+ "finiteBounds": true,
+ "size": [
+ 3.4406057210321332,
+ 3.390913184034896,
+ 6.000000000000001
+ ],
+ "camera": {
+ "position": [
+ 10.565439077211714,
+ 5.0456876852867705,
+ 7.37050840519251
+ ],
+ "target": [
+ 4.2,
+ 1.695456592017448,
+ 0
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 11669
+ },
+ "scene": {
+ "projectId": "159",
+ "versionId": "209"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605199171-2",
+ "name": "牛奶卡车检验主装备",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
+ "name": "Yup2Zup",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
+ "name": "Cesium_Milk_Truck",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
+ "name": "Cesium_Milk_Truck_1",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
+ "name": "Cesium_Milk_Truck_2",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
+ "name": "Cesium_Milk_Truck_3",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
+ "name": "Node",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
+ "name": "Wheels",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
+ "name": "Node001",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
+ "name": "Wheels001",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别牛奶卡车检验目标",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 50
+ },
+ {
+ "title": "检查卡车外观并确认状态",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2100",
+ "bytes": 42163,
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ }
+ ],
+ "projects": [
+ {
+ "id": "161",
+ "name": "实测训练-狐狸野外巡检-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "213",
+ "publishedVersionId": "213",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2098",
+ "image": "screenshots/fox-final-cover.jpg"
+ }
+ },
+ {
+ "id": "162",
+ "name": "实测训练-牛奶卡车进场检查-已验证-20260905",
+ "status": "PUBLISHED",
+ "currentVersionId": "214",
+ "publishedVersionId": "214",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2100",
+ "image": "screenshots/truck-final-cover.jpg"
+ }
+ }
+ ],
+ "finishedAt": "2026-09-05T15:24:01.659Z"
+}
+
+{
+ "title": "真实浏览器异常分支验证(定向注入HTTP失败)",
+ "status": "PASS",
+ "checks": [
+ {
+ "title": "封面上传503:训练内容仍保存并保留原封面",
+ "status": "PASS",
+ "details": {
+ "beforeVersion": 6,
+ "afterVersion": 7,
+ "sameCover": true,
+ "stepCount": 2
+ },
+ "screenshot": "screenshots/12-cover-failure-preserves-old.png"
+ },
+ {
+ "title": "保存409:不覆盖服务端内容和封面,并保持未保存提示",
+ "status": "PASS",
+ "details": {
+ "version": 7,
+ "putAttempts": 1,
+ "sameCover": true
+ },
+ "screenshot": "screenshots/13-save-conflict.png"
+ },
+ {
+ "title": "切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入",
+ "status": "PASS",
+ "details": {
+ "retainedSceneProjectId": "160",
+ "blockedDownloads": 1,
+ "putCount": 0
+ },
+ "screenshots": [
+ "screenshots/14-binding-disables-save.png",
+ "screenshots/15-binding-failure-retains-scene.png"
+ ]
+ }
+ ]
+}
+
+编排 UI · 默认教学入口 · 正式教学 API
编排与 API 已验证,教学页面闭环受限。需完成默认教学页面与正式任务运行的接线,才能从页面走完新模型对应的训练。
2026-09-05 · http://127.0.0.1:6180
证据组 2/2 已完成;检查通过 26,受限 2,失败 0,待验证 0;已校验截图 25 张。
| 模式 | 正式渠道 | 本轮 API 验证方法 | 必检项 | 默认界面边界 |
|---|---|---|---|---|
| 实装实训 | PHYSICAL | 正式 API 的 MANUAL 人工事件 | 三步顺序、失败重试、来源权限、事件幂等、提交与评定 | 默认离线识别页;设备 / 视觉接入另行联调 |
| 对抗训练 | CONFRONTATION | 正式 API 的红蓝两队共享运行 | 岗位权限、两队隔离、顺序、时限、命令幂等、提交与评定 | 默认本地工作台;不混同正式 API 运行 |
| 训练 ID | 名称 | 模式 | 状态 | 当前发布版本 | API 已测版本 | 步骤数 |
|---|---|---|---|---|---|---|
| 163 | 实测训练-牛奶卡车实装检查-20260905 | PHYSICAL | PUBLISHED | 217 | 215 | 3 |
| 164 | 实测训练-狐狸红蓝协同巡检-20260905 | CONFRONTATION | PUBLISHED | 216 | 216 | 2 |
训练 163:API任务50固定215;说明字段兼容修复后在217做UI及封面回归,未改写任务快照。
{
+ "id": "163",
+ "name": "实测训练-牛奶卡车实装检查-20260905",
+ "mode": "PHYSICAL",
+ "status": "PUBLISHED",
+ "currentVersionId": "217",
+ "publishedVersionId": "217",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 3,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2111",
+ "image": "screenshots/physical-final-cover.jpg"
+ },
+ "trainingMode": "PHYSICAL",
+ "modeConfig": {
+ "type": "PHYSICAL",
+ "stationName": "牛奶卡车测试工位 A",
+ "equipmentCode": "E2E-TRUCK-163",
+ "gatewayProtocol": "HTTP 测试网关",
+ "sensorChannels": "人工外观确认;测试状态点位",
+ "safetyInterlocks": "测试工位隔离确认"
+ },
+ "steps": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "actionCode": "PHYSICAL-A01",
+ "title": "核对卡车工位与装备",
+ "summary": "核对测试工位与牛奶卡车编号。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33,
+ "manualConfirmAllowed": true
+ },
+ "score": 30
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "actionCode": "PHYSICAL-A02",
+ "title": "检查卡车外观状态",
+ "summary": "人工确认卡车外观状态,使用受控事件记录检查。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67,
+ "manualConfirmAllowed": true
+ },
+ "score": 30
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "actionCode": "PHYSICAL-A03",
+ "title": "确认检查完成",
+ "summary": "核实步骤记录并提交检查结果。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100,
+ "manualConfirmAllowed": true
+ },
+ "score": 40
+ }
+ ],
+ "apiTestVersionId": "215",
+ "apiVersionNote": "API任务50固定215;说明字段兼容修复后在217做UI及封面回归,未改写任务快照。"
+}{
+ "id": "164",
+ "name": "实测训练-狐狸红蓝协同巡检-20260905",
+ "mode": "CONFRONTATION",
+ "status": "PUBLISHED",
+ "currentVersionId": "216",
+ "publishedVersionId": "216",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2108",
+ "image": "screenshots/confrontation-final-cover.jpg"
+ },
+ "trainingMode": "CONFRONTATION",
+ "modeConfig": {
+ "type": "CONFRONTATION",
+ "redTeamName": "狐狸巡检红队",
+ "blueTeamName": "狐狸巡检蓝队",
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "objective": "两队分别完成狐狸模型巡检并确认状态",
+ "winRule": "按已完成步骤与协同检查结果评分",
+ "submitPositions": [
+ "COMMANDER"
+ ],
+ "roundDuration": 7,
+ "eventInjections": ""
+ },
+ "steps": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "actionCode": "CONFRONTATION-A01",
+ "title": "识别狐狸巡检目标",
+ "summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "allowedPositions": [
+ "COMMANDER"
+ ],
+ "physicalEventRule": {},
+ "score": 50
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "actionCode": "CONFRONTATION-A02",
+ "title": "完成狐狸状态检查",
+ "summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "allowedPositions": [
+ "COMMANDER"
+ ],
+ "physicalEventRule": {},
+ "score": 50
+ }
+ ],
+ "apiTestVersionId": "216"
+}| 任务 ID | 运行 ID | 渠道 | 队伍 | 状态 | 完成度 | 评定分 |
|---|---|---|---|---|---|---|
| 50 | 44 | PHYSICAL | NEUTRAL | REVIEWED | 100 | 100 |
| 51 | 45 | CONFRONTATION | RED | REVIEWED | 100 | 100 |
| 51 | 46 | CONFRONTATION | BLUE | REVIEWED | 100 | 100 |
{
+ "assignments": [
+ "50",
+ "51"
+ ],
+ "runs": [
+ "44",
+ "45",
+ "46"
+ ],
+ "users": [
+ "95",
+ "96",
+ "97",
+ "98"
+ ],
+ "note": "仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。"
+}随机测试密码与登录会话不写入报告;保留新建数据用于追溯。
证据组 2/2 已完成;检查通过 26,受限 2,失败 0,待验证 0;已校验截图 25 张。
证据类型:UI · evidence.json
{
+ "title": "实装:创建、绑定牛奶卡车、3步配置、保存重开与发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-settings-reopen.png",
+ "screenshots/physical-authoring.png",
+ "screenshots/physical-preflight.png",
+ "screenshots/physical-published.png"
+ ],
+ "details": {
+ "id": "163",
+ "version": "215",
+ "progress": [
+ 33,
+ 67,
+ 100
+ ],
+ "profile": {
+ "type": "PHYSICAL",
+ "stationName": "牛奶卡车测试工位 A",
+ "equipmentCode": "E2E-TRUCK-163",
+ "gatewayProtocol": "HTTP 测试网关",
+ "sensorChannels": "人工外观确认;测试状态点位",
+ "safetyInterlocks": "测试工位隔离确认"
+ }
+ }
+}{
+ "title": "对抗:每队人数为小数时阻止发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-invalid-team-size.png"
+ ],
+ "details": {
+ "input": 2.5,
+ "message": "发布校验未通过\n×\n发现 1 个必须处理的问题\n\n2 个步骤 · 1 个目标 · 0 个故障 · 及格线 80 分\n\n!\n每队人数必须是 1 到 200 的整数\nmodeConfig.teamSize\ni\n本步骤尚未配置必选安全前置条件\nsteps[0].safetyRules\ni\n本步骤尚未配置必选安全前置条件\nsteps[1].safetyRules\ni\n尚未绑定离线讲解文件,运行时将保留字幕并使用默认本地语音\nsteps.audio\n关闭"
+ }
+}{
+ "title": "对抗:显式清空岗位后保存重开,不恢复默认授权",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-empty-positions.png"
+ ],
+ "details": {
+ "allowedPositions": [],
+ "published": false
+ }
+}{
+ "title": "对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-positions.png",
+ "screenshots/confrontation-settings-reopen.png",
+ "screenshots/confrontation-authoring.png",
+ "screenshots/confrontation-preflight.png",
+ "screenshots/confrontation-published.png"
+ ],
+ "details": {
+ "id": "164",
+ "version": "216",
+ "profile": {
+ "type": "CONFRONTATION",
+ "redTeamName": "狐狸巡检红队",
+ "blueTeamName": "狐狸巡检蓝队",
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "objective": "两队分别完成狐狸模型巡检并确认状态",
+ "winRule": "按已完成步骤与协同检查结果评分",
+ "submitPositions": [
+ "COMMANDER"
+ ],
+ "roundDuration": 7,
+ "eventInjections": ""
+ },
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
+ "allowedPositions": [
+ "COMMANDER"
+ ]
+ },
+ {
+ "title": "完成狐狸状态检查",
+ "summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
+ "allowedPositions": [
+ "COMMANDER"
+ ]
+ }
+ ]
+ }
+}{
+ "title": "实装:说明兼容修复回写,重新截图封面及发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-final-published.png"
+ ],
+ "details": {
+ "projectId": "163",
+ "publishedVersionId": "217",
+ "apiTestVersionId": "215",
+ "note": "既有任务50固定215;本次仅回填说明与更新截图封面,未改写任务快照。",
+ "steps": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "actionCode": "PHYSICAL-A01",
+ "summary": "核对测试工位与牛奶卡车编号。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33,
+ "manualConfirmAllowed": true
+ }
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "actionCode": "PHYSICAL-A02",
+ "summary": "人工确认卡车外观状态,使用受控事件记录检查。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67,
+ "manualConfirmAllowed": true
+ }
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "actionCode": "PHYSICAL-A03",
+ "summary": "核实步骤记录并提交检查结果。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100,
+ "manualConfirmAllowed": true
+ }
+ }
+ ]
+ }
+}{
+ "title": "实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤",
+ "status": "BLOCKED",
+ "screenshots": [
+ "screenshots/physical-teaching-list.png",
+ "screenshots/physical-offline-preview.png",
+ "screenshots/physical-offline-confirmed.png"
+ ],
+ "details": {
+ "assignmentId": "50",
+ "previewUrl": "/teaching/physical-training/preview/50",
+ "observed": "进入任务后仍显示固定45秒维修识别录像,确认只改变本次页面候选,未加载卡车三步正式运行。",
+ "businessWrites": [],
+ "required": "需将正式任务快照、场景、人工/设备事件及运行记录接入工作台。"
+ }
+}{
+ "title": "对抗默认页面:新建下拉仅有本地液压预设,无法选择已发布狐狸训练",
+ "status": "BLOCKED",
+ "screenshots": [
+ "screenshots/confrontation-final-entry.png",
+ "screenshots/confrontation-final-local-create.png"
+ ],
+ "details": {
+ "options": [
+ "桥梁架设装备液压系统检测与维修训练"
+ ],
+ "formalAssignmentId": "51",
+ "formalAssignmentExists": true,
+ "required": "需将默认任务清单、创建表单和执行入口接入正式对抗任务服务,并确认通用巡检与五阶段排故的适用范围。"
+ }
+}{
+ "title": "训练列表两种模式展示各自真实截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-list-cover.png",
+ "screenshots/confrontation-list-cover.png"
+ ],
+ "details": {
+ "projects": [
+ "163",
+ "164"
+ ],
+ "authenticatedImagesLoaded": true
+ }
+}{
+ "title": "牛奶卡车实装检查:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-independent-reopen.png",
+ "screenshots/physical-independent-settings.png",
+ "screenshots/physical-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 5,
+ "visibleMeshes": 5,
+ "skinnedMeshes": 0,
+ "position": [
+ 4.2,
+ 0.20617168998977797,
+ -0.4356763541543951
+ ],
+ "scale": [
+ 1.2323086149245381,
+ 1.2323086149245381,
+ 1.2323086149245381
+ ],
+ "finiteBounds": true,
+ "size": [
+ 3.4406057210321332,
+ 3.390913184034896,
+ 6.000000000000001
+ ],
+ "camera": {
+ "position": [
+ 10.565439077211714,
+ 5.0456876852867705,
+ 7.37050840519251
+ ],
+ "target": [
+ 4.2,
+ 1.695456592017448,
+ 0
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 11669
+ },
+ "scene": {
+ "projectId": "159",
+ "versionId": "209"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605199171-2",
+ "name": "牛奶卡车检验主装备",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
+ "name": "Yup2Zup",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
+ "name": "Cesium_Milk_Truck",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
+ "name": "Cesium_Milk_Truck_1",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
+ "name": "Cesium_Milk_Truck_2",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
+ "name": "Cesium_Milk_Truck_3",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
+ "name": "Node",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
+ "name": "Wheels",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
+ "name": "Node001",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
+ "name": "Wheels001",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "核对卡车工位与装备",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 30
+ },
+ {
+ "title": "检查卡车外观状态",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 30
+ },
+ {
+ "title": "确认检查完成",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 40
+ }
+ ],
+ "cover": {
+ "assetId": "2111",
+ "bytes": 42163,
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+}{
+ "title": "狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-independent-reopen.png",
+ "screenshots/confrontation-independent-settings.png",
+ "screenshots/confrontation-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 1,
+ "visibleMeshes": 1,
+ "skinnedMeshes": 1,
+ "position": [
+ -7.5,
+ 0.10930174852949677,
+ 0.3440109341705351
+ ],
+ "scale": [
+ 0.03616375932570189,
+ 0.03616375932570189,
+ 0.03616375932570189
+ ],
+ "finiteBounds": true,
+ "size": [
+ 0.9658796100519353,
+ 2.811899945787403,
+ 6
+ ],
+ "camera": {
+ "position": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "target": [
+ -7.512232860974557,
+ 1.4059499759307126,
+ -2.2204460492503126e-16
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 827
+ },
+ "scene": {
+ "projectId": "160",
+ "versionId": "210"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605873329-2",
+ "name": "维修装备主体 02",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6",
+ "name": "狐狸野外巡检目标",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
+ "name": "fox",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ },
+ {
+ "title": "完成狐狸状态检查",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2108",
+ "bytes": 51257,
+ "sha256": "79779a9d1e40c0c160fa3dab25e83931639942f53e2ee3a5da12f9f86ad70081",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+}{
+ "title": "实装实训与对抗训练真实流程测试",
+ "startedAt": "2026-09-05T15:37:32.805Z",
+ "status": "PASS_WITH_LIMITATIONS",
+ "checks": [
+ {
+ "title": "实装:创建、绑定牛奶卡车、3步配置、保存重开与发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-settings-reopen.png",
+ "screenshots/physical-authoring.png",
+ "screenshots/physical-preflight.png",
+ "screenshots/physical-published.png"
+ ],
+ "details": {
+ "id": "163",
+ "version": "215",
+ "progress": [
+ 33,
+ 67,
+ 100
+ ],
+ "profile": {
+ "type": "PHYSICAL",
+ "stationName": "牛奶卡车测试工位 A",
+ "equipmentCode": "E2E-TRUCK-163",
+ "gatewayProtocol": "HTTP 测试网关",
+ "sensorChannels": "人工外观确认;测试状态点位",
+ "safetyInterlocks": "测试工位隔离确认"
+ }
+ }
+ },
+ {
+ "title": "对抗:每队人数为小数时阻止发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-invalid-team-size.png"
+ ],
+ "details": {
+ "input": 2.5,
+ "message": "发布校验未通过\n×\n发现 1 个必须处理的问题\n\n2 个步骤 · 1 个目标 · 0 个故障 · 及格线 80 分\n\n!\n每队人数必须是 1 到 200 的整数\nmodeConfig.teamSize\ni\n本步骤尚未配置必选安全前置条件\nsteps[0].safetyRules\ni\n本步骤尚未配置必选安全前置条件\nsteps[1].safetyRules\ni\n尚未绑定离线讲解文件,运行时将保留字幕并使用默认本地语音\nsteps.audio\n关闭"
+ }
+ },
+ {
+ "title": "对抗:显式清空岗位后保存重开,不恢复默认授权",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-empty-positions.png"
+ ],
+ "details": {
+ "allowedPositions": [],
+ "published": false
+ }
+ },
+ {
+ "title": "对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-positions.png",
+ "screenshots/confrontation-settings-reopen.png",
+ "screenshots/confrontation-authoring.png",
+ "screenshots/confrontation-preflight.png",
+ "screenshots/confrontation-published.png"
+ ],
+ "details": {
+ "id": "164",
+ "version": "216",
+ "profile": {
+ "type": "CONFRONTATION",
+ "redTeamName": "狐狸巡检红队",
+ "blueTeamName": "狐狸巡检蓝队",
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "objective": "两队分别完成狐狸模型巡检并确认状态",
+ "winRule": "按已完成步骤与协同检查结果评分",
+ "submitPositions": [
+ "COMMANDER"
+ ],
+ "roundDuration": 7,
+ "eventInjections": ""
+ },
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
+ "allowedPositions": [
+ "COMMANDER"
+ ]
+ },
+ {
+ "title": "完成狐狸状态检查",
+ "summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
+ "allowedPositions": [
+ "COMMANDER"
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装:说明兼容修复回写,重新截图封面及发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-final-published.png"
+ ],
+ "details": {
+ "projectId": "163",
+ "publishedVersionId": "217",
+ "apiTestVersionId": "215",
+ "note": "既有任务50固定215;本次仅回填说明与更新截图封面,未改写任务快照。",
+ "steps": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "actionCode": "PHYSICAL-A01",
+ "summary": "核对测试工位与牛奶卡车编号。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33,
+ "manualConfirmAllowed": true
+ }
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "actionCode": "PHYSICAL-A02",
+ "summary": "人工确认卡车外观状态,使用受控事件记录检查。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67,
+ "manualConfirmAllowed": true
+ }
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "actionCode": "PHYSICAL-A03",
+ "summary": "核实步骤记录并提交检查结果。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100,
+ "manualConfirmAllowed": true
+ }
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤",
+ "status": "BLOCKED",
+ "screenshots": [
+ "screenshots/physical-teaching-list.png",
+ "screenshots/physical-offline-preview.png",
+ "screenshots/physical-offline-confirmed.png"
+ ],
+ "details": {
+ "assignmentId": "50",
+ "previewUrl": "/teaching/physical-training/preview/50",
+ "observed": "进入任务后仍显示固定45秒维修识别录像,确认只改变本次页面候选,未加载卡车三步正式运行。",
+ "businessWrites": [],
+ "required": "需将正式任务快照、场景、人工/设备事件及运行记录接入工作台。"
+ }
+ },
+ {
+ "title": "对抗默认页面:新建下拉仅有本地液压预设,无法选择已发布狐狸训练",
+ "status": "BLOCKED",
+ "screenshots": [
+ "screenshots/confrontation-final-entry.png",
+ "screenshots/confrontation-final-local-create.png"
+ ],
+ "details": {
+ "options": [
+ "桥梁架设装备液压系统检测与维修训练"
+ ],
+ "formalAssignmentId": "51",
+ "formalAssignmentExists": true,
+ "required": "需将默认任务清单、创建表单和执行入口接入正式对抗任务服务,并确认通用巡检与五阶段排故的适用范围。"
+ }
+ },
+ {
+ "title": "训练列表两种模式展示各自真实截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-list-cover.png",
+ "screenshots/confrontation-list-cover.png"
+ ],
+ "details": {
+ "projects": [
+ "163",
+ "164"
+ ],
+ "authenticatedImagesLoaded": true
+ }
+ },
+ {
+ "title": "牛奶卡车实装检查:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-independent-reopen.png",
+ "screenshots/physical-independent-settings.png",
+ "screenshots/physical-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 5,
+ "visibleMeshes": 5,
+ "skinnedMeshes": 0,
+ "position": [
+ 4.2,
+ 0.20617168998977797,
+ -0.4356763541543951
+ ],
+ "scale": [
+ 1.2323086149245381,
+ 1.2323086149245381,
+ 1.2323086149245381
+ ],
+ "finiteBounds": true,
+ "size": [
+ 3.4406057210321332,
+ 3.390913184034896,
+ 6.000000000000001
+ ],
+ "camera": {
+ "position": [
+ 10.565439077211714,
+ 5.0456876852867705,
+ 7.37050840519251
+ ],
+ "target": [
+ 4.2,
+ 1.695456592017448,
+ 0
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 11669
+ },
+ "scene": {
+ "projectId": "159",
+ "versionId": "209"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605199171-2",
+ "name": "牛奶卡车检验主装备",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
+ "name": "Yup2Zup",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
+ "name": "Cesium_Milk_Truck",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
+ "name": "Cesium_Milk_Truck_1",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
+ "name": "Cesium_Milk_Truck_2",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
+ "name": "Cesium_Milk_Truck_3",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
+ "name": "Node",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
+ "name": "Wheels",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
+ "name": "Node001",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
+ "name": "Wheels001",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "核对卡车工位与装备",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 30
+ },
+ {
+ "title": "检查卡车外观状态",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 30
+ },
+ {
+ "title": "确认检查完成",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 40
+ }
+ ],
+ "cover": {
+ "assetId": "2111",
+ "bytes": 42163,
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ },
+ {
+ "title": "狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-independent-reopen.png",
+ "screenshots/confrontation-independent-settings.png",
+ "screenshots/confrontation-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 1,
+ "visibleMeshes": 1,
+ "skinnedMeshes": 1,
+ "position": [
+ -7.5,
+ 0.10930174852949677,
+ 0.3440109341705351
+ ],
+ "scale": [
+ 0.03616375932570189,
+ 0.03616375932570189,
+ 0.03616375932570189
+ ],
+ "finiteBounds": true,
+ "size": [
+ 0.9658796100519353,
+ 2.811899945787403,
+ 6
+ ],
+ "camera": {
+ "position": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "target": [
+ -7.512232860974557,
+ 1.4059499759307126,
+ -2.2204460492503126e-16
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 827
+ },
+ "scene": {
+ "projectId": "160",
+ "versionId": "210"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605873329-2",
+ "name": "维修装备主体 02",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6",
+ "name": "狐狸野外巡检目标",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
+ "name": "fox",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ },
+ {
+ "title": "完成狐狸状态检查",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2108",
+ "bytes": 51257,
+ "sha256": "79779a9d1e40c0c160fa3dab25e83931639942f53e2ee3a5da12f9f86ad70081",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ }
+ ],
+ "projects": [
+ {
+ "id": "163",
+ "name": "实测训练-牛奶卡车实装检查-20260905",
+ "mode": "PHYSICAL",
+ "status": "PUBLISHED",
+ "currentVersionId": "217",
+ "publishedVersionId": "217",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 3,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2111",
+ "image": "screenshots/physical-final-cover.jpg"
+ },
+ "trainingMode": "PHYSICAL",
+ "modeConfig": {
+ "type": "PHYSICAL",
+ "stationName": "牛奶卡车测试工位 A",
+ "equipmentCode": "E2E-TRUCK-163",
+ "gatewayProtocol": "HTTP 测试网关",
+ "sensorChannels": "人工外观确认;测试状态点位",
+ "safetyInterlocks": "测试工位隔离确认"
+ },
+ "steps": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "actionCode": "PHYSICAL-A01",
+ "title": "核对卡车工位与装备",
+ "summary": "核对测试工位与牛奶卡车编号。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33,
+ "manualConfirmAllowed": true
+ },
+ "score": 30
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "actionCode": "PHYSICAL-A02",
+ "title": "检查卡车外观状态",
+ "summary": "人工确认卡车外观状态,使用受控事件记录检查。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67,
+ "manualConfirmAllowed": true
+ },
+ "score": 30
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "actionCode": "PHYSICAL-A03",
+ "title": "确认检查完成",
+ "summary": "核实步骤记录并提交检查结果。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100,
+ "manualConfirmAllowed": true
+ },
+ "score": 40
+ }
+ ],
+ "apiTestVersionId": "215",
+ "apiVersionNote": "API任务50固定215;说明字段兼容修复后在217做UI及封面回归,未改写任务快照。"
+ },
+ {
+ "id": "164",
+ "name": "实测训练-狐狸红蓝协同巡检-20260905",
+ "mode": "CONFRONTATION",
+ "status": "PUBLISHED",
+ "currentVersionId": "216",
+ "publishedVersionId": "216",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2108",
+ "image": "screenshots/confrontation-final-cover.jpg"
+ },
+ "trainingMode": "CONFRONTATION",
+ "modeConfig": {
+ "type": "CONFRONTATION",
+ "redTeamName": "狐狸巡检红队",
+ "blueTeamName": "狐狸巡检蓝队",
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "objective": "两队分别完成狐狸模型巡检并确认状态",
+ "winRule": "按已完成步骤与协同检查结果评分",
+ "submitPositions": [
+ "COMMANDER"
+ ],
+ "roundDuration": 7,
+ "eventInjections": ""
+ },
+ "steps": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "actionCode": "CONFRONTATION-A01",
+ "title": "识别狐狸巡检目标",
+ "summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "allowedPositions": [
+ "COMMANDER"
+ ],
+ "physicalEventRule": {},
+ "score": 50
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "actionCode": "CONFRONTATION-A02",
+ "title": "完成狐狸状态检查",
+ "summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "allowedPositions": [
+ "COMMANDER"
+ ],
+ "physicalEventRule": {},
+ "score": 50
+ }
+ ],
+ "apiTestVersionId": "216"
+ }
+ ],
+ "bugs": [],
+ "limitations": [
+ "实装为MANUAL人工事件合同测试,没有接真实PLC、摄像头、视觉识别服务或安全联锁设备。",
+ "两步狐狸巡检用于通用步骤与红蓝岗位合同,不是需要CONF-INSPECT等五个固定编码的完整排故作业;未宣称排故工作台阶段回写已打通。",
+ "对抗验证服务端设置了7分钟截止时刻,未等待自然超时触发;未把API账号请求视为多人跨浏览器实时协同UI测试。",
+ "牛奶卡车沿用原发布场景的视角,车间立柱遮挡部分车身;真实模型已渲染且封面来自该视口。",
+ "实装当前发布217仅回填兼容说明并更新封面;已完成教学任务50仍固定不可变版本215。",
+ "历史仅positionActions设置动作岗位的定义在教学读取侧与当前发布预检存在口径差异;本次按现有发布规则配置非空allowedPositions,未改后端岗位合同。",
+ "开发环境列表切换曾出现一次15秒超时,刷新后恢复;独立复测两种模式共6次查询均HTTP 200、耗时247–403ms,未复现稳定慢查询。详见list-api-latency.json。"
+ ],
+ "pageErrors": [],
+ "development": [
+ "修复模式配置被旧channelProfiles覆盖,保持默认profile与modeConfig一致;对抗分钟/秒及旧roundDuration兼容。",
+ "保留显式空配置文本;每队人数范围与服务端1–200一致,无效数字阻止发布,不静默夹断。",
+ "保留步骤设备事件、人工确认、岗位与运行编码;增删排序后重新计算严格递增进度。",
+ "修复复制步骤编码冲突(含大小写),复制动作岗位映射随新动作编码迁移。",
+ "显式清空allowedPositions不再补回默认三岗位,保持权限边界并交由正式发布校验阻断。",
+ "修复instruction/summary双界面操作说明丢失及显式空提示恢复演示内容;用户实际编辑时同步。",
+ "正式targetId优先,目标选择同步语义引用、节点与动作,防止旧targetRef把新目标回退。",
+ "沿用上轮训练3D视口截图方案:受控TRAINING_COVER上传、同次保存资产与内容,保存/发布后列表正确显示截图。",
+ "本次均为正式工程合同适配与bugfix,未新增数据库字段、执行DDL或更改原型业务流程;测试数据通过开发API创建。"
+ ],
+ "commands": [
+ {
+ "command": "pnpm run build",
+ "status": "PASS",
+ "details": "vue-tsc、Vite构建和legacy iframe分发校验均通过;仅已有包体积提示。"
+ },
+ {
+ "command": "node --test tools/training-*.test.mjs(本次8个聚焦文件)",
+ "status": "PASS",
+ "details": "94/94通过,涵盖封面、场景、真实模型、目标、元数据、模式与步骤兼容。"
+ },
+ {
+ "command": "pnpm run test:training-legacy",
+ "status": "PASS",
+ "details": "两项既有训练/地形合同检查通过。"
+ },
+ {
+ "command": "content-training-studio.spec.ts / Edge",
+ "status": "PASS",
+ "details": "7/7通过(mock API的工作台回归),无跳过、无不稳定用例,独立报告studio-regression/html/index.html。"
+ },
+ {
+ "command": "UTE2E_TRAINING_MODES=1 UTE2E_BASE_URL=http://192.168.31.168:6180 node tools/training-scene-visibility.e2e.mjs",
+ "status": "PASS",
+ "details": "独立Edge会话2/2通过,真实LAN服务、禁止业务写请求;模型可见像素差、固定场景依赖、JPEG尺寸与SHA-256全部通过。"
+ },
+ {
+ "command": "node tools/training-modes-api-live.mjs(163:215;164:216)",
+ "status": "PASS",
+ "details": "18/18检查组通过,90次正式教学API请求;含训练/场景/模型固定快照只读复核,详见api-evidence.json。"
+ }
+ ],
+ "confirmationItems": [
+ "将实装工作台从固定离线识别回放扩展为读取正式任务、已发布场景和步骤,并接入人工/设备事件、提交与结果。",
+ "将对抗默认入口从本地预设任务接入正式任务清单、创建和执行链路;确认通用巡检步骤与五阶段排故工作台的适用范围。"
+ ],
+ "finishedAt": "2026-09-05T15:58:39.239Z"
+}证据类型:API · api-evidence.json
{
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "version": 2,
+ "attemptCount": 1
+ }
+}{
+ "title": "实装任务固定发布版本、分配学员并开始",
+ "status": "PASS",
+ "details": {
+ "assignmentId": "50",
+ "run": {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "IN_PROGRESS",
+ "version": 2,
+ "progressPercent": 0,
+ "currentStepCode": "PHYSICAL-S01",
+ "score": null,
+ "passed": null
+ },
+ "content": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "rules": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100
+ }
+ ]
+ }
+}{
+ "title": "实装拒绝普通进度接口、抢跑、提前提交及错误事件身份",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "forbiddenProgress": {
+ "status": 403,
+ "message": "实装运行只能由设备、视觉或人工确认事件推进"
+ },
+ "prematureSubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "skipped": {
+ "status": 409,
+ "message": "物理事件必须匹配当前待完成步骤,不能跳步或回退"
+ },
+ "untrustedDevice": {
+ "status": 403,
+ "message": "无权执行此操作"
+ },
+ "managerManual": {
+ "status": 403,
+ "message": "当前学员不是该运行成员"
+ },
+ "progressUnchanged": 0
+ }
+}{
+ "title": "实装不匹配事件失败,必须重试后才可继续",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "outcome": "UNMATCHED",
+ "beforeRetry": {
+ "status": 409,
+ "message": "实装步骤失败后必须先重试,完成后不能再次推进"
+ },
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "IN_PROGRESS",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "PENDING",
+ "targetProgressPercent": 67,
+ "attemptCount": 0,
+ "failureCount": 0,
+ "evidenceSourceCode": "NONE"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "PENDING",
+ "targetProgressPercent": 100,
+ "attemptCount": 0,
+ "failureCount": 0,
+ "evidenceSourceCode": "NONE"
+ }
+ ]
+ }
+}{
+ "title": "实装逐步人工确认、事件幂等与载荷冲突",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "progression": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "eventId": "MANUAL-FF103EF3-B498-4EAC-8F06-42DE46CC9CE4",
+ "progress": 33,
+ "replayVersion": 5,
+ "alteredEventStatus": 409
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "eventId": "MANUAL-9D8A36B7-C161-4736-A71E-D1700E55216B",
+ "progress": 67,
+ "replayVersion": 6,
+ "alteredEventStatus": 409
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "eventId": "MANUAL-1D1ADE8F-D32B-434B-A7D8-A2571E502137",
+ "progress": 100,
+ "replayVersion": 7,
+ "alteredEventStatus": 409
+ }
+ ],
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ }
+}{
+ "title": "PHYSICAL 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "REVIEWED",
+ "version": 9,
+ "progressPercent": 100,
+ "currentStepCode": "PHYSICAL-S03",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ }
+}{
+ "title": "对抗红蓝两队各两人,按队伍共享且运行互相独立",
+ "status": "PASS",
+ "details": {
+ "assignmentId": "51",
+ "content": {
+ "projectId": "164",
+ "versionId": "216"
+ },
+ "configuration": {
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "submitPositions": [
+ "COMMANDER"
+ ]
+ },
+ "runs": [
+ {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ },
+ {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ }
+ ]
+ }
+}{
+ "title": "对抗学员不能读取另一队运行明细",
+ "status": "PASS",
+ "details": {
+ "requestingTeam": "RED",
+ "otherRunId": "46",
+ "response": {
+ "status": 403,
+ "message": "无权查看该训练运行"
+ }
+ }
+}{
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "version": 2,
+ "attemptCount": 1
+ }
+}{
+ "title": "RED 队岗位越权、跳步和提前提交均拒绝",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "wrongPosition": {
+ "status": 403,
+ "message": "当前岗位不能推进共享训练进度"
+ },
+ "skipped": {
+ "status": 400,
+ "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+ },
+ "earlySubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "deadlineAt": 1788623936,
+ "serverNow": 1788623517
+ }
+}{
+ "title": "RED 队授权动作逐步推进、重放幂等、提交负责人限制",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "progression": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "progress": 50,
+ "replayVersion": 3,
+ "changedCommandStatus": 409
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "progress": 100,
+ "replayVersion": 4,
+ "changedCommandStatus": 409
+ }
+ ],
+ "wrongSubmitPosition": {
+ "status": 403,
+ "message": "共享训练仅允许任务声明的负责人岗位提交结果"
+ }
+ }
+}{
+ "title": "CONFRONTATION 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+}{
+ "title": "红队完成不会推进蓝队进度",
+ "status": "PASS",
+ "details": {
+ "redRunId": "45",
+ "blueRun": {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ }
+ }
+}{
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "version": 2,
+ "attemptCount": 1
+ }
+}{
+ "title": "BLUE 队岗位越权、跳步和提前提交均拒绝",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "wrongPosition": {
+ "status": 403,
+ "message": "当前岗位不能推进共享训练进度"
+ },
+ "skipped": {
+ "status": 400,
+ "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+ },
+ "earlySubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "deadlineAt": 1788623942,
+ "serverNow": 1788623523
+ }
+}{
+ "title": "BLUE 队授权动作逐步推进、重放幂等、提交负责人限制",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "progression": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "progress": 50,
+ "replayVersion": 3,
+ "changedCommandStatus": 409
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "progress": 100,
+ "replayVersion": 4,
+ "changedCommandStatus": 409
+ }
+ ],
+ "wrongSubmitPosition": {
+ "status": 403,
+ "message": "共享训练仅允许任务声明的负责人岗位提交结果"
+ }
+ }
+}{
+ "title": "CONFRONTATION 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+}{
+ "title": "正式任务快照固定训练、场景与模型发布版本",
+ "status": "PASS",
+ "details": {
+ "snapshots": [
+ {
+ "assignmentId": "50",
+ "channel": "PHYSICAL",
+ "content": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "frozenScene": {
+ "projectId": "159",
+ "versionId": "209",
+ "models": [
+ {
+ "projectId": "156",
+ "versionId": "204",
+ "assetCode": "MODEL_MTO6WI6K_08-CESIUMMILKTRUCK"
+ }
+ ]
+ }
+ },
+ {
+ "assignmentId": "51",
+ "channel": "CONFRONTATION",
+ "content": {
+ "projectId": "164",
+ "versionId": "216"
+ },
+ "frozenScene": {
+ "projectId": "160",
+ "versionId": "210",
+ "models": [
+ {
+ "projectId": "157",
+ "versionId": "201",
+ "assetCode": "MODEL_MTO6V5W6_09-FOX"
+ }
+ ]
+ }
+ }
+ ]
+ }
+}{
+ "title": "实装人工事件与红蓝对抗正式 API 闭环",
+ "status": "PASS",
+ "startedAt": "2026-09-05T15:48:46.342Z",
+ "baseURL": "http://127.0.0.1:6180",
+ "method": "真实 Auth / Gateway / Tran API;新建隔离学生与开发测试任务;不连接设备、视觉推理或外部通知。",
+ "references": {
+ "PHYSICAL": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "CONFRONTATION": {
+ "projectId": "164",
+ "versionId": "216"
+ }
+ },
+ "checks": [
+ {
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "version": 2,
+ "attemptCount": 1
+ }
+ },
+ {
+ "title": "实装任务固定发布版本、分配学员并开始",
+ "status": "PASS",
+ "details": {
+ "assignmentId": "50",
+ "run": {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "IN_PROGRESS",
+ "version": 2,
+ "progressPercent": 0,
+ "currentStepCode": "PHYSICAL-S01",
+ "score": null,
+ "passed": null
+ },
+ "content": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "rules": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装拒绝普通进度接口、抢跑、提前提交及错误事件身份",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "forbiddenProgress": {
+ "status": 403,
+ "message": "实装运行只能由设备、视觉或人工确认事件推进"
+ },
+ "prematureSubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "skipped": {
+ "status": 409,
+ "message": "物理事件必须匹配当前待完成步骤,不能跳步或回退"
+ },
+ "untrustedDevice": {
+ "status": 403,
+ "message": "无权执行此操作"
+ },
+ "managerManual": {
+ "status": 403,
+ "message": "当前学员不是该运行成员"
+ },
+ "progressUnchanged": 0
+ }
+ },
+ {
+ "title": "实装不匹配事件失败,必须重试后才可继续",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "outcome": "UNMATCHED",
+ "beforeRetry": {
+ "status": 409,
+ "message": "实装步骤失败后必须先重试,完成后不能再次推进"
+ },
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "IN_PROGRESS",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "PENDING",
+ "targetProgressPercent": 67,
+ "attemptCount": 0,
+ "failureCount": 0,
+ "evidenceSourceCode": "NONE"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "PENDING",
+ "targetProgressPercent": 100,
+ "attemptCount": 0,
+ "failureCount": 0,
+ "evidenceSourceCode": "NONE"
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装逐步人工确认、事件幂等与载荷冲突",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "progression": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "eventId": "MANUAL-FF103EF3-B498-4EAC-8F06-42DE46CC9CE4",
+ "progress": 33,
+ "replayVersion": 5,
+ "alteredEventStatus": 409
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "eventId": "MANUAL-9D8A36B7-C161-4736-A71E-D1700E55216B",
+ "progress": 67,
+ "replayVersion": 6,
+ "alteredEventStatus": 409
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "eventId": "MANUAL-1D1ADE8F-D32B-434B-A7D8-A2571E502137",
+ "progress": 100,
+ "replayVersion": 7,
+ "alteredEventStatus": 409
+ }
+ ],
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ }
+ },
+ {
+ "title": "PHYSICAL 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "REVIEWED",
+ "version": 9,
+ "progressPercent": 100,
+ "currentStepCode": "PHYSICAL-S03",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ }
+ },
+ {
+ "title": "对抗红蓝两队各两人,按队伍共享且运行互相独立",
+ "status": "PASS",
+ "details": {
+ "assignmentId": "51",
+ "content": {
+ "projectId": "164",
+ "versionId": "216"
+ },
+ "configuration": {
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "submitPositions": [
+ "COMMANDER"
+ ]
+ },
+ "runs": [
+ {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ },
+ {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ }
+ ]
+ }
+ },
+ {
+ "title": "对抗学员不能读取另一队运行明细",
+ "status": "PASS",
+ "details": {
+ "requestingTeam": "RED",
+ "otherRunId": "46",
+ "response": {
+ "status": 403,
+ "message": "无权查看该训练运行"
+ }
+ }
+ },
+ {
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "version": 2,
+ "attemptCount": 1
+ }
+ },
+ {
+ "title": "RED 队岗位越权、跳步和提前提交均拒绝",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "wrongPosition": {
+ "status": 403,
+ "message": "当前岗位不能推进共享训练进度"
+ },
+ "skipped": {
+ "status": 400,
+ "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+ },
+ "earlySubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "deadlineAt": 1788623936,
+ "serverNow": 1788623517
+ }
+ },
+ {
+ "title": "RED 队授权动作逐步推进、重放幂等、提交负责人限制",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "progression": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "progress": 50,
+ "replayVersion": 3,
+ "changedCommandStatus": 409
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "progress": 100,
+ "replayVersion": 4,
+ "changedCommandStatus": 409
+ }
+ ],
+ "wrongSubmitPosition": {
+ "status": 403,
+ "message": "共享训练仅允许任务声明的负责人岗位提交结果"
+ }
+ }
+ },
+ {
+ "title": "CONFRONTATION 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+ },
+ {
+ "title": "红队完成不会推进蓝队进度",
+ "status": "PASS",
+ "details": {
+ "redRunId": "45",
+ "blueRun": {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ }
+ }
+ },
+ {
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "version": 2,
+ "attemptCount": 1
+ }
+ },
+ {
+ "title": "BLUE 队岗位越权、跳步和提前提交均拒绝",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "wrongPosition": {
+ "status": 403,
+ "message": "当前岗位不能推进共享训练进度"
+ },
+ "skipped": {
+ "status": 400,
+ "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+ },
+ "earlySubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "deadlineAt": 1788623942,
+ "serverNow": 1788623523
+ }
+ },
+ {
+ "title": "BLUE 队授权动作逐步推进、重放幂等、提交负责人限制",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "progression": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "progress": 50,
+ "replayVersion": 3,
+ "changedCommandStatus": 409
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "progress": 100,
+ "replayVersion": 4,
+ "changedCommandStatus": 409
+ }
+ ],
+ "wrongSubmitPosition": {
+ "status": 403,
+ "message": "共享训练仅允许任务声明的负责人岗位提交结果"
+ }
+ }
+ },
+ {
+ "title": "CONFRONTATION 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+ },
+ {
+ "title": "正式任务快照固定训练、场景与模型发布版本",
+ "status": "PASS",
+ "details": {
+ "snapshots": [
+ {
+ "assignmentId": "50",
+ "channel": "PHYSICAL",
+ "content": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "frozenScene": {
+ "projectId": "159",
+ "versionId": "209",
+ "models": [
+ {
+ "projectId": "156",
+ "versionId": "204",
+ "assetCode": "MODEL_MTO6WI6K_08-CESIUMMILKTRUCK"
+ }
+ ]
+ }
+ },
+ {
+ "assignmentId": "51",
+ "channel": "CONFRONTATION",
+ "content": {
+ "projectId": "164",
+ "versionId": "216"
+ },
+ "frozenScene": {
+ "projectId": "160",
+ "versionId": "210",
+ "models": [
+ {
+ "projectId": "157",
+ "versionId": "201",
+ "assetCode": "MODEL_MTO6V5W6_09-FOX"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "assignments": [
+ {
+ "id": "50",
+ "code": "UTE2E-MODES-MTOK5BBA-PHYSICAL",
+ "channel": "PHYSICAL",
+ "contentProjectId": "163",
+ "contentVersionId": "215",
+ "status": "PUBLISHED",
+ "version": 1,
+ "frozenScene": {
+ "projectId": "159",
+ "versionId": "209",
+ "models": [
+ {
+ "projectId": "156",
+ "versionId": "204",
+ "assetCode": "MODEL_MTO6WI6K_08-CESIUMMILKTRUCK"
+ }
+ ]
+ }
+ },
+ {
+ "id": "51",
+ "code": "UTE2E-MODES-MTOK5BBA-CONFRONTATION",
+ "channel": "CONFRONTATION",
+ "contentProjectId": "164",
+ "contentVersionId": "216",
+ "status": "PUBLISHED",
+ "version": 1,
+ "frozenScene": {
+ "projectId": "160",
+ "versionId": "210",
+ "models": [
+ {
+ "projectId": "157",
+ "versionId": "201",
+ "assetCode": "MODEL_MTO6V5W6_09-FOX"
+ }
+ ]
+ }
+ }
+ ],
+ "runs": [
+ {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "REVIEWED",
+ "version": 9,
+ "progressPercent": 100,
+ "currentStepCode": "PHYSICAL-S03",
+ "score": 100,
+ "passed": true,
+ "channel": "PHYSICAL",
+ "team": "NEUTRAL",
+ "stepFacts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ },
+ {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true,
+ "channel": "CONFRONTATION",
+ "team": "RED",
+ "stepFacts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ },
+ {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true,
+ "channel": "CONFRONTATION",
+ "team": "BLUE",
+ "stepFacts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+ ],
+ "actors": [
+ {
+ "userId": "95",
+ "team": "RED",
+ "position": "COMMANDER",
+ "departmentId": "5"
+ },
+ {
+ "userId": "96",
+ "team": "RED",
+ "position": "OPERATOR",
+ "departmentId": "5"
+ },
+ {
+ "userId": "97",
+ "team": "BLUE",
+ "position": "COMMANDER",
+ "departmentId": "5"
+ },
+ {
+ "userId": "98",
+ "team": "BLUE",
+ "position": "OPERATOR",
+ "departmentId": "5"
+ }
+ ],
+ "requests": [
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/50/publish",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/50/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/start",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/44/progress",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/submit",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 403
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/review",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/review",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/publish",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs?assignmentId=51&size=100",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/start",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45/execution",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 403
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 400
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 409
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 409
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/review",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/review",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/start",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46/execution",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 403
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 400
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 409
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 409
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/review",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/review",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/assignments/50",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/assignments/51",
+ "status": 200
+ }
+ ],
+ "limitations": [
+ "实装事件均为本次自动化提交的 MANUAL 人工确认或明确的权限拒绝测试,不代表真实设备、PLC、摄像机或视觉模型联调。",
+ "教学运行结果来自正式 API;默认实装离线回放和默认对抗本地工作台的显示另行记录,不混同为服务端运行闭环。",
+ "对抗按四名隔离学员的独立会话依次操作,检查服务端截止时间已生效;未等待整局自然超时,也不代表多人同时操作的压力或网络同步测试。",
+ "测试账号使用本次随机密码,密码与会话仅在运行内存中;报告只保留用户 ID、队伍和岗位以便追溯。"
+ ],
+ "finishedAt": "2026-09-05T15:58:56.293Z",
+ "retainedData": {
+ "assignments": [
+ "50",
+ "51"
+ ],
+ "runs": [
+ "44",
+ "45",
+ "46"
+ ],
+ "users": [
+ "95",
+ "96",
+ "97",
+ "98"
+ ],
+ "note": "仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。"
+ }
+}vue-tsc、Vite构建和legacy iframe分发校验均通过;仅已有包体积提示。
94/94通过,涵盖封面、场景、真实模型、目标、元数据、模式与步骤兼容。
两项既有训练/地形合同检查通过。
7/7通过(mock API的工作台回归),无跳过、无不稳定用例,独立报告studio-regression/html/index.html。
独立Edge会话2/2通过,真实LAN服务、禁止业务写请求;模型可见像素差、固定场景依赖、JPEG尺寸与SHA-256全部通过。
18/18检查组通过,90次正式教学API请求;含训练/场景/模型固定快照只读复核,详见api-evidence.json。
截图只来自实际存在的文件;API 请求结果单列,不以 UI 截图代替。

























{
+ "title": "实装实训与对抗训练真实流程测试",
+ "startedAt": "2026-09-05T15:37:32.805Z",
+ "status": "PASS_WITH_LIMITATIONS",
+ "checks": [
+ {
+ "title": "实装:创建、绑定牛奶卡车、3步配置、保存重开与发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-settings-reopen.png",
+ "screenshots/physical-authoring.png",
+ "screenshots/physical-preflight.png",
+ "screenshots/physical-published.png"
+ ],
+ "details": {
+ "id": "163",
+ "version": "215",
+ "progress": [
+ 33,
+ 67,
+ 100
+ ],
+ "profile": {
+ "type": "PHYSICAL",
+ "stationName": "牛奶卡车测试工位 A",
+ "equipmentCode": "E2E-TRUCK-163",
+ "gatewayProtocol": "HTTP 测试网关",
+ "sensorChannels": "人工外观确认;测试状态点位",
+ "safetyInterlocks": "测试工位隔离确认"
+ }
+ }
+ },
+ {
+ "title": "对抗:每队人数为小数时阻止发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-invalid-team-size.png"
+ ],
+ "details": {
+ "input": 2.5,
+ "message": "发布校验未通过\n×\n发现 1 个必须处理的问题\n\n2 个步骤 · 1 个目标 · 0 个故障 · 及格线 80 分\n\n!\n每队人数必须是 1 到 200 的整数\nmodeConfig.teamSize\ni\n本步骤尚未配置必选安全前置条件\nsteps[0].safetyRules\ni\n本步骤尚未配置必选安全前置条件\nsteps[1].safetyRules\ni\n尚未绑定离线讲解文件,运行时将保留字幕并使用默认本地语音\nsteps.audio\n关闭"
+ }
+ },
+ {
+ "title": "对抗:显式清空岗位后保存重开,不恢复默认授权",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-empty-positions.png"
+ ],
+ "details": {
+ "allowedPositions": [],
+ "published": false
+ }
+ },
+ {
+ "title": "对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-positions.png",
+ "screenshots/confrontation-settings-reopen.png",
+ "screenshots/confrontation-authoring.png",
+ "screenshots/confrontation-preflight.png",
+ "screenshots/confrontation-published.png"
+ ],
+ "details": {
+ "id": "164",
+ "version": "216",
+ "profile": {
+ "type": "CONFRONTATION",
+ "redTeamName": "狐狸巡检红队",
+ "blueTeamName": "狐狸巡检蓝队",
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "objective": "两队分别完成狐狸模型巡检并确认状态",
+ "winRule": "按已完成步骤与协同检查结果评分",
+ "submitPositions": [
+ "COMMANDER"
+ ],
+ "roundDuration": 7,
+ "eventInjections": ""
+ },
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
+ "allowedPositions": [
+ "COMMANDER"
+ ]
+ },
+ {
+ "title": "完成狐狸状态检查",
+ "summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
+ "allowedPositions": [
+ "COMMANDER"
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装:说明兼容修复回写,重新截图封面及发布",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-final-published.png"
+ ],
+ "details": {
+ "projectId": "163",
+ "publishedVersionId": "217",
+ "apiTestVersionId": "215",
+ "note": "既有任务50固定215;本次仅回填说明与更新截图封面,未改写任务快照。",
+ "steps": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "actionCode": "PHYSICAL-A01",
+ "summary": "核对测试工位与牛奶卡车编号。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33,
+ "manualConfirmAllowed": true
+ }
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "actionCode": "PHYSICAL-A02",
+ "summary": "人工确认卡车外观状态,使用受控事件记录检查。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67,
+ "manualConfirmAllowed": true
+ }
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "actionCode": "PHYSICAL-A03",
+ "summary": "核实步骤记录并提交检查结果。",
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100,
+ "manualConfirmAllowed": true
+ }
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤",
+ "status": "BLOCKED",
+ "screenshots": [
+ "screenshots/physical-teaching-list.png",
+ "screenshots/physical-offline-preview.png",
+ "screenshots/physical-offline-confirmed.png"
+ ],
+ "details": {
+ "assignmentId": "50",
+ "previewUrl": "/teaching/physical-training/preview/50",
+ "observed": "进入任务后仍显示固定45秒维修识别录像,确认只改变本次页面候选,未加载卡车三步正式运行。",
+ "businessWrites": [],
+ "required": "需将正式任务快照、场景、人工/设备事件及运行记录接入工作台。"
+ }
+ },
+ {
+ "title": "对抗默认页面:新建下拉仅有本地液压预设,无法选择已发布狐狸训练",
+ "status": "BLOCKED",
+ "screenshots": [
+ "screenshots/confrontation-final-entry.png",
+ "screenshots/confrontation-final-local-create.png"
+ ],
+ "details": {
+ "options": [
+ "桥梁架设装备液压系统检测与维修训练"
+ ],
+ "formalAssignmentId": "51",
+ "formalAssignmentExists": true,
+ "required": "需将默认任务清单、创建表单和执行入口接入正式对抗任务服务,并确认通用巡检与五阶段排故的适用范围。"
+ }
+ },
+ {
+ "title": "训练列表两种模式展示各自真实截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-list-cover.png",
+ "screenshots/confrontation-list-cover.png"
+ ],
+ "details": {
+ "projects": [
+ "163",
+ "164"
+ ],
+ "authenticatedImagesLoaded": true
+ }
+ },
+ {
+ "title": "牛奶卡车实装检查:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/physical-independent-reopen.png",
+ "screenshots/physical-independent-settings.png",
+ "screenshots/physical-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 5,
+ "visibleMeshes": 5,
+ "skinnedMeshes": 0,
+ "position": [
+ 4.2,
+ 0.20617168998977797,
+ -0.4356763541543951
+ ],
+ "scale": [
+ 1.2323086149245381,
+ 1.2323086149245381,
+ 1.2323086149245381
+ ],
+ "finiteBounds": true,
+ "size": [
+ 3.4406057210321332,
+ 3.390913184034896,
+ 6.000000000000001
+ ],
+ "camera": {
+ "position": [
+ 10.565439077211714,
+ 5.0456876852867705,
+ 7.37050840519251
+ ],
+ "target": [
+ 4.2,
+ 1.695456592017448,
+ 0
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 11669
+ },
+ "scene": {
+ "projectId": "159",
+ "versionId": "209"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605199171-2",
+ "name": "牛奶卡车检验主装备",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-1ba581e6",
+ "name": "Yup2Zup",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-004dec41",
+ "name": "Cesium_Milk_Truck",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5f7e4bee",
+ "name": "Cesium_Milk_Truck_1",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-607e4d81",
+ "name": "Cesium_Milk_Truck_2",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5d7e48c8",
+ "name": "Cesium_Milk_Truck_3",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-5e7e4a5b",
+ "name": "Node",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-ce2feac4",
+ "name": "Wheels",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-637e523a",
+ "name": "Node001",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605199171-2/part/interaction-f417f82d",
+ "name": "Wheels001",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "核对卡车工位与装备",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 30
+ },
+ {
+ "title": "检查卡车外观状态",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 30
+ },
+ {
+ "title": "确认检查完成",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "weight": 40
+ }
+ ],
+ "cover": {
+ "assetId": "2111",
+ "bytes": 42163,
+ "sha256": "efb7060464f840bde78744d89567c609dc7b7922216d1c948fe22c1eed3d41a4",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ },
+ {
+ "title": "狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面",
+ "status": "PASS",
+ "screenshots": [
+ "screenshots/confrontation-independent-reopen.png",
+ "screenshots/confrontation-independent-settings.png",
+ "screenshots/confrontation-final-cover.jpg"
+ ],
+ "details": {
+ "state": "ready",
+ "meshes": 1,
+ "visibleMeshes": 1,
+ "skinnedMeshes": 1,
+ "position": [
+ -7.5,
+ 0.10930174852949677,
+ 0.3440109341705351
+ ],
+ "scale": [
+ 0.03616375932570189,
+ 0.03616375932570189,
+ 0.03616375932570189
+ ],
+ "finiteBounds": true,
+ "size": [
+ 0.9658796100519353,
+ 2.811899945787403,
+ 6
+ ],
+ "camera": {
+ "position": [
+ -0.04179225073787585,
+ 6.099634646023559,
+ 17.355036987378313
+ ],
+ "target": [
+ -7.512232860974557,
+ 1.4059499759307126,
+ -2.2204460492503126e-16
+ ]
+ },
+ "sample": {
+ "width": 320,
+ "height": 180,
+ "changedPixels": 827
+ },
+ "scene": {
+ "projectId": "160",
+ "versionId": "210"
+ },
+ "targets": [
+ {
+ "id": "scene:OBJ-1788605873329-2",
+ "name": "维修装备主体 02",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6",
+ "name": "狐狸野外巡检目标",
+ "source": "scene"
+ },
+ {
+ "id": "scene:OBJ-1788605916565-6/part/interaction-a3d35c26",
+ "name": "fox",
+ "source": "scene"
+ }
+ ],
+ "steps": [
+ {
+ "title": "识别狐狸巡检目标",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ },
+ {
+ "title": "完成狐狸状态检查",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "weight": 50
+ }
+ ],
+ "cover": {
+ "assetId": "2108",
+ "bytes": 51257,
+ "sha256": "79779a9d1e40c0c160fa3dab25e83931639942f53e2ee3a5da12f9f86ad70081",
+ "width": 628,
+ "height": 640
+ },
+ "pageErrors": [],
+ "blockedWrites": []
+ }
+ }
+ ],
+ "projects": [
+ {
+ "id": "163",
+ "name": "实测训练-牛奶卡车实装检查-20260905",
+ "mode": "PHYSICAL",
+ "status": "PUBLISHED",
+ "currentVersionId": "217",
+ "publishedVersionId": "217",
+ "sceneProjectId": "159",
+ "sceneVersionId": "209",
+ "stepCount": 3,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2111",
+ "image": "screenshots/physical-final-cover.jpg"
+ },
+ "trainingMode": "PHYSICAL",
+ "modeConfig": {
+ "type": "PHYSICAL",
+ "stationName": "牛奶卡车测试工位 A",
+ "equipmentCode": "E2E-TRUCK-163",
+ "gatewayProtocol": "HTTP 测试网关",
+ "sensorChannels": "人工外观确认;测试状态点位",
+ "safetyInterlocks": "测试工位隔离确认"
+ },
+ "steps": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "actionCode": "PHYSICAL-A01",
+ "title": "核对卡车工位与装备",
+ "summary": "核对测试工位与牛奶卡车编号。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33,
+ "manualConfirmAllowed": true
+ },
+ "score": 30
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "actionCode": "PHYSICAL-A02",
+ "title": "检查卡车外观状态",
+ "summary": "人工确认卡车外观状态,使用受控事件记录检查。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67,
+ "manualConfirmAllowed": true
+ },
+ "score": 30
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "actionCode": "PHYSICAL-A03",
+ "title": "确认检查完成",
+ "summary": "核实步骤记录并提交检查结果。",
+ "targetId": "scene:OBJ-1788605199171-2",
+ "allowedPositions": [],
+ "physicalEventRule": {
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100,
+ "manualConfirmAllowed": true
+ },
+ "score": 40
+ }
+ ],
+ "apiTestVersionId": "215",
+ "apiVersionNote": "API任务50固定215;说明字段兼容修复后在217做UI及封面回归,未改写任务快照。"
+ },
+ {
+ "id": "164",
+ "name": "实测训练-狐狸红蓝协同巡检-20260905",
+ "mode": "CONFRONTATION",
+ "status": "PUBLISHED",
+ "currentVersionId": "216",
+ "publishedVersionId": "216",
+ "sceneProjectId": "160",
+ "sceneVersionId": "210",
+ "stepCount": 2,
+ "totalScore": 100,
+ "cover": {
+ "assetId": "2108",
+ "image": "screenshots/confrontation-final-cover.jpg"
+ },
+ "trainingMode": "CONFRONTATION",
+ "modeConfig": {
+ "type": "CONFRONTATION",
+ "redTeamName": "狐狸巡检红队",
+ "blueTeamName": "狐狸巡检蓝队",
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "objective": "两队分别完成狐狸模型巡检并确认状态",
+ "winRule": "按已完成步骤与协同检查结果评分",
+ "submitPositions": [
+ "COMMANDER"
+ ],
+ "roundDuration": 7,
+ "eventInjections": ""
+ },
+ "steps": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "actionCode": "CONFRONTATION-A01",
+ "title": "识别狐狸巡检目标",
+ "summary": "红蓝两队分别识别场景中的狐狸模型,由指挥员确认目标。",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "allowedPositions": [
+ "COMMANDER"
+ ],
+ "physicalEventRule": {},
+ "score": 50
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "actionCode": "CONFRONTATION-A02",
+ "title": "完成狐狸状态检查",
+ "summary": "由指挥员完成状态确认,操作员协同观察;提交团队检查结果。",
+ "targetId": "scene:OBJ-1788605916565-6",
+ "allowedPositions": [
+ "COMMANDER"
+ ],
+ "physicalEventRule": {},
+ "score": 50
+ }
+ ],
+ "apiTestVersionId": "216"
+ }
+ ],
+ "bugs": [],
+ "limitations": [
+ "实装为MANUAL人工事件合同测试,没有接真实PLC、摄像头、视觉识别服务或安全联锁设备。",
+ "两步狐狸巡检用于通用步骤与红蓝岗位合同,不是需要CONF-INSPECT等五个固定编码的完整排故作业;未宣称排故工作台阶段回写已打通。",
+ "对抗验证服务端设置了7分钟截止时刻,未等待自然超时触发;未把API账号请求视为多人跨浏览器实时协同UI测试。",
+ "牛奶卡车沿用原发布场景的视角,车间立柱遮挡部分车身;真实模型已渲染且封面来自该视口。",
+ "实装当前发布217仅回填兼容说明并更新封面;已完成教学任务50仍固定不可变版本215。",
+ "历史仅positionActions设置动作岗位的定义在教学读取侧与当前发布预检存在口径差异;本次按现有发布规则配置非空allowedPositions,未改后端岗位合同。",
+ "开发环境列表切换曾出现一次15秒超时,刷新后恢复;独立复测两种模式共6次查询均HTTP 200、耗时247–403ms,未复现稳定慢查询。详见list-api-latency.json。"
+ ],
+ "pageErrors": [],
+ "development": [
+ "修复模式配置被旧channelProfiles覆盖,保持默认profile与modeConfig一致;对抗分钟/秒及旧roundDuration兼容。",
+ "保留显式空配置文本;每队人数范围与服务端1–200一致,无效数字阻止发布,不静默夹断。",
+ "保留步骤设备事件、人工确认、岗位与运行编码;增删排序后重新计算严格递增进度。",
+ "修复复制步骤编码冲突(含大小写),复制动作岗位映射随新动作编码迁移。",
+ "显式清空allowedPositions不再补回默认三岗位,保持权限边界并交由正式发布校验阻断。",
+ "修复instruction/summary双界面操作说明丢失及显式空提示恢复演示内容;用户实际编辑时同步。",
+ "正式targetId优先,目标选择同步语义引用、节点与动作,防止旧targetRef把新目标回退。",
+ "沿用上轮训练3D视口截图方案:受控TRAINING_COVER上传、同次保存资产与内容,保存/发布后列表正确显示截图。",
+ "本次均为正式工程合同适配与bugfix,未新增数据库字段、执行DDL或更改原型业务流程;测试数据通过开发API创建。"
+ ],
+ "commands": [
+ {
+ "command": "pnpm run build",
+ "status": "PASS",
+ "details": "vue-tsc、Vite构建和legacy iframe分发校验均通过;仅已有包体积提示。"
+ },
+ {
+ "command": "node --test tools/training-*.test.mjs(本次8个聚焦文件)",
+ "status": "PASS",
+ "details": "94/94通过,涵盖封面、场景、真实模型、目标、元数据、模式与步骤兼容。"
+ },
+ {
+ "command": "pnpm run test:training-legacy",
+ "status": "PASS",
+ "details": "两项既有训练/地形合同检查通过。"
+ },
+ {
+ "command": "content-training-studio.spec.ts / Edge",
+ "status": "PASS",
+ "details": "7/7通过(mock API的工作台回归),无跳过、无不稳定用例,独立报告studio-regression/html/index.html。"
+ },
+ {
+ "command": "UTE2E_TRAINING_MODES=1 UTE2E_BASE_URL=http://192.168.31.168:6180 node tools/training-scene-visibility.e2e.mjs",
+ "status": "PASS",
+ "details": "独立Edge会话2/2通过,真实LAN服务、禁止业务写请求;模型可见像素差、固定场景依赖、JPEG尺寸与SHA-256全部通过。"
+ },
+ {
+ "command": "node tools/training-modes-api-live.mjs(163:215;164:216)",
+ "status": "PASS",
+ "details": "18/18检查组通过,90次正式教学API请求;含训练/场景/模型固定快照只读复核,详见api-evidence.json。"
+ }
+ ],
+ "confirmationItems": [
+ "将实装工作台从固定离线识别回放扩展为读取正式任务、已发布场景和步骤,并接入人工/设备事件、提交与结果。",
+ "将对抗默认入口从本地预设任务接入正式任务清单、创建和执行链路;确认通用巡检步骤与五阶段排故工作台的适用范围。"
+ ],
+ "finishedAt": "2026-09-05T15:58:39.239Z"
+}
+
+{
+ "title": "实装人工事件与红蓝对抗正式 API 闭环",
+ "status": "PASS",
+ "startedAt": "2026-09-05T15:48:46.342Z",
+ "baseURL": "http://127.0.0.1:6180",
+ "method": "真实 Auth / Gateway / Tran API;新建隔离学生与开发测试任务;不连接设备、视觉推理或外部通知。",
+ "references": {
+ "PHYSICAL": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "CONFRONTATION": {
+ "projectId": "164",
+ "versionId": "216"
+ }
+ },
+ "checks": [
+ {
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "version": 2,
+ "attemptCount": 1
+ }
+ },
+ {
+ "title": "实装任务固定发布版本、分配学员并开始",
+ "status": "PASS",
+ "details": {
+ "assignmentId": "50",
+ "run": {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "IN_PROGRESS",
+ "version": 2,
+ "progressPercent": 0,
+ "currentStepCode": "PHYSICAL-S01",
+ "score": null,
+ "passed": null
+ },
+ "content": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "rules": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "source": "MANUAL",
+ "eventType": "step.physical-s01.confirmed",
+ "progressPercent": 33
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "source": "MANUAL",
+ "eventType": "step.physical-s02.confirmed",
+ "progressPercent": 67
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "source": "MANUAL",
+ "eventType": "step.physical-s03.confirmed",
+ "progressPercent": 100
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装拒绝普通进度接口、抢跑、提前提交及错误事件身份",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "forbiddenProgress": {
+ "status": 403,
+ "message": "实装运行只能由设备、视觉或人工确认事件推进"
+ },
+ "prematureSubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "skipped": {
+ "status": 409,
+ "message": "物理事件必须匹配当前待完成步骤,不能跳步或回退"
+ },
+ "untrustedDevice": {
+ "status": 403,
+ "message": "无权执行此操作"
+ },
+ "managerManual": {
+ "status": 403,
+ "message": "当前学员不是该运行成员"
+ },
+ "progressUnchanged": 0
+ }
+ },
+ {
+ "title": "实装不匹配事件失败,必须重试后才可继续",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "outcome": "UNMATCHED",
+ "beforeRetry": {
+ "status": 409,
+ "message": "实装步骤失败后必须先重试,完成后不能再次推进"
+ },
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "IN_PROGRESS",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "PENDING",
+ "targetProgressPercent": 67,
+ "attemptCount": 0,
+ "failureCount": 0,
+ "evidenceSourceCode": "NONE"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "PENDING",
+ "targetProgressPercent": 100,
+ "attemptCount": 0,
+ "failureCount": 0,
+ "evidenceSourceCode": "NONE"
+ }
+ ]
+ }
+ },
+ {
+ "title": "实装逐步人工确认、事件幂等与载荷冲突",
+ "status": "PASS",
+ "details": {
+ "runId": "44",
+ "progression": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "eventId": "MANUAL-FF103EF3-B498-4EAC-8F06-42DE46CC9CE4",
+ "progress": 33,
+ "replayVersion": 5,
+ "alteredEventStatus": 409
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "eventId": "MANUAL-9D8A36B7-C161-4736-A71E-D1700E55216B",
+ "progress": 67,
+ "replayVersion": 6,
+ "alteredEventStatus": 409
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "eventId": "MANUAL-1D1ADE8F-D32B-434B-A7D8-A2571E502137",
+ "progress": 100,
+ "replayVersion": 7,
+ "alteredEventStatus": 409
+ }
+ ],
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ }
+ },
+ {
+ "title": "PHYSICAL 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "REVIEWED",
+ "version": 9,
+ "progressPercent": 100,
+ "currentStepCode": "PHYSICAL-S03",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ }
+ },
+ {
+ "title": "对抗红蓝两队各两人,按队伍共享且运行互相独立",
+ "status": "PASS",
+ "details": {
+ "assignmentId": "51",
+ "content": {
+ "projectId": "164",
+ "versionId": "216"
+ },
+ "configuration": {
+ "teamSize": 2,
+ "roundDurationMinutes": 7,
+ "submitPositions": [
+ "COMMANDER"
+ ]
+ },
+ "runs": [
+ {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ },
+ {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ }
+ ]
+ }
+ },
+ {
+ "title": "对抗学员不能读取另一队运行明细",
+ "status": "PASS",
+ "details": {
+ "requestingTeam": "RED",
+ "otherRunId": "46",
+ "response": {
+ "status": 403,
+ "message": "无权查看该训练运行"
+ }
+ }
+ },
+ {
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "version": 2,
+ "attemptCount": 1
+ }
+ },
+ {
+ "title": "RED 队岗位越权、跳步和提前提交均拒绝",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "wrongPosition": {
+ "status": 403,
+ "message": "当前岗位不能推进共享训练进度"
+ },
+ "skipped": {
+ "status": 400,
+ "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+ },
+ "earlySubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "deadlineAt": 1788623936,
+ "serverNow": 1788623517
+ }
+ },
+ {
+ "title": "RED 队授权动作逐步推进、重放幂等、提交负责人限制",
+ "status": "PASS",
+ "details": {
+ "runId": "45",
+ "progression": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "progress": 50,
+ "replayVersion": 3,
+ "changedCommandStatus": 409
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "progress": 100,
+ "replayVersion": 4,
+ "changedCommandStatus": 409
+ }
+ ],
+ "wrongSubmitPosition": {
+ "status": 403,
+ "message": "共享训练仅允许任务声明的负责人岗位提交结果"
+ }
+ }
+ },
+ {
+ "title": "CONFRONTATION 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+ },
+ {
+ "title": "红队完成不会推进蓝队进度",
+ "status": "PASS",
+ "details": {
+ "redRunId": "45",
+ "blueRun": {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "ACCEPTED",
+ "version": 1,
+ "progressPercent": 0,
+ "currentStepCode": "",
+ "score": null,
+ "passed": null
+ }
+ }
+ },
+ {
+ "title": "开始命令重放不会重复激活或递增次数",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "version": 2,
+ "attemptCount": 1
+ }
+ },
+ {
+ "title": "BLUE 队岗位越权、跳步和提前提交均拒绝",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "wrongPosition": {
+ "status": 403,
+ "message": "当前岗位不能推进共享训练进度"
+ },
+ "skipped": {
+ "status": 400,
+ "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+ },
+ "earlySubmit": {
+ "status": 409,
+ "message": "训练步骤尚未全部完成,不能提交"
+ },
+ "deadlineAt": 1788623942,
+ "serverNow": 1788623523
+ }
+ },
+ {
+ "title": "BLUE 队授权动作逐步推进、重放幂等、提交负责人限制",
+ "status": "PASS",
+ "details": {
+ "runId": "46",
+ "progression": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "progress": 50,
+ "replayVersion": 3,
+ "changedCommandStatus": 409
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "progress": 100,
+ "replayVersion": 4,
+ "changedCommandStatus": 409
+ }
+ ],
+ "wrongSubmitPosition": {
+ "status": 403,
+ "message": "共享训练仅允许任务声明的负责人岗位提交结果"
+ }
+ }
+ },
+ {
+ "title": "CONFRONTATION 提交、教员评定与结果回读",
+ "status": "PASS",
+ "details": {
+ "run": {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true
+ },
+ "submitReplay": true,
+ "reviewReplay": true,
+ "facts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+ },
+ {
+ "title": "正式任务快照固定训练、场景与模型发布版本",
+ "status": "PASS",
+ "details": {
+ "snapshots": [
+ {
+ "assignmentId": "50",
+ "channel": "PHYSICAL",
+ "content": {
+ "projectId": "163",
+ "versionId": "215"
+ },
+ "frozenScene": {
+ "projectId": "159",
+ "versionId": "209",
+ "models": [
+ {
+ "projectId": "156",
+ "versionId": "204",
+ "assetCode": "MODEL_MTO6WI6K_08-CESIUMMILKTRUCK"
+ }
+ ]
+ }
+ },
+ {
+ "assignmentId": "51",
+ "channel": "CONFRONTATION",
+ "content": {
+ "projectId": "164",
+ "versionId": "216"
+ },
+ "frozenScene": {
+ "projectId": "160",
+ "versionId": "210",
+ "models": [
+ {
+ "projectId": "157",
+ "versionId": "201",
+ "assetCode": "MODEL_MTO6V5W6_09-FOX"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "assignments": [
+ {
+ "id": "50",
+ "code": "UTE2E-MODES-MTOK5BBA-PHYSICAL",
+ "channel": "PHYSICAL",
+ "contentProjectId": "163",
+ "contentVersionId": "215",
+ "status": "PUBLISHED",
+ "version": 1,
+ "frozenScene": {
+ "projectId": "159",
+ "versionId": "209",
+ "models": [
+ {
+ "projectId": "156",
+ "versionId": "204",
+ "assetCode": "MODEL_MTO6WI6K_08-CESIUMMILKTRUCK"
+ }
+ ]
+ }
+ },
+ {
+ "id": "51",
+ "code": "UTE2E-MODES-MTOK5BBA-CONFRONTATION",
+ "channel": "CONFRONTATION",
+ "contentProjectId": "164",
+ "contentVersionId": "216",
+ "status": "PUBLISHED",
+ "version": 1,
+ "frozenScene": {
+ "projectId": "160",
+ "versionId": "210",
+ "models": [
+ {
+ "projectId": "157",
+ "versionId": "201",
+ "assetCode": "MODEL_MTO6V5W6_09-FOX"
+ }
+ ]
+ }
+ }
+ ],
+ "runs": [
+ {
+ "id": "44",
+ "assignmentId": "50",
+ "status": "REVIEWED",
+ "version": 9,
+ "progressPercent": 100,
+ "currentStepCode": "PHYSICAL-S03",
+ "score": 100,
+ "passed": true,
+ "channel": "PHYSICAL",
+ "team": "NEUTRAL",
+ "stepFacts": [
+ {
+ "stepCode": "PHYSICAL-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 33,
+ "attemptCount": 2,
+ "failureCount": 1,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 67,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ },
+ {
+ "stepCode": "PHYSICAL-S03",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "MANUAL"
+ }
+ ]
+ },
+ {
+ "id": "45",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true,
+ "channel": "CONFRONTATION",
+ "team": "RED",
+ "stepFacts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ },
+ {
+ "id": "46",
+ "assignmentId": "51",
+ "status": "REVIEWED",
+ "version": 6,
+ "progressPercent": 100,
+ "currentStepCode": "CONFRONTATION-S02",
+ "score": 100,
+ "passed": true,
+ "channel": "CONFRONTATION",
+ "team": "BLUE",
+ "stepFacts": [
+ {
+ "stepCode": "CONFRONTATION-S01",
+ "status": "COMPLETED",
+ "targetProgressPercent": 50,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ },
+ {
+ "stepCode": "CONFRONTATION-S02",
+ "status": "COMPLETED",
+ "targetProgressPercent": 100,
+ "attemptCount": 1,
+ "failureCount": 0,
+ "evidenceSourceCode": "COMMAND"
+ }
+ ]
+ }
+ ],
+ "actors": [
+ {
+ "userId": "95",
+ "team": "RED",
+ "position": "COMMANDER",
+ "departmentId": "5"
+ },
+ {
+ "userId": "96",
+ "team": "RED",
+ "position": "OPERATOR",
+ "departmentId": "5"
+ },
+ {
+ "userId": "97",
+ "team": "BLUE",
+ "position": "COMMANDER",
+ "departmentId": "5"
+ },
+ {
+ "userId": "98",
+ "team": "BLUE",
+ "position": "OPERATOR",
+ "departmentId": "5"
+ }
+ ],
+ "requests": [
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/50/publish",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/50/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/start",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/44/progress",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/submit",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 403
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/physical-events",
+ "status": 409
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/review",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/44/review",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/44/steps",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/publish",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/assignments/51/accept",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs?assignmentId=51&size=100",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/start",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45/execution",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 403
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 400
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 409
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 409
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/45/progress",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/review",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/45/review",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/45/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/start",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/start",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46/execution",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 403
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 400
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 409
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 409
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 200
+ },
+ {
+ "method": "PUT",
+ "path": "/api/tran/v1/teaching/runs/46/progress",
+ "status": 409
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 403
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/submit",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/review",
+ "status": 200
+ },
+ {
+ "method": "POST",
+ "path": "/api/tran/v1/teaching/runs/46/review",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/runs/46/steps",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/assignments/50",
+ "status": 200
+ },
+ {
+ "method": "GET",
+ "path": "/api/tran/v1/teaching/assignments/51",
+ "status": 200
+ }
+ ],
+ "limitations": [
+ "实装事件均为本次自动化提交的 MANUAL 人工确认或明确的权限拒绝测试,不代表真实设备、PLC、摄像机或视觉模型联调。",
+ "教学运行结果来自正式 API;默认实装离线回放和默认对抗本地工作台的显示另行记录,不混同为服务端运行闭环。",
+ "对抗按四名隔离学员的独立会话依次操作,检查服务端截止时间已生效;未等待整局自然超时,也不代表多人同时操作的压力或网络同步测试。",
+ "测试账号使用本次随机密码,密码与会话仅在运行内存中;报告只保留用户 ID、队伍和岗位以便追溯。"
+ ],
+ "finishedAt": "2026-09-05T15:58:56.293Z",
+ "retainedData": {
+ "assignments": [
+ "50",
+ "51"
+ ],
+ "runs": [
+ "44",
+ "45",
+ "46"
+ ],
+ "users": [
+ "95",
+ "96",
+ "97",
+ "98"
+ ],
+ "note": "仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。"
+ }
+}
+
+真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网
通过 8 / 8 项 · 业务真实写入 0 次
依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。
本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。
pnpm run build:PASS · 根代理执行,含 vue-tsc、Vite 与 iframe 校验;既有构建警告不影响退出码 0。
git diff --check:PASS · 根代理执行,退出码 0。
JSON 证据:evidence.json。所有截图来自实际浏览器,测试凭据没有写入报告。
{
+ "before": {
+ "path": "/content/training-projects/162/legacy",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/161/legacy",
+ "fullPath": "/content/training-projects/161/legacy",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/162/legacy",
+ "fullPath": "/content/training-projects/162/legacy",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ }
+ ]
+ },
+ "after": {
+ "path": "/content/training-projects/161/legacy",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/161/legacy",
+ "fullPath": "/content/training-projects/161/legacy",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/162/legacy",
+ "fullPath": "/content/training-projects/162/legacy",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ }
+ ]
+ },
+ "method": "真实页面标签点击"
+}
{
+ "path": "/content/training-projects",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/162/legacy",
+ "fullPath": "/content/training-projects/162/legacy",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ }
+ ]
+}
{
+ "path": "/content/training-projects",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/162/legacy",
+ "fullPath": "/content/training-projects/162/legacy",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ }
+ ]
+}
{
+ "path": "/content/training-projects/161/legacy",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/162/legacy",
+ "fullPath": "/content/training-projects/162/legacy",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/161/legacy",
+ "fullPath": "/content/training-projects/161/legacy",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ }
+ ]
+}

{
+ "tabs": {
+ "path": "/content/training-projects/161/legacy",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/162/legacy",
+ "fullPath": "/content/training-projects/162/legacy",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/161/legacy",
+ "fullPath": "/content/training-projects/161/legacy",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ }
+ ]
+ },
+ "writes": [
+ {
+ "origin": "http://127.0.0.1:6180",
+ "path": "/api/tran/v1/content/projects/161/assets/upload",
+ "method": "POST",
+ "response": 503,
+ "forwarded": false
+ },
+ {
+ "origin": "http://127.0.0.1:6180",
+ "path": "/api/tran/v1/content/projects/161",
+ "method": "PUT",
+ "response": 503,
+ "forwarded": false
+ }
+ ],
+ "simulated": true
+}
{
+ "path": "/content/training-projects",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects/162/legacy",
+ "fullPath": "/content/training-projects/162/legacy",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ },
+ {
+ "title": "训练编排",
+ "active": false
+ }
+ ]
+}
{
+ "projects": [
+ {
+ "id": "161",
+ "version": 9,
+ "currentVersionId": "213",
+ "publishedVersionId": "213",
+ "coverUri": "fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg",
+ "checksum": "d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605",
+ "unchanged": true,
+ "after": {
+ "id": "161",
+ "version": 9,
+ "currentVersionId": "213",
+ "publishedVersionId": "213",
+ "coverUri": "fsvc://unreal-tran/image/2026/09/05/32b2fb56adf53691ede867a8c957c573.jpg",
+ "checksum": "d5a0f4c872c8e275db980808375d9351bd51ea0aaf6b52bf453b7e8f9cc55605"
+ }
+ },
+ {
+ "id": "162",
+ "version": 6,
+ "currentVersionId": "214",
+ "publishedVersionId": "214",
+ "coverUri": "fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg",
+ "checksum": "e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3",
+ "unchanged": true,
+ "after": {
+ "id": "162",
+ "version": 6,
+ "currentVersionId": "214",
+ "publishedVersionId": "214",
+ "coverUri": "fsvc://unreal-tran/image/2026/09/05/be78d4ad687c3872a15001b8333f1afe.jpg",
+ "checksum": "e13b82f8fbc38ad327d8f83baec8295316e614132acf5eeeba25ea7371dfd9d3"
+ }
+ }
+ ],
+ "forwardedBusinessWrites": 0
+}{
+ "origin": "http://192.168.31.168:6180",
+ "tabs": {
+ "path": "/content/training-projects",
+ "entries": [
+ {
+ "key": "/dashboard",
+ "fullPath": "/dashboard",
+ "title": "工作总览",
+ "locked": false
+ },
+ {
+ "key": "/profile",
+ "fullPath": "/profile",
+ "title": "个人中心",
+ "locked": false
+ },
+ {
+ "key": "/content/training-projects",
+ "fullPath": "/content/training-projects",
+ "title": "训练编排",
+ "locked": false
+ }
+ ],
+ "labels": [
+ {
+ "title": "工作总览",
+ "active": false
+ },
+ {
+ "title": "个人中心",
+ "active": false
+ },
+ {
+ "title": "训练编排",
+ "active": true
+ }
+ ]
+ }
+}
${html(pretty(value))}| ${html(value)} | `).join('')}
|---|
| ${html(value)} | `).join('')}
已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json,不以阶段性保存结果代替最终记录。
' +const mdProjects = projects.length ? `| ${projectHeaders.map(md).join(' | ')} |\n| ${projectHeaders.map(() => '---').join(' | ')} |\n${projects.map(project => `| ${projectRow(project).map(md).join(' | ')} |`).join('\n')}` + : '已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json。' +function sourceHtml(source) { + if (!source.data) return `等待 ${html(source.file)}。未计为通过。
历史记录,不计入当前通过数。
' : ''}${details(check)}${html(pretty(source.data))}\n\n${html(command.details || command.result || '')}
狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布
2026-09-05 · ${html(final.baseURL || 'http://127.0.0.1:6180')} · ${html(final.browser || 'Microsoft Edge / Playwright')} · ${html(final.role || '教员编排与独立浏览器只读重开')}
${html(summary)}
报告草稿:最终证据尚不完整。已有过程截图和开发记录不能代替保存、发布及重新打开的最终验证。
' : ''} +保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。
${html(summary)}
过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。
${versionMismatches.length ? `独立重开证据尚未覆盖最终发布版本,不能标记完整通过。
${details(versionMismatches, '待重验的版本')}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `命令与最终构建结果等待证据记录。
'}等待最终证据引用实际截图。尚无最终截图不等于模型已通过可见性检查。
'}这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。
${html(pretty(versionMismatches))}\n` : ''}
+${sources.map(sourceMd).join('\n\n')}
+
+${commands.length ? `### 执行命令与验证结果\n\n${mdCommands}` : '命令与最终构建结果等待证据记录。'}
+
+## 最终及异常验证截图
+
+${finalImages.length ? finalImages.map(mdFigure).join('\n\n') : '等待最终证据引用实际截图;尚未将模型可见性计为通过。'}
+
+## 过程截图 · 不计入最终通过结论
+
+${history.map(mdFigure).join('\n\n')}
+
+## 范围、限制与功能确认结论
+
+${mdBullets(scopeItems)}
+
+${limitations.length ? `### 实测限制与后续项\n\n${mdBullets(limitations.map(pretty))}\n` : ''}
+报告生成:${generatedAt}(Asia/Shanghai)。
+`
+fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
+fs.writeFileSync(path.join(reportDir, 'report.md'), outputMd, 'utf8')
+console.log(JSON.stringify({ report: 'reports/training-live-20260905/index.html', status: overall, sources: sources.map(({ file, status: state }) => ({ file, status: state })), checks: { pass: passedChecks, fail: failedChecks, pending: pendingChecks }, images: imagePaths.size }))
diff --git a/tools/build-training-modes-report.mjs b/tools/build-training-modes-report.mjs
new file mode 100644
index 0000000..eeb2bc4
--- /dev/null
+++ b/tools/build-training-modes-report.mjs
@@ -0,0 +1,194 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+// Evidence is owned by the actual UI/API test runs. Missing evidence stays pending.
+// This script only generates the two report documents and never calls a business API.
+const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-modes-20260905')
+fs.mkdirSync(reportDir, { recursive: true })
+const array = value => Array.isArray(value) ? value : value == null ? [] : [value]
+const sensitive = /password|passwd|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key|username|loginName|accountName|headers|storageState/i
+const cleanText = value => String(value ?? '')
+ .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
+ .replace(/\b((?:access|refresh)?[_-]?token|password|passwd|secret|uploadTicket|signature|api[_-]?key|X-Amz-(?:Signature|Credential|Security-Token))\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
+function sanitize(value) {
+ if (typeof value === 'string') return cleanText(value)
+ if (Array.isArray(value)) return value.map(sanitize)
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
+ .filter(([key]) => !sensitive.test(key)).map(([key, item]) => [key, sanitize(item)]))
+ return value
+}
+const html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
+const md = value => cleanText(value).replace(/[|\[\]*_`<>]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
+const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
+const recorded = value => value === undefined || value === null || value === '' ? '未记录' : value
+const state = value => /^(PASS|PASSED)$/i.test(String(value)) ? 'PASS'
+ : /^(FAIL|FAILED)$/i.test(String(value)) ? 'FAIL'
+ : /^(BLOCKED|LIMITED)$/i.test(String(value)) ? 'BLOCKED'
+ : /^PASS_WITH_LIMITATIONS$/i.test(String(value)) ? 'PASS_WITH_LIMITATIONS' : 'PENDING'
+const label = value => ({ PASS: '通过', FAIL: '失败', BLOCKED: '受限(需功能接线)', PASS_WITH_LIMITATIONS: '部分链路已验证,页面闭环受限', PENDING: '待补充证据' })[state(value)]
+const badge = value => `${label(value)}`
+const details = (value, title = '查看脱敏详细记录') => `${html(pretty(value))}| ${html(value)} | `).join('')}
|---|
| ${html(recorded(value))} | `).join('')}
等待 ${html(source.file)},未计为通过。
证据类型:${source.kind} · ${html(source.file)}
${source.checks.map(check => `${html(pretty(source.data))}\n\n编排 UI · 默认教学入口 · 正式教学 API
${conclusion ? `${html(conclusion)}
` : ''}2026-09-05 · ${html(ui.baseURL || api.baseURL || 'http://127.0.0.1:6180')}
${html(summary)}
报告草稿:最终证据尚不完整,不以配置完成或单独 API 成功替代界面全流程验证。
' : ''} +训练 ${html(project.id || project.projectId)}:${html(project.apiVersionNote)}
` : ''}${details(project, `训练 ${project.id || project.projectId} · 发布与配置记录`)}`).join('') : '等待 UI 最终项目记录。
'}等待 API 运行结果。
'}${api.retainedData ? details(api.retainedData, '本次新增数据 ID') : ''}随机测试密码与登录会话不写入报告;保留新建数据用于追溯。
${html(summary)}
${versionMismatches.length ? `UI 与 API 证据引用的发布版本尚未一致,不能标记完整通过。
${details(versionMismatches)}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `${html(command.details || command.result || '')}
截图只来自实际存在的文件;API 请求结果单列,不以 UI 截图代替。
${images.length ? `等待 evidence.json 引用实际截图。
'}本栏等待最终证据中的功能范围结论;默认教学 UI 与正式运行的接线现状需按实际结果评估,不自动扩展实现。
'}${html(pretty(versionMismatches))}\n` : ''}
+${sources.map(sourceMd).join('\n\n')}
+
+${commands.length ? `### 测试命令与构建\n\n${commands.map(command => `- ${md(command.command || command.name)}:${label(command.status)}。${md(command.details || command.result || '')}`).join('\n')}` : ''}
+
+## 真实浏览器截图
+
+${images.length ? images.map(mdFigure).join('\n\n') : '等待最终证据引用实际截图。'}
+
+## 范围、限制与待确认项
+
+${mdBullets(scope)}
+
+${limitations.length ? `### 实测边界与限制\n\n${mdBullets(limitations)}\n` : ''}
+### 需要另行确认的功能范围
+
+${followups.length ? mdBullets(followups) : '等待最终证据中的功能范围结论;默认教学 UI 与正式运行接线现状按实际结果评估,不自动扩展实现。'}
+
+报告生成:${generatedAt}(Asia/Shanghai)。
+`
+fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
+fs.writeFileSync(path.join(reportDir, 'report.md'), outputMd, 'utf8')
+console.log(JSON.stringify({ report: 'reports/training-modes-20260905/index.html', status: overall, checks: counts, images: images.length, sources: sources.map(({ file, status }) => ({ file, status })) }))
diff --git a/tools/training-cover.test.mjs b/tools/training-cover.test.mjs
new file mode 100644
index 0000000..1cf6197
--- /dev/null
+++ b/tools/training-cover.test.mjs
@@ -0,0 +1,236 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import test from 'node:test'
+import ts from 'typescript'
+
+const features = new URL('../../unreal_tran_web/src/features/', import.meta.url)
+async function moduleUrl(relative, replacements = {}) {
+ let source = await readFile(new URL(relative, features), 'utf8')
+ for (const [from, to] of Object.entries(replacements)) source = source.replace(from, to)
+ const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } })
+ return `data:text/javascript;base64,${Buffer.from(output.outputText).toString('base64')}`
+}
+const sceneCaptureUrl = await moduleUrl('scene-legacy/sceneCoverCapture.ts')
+const { captureTrainingCover } = await import(await moduleUrl('training-legacy/trainingCoverCapture.ts', {
+ '../scene-legacy/sceneCoverCapture': sceneCaptureUrl,
+}))
+const { saveTrainingWithCover } = await import(await moduleUrl('training-legacy/trainingCoverPersistence.ts'))
+const oldCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://old', sortOrder: 1 }
+const audio = { code: 'NARRATION', storageUri: 'fsvc://audio', sortOrder: 0 }
+const newCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://new', sortOrder: 1, uploadTicket: 'test-only-ticket' }
+function persistenceFixture({ captureError, uploadError, updateError, invalidateAfterUpload = false } = {}) {
+ const calls = []
+ let current = true
+ const project = { id: '161', version: 8, coverUri: oldCover.storageUri, assets: [audio, oldCover] }
+ const input = { type: 'TRAINING', version: 8, assets: [audio, oldCover], content: { steps: [{ stepCode: 'STEP-1' }] }, dependencies: [{ targetProjectId: '160', targetVersionId: '210', relationType: 'TRAINING_SCENE', required: true }] }
+ const capture = async () => { if (captureError) throw captureError; return new Blob(['jpeg'], { type: 'image/jpeg' }) }
+ const api = {
+ async upload(file, params) { calls.push({ method: 'upload', file, params }); if (uploadError) throw uploadError; if (invalidateAfterUpload) current = false; return newCover },
+ async update(body) { calls.push({ method: 'PUT', body }); if (updateError) throw updateError; return { ...project, ...body, version: 9 } },
+ }
+ const run = () => saveTrainingWithCover(project, input, capture, api, () => { if (!current) throw new Error('Project switched') })
+ return { calls, project, input, run }
+}
+
+test('training cover ticket, content and fixed scene dependency share exactly one PUT', async () => {
+ const f = persistenceFixture()
+ const result = await f.run()
+ assert.equal(result.coverFailed, false)
+ assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT'])
+ assert.equal(f.calls[0].file.type, 'image/jpeg')
+ assert.equal(f.calls[0].params.projectVersion, 8)
+ assert.equal(f.calls[0].params.code, 'TRAINING_COVER')
+ assert.deepEqual(f.calls[1].body.assets, [audio, newCover])
+ assert.equal(f.calls[1].body.coverUri, 'fsvc://new')
+ assert.deepEqual(f.calls[1].body.dependencies, f.input.dependencies)
+ assert.deepEqual(f.calls[1].body.content, f.input.content)
+ assert.deepEqual(f.input.assets, [audio, oldCover])
+})
+
+for (const failure of ['captureError', 'uploadError']) {
+ test(`${failure} retains the old cover while saving training content`, async () => {
+ const f = persistenceFixture({ [failure]: new Error('Unavailable') })
+ const result = await f.run()
+ assert.equal(result.coverFailed, true)
+ const puts = f.calls.filter(x => x.method === 'PUT')
+ assert.equal(puts.length, 1)
+ assert.equal(puts[0].body.coverUri, 'fsvc://old')
+ assert.deepEqual(puts[0].body.assets, [audio, oldCover])
+ })
+}
+test('upload 409 stops without attempting a fallback PUT', async () => {
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
+ const f = persistenceFixture({ uploadError: conflict })
+ await assert.rejects(f.run(), error => error === conflict)
+ assert.deepEqual(f.calls.map(x => x.method), ['upload'])
+})
+test('PUT 409 is propagated without retrying or replacing the prior cover', async () => {
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
+ const f = persistenceFixture({ updateError: conflict })
+ await assert.rejects(f.run(), error => error === conflict)
+ assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT'])
+ assert.equal(f.project.coverUri, oldCover.storageUri)
+})
+test('project switch while uploading stops the final update', async () => {
+ const f = persistenceFixture({ invalidateAfterUpload: true })
+ await assert.rejects(f.run(), /Project switched/)
+ assert.deepEqual(f.calls.map(x => x.method), ['upload'])
+})
+
+const value = initial => ({ value: initial, clone() { return value(this.value) }, copy(other) { this.value = other.value; return this } })
+function captureFixture({ drawError } = {}) {
+ const grid = { type: 'GridHelper', visible: true }, path = { visible: true }
+ const material = { emissive: value('selected'), emissiveIntensity: 0.55 }
+ const object = { scale: value(1.04) }
+ const events = []
+ const record = action => events.push({ action, grid: grid.visible, path: path.visible, color: material.emissive.value, intensity: material.emissiveIntensity, scale: object.scale.value })
+ const canvas = { width: 0, height: 0, getContext: () => ({ drawImage() { record('copy'); if (drawError) throw drawError } }), toBlob(callback, type) { queueMicrotask(() => callback(new Blob(['image'], { type }))) } }
+ const editor = {
+ scene: { traverse: visitor => visitor(grid) }, camera: {}, pathPreview: path,
+ targetMaterials: new Map([['fox', [{ material, color: value('original'), intensity: 0 }]]]),
+ targetScales: new Map([['fox', value(1)]]), targetObjects: new Map([['fox', object]]),
+ renderer: { domElement: { width: 1600, height: 900 }, render() { record('render') } },
+ }
+ const options = { document: { createElement: () => canvas } }
+ const restored = () => {
+ assert.equal(grid.visible, true); assert.equal(path.visible, true)
+ assert.equal(material.emissive.value, 'selected'); assert.equal(material.emissiveIntensity, 0.55)
+ assert.equal(object.scale.value, 1.04)
+ }
+ return { editor, options, canvas, events, restored }
+}
+test('capture omits training selection glow, scale and helpers, preserving the current camera', async () => {
+ const f = captureFixture()
+ const camera = f.editor.camera
+ const image = await captureTrainingCover(f.editor, f.options)
+ const copied = f.events.find(event => event.action === 'copy')
+ assert.deepEqual(copied, { action: 'copy', grid: false, path: false, color: 'original', intensity: 0, scale: 1 })
+ f.restored()
+ assert.equal(f.editor.camera, camera)
+ assert.equal(f.canvas.width, 640); assert.equal(f.canvas.height, 360)
+ assert.equal(image.type, 'image/jpeg')
+})
+test('capture failures restore all transient training presentation state', async () => {
+ const f = captureFixture({ drawError: new Error('Canvas blocked') })
+ await assert.rejects(captureTrainingCover(f.editor, f.options), /Canvas blocked/)
+ f.restored()
+})
+test('loading, failed and disposed training viewports cannot overwrite the cover with a placeholder', async () => {
+ for (const state of ['loading', 'error']) {
+ const f = captureFixture(); f.editor.container = { dataset: { state } }
+ await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/)
+ assert.equal(f.events.length, 0)
+ }
+ const f = captureFixture(); f.editor.disposed = true
+ await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/)
+ const hidden = captureFixture(); hidden.editor.renderer.domElement.width = 2
+ await assert.rejects(captureTrainingCover(hidden.editor, hidden.options), /尚未加载完成/)
+})
+
+// Exercise the actual vendored methods with a minimal host, avoiding a mock reimplementation.
+const editorSource = await readFile(new URL('guide-legacy/demo-src/training-editor.js', features), 'utf8')
+const saveMethod = editorSource.slice(editorSource.indexOf(' async saveDraft('), editorSource.indexOf(' showValidation()'))
+const publishMethod = editorSource.slice(editorSource.indexOf(' async publishProject('), editorSource.indexOf(' async runProject()'))
+const bindMethod = editorSource.slice(editorSource.indexOf(' async bindPublishedScene('), editorSource.indexOf(' async previewAudio('))
+const EditorMethods = new Function('saveTrainingProject', 'clone', 'publishTrainingProject', 'ENABLE_TRAINING_TOOLS', `return class { ${saveMethod} ${publishMethod} ${bindMethod} }`)(
+ (_storage, project, options) => ({ ...project, status: options.status }), structuredClone,
+ () => { throw new Error('Host-backed editor must not publish locally first') },
+ false,
+)
+function editorFixture() {
+ let resolve
+ const pending = new Promise(done => { resolve = done })
+ const editor = new EditorMethods()
+ Object.assign(editor, {
+ project: { id: '161', status: 'draft', scenario: {}, steps: [] }, storage: {}, editRevision: 1,
+ persisting: false, dirty: true, serverPending: true, root: { querySelector: () => null, querySelectorAll: () => [] },
+ onPersist: () => pending, assetStore: { validateReferences: async () => ({ valid: true }) },
+ })
+ for (const name of ['renderSaveState', 'renderProjects', 'renderProjectHeader', 'rememberSavedBaseline', 'replaceCurrentHistoryState', 'renderAll', 'toast', 'showValidation']) editor[name] = () => {}
+ return { editor, resolve }
+}
+test('manual save remains pending until server acknowledgment and blocks duplicate writes', async () => {
+ const f = editorFixture()
+ const pending = f.editor.saveDraft({ notify: true })
+ assert.equal(f.editor.persisting, true)
+ assert.equal(await f.editor.saveDraft({ notify: true }), null)
+ f.resolve({ status: 'draft' })
+ assert.ok(await pending)
+ assert.equal(f.editor.serverPending, false)
+ assert.equal(f.editor.persisting, false)
+})
+test('failed host save stays dirty and never reports a successful local result', async () => {
+ const f = editorFixture()
+ const pending = f.editor.saveDraft({ notify: true })
+ f.resolve(false)
+ assert.equal(await pending, null)
+ assert.equal(f.editor.serverPending, true)
+ assert.equal(f.editor.dirty, true)
+})
+test('new edits during a server save remain pending after the older snapshot succeeds', async () => {
+ const f = editorFixture()
+ const pending = f.editor.saveDraft({ notify: true })
+ f.editor.editRevision += 1
+ f.editor.dirty = true
+ f.resolve({ status: 'draft' })
+ await pending
+ assert.equal(f.editor.serverPending, true)
+ assert.equal(f.editor.dirty, true)
+})
+test('publishing keeps the editor in draft until the server confirms publication', async () => {
+ const f = editorFixture()
+ const pending = f.editor.publishProject()
+ await Promise.resolve()
+ assert.equal(f.editor.project.status, 'draft')
+ assert.equal(f.editor.persisting, true)
+ f.resolve({ status: 'published' })
+ assert.ok(await pending)
+ assert.equal(f.editor.project.status, 'published')
+ assert.equal(f.editor.serverPending, false)
+})
+test('server-rejected publication leaves draft status and pending changes intact', async () => {
+ const f = editorFixture()
+ const pending = f.editor.publishProject()
+ f.resolve(false)
+ assert.equal(await pending, null)
+ assert.equal(f.editor.project.status, 'draft')
+ assert.equal(f.editor.serverPending, true)
+})
+
+test('failed scene binding cannot persist a half-bound scene through save or publish', async () => {
+ const { editor } = editorFixture()
+ editor.project.scenario = { publishedSceneId: 'old@1', environment: {} }
+ editor.dirty = false
+ editor.serverPending = false
+ editor.clearEnvironmentPreview = () => {}
+ let writes = 0
+ editor.onPersist = async () => { writes += 1; return { status: 'draft' } }
+ editor.loadServerScene = async () => ({ id: 'new@2', name: 'New scene', sourceProjectId: '2', sourceVersionId: '2', objects: [] })
+ let finishRender
+ const loading = new Promise(resolve => { finishRender = resolve })
+ let renders = 0
+ editor.renderScene = () => ++renders === 1 ? loading : Promise.resolve({})
+ const binding = editor.bindPublishedScene('new@2')
+ await Promise.resolve(); await Promise.resolve(); await Promise.resolve()
+ assert.equal(editor.bindingScene, true)
+ assert.equal(editor.project.scenario.publishedSceneId, 'new@2')
+ assert.equal(await editor.saveDraft({ notify: true }), null)
+ assert.equal(await editor.publishProject(), null)
+ assert.equal(writes, 0)
+ finishRender({ error: new Error('Model loader failed') })
+ await binding
+ assert.equal(editor.bindingScene, false)
+ assert.equal(editor.project.scenario.publishedSceneId, 'old@1')
+ assert.equal(writes, 0)
+ assert.equal(editor.dirty, false)
+ assert.equal(editor.serverPending, false)
+})
+
+test('scene binding cannot replace the document while an existing save is in flight', async () => {
+ const { editor } = editorFixture()
+ editor.persisting = true
+ let loads = 0
+ editor.applyPublishedScene = async () => { loads += 1 }
+ await editor.bindPublishedScene('new@2')
+ assert.equal(loads, 0)
+})
diff --git a/tools/training-mode-bridge.test.mjs b/tools/training-mode-bridge.test.mjs
new file mode 100644
index 0000000..7ceba43
--- /dev/null
+++ b/tools/training-mode-bridge.test.mjs
@@ -0,0 +1,228 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+
+const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
+const ts = requireWeb('typescript')
+const web = new URL('../../unreal_tran_web/src/features/', import.meta.url)
+const compile = file => ts.transpileModule(readFileSync(new URL(file, web), 'utf8'), {
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+}).outputText
+const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
+const documentUrl = dataUrl(compile('training/trainingDocument.ts'))
+const bridgeUrl = dataUrl(compile('training-legacy/bridge.ts').replace(/from ['"]\.\.\/training\/trainingDocument['"]/, `from '${documentUrl}'`))
+const { toFormalContent, toLegacyProject } = await import(bridgeUrl)
+const { normalizeTrainingContent, validateTrainingProject, resequenceSteps, CONFRONTATION_DEFAULT_POSITIONS } = await import(documentUrl)
+const { createTrainingProject, normalizeTrainingProject } = await import(new URL('guide-legacy/demo-src/training-project-io.js', web))
+
+function fixture(mode) {
+ const legacy = createTrainingProject({ trainingMode: mode.toLowerCase(), id: 'mode-fixture' })
+ legacy.steps = legacy.steps.slice(0, 2)
+ legacy.steps[0].id = 'step-first'
+ legacy.steps[1].id = 'step-last'
+ const content = toFormalContent(legacy, mode).content
+ return { id: '163', name: 'Channel test', type: 'TRAINING', trainingMode: mode, status: 'DRAFT', content }
+}
+const open = project => normalizeTrainingProject(toLegacyProject(project))
+const saved = (legacy, previous) => toFormalContent(legacy, previous.trainingMode, previous.content).content
+
+test('physical UI fields replace the authoritative default profile and survive reopen', () => {
+ const project = fixture('PHYSICAL')
+ project.content.enabledChannels = ['VIRTUAL', 'PHYSICAL']
+ project.content.channelProfiles.VIRTUAL = { type: 'VIRTUAL', renderMode: 'retain virtual settings' }
+ project.content.channelProfiles.PHYSICAL.manualConfirmAllowed = false
+ const original = structuredClone(project.content)
+ const legacy = open(project)
+ const changes = { stationName: 'Truck diagnostic bay', equipmentCode: 'TRUCK-TEST-001', gatewayProtocol: 'MODBUS TCP TEST',
+ sensorChannels: 'pressure,position,test-signal', safetyInterlocks: 'E-STOP TEST' }
+ Object.assign(legacy.modeConfig, changes)
+ const result = saved(legacy, project)
+ assert.deepEqual(result.modeConfig, result.channelProfiles.PHYSICAL)
+ for (const [field, value] of Object.entries(changes)) assert.equal(result.modeConfig[field], value)
+ assert.equal(result.modeConfig.manualConfirmAllowed, false)
+ for (const [field, value] of Object.entries(original.channelProfiles.VIRTUAL)) {
+ assert.deepEqual(result.channelProfiles.VIRTUAL[field], value)
+ }
+ const reopened = open({ ...project, content: result })
+ for (const [field, value] of Object.entries(changes)) assert.equal(reopened.modeConfig[field], value)
+ assert.deepEqual(project.content, original)
+})
+
+test('confrontation canonical minutes populate UI and user edits update both default profile and mirror', () => {
+ const project = fixture('CONFRONTATION')
+ const profile = { ...project.content.modeConfig, roundDurationMinutes: 7, submitPositions: ['COMMANDER'],
+ positionActions: { SUBMIT: ['COMMANDER'] }, teamSize: 2 }
+ delete profile.roundDuration
+ project.content.modeConfig = profile
+ project.content.channelProfiles.CONFRONTATION = profile
+ const legacy = open(project)
+ assert.equal(legacy.modeConfig.roundDuration, 7)
+ Object.assign(legacy.modeConfig, { roundDuration: 12, redTeamName: 'Red maintenance', blueTeamName: 'Blue maintenance',
+ eventInjections: 'signal drift', objective: 'isolate test fault', winRule: 'fewer errors wins' })
+ const result = saved(legacy, project)
+ assert.equal(result.modeConfig.roundDurationMinutes, 12)
+ assert.equal(result.modeConfig.roundDuration, 12)
+ assert.deepEqual(result.modeConfig, result.channelProfiles.CONFRONTATION)
+ assert.deepEqual(result.modeConfig.submitPositions, ['COMMANDER'])
+ assert.deepEqual(result.modeConfig.positionActions, { SUBMIT: ['COMMANDER'] })
+ assert.equal(open({ ...project, content: result }).modeConfig.roundDuration, 12)
+})
+
+test('seconds override keeps its exact duration until the displayed minutes are edited', () => {
+ const project = fixture('CONFRONTATION')
+ Object.assign(project.content.modeConfig, { durationSeconds: 90, roundDurationMinutes: 30 })
+ project.content.channelProfiles.CONFRONTATION = structuredClone(project.content.modeConfig)
+ const legacy = open(project)
+ assert.equal(legacy.modeConfig.roundDuration, 1.5)
+ assert.equal(saved(legacy, project).modeConfig.durationSeconds, 90)
+ legacy.modeConfig.roundDuration = 7
+ const result = saved(legacy, project)
+ assert.equal(result.modeConfig.durationSeconds, 420)
+ assert.equal(result.modeConfig.roundDurationMinutes, 7)
+ assert.equal(open({ ...project, content: result }).modeConfig.roundDuration, 7)
+})
+
+test('runtime codes, explicit event sources, confirmation policy and roles survive legacy normalization/save', () => {
+ const project = fixture('PHYSICAL')
+ Object.assign(project.content.steps[0], {
+ stepCode: 'CUSTOM-FIRST', actionCode: 'CUSTOM-FIRST-ACTION',
+ physicalEventRule: { source: 'DEVICE', eventType: 'PRESSURE_READY', progressPercent: 25, manualConfirmAllowed: false },
+ allowedPositions: ['MECHANIC'], positionActions: { CHECK: ['MECHANIC'] },
+ })
+ Object.assign(project.content.steps[1], { physicalEventRule: { source: 'VISION', eventType: 'VISION_PASS', progressPercent: 100, manualConfirmAllowed: false } })
+ const result = saved(open(project), project)
+ assert.equal(result.steps[0].stepCode, 'CUSTOM-FIRST')
+ assert.equal(result.steps[0].actionCode, 'CUSTOM-FIRST-ACTION')
+ assert.deepEqual(result.steps[0].physicalEventRule, project.content.steps[0].physicalEventRule)
+ assert.deepEqual(result.steps[1].physicalEventRule, project.content.steps[1].physicalEventRule)
+ assert.deepEqual(result.steps[0].allowedPositions, ['MECHANIC'])
+ assert.deepEqual(result.steps[0].positionActions, { CHECK: ['MECHANIC'] })
+})
+
+test('reordering uses step identity for event rules and rebuilds monotonic progress in new order', () => {
+ const project = fixture('PHYSICAL')
+ project.content.steps[0].physicalEventRule = { source: 'DEVICE', eventType: 'PRESSURE_READY', progressPercent: 25, manualConfirmAllowed: false }
+ project.content.steps[1].physicalEventRule = { source: 'VISION', eventType: 'VISION_PASS', progressPercent: 100, manualConfirmAllowed: false }
+ const legacy = open(project)
+ legacy.steps.reverse()
+ const result = saved(legacy, project)
+ assert.deepEqual(result.steps.map(step => step.id), ['step-last', 'step-first'])
+ assert.deepEqual(result.steps.map(step => step.physicalEventRule.source), ['VISION', 'DEVICE'])
+ assert.deepEqual(result.steps.map(step => step.physicalEventRule.progressPercent), [50, 100])
+ assert.deepEqual(result.steps.map(step => step.order), [1, 2])
+})
+
+test('adding and deleting steps derives valid physical progress without copying another step role or event', () => {
+ const project = fixture('PHYSICAL')
+ const legacy = open(project)
+ legacy.steps.splice(1, 0, { ...legacy.steps[0], id: 'new-step' })
+ delete legacy.steps[1].physicalEventRule
+ delete legacy.steps[1].stepCode
+ delete legacy.steps[1].actionCode
+ const result = saved(legacy, project)
+ assert.deepEqual(result.steps.map(step => step.physicalEventRule.progressPercent), [33, 67, 100])
+ assert.equal(result.steps[1].physicalEventRule.source, 'MANUAL')
+ assert.equal(new Set(result.steps.map(step => step.stepCode)).size, 3)
+ assert.equal(new Set(result.steps.map(step => step.actionCode)).size, 3)
+ assert.equal(result.steps[0].stepCode, project.content.steps[0].stepCode)
+ assert.equal(result.steps[2].stepCode, project.content.steps[1].stepCode)
+ assert.ok(/^[A-Za-z][A-Za-z0-9._-]{1,99}$/.test(result.steps[1].physicalEventRule.eventType))
+ const next = { ...project, content: result }
+ const reduced = open(next)
+ reduced.steps = reduced.steps.slice(0, 1)
+ assert.equal(saved(reduced, next).steps[0].physicalEventRule.progressPercent, 100)
+})
+
+test('duplicating a saved step gives the new identity distinct runtime codes while keeping the original stable', () => {
+ const project = fixture('CONFRONTATION')
+ const legacy = open(project)
+ legacy.steps.push({ ...legacy.steps[0], id: 'copied-step', stepCode: project.content.steps[0].stepCode,
+ actionCode: project.content.steps[0].actionCode })
+ const result = saved(legacy, project)
+ assert.equal(result.steps[0].stepCode, project.content.steps[0].stepCode)
+ assert.equal(result.steps[0].actionCode, project.content.steps[0].actionCode)
+ assert.equal(new Set(result.steps.map(step => step.stepCode)).size, 3)
+ assert.equal(new Set(result.steps.map(step => step.actionCode)).size, 3)
+})
+
+test('copied action-specific role mapping follows the new step action instead of pointing back to its source', () => {
+ for (const retainCopiedCode of [true, false]) {
+ const project = fixture('CONFRONTATION')
+ const sourceCode = project.content.steps[0].actionCode
+ project.content.steps[0].positionActions = { [sourceCode]: ['MECHANIC'] }
+ const legacy = open(project)
+ const copied = { ...legacy.steps[0], id: 'copied-step', positionActions: { [sourceCode]: ['MECHANIC'] } }
+ if (!retainCopiedCode) delete copied.actionCode
+ legacy.steps.push(copied)
+ const result = saved(legacy, project)
+ const newStep = result.steps.at(-1)
+ assert.notEqual(newStep.actionCode, sourceCode)
+ assert.deepEqual(newStep.positionActions, { [newStep.actionCode]: ['MECHANIC'] })
+ assert.deepEqual(result.steps[0].positionActions, { [sourceCode]: ['MECHANIC'] })
+ }
+})
+
+test('old documents without channelProfiles migrate edited modeConfig without losing other enabled channel configuration', () => {
+ const project = fixture('CONFRONTATION')
+ delete project.content.channelProfiles
+ delete project.content.enabledChannels
+ const legacy = open(project)
+ legacy.modeConfig.roundDuration = 9
+ const result = saved(legacy, project)
+ assert.deepEqual(result.enabledChannels, ['CONFRONTATION'])
+ assert.equal(result.channelProfiles.CONFRONTATION.roundDurationMinutes, 9)
+ assert.deepEqual(result.modeConfig, result.channelProfiles.CONFRONTATION)
+ assert.deepEqual(normalizeTrainingContent(result, 'CONFRONTATION').modeConfig, result.modeConfig)
+})
+
+test('explicitly empty step positions remain empty through editor save and are blocked by publish validation', () => {
+ const project = fixture('CONFRONTATION')
+ const legacy = open(project)
+ legacy.steps[0].allowedPositions = []
+ const result = saved(legacy, project)
+ assert.deepEqual(result.steps[0].allowedPositions, [], 'clearing all roles must not restore previous or default grants')
+ assert.deepEqual(open({ ...project, content: result }).steps[0].allowedPositions, [])
+ const validation = validateTrainingProject({ ...project, code: 'TEST-CONF', content: result })
+ assert.ok(validation.errors.some(issue => issue.path === 'steps[0].allowedPositions'))
+ assert.equal(validation.ok, false)
+})
+
+test('only missing legacy step positions receive compatibility defaults; explicit malformed or empty values grant no roles', () => {
+ const project = fixture('CONFRONTATION')
+ for (const supplied of [[], null, 'COMMANDER']) {
+ const content = structuredClone(project.content)
+ content.steps[0].allowedPositions = supplied
+ assert.deepEqual(normalizeTrainingContent(content, 'CONFRONTATION').steps[0].allowedPositions, [])
+ }
+ delete project.content.steps[0].allowedPositions
+ assert.deepEqual(normalizeTrainingContent(project.content, 'CONFRONTATION').steps[0].allowedPositions, CONFRONTATION_DEFAULT_POSITIONS)
+})
+
+test('reordering never repopulates an explicitly empty role set and retains a restricted role set', () => {
+ const project = fixture('CONFRONTATION')
+ project.content.steps[0].allowedPositions = []
+ project.content.steps[1].allowedPositions = ['COMMANDER']
+ const reordered = resequenceSteps([...project.content.steps].reverse(), 'CONFRONTATION')
+ assert.deepEqual(reordered.map(step => step.allowedPositions), [['COMMANDER'], []])
+ const legacy = open(project)
+ legacy.steps.reverse()
+ assert.deepEqual(saved(legacy, project).steps.map(step => step.allowedPositions), [['COMMANDER'], []])
+})
+
+test('new runtime codes avoid case-insensitive collisions using the server uniqueness rules', () => {
+ const project = fixture('CONFRONTATION')
+ project.content.steps = project.content.steps.slice(0, 1)
+ project.content.steps[0].stepCode = 'confrontation-s02'
+ project.content.steps[0].actionCode = 'confrontation-a02'
+ const legacy = open(project)
+ const copied = { ...legacy.steps[0], id: 'copied-case-step' }
+ delete copied.stepCode
+ delete copied.actionCode
+ legacy.steps.push(copied)
+ const result = saved(legacy, project)
+ assert.equal(result.steps[0].stepCode, 'confrontation-s02')
+ assert.equal(result.steps[0].actionCode, 'confrontation-a02')
+ assert.equal(new Set(result.steps.map(step => step.stepCode.toUpperCase())).size, 2)
+ assert.equal(new Set(result.steps.map(step => step.actionCode.toUpperCase())).size, 2)
+})
diff --git a/tools/training-mode-config.test.mjs b/tools/training-mode-config.test.mjs
new file mode 100644
index 0000000..3e1891d
--- /dev/null
+++ b/tools/training-mode-config.test.mjs
@@ -0,0 +1,123 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import test from 'node:test'
+
+const webSource = new URL('../../unreal_tran_web/src/features/', import.meta.url)
+const editorSource = await readFile(new URL('guide-legacy/demo-src/training-editor.js', webSource), 'utf8')
+const stepCreationMethods = editorSource.slice(editorSource.indexOf(' addStep()'), editorSource.indexOf(' removeStep()'))
+const storageFixture = () => {
+ const values = new Map()
+ return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) }
+}
+
+for (const runtime of ['guide-legacy/demo-src', 'virtual-training-legacy/upstream']) {
+ const { createTrainingProject, normalizeTrainingProject, validateTrainingProject, saveTrainingProject, loadTrainingProject } = await import(new URL(`${runtime}/training-project-io.js`, webSource))
+
+ test(`${runtime}: physical authored configuration survives save and reopen`, () => {
+ const project = createTrainingProject({ trainingMode: 'physical' })
+ project.modeConfig = {
+ stationName: 'Fox inspection station', equipmentCode: 'FOX-PHY-001', gatewayProtocol: 'MQTT',
+ sensorChannels: 'pressure,temperature', safetyInterlocks: 'emergency stop,door lock',
+ }
+ const storage = storageFixture()
+ const saved = saveTrainingProject(storage, project, { status: 'draft' })
+ assert.deepEqual(loadTrainingProject(storage, saved.id).modeConfig, project.modeConfig)
+ })
+
+ test(`${runtime}: clearing optional physical text does not restore demo values`, () => {
+ const project = createTrainingProject({ trainingMode: 'physical' })
+ project.modeConfig = { stationName: '', equipmentCode: '', gatewayProtocol: '', sensorChannels: '', safetyInterlocks: '' }
+ const storage = storageFixture()
+ const saved = saveTrainingProject(storage, project, { status: 'draft' })
+ assert.deepEqual(loadTrainingProject(storage, saved.id).modeConfig, project.modeConfig)
+ const report = validateTrainingProject(saved)
+ assert.ok(report.warnings.some(item => item.path === 'modeConfig.stationName'))
+ assert.ok(report.warnings.some(item => item.path === 'modeConfig.equipmentCode'))
+ })
+
+ test(`${runtime}: confrontation configuration keeps a supported 50-member team and all authored text`, () => {
+ const project = createTrainingProject({ trainingMode: 'confrontation' })
+ project.modeConfig = {
+ redTeamName: 'Red maintenance', blueTeamName: 'Blue inspection', teamSize: 50,
+ roundDuration: 75, objective: 'Find fault', eventInjections: 'sensor failure', winRule: 'Score then elapsed time',
+ }
+ const storage = storageFixture()
+ const saved = saveTrainingProject(storage, project, { status: 'draft' })
+ const reloaded = loadTrainingProject(storage, saved.id)
+ assert.deepEqual(reloaded.modeConfig, project.modeConfig)
+ assert.equal(validateTrainingProject(reloaded).errors.some(item => item.path.startsWith('modeConfig.')), false)
+ })
+
+ test(`${runtime}: explicit empty confrontation text stays empty and empty win rule warns`, () => {
+ const project = createTrainingProject({ trainingMode: 'confrontation' })
+ for (const key of ['redTeamName', 'blueTeamName', 'objective', 'eventInjections', 'winRule']) project.modeConfig[key] = ''
+ const normalized = normalizeTrainingProject(project)
+ for (const key of ['redTeamName', 'blueTeamName', 'objective', 'eventInjections', 'winRule']) assert.equal(normalized.modeConfig[key], '')
+ assert.ok(validateTrainingProject(normalized).warnings.some(item => item.path === 'modeConfig.winRule'))
+ })
+
+ test(`${runtime}: invalid team sizes remain visible and fail local validation instead of silently clamping`, () => {
+ for (const teamSize of [0, -1, 2.5, 201]) {
+ const project = createTrainingProject({ trainingMode: 'confrontation' })
+ project.modeConfig.teamSize = teamSize
+ const normalized = normalizeTrainingProject(project)
+ assert.equal(normalized.modeConfig.teamSize, teamSize)
+ assert.ok(validateTrainingProject(normalized).errors.some(item => item.path === 'modeConfig.teamSize'))
+ }
+ })
+
+ test(`${runtime}: invalid duration fails but existing sub-minute and long durations are preserved`, () => {
+ for (const roundDuration of [0, -1]) {
+ const project = createTrainingProject({ trainingMode: 'confrontation' })
+ project.modeConfig.roundDuration = roundDuration
+ assert.ok(validateTrainingProject(project).errors.some(item => item.path === 'modeConfig.roundDuration'))
+ }
+ for (const roundDuration of [0.5, 1.5, 720]) {
+ const project = createTrainingProject({ trainingMode: 'confrontation' })
+ project.modeConfig.roundDuration = roundDuration
+ assert.equal(normalizeTrainingProject(project).modeConfig.roundDuration, roundDuration)
+ assert.equal(validateTrainingProject(project).errors.some(item => item.path === 'modeConfig.roundDuration'), false)
+ }
+ })
+
+ test(`${runtime}: existing device event and team position contracts survive normalization and reorder`, () => {
+ const project = createTrainingProject({ trainingMode: 'physical' })
+ const contract = {
+ stepCode: 'PHY-FOX-INSPECT', actionCode: 'FOX_INSPECT',
+ physicalEventRule: { source: 'VISION', eventType: 'fox.inspection.done', progressPercent: 50, manualConfirmAllowed: false },
+ allowedPositions: ['observer'], positionActions: { observer: ['FOX_INSPECT'] },
+ }
+ Object.assign(project.steps[0], structuredClone(contract))
+ const stepId = project.steps[0].id
+ const storage = storageFixture()
+ const saved = saveTrainingProject(storage, project, { status: 'draft' })
+ const reopened = loadTrainingProject(storage, saved.id)
+ reopened.steps.reverse()
+ const normalized = normalizeTrainingProject(reopened)
+ const retained = normalized.steps.find(step => step.id === stepId)
+ for (const key of Object.keys(contract)) assert.deepEqual(retained[key], contract[key])
+ retained.physicalEventRule.source = 'MANUAL'
+ assert.equal(project.steps[0].physicalEventRule.source, 'VISION')
+ })
+
+ test(`${runtime}: add and duplicate allocate a new step without copying its source runtime codes`, () => {
+ const Harness = new Function('clone', 'normalizeTrainingProject', `return class { ${stepCreationMethods} }`)(structuredClone, normalizeTrainingProject)
+ for (const method of ['addStep', 'duplicateStep']) {
+ const editor = new Harness()
+ editor.project = createTrainingProject({ trainingMode: 'physical' })
+ editor.selectedStep = editor.project.steps[0]
+ editor.selectedStep.stepCode = 'EXISTING-STEP'
+ editor.selectedStep.actionCode = 'EXISTING_ACTION'
+ const sourceId = editor.selectedStep.id
+ for (const name of ['renumberSteps', 'markDirty', 'renderProjectHeader', 'renderScene', 'renderSteps', 'renderInspector', 'toast']) editor[name] = () => {}
+ editor[method]()
+ const created = editor.project.steps.find(step => step.id === editor.selectedStepId)
+ const original = editor.project.steps.find(step => step.id === sourceId)
+ assert.notEqual(created.id, original.id)
+ assert.equal(created.stepCode, undefined)
+ assert.equal(created.actionCode, undefined)
+ assert.equal(original.stepCode, 'EXISTING-STEP')
+ assert.equal(original.actionCode, 'EXISTING_ACTION')
+ }
+ })
+}
diff --git a/tools/training-model-placement.test.mjs b/tools/training-model-placement.test.mjs
new file mode 100644
index 0000000..ce04fde
--- /dev/null
+++ b/tools/training-model-placement.test.mjs
@@ -0,0 +1,186 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { pathToFileURL } from 'node:url'
+import test from 'node:test'
+import ts from 'typescript'
+
+const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
+const threeUrl = pathToFileURL(requireWeb.resolve('three')).href
+const THREE = await import(threeUrl)
+const webSource = new URL('../../unreal_tran_web/src/features/', import.meta.url)
+const placementSource = await readFile(new URL('scene-legacy/modelPlacement.ts', webSource), 'utf8')
+const placementJavaScript = ts.transpileModule(placementSource, {
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText.replace(/from 'three'/g, `from '${threeUrl}'`)
+const placementUrl = `data:text/javascript;base64,${Buffer.from(placementJavaScript).toString('base64')}`
+const { refreshSceneModelBounds } = await import(placementUrl)
+
+// Exercise the actual Three functions without constructing the browser-only
+// editor UI or opening its storage. Metadata normalization is irrelevant to
+// these geometry checks and remains an identity adapter in this harness.
+async function renderingFunctions(relativePath) {
+ const source = await readFile(new URL(relativePath, webSource), 'utf8')
+ const ast = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
+ const functionNames = new Set(['fitObject', 'applyPublishedSceneTransform', 'buildTrainingContentSnapshot', 'resolveTrainingEnvironmentUrl'])
+ const functions = ast.statements
+ .filter(node => ts.isFunctionDeclaration(node) && functionNames.has(node.name?.text))
+ .map(node => node.getText(ast).replace(/^export\s+/, ''))
+ assert.equal(functions.length, functionNames.size)
+ const editor = ast.statements.find(node => ts.isClassDeclaration(node) && node.name?.text === 'TrainingSceneEditor')
+ const highlight = editor.members.find(node => node.name?.getText(ast) === 'highlightTarget')
+ const setProject = editor.members.find(node => node.name?.getText(ast) === 'setProject')
+ const body = `
+ import * as THREE from '${threeUrl}';
+ import { refreshSceneModelBounds } from '${placementUrl}';
+ const normalizeTrainingModelPartTargets = item => item.modelPartTargets || [];
+ const normalizeTrainingPartOverrides = item => item || {};
+ const normalizeSceneRendering = item => item || {};
+ ${functions.join('\n')}
+ class TrainingSceneEditor { ${highlight.getText(ast)} ${setProject.getText(ast)} }
+ export { fitObject, applyPublishedSceneTransform, TrainingSceneEditor };
+ `
+ return import(`data:text/javascript;base64,${Buffer.from(body).toString('base64')}`)
+}
+
+function skinFixture() {
+ const root = new THREE.Group()
+ const bone = new THREE.Bone()
+ const geometry = new THREE.BoxGeometry(2, 4, 10)
+ geometry.translate(0, 2, 0)
+ const count = geometry.getAttribute('position').count
+ const indices = new Uint16Array(count * 4)
+ const weights = new Float32Array(count * 4)
+ for (let i = 0; i < count; i += 1) weights[i * 4] = 1
+ geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4))
+ geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4))
+ const mesh = new THREE.SkinnedMesh(geometry, new THREE.MeshBasicMaterial())
+ root.add(bone, mesh)
+ root.updateMatrixWorld(true)
+ mesh.bind(new THREE.Skeleton([bone]))
+ root.scale.setScalar(0.1)
+ root.position.set(1, 2, 3)
+ bone.position.y = 5
+ return { root, bone, mesh }
+}
+
+function close(actual, expected) {
+ assert.ok(Math.abs(actual - expected) < 1e-7, `${actual} != ${expected}`)
+}
+
+function assertSkinFitsCachedBounds(mesh) {
+ const vertex = new THREE.Vector3()
+ const worldBox = new THREE.Box3().setFromObject(mesh).expandByScalar(1e-7)
+ for (let i = 0; i < mesh.geometry.getAttribute('position').count; i += 1) {
+ mesh.getVertexPosition(i, vertex)
+ assert.ok(vertex.distanceTo(mesh.boundingSphere.center) <= mesh.boundingSphere.radius + 1e-7)
+ assert.ok(worldBox.containsPoint(vertex.applyMatrix4(mesh.matrixWorld)))
+ }
+}
+
+for (const runtime of ['guide-legacy/demo-src', 'virtual-training-legacy/upstream']) {
+ const { fitObject, applyPublishedSceneTransform, TrainingSceneEditor } = await renderingFunctions(`${runtime}/training-scene-editor.js`)
+
+ test(`${runtime}: fitting an authored skinned model uses its current pose and real proportions`, () => {
+ const { root, mesh } = skinFixture()
+ new THREE.Box3().setFromObject(root) // stale cached GLTF bounds
+ fitObject(root, { maxSize: 6, center: [0, 0, 0] })
+ const box = new THREE.Box3().setFromObject(root)
+ const size = box.getSize(new THREE.Vector3())
+ close(size.x, 1.2)
+ close(size.y, 2.4)
+ close(size.z, 6)
+ close(box.min.y, 0)
+ close(root.scale.x, 0.6)
+ assertSkinFitsCachedBounds(mesh)
+ })
+
+ test(`${runtime}: published scene restore preserves saved scale and refreshes skin culling bounds`, () => {
+ const { root, bone, mesh } = skinFixture()
+ refreshSceneModelBounds(root)
+ const item = {
+ position: [-7.5, 1.06, -0.45], rotation: [0.1, 0.4, -0.2],
+ scale: [0.0361637593257, 0.06, 0.1], visible: true,
+ }
+ bone.position.set(1, 17, 3)
+ applyPublishedSceneTransform(root, item)
+ assert.deepEqual(root.position.toArray(), item.position)
+ assert.deepEqual(root.rotation.toArray().slice(0, 3), item.rotation)
+ assert.deepEqual(root.scale.toArray(), item.scale)
+ assert.deepEqual(bone.position.toArray(), [1, 17, 3])
+ assertSkinFitsCachedBounds(mesh)
+ })
+
+ test(`${runtime}: selecting and clearing a skinned training target keeps bounds consistent`, () => {
+ const { root, mesh } = skinFixture()
+ refreshSceneModelBounds(root)
+ const context = {
+ targetMaterials: new Map([['fox', []]]), targetObjects: new Map([['fox', root]]),
+ targetScales: new Map([['fox', root.scale.clone()]]), publishedSceneRoot: root,
+ }
+ TrainingSceneEditor.prototype.highlightTarget.call(context, 'fox')
+ close(root.scale.x, 0.104)
+ assertSkinFitsCachedBounds(mesh)
+ TrainingSceneEditor.prototype.highlightTarget.call(context, '')
+ close(root.scale.x, 0.1)
+ assertSkinFitsCachedBounds(mesh)
+ })
+
+ test(`${runtime}: ground datum follows the final authored root scale`, () => {
+ const { root } = skinFixture()
+ root.position.set(0, 0, 0)
+ fitObject(root, { maxSize: 6, center: [0, 0, 0], groundLevelY: -1.5 })
+ close(root.position.y - 1.5 * root.scale.y, 0)
+ })
+
+ if (runtime === 'guide-legacy/demo-src') {
+ test('API-backed scene bind reports a failed load and permits a same-snapshot retry', async () => {
+ const failure = new Error('controlled asset unavailable')
+ let calls = 0
+ const context = {
+ environmentUrl: '', environmentModel: null,
+ workshop: { visible: false }, machine: { visible: false },
+ publishedSceneSignature: '', publishedSceneObjects: [],
+ applyRendering() {}, async loadEnvironment() {},
+ async loadPublishedSceneSnapshot() {
+ calls += 1
+ if (calls === 1) return { count: 0, objects: [], error: failure }
+ this.publishedSceneObjects = [new THREE.Group()]
+ return { count: 1, objects: this.publishedSceneObjects }
+ },
+ }
+ const project = { scenario: { publishedSceneId: '160', sceneResource: { snapshot: { objects: [{ sceneId: 'fox' }] } } } }
+ const failed = await TrainingSceneEditor.prototype.setProject.call(context, project)
+ assert.equal(failed.error, failure)
+ assert.equal(failed.snapshotLoaded, false)
+ const recovered = await TrainingSceneEditor.prototype.setProject.call(context, project)
+ assert.equal(calls, 2)
+ assert.equal(recovered.error, undefined)
+ assert.equal(recovered.snapshotLoaded, true)
+ assert.equal(recovered.objectCount, 1)
+ })
+ }
+}
+
+test('cached published skins retain independent bones after each scene instance is transformed', async () => {
+ const { loadCachedPublishedModel, clearTrainingModelCache } = await import(new URL('guide-legacy/demo-src/training-model-cache.js', webSource))
+ clearTrainingModelCache()
+ const fixture = skinFixture()
+ const canonical = { scene: fixture.root, scenes: [fixture.root], animations: [] }
+ const loader = { loadAsync: async () => canonical }
+ const first = await loadCachedPublishedModel('fixture://fox.glb', loader)
+ const second = await loadCachedPublishedModel('fixture://fox.glb', loader)
+ let firstSkin, secondSkin
+ first.scene.traverse(node => { if (node.isSkinnedMesh) firstSkin = node })
+ second.scene.traverse(node => { if (node.isSkinnedMesh) secondSkin = node })
+ assert.notEqual(firstSkin.skeleton.bones[0], secondSkin.skeleton.bones[0])
+ assert.notEqual(firstSkin.skeleton.bones[0], fixture.bone)
+ firstSkin.skeleton.bones[0].position.y = 17
+ first.scene.scale.setScalar(0.0361637593257)
+ refreshSceneModelBounds(first.scene)
+ refreshSceneModelBounds(second.scene)
+ close(secondSkin.skeleton.bones[0].position.y, 5)
+ assertSkinFitsCachedBounds(firstSkin)
+ assertSkinFitsCachedBounds(secondSkin)
+ clearTrainingModelCache()
+})
diff --git a/tools/training-modes-api-live.mjs b/tools/training-modes-api-live.mjs
new file mode 100644
index 0000000..a749c56
--- /dev/null
+++ b/tools/training-modes-api-live.mjs
@@ -0,0 +1,356 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import crypto from 'node:crypto'
+import { fileURLToPath } from 'node:url'
+import { api, localCredentials } from './review-api.mjs'
+
+// Development integration test. Creates isolated students, tasks and runs;
+// credentials stay in memory and no hardware adapter is contacted.
+// node tools/training-modes-api-live.mjs --physical 163:215 --confrontation 164:216
+const args = process.argv.slice(2)
+const parameter = name => args[args.indexOf(name) + 1]
+function reference(name) {
+ if (!args.includes(name) || !/^\d+:\d+$/.test(parameter(name) || '')) throw new Error(`Required: ${name} projectId:publishedVersionId`)
+ const [projectId, versionId] = parameter(name).split(':')
+ return { projectId, versionId }
+}
+const waitForConfrontation = args.includes('--wait-confrontation')
+const references = { PHYSICAL: reference('--physical'), CONFRONTATION: waitForConfrontation ? null : reference('--confrontation') }
+const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-modes-20260905')
+const readyFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../.codex-tmp/training-modes-confrontation-ready.json')
+fs.mkdirSync(reportDir, { recursive: true })
+const evidencePath = path.join(reportDir, 'api-evidence.json')
+const suffix = Date.now().toString(36).toUpperCase()
+const prefix = `UTE2E-MODES-${suffix}`
+const evidence = {
+ title: '实装人工事件与红蓝对抗正式 API 闭环', status: 'RUNNING',
+ startedAt: new Date().toISOString(), baseURL: 'http://127.0.0.1:6180',
+ method: '真实 Auth / Gateway / Tran API;新建隔离学生与开发测试任务;不连接设备、视觉推理或外部通知。',
+ references, checks: [], assignments: [], runs: [], actors: [], requests: [],
+ limitations: [
+ '实装事件均为本次自动化提交的 MANUAL 人工确认或明确的权限拒绝测试,不代表真实设备、PLC、摄像机或视觉模型联调。',
+ '教学运行结果来自正式 API;默认实装离线回放和默认对抗本地工作台的显示另行记录,不混同为服务端运行闭环。',
+ '对抗按四名隔离学员的独立会话依次操作,检查服务端截止时间已生效;未等待整局自然超时,也不代表多人同时操作的压力或网络同步测试。',
+ '测试账号使用本次随机密码,密码与会话仅在运行内存中;报告只保留用户 ID、队伍和岗位以便追溯。',
+ ],
+}
+const credentials = localCredentials()
+const ephemeralPassword = `Tm!${crypto.randomBytes(8).toString('hex')}`
+const secretValues = [credentials.username, credentials.password, ephemeralPassword]
+const sessions = []
+let admin
+const save = () => fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`)
+function safeError(error) {
+ let result = error instanceof Error ? error.message : String(error)
+ for (const value of secretValues) if (value) result = result.split(value).join('[redacted]')
+ return result.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[redacted]')
+}
+async function call(url, actor, body, method) {
+ const result = await api(url, typeof actor === 'string' ? actor : actor?.token, body, method)
+ if (url.startsWith('/api/tran/v1/teaching/')) evidence.requests.push({ method: method || (body === undefined ? 'GET' : 'POST'), path: url, status: result.status })
+ return result
+}
+async function request(url, actor, body, method) {
+ const result = await call(url, actor, body, method)
+ if (result.status !== 200) throw new Error(`${method || (body === undefined ? 'GET' : 'POST')} ${url}: HTTP ${result.status}; ${safeError(result.message || '')}`)
+ return result.data
+}
+async function denied(url, actor, body, expected, method) {
+ const result = await call(url, actor, body, method)
+ assert.ok([expected].flat().includes(result.status), `${method || 'POST'} ${url}: expected ${expected}, received ${result.status}: ${safeError(result.message || '')}`)
+ return { status: result.status, message: safeError(result.message || '') }
+}
+function passed(title, details) { evidence.checks.push({ title, status: 'PASS', details }); save() }
+async function section(title, fn) {
+ try { await fn() } catch (error) {
+ evidence.checks.push({ title, status: 'FAIL', details: safeError(error) }); save()
+ }
+}
+const uuid = () => crypto.randomUUID()
+const teaching = relative => `/api/tran/v1/teaching${relative}`
+const codeOf = step => String(step.stepCode || step.code || step.id || '')
+const actionOf = step => String(step.actionCode || step.action?.code || '')
+const slimRun = run => ({ id: run.id, assignmentId: run.assignmentId, status: run.status, version: run.version, progressPercent: run.progressPercent, currentStepCode: run.currentStepCode, score: run.score, passed: run.passed })
+const slimFacts = facts => facts.map(fact => ({ stepCode: fact.stepCode, status: fact.statusCode, targetProgressPercent: fact.targetProgressPercent, attemptCount: fact.attemptCount, failureCount: fact.failureCount, evidenceSourceCode: fact.evidenceSourceCode }))
+const rowsOf = value => value?.records || value || []
+const runOf = (id, actor) => request(teaching(`/runs/${id}`), actor)
+const factsOf = (id, actor) => request(teaching(`/runs/${id}/steps`), actor)
+function rememberRun(run, extra = {}) {
+ const entry = { ...slimRun(run), ...extra }
+ const index = evidence.runs.findIndex(item => item.id === run.id)
+ if (index >= 0) evidence.runs[index] = { ...evidence.runs[index], ...entry }
+ else evidence.runs.push(entry)
+ save()
+}
+async function loginAdmin() {
+ const login = await request('/api/auth/v1/auth/login', null, { ...credentials, roleCode: 'admin', rememberMe: false })
+ admin = { token: login.accessToken }
+ secretValues.push(admin.token)
+ sessions.push(admin)
+}
+async function createActors() {
+ const roles = rowsOf(await request('/api/auth/v1/roles?size=100', admin))
+ const role = roles.find(item => item.code === 'student')
+ assert.ok(role?.id, 'student role must exist')
+ const context = await request('/api/auth/v1/auth/login-context', admin)
+ const department = context.departments?.find(item => item.children?.length)?.children?.[0] || context.departments?.[0]
+ assert.ok(department?.id, 'An active department must exist')
+ const actors = []
+ for (const team of ['RED', 'BLUE']) for (const position of ['COMMANDER', 'OPERATOR']) {
+ const username = `tm_${suffix.toLowerCase()}_${team[0].toLowerCase()}${position[0].toLowerCase()}`
+ secretValues.push(username)
+ const created = await request('/api/auth/v1/users', admin, {
+ username, displayName: `模式实测-${suffix}-${team}-${position}`,
+ departmentId: department.id, roleIds: [role.id], defaultRoleId: role.id,
+ enabled: true, password: ephemeralPassword, remark: `${prefix}; isolated development API test; no hardware`,
+ })
+ const user = created.user || created
+ assert.ok(user.id, 'New student must have an id')
+ evidence.actors.push({ userId: String(user.id), team, position, departmentId: String(department.id) })
+ save()
+ const logged = await request('/api/auth/v1/auth/login', null, { userId: user.id, roleCode: 'student', password: ephemeralPassword, rememberMe: false })
+ const actor = { id: String(user.id), token: logged.accessToken, team, position }
+ secretValues.push(actor.token)
+ sessions.push(actor)
+ actors.push(actor)
+ }
+ return actors
+}
+async function contentReference(channel) {
+ const reference = references[channel]
+ const detail = await request(`/api/tran/v1/content/projects/${reference.projectId}`, admin)
+ const project = detail.project || detail
+ assert.equal(project.status, 'PUBLISHED')
+ assert.equal(String(project.publishedVersionId), reference.versionId)
+ const version = await request(`/api/tran/v1/content/projects/${reference.projectId}/versions/${reference.versionId}`, admin)
+ const content = version.content || version.version?.content
+ assert.ok(content && Array.isArray(content.steps) && content.steps.length > 1, `${channel}: published steps required`)
+ return { reference, content, sceneDependencies: (version.dependencies || []).filter(dependency => dependency.relationType === 'TRAINING_SCENE') }
+}
+async function verifyFrozenSnapshots(profiles) {
+ assert.equal(evidence.assignments.length, 2, 'Both isolated teaching assignments must exist')
+ const snapshots = []
+ for (const assignment of evidence.assignments) {
+ const profile = profiles[assignment.channel]
+ const current = await request(teaching(`/assignments/${assignment.id}`), admin)
+ assert.equal(String(current.contentProjectId), profile.reference.projectId)
+ assert.equal(String(current.contentVersionId), profile.reference.versionId)
+ assert.equal(current.definitionSnapshot?.trainingMode, assignment.channel)
+ assert.deepEqual(current.definitionSnapshot.steps.map(codeOf), profile.content.steps.map(codeOf))
+ assert.equal(profile.sceneDependencies.length, 1)
+ const scene = current.definitionSnapshot.teachingScene
+ const expected = profile.sceneDependencies[0]
+ assert.equal(String(scene.sourceProjectId), String(expected.targetProjectId))
+ assert.equal(String(scene.sourceVersionId), String(expected.targetVersionId))
+ const models = scene.objects.filter(object => object.targetProjectId).map(object => ({ projectId: object.targetProjectId, versionId: object.targetVersionId, assetCode: object.assetCode }))
+ assert.ok(models.length && models.every(model => model.versionId && model.assetCode))
+ const frozenScene = { projectId: scene.sourceProjectId, versionId: scene.sourceVersionId, models }
+ assignment.frozenScene = frozenScene
+ snapshots.push({ assignmentId: assignment.id, channel: assignment.channel, content: profile.reference, frozenScene })
+ }
+ passed('正式任务快照固定训练、场景与模型发布版本', { snapshots })
+}
+async function waitForConfrontationPublication() {
+ evidence.waitingFor = 'CONFRONTATION published project/version reference from the UI test owner'
+ save()
+ console.log(JSON.stringify({ phase: 'WAITING_FOR_CONFRONTATION', physicalChecks: evidence.checks.length, readyFile }))
+ const until = Date.now() + 30 * 60 * 1000
+ while (Date.now() < until) {
+ if (fs.existsSync(readyFile) && fs.statSync(readyFile).mtimeMs >= Date.parse(evidence.startedAt)) {
+ const ready = JSON.parse(fs.readFileSync(readyFile, 'utf8').replace(/^\uFEFF/, ''))
+ assert.match(String(ready.projectId), /^\d+$/)
+ assert.match(String(ready.versionId), /^\d+$/)
+ references.CONFRONTATION = { projectId: String(ready.projectId), versionId: String(ready.versionId) }
+ delete evidence.waitingFor
+ save()
+ return
+ }
+ await new Promise(resolve => setTimeout(resolve, 1500))
+ }
+ throw new Error('Timed out waiting for the published CONFRONTATION UI reference; PHYSICAL evidence and all retained IDs are preserved')
+}
+async function newAssignment(channel, profile, actors) {
+ const members = channel === 'PHYSICAL'
+ ? [{ userId: actors[0].id, memberType: 'LEARNER', teamCode: 'NEUTRAL', positionCode: 'OPERATOR', sortOrder: 0 }]
+ : actors.map((actor, index) => ({ userId: actor.id, memberType: 'LEARNER', teamCode: actor.team, positionCode: actor.position, sortOrder: index }))
+ const created = await request(teaching('/assignments'), admin, {
+ code: `${prefix}-${channel}`, name: `开发实测-${channel === 'PHYSICAL' ? '实装人工事件' : '红蓝岗位对抗'}-${suffix}`,
+ description: '开发环境正式 API 流程验证;人工/模拟事件,不代表设备联调或实际技能评定。',
+ channel, assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'ASSIGNED',
+ collaborationMode: channel === 'PHYSICAL' ? 'INDIVIDUAL' : 'TEAM', digitalHumanAllowed: false,
+ contentProjectId: profile.reference.projectId, contentVersionId: profile.reference.versionId, members,
+ })
+ evidence.assignments.push({ id: created.id, code: created.code, channel, contentProjectId: profile.reference.projectId, contentVersionId: profile.reference.versionId, status: created.status })
+ save()
+ const published = await request(teaching(`/assignments/${created.id}/publish`), admin, { version: created.version })
+ evidence.assignments.at(-1).status = published.status
+ evidence.assignments.at(-1).version = published.version
+ save()
+ return published
+}
+async function accept(assignment, actor, physical = false) {
+ const run = await request(teaching(`/assignments/${assignment.id}/accept`), actor, {
+ assignmentVersion: assignment.version, teamCode: physical ? 'NEUTRAL' : actor.team,
+ positionCode: physical ? 'OPERATOR' : actor.position,
+ })
+ rememberRun(run, { channel: assignment.channel, team: physical ? 'NEUTRAL' : actor.team })
+ return run
+}
+async function startWithReplay(run, actor) {
+ const body = { version: run.version, commandId: uuid() }
+ const started = await request(teaching(`/runs/${run.id}/start`), actor, body)
+ const replay = await request(teaching(`/runs/${run.id}/start`), actor, body)
+ assert.equal(replay.version, started.version)
+ const facts = await factsOf(run.id, actor)
+ assert.equal(facts[0].attemptCount, 1)
+ assert.equal(facts[0].statusCode, 'IN_PROGRESS')
+ passed('开始命令重放不会重复激活或递增次数', { runId: run.id, version: started.version, attemptCount: facts[0].attemptCount })
+ return started
+}
+async function submitAndReview(run, actor, channel) {
+ const body = { version: run.version, commandId: uuid(), summary: '开发自动化合同测试完成;不代表真实技能训练。', evidence: { testOnly: true, source: 'development-api-contract' }, payload: {} }
+ const submitted = await request(teaching(`/runs/${run.id}/submit`), actor, body)
+ const replay = await request(teaching(`/runs/${run.id}/submit`), actor, body)
+ assert.equal(submitted.status, 'SUBMITTED')
+ assert.equal(replay.version, submitted.version)
+ const reviewBody = { version: submitted.version, commandId: uuid(), score: 100, passed: true, feedback: '仅测试正式 API 评定留痕,不代表实际维修能力。', rubric: { testOnly: true } }
+ const reviewed = await request(teaching(`/runs/${run.id}/review`), admin, reviewBody)
+ const reviewReplay = await request(teaching(`/runs/${run.id}/review`), admin, reviewBody)
+ assert.equal(reviewed.status, 'REVIEWED')
+ assert.equal(reviewReplay.version, reviewed.version)
+ const facts = await factsOf(run.id, admin)
+ assert.ok(facts.every(fact => fact.statusCode === 'COMPLETED'))
+ rememberRun(reviewed, { channel, stepFacts: slimFacts(facts) })
+ passed(`${channel} 提交、教员评定与结果回读`, { run: slimRun(reviewed), submitReplay: true, reviewReplay: true, facts: slimFacts(facts) })
+ return reviewed
+}
+async function physicalFlow(profile, actors) {
+ const steps = profile.content.steps
+ assert.ok(steps.every(step => step.physicalEventRule?.source === 'MANUAL'), 'This test only drives explicitly configured MANUAL rules')
+ assert.deepEqual(steps.map(step => step.physicalEventRule.progressPercent), steps.map((_, index) => Math.round((index + 1) * 100 / steps.length)))
+ const assignment = await newAssignment('PHYSICAL', profile, actors)
+ const actor = actors[0]
+ let run = await accept(assignment, actor, true)
+ run = await startWithReplay(run, actor)
+ passed('实装任务固定发布版本、分配学员并开始', { assignmentId: assignment.id, run: slimRun(run), content: profile.reference, rules: steps.map(step => ({ stepCode: codeOf(step), source: step.physicalEventRule.source, eventType: step.physicalEventRule.eventType, progressPercent: step.physicalEventRule.progressPercent })) })
+ const eventBody = (step, extra = {}) => ({ eventId: `MANUAL-${uuid()}`, runVersion: run.version, source: 'MANUAL', eventType: step.physicalEventRule.eventType, stepCode: codeOf(step), manualConfirmed: true, occurredAt: Math.floor(Date.now() / 1000), publicPayload: { testOnly: true, evidenceKind: 'api-contract-simulation' }, ...extra })
+ const forbiddenProgress = await denied(teaching(`/runs/${run.id}/progress`), actor, { version: run.version, commandId: uuid(), completedStepCode: codeOf(steps[0]), actionCode: actionOf(steps[0]) }, 403, 'PUT')
+ const prematureSubmit = await denied(teaching(`/runs/${run.id}/submit`), actor, { version: run.version, commandId: uuid(), summary: 'negative test' }, 409)
+ const skipped = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[1]), 409)
+ const untrustedDevice = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0], { source: 'DEVICE' }), 403)
+ const managerManual = await denied(teaching(`/runs/${run.id}/physical-events`), admin, eventBody(steps[0]), 403)
+ assert.equal((await runOf(run.id, actor)).progressPercent, 0)
+ passed('实装拒绝普通进度接口、抢跑、提前提交及错误事件身份', { runId: run.id, forbiddenProgress, prematureSubmit, skipped, untrustedDevice, managerManual, progressUnchanged: 0 })
+
+ const failed = await request(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0], { manualConfirmed: false }))
+ assert.equal(failed.outcome, 'UNMATCHED')
+ assert.equal(failed.progressApplied, false)
+ let facts = await factsOf(run.id, actor)
+ assert.equal(facts[0].statusCode, 'FAILED')
+ assert.equal(facts[0].failureCount, 1)
+ run = await runOf(run.id, actor)
+ const beforeRetry = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0]), 409)
+ run = await request(teaching(`/runs/${run.id}/start`), actor, { version: run.version, commandId: uuid() })
+ facts = await factsOf(run.id, actor)
+ assert.equal(facts[0].attemptCount, 2)
+ passed('实装不匹配事件失败,必须重试后才可继续', { runId: run.id, outcome: failed.outcome, beforeRetry, facts: slimFacts(facts) })
+
+ const progression = []
+ for (const [index, step] of steps.entries()) {
+ const body = eventBody(step)
+ const result = await request(teaching(`/runs/${run.id}/physical-events`), actor, body)
+ assert.equal(result.outcome, 'MATCHED')
+ assert.equal(result.progressApplied, true)
+ const after = await runOf(run.id, actor)
+ assert.equal(after.progressPercent, Math.round((index + 1) * 100 / steps.length))
+ const replay = await request(teaching(`/runs/${run.id}/physical-events`), actor, body)
+ assert.equal(replay.runVersion, after.version)
+ assert.equal((await runOf(run.id, actor)).version, after.version)
+ const differentPayload = await denied(teaching(`/runs/${run.id}/physical-events`), actor, { ...body, publicPayload: { testOnly: true, altered: true } }, 409)
+ progression.push({ stepCode: codeOf(step), eventId: result.eventId, progress: after.progressPercent, replayVersion: replay.runVersion, alteredEventStatus: differentPayload.status })
+ run = after
+ }
+ facts = await factsOf(run.id, actor)
+ assert.ok(facts.every(fact => fact.statusCode === 'COMPLETED' && fact.evidenceSourceCode === 'MANUAL'))
+ passed('实装逐步人工确认、事件幂等与载荷冲突', { runId: run.id, progression, facts: slimFacts(facts) })
+ await submitAndReview(run, actor, 'PHYSICAL')
+}
+async function confrontationFlow(profile, actors) {
+ const configuration = profile.content.channelProfiles?.CONFRONTATION || profile.content.modeConfig
+ assert.equal(configuration.teamSize, 2, 'Isolated test expects two learners per team')
+ assert.ok(configuration.submitPositions.includes('COMMANDER'))
+ assert.ok(profile.content.steps.every(step => step.allowedPositions?.includes('COMMANDER') && !step.allowedPositions?.includes('OPERATOR')), 'Each tested action must be COMMANDER-only for the negative role test')
+ const assignment = await newAssignment('CONFRONTATION', profile, actors)
+ const runs = new Map()
+ for (const actor of actors) {
+ const run = await accept(assignment, actor)
+ if (runs.has(actor.team)) assert.equal(run.id, runs.get(actor.team).id)
+ runs.set(actor.team, run)
+ }
+ assert.notEqual(runs.get('RED').id, runs.get('BLUE').id)
+ const listed = rowsOf(await request(teaching(`/runs?assignmentId=${assignment.id}&size=100`), admin))
+ assert.equal(listed.length, 2)
+ passed('对抗红蓝两队各两人,按队伍共享且运行互相独立', { assignmentId: assignment.id, content: profile.reference, configuration: { teamSize: configuration.teamSize, roundDurationMinutes: configuration.roundDurationMinutes, submitPositions: configuration.submitPositions }, runs: listed.map(slimRun) })
+ const redCommander = actors.find(actor => actor.team === 'RED' && actor.position === 'COMMANDER')
+ const crossTeam = await denied(teaching(`/runs/${runs.get('BLUE').id}`), redCommander, undefined, 403, 'GET')
+ passed('对抗学员不能读取另一队运行明细', { requestingTeam: 'RED', otherRunId: runs.get('BLUE').id, response: crossTeam })
+ for (const team of ['RED', 'BLUE']) {
+ const commander = actors.find(actor => actor.team === team && actor.position === 'COMMANDER')
+ const operator = actors.find(actor => actor.team === team && actor.position === 'OPERATOR')
+ let run = await startWithReplay(runs.get(team), commander)
+ const execution = await request(teaching(`/runs/${run.id}/execution`), commander)
+ assert.ok(Number.isFinite(execution.deadlineAt) && execution.deadlineAt > execution.serverNow, 'Server deadline must be active')
+ const steps = profile.content.steps
+ const progressBody = step => ({ version: run.version, commandId: uuid(), completedStepCode: codeOf(step), actionCode: actionOf(step), checkpoint: { testOnly: true }, automaticResult: {} })
+ const wrongPosition = await denied(teaching(`/runs/${run.id}/progress`), operator, progressBody(steps[0]), 403, 'PUT')
+ const skipped = await denied(teaching(`/runs/${run.id}/progress`), commander, progressBody(steps[1]), [400, 409], 'PUT')
+ const earlySubmit = await denied(teaching(`/runs/${run.id}/submit`), commander, { version: run.version, commandId: uuid(), summary: 'negative test' }, 409)
+ assert.equal((await runOf(run.id, commander)).progressPercent, 0)
+ passed(`${team} 队岗位越权、跳步和提前提交均拒绝`, { runId: run.id, wrongPosition, skipped, earlySubmit, deadlineAt: execution.deadlineAt, serverNow: execution.serverNow })
+ const progression = []
+ for (const [index, step] of steps.entries()) {
+ const body = progressBody(step)
+ const advanced = await request(teaching(`/runs/${run.id}/progress`), commander, body, 'PUT')
+ const replay = await request(teaching(`/runs/${run.id}/progress`), commander, body, 'PUT')
+ assert.equal(replay.version, advanced.version)
+ assert.equal(advanced.progressPercent, Math.round((index + 1) * 100 / steps.length))
+ const conflict = await denied(teaching(`/runs/${run.id}/progress`), commander, { ...body, checkpoint: { testOnly: true, altered: true } }, 409, 'PUT')
+ progression.push({ stepCode: codeOf(step), progress: advanced.progressPercent, replayVersion: replay.version, changedCommandStatus: conflict.status })
+ run = advanced
+ }
+ const wrongSubmitPosition = await denied(teaching(`/runs/${run.id}/submit`), operator, { version: run.version, commandId: uuid(), summary: 'negative role test' }, 403)
+ passed(`${team} 队授权动作逐步推进、重放幂等、提交负责人限制`, { runId: run.id, progression, wrongSubmitPosition })
+ await submitAndReview(run, commander, 'CONFRONTATION')
+ if (team === 'RED') {
+ const blue = await runOf(runs.get('BLUE').id, admin)
+ assert.equal(blue.progressPercent, 0)
+ passed('红队完成不会推进蓝队进度', { redRunId: run.id, blueRun: slimRun(blue) })
+ }
+ }
+}
+
+save()
+try {
+ await loginAdmin()
+ const physical = await contentReference('PHYSICAL')
+ let confrontation = waitForConfrontation ? null : await contentReference('CONFRONTATION')
+ const actors = await createActors()
+ await section('PHYSICAL 正式流程', () => physicalFlow(physical, actors))
+ if (waitForConfrontation) {
+ await waitForConfrontationPublication()
+ confrontation = await contentReference('CONFRONTATION')
+ }
+ await section('CONFRONTATION 正式流程', () => confrontationFlow(confrontation, actors))
+ await section('固定发布快照回读', () => verifyFrozenSnapshots({ PHYSICAL: physical, CONFRONTATION: confrontation }))
+} catch (error) {
+ evidence.checks.push({ title: '测试准备', status: 'FAIL', details: safeError(error) })
+} finally {
+ for (const actor of sessions.reverse()) await api('/api/auth/v1/auth/logout', actor.token, {}).catch(() => {})
+ evidence.status = evidence.checks.some(check => check.status === 'FAIL') ? 'FAIL' : 'PASS'
+ evidence.finishedAt = new Date().toISOString()
+ evidence.retainedData = { assignments: evidence.assignments.map(item => item.id), runs: evidence.runs.map(item => item.id), users: evidence.actors.map(item => item.userId), note: '仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。' }
+ save()
+ console.log(JSON.stringify({ status: evidence.status, checks: evidence.checks.length, passed: evidence.checks.filter(check => check.status === 'PASS').length, evidence: 'reports/training-modes-20260905/api-evidence.json', retainedData: evidence.retainedData }))
+ if (evidence.status !== 'PASS') process.exitCode = 1
+}
diff --git a/tools/training-project-metadata.test.mjs b/tools/training-project-metadata.test.mjs
new file mode 100644
index 0000000..5ec275a
--- /dev/null
+++ b/tools/training-project-metadata.test.mjs
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+
+const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
+const ts = requireWeb('typescript')
+const source = new URL('../../unreal_tran_web/src/features/', import.meta.url)
+const compile = path => ts.transpileModule(readFileSync(new URL(path, source), 'utf8'), {
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+}).outputText
+const url = text => `data:text/javascript;base64,${Buffer.from(text).toString('base64')}`
+const documentUrl = url(compile('training/trainingDocument.ts'))
+const bridgeUrl = url(compile('training-legacy/bridge.ts').replace(/from ['"]\.\.\/training\/trainingDocument['"]/, `from '${documentUrl}'`))
+const { trainingProjectMetadata } = await import(url(compile('training-legacy/trainingProjectMetadata.ts')))
+const { toFormalContent, toLegacyProject } = await import(bridgeUrl)
+const { saveTrainingWithCover } = await import(url(compile('training-legacy/trainingCoverPersistence.ts')))
+const current = () => ({ id: '161', type: 'TRAINING', name: 'Original name', description: 'Project purpose from creation',
+ categoryCode: 'RESOURCE-TEST', trainingMode: 'VIRTUAL', content: {}, assets: [], coverUri: '', version: 3 })
+
+test('edited project name and task settings survive actual formal/legacy save roundtrip', async () => {
+ const original = current()
+ const legacy = { name: ' Fox inspection training ', trainingMode: 'virtual',
+ task: { description: 'Inspect the visible fox', category: 'Task subject', duration: 12, level: 'L2' },
+ runtime: { startMode: 'exam' }, steps: [] }
+ const metadata = trainingProjectMetadata(legacy, original)
+ const { content } = toFormalContent(legacy, metadata.trainingMode, original.content)
+ let received
+ const result = await saveTrainingWithCover(original, { ...original, ...metadata, content, dependencies: [] },
+ async () => { throw new Error('No viewport in metadata unit test') }, {
+ upload: async () => { throw new Error('unexpected upload') },
+ update: async body => { received = structuredClone(body); return { ...body, version: 4 } },
+ }, () => {})
+ const reopened = toLegacyProject(result.project)
+ assert.equal(received.name, 'Fox inspection training')
+ assert.equal(reopened.name, 'Fox inspection training')
+ assert.equal(received.description, original.description, 'project purpose is independent of task instructions')
+ assert.equal(received.categoryCode, original.categoryCode, 'task category must not replace the project category')
+ assert.equal(reopened.task.description, 'Inspect the visible fox')
+ assert.equal(reopened.task.category, 'Task subject')
+ assert.equal(reopened.runtime.startMode, 'exam', 'demo/practice/exam is runtime mode, not training channel')
+ assert.equal(reopened.task.duration, 12)
+ assert.equal(received.content.name, undefined, 'project name belongs only to the envelope')
+ assert.equal(original.name, 'Original name')
+})
+
+test('supported document training modes agree across envelope, normalized content and reopened editor', () => {
+ for (const mode of ['virtual', 'physical', 'confrontation']) {
+ const previous = current()
+ const legacy = { name: 'Edited', trainingMode: mode, steps: [], task: {} }
+ const metadata = trainingProjectMetadata(legacy, previous)
+ const { content } = toFormalContent(legacy, metadata.trainingMode, previous.content)
+ const reopened = toLegacyProject({ ...previous, ...metadata, content })
+ assert.equal(metadata.trainingMode, mode.toUpperCase())
+ assert.equal(content.trainingMode, metadata.trainingMode)
+ assert.ok(content.enabledChannels.includes(metadata.trainingMode))
+ assert.equal(reopened.trainingMode, mode)
+ }
+})
+
+test('missing envelope fields retain existing values while invalid supplied mode fails explicitly', () => {
+ const previous = current()
+ assert.deepEqual(trainingProjectMetadata({ task: { description: 'Default task prose' } }, previous), {
+ name: previous.name, description: previous.description, categoryCode: previous.categoryCode, trainingMode: previous.trainingMode,
+ })
+ assert.throws(() => trainingProjectMetadata({ trainingMode: 'exam' }, previous), /训练形态无效/)
+})
+
+test('server mode rejection propagates without treating metadata as successfully saved', async () => {
+ const previous = current()
+ const metadata = trainingProjectMetadata({ name: 'Changed', trainingMode: 'physical' }, previous)
+ const denied = new Error('模式修改不允许')
+ await assert.rejects(saveTrainingWithCover(previous, { ...previous, ...metadata },
+ async () => { throw new Error('No viewport') }, {
+ upload: async () => { throw new Error('unexpected upload') },
+ update: async () => { throw denied },
+ }, () => {}), error => error === denied)
+ assert.equal(previous.name, 'Original name')
+ assert.equal(previous.trainingMode, 'VIRTUAL')
+})
diff --git a/tools/training-return-tabs.e2e.mjs b/tools/training-return-tabs.e2e.mjs
new file mode 100644
index 0000000..f144508
--- /dev/null
+++ b/tools/training-return-tabs.e2e.mjs
@@ -0,0 +1,243 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { chromium, expect } from '@playwright/test'
+import { localCredentials } from './review-api.mjs'
+
+// Real UI navigation with existing fixtures; all business mutations are blocked.
+// Only the failure response for "save and leave" is simulated, never sent upstream.
+const reportDir = path.resolve('reports/training-return-tabs-20260906')
+fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true })
+const listPath = '/content/training-projects'
+const editPath = id => `${listPath}/${id}/legacy`
+const evidence = {
+ title: '训练编排返回列表与页面标签聚焦回归', status: 'RUNNING', startedAt: new Date().toISOString(),
+ environment: { browser: 'Microsoft Edge / Playwright', role: '真实管理员登录后切换教员', origins: ['http://127.0.0.1:6180', 'http://192.168.31.168:6180'] },
+ checks: [], blockedWrites: [], pageErrors: [], projects: [],
+ preparationNotes: [],
+ scope: ['仅验证顶部返回列表及现有离开确认。打开已有训练 161/162,业务写请求全部由浏览器拦截。', '“保存并离开”的失败由浏览器模拟 HTTP 503;不代表真实服务端故障。没有真实保存、发布、上传或修改这两条项目。', '本轮未测试成功保存或成功发布;其业务路径未被本次代码修改。'],
+ commands: [{ command: 'pnpm run build', status: 'PASS', details: '根代理执行,含 vue-tsc、Vite 与 iframe 校验;既有构建警告不影响退出码 0。' }, { command: 'git diff --check', status: 'PASS', details: '根代理执行,退出码 0。' }],
+}
+const credentials = localCredentials()
+const browser = await chromium.launch({ channel: 'msedge', headless: true })
+let currentPage, currentTitle = '准备'
+const save = () => fs.writeFileSync(path.join(reportDir, 'evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`)
+function safeError(error) {
+ let result = error instanceof Error ? error.message : String(error)
+ for (const value of [credentials.username, credentials.password]) if (value) result = result.split(value).join('[已隐藏]')
+ return result.replace(/Bearer\s+\S+/gi, 'Bearer [已隐藏]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[已隐藏]')
+}
+async function screenshot(page, name) {
+ const relative = `screenshots/${name}.png`
+ await page.screenshot({ path: path.join(reportDir, relative), fullPage: true })
+ return relative
+}
+function passed(title, details, screenshots) { evidence.checks.push({ title, status: 'PASS', details, screenshots }); save() }
+async function tabs(page) {
+ return page.evaluate(() => {
+ const data = JSON.parse(localStorage.getItem('unreal-tran:web:tabs:v1') || '{}')
+ const entries = Object.values(data).flatMap(bucket => bucket.tabs || [])
+ return { path: location.pathname, entries: entries.map(tab => ({ key: tab.key, fullPath: tab.fullPath, title: tab.title, locked: tab.locked })), labels: [...document.querySelectorAll('.page-tab-link')].map(tab => ({ title: tab.textContent.trim(), active: tab.getAttribute('aria-selected') === 'true' })) }
+ })
+}
+function has(snapshot, target) { return snapshot.entries.some(tab => tab.key === target) }
+async function editorReady(page, id) {
+ await expect(page.locator('.te-shell')).toBeVisible({ timeout: 90_000 })
+ await page.waitForFunction(id => String(globalThis.__training?.project?.id) === String(id) && globalThis.__training?.sceneEditor?.container?.dataset?.state === 'ready', id, { timeout: 90_000 })
+}
+async function openEditor(page, base, id) { await page.goto(`${base}${editPath(id)}`); await editorReady(page, id) }
+async function openList(page, base) { await page.goto(`${base}${listPath}`); await expect(page.locator('.platform-shell')).toBeVisible() }
+async function activate(page, target) {
+ const before = await tabs(page)
+ const index = before.entries.findIndex(tab => tab.key === target)
+ assert.ok(index >= 0, `Expected tab ${target}`)
+ await page.locator('.page-tab-link').nth(index).click()
+ await expect(page).toHaveURL(new RegExp(`${target.replaceAll('/', '\\/')}(?:\\?.*)?$`))
+}
+async function returnToList(page) { await page.locator('[data-action=return-to-list]').click() }
+async function expectReturned(page, closedId, retainedId) {
+ await expect(page).toHaveURL(new RegExp(`${listPath.replaceAll('/', '\\/')}(?:\\?.*)?$`))
+ await expect.poll(async () => has(await tabs(page), editPath(closedId))).toBe(false)
+ const snapshot = await tabs(page)
+ assert.ok(has(snapshot, listPath))
+ if (retainedId) assert.ok(has(snapshot, editPath(retainedId)))
+ await expect(page.locator('.training-project-card').first()).toBeVisible({ timeout: 30_000 })
+ await expect(page.locator('.training-project-grid > .el-loading-mask')).toBeHidden({ timeout: 30_000 })
+ await page.waitForFunction(() => [...document.querySelectorAll('.training-card-cover img')].every(image => image.complete), null, { timeout: 20_000 })
+ return snapshot
+}
+async function makeContext(base) {
+ const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN' })
+ let token = ''
+ const authResponses = []
+ await context.route('**/*', async route => {
+ const request = route.request(), pathname = new URL(request.url()).pathname
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) {
+ evidence.blockedWrites.push({ origin: base, path: pathname, method: request.method(), response: 503, forwarded: false })
+ return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'E2E_SIMULATED_FAILURE', message: '浏览器模拟保存失败:测试禁止写入现有项目', data: null }) })
+ }
+ return route.continue()
+ })
+ const page = await context.newPage()
+ currentPage = page
+ page.setDefaultTimeout(25_000)
+ page.on('pageerror', error => evidence.pageErrors.push(safeError(error)))
+ page.on('response', async response => {
+ if (new URL(response.url()).pathname.startsWith('/api/auth/')) authResponses.push({ path: new URL(response.url()).pathname, status: response.status() })
+ if (new URL(response.url()).pathname.startsWith('/api/auth/v1/auth/')) {
+ const payload = await response.json().catch(() => null)
+ if (payload?.data?.accessToken) token = payload.data.accessToken
+ }
+ })
+ await page.route('**/src/features/guide-legacy/demo-src/training-editor.js*', async route => {
+ const response = await route.fetch(), source = await response.text(), marker = 'const editor = new TrainingProjectEditor(root, options);'
+ assert.ok(source.includes(marker))
+ await route.fulfill({ response, body: source.replace(marker, `${marker} globalThis.__training = editor;`) })
+ })
+ async function login() {
+ await page.goto(`${base}/login`)
+ await page.locator('[data-role-code=admin]').click()
+ await page.locator('input[name=username]').fill(credentials.username)
+ await page.locator('input[name=password]').fill(credentials.password)
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await page.getByRole('button', { name: '登录系统', exact: true }).click()
+ try { await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 20_000 }); return }
+ catch { if (attempt === 2) throw new Error('真实登录三次未完成') }
+ }
+ }
+ await login()
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ await page.goto(`${base}/profile`)
+ const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' })
+ await expect(teacher).toBeVisible({ timeout: 20_000 })
+ if (!(await teacher.getAttribute('class')).includes('active')) await teacher.click()
+ await expect(teacher).toHaveClass(/active/, { timeout: 20_000 })
+ break
+ } catch (error) {
+ evidence.preparationNotes.push({ origin: base, attempt: attempt + 1, path: new URL(page.url()).pathname, authResponses: authResponses.slice(-12), error: safeError(error) })
+ if (await page.locator('.platform-shell').isVisible()) await screenshot(page, `profile-retry-${attempt + 1}`)
+ save()
+ if (attempt === 2) throw error
+ if (new URL(page.url()).pathname === '/login') await login()
+ }
+ }
+ const readProject = async id => {
+ let response
+ for (let attempt = 0; attempt < 3; attempt++) {
+ response = await fetch(`${base}/api/tran/v1/content/projects/${id}`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) })
+ if (response.status === 200) break
+ evidence.preparationNotes.push({ origin: base, stage: 'readProject', path: `/api/tran/v1/content/projects/${id}`, attempt: attempt + 1, status: response.status })
+ save()
+ if (response.status !== 503 || attempt === 2) break
+ await new Promise(resolve => setTimeout(resolve, 1200))
+ }
+ assert.equal(response.status, 200)
+ const detail = (await response.json()).data, project = detail.project
+ assert.ok(typeof detail.currentVersion?.checksum === 'string' && detail.currentVersion.checksum.length > 0, 'Actual content checksum is required for unchanged-project verification')
+ return { id: String(project.id), version: project.version, currentVersionId: project.currentVersionId, publishedVersionId: project.publishedVersionId, coverUri: project.coverUri, checksum: detail.currentVersion.checksum }
+ }
+ return { context, page, readProject }
+}
+
+save()
+try {
+ const base = evidence.environment.origins[0]
+ const { context, page, readProject } = await makeContext(base)
+ try {
+ const beforeProjects = await Promise.all(['161', '162'].map(readProject))
+ currentTitle = '手动切换标签保留原编辑页'
+ await openList(page, base)
+ await openEditor(page, base, '161')
+ await openEditor(page, base, '162')
+ const before = await tabs(page)
+ assert.ok(has(before, listPath) && has(before, editPath('161')) && has(before, editPath('162')))
+ await activate(page, editPath('161'))
+ await editorReady(page, '161')
+ const switched = await tabs(page)
+ assert.ok(has(switched, editPath('161')) && has(switched, editPath('162')))
+ passed(currentTitle, { before, after: switched, method: '真实页面标签点击' }, [await screenshot(page, '01-manual-switch-keeps-editors')])
+
+ currentTitle = '顶部返回只关闭当前编辑标签,保留列表及另一编辑标签'
+ await returnToList(page)
+ const returned = await expectReturned(page, '161', '162')
+ passed(currentTitle, returned, [await screenshot(page, '02-return-closes-current')])
+
+ currentTitle = '刷新后已关闭标签不复活'
+ await page.reload()
+ await expect(page.locator('.platform-shell')).toBeVisible()
+ const refreshed = await expectReturned(page, '161', '162')
+ passed(currentTitle, refreshed, [await screenshot(page, '03-refresh-no-resurrection')])
+
+ currentTitle = '未保存时 Escape 取消返回,原页与修改保留'
+ await openEditor(page, base, '161')
+ const input = page.locator('input[data-field=title]').filter({ visible: true }).first()
+ const originalTitle = await input.inputValue()
+ await input.fill(`${originalTitle}(标签返回临时测试)`)
+ await input.press('Tab')
+ await returnToList(page)
+ await expect(page.locator('.el-message-box')).toContainText('离开训练编排?')
+ const confirmation = await screenshot(page, '04-unsaved-return-confirmation')
+ await page.keyboard.press('Escape')
+ await expect(page.locator('.el-message-box')).toBeHidden()
+ assert.equal(new URL(page.url()).pathname, editPath('161'))
+ assert.ok(has(await tabs(page), editPath('161')))
+ await expect(page.locator('input[data-field=title]').filter({ visible: true }).first()).toHaveValue(`${originalTitle}(标签返回临时测试)`)
+ passed(currentTitle, await tabs(page), [confirmation, await screenshot(page, '05-escape-keeps-editor')])
+
+ currentTitle = '保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)'
+ await returnToList(page)
+ await page.getByRole('button', { name: '保存并离开', exact: true }).click()
+ await expect(page.locator('.el-message-box').filter({ hasText: '保存失败' })).toBeVisible({ timeout: 45_000 })
+ assert.equal(new URL(page.url()).pathname, editPath('161'))
+ assert.ok(has(await tabs(page), editPath('161')))
+ assert.ok(evidence.blockedWrites.some(write => write.method === 'PUT' && write.path.endsWith('/projects/161')))
+ passed(currentTitle, { tabs: await tabs(page), writes: evidence.blockedWrites, simulated: true }, [await screenshot(page, '06-save-failure-keeps-editor')])
+ await page.getByRole('button', { name: '知道了', exact: true }).click()
+
+ currentTitle = '明确放弃修改后返回并关闭当前编辑标签'
+ await returnToList(page)
+ await page.getByRole('button', { name: '放弃修改', exact: true }).click()
+ const discarded = await expectReturned(page, '161', '162')
+ passed(currentTitle, discarded, [await screenshot(page, '07-discard-closes-current')])
+
+ const afterProjects = await Promise.all(['161', '162'].map(readProject))
+ assert.deepEqual(afterProjects, beforeProjects)
+ evidence.projects = beforeProjects.map((before, index) => ({ ...before, unchanged: true, after: afterProjects[index] }))
+ passed('服务端两项目版本、封面与内容校验值未变', { projects: evidence.projects, forwardedBusinessWrites: 0 }, [])
+ } catch (error) {
+ if (await page.locator('.platform-shell').isVisible()) evidence.failureScreenshot = await screenshot(page, 'failure-localhost')
+ throw error
+ } finally { await context.close() }
+
+ currentTitle = '局域网地址返回列表也关闭当前编辑标签'
+ const lan = evidence.environment.origins[1]
+ const { context: lanContext, page: lanPage } = await makeContext(lan)
+ try {
+ await openList(lanPage, lan)
+ await openEditor(lanPage, lan, '161')
+ await returnToList(lanPage)
+ passed(currentTitle, { origin: lan, tabs: await expectReturned(lanPage, '161') }, [await screenshot(lanPage, '08-lan-return-closes-current')])
+ } catch (error) {
+ if (await lanPage.locator('.platform-shell').isVisible()) evidence.failureScreenshot = await screenshot(lanPage, 'failure-lan')
+ throw error
+ } finally { await lanContext.close() }
+ assert.deepEqual(evidence.pageErrors, [])
+ evidence.status = 'PASS'
+} catch (error) {
+ const screenshots = evidence.failureScreenshot ? [evidence.failureScreenshot] : []
+ if (currentPage && !currentPage.isClosed() && await currentPage.locator('.platform-shell').isVisible().catch(() => false)) screenshots.push(await screenshot(currentPage, 'failure').catch(() => ''))
+ evidence.checks.push({ title: currentTitle, status: 'FAIL', details: safeError(error), screenshots: screenshots.filter(Boolean) })
+ evidence.status = 'FAIL'
+} finally {
+ evidence.finishedAt = new Date().toISOString()
+ save()
+ await browser.close()
+}
+
+const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
+const checks = evidence.checks.map(check => `${escape(JSON.stringify(check.details, null, 2))}真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网
通过 ${evidence.checks.filter(check => check.status === 'PASS').length} / ${evidence.checks.length} 项 · 业务真实写入 0 次
依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。
本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。
${escape(command.command)}:${command.status} · ${escape(command.details)}
`).join('')}JSON 证据:evidence.json。所有截图来自实际浏览器,测试凭据没有写入报告。