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。浏览器禁止内容业务写请求,节点添加/排布仅为本地样式检查。编辑器沿用固定浅色画布,分别切换四种宿主主题属性和系统暗色偏好验证。

修复后:实际狐狸项目

修复后的高级编排

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

修复前

原有彩底灰字

全部类型及宿主主题检查

militarymilitary主题下全部节点类型
technologytechnology主题下全部节点类型
graphitegraphite主题下全部节点类型
darkdark主题下全部节点类型

验证记录

查看检查数据

\ No newline at end of file diff --git a/reports/training-colors-20260906/screenshots/after-layout.png b/reports/training-colors-20260906/screenshots/after-layout.png new file mode 100644 index 0000000..48214d0 Binary files /dev/null and b/reports/training-colors-20260906/screenshots/after-layout.png differ diff --git a/reports/training-colors-20260906/screenshots/after.png b/reports/training-colors-20260906/screenshots/after.png new file mode 100644 index 0000000..0e9e459 Binary files /dev/null and b/reports/training-colors-20260906/screenshots/after.png differ diff --git a/reports/training-colors-20260906/screenshots/before.png b/reports/training-colors-20260906/screenshots/before.png new file mode 100644 index 0000000..a106ce9 Binary files /dev/null and b/reports/training-colors-20260906/screenshots/before.png differ diff --git a/reports/training-colors-20260906/screenshots/lan-after.png b/reports/training-colors-20260906/screenshots/lan-after.png new file mode 100644 index 0000000..aab07d3 Binary files /dev/null and b/reports/training-colors-20260906/screenshots/lan-after.png differ diff --git a/reports/training-colors-20260906/screenshots/theme-dark.png b/reports/training-colors-20260906/screenshots/theme-dark.png new file mode 100644 index 0000000..6e0148b Binary files /dev/null and b/reports/training-colors-20260906/screenshots/theme-dark.png differ diff --git a/reports/training-colors-20260906/screenshots/theme-graphite.png b/reports/training-colors-20260906/screenshots/theme-graphite.png new file mode 100644 index 0000000..b120758 Binary files /dev/null and b/reports/training-colors-20260906/screenshots/theme-graphite.png differ diff --git a/reports/training-colors-20260906/screenshots/theme-military.png b/reports/training-colors-20260906/screenshots/theme-military.png new file mode 100644 index 0000000..49ded7c Binary files /dev/null and b/reports/training-colors-20260906/screenshots/theme-military.png differ diff --git a/reports/training-colors-20260906/screenshots/theme-technology.png b/reports/training-colors-20260906/screenshots/theme-technology.png new file mode 100644 index 0000000..b58530c Binary files /dev/null and b/reports/training-colors-20260906/screenshots/theme-technology.png differ diff --git a/reports/training-live-20260905/error-evidence.json b/reports/training-live-20260905/error-evidence.json new file mode 100644 index 0000000..dde3348 --- /dev/null +++ b/reports/training-live-20260905/error-evidence.json @@ -0,0 +1,40 @@ +{ + "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" + ] + } + ] +} \ No newline at end of file diff --git a/reports/training-live-20260905/final-evidence.json b/reports/training-live-20260905/final-evidence.json new file mode 100644 index 0000000..4495d6f --- /dev/null +++ b/reports/training-live-20260905/final-evidence.json @@ -0,0 +1,183 @@ +{ + "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": [] +} diff --git a/reports/training-live-20260905/index.html b/reports/training-live-20260905/index.html new file mode 100644 index 0000000..4860023 --- /dev/null +++ b/reports/training-live-20260905/index.html @@ -0,0 +1,869 @@ + +训练编排真实测试报告 · 2026-09-05 +
+

训练编排真实测试报告 通过

狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布

2026-09-05 · http://127.0.0.1:6180 · Microsoft Edge / Playwright · 教员编排与独立浏览器只读重开

证据组:3/3 通过;明确检查:通过 9,失败 0,待验证 0;已校验截图 19 张。

+ + +

资料 → 产品原型 → 设计 → 开发

1 · 资料

  • 参考《文档/维修训练内容设计.pdf》(6 页)与已有场景制作成果。按“场景 → 任务步骤 → 学习 / 训练 / 评估”的资料结构检查实现。
  • 本次使用已发布的狐狸场景和牛奶卡车场景验证训练编排,确认真实模型可见、步骤目标来自绑定场景、保存及发布使用服务端版本。

2 · 产品原型

  • 沿用现有三维训练编排工作台:选择场景、选择三维对象、配置任务步骤、保存草稿和发布。
  • 新建时的液压模板仅作为现有界面起点;绑定新场景后不得继续选择该场景中不存在的液压设备。

3 · 设计

  • 两条独立训练分别绑定狐狸与卡车的明确发布版本;每条配置两步、每步 50 分,总计 100 分,并核验重新打开后的状态。
  • 保存时以三维视口截图生成训练封面;模型可见性要通过实际截图及模型显示 / 隐藏的像素差异判断。
  • 将正常流程、独立浏览器只读重开、资源失败、封面失败、版本冲突及并发切换分别留证。独立重开阻断所有业务写请求,核验模型像素可见性、封面哈希与发布依赖;不将其表述为只读角色权限测试。

4 · 开发

  • 场景目录对接已发布内容版本;GLB 通过受控下载读取;训练依赖固定到所选场景版本,恢复项目时保留来源元数据。
  • 修复蒙皮模型缩放后的骨骼矩阵与包围盒刷新;场景恢复、部位覆盖和高亮使用一致的尺寸计算。
  • 绑定场景后移除不存在的内置操作目标,过滤隐藏或已删除对象及其子目标。
  • 保存和发布等待异步操作完成;封面与内容在同一次版本保存中持久化;失败、冲突及并发切换必须保留可恢复状态。最终实现效果以下方真实测试记录为准。
  • 修复旧工程快照回写覆盖最新名称与训练形态;场景绑定进行中禁止保存和发布,避免模型尚未加载成功就写入新的场景依赖。
+

保留训练数据

训练 ID名称状态当前版本发布版本场景 ID固定场景版本步骤数总分
161实测训练-狐狸野外巡检-已验证-20260905PUBLISHED2132131602102100
162实测训练-牛奶卡车进场检查-已验证-20260905PUBLISHED2142141592092100
训练 161:版本、步骤、封面与依赖记录
{
+  "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"
+  }
+}
训练 162:版本、步骤、封面与依赖记录
{
+  "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"
+  }
+}

保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。

+

5 · 测试

证据组:3/3 通过;明确检查:通过 9,失败 0,待验证 0;已校验截图 19 张。

过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。

真实UI训练编排与发布实测 通过

使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面 通过

查看脱敏后的完整记录
{
+  "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"
+  ]
+}
final-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",
+  "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": []
+  }
+}
readonly-evidence.json · 完整记录
{
+  "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"
+}

真实浏览器异常分支验证(定向注入HTTP失败) 通过

封面上传503:训练内容仍保存并保留原封面 通过

查看脱敏后的完整记录
{
+  "title": "封面上传503:训练内容仍保存并保留原封面",
+  "status": "PASS",
+  "details": {
+    "beforeVersion": 6,
+    "afterVersion": 7,
+    "sameCover": true,
+    "stepCount": 2
+  },
+  "screenshot": "screenshots/12-cover-failure-preserves-old.png"
+}

保存409:不覆盖服务端内容和封面,并保持未保存提示 通过

查看脱敏后的完整记录
{
+  "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"
+  ]
+}
error-evidence.json · 完整记录
{
+  "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"
+      ]
+    }
+  ]
+}

执行命令与验证结果

pnpm run build 通过

vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。

pnpm run test:training-legacy 通过

训练原型迁移和野外场景共享定义两个合同均通过。

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 通过

48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。

node tools/training-scene-visibility.e2e.mjs 通过

独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。

+

最终及异常验证截图

+

过程截图 · 不计入最终通过结论

这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。

+

范围、限制与功能确认结论

实测限制与后续项

+ +
\ No newline at end of file diff --git a/reports/training-live-20260905/readonly-evidence.json b/reports/training-live-20260905/readonly-evidence.json new file mode 100644 index 0000000..fe9e85c --- /dev/null +++ b/reports/training-live-20260905/readonly-evidence.json @@ -0,0 +1,253 @@ +{ + "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" +} \ No newline at end of file diff --git a/reports/training-live-20260905/report.md b/reports/training-live-20260905/report.md new file mode 100644 index 0000000..8da720f --- /dev/null +++ b/reports/training-live-20260905/report.md @@ -0,0 +1,616 @@ +# 训练编排真实测试报告 · 2026-09-05 + +状态:**通过**。证据组:3/3 通过;明确检查:通过 9,失败 0,待验证 0;已校验截图 19 张。 + + +## 1 · 资料 + +- 参考《文档/维修训练内容设计.pdf》(6 页)与已有场景制作成果。按“场景 → 任务步骤 → 学习 / 训练 / 评估”的资料结构检查实现。 +- 本次使用已发布的狐狸场景和牛奶卡车场景验证训练编排,确认真实模型可见、步骤目标来自绑定场景、保存及发布使用服务端版本。 + +## 2 · 产品原型 + +- 沿用现有三维训练编排工作台:选择场景、选择三维对象、配置任务步骤、保存草稿和发布。 +- 新建时的液压模板仅作为现有界面起点;绑定新场景后不得继续选择该场景中不存在的液压设备。 + +## 3 · 设计 + +- 两条独立训练分别绑定狐狸与卡车的明确发布版本;每条配置两步、每步 50 分,总计 100 分,并核验重新打开后的状态。 +- 保存时以三维视口截图生成训练封面;模型可见性要通过实际截图及模型显示 / 隐藏的像素差异判断。 +- 将正常流程、独立浏览器只读重开、资源失败、封面失败、版本冲突及并发切换分别留证。独立重开阻断所有业务写请求,核验模型像素可见性、封面哈希与发布依赖;不将其表述为只读角色权限测试。 + +## 4 · 开发 + +- 场景目录对接已发布内容版本;GLB 通过受控下载读取;训练依赖固定到所选场景版本,恢复项目时保留来源元数据。 +- 修复蒙皮模型缩放后的骨骼矩阵与包围盒刷新;场景恢复、部位覆盖和高亮使用一致的尺寸计算。 +- 绑定场景后移除不存在的内置操作目标,过滤隐藏或已删除对象及其子目标。 +- 保存和发布等待异步操作完成;封面与内容在同一次版本保存中持久化;失败、冲突及并发切换必须保留可恢复状态。最终实现效果以下方真实测试记录为准。 +- 修复旧工程快照回写覆盖最新名称与训练形态;场景绑定进行中禁止保存和发布,避免模型尚未加载成功就写入新的场景依赖。 + +## 保留训练数据 + +| 训练 ID | 名称 | 状态 | 当前版本 | 发布版本 | 场景 ID | 固定场景版本 | 步骤数 | 总分 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 161 | 实测训练-狐狸野外巡检-已验证-20260905 | PUBLISHED | 213 | 213 | 160 | 210 | 2 | 100 | +| 162 | 实测训练-牛奶卡车进场检查-已验证-20260905 | PUBLISHED | 214 | 214 | 159 | 209 | 2 | 100 | + +保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。 + +## 5 · 测试 + +过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。 + + +### 真实UI训练编排与发布实测 · 通过 + +- 使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面:通过 +- 狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布:通过 +- 狐狸三维点击选目标、调整视角并截图发布、异常恢复:通过 +- 卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布:通过 + +
final-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": []
+}
+ +
+ +### 独立浏览器重开、真实模型可见性及封面验证 · 通过 + +- 狐狸野外巡检:发布重开、模型实际可见、截图封面:通过 +- 牛奶卡车进场检查:发布重开、模型实际可见、截图封面:通过 + +
readonly-evidence.json · 脱敏完整记录 + +
{
+  "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"
+}
+ +
+ +### 真实浏览器异常分支验证(定向注入HTTP失败) · 通过 + +- 封面上传503:训练内容仍保存并保留原封面:通过 +- 保存409:不覆盖服务端内容和封面,并保持未保存提示:通过 +- 切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入:通过 + +
error-evidence.json · 脱敏完整记录 + +
{
+  "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"
+      ]
+    }
+  ]
+}
+ +
+ +### 执行命令与验证结果 + +- pnpm run build:通过。vue-tsc、Vite构建、iframe产物一致性验证通过;有既有chunk体积和DRACO路径构建提示。 +- pnpm run test:training-legacy:通过。训练原型迁移和野外场景共享定义两个合同均通过。 +- 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:通过。48/48通过。首次合跑1个测试夹具缺renderSaveState回调,修复夹具后全量复跑通过。 +- node tools/training-scene-visibility.e2e.mjs:通过。独立浏览器只读重开验证,最终版本变更后再次运行;细项以readonly-evidence.json为准。 + +## 最终及异常验证截图 + +[![实测训练-狐狸野外巡检-已验证-20260905](screenshots/fox-final-cover.jpg)](screenshots/fox-final-cover.jpg) + +[![实测训练-牛奶卡车进场检查-已验证-20260905](screenshots/truck-final-cover.jpg)](screenshots/truck-final-cover.jpg) + +[![使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面](screenshots/20-training-list-final.png)](screenshots/20-training-list-final.png) + +[![狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布](screenshots/09-fox-advanced-timeline.png)](screenshots/09-fox-advanced-timeline.png) + +[![狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布](screenshots/10-fox-preflight.png)](screenshots/10-fox-preflight.png) + +[![狐狸三维点击选目标、调整视角并截图发布、异常恢复](screenshots/16-fox-mesh-picking.png)](screenshots/16-fox-mesh-picking.png) + +[![狐狸三维点击选目标、调整视角并截图发布、异常恢复](screenshots/17-fox-final-published.png)](screenshots/17-fox-final-published.png) + +[![卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布](screenshots/18-truck-mesh-picking.png)](screenshots/18-truck-mesh-picking.png) + +[![卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布](screenshots/19-truck-final-published.png)](screenshots/19-truck-final-published.png) + +[![狐狸野外巡检:发布重开、模型实际可见、截图封面](screenshots/fox-independent-reopen.png)](screenshots/fox-independent-reopen.png) + +[![牛奶卡车进场检查:发布重开、模型实际可见、截图封面](screenshots/truck-independent-reopen.png)](screenshots/truck-independent-reopen.png) + +[![封面上传503:训练内容仍保存并保留原封面](screenshots/12-cover-failure-preserves-old.png)](screenshots/12-cover-failure-preserves-old.png) + +[![保存409:不覆盖服务端内容和封面,并保持未保存提示](screenshots/13-save-conflict.png)](screenshots/13-save-conflict.png) + +[![切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入](screenshots/14-binding-disables-save.png)](screenshots/14-binding-disables-save.png) + +[![切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入](screenshots/15-binding-failure-retains-scene.png)](screenshots/15-binding-failure-retains-scene.png) + +## 过程截图 · 不计入最终通过结论 + +[![过程记录:初建训练仍显示默认液压模板,仅记录起点,不作为故障对照或最终通过证据。](screenshots/01-before-scene-catalog.png)](screenshots/01-before-scene-catalog.png) + +[![过程记录:打开已发布场景目录。最终版本和固定依赖以最终证据为准。](screenshots/02-published-scenes.png)](screenshots/02-published-scenes.png) + +[![过程记录:首次绑定卡车时仍有 8 个旧模板步骤;不代表最终两步编排通过。](screenshots/03-truck-bound.png)](screenshots/03-truck-bound.png) + +[![过程记录:卡车训练阶段性保存;最终步骤、状态、版本和封面以最终证据为准。](screenshots/04-truck-saved.png)](screenshots/04-truck-saved.png) + +## 范围、限制与功能确认结论 + +- 本次范围为训练编排、草稿保存、发布与重新打开,不包含创建教学任务、下发学员或完成学员训练。 +- “预览训练”仍提示需发布后在教学实施中下发;本报告不将该提示视作学员训练已运行。 +- 本轮仅修复已实现功能的问题,无新增需要用户确认的功能改动。 + +### 实测限制与后续项 + +- 本次未创建教学下发任务,也未验证学员端完成训练;现有“预览训练”入口仍提示先发布再从虚实训练下发任务。 +- 未配置讲解音频和必选安全前置条件,编辑器校验给出非阻断提醒;两条为轻量素材功能测试,不作为正式维修教学内容。 +- 狐狸源场景实际已更新至V3(版本210),使用目录当时的真实发布版;未修改两个源场景或源模型。 +- 训练封面截取保存时的当前视角;卡车封面通过现有“隐藏外壳”按钮隐藏围护结构,立柱仍遮挡部分车身。这是继承的源场景及视角,不是模型未加载;重新打开时仍按固定场景还原相机。 +- 本轮没有新增需确认的功能变更,截图封面按本次明确授权实现;未改变数据库结构,无DDL变更。 + +报告生成:2026/9/5 23:26:58(Asia/Shanghai)。 diff --git a/reports/training-live-20260905/screenshots/01-before-scene-catalog.png b/reports/training-live-20260905/screenshots/01-before-scene-catalog.png new file mode 100644 index 0000000..c274b7c Binary files /dev/null and b/reports/training-live-20260905/screenshots/01-before-scene-catalog.png differ diff --git a/reports/training-live-20260905/screenshots/02-published-scenes.png b/reports/training-live-20260905/screenshots/02-published-scenes.png new file mode 100644 index 0000000..5f7ce17 Binary files /dev/null and b/reports/training-live-20260905/screenshots/02-published-scenes.png differ diff --git a/reports/training-live-20260905/screenshots/03-truck-bound.png b/reports/training-live-20260905/screenshots/03-truck-bound.png new file mode 100644 index 0000000..840ea0c Binary files /dev/null and b/reports/training-live-20260905/screenshots/03-truck-bound.png differ diff --git a/reports/training-live-20260905/screenshots/04-truck-saved.png b/reports/training-live-20260905/screenshots/04-truck-saved.png new file mode 100644 index 0000000..4db3f9b Binary files /dev/null and b/reports/training-live-20260905/screenshots/04-truck-saved.png differ diff --git a/reports/training-live-20260905/screenshots/05-truck-preflight.png b/reports/training-live-20260905/screenshots/05-truck-preflight.png new file mode 100644 index 0000000..ebd3dfa Binary files /dev/null and b/reports/training-live-20260905/screenshots/05-truck-preflight.png differ diff --git a/reports/training-live-20260905/screenshots/06-truck-published.png b/reports/training-live-20260905/screenshots/06-truck-published.png new file mode 100644 index 0000000..7fe7d8b Binary files /dev/null and b/reports/training-live-20260905/screenshots/06-truck-published.png differ diff --git a/reports/training-live-20260905/screenshots/07-fox-bound.png b/reports/training-live-20260905/screenshots/07-fox-bound.png new file mode 100644 index 0000000..2737dca Binary files /dev/null and b/reports/training-live-20260905/screenshots/07-fox-bound.png differ diff --git a/reports/training-live-20260905/screenshots/08-fox-saved.png b/reports/training-live-20260905/screenshots/08-fox-saved.png new file mode 100644 index 0000000..ddc342f Binary files /dev/null and b/reports/training-live-20260905/screenshots/08-fox-saved.png differ diff --git a/reports/training-live-20260905/screenshots/09-fox-advanced-timeline.png b/reports/training-live-20260905/screenshots/09-fox-advanced-timeline.png new file mode 100644 index 0000000..6904911 Binary files /dev/null and b/reports/training-live-20260905/screenshots/09-fox-advanced-timeline.png differ diff --git a/reports/training-live-20260905/screenshots/10-fox-preflight.png b/reports/training-live-20260905/screenshots/10-fox-preflight.png new file mode 100644 index 0000000..07f329e Binary files /dev/null and b/reports/training-live-20260905/screenshots/10-fox-preflight.png differ diff --git a/reports/training-live-20260905/screenshots/11-fox-published.png b/reports/training-live-20260905/screenshots/11-fox-published.png new file mode 100644 index 0000000..8fb0a22 Binary files /dev/null and b/reports/training-live-20260905/screenshots/11-fox-published.png differ diff --git a/reports/training-live-20260905/screenshots/12-cover-failure-preserves-old.png b/reports/training-live-20260905/screenshots/12-cover-failure-preserves-old.png new file mode 100644 index 0000000..d633fff Binary files /dev/null and b/reports/training-live-20260905/screenshots/12-cover-failure-preserves-old.png differ diff --git a/reports/training-live-20260905/screenshots/13-save-conflict.png b/reports/training-live-20260905/screenshots/13-save-conflict.png new file mode 100644 index 0000000..8b65b32 Binary files /dev/null and b/reports/training-live-20260905/screenshots/13-save-conflict.png differ diff --git a/reports/training-live-20260905/screenshots/14-binding-disables-save.png b/reports/training-live-20260905/screenshots/14-binding-disables-save.png new file mode 100644 index 0000000..a70d92e Binary files /dev/null and b/reports/training-live-20260905/screenshots/14-binding-disables-save.png differ diff --git a/reports/training-live-20260905/screenshots/15-binding-failure-retains-scene.png b/reports/training-live-20260905/screenshots/15-binding-failure-retains-scene.png new file mode 100644 index 0000000..c63b4c3 Binary files /dev/null and b/reports/training-live-20260905/screenshots/15-binding-failure-retains-scene.png differ diff --git a/reports/training-live-20260905/screenshots/16-fox-mesh-picking.png b/reports/training-live-20260905/screenshots/16-fox-mesh-picking.png new file mode 100644 index 0000000..8336c49 Binary files /dev/null and b/reports/training-live-20260905/screenshots/16-fox-mesh-picking.png differ diff --git a/reports/training-live-20260905/screenshots/17-fox-final-published.png b/reports/training-live-20260905/screenshots/17-fox-final-published.png new file mode 100644 index 0000000..42a569e Binary files /dev/null and b/reports/training-live-20260905/screenshots/17-fox-final-published.png differ diff --git a/reports/training-live-20260905/screenshots/18-truck-mesh-picking.png b/reports/training-live-20260905/screenshots/18-truck-mesh-picking.png new file mode 100644 index 0000000..829073f Binary files /dev/null and b/reports/training-live-20260905/screenshots/18-truck-mesh-picking.png differ diff --git a/reports/training-live-20260905/screenshots/19-truck-final-published.png b/reports/training-live-20260905/screenshots/19-truck-final-published.png new file mode 100644 index 0000000..0e668b1 Binary files /dev/null and b/reports/training-live-20260905/screenshots/19-truck-final-published.png differ diff --git a/reports/training-live-20260905/screenshots/20-training-list-final.png b/reports/training-live-20260905/screenshots/20-training-list-final.png new file mode 100644 index 0000000..8741087 Binary files /dev/null and b/reports/training-live-20260905/screenshots/20-training-list-final.png differ diff --git a/reports/training-live-20260905/screenshots/fox-final-cover.jpg b/reports/training-live-20260905/screenshots/fox-final-cover.jpg new file mode 100644 index 0000000..24f2631 Binary files /dev/null and b/reports/training-live-20260905/screenshots/fox-final-cover.jpg differ diff --git a/reports/training-live-20260905/screenshots/fox-independent-reopen.png b/reports/training-live-20260905/screenshots/fox-independent-reopen.png new file mode 100644 index 0000000..c10522a Binary files /dev/null and b/reports/training-live-20260905/screenshots/fox-independent-reopen.png differ diff --git a/reports/training-live-20260905/screenshots/truck-final-cover.jpg b/reports/training-live-20260905/screenshots/truck-final-cover.jpg new file mode 100644 index 0000000..638409d Binary files /dev/null and b/reports/training-live-20260905/screenshots/truck-final-cover.jpg differ diff --git a/reports/training-live-20260905/screenshots/truck-independent-reopen.png b/reports/training-live-20260905/screenshots/truck-independent-reopen.png new file mode 100644 index 0000000..d85e019 Binary files /dev/null and b/reports/training-live-20260905/screenshots/truck-independent-reopen.png differ diff --git a/reports/training-modes-20260905/api-evidence.json b/reports/training-modes-20260905/api-evidence.json new file mode 100644 index 0000000..fbd212c --- /dev/null +++ b/reports/training-modes-20260905/api-evidence.json @@ -0,0 +1,1177 @@ +{ + "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": "仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。" + } +} diff --git a/reports/training-modes-20260905/evidence.json b/reports/training-modes-20260905/evidence.json new file mode 100644 index 0000000..72a0dd3 --- /dev/null +++ b/reports/training-modes-20260905/evidence.json @@ -0,0 +1,635 @@ +{ + "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" +} \ No newline at end of file diff --git a/reports/training-modes-20260905/index.html b/reports/training-modes-20260905/index.html new file mode 100644 index 0000000..73331bc --- /dev/null +++ b/reports/training-modes-20260905/index.html @@ -0,0 +1,2835 @@ +实装与对抗训练实测报告 · 2026-09-05

实装与对抗训练实测报告 部分链路已验证,页面闭环受限

编排 UI · 默认教学入口 · 正式教学 API

编排与 API 已验证,教学页面闭环受限。需完成默认教学页面与正式任务运行的接线,才能从页面走完新模型对应的训练。

2026-09-05 · http://127.0.0.1:6180

证据组 2/2 已完成;检查通过 26,受限 2,失败 0,待验证 0;已校验截图 25 张。

+ +

资料 → 产品原型 → 设计 → 开发

1 · 资料

  • 实际查看《维修训练内容设计.pdf》第 1–3、5–6 页扫描件。第 1–2 页将虚拟、混合现实、AI 指导下的实装组织为递进训练,流程包含构建场景、编排任务、组班下发、学员训练与数据报告。
  • 本次使用狐狸与牛奶卡车测试素材验证软件链路,不将其视为原文装备维修内容或真实维修能力考核。原文已查看页面没有单列红蓝对抗规则;对抗依据现有教学实施、渠道合同、命令幂等及步骤事实文档验证。
  • 合同来源:unreal_tran_api/wiki/14-教学实施模块.md、19-训练定义渠道合同.md、20-教学命令幂等.md、21-教学步骤运行事实.md。

2 · 产品原型

  • 沿用现有训练编排工作台与模式配置表单,分别建立实装和对抗内容;保存、发布后重新打开检查步骤、分值、封面与模式参数。
  • 实装默认运行入口为离线识别回放;对抗默认入口为本地工作台。分别记录界面实际行为,再通过正式 API 验证服务端教学闭环。

3 · 设计

  • 实装配置三步人工确认,分值 30 / 30 / 40;完成进度按步骤推导为 33 / 67 / 100。验证失败重试、顺序约束、事件身份及重复事件处理。
  • 对抗配置红蓝两队、每队两人,步骤和提交均由 COMMANDER 执行;OPERATOR 用于岗位拒绝测试。验证同队共享运行、跨队隔离、服务器时限、提交与教员评定。
  • 任务固定引用已发布训练版本;只创建本次开发测试数据。人工确认和客户端模拟参数均明确标识为测试,不连接真实工位、PLC、摄像头或视觉服务。

4 · 开发

  • 修复模式配置被旧channelProfiles覆盖,保持默认profile与modeConfig一致;对抗分钟/秒及旧roundDuration兼容。
  • 保留显式空配置文本;每队人数范围与服务端1–200一致,无效数字阻止发布,不静默夹断。
  • 保留步骤设备事件、人工确认、岗位与运行编码;增删排序后重新计算严格递增进度。
  • 修复复制步骤编码冲突(含大小写),复制动作岗位映射随新动作编码迁移。
  • 显式清空allowedPositions不再补回默认三岗位,保持权限边界并交由正式发布校验阻断。
  • 修复instruction/summary双界面操作说明丢失及显式空提示恢复演示内容;用户实际编辑时同步。
  • 正式targetId优先,目标选择同步语义引用、节点与动作,防止旧targetRef把新目标回退。
  • 沿用上轮训练3D视口截图方案:受控TRAINING_COVER上传、同次保存资产与内容,保存/发布后列表正确显示截图。
  • 本次均为正式工程合同适配与bugfix,未新增数据库字段、执行DDL或更改原型业务流程;测试数据通过开发API创建。

两种模式的验证边界

模式正式渠道本轮 API 验证方法必检项默认界面边界
实装实训PHYSICAL正式 API 的 MANUAL 人工事件三步顺序、失败重试、来源权限、事件幂等、提交与评定默认离线识别页;设备 / 视觉接入另行联调
对抗训练CONFRONTATION正式 API 的红蓝两队共享运行岗位权限、两队隔离、顺序、时限、命令幂等、提交与评定默认本地工作台;不混同正式 API 运行
+

保留开发测试数据

训练 ID名称模式状态当前发布版本API 已测版本步骤数
163实测训练-牛奶卡车实装检查-20260905PHYSICALPUBLISHED2172153
164实测训练-狐狸红蓝协同巡检-20260905CONFRONTATIONPUBLISHED2162162

训练 163:API任务50固定215;说明字段兼容修复后在217做UI及封面回归,未改写任务快照。

训练 163 · 发布与配置记录
{
+  "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及封面回归,未改写任务快照。"
+}
训练 164 · 发布与配置记录
{
+  "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渠道队伍状态完成度评定分
5044PHYSICALNEUTRALREVIEWED100100
5145CONFRONTATIONREDREVIEWED100100
5146CONFRONTATIONBLUEREVIEWED100100
本次新增数据 ID
{
+  "assignments": [
+    "50",
+    "51"
+  ],
+  "runs": [
+    "44",
+    "45",
+    "46"
+  ],
+  "users": [
+    "95",
+    "96",
+    "97",
+    "98"
+  ],
+  "note": "仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。"
+}

随机测试密码与登录会话不写入报告;保留新建数据用于追溯。

+

5 · 测试

证据组 2/2 已完成;检查通过 26,受限 2,失败 0,待验证 0;已校验截图 25 张。

实装实训与对抗训练真实流程测试 部分链路已验证,页面闭环受限

证据类型:UI · evidence.json

实装:创建、绑定牛奶卡车、3步配置、保存重开与发布 通过

查看脱敏详细记录
{
+  "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": []
+  }
+}
evidence.json · 完整记录
{
+  "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 · 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"
+      }
+    ]
+  }
+}

PHYSICAL 提交、教员评定与结果回读 通过

查看脱敏详细记录
{
+  "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
+  }
+}

RED 队岗位越权、跳步和提前提交均拒绝 通过

查看脱敏详细记录
{
+  "title": "RED 队岗位越权、跳步和提前提交均拒绝",
+  "status": "PASS",
+  "details": {
+    "runId": "45",
+    "wrongPosition": {
+      "status": 403,
+      "message": "当前岗位不能推进共享训练进度"
+    },
+    "skipped": {
+      "status": 400,
+      "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+    },
+    "earlySubmit": {
+      "status": 409,
+      "message": "训练步骤尚未全部完成,不能提交"
+    },
+    "deadlineAt": 1788623936,
+    "serverNow": 1788623517
+  }
+}

RED 队授权动作逐步推进、重放幂等、提交负责人限制 通过

查看脱敏详细记录
{
+  "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": "共享训练仅允许任务声明的负责人岗位提交结果"
+    }
+  }
+}

CONFRONTATION 提交、教员评定与结果回读 通过

查看脱敏详细记录
{
+  "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
+  }
+}

BLUE 队岗位越权、跳步和提前提交均拒绝 通过

查看脱敏详细记录
{
+  "title": "BLUE 队岗位越权、跳步和提前提交均拒绝",
+  "status": "PASS",
+  "details": {
+    "runId": "46",
+    "wrongPosition": {
+      "status": 403,
+      "message": "当前岗位不能推进共享训练进度"
+    },
+    "skipped": {
+      "status": 400,
+      "message": "必须按发布顺序完成当前步骤,不能跳步或回退"
+    },
+    "earlySubmit": {
+      "status": 409,
+      "message": "训练步骤尚未全部完成,不能提交"
+    },
+    "deadlineAt": 1788623942,
+    "serverNow": 1788623523
+  }
+}

BLUE 队授权动作逐步推进、重放幂等、提交负责人限制 通过

查看脱敏详细记录
{
+  "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": "共享训练仅允许任务声明的负责人岗位提交结果"
+    }
+  }
+}

CONFRONTATION 提交、教员评定与结果回读 通过

查看脱敏详细记录
{
+  "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"
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
api-evidence.json · 完整记录
{
+  "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": "仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。"
+  }
+}

测试命令与构建

pnpm run build 通过

vue-tsc、Vite构建和legacy iframe分发校验均通过;仅已有包体积提示。

node --test tools/training-*.test.mjs(本次8个聚焦文件) 通过

94/94通过,涵盖封面、场景、真实模型、目标、元数据、模式与步骤兼容。

pnpm run test:training-legacy 通过

两项既有训练/地形合同检查通过。

content-training-studio.spec.ts / Edge 通过

7/7通过(mock API的工作台回归),无跳过、无不稳定用例,独立报告studio-regression/html/index.html。

UTE2E_TRAINING_MODES=1 UTE2E_BASE_URL=http://192.168.31.168:6180 node tools/training-scene-visibility.e2e.mjs 通过

独立Edge会话2/2通过,真实LAN服务、禁止业务写请求;模型可见像素差、固定场景依赖、JPEG尺寸与SHA-256全部通过。

node tools/training-modes-api-live.mjs(163:215;164:216) 通过

18/18检查组通过,90次正式教学API请求;含训练/场景/模型固定快照只读复核,详见api-evidence.json。

+

真实浏览器截图

截图只来自实际存在的文件;API 请求结果单列,不以 UI 截图代替。

+

范围、限制与待确认项

实测边界与限制

需要另行确认的功能范围

\ No newline at end of file diff --git a/reports/training-modes-20260905/list-api-latency.json b/reports/training-modes-20260905/list-api-latency.json new file mode 100644 index 0000000..1845d42 --- /dev/null +++ b/reports/training-modes-20260905/list-api-latency.json @@ -0,0 +1,150 @@ +{ + "checkedAt": "2026-09-05T15:59:47.158Z", + "method": "GET only, existing development administrator session; each request timeout 20 seconds", + "results": [ + { + "round": 1, + "mode": "PHYSICAL", + "status": 200, + "elapsedMs": 247, + "total": 2, + "records": [ + { + "id": "163", + "trainingMode": "PHYSICAL", + "hasCover": true + }, + { + "id": "52", + "trainingMode": "PHYSICAL", + "hasCover": true + } + ] + }, + { + "round": 1, + "mode": "CONFRONTATION", + "status": 200, + "elapsedMs": 345, + "total": 4, + "records": [ + { + "id": "164", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "154", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "98", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "53", + "trainingMode": "CONFRONTATION", + "hasCover": true + } + ] + }, + { + "round": 2, + "mode": "PHYSICAL", + "status": 200, + "elapsedMs": 259, + "total": 2, + "records": [ + { + "id": "163", + "trainingMode": "PHYSICAL", + "hasCover": true + }, + { + "id": "52", + "trainingMode": "PHYSICAL", + "hasCover": true + } + ] + }, + { + "round": 2, + "mode": "CONFRONTATION", + "status": 200, + "elapsedMs": 344, + "total": 4, + "records": [ + { + "id": "164", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "154", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "98", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "53", + "trainingMode": "CONFRONTATION", + "hasCover": true + } + ] + }, + { + "round": 3, + "mode": "PHYSICAL", + "status": 200, + "elapsedMs": 308, + "total": 2, + "records": [ + { + "id": "163", + "trainingMode": "PHYSICAL", + "hasCover": true + }, + { + "id": "52", + "trainingMode": "PHYSICAL", + "hasCover": true + } + ] + }, + { + "round": 3, + "mode": "CONFRONTATION", + "status": 200, + "elapsedMs": 403, + "total": 4, + "records": [ + { + "id": "164", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "154", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "98", + "trainingMode": "CONFRONTATION", + "hasCover": true + }, + { + "id": "53", + "trainingMode": "CONFRONTATION", + "hasCover": true + } + ] + } + ] +} diff --git a/reports/training-modes-20260905/readonly-evidence.json b/reports/training-modes-20260905/readonly-evidence.json new file mode 100644 index 0000000..1bc6508 --- /dev/null +++ b/reports/training-modes-20260905/readonly-evidence.json @@ -0,0 +1,262 @@ +{ + "title": "独立浏览器重开、真实模型可见性及封面验证", + "status": "PASS", + "startedAt": "2026-09-05T15:52:55.523Z", + "checks": [ + { + "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" + } + }, + { + "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" + } + } + ], + "finishedAt": "2026-09-05T15:53:22.783Z" +} \ No newline at end of file diff --git a/reports/training-modes-20260905/report.md b/reports/training-modes-20260905/report.md new file mode 100644 index 0000000..a753e6b --- /dev/null +++ b/reports/training-modes-20260905/report.md @@ -0,0 +1,1989 @@ +# 实装与对抗训练实测报告 · 2026-09-05 + +状态:**部分链路已验证,页面闭环受限**。证据组 2/2 已完成;检查通过 26,受限 2,失败 0,待验证 0;已校验截图 25 张。 + +编排与 API 已验证,教学页面闭环受限。需完成默认教学页面与正式任务运行的接线,才能从页面走完新模型对应的训练。 + +范围:编排 UI、默认教学入口、正式教学 API 分开验证。 + +## 1 · 资料 + +- 实际查看《维修训练内容设计.pdf》第 1–3、5–6 页扫描件。第 1–2 页将虚拟、混合现实、AI 指导下的实装组织为递进训练,流程包含构建场景、编排任务、组班下发、学员训练与数据报告。 +- 本次使用狐狸与牛奶卡车测试素材验证软件链路,不将其视为原文装备维修内容或真实维修能力考核。原文已查看页面没有单列红蓝对抗规则;对抗依据现有教学实施、渠道合同、命令幂等及步骤事实文档验证。 +- 合同来源:unreal\_tran\_api/wiki/14-教学实施模块.md、19-训练定义渠道合同.md、20-教学命令幂等.md、21-教学步骤运行事实.md。 + +## 2 · 产品原型 + +- 沿用现有训练编排工作台与模式配置表单,分别建立实装和对抗内容;保存、发布后重新打开检查步骤、分值、封面与模式参数。 +- 实装默认运行入口为离线识别回放;对抗默认入口为本地工作台。分别记录界面实际行为,再通过正式 API 验证服务端教学闭环。 + +## 3 · 设计 + +- 实装配置三步人工确认,分值 30 / 30 / 40;完成进度按步骤推导为 33 / 67 / 100。验证失败重试、顺序约束、事件身份及重复事件处理。 +- 对抗配置红蓝两队、每队两人,步骤和提交均由 COMMANDER 执行;OPERATOR 用于岗位拒绝测试。验证同队共享运行、跨队隔离、服务器时限、提交与教员评定。 +- 任务固定引用已发布训练版本;只创建本次开发测试数据。人工确认和客户端模拟参数均明确标识为测试,不连接真实工位、PLC、摄像头或视觉服务。 + +## 4 · 开发 + +- 修复模式配置被旧channelProfiles覆盖,保持默认profile与modeConfig一致;对抗分钟/秒及旧roundDuration兼容。 +- 保留显式空配置文本;每队人数范围与服务端1–200一致,无效数字阻止发布,不静默夹断。 +- 保留步骤设备事件、人工确认、岗位与运行编码;增删排序后重新计算严格递增进度。 +- 修复复制步骤编码冲突(含大小写),复制动作岗位映射随新动作编码迁移。 +- 显式清空allowedPositions不再补回默认三岗位,保持权限边界并交由正式发布校验阻断。 +- 修复instruction/summary双界面操作说明丢失及显式空提示恢复演示内容;用户实际编辑时同步。 +- 正式targetId优先,目标选择同步语义引用、节点与动作,防止旧targetRef把新目标回退。 +- 沿用上轮训练3D视口截图方案:受控TRAINING\_COVER上传、同次保存资产与内容,保存/发布后列表正确显示截图。 +- 本次均为正式工程合同适配与bugfix,未新增数据库字段、执行DDL或更改原型业务流程;测试数据通过开发API创建。 + +## 两种模式的验证边界 + +| 模式 | 正式渠道 | 本轮 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 | 运行 ID | 渠道 | 队伍 | 状态 | 完成度 | 评定分 | +| --- | --- | --- | --- | --- | --- | --- | +| 50 | 44 | PHYSICAL | NEUTRAL | REVIEWED | 100 | 100 | +| 51 | 45 | CONFRONTATION | RED | REVIEWED | 100 | 100 | +| 51 | 46 | CONFRONTATION | BLUE | REVIEWED | 100 | 100 | + +随机测试密码与登录会话不写入报告;保留新建数据用于追溯。 + +## 5 · 测试 + + +### 实装实训与对抗训练真实流程测试 · 部分链路已验证,页面闭环受限 + +证据类型:UI。 + +- 实装:创建、绑定牛奶卡车、3步配置、保存重开与发布:通过 +- 对抗:每队人数为小数时阻止发布:通过 +- 对抗:显式清空岗位后保存重开,不恢复默认授权:通过 +- 对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布:通过 +- 实装:说明兼容修复回写,重新截图封面及发布:通过 +- 实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤:受限(需功能接线) +- 对抗默认页面:新建下拉仅有本地液压预设,无法选择已发布狐狸训练:受限(需功能接线) +- 训练列表两种模式展示各自真实截图封面:通过 +- 牛奶卡车实装检查:发布重开、模型实际可见、截图封面:通过 +- 狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面:通过 + +
evidence.json · 脱敏完整记录 + +
{
+  "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。 + +- 开始命令重放不会重复激活或递增次数:通过 +- 实装任务固定发布版本、分配学员并开始:通过 +- 实装拒绝普通进度接口、抢跑、提前提交及错误事件身份:通过 +- 实装不匹配事件失败,必须重试后才可继续:通过 +- 实装逐步人工确认、事件幂等与载荷冲突:通过 +- PHYSICAL 提交、教员评定与结果回读:通过 +- 对抗红蓝两队各两人,按队伍共享且运行互相独立:通过 +- 对抗学员不能读取另一队运行明细:通过 +- 开始命令重放不会重复激活或递增次数:通过 +- RED 队岗位越权、跳步和提前提交均拒绝:通过 +- RED 队授权动作逐步推进、重放幂等、提交负责人限制:通过 +- CONFRONTATION 提交、教员评定与结果回读:通过 +- 红队完成不会推进蓝队进度:通过 +- 开始命令重放不会重复激活或递增次数:通过 +- BLUE 队岗位越权、跳步和提前提交均拒绝:通过 +- BLUE 队授权动作逐步推进、重放幂等、提交负责人限制:通过 +- CONFRONTATION 提交、教员评定与结果回读:通过 +- 正式任务快照固定训练、场景与模型发布版本:通过 + +
api-evidence.json · 脱敏完整记录 + +
{
+  "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": "仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。"
+  }
+}
+ +
+ +### 测试命令与构建 + +- pnpm run build:通过。vue-tsc、Vite构建和legacy iframe分发校验均通过;仅已有包体积提示。 +- node --test tools/training-\*.test.mjs(本次8个聚焦文件):通过。94/94通过,涵盖封面、场景、真实模型、目标、元数据、模式与步骤兼容。 +- pnpm run test:training-legacy:通过。两项既有训练/地形合同检查通过。 +- content-training-studio.spec.ts / Edge:通过。7/7通过(mock API的工作台回归),无跳过、无不稳定用例,独立报告studio-regression/html/index.html。 +- UTE2E\_TRAINING\_MODES=1 UTE2E\_BASE\_URL=http://192.168.31.168:6180 node tools/training-scene-visibility.e2e.mjs:通过。独立Edge会话2/2通过,真实LAN服务、禁止业务写请求;模型可见像素差、固定场景依赖、JPEG尺寸与SHA-256全部通过。 +- node tools/training-modes-api-live.mjs(163:215;164:216):通过。18/18检查组通过,90次正式教学API请求;含训练/场景/模型固定快照只读复核,详见api-evidence.json。 + +## 真实浏览器截图 + +[![实装:创建、绑定牛奶卡车、3步配置、保存重开与发布](screenshots/physical-settings-reopen.png)](screenshots/physical-settings-reopen.png) + +[![实装:创建、绑定牛奶卡车、3步配置、保存重开与发布](screenshots/physical-authoring.png)](screenshots/physical-authoring.png) + +[![实装:创建、绑定牛奶卡车、3步配置、保存重开与发布](screenshots/physical-preflight.png)](screenshots/physical-preflight.png) + +[![实装:创建、绑定牛奶卡车、3步配置、保存重开与发布](screenshots/physical-published.png)](screenshots/physical-published.png) + +[![对抗:每队人数为小数时阻止发布](screenshots/confrontation-invalid-team-size.png)](screenshots/confrontation-invalid-team-size.png) + +[![对抗:显式清空岗位后保存重开,不恢复默认授权](screenshots/confrontation-empty-positions.png)](screenshots/confrontation-empty-positions.png) + +[![对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布](screenshots/confrontation-positions.png)](screenshots/confrontation-positions.png) + +[![对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布](screenshots/confrontation-settings-reopen.png)](screenshots/confrontation-settings-reopen.png) + +[![对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布](screenshots/confrontation-authoring.png)](screenshots/confrontation-authoring.png) + +[![对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布](screenshots/confrontation-preflight.png)](screenshots/confrontation-preflight.png) + +[![对抗:狐狸场景、两步巡检、岗位配置、说明往返与发布](screenshots/confrontation-published.png)](screenshots/confrontation-published.png) + +[![实装:说明兼容修复回写,重新截图封面及发布](screenshots/physical-final-published.png)](screenshots/physical-final-published.png) + +[![实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤](screenshots/physical-teaching-list.png)](screenshots/physical-teaching-list.png) + +[![实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤](screenshots/physical-offline-preview.png)](screenshots/physical-offline-preview.png) + +[![实装默认页面:离线播放与候选复核可操作,无法执行新卡车正式步骤](screenshots/physical-offline-confirmed.png)](screenshots/physical-offline-confirmed.png) + +[![对抗默认页面:新建下拉仅有本地液压预设,无法选择已发布狐狸训练](screenshots/confrontation-final-entry.png)](screenshots/confrontation-final-entry.png) + +[![对抗默认页面:新建下拉仅有本地液压预设,无法选择已发布狐狸训练](screenshots/confrontation-final-local-create.png)](screenshots/confrontation-final-local-create.png) + +[![训练列表两种模式展示各自真实截图封面](screenshots/physical-list-cover.png)](screenshots/physical-list-cover.png) + +[![训练列表两种模式展示各自真实截图封面](screenshots/confrontation-list-cover.png)](screenshots/confrontation-list-cover.png) + +[![牛奶卡车实装检查:发布重开、模型实际可见、截图封面](screenshots/physical-independent-reopen.png)](screenshots/physical-independent-reopen.png) + +[![牛奶卡车实装检查:发布重开、模型实际可见、截图封面](screenshots/physical-independent-settings.png)](screenshots/physical-independent-settings.png) + +[![实测训练-牛奶卡车实装检查-20260905](screenshots/physical-final-cover.jpg)](screenshots/physical-final-cover.jpg) + +[![狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面](screenshots/confrontation-independent-reopen.png)](screenshots/confrontation-independent-reopen.png) + +[![狐狸红蓝协同巡检:发布重开、模型实际可见、截图封面](screenshots/confrontation-independent-settings.png)](screenshots/confrontation-independent-settings.png) + +[![实测训练-狐狸红蓝协同巡检-20260905](screenshots/confrontation-final-cover.jpg)](screenshots/confrontation-final-cover.jpg) + +## 范围、限制与待确认项 + +- 本报告区分编排 UI、默认教学 UI 和正式教学 API 三类证据;API 成功不能证明默认 UI 已连接该服务端运行。 +- 实装的设备或视觉事件链路未接真实设备验证;人工确认测试不能替代实机联调、安全联锁或实际操作技能考核。 +- 对抗的默认本地工作台与正式服务端任务分别留证;无证据时不声称多人跨浏览器实时协作或正式比赛胜负已联调。 +- 正式 API 对抗由四个隔离学员会话依次操作;检查服务端截止时间已生效,未等待整局自然超时,不作为多人并发或网络同步压力测试。 +- 100 分评定仅验证接口和结果留痕,不代表学员实际维修能力。测试不会向他人发送消息或下发真实设备指令。 + +### 实测边界与限制 + +- 实装为MANUAL人工事件合同测试,没有接真实PLC、摄像头、视觉识别服务或安全联锁设备。 +- 两步狐狸巡检用于通用步骤与红蓝岗位合同,不是需要CONF-INSPECT等五个固定编码的完整排故作业;未宣称排故工作台阶段回写已打通。 +- 对抗验证服务端设置了7分钟截止时刻,未等待自然超时触发;未把API账号请求视为多人跨浏览器实时协同UI测试。 +- 牛奶卡车沿用原发布场景的视角,车间立柱遮挡部分车身;真实模型已渲染且封面来自该视口。 +- 实装当前发布217仅回填兼容说明并更新封面;已完成教学任务50仍固定不可变版本215。 +- 历史仅positionActions设置动作岗位的定义在教学读取侧与当前发布预检存在口径差异;本次按现有发布规则配置非空allowedPositions,未改后端岗位合同。 +- 开发环境列表切换曾出现一次15秒超时,刷新后恢复;独立复测两种模式共6次查询均HTTP 200、耗时247–403ms,未复现稳定慢查询。详见list-api-latency.json。 +- 实装事件均为本次自动化提交的 MANUAL 人工确认或明确的权限拒绝测试,不代表真实设备、PLC、摄像机或视觉模型联调。 +- 教学运行结果来自正式 API;默认实装离线回放和默认对抗本地工作台的显示另行记录,不混同为服务端运行闭环。 +- 对抗按四名隔离学员的独立会话依次操作,检查服务端截止时间已生效;未等待整局自然超时,也不代表多人同时操作的压力或网络同步测试。 +- 测试账号使用本次随机密码,密码与会话仅在运行内存中;报告只保留用户 ID、队伍和岗位以便追溯。 + +### 需要另行确认的功能范围 + +- 将实装工作台从固定离线识别回放扩展为读取正式任务、已发布场景和步骤,并接入人工/设备事件、提交与结果。 +- 将对抗默认入口从本地预设任务接入正式任务清单、创建和执行链路;确认通用巡检步骤与五阶段排故工作台的适用范围。 + +报告生成:2026/9/6 00:01:37(Asia/Shanghai)。 diff --git a/reports/training-modes-20260905/screenshots/confrontation-authoring.png b/reports/training-modes-20260905/screenshots/confrontation-authoring.png new file mode 100644 index 0000000..feb79d1 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-authoring.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-empty-positions.png b/reports/training-modes-20260905/screenshots/confrontation-empty-positions.png new file mode 100644 index 0000000..bed1b4a Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-empty-positions.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-entry.png b/reports/training-modes-20260905/screenshots/confrontation-entry.png new file mode 100644 index 0000000..ce3743b Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-entry.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-final-cover.jpg b/reports/training-modes-20260905/screenshots/confrontation-final-cover.jpg new file mode 100644 index 0000000..f11a734 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-final-cover.jpg differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-final-entry.png b/reports/training-modes-20260905/screenshots/confrontation-final-entry.png new file mode 100644 index 0000000..cd15fa5 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-final-entry.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-final-local-create.png b/reports/training-modes-20260905/screenshots/confrontation-final-local-create.png new file mode 100644 index 0000000..efb0f0f Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-final-local-create.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-independent-reopen.png b/reports/training-modes-20260905/screenshots/confrontation-independent-reopen.png new file mode 100644 index 0000000..7ab5fbd Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-independent-reopen.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-independent-settings.png b/reports/training-modes-20260905/screenshots/confrontation-independent-settings.png new file mode 100644 index 0000000..922e576 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-independent-settings.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-invalid-team-size.png b/reports/training-modes-20260905/screenshots/confrontation-invalid-team-size.png new file mode 100644 index 0000000..d716927 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-invalid-team-size.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-list-cover.png b/reports/training-modes-20260905/screenshots/confrontation-list-cover.png new file mode 100644 index 0000000..661624a Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-list-cover.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-local-create.png b/reports/training-modes-20260905/screenshots/confrontation-local-create.png new file mode 100644 index 0000000..4b9052f Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-local-create.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-positions.png b/reports/training-modes-20260905/screenshots/confrontation-positions.png new file mode 100644 index 0000000..a42fa25 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-positions.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-preflight.png b/reports/training-modes-20260905/screenshots/confrontation-preflight.png new file mode 100644 index 0000000..aed0dde Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-preflight.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-published.png b/reports/training-modes-20260905/screenshots/confrontation-published.png new file mode 100644 index 0000000..be134bd Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-published.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-settings-reopen.png b/reports/training-modes-20260905/screenshots/confrontation-settings-reopen.png new file mode 100644 index 0000000..0e7694a Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-settings-reopen.png differ diff --git a/reports/training-modes-20260905/screenshots/confrontation-settings.png b/reports/training-modes-20260905/screenshots/confrontation-settings.png new file mode 100644 index 0000000..516644f Binary files /dev/null and b/reports/training-modes-20260905/screenshots/confrontation-settings.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-authoring.png b/reports/training-modes-20260905/screenshots/physical-authoring.png new file mode 100644 index 0000000..1aa501e Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-authoring.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-final-cover.jpg b/reports/training-modes-20260905/screenshots/physical-final-cover.jpg new file mode 100644 index 0000000..638409d Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-final-cover.jpg differ diff --git a/reports/training-modes-20260905/screenshots/physical-final-published.png b/reports/training-modes-20260905/screenshots/physical-final-published.png new file mode 100644 index 0000000..17d5477 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-final-published.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-independent-reopen.png b/reports/training-modes-20260905/screenshots/physical-independent-reopen.png new file mode 100644 index 0000000..4bb8ce3 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-independent-reopen.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-independent-settings.png b/reports/training-modes-20260905/screenshots/physical-independent-settings.png new file mode 100644 index 0000000..b5fc539 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-independent-settings.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-list-cover.png b/reports/training-modes-20260905/screenshots/physical-list-cover.png new file mode 100644 index 0000000..e3073af Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-list-cover.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-offline-confirmed.png b/reports/training-modes-20260905/screenshots/physical-offline-confirmed.png new file mode 100644 index 0000000..b502eea Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-offline-confirmed.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-offline-preview.png b/reports/training-modes-20260905/screenshots/physical-offline-preview.png new file mode 100644 index 0000000..cc190c0 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-offline-preview.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-offline-review.png b/reports/training-modes-20260905/screenshots/physical-offline-review.png new file mode 100644 index 0000000..a20e206 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-offline-review.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-preflight.png b/reports/training-modes-20260905/screenshots/physical-preflight.png new file mode 100644 index 0000000..733c62f Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-preflight.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-published.png b/reports/training-modes-20260905/screenshots/physical-published.png new file mode 100644 index 0000000..ef66455 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-published.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-settings-reopen.png b/reports/training-modes-20260905/screenshots/physical-settings-reopen.png new file mode 100644 index 0000000..206e1f2 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-settings-reopen.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-settings.png b/reports/training-modes-20260905/screenshots/physical-settings.png new file mode 100644 index 0000000..f8963a2 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-settings.png differ diff --git a/reports/training-modes-20260905/screenshots/physical-teaching-list.png b/reports/training-modes-20260905/screenshots/physical-teaching-list.png new file mode 100644 index 0000000..adebe12 Binary files /dev/null and b/reports/training-modes-20260905/screenshots/physical-teaching-list.png differ diff --git a/reports/training-modes-20260905/studio-regression/html/index.html b/reports/training-modes-20260905/studio-regression/html/index.html new file mode 100644 index 0000000..b739fd3 --- /dev/null +++ b/reports/training-modes-20260905/studio-regression/html/index.html @@ -0,0 +1,76 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/reports/training-modes-20260905/studio-regression/results.json b/reports/training-modes-20260905/studio-regression/results.json new file mode 100644 index 0000000..a62a812 --- /dev/null +++ b/reports/training-modes-20260905/studio-regression/results.json @@ -0,0 +1,332 @@ +{ + "config": { + "configFile": "E:\\work\\edit_3d\\.codex-tmp\\training-studio-edge.config.ts", + "rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests", + "forbidOnly": false, + "fullyParallel": false, + "globalSetup": null, + "globalTeardown": null, + "globalTimeout": 0, + "grep": {}, + "grepInvert": null, + "maxFailures": 0, + "metadata": { + "actualWorkers": 1 + }, + "preserveOutput": "always", + "reporter": [ + [ + "list", + null + ], + [ + "html", + { + "outputFolder": "../unreal_tran/ute2e/reports/training-modes-20260905/studio-regression/html", + "open": "never" + } + ], + [ + "json", + { + "outputFile": "../unreal_tran/ute2e/reports/training-modes-20260905/studio-regression/results.json" + } + ] + ], + "reportSlowTests": { + "max": 5, + "threshold": 300000 + }, + "quiet": false, + "projects": [ + { + "outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/training-modes-20260905/studio-regression/test-results", + "repeatEach": 1, + "retries": 0, + "metadata": { + "actualWorkers": 1 + }, + "id": "edge-api-mock", + "name": "edge-api-mock", + "testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests", + "testIgnore": [], + "testMatch": [ + "content-training-studio.spec.ts" + ], + "timeout": 90000 + } + ], + "shard": null, + "updateSnapshots": "missing", + "updateSourceMethod": "patch", + "version": "1.55.0", + "workers": 1, + "webServer": null + }, + "suites": [ + { + "title": "content-training-studio.spec.ts", + "file": "content-training-studio.spec.ts", + "column": 0, + "line": 0, + "specs": [], + "suites": [ + { + "title": "训练编排工作台", + "file": "content-training-studio.spec.ts", + "line": 177, + "column": 6, + "specs": [ + { + "title": "绑定场景解析目标、步骤编排与保存产出运行时编码", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 90000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "edge-api-mock", + "projectName": "edge-api-mock", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 5740, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-09-05T15:50:21.018Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "1a7f56b7a54b61c74698-9e443d39b12c3afac5d7", + "file": "content-training-studio.spec.ts", + "line": 178, + "column": 3 + }, + { + "title": "未固定场景时目标不可选并阻断发布", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 90000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "edge-api-mock", + "projectName": "edge-api-mock", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 2667, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-09-05T15:50:27.324Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "1a7f56b7a54b61c74698-14e619265f39e9179269", + "file": "content-training-studio.spec.ts", + "line": 260, + "column": 3 + }, + { + "title": "实装形态自动生成严格递增的事件进度", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 90000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "edge-api-mock", + "projectName": "edge-api-mock", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 1931, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-09-05T15:50:30.007Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "1a7f56b7a54b61c74698-79c43419f766d9ba63ff", + "file": "content-training-studio.spec.ts", + "line": 273, + "column": 3 + }, + { + "title": "对抗形态校验每队人数、岗位与可提交岗位", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 90000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "edge-api-mock", + "projectName": "edge-api-mock", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 3038, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-09-05T15:50:31.953Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "1a7f56b7a54b61c74698-dc1dddf62fa98b0f3c27", + "file": "content-training-studio.spec.ts", + "line": 301, + "column": 3 + }, + { + "title": "高级编排从线性流程派生并能加分支节点与时间轴", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 90000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "edge-api-mock", + "projectName": "edge-api-mock", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 2212, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-09-05T15:50:35.005Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "1a7f56b7a54b61c74698-7fe1949db429235b70fa", + "file": "content-training-studio.spec.ts", + "line": 324, + "column": 3 + }, + { + "title": "训练目标支持 Enter 换行并按非空行保存", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 90000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "edge-api-mock", + "projectName": "edge-api-mock", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 1646, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-09-05T15:50:37.228Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "1a7f56b7a54b61c74698-deca564eea6b78d08f2c", + "file": "content-training-studio.spec.ts", + "line": 383, + "column": 3 + }, + { + "title": "九个流程视图与工具、故障、评分视图均可渲染", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 90000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "edge-api-mock", + "projectName": "edge-api-mock", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 8786, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-09-05T15:50:38.883Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "1a7f56b7a54b61c74698-36c17cd11d90eb649e98", + "file": "content-training-studio.spec.ts", + "line": 400, + "column": 3 + } + ] + } + ] + } + ], + "errors": [], + "stats": { + "startTime": "2026-09-05T15:50:19.681Z", + "duration": 59440.333, + "expected": 7, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } +} \ No newline at end of file diff --git a/reports/training-modes-20260905/studio-regression/screenshots/01-场景与目标.png b/reports/training-modes-20260905/studio-regression/screenshots/01-场景与目标.png new file mode 100644 index 0000000..be40fc6 Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/01-场景与目标.png differ diff --git a/reports/training-modes-20260905/studio-regression/screenshots/02-步骤编排.png b/reports/training-modes-20260905/studio-regression/screenshots/02-步骤编排.png new file mode 100644 index 0000000..1f04f8e Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/02-步骤编排.png differ diff --git a/reports/training-modes-20260905/studio-regression/screenshots/03-发布校验.png b/reports/training-modes-20260905/studio-regression/screenshots/03-发布校验.png new file mode 100644 index 0000000..510d6ec Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/03-发布校验.png differ diff --git a/reports/training-modes-20260905/studio-regression/screenshots/04-未绑定场景.png b/reports/training-modes-20260905/studio-regression/screenshots/04-未绑定场景.png new file mode 100644 index 0000000..26b3c2e Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/04-未绑定场景.png differ diff --git a/reports/training-modes-20260905/studio-regression/screenshots/05-实装进度.png b/reports/training-modes-20260905/studio-regression/screenshots/05-实装进度.png new file mode 100644 index 0000000..9633220 Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/05-实装进度.png differ diff --git a/reports/training-modes-20260905/studio-regression/screenshots/06-对抗校验.png b/reports/training-modes-20260905/studio-regression/screenshots/06-对抗校验.png new file mode 100644 index 0000000..2fecf0f Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/06-对抗校验.png differ diff --git a/reports/training-modes-20260905/studio-regression/screenshots/07-工具与故障.png b/reports/training-modes-20260905/studio-regression/screenshots/07-工具与故障.png new file mode 100644 index 0000000..ca70353 Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/07-工具与故障.png differ diff --git a/reports/training-modes-20260905/studio-regression/screenshots/08-高级编排.png b/reports/training-modes-20260905/studio-regression/screenshots/08-高级编排.png new file mode 100644 index 0000000..8a73fe9 Binary files /dev/null and b/reports/training-modes-20260905/studio-regression/screenshots/08-高级编排.png differ diff --git a/reports/training-return-tabs-20260906/evidence.json b/reports/training-return-tabs-20260906/evidence.json new file mode 100644 index 0000000..4eedabf --- /dev/null +++ b/reports/training-return-tabs-20260906/evidence.json @@ -0,0 +1,612 @@ +{ + "title": "训练编排返回列表与页面标签聚焦回归", + "status": "PASS", + "startedAt": "2026-09-05T16:40:46.677Z", + "environment": { + "browser": "Microsoft Edge / Playwright", + "role": "真实管理员登录后切换教员", + "origins": [ + "http://127.0.0.1:6180", + "http://192.168.31.168:6180" + ] + }, + "checks": [ + { + "title": "手动切换标签保留原编辑页", + "status": "PASS", + "details": { + "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": "真实页面标签点击" + }, + "screenshots": [ + "screenshots/01-manual-switch-keeps-editors.png" + ] + }, + { + "title": "顶部返回只关闭当前编辑标签,保留列表及另一编辑标签", + "status": "PASS", + "details": { + "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 + } + ] + }, + "screenshots": [ + "screenshots/02-return-closes-current.png" + ] + }, + { + "title": "刷新后已关闭标签不复活", + "status": "PASS", + "details": { + "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 + } + ] + }, + "screenshots": [ + "screenshots/03-refresh-no-resurrection.png" + ] + }, + { + "title": "未保存时 Escape 取消返回,原页与修改保留", + "status": "PASS", + "details": { + "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 + } + ] + }, + "screenshots": [ + "screenshots/04-unsaved-return-confirmation.png", + "screenshots/05-escape-keeps-editor.png" + ] + }, + { + "title": "保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)", + "status": "PASS", + "details": { + "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 + }, + "screenshots": [ + "screenshots/06-save-failure-keeps-editor.png" + ] + }, + { + "title": "明确放弃修改后返回并关闭当前编辑标签", + "status": "PASS", + "details": { + "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 + } + ] + }, + "screenshots": [ + "screenshots/07-discard-closes-current.png" + ] + }, + { + "title": "服务端两项目版本、封面与内容校验值未变", + "status": "PASS", + "details": { + "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 + }, + "screenshots": [] + }, + { + "title": "局域网地址返回列表也关闭当前编辑标签", + "status": "PASS", + "details": { + "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 + } + ] + } + }, + "screenshots": [ + "screenshots/08-lan-return-closes-current.png" + ] + } + ], + "blockedWrites": [ + { + "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 + } + ], + "pageErrors": [], + "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" + } + } + ], + "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。" + } + ], + "finishedAt": "2026-09-05T16:41:29.185Z" +} diff --git a/reports/training-return-tabs-20260906/index.html b/reports/training-return-tabs-20260906/index.html new file mode 100644 index 0000000..7a5e664 --- /dev/null +++ b/reports/training-return-tabs-20260906/index.html @@ -0,0 +1,463 @@ +训练编排返回列表与页面标签聚焦回归

训练编排返回列表与页面标签聚焦回归 PASS

真实教员身份 · 已有狐狸 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。所有截图来自实际浏览器,测试凭据没有写入报告。

手动切换标签保留原编辑页 PASS

查看真实观察记录
{
+  "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": "真实页面标签点击"
+}
手动切换标签保留原编辑页
手动切换标签保留原编辑页 · screenshots/01-manual-switch-keeps-editors.png

顶部返回只关闭当前编辑标签,保留列表及另一编辑标签 PASS

查看真实观察记录
{
+  "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
+    }
+  ]
+}
顶部返回只关闭当前编辑标签,保留列表及另一编辑标签
顶部返回只关闭当前编辑标签,保留列表及另一编辑标签 · screenshots/02-return-closes-current.png

刷新后已关闭标签不复活 PASS

查看真实观察记录
{
+  "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
+    }
+  ]
+}
刷新后已关闭标签不复活
刷新后已关闭标签不复活 · screenshots/03-refresh-no-resurrection.png

未保存时 Escape 取消返回,原页与修改保留 PASS

查看真实观察记录
{
+  "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
+    }
+  ]
+}
未保存时 Escape 取消返回,原页与修改保留
未保存时 Escape 取消返回,原页与修改保留 · screenshots/04-unsaved-return-confirmation.png
未保存时 Escape 取消返回,原页与修改保留
未保存时 Escape 取消返回,原页与修改保留 · screenshots/05-escape-keeps-editor.png

保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503) PASS

查看真实观察记录
{
+  "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
+}
保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)
保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503) · screenshots/06-save-failure-keeps-editor.png

明确放弃修改后返回并关闭当前编辑标签 PASS

查看真实观察记录
{
+  "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
+    }
+  ]
+}
明确放弃修改后返回并关闭当前编辑标签
明确放弃修改后返回并关闭当前编辑标签 · screenshots/07-discard-closes-current.png

服务端两项目版本、封面与内容校验值未变 PASS

查看真实观察记录
{
+  "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
+}

局域网地址返回列表也关闭当前编辑标签 PASS

查看真实观察记录
{
+  "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
+      }
+    ]
+  }
+}
局域网地址返回列表也关闭当前编辑标签
局域网地址返回列表也关闭当前编辑标签 · screenshots/08-lan-return-closes-current.png
\ No newline at end of file diff --git a/reports/training-return-tabs-20260906/screenshots/01-manual-switch-keeps-editors.png b/reports/training-return-tabs-20260906/screenshots/01-manual-switch-keeps-editors.png new file mode 100644 index 0000000..4546ce3 Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/01-manual-switch-keeps-editors.png differ diff --git a/reports/training-return-tabs-20260906/screenshots/02-return-closes-current.png b/reports/training-return-tabs-20260906/screenshots/02-return-closes-current.png new file mode 100644 index 0000000..498baee Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/02-return-closes-current.png differ diff --git a/reports/training-return-tabs-20260906/screenshots/03-refresh-no-resurrection.png b/reports/training-return-tabs-20260906/screenshots/03-refresh-no-resurrection.png new file mode 100644 index 0000000..ddc02e0 Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/03-refresh-no-resurrection.png differ diff --git a/reports/training-return-tabs-20260906/screenshots/04-unsaved-return-confirmation.png b/reports/training-return-tabs-20260906/screenshots/04-unsaved-return-confirmation.png new file mode 100644 index 0000000..12d373b Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/04-unsaved-return-confirmation.png differ diff --git a/reports/training-return-tabs-20260906/screenshots/05-escape-keeps-editor.png b/reports/training-return-tabs-20260906/screenshots/05-escape-keeps-editor.png new file mode 100644 index 0000000..2cf68ce Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/05-escape-keeps-editor.png differ diff --git a/reports/training-return-tabs-20260906/screenshots/06-save-failure-keeps-editor.png b/reports/training-return-tabs-20260906/screenshots/06-save-failure-keeps-editor.png new file mode 100644 index 0000000..e2c0893 Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/06-save-failure-keeps-editor.png differ diff --git a/reports/training-return-tabs-20260906/screenshots/07-discard-closes-current.png b/reports/training-return-tabs-20260906/screenshots/07-discard-closes-current.png new file mode 100644 index 0000000..046f1e0 Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/07-discard-closes-current.png differ diff --git a/reports/training-return-tabs-20260906/screenshots/08-lan-return-closes-current.png b/reports/training-return-tabs-20260906/screenshots/08-lan-return-closes-current.png new file mode 100644 index 0000000..9d1d140 Binary files /dev/null and b/reports/training-return-tabs-20260906/screenshots/08-lan-return-closes-current.png differ diff --git a/tests/content-training-studio.spec.ts b/tests/content-training-studio.spec.ts index c46fad9..378a2d7 100644 --- a/tests/content-training-studio.spec.ts +++ b/tests/content-training-studio.spec.ts @@ -104,13 +104,14 @@ class TrainingMock { } async install(page: Page) { + await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered')) await page.addInitScript(() => { sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token') }) await page.route('**/api/auth/v1/auth/me', (route) => ok(route, { - user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 }, - activeRoleId: 'role-admin', - roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }], + user: { id: '1', username: 'review.teacher', displayName: '回归测试教员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 }, + activeRoleId: 'role-teacher', + roles: [{ id: 'role-teacher', code: 'teacher', name: '教员', shortName: '教', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL' }], permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'], authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200, })) @@ -301,6 +302,9 @@ test.describe('训练编排工作台', () => { const mock = new TrainingMock() mock.mode = 'CONFRONTATION' mock.content = legacyTrainingContent('CONFRONTATION') + // Missing fields in old documents now use compatibility defaults; an explicit empty + // grant must stay empty and be blocked by publish validation. + for (const step of mock.content.steps as Json[]) step.allowedPositions = [] await mock.install(page) await page.goto('/content/training-projects/901/studio?view=overview') @@ -311,7 +315,7 @@ test.describe('训练编排工作台', () => { await page.locator('.step-row').first().click() await expect(page.locator('.inspector-panel')).toContainText('执行岗位') - // 历史工程的对抗步骤没有岗位,发布校验必须逐步骤阻断 + // 明确清空对抗步骤岗位后,发布校验必须逐步骤阻断,不能恢复默认权限。 await page.goto('/content/training-projects/901/studio?view=preflight') await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位') await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true }) diff --git a/tools/build-training-live-report.mjs b/tools/build-training-live-report.mjs new file mode 100644 index 0000000..8781da5 --- /dev/null +++ b/tools/build-training-live-report.mjs @@ -0,0 +1,237 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// Browser runs own the evidence files. This generator only writes index.html +// and report.md. Missing evidence remains pending, never an inferred pass. +// Each JSON may use { status, title, checks/evidence/results/tests: [...], +// projects: [...], commands: [...], limitations: [...] }. Unknown shapes remain +// available as sanitized JSON details rather than being silently discarded. +const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-live-20260905') +fs.mkdirSync(reportDir, { recursive: true }) +const array = value => Array.isArray(value) ? value : value == null ? [] : [value] +const sensitiveKey = /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]) => !sensitiveKey.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 status = value => { + const normalized = String(value ?? '').toUpperCase() + return normalized === 'PASS' || normalized === 'PASSED' ? 'PASS' + : normalized === 'FAIL' || normalized === 'FAILED' ? 'FAIL' : 'PENDING' +} +const statusLabel = value => ({ PASS: '通过', FAIL: '失败', PENDING: '待补充证据' })[status(value)] +const statusClass = value => status(value).toLowerCase() + +const sourceDefinitions = [ + { file: 'final-evidence.json', title: '真实编排、保存、发布及重新打开' }, + { file: 'readonly-evidence.json', title: '独立浏览器只读重开与真实画面验证' }, + { file: 'error-evidence.json', title: '失败、冲突与并发处理' }, +] +const sources = sourceDefinitions.map(definition => { + const absolute = path.join(reportDir, definition.file) + const data = fs.existsSync(absolute) + ? sanitize(JSON.parse(fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, ''))) : null + const checks = data ? array(data.checks || data.evidence || data.results || data.tests) : [] + const assessed = checks.filter(item => item && item.superseded !== true && item.historical !== true) + const sourceStatus = assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL' + : status(data?.status) !== 'PENDING' ? status(data.status) + : assessed.length && assessed.every(item => status(item.status) === 'PASS') ? 'PASS' + : assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL' : 'PENDING' + return { ...definition, data, checks, status: sourceStatus } +}) +const final = sources[0].data || {} +const projects = array(final.projects || final.retainedProjects || final.trainingProjects) +const reopenedProjects = array(sources[1].data?.projects) +const versionMismatches = projects.flatMap(project => { + const expected = project.publishedVersionId || project.finalVersionId || project.currentVersionId + if (!expected || !sources[1].data) return [] + const reopened = reopenedProjects.find(item => String(item.id || item.projectId) === String(project.id || project.projectId)) + const actual = reopened?.publishedVersionId || reopened?.currentVersionId + return String(expected) === String(actual) ? [] : [{ projectId: project.id || project.projectId, expected, actual: actual || '未记录' }] +}) +if (versionMismatches.length && sources[1].status !== 'FAIL') sources[1].status = 'PENDING' +const allChecks = sources.flatMap(source => source.checks) + .filter(item => item && item.superseded !== true && item.historical !== true) +const passedChecks = allChecks.filter(item => status(item.status) === 'PASS').length +const failedChecks = allChecks.filter(item => status(item.status) === 'FAIL').length +const pendingChecks = allChecks.length - passedChecks - failedChecks +const overall = sources.some(source => source.status === 'FAIL') || failedChecks ? 'FAIL' + : sources.every(source => source.status === 'PASS') && !pendingChecks ? 'PASS' : 'PENDING' +const commands = sources.flatMap(source => array(source.data?.commands)) +const limitations = [...new Map(sources.flatMap(source => array(source.data?.limitations || source.data?.followUpIssues)) + .map(item => [pretty(item), item])).values()] +const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) + +const history = [ + { path: 'screenshots/01-before-scene-catalog.png', caption: '过程记录:初建训练仍显示默认液压模板,仅记录起点,不作为故障对照或最终通过证据。' }, + { path: 'screenshots/02-published-scenes.png', caption: '过程记录:打开已发布场景目录。最终版本和固定依赖以最终证据为准。' }, + { path: 'screenshots/03-truck-bound.png', caption: '过程记录:首次绑定卡车时仍有 8 个旧模板步骤;不代表最终两步编排通过。' }, + { path: 'screenshots/04-truck-saved.png', caption: '过程记录:卡车训练阶段性保存;最终步骤、状态、版本和封面以最终证据为准。' }, +].filter(item => fs.existsSync(path.join(reportDir, item.path))) +const historyPaths = new Set(history.map(item => item.path)) +const screenshotPattern = /^screenshots\/[\w\W]+\.(?:png|jpe?g|webp)$/i +const imagePaths = new Set() +function imageRefs(value, inheritedCaption = '') { + if (!value) return [] + if (typeof value === 'string') return screenshotPattern.test(value.replaceAll('\\', '/')) + ? [{ path: value.replaceAll('\\', '/'), caption: inheritedCaption }] : [] + if (Array.isArray(value)) return value.flatMap(item => imageRefs(item, inheritedCaption)) + if (typeof value !== 'object') return [] + const caption = value.caption || value.title || value.label || value.name || inheritedCaption + return Object.entries(value).flatMap(([key, item]) => imageRefs(item, caption || key)) +} +function validateImage(relative) { + const absolute = path.resolve(reportDir, relative) + const relativeToRoot = path.relative(reportDir, absolute) + if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative) + || relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) + || !screenshotPattern.test(relative) || !fs.existsSync(absolute) + || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) { + throw new Error(`截图引用不存在、为空或不在报告目录内:${relative}`) + } + const realRelative = path.relative(fs.realpathSync(reportDir), fs.realpathSync(absolute)) + if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) throw new Error(`截图链接越出报告目录:${relative}`) + imagePaths.add(relative) +} +const uniqueImages = refs => [...new Map(refs.map(ref => [ref.path, ref])).values()] +const evidenceImages = uniqueImages(sources.flatMap(source => imageRefs(source.data))) +evidenceImages.forEach(ref => validateImage(ref.path)) +history.forEach(ref => validateImage(ref.path)) +const finalImages = evidenceImages.filter(ref => !historyPaths.has(ref.path)) +const imageUrl = value => value.split('/').map(encodeURIComponent).join('/') +const figure = ref => `
${html(ref.caption || ref.path)}
${html(ref.caption || ref.path)} · 点击查看原图
` +const mdFigure = ref => `[![${md(ref.caption || ref.path)}](${imageUrl(ref.path)})](${imageUrl(ref.path)})` +const details = (value, title = '查看脱敏后的完整记录') => `
${html(title)}
${html(pretty(value))}
` +const bullets = items => `` +const mdBullets = items => items.map(item => `- ${md(item)}`).join('\n') +const badge = value => `${statusLabel(value)}` + +const stages = [ + { title: '1 · 资料', items: [ + '参考《文档/维修训练内容设计.pdf》(6 页)与已有场景制作成果。按“场景 → 任务步骤 → 学习 / 训练 / 评估”的资料结构检查实现。', + '本次使用已发布的狐狸场景和牛奶卡车场景验证训练编排,确认真实模型可见、步骤目标来自绑定场景、保存及发布使用服务端版本。', + ] }, + { title: '2 · 产品原型', items: [ + '沿用现有三维训练编排工作台:选择场景、选择三维对象、配置任务步骤、保存草稿和发布。', + '新建时的液压模板仅作为现有界面起点;绑定新场景后不得继续选择该场景中不存在的液压设备。', + ] }, + { title: '3 · 设计', items: [ + '两条独立训练分别绑定狐狸与卡车的明确发布版本;每条配置两步、每步 50 分,总计 100 分,并核验重新打开后的状态。', + '保存时以三维视口截图生成训练封面;模型可见性要通过实际截图及模型显示 / 隐藏的像素差异判断。', + '将正常流程、独立浏览器只读重开、资源失败、封面失败、版本冲突及并发切换分别留证。独立重开阻断所有业务写请求,核验模型像素可见性、封面哈希与发布依赖;不将其表述为只读角色权限测试。', + ] }, + { title: '4 · 开发', items: [ + '场景目录对接已发布内容版本;GLB 通过受控下载读取;训练依赖固定到所选场景版本,恢复项目时保留来源元数据。', + '修复蒙皮模型缩放后的骨骼矩阵与包围盒刷新;场景恢复、部位覆盖和高亮使用一致的尺寸计算。', + '绑定场景后移除不存在的内置操作目标,过滤隐藏或已删除对象及其子目标。', + '保存和发布等待异步操作完成;封面与内容在同一次版本保存中持久化;失败、冲突及并发切换必须保留可恢复状态。最终实现效果以下方真实测试记录为准。', + '修复旧工程快照回写覆盖最新名称与训练形态;场景绑定进行中禁止保存和发布,避免模型尚未加载成功就写入新的场景依赖。', + ] }, +] +const scopeItems = [ + '本次范围为训练编排、草稿保存、发布与重新打开,不包含创建教学任务、下发学员或完成学员训练。', + '“预览训练”仍提示需发布后在教学实施中下发;本报告不将该提示视作学员训练已运行。', + '本轮仅修复已实现功能的问题,无新增需要用户确认的功能改动。', +] +const projectValue = (project, names) => names.map(name => project?.[name]).find(value => value !== undefined && value !== null && value !== '') +function projectRow(project) { + const scene = project.scene || project.sceneDependency || project.boundScene || {} + return [ + projectValue(project, ['id', 'projectId', 'trainingProjectId']), + projectValue(project, ['name', 'title']), + projectValue(project, ['finalStatus', 'status']), + projectValue(project, ['finalVersionId', 'currentVersionId', 'versionId']), + projectValue(project, ['publishedVersionId']), + projectValue(project, ['sceneProjectId', 'sceneId']) || projectValue(scene, ['projectId', 'sceneId', 'id']), + projectValue(project, ['sceneVersionId']) || projectValue(scene, ['versionId', 'publishedVersionId']), + projectValue(project, ['stepCount']) ?? (Array.isArray(project.steps) ? project.steps.length : undefined), + projectValue(project, ['totalScore', 'score']), + ].map(recorded) +} +const projectHeaders = ['训练 ID', '名称', '状态', '当前版本', '发布版本', '场景 ID', '固定场景版本', '步骤数', '总分'] +const htmlProjects = projects.length ? `
${projectHeaders.map(value => ``).join('')}${projects.map(project => `${projectRow(project).map(value => ``).join('')}`).join('')}
${html(value)}
${html(value)}
${projects.map(project => details(project, `训练 ${recorded(projectValue(project, ['id', 'projectId', 'trainingProjectId']))}:版本、步骤、封面与依赖记录`)).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.title)} ${badge('PENDING')}

等待 ${html(source.file)}。未计为通过。

` + return `

${html(source.data.title || source.title)} ${badge(source.status)}

${source.checks.map((check, index) => `

${html(check.title || check.name || check.label || `检查 ${index + 1}`)} ${badge(check.status)}

${check.superseded || check.historical ? '

历史记录,不计入当前通过数。

' : ''}${details(check)}
`).join('')}${details(source.data, `${source.file} · 完整记录`)}
` +} +function sourceMd(source) { + const title = source.data?.title || source.title + if (!source.data) return `### ${title} · 待补充证据\n\n等待 ${source.file},未计为通过。` + const checks = source.checks.map((check, index) => `- ${md(check.title || check.name || check.label || `检查 ${index + 1}`)}:${statusLabel(check.status)}${check.superseded || check.historical ? '(历史记录,不计入当前通过数)' : ''}`).join('\n') + return `### ${title} · ${statusLabel(source.status)}\n\n${checks}\n\n
${html(source.file)} · 脱敏完整记录\n\n
${html(pretty(source.data))}
\n\n
` +} +const summary = `证据组:${sources.filter(source => source.status === 'PASS').length}/${sources.length} 通过;明确检查:通过 ${passedChecks},失败 ${failedChecks},待验证 ${pendingChecks};已校验截图 ${imagePaths.size} 张。` +const htmlCommands = commands.map(command => `

${html(command.command || command.name || '验证命令')} ${badge(command.status)}

${html(command.details || command.result || '')}

`).join('') +const mdCommands = commands.map(command => `- ${md(command.command || command.name || '验证命令')}:${statusLabel(command.status)}。${md(command.details || command.result || '')}`).join('\n') +const outputHtml = ` +训练编排真实测试报告 · 2026-09-05 +
+

训练编排真实测试报告 ${badge(overall)}

狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布

2026-09-05 · ${html(final.baseURL || 'http://127.0.0.1:6180')} · ${html(final.browser || 'Microsoft Edge / Playwright')} · ${html(final.role || '教员编排与独立浏览器只读重开')}

${html(summary)}

+ +${overall === 'PENDING' ? '

报告草稿:最终证据尚不完整。已有过程截图和开发记录不能代替保存、发布及重新打开的最终验证。

' : ''} +

资料 → 产品原型 → 设计 → 开发

${stages.map(stage => `

${html(stage.title)}

${bullets(stage.items)}
`).join('')}
+

保留训练数据

${htmlProjects}

保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。

+

5 · 测试

${html(summary)}

过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。

${versionMismatches.length ? `

独立重开证据尚未覆盖最终发布版本,不能标记完整通过。

${details(versionMismatches, '待重验的版本')}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `

执行命令与验证结果

${htmlCommands}` : '

命令与最终构建结果等待证据记录。

'}
+

最终及异常验证截图

${finalImages.length ? `` : '

等待最终证据引用实际截图。尚无最终截图不等于模型已通过可见性检查。

'}
+

过程截图 · 不计入最终通过结论

这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。

+

范围、限制与功能确认结论

${bullets(scopeItems)}${limitations.length ? `

实测限制与后续项

${bullets(limitations.map(pretty))}` : ''}
+ +
` +const outputMd = `# 训练编排真实测试报告 · 2026-09-05 + +状态:**${statusLabel(overall)}**。${summary} + +${overall === 'PENDING' ? '> 报告草稿:最终证据尚不完整,过程截图不能代替最终保存、发布和重新打开验证。\n' : ''} +${stages.map(stage => `## ${stage.title}\n\n${mdBullets(stage.items)}`).join('\n\n')} + +## 保留训练数据 + +${mdProjects} + +保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。 + +## 5 · 测试 + +过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。 + +${versionMismatches.length ? `> 独立重开证据尚未覆盖最终发布版本,不能标记完整通过。\n\n
${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(title)}
${html(pretty(value))}
` +const bullets = items => `` +const mdBullets = items => items.map(item => `- ${md(pretty(item))}`).join('\n') +const sources = [ + { file: 'evidence.json', title: '浏览器编排、重新打开及教学入口实测', kind: 'UI' }, + { file: 'api-evidence.json', title: '正式教学 API:人工事件与红蓝岗位运行', kind: 'API' }, +].map(source => { + const absolute = path.join(reportDir, source.file) + const data = fs.existsSync(absolute) ? sanitize(JSON.parse(fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, ''))) : null + const checks = array(data?.checks || data?.results || data?.tests).filter(item => item && !item.historical && !item.superseded) + const status = checks.some(check => state(check.status) === 'FAIL') ? 'FAIL' + : data?.status ? state(data.status) === 'PASS' && checks.some(check => state(check.status) === 'BLOCKED') ? 'BLOCKED' : state(data.status) + : checks.some(check => state(check.status) === 'BLOCKED') ? 'BLOCKED' + : checks.length && checks.every(check => state(check.status) === 'PASS') ? 'PASS' : 'PENDING' + return { ...source, data, checks, status } +}) +const ui = sources[0].data || {} +const api = sources[1].data || {} +const projects = array(ui.projects || ui.trainingProjects || ui.retainedProjects) +const versionMismatches = Object.entries(api.references || {}).flatMap(([channel, reference]) => { + if (!reference) return [] + const project = projects.find(item => String(item.id || item.projectId) === String(reference.projectId)) + if (!projects.length) return [] + const expected = project?.publishedVersionId || project?.finalVersionId + if (project?.apiTestVersionId && project?.apiVersionNote && String(project.apiTestVersionId) === String(reference.versionId)) return [] + return expected && String(expected) === String(reference.versionId) ? [] : [{ channel, projectId: reference.projectId, uiPublishedVersionId: expected || '未记录', apiVersionId: reference.versionId }] +}) +const checks = sources.flatMap(source => source.checks) +const counts = { passed: checks.filter(check => state(check.status) === 'PASS').length, blocked: checks.filter(check => state(check.status) === 'BLOCKED').length, failed: checks.filter(check => state(check.status) === 'FAIL').length, pending: checks.filter(check => state(check.status) === 'PENDING').length } +const overall = sources.some(source => source.status === 'FAIL') || counts.failed ? 'FAIL' + : sources.some(source => source.status === 'PENDING') || counts.pending || versionMismatches.length ? 'PENDING' + : counts.blocked || sources.some(source => ['BLOCKED', 'PASS_WITH_LIMITATIONS'].includes(source.status)) ? 'PASS_WITH_LIMITATIONS' : 'PASS' +const limitations = [...new Map(sources.flatMap(source => array(source.data?.limitations)).map(item => [pretty(item), item])).values()] +const followups = array(ui.confirmationItems || ui.followUpIssues || ui.featureConfirmations) +const commands = sources.flatMap(source => array(source.data?.commands)) +const imagePattern = /^screenshots\/.+\.(?:png|jpe?g|webp)$/i +function imageRefs(value, inheritedCaption = '') { + if (typeof value === 'string') return imagePattern.test(value.replaceAll('\\', '/')) ? [{ path: value.replaceAll('\\', '/'), caption: inheritedCaption }] : [] + if (Array.isArray(value)) return value.flatMap(item => imageRefs(item, inheritedCaption)) + if (!value || typeof value !== 'object') return [] + const caption = value.caption || value.title || value.label || value.name || inheritedCaption + return Object.values(value).flatMap(item => imageRefs(item, caption)) +} +const images = [...new Map(sources.flatMap(source => imageRefs(source.data)).map(item => [item.path, item])).values()] +for (const image of images) { + const absolute = path.resolve(reportDir, image.path) + const relative = path.relative(reportDir, absolute) + if (path.isAbsolute(image.path) || relative.startsWith('..') || path.isAbsolute(relative) || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) throw new Error(`截图不存在或路径非法:${image.path}`) + const real = path.relative(fs.realpathSync(reportDir), fs.realpathSync(absolute)) + if (real.startsWith('..') || path.isAbsolute(real)) throw new Error(`截图越出目录:${image.path}`) +} +const imageUrl = value => value.split('/').map(encodeURIComponent).join('/') +const figure = image => `
${html(image.caption || image.path)}
${html(image.caption || image.path)} · 点击查看原图
` +const mdFigure = image => `[![${md(image.caption || image.path)}](${imageUrl(image.path)})](${imageUrl(image.path)})` +const stages = [ + { title: '1 · 资料', items: [ + '实际查看《维修训练内容设计.pdf》第 1–3、5–6 页扫描件。第 1–2 页将虚拟、混合现实、AI 指导下的实装组织为递进训练,流程包含构建场景、编排任务、组班下发、学员训练与数据报告。', + '本次使用狐狸与牛奶卡车测试素材验证软件链路,不将其视为原文装备维修内容或真实维修能力考核。原文已查看页面没有单列红蓝对抗规则;对抗依据现有教学实施、渠道合同、命令幂等及步骤事实文档验证。', + '合同来源:unreal_tran_api/wiki/14-教学实施模块.md、19-训练定义渠道合同.md、20-教学命令幂等.md、21-教学步骤运行事实.md。', + ] }, + { title: '2 · 产品原型', items: [ + '沿用现有训练编排工作台与模式配置表单,分别建立实装和对抗内容;保存、发布后重新打开检查步骤、分值、封面与模式参数。', + '实装默认运行入口为离线识别回放;对抗默认入口为本地工作台。分别记录界面实际行为,再通过正式 API 验证服务端教学闭环。', + ] }, + { title: '3 · 设计', items: [ + '实装配置三步人工确认,分值 30 / 30 / 40;完成进度按步骤推导为 33 / 67 / 100。验证失败重试、顺序约束、事件身份及重复事件处理。', + '对抗配置红蓝两队、每队两人,步骤和提交均由 COMMANDER 执行;OPERATOR 用于岗位拒绝测试。验证同队共享运行、跨队隔离、服务器时限、提交与教员评定。', + '任务固定引用已发布训练版本;只创建本次开发测试数据。人工确认和客户端模拟参数均明确标识为测试,不连接真实工位、PLC、摄像头或视觉服务。', + ] }, + { title: '4 · 开发', items: array(ui.development || ui.fixes).length ? array(ui.development || ui.fixes) : [ + '本轮业务修复与验证结果等待浏览器 evidence.json;本报告生成器不会修改业务数据或将待实施功能标为已完成。', + ] }, +] +const scope = [ + '本报告区分编排 UI、默认教学 UI 和正式教学 API 三类证据;API 成功不能证明默认 UI 已连接该服务端运行。', + '实装的设备或视觉事件链路未接真实设备验证;人工确认测试不能替代实机联调、安全联锁或实际操作技能考核。', + '对抗的默认本地工作台与正式服务端任务分别留证;无证据时不声称多人跨浏览器实时协作或正式比赛胜负已联调。', + '正式 API 对抗由四个隔离学员会话依次操作;检查服务端截止时间已生效,未等待整局自然超时,不作为多人并发或网络同步压力测试。', + '100 分评定仅验证接口和结果留痕,不代表学员实际维修能力。测试不会向他人发送消息或下发真实设备指令。', +] +const modeRows = [ + ['实装实训', 'PHYSICAL', '正式 API 的 MANUAL 人工事件', '三步顺序、失败重试、来源权限、事件幂等、提交与评定', '默认离线识别页;设备 / 视觉接入另行联调'], + ['对抗训练', 'CONFRONTATION', '正式 API 的红蓝两队共享运行', '岗位权限、两队隔离、顺序、时限、命令幂等、提交与评定', '默认本地工作台;不混同正式 API 运行'], +] +function tableHtml(headers, rows) { return `
${headers.map(value => ``).join('')}${rows.map(row => `${row.map(value => ``).join('')}`).join('')}
${html(value)}
${html(recorded(value))}
` } +function tableMd(headers, rows) { return `| ${headers.map(md).join(' | ')} |\n| ${headers.map(() => '---').join(' | ')} |\n${rows.map(row => `| ${row.map(value => md(recorded(value))).join(' | ')} |`).join('\n')}` } +const modeHeaders = ['模式', '正式渠道', '本轮 API 验证方法', '必检项', '默认界面边界'] +const projectHeaders = ['训练 ID', '名称', '模式', '状态', '当前发布版本', 'API 已测版本', '步骤数'] +const projectRows = projects.map(project => [project.id || project.projectId, project.name || project.title, project.trainingMode || project.mode || project.channel, project.finalStatus || project.status, project.publishedVersionId || project.finalVersionId, Object.values(api.references || {}).find(reference => reference && String(reference.projectId) === String(project.id || project.projectId))?.versionId, project.stepCount ?? project.steps?.length]) +const runHeaders = ['任务 ID', '运行 ID', '渠道', '队伍', '状态', '完成度', '评定分'] +const runRows = array(api.runs).map(run => [run.assignmentId, run.id, run.channel, run.team, run.status, run.progressPercent, run.score]) +function sourceHtml(source) { + if (!source.data) return `

${html(source.title)} ${badge('PENDING')}

等待 ${html(source.file)},未计为通过。

` + return `

${html(source.data.title || source.title)} ${badge(source.status)}

证据类型:${source.kind} · ${html(source.file)}

${source.checks.map(check => `

${html(check.title || check.name || '检查')} ${badge(check.status)}

${details(check)}
`).join('')}${details(source.data, `${source.file} · 完整记录`)}
` +} +function sourceMd(source) { + return `### ${md(source.data?.title || source.title)} · ${label(source.status)}\n\n证据类型:${source.kind}。\n\n${source.data ? source.checks.map(check => `- ${md(check.title || check.name || '检查')}:${label(check.status)}`).join('\n') : `等待 ${source.file},未计为通过。`}\n\n${source.data ? `
${html(source.file)} · 脱敏完整记录\n\n
${html(pretty(source.data))}
\n\n
` : ''}` +} +const summary = `证据组 ${sources.filter(source => source.status !== 'PENDING').length}/${sources.length} 已完成;检查通过 ${counts.passed},受限 ${counts.blocked},失败 ${counts.failed},待验证 ${counts.pending};已校验截图 ${images.length} 张。` +const conclusion = overall === 'PASS_WITH_LIMITATIONS' ? '编排与 API 已验证,教学页面闭环受限。需完成默认教学页面与正式任务运行的接线,才能从页面走完新模型对应的训练。' : '' +const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) +const outputHtml = `实装与对抗训练实测报告 · 2026-09-05

实装与对抗训练实测报告 ${badge(overall)}

编排 UI · 默认教学入口 · 正式教学 API

${conclusion ? `

${html(conclusion)}

` : ''}

2026-09-05 · ${html(ui.baseURL || api.baseURL || 'http://127.0.0.1:6180')}

${html(summary)}

+${overall === 'PENDING' ? '

报告草稿:最终证据尚不完整,不以配置完成或单独 API 成功替代界面全流程验证。

' : ''} +

资料 → 产品原型 → 设计 → 开发

${stages.map(stage => `

${html(stage.title)}

${bullets(stage.items)}
`).join('')}

两种模式的验证边界

${tableHtml(modeHeaders, modeRows)}
+

保留开发测试数据

${projects.length ? tableHtml(projectHeaders, projectRows) + projects.map(project => `${project.apiVersionNote ? `

训练 ${html(project.id || project.projectId)}:${html(project.apiVersionNote)}

` : ''}${details(project, `训练 ${project.id || project.projectId} · 发布与配置记录`)}`).join('') : '

等待 UI 最终项目记录。

'}

正式教学任务与运行

${runRows.length ? tableHtml(runHeaders, runRows) : '

等待 API 运行结果。

'}${api.retainedData ? details(api.retainedData, '本次新增数据 ID') : ''}

随机测试密码与登录会话不写入报告;保留新建数据用于追溯。

+

5 · 测试

${html(summary)}

${versionMismatches.length ? `

UI 与 API 证据引用的发布版本尚未一致,不能标记完整通过。

${details(versionMismatches)}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `

测试命令与构建

${commands.map(command => `

${html(command.command || command.name)} ${badge(command.status)}

${html(command.details || command.result || '')}

`).join('')}` : ''}
+

真实浏览器截图

截图只来自实际存在的文件;API 请求结果单列,不以 UI 截图代替。

${images.length ? `` : '

等待 evidence.json 引用实际截图。

'}
+

范围、限制与待确认项

${bullets(scope)}${limitations.length ? `

实测边界与限制

${bullets(limitations)}` : ''}

需要另行确认的功能范围

${followups.length ? bullets(followups) : '

本栏等待最终证据中的功能范围结论;默认教学 UI 与正式运行的接线现状需按实际结果评估,不自动扩展实现。

'}
` +const outputMd = `# 实装与对抗训练实测报告 · 2026-09-05 + +状态:**${label(overall)}**。${summary} + +${conclusion} + +范围:编排 UI、默认教学入口、正式教学 API 分开验证。 + +${stages.map(stage => `## ${stage.title}\n\n${mdBullets(stage.items)}`).join('\n\n')} + +## 两种模式的验证边界 + +${tableMd(modeHeaders, modeRows)} + +## 保留开发测试数据 + +${projects.length ? tableMd(projectHeaders, projectRows) : '等待最终 UI 项目记录。'} + +${projects.filter(project => project.apiVersionNote).map(project => `> 训练 ${md(project.id || project.projectId)}:${md(project.apiVersionNote)}`).join('\n\n')} + +### 正式教学任务与运行 + +${runRows.length ? tableMd(runHeaders, runRows) : '等待 API 运行结果。'} + +随机测试密码与登录会话不写入报告;保留新建数据用于追溯。 + +## 5 · 测试 + +${versionMismatches.length ? `> UI 与 API 发布版本不一致,未计为完整通过。\n\n
${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(check.title)} ${check.status}

查看真实观察记录
${escape(JSON.stringify(check.details, null, 2))}
${(check.screenshots || []).map(image => `
${escape(check.title)}
${escape(check.title)} · ${image}
`).join('')}
`).join('') +const report = `${escape(evidence.title)}

${escape(evidence.title)} ${evidence.status}

真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网

通过 ${evidence.checks.filter(check => check.status === 'PASS').length} / ${evidence.checks.length} 项 · 业务真实写入 0 次

资料 → 原型 → 设计 → 开发

依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。

本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。

测试与构建

${evidence.commands.map(command => `

${escape(command.command)}:${command.status} · ${escape(command.details)}

`).join('')}

JSON 证据:evidence.json。所有截图来自实际浏览器,测试凭据没有写入报告。

${checks}
` +fs.writeFileSync(path.join(reportDir, 'index.html'), report) +console.log(JSON.stringify({ status: evidence.status, passed: evidence.checks.filter(check => check.status === 'PASS').length, checks: evidence.checks.length, screenshots: evidence.checks.flatMap(check => check.screenshots || []).length, blockedWrites: evidence.blockedWrites.length, report: 'reports/training-return-tabs-20260906/index.html' })) +if (evidence.status !== 'PASS') process.exitCode = 1 diff --git a/tools/training-scene-api.test.mjs b/tools/training-scene-api.test.mjs new file mode 100644 index 0000000..c3218c3 --- /dev/null +++ b/tools/training-scene-api.test.mjs @@ -0,0 +1,167 @@ +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 = source => ts.transpileModule(source, { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, +} }).outputText +const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` +const sceneAdapter = dataUrl(compile(readFileSync(new URL('scene-legacy/contentApiClient.ts', web), 'utf8'))) +const compiled = compile(readFileSync(new URL('training-legacy/sceneApiBridge.ts', web), 'utf8')) + .replace(/import \{[^}]+\} from ['"]\.\.\/\.\.\/api\/content['"];?/, 'const downloadContentAsset = null, getContentCatalog = null, getContentVersion = null;') + .replace(/from ['"]\.\.\/scene-legacy\/contentApiClient['"]/, `from '${sceneAdapter}'`) +const { TrainingSceneApiBridge } = await import(dataUrl(compiled)) +const { normalizeTrainingProject } = await import(new URL('guide-legacy/demo-src/training-project-io.js', web)) +const editorSource = readFileSync(new URL('guide-legacy/demo-src/training-editor.js', web), 'utf8') +const editorAst = ts.createSourceFile('training-editor.js', editorSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) +const editorClass = editorAst.statements.find(node => ts.isClassDeclaration(node) && node.members.some(member => member.name?.getText(editorAst) === 'bindPublishedScene')) +const bindingMethods = editorClass.members.filter(node => ['bindPublishedScene', 'applyPublishedScene'].includes(node.name?.getText(editorAst))) +assert.equal(bindingMethods.length, 2) +const { BindingEditor } = await import(dataUrl(`const clone = value => structuredClone(value); export class BindingEditor { ${bindingMethods.map(node => node.getText(editorAst)).join('\n')} }`)) + +const uri = 'content://asset/model/157/201/3001' +const scene = () => ({ + id: '208', projectId: '160', type: 'SCENE', status: 'PUBLISHED', versionNumber: 2, + publishedAt: '2026-09-05T12:00:00', + content: { schema: 'edit3dv4.scene', name: 'Fox scene', type: 'outdoor', + objects: [{ id: 'fox', type: 'imported', operable: true, resourceUrl: uri, position: [-7.5, 0, 0] }], + environment: { type: 'outdoor' }, rendering: { camera: { position: [10, 6, 9] } } }, + dependencies: [{ relationType: 'SCENE_MODEL', targetProjectId: '157', targetVersionId: '201', required: true }], + assets: [], +}) +function fixture(options = {}) { + const calls = [], created = [], revoked = [] + const version = scene() + const api = { + catalog: async query => ({ records: [{ projectId: '160', versionId: '208', name: 'Fox scene', + content: { objectCount: 1 }, versionNumber: 2, publishedAt: version.publishedAt }], total: 1, pages: 1, size: 100 }), + version: async (projectId, versionId) => { calls.push(['version', projectId, versionId]); return structuredClone(version) }, + download: async (...args) => { calls.push(['download', ...args]); return new Blob(['GLB']) }, + objectUrls: { createObjectURL: blob => { created.push(blob); return `blob:scene-${created.length}` }, revokeObjectURL: url => revoked.push(url) }, + ...options, + } + const fallback = { put: async () => ({}), get: async () => null, + acquireObjectUrl: async value => value.startsWith('/models/') ? value : '', releaseObjectUrl() {} } + return { bridge: new TrainingSceneApiBridge(fallback, api), api, version, calls, created, revoked } +} +const project = () => ({ id: '161', name: 'Fox training', type: 'TRAINING', trainingMode: 'VIRTUAL', + status: 'DRAFT', content: { scenario: { sceneResource: { snapshot: { objects: [{ id: 'stale' }] } } } }, + dependencies: [{ relationType: 'TRAINING_SCENE', targetProjectId: '160', targetVersionId: '208', required: true, targetName: 'Fox scene' }] }) + +test('catalog reads every server page and selects the catalog fixed version rather than a current draft', async () => { + const queries = [] + const f = fixture({ catalog: async query => { + queries.push(query) + return { records: [{ projectId: String(159 + query.page), versionId: String(207 + query.page), + name: `Scene ${query.page}`, versionNumber: 2, content: {} }], total: 101, pages: 2, size: 100 } + } }) + const list = await f.bridge.listPublishedScenes() + assert.equal(list.length, 2) + assert.deepEqual(queries.map(q => [q.type, q.relationType, q.page]), [['SCENE', 'TRAINING_SCENE', 1], ['SCENE', 'TRAINING_SCENE', 2]]) + const loaded = await f.bridge.loadPublishedScene(list[0].id) + assert.equal(loaded.sourceVersionId, '208') + assert.deepEqual(f.calls[0], ['version', '160', '208']) +}) + +test('reopening refreshes snapshot from exact dependency, keeps project source immutable and survives editor normalization', async () => { + const f = fixture() + const source = project() + const prepared = await f.bridge.prepare(source) + const normalized = normalizeTrainingProject({ ...prepared.content, id: prepared.id, name: prepared.name }) + assert.equal(source.content.scenario.sceneResource.snapshot.objects[0].id, 'stale') + assert.equal(normalized.scenario.sceneResource.snapshot.objects[0].id, 'fox') + assert.equal(normalized.scenario.sceneResource.sourceProjectId, '160') + assert.equal(normalized.scenario.sceneResource.sourceVersionId, '208') + assert.deepEqual(f.bridge.dependencies(normalized, source.dependencies), [{ + targetProjectId: '160', targetVersionId: '208', relationType: 'TRAINING_SCENE', required: true, + }]) +}) + +test('changing scene replaces previous scene dependency while retaining explicit model dependencies', () => { + const f = fixture() + const next = { scenario: { sceneResource: { sourceProjectId: '159', sourceVersionId: '209' } } } + const previous = [...project().dependencies, { relationType: 'TRAINING_MODEL', targetProjectId: '8', targetVersionId: '9', required: false }] + assert.deepEqual(f.bridge.dependencies(next, previous), [ + { relationType: 'TRAINING_MODEL', targetProjectId: '8', targetVersionId: '9', required: false }, + { relationType: 'TRAINING_SCENE', targetProjectId: '159', targetVersionId: '209', required: true }, + ]) + assert.throws(() => f.bridge.dependencies({ scenario: { publishedSceneId: 'SCENE-demo' } }, previous), /重新选择/) +}) + +test('controlled model bytes use visible scene authorization, reuse one Blob and revoke only on disposal', async () => { + const f = fixture() + await f.bridge.prepare(project()) + const [a, b] = await Promise.all([f.bridge.assetStore.acquireObjectUrl(uri), f.bridge.assetStore.acquireObjectUrl(uri)]) + assert.equal(a, b) + assert.deepEqual(f.calls.filter(call => call[0] === 'download'), [['download', '160', '3001']]) + assert.deepEqual(await f.bridge.assetStore.validateReferences([{ resourceUrl: uri }]), { valid: true, missing: [] }) + f.bridge.assetStore.releaseObjectUrl(uri) + assert.equal(f.revoked.length, 0) + f.bridge.dispose() + await Promise.resolve() + assert.deepEqual(f.revoked, [a]) +}) + +test('unbound content references and mismatched model versions are rejected before download', async () => { + const f = fixture() + await assert.rejects(f.bridge.assetStore.acquireObjectUrl(uri), /不属于/) + f.version.dependencies[0].targetVersionId = '202' + await assert.rejects(f.bridge.prepare(project()), /版本不一致/) + assert.equal(f.calls.filter(call => call[0] === 'download').length, 0) +}) + +test('draft or foreign scene version cannot replace a published binding', async () => { + for (const patch of [{ status: 'DRAFT' }, { projectId: '999' }, { id: '999' }, { publishedAt: null }]) { + const f = fixture() + Object.assign(f.version, patch) + await assert.rejects(f.bridge.prepare(project()), /已经发布/) + } + const f = fixture() + f.version.status = 'ARCHIVED' + assert.equal((await f.bridge.prepare(project())).content.scenario.sceneResource.sourceVersionId, '208') +}) + +test('transient download failure retries and disposal during download creates no leaked Blob URL', async () => { + let attempts = 0 + const f = fixture({ download: async () => { if (!attempts++) throw new Error('unavailable'); return new Blob(['GLB']) } }) + await f.bridge.prepare(project()) + await assert.rejects(f.bridge.assetStore.acquireObjectUrl(uri), /unavailable/) + assert.equal(await f.bridge.assetStore.acquireObjectUrl(uri), 'blob:scene-1') + f.bridge.dispose() + let finish + const pending = fixture({ download: () => new Promise(resolve => { finish = resolve }) }) + await pending.bridge.prepare(project()) + const download = pending.bridge.assetStore.acquireObjectUrl(uri) + pending.bridge.dispose() + finish(new Blob(['GLB'])) + await assert.rejects(download, /已关闭/) + assert.equal(pending.created.length, 0) +}) + +test('failed WebGL restore rolls scene binding back and never reports success or schedules save', async () => { + const editor = new BindingEditor() + const messages = [] + let closes = 0, changes = 0, renders = 0 + const oldScenario = { publishedSceneId: 'old', environment: {}, sceneResource: { sourceProjectId: '159', sourceVersionId: '209' } } + editor.project = { scenario: structuredClone(oldScenario) } + editor.root = { querySelectorAll: () => [] } + editor.renderSaveState = () => {} + editor.loadServerScene = async () => ({ id: '160@208', name: 'Fox', sourceProjectId: '160', sourceVersionId: '208', objects: [] }) + editor.assetStore = { validateReferences: async () => ({ valid: true }) } + editor.clearEnvironmentPreview = () => {} + editor.renderScene = async () => ++renders === 1 ? { error: new Error('GLB parse failed') } : {} + editor.markDirty = () => { changes++ } + editor.closeDialog = () => { closes++ } + editor.toast = (message, type) => messages.push([message, type]) + await editor.bindPublishedScene('160@208') + assert.deepEqual(editor.project.scenario, oldScenario) + assert.deepEqual(messages, [['GLB parse failed', 'error']]) + assert.equal(changes, 0) + assert.equal(closes, 0) + assert.equal(editor.bindingScene, false) + assert.equal(renders, 2) +}) diff --git a/tools/training-scene-targets.test.mjs b/tools/training-scene-targets.test.mjs new file mode 100644 index 0000000..6f8ecf5 --- /dev/null +++ b/tools/training-scene-targets.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +const webSource = new URL('../../unreal_tran_web/src/features/', import.meta.url) +const fox = { + sceneId: 'fox-scene-object', name: 'Fox', semantic: '实训装备', + operable: true, interactionId: 'scene:fox', capabilities: ['select', 'inspect'], + modelPartTargets: [{ + sceneId: 'fox-scene-object', interactionId: 'scene:fox/part/mesh', + sourceInteractionId: 'model:fox-mesh', editorId: 'fox-mesh', name: 'Fox mesh', + operable: true, visible: true, capabilities: ['select', 'inspect'], + }], +} +const projectWith = objects => ({ + scenario: { + publishedSceneId: 'content-scene-160', + sceneResource: { sceneId: 'content-scene-160', snapshot: { objects } }, + }, +}) + +for (const runtime of ['guide-legacy/demo-src', 'virtual-training-legacy/upstream']) { + const { + TRAINING_PROJECT_TARGETS, createTrainingProject, getTrainingProjectTargets, + listTrainingSceneTargets, normalizeTrainingProject, validateTrainingProject, + } = await import(new URL(`${runtime}/training-project-io.js`, webSource)) + + test(`${runtime}: binding a published scene replaces all demo-machine targets`, () => { + assert.deepEqual(getTrainingProjectTargets(projectWith([fox])).map(item => item.id), ['scene:fox', 'scene:fox/part/mesh']) + assert.deepEqual(getTrainingProjectTargets(projectWith([])), []) + assert.equal(getTrainingProjectTargets({}).length, TRAINING_PROJECT_TARGETS.length) + const reservedId = TRAINING_PROJECT_TARGETS[0].id + assert.deepEqual(getTrainingProjectTargets(projectWith([{ ...fox, interactionId: reservedId, modelPartTargets: [] }])).map(item => item.id), [reservedId]) + }) + + test(`${runtime}: hidden and deleted roots exclude their parts and split children`, () => { + for (const disabled of [{ visible: false }, { deleted: true }, { softDeleted: true }]) { + const root = { ...fox, ...disabled, splitParts: [{ ...fox, sceneId: 'split', interactionId: 'scene:split' }] } + assert.deepEqual(listTrainingSceneTargets({ objects: [root] }), []) + const normalized = normalizeTrainingProject({ ...createTrainingProject(), ...projectWith([root]) }) + assert.deepEqual(getTrainingProjectTargets(normalized), []) + assert.equal(normalized.scenario.sceneResource.snapshot.objects[0].visible, false) + } + }) + + test(`${runtime}: part visibility and soft-delete overrides keep only the valid root`, () => { + for (const disabled of [{ visible: false }, { deleted: true }, { softDeleted: true }]) { + const root = { ...fox, modelPartTargets: [{ ...fox.modelPartTargets[0], ...disabled }] } + assert.deepEqual(listTrainingSceneTargets({ objects: [root] }).map(item => item.id), ['scene:fox']) + } + const root = { ...fox, partOverrides: { 'scene:fox/part/mesh': { softDeleted: true } } } + assert.deepEqual(listTrainingSceneTargets({ objects: [root] }).map(item => item.id), ['scene:fox']) + }) + + test(`${runtime}: formal semantic target ids follow backend operable/visible/deleted rules`, () => { + const formal = { + id: 'fox-formal', name: 'Fox', + semantic: { operable: true, interactionId: 'scene:formal-fox', deviceId: 'FOX-001', capabilities: ['inspect'] }, + } + const objects = [formal, + { ...formal, id: 'hidden', visible: false }, + { ...formal, id: 'deleted', deleted: true }, + { id: 'not-operable', semantic: { interactionId: 'scene:locked', operable: false } }, + { id: 'missing-id', semantic: { operable: true } }, + ] + const targets = listTrainingSceneTargets({ objects }) + assert.deepEqual(targets.map(item => item.id), ['scene:formal-fox']) + assert.deepEqual(targets[0].modes, ['inspect']) + assert.equal(targets[0].targetRef.deviceId, 'FOX-001') + const normalized = normalizeTrainingProject({ ...createTrainingProject(), ...projectWith(objects) }) + assert.deepEqual(getTrainingProjectTargets(normalized).map(item => item.id), ['scene:formal-fox']) + }) + + test(`${runtime}: old demo steps stay invalid after binding, then real scene selections validate`, () => { + const source = { ...createTrainingProject(), ...projectWith([fox]) } + const invalid = validateTrainingProject(source) + assert.ok(invalid.errors.some(item => item.path?.endsWith('.targetId'))) + source.steps = [{ ...source.steps[0], targetId: 'scene:fox', targetRef: { interactionId: 'scene:fox' }, mode: 'inspect' }] + const normalized = normalizeTrainingProject(source) + const result = validateTrainingProject(normalized) + assert.equal(result.errors.some(item => item.path?.endsWith('.targetId')), false) + assert.equal(normalized.steps[0].targetRef.sceneObjectId, 'fox-scene-object') + }) +} diff --git a/tools/training-scene-visibility.e2e.mjs b/tools/training-scene-visibility.e2e.mjs new file mode 100644 index 0000000..13a4f4d --- /dev/null +++ b/tools/training-scene-visibility.e2e.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { chromium, expect } from '@playwright/test'; +import { localCredentials } from './review-api.mjs'; + +// Read-only verification of the retained training acceptance fixtures. +// Run from ute2e: node tools/training-scene-visibility.e2e.mjs +// For PHYSICAL/CONFRONTATION fixtures, set UTE2E_TRAINING_MODES=1. +const baseURL = process.env.UTE2E_BASE_URL || 'http://127.0.0.1:6180'; +const testModes = process.env.UTE2E_TRAINING_MODES === '1'; +const reportDir = path.resolve(testModes ? 'reports/training-modes-20260905' : 'reports/training-live-20260905'); +fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true }); +const cases = testModes ? [ + { id: '163', key: 'physical', name: '牛奶卡车实装检查', mode: 'PHYSICAL', weights: [30, 30, 40], sceneProjectId: '159', sceneVersionId: '209', sceneObjectId: 'OBJ-1788605199171-2', skinned: false }, + { id: '164', key: 'confrontation', name: '狐狸红蓝协同巡检', mode: 'CONFRONTATION', weights: [50, 50], sceneProjectId: '160', sceneVersionId: '210', sceneObjectId: 'OBJ-1788605916565-6', skinned: true }, +] : [ + { id: '161', key: 'fox', name: '狐狸野外巡检', sceneProjectId: '160', sceneVersionId: '210', sceneObjectId: 'OBJ-1788605916565-6', skinned: true }, + { id: '162', key: 'truck', name: '牛奶卡车进场检查', sceneProjectId: '159', sceneVersionId: '209', sceneObjectId: 'OBJ-1788605199171-2', skinned: false }, +]; +const evidence = { title: '独立浏览器重开、真实模型可见性及封面验证', status: 'RUNNING', startedAt: new Date().toISOString(), checks: [], projects: [] }; +const browser = await chromium.launch({ channel: 'msedge', headless: true }); + +async function login(page) { + const credentials = localCredentials(); + await page.goto(`${baseURL}/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 }); break; } + catch { if (attempt === 2) throw new Error('Development sign-in did not complete'); } + } + await page.goto(`${baseURL}/profile`); + const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' }); + for (let attempt = 0; attempt < 3; attempt++) { + if ((await teacher.getAttribute('class')).includes('active')) break; + await teacher.click(); + try { await expect(teacher).toHaveClass(/active/, { timeout: 15_000 }); break; } + catch { if (attempt === 2) throw new Error('Development teacher role switch did not complete'); } + } +} + +async function visibility(page, sceneObjectId) { + return page.evaluate(sceneObjectId => { + const editor = globalThis.__training.sceneEditor; + const THREE = globalThis.__trainingThree; + const root = editor.publishedSceneObjects.find(object => object.userData.sceneId === sceneObjectId); + if (!root) throw new Error('Expected scene model was not restored'); + const bounds = new THREE.Box3().setFromObject(root); + let meshes = 0, visibleMeshes = 0, skinnedMeshes = 0; + const visible = object => { for (let p = object; p; p = p.parent) if (!p.visible) return false; return true; }; + root.traverse(object => { + if (!object.isMesh) return; + meshes++; + if (visible(object)) visibleMeshes++; + if (object.isSkinnedMesh) skinnedMeshes++; + }); + const sample = document.createElement('canvas'); + sample.width = 320; sample.height = 180; + const ctx = sample.getContext('2d', { willReadFrequently: true }); + const read = () => { + editor.renderer.render(editor.scene, editor.camera); + ctx.drawImage(editor.renderer.domElement, 0, 0, 320, 180); + return ctx.getImageData(0, 0, 320, 180).data; + }; + const shadows = []; + const originalVisibility = root.visible; + let withModel, withoutModel; + try { + root.traverse(object => { if (object.isMesh) { shadows.push([object, object.castShadow]); object.castShadow = false; } }); + editor.renderer.shadowMap.needsUpdate = true; + withModel = read(); + root.visible = false; + editor.renderer.shadowMap.needsUpdate = true; + withoutModel = read(); + } finally { + root.visible = originalVisibility; + shadows.forEach(([object, castShadow]) => { object.castShadow = castShadow; }); + editor.renderer.shadowMap.needsUpdate = true; + editor.renderer.render(editor.scene, editor.camera); + } + let changedPixels = 0; + for (let offset = 0; offset < withModel.length; offset += 4) { + const delta = Math.abs(withModel[offset] - withoutModel[offset]) + + Math.abs(withModel[offset + 1] - withoutModel[offset + 1]) + + Math.abs(withModel[offset + 2] - withoutModel[offset + 2]); + if (delta >= 12) changedPixels++; + } + return { + state: editor.container.dataset.state, meshes, visibleMeshes, skinnedMeshes, + position: root.position.toArray(), scale: root.scale.toArray(), + finiteBounds: !bounds.isEmpty() && [...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite), + size: bounds.getSize(new THREE.Vector3()).toArray(), + camera: { position: editor.camera.position.toArray(), target: editor.controls.target.toArray() }, + sample: { width: 320, height: 180, changedPixels }, + scene: { projectId: globalThis.__training.project.scenario.sceneResource.sourceProjectId, versionId: globalThis.__training.project.scenario.sceneResource.sourceVersionId }, + targets: globalThis.__training.availableTargets.map(target => ({ id: target.id, name: target.label, source: target.source })), + steps: globalThis.__training.project.steps.map(step => ({ title: step.title, targetId: step.targetId, weight: step.assessment.weight })), + }; + }, sceneObjectId); +} + +try { + for (const testCase of cases) { + const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN' }); + let token = ''; + const errors = [], blockedWrites = []; + const check = { title: `${testCase.name}:发布重开、模型实际可见、截图封面`, status: 'RUNNING', screenshots: [] }; + evidence.checks.push(check); + try { + 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/'))) { + blockedWrites.push({ path: pathname, method: request.method() }); + return route.abort('blockedbyclient'); + } + return route.continue(); + }); + const page = await context.newPage(); + page.setDefaultTimeout(30_000); + page.on('pageerror', error => errors.push(error.message)); + page.on('response', async response => { + if (new URL(response.url()).pathname.startsWith('/api/auth/v1/auth/')) { + const data = await response.json().catch(() => null); + if (data?.data?.accessToken) token = data.data.accessToken; + } + }); + await page.route('**/src/features/guide-legacy/demo-src/training-editor.js*', async route => { + const response = await route.fetch(); + const text = await response.text(); + const marker = 'const editor = new TrainingProjectEditor(root, options);'; + assert.ok(text.includes(marker)); + return route.fulfill({ response, body: text.replace(marker, marker + ' globalThis.__training = editor;') }); + }); + await page.route('**/src/features/guide-legacy/demo-src/training-scene-editor.js*', async route => { + const response = await route.fetch(); + const text = await response.text(), marker = 'export function createTrainingSceneEditor'; + assert.ok(text.includes(marker)); + return route.fulfill({ response, body: text.replace(marker, 'globalThis.__trainingThree = THREE;\n' + marker) }); + }); + await login(page); + await page.goto(`${baseURL}/content/training-projects/${testCase.id}/legacy`); + await expect(page.locator('.te-shell')).toBeVisible({ timeout: 90_000 }); + await page.evaluate(() => globalThis.__training.sceneEditor.publishedSceneLoadPromise); + await page.waitForFunction(() => globalThis.__training.sceneEditor.container.dataset.state === 'ready', null, { timeout: 60_000 }); + const measured = await visibility(page, testCase.sceneObjectId); + assert.equal(measured.scene.projectId, testCase.sceneProjectId); + assert.equal(measured.scene.versionId, testCase.sceneVersionId); + assert.ok(measured.finiteBounds && measured.visibleMeshes > 0); + assert.ok(measured.sample.changedPixels > 500, `Only ${measured.sample.changedPixels} model pixels visible`); + if (testCase.skinned) assert.ok(measured.skinnedMeshes > 0); + assert.ok(measured.targets.length > 0 && measured.targets.every(target => target.source === 'scene')); + assert.equal(measured.steps.length, (testCase.weights ?? [50, 50]).length); + assert.deepEqual(measured.steps.map(step => step.weight), testCase.weights ?? [50, 50]); + assert.ok(measured.steps.every(step => measured.targets.some(target => target.id === step.targetId))); + const screenshot = `screenshots/${testCase.key}-independent-reopen.png`; + await page.screenshot({ path: path.join(reportDir, screenshot), fullPage: true }); + check.screenshots.push(screenshot); + const detailResponse = await fetch(`${baseURL}/api/tran/v1/content/projects/${testCase.id}`, { headers: { Authorization: `Bearer ${token}` } }); + assert.equal(detailResponse.status, 200); + const detail = (await detailResponse.json()).data; + assert.equal(detail.project.status, 'PUBLISHED'); + if (testCase.mode) { + const content = detail.currentVersion.content; + assert.equal(detail.project.trainingMode, testCase.mode); + assert.deepEqual(content.enabledChannels, [testCase.mode]); + assert.deepEqual(content.modeConfig, content.channelProfiles[testCase.mode]); + if (testCase.mode === 'PHYSICAL') { + assert.equal(content.modeConfig.stationName, '牛奶卡车测试工位 A'); + assert.equal(content.modeConfig.equipmentCode, 'E2E-TRUCK-163'); + assert.deepEqual(content.steps.map(step => step.physicalEventRule.progressPercent), [33, 67, 100]); + assert.ok(content.steps.every(step => step.physicalEventRule.source === 'MANUAL' && step.physicalEventRule.manualConfirmAllowed)); + } else { + assert.equal(content.modeConfig.teamSize, 2); + assert.equal(content.modeConfig.roundDurationMinutes, 7); + assert.deepEqual(content.modeConfig.submitPositions, ['COMMANDER']); + assert.ok(content.steps.every(step => JSON.stringify(step.allowedPositions) === '["COMMANDER"]')); + } + await page.locator('[data-action=configure-training-mode]').click(); + const field = testCase.mode === 'PHYSICAL' ? 'stationName' : 'roundDuration'; + await expect(page.locator(`[data-field="modeConfig.${field}"]`)).toHaveValue(testCase.mode === 'PHYSICAL' ? '牛奶卡车测试工位 A' : '7'); + const settingsShot = `screenshots/${testCase.key}-independent-settings.png`; + await page.screenshot({ path: path.join(reportDir, settingsShot), fullPage: true }); + check.screenshots.push(settingsShot); + await page.getByRole('button', { name: '完成配置', exact: true }).click(); + } + const dependency = detail.currentVersion.dependencies.find(item => item.relationType === 'TRAINING_SCENE' && item.required); + assert.equal(dependency.targetProjectId, testCase.sceneProjectId); + assert.equal(dependency.targetVersionId, testCase.sceneVersionId); + const coverAsset = detail.currentVersion.assets.find(asset => asset.code === 'TRAINING_COVER'); + assert.ok(coverAsset && coverAsset.storageUri === detail.project.coverUri); + const coverResponse = await fetch(`${baseURL}/api/tran/v1/content/projects/${testCase.id}/cover`, { headers: { Authorization: `Bearer ${token}` } }); + assert.equal(coverResponse.status, 200); + assert.ok(coverResponse.headers.get('content-type').startsWith('image/jpeg')); + const bytes = Buffer.from(await coverResponse.arrayBuffer()); + const sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); + assert.equal(sha256.toLowerCase(), coverAsset.sha256.toLowerCase()); + const coverFile = `screenshots/${testCase.key}-final-cover.jpg`; + fs.writeFileSync(path.join(reportDir, coverFile), bytes); + const dimensions = await page.evaluate(async base64 => { + const img = new Image(); img.src = `data:image/jpeg;base64,${base64}`; await img.decode(); + return { width: img.naturalWidth, height: img.naturalHeight }; + }, bytes.toString('base64')); + assert.ok(Math.max(dimensions.width, dimensions.height) <= 640 && Math.min(dimensions.width, dimensions.height) > 100); + check.screenshots.push(coverFile); + assert.deepEqual(errors, []); + assert.deepEqual(blockedWrites, []); + check.status = 'PASS'; + check.details = { ...measured, cover: { assetId: coverAsset.id, bytes: bytes.length, sha256, ...dimensions }, pageErrors: errors, blockedWrites }; + evidence.projects.push({ id: testCase.id, name: detail.project.name, mode: detail.project.trainingMode, status: detail.project.status, currentVersionId: detail.project.currentVersionId, publishedVersionId: detail.project.publishedVersionId, sceneProjectId: testCase.sceneProjectId, sceneVersionId: testCase.sceneVersionId, stepCount: measured.steps.length, totalScore: detail.currentVersion.content.totalScore, cover: { assetId: coverAsset.id, image: coverFile } }); + } catch (error) { + check.status = 'FAIL'; check.details = { error: error.message, pageErrors: errors, blockedWrites }; + } finally { await context.close(); } + } + evidence.status = evidence.checks.every(check => check.status === 'PASS') ? 'PASS' : 'FAIL'; +} finally { + evidence.finishedAt = new Date().toISOString(); + fs.writeFileSync(path.join(reportDir, 'readonly-evidence.json'), JSON.stringify(evidence, null, 2)); + await browser.close(); +} +console.log(JSON.stringify({ status: evidence.status, checks: evidence.checks.map(({ title, status, details }) => ({ title, status, changedPixels: details?.sample?.changedPixels, error: details?.error })) }, null, 2)); +if (evidence.status !== 'PASS') process.exitCode = 1; diff --git a/tools/training-step-compatibility.test.mjs b/tools/training-step-compatibility.test.mjs new file mode 100644 index 0000000..95811a5 --- /dev/null +++ b/tools/training-step-compatibility.test.mjs @@ -0,0 +1,186 @@ +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 features = new URL('../../unreal_tran_web/src/features/', import.meta.url) +const compileText = source => ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, +}).outputText +const compile = path => compileText(readFileSync(new URL(path, features), 'utf8')) +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 } = await import(documentUrl) +const project = content => ({ id: 'compat-test', name: 'Compatibility', type: 'TRAINING', trainingMode: 'VIRTUAL', status: 'DRAFT', content }) +const save = (legacy, previous) => toFormalContent(legacy, 'VIRTUAL', previous).content +const sceneObjects = ['A', 'B'].map(id => ({ + id, sceneId: id, name: `Target ${id}`, visible: true, + semantic: { operable: true, interactionId: `scene:${id}`, capabilities: ['inspect'], deviceId: `DEV-${id}` }, +})) + +for (const runtime of ['guide-legacy/demo-src', 'virtual-training-legacy/upstream']) { + const { createTrainingProject, normalizeTrainingProject } = await import(new URL(`${runtime}/training-project-io.js`, features)) + const fixture = () => { + const value = createTrainingProject({ trainingMode: 'virtual' }) + value.steps = value.steps.slice(0, 1) + return value + } + const reopen = content => normalizeTrainingProject(toLegacyProject(project(content))) + + test(`${runtime}: legacy operation text is visible to the formal editor and survives reopen`, () => { + const value = fixture() + value.steps[0].instruction = '确认狐狸目标位置并记录观察结果' + value.steps[0].guidance = '使用当前镜头观察目标' + const content = save(value) + assert.equal(content.steps[0].summary, value.steps[0].instruction) + assert.equal(content.steps[0].guidance, value.steps[0].guidance) + assert.equal(reopen(content).steps[0].instruction, value.steps[0].instruction) + }) + + test(`${runtime}: distinct existing summary and instruction survive an unchanged roundtrip`, () => { + const content = save(fixture()) + Object.assign(content.steps[0], { summary: '正式已有说明', instruction: '已有详细说明', guidance: '已有引导' }) + const original = structuredClone(content) + const next = save(reopen(content), content) + for (const key of ['summary', 'instruction', 'guidance']) assert.equal(next.steps[0][key], content.steps[0][key]) + assert.deepEqual(content, original) + }) + + test(`${runtime}: summary-only historical documents open with their real operation text`, () => { + const content = save(fixture()) + content.steps[0].summary = '正式说明用于旧编辑器显示' + for (const instruction of [undefined, '']) { + if (instruction === undefined) delete content.steps[0].instruction + else content.steps[0].instruction = instruction + const reopened = reopen(content) + assert.equal(reopened.steps[0].instruction, content.steps[0].summary) + assert.equal(save(reopened, content).steps[0].summary, content.steps[0].summary) + } + }) + + test(`${runtime}: clearing operation and optional guidance does not restore demonstration text`, () => { + const content = save(fixture()) + const fields = ['summary', 'instruction', 'guidance', 'warning', 'destination', 'success'] + for (const key of fields) content.steps[0][key] = '' + const reopened = reopen(content) + const next = save(reopened, content) + for (const key of fields) { + assert.equal(reopened.steps[0][key], '', key) + assert.equal(next.steps[0][key], '', key) + } + delete content.steps[0].instruction + assert.equal(reopen(content).steps[0].instruction, '') + }) + + test(`${runtime}: formally selected target wins over stale legacy references`, () => { + const source = fixture() + source.scenario.publishedSceneId = 'scene-test' + source.scenario.sceneResource = { sceneId: 'scene-test', snapshot: { objects: sceneObjects } } + Object.assign(source.steps[0], { targetId: 'scene:A', targetRef: { interactionId: 'scene:A' }, mode: 'inspect', targetNodeId: 'scene:A' }) + const content = save(normalizeTrainingProject(source)) + Object.assign(content.steps[0], { targetId: 'scene:B', targetName: 'Target B' }) + content.steps[0].action.targetId = 'scene:B' + const next = save(reopen(content), content) + assert.equal(next.steps[0].targetId, 'scene:B') + assert.equal(next.steps[0].action.targetId, 'scene:B') + assert.equal(next.steps[0].targetRef.interactionId, 'scene:B') + assert.equal(next.steps[0].targetRef.sceneObjectId, 'B') + assert.equal(next.steps[0].targetRef.deviceId, 'DEV-B') + assert.equal(next.steps[0].targetNodeId, 'scene:B') + assert.equal(next.steps[0].targetNodeName, 'Target B') + assert.equal(content.steps[0].targetRef.interactionId, 'scene:A') + }) + + test(`${runtime}: an explicitly cleared or missing target is not replaced by its stale reference`, () => { + const source = fixture() + source.scenario.publishedSceneId = 'scene-test' + source.scenario.sceneResource = { sceneId: 'scene-test', snapshot: { objects: sceneObjects } } + for (const id of ['', 'scene:missing']) { + Object.assign(source.steps[0], { targetId: id, targetRef: { interactionId: 'scene:A' }, mode: 'inspect' }) + const normalized = normalizeTrainingProject(source) + assert.equal(normalized.steps[0].targetId, id) + assert.equal(normalized.steps[0].targetRef, null) + assert.equal(normalized.steps[0].action.targetId, id) + } + }) +} + +test('formal normalizer migrates old operation text while preserving explicit clears and distinct text', () => { + const content = { steps: [ + { id: 'instruction-only', instruction: '旧说明' }, + { id: 'empty-summary', summary: '', instruction: '旧说明二' }, + { id: 'both-empty', summary: '', instruction: '' }, + { id: 'distinct', summary: '简述', instruction: '详情' }, + ] } + const next = normalizeTrainingContent(content, 'VIRTUAL') + assert.deepEqual(next.steps.map(step => [step.summary, step.instruction]), [ + ['旧说明', '旧说明'], ['旧说明二', '旧说明二'], ['', ''], ['简述', '详情'], + ]) +}) + +test('old local buffers retain saved summary unless the operation instruction was edited', () => { + const content = normalizeTrainingContent({ steps: [{ id: 'step-a', summary: '正式说明', instruction: '详细说明' }] }, 'VIRTUAL') + const unchanged = { steps: [{ id: 'step-a', instruction: '详细说明' }] } + assert.equal(save(unchanged, content).steps[0].summary, '正式说明') + const changed = { steps: [{ id: 'step-a', instruction: '实际编辑新说明' }] } + assert.equal(save(changed, content).steps[0].summary, '实际编辑新说明') +}) + +test('actual legacy input handler synchronizes operation text only when that field is edited', () => { + const source = readFileSync(new URL('guide-legacy/demo-src/training-editor.js', features), 'utf8') + const start = source.indexOf(' updateField(input, { render = false } = {}) {') + const end = source.indexOf('\n onDragStart(', start) + assert.ok(start >= 0 && end > start) + const editor = new Function('setPath', `return ({${source.slice(start, end)}})`)((object, key, value) => { object[key] = value }) + editor.selectedStep = { id: 'test', summary: '原正式说明', instruction: '原详细说明' } + editor.project = { id: 'test' } + editor.markDirty = () => {} + editor.updateField({ dataset: { scope: 'step', field: 'guidance' }, type: 'text', value: '新引导' }) + assert.equal(editor.selectedStep.summary, '原正式说明') + for (const value of ['新的操作说明', '']) { + editor.updateField({ dataset: { scope: 'step', field: 'instruction' }, type: 'text', value }) + assert.equal(editor.selectedStep.summary, value) + assert.equal(editor.selectedStep.instruction, value) + } +}) + +const studio = readFileSync(new URL('../views/content/TrainingStudioView.vue', features), 'utf8') +test('actual studio description handler synchronizes both document fields including clear', () => { + const start = studio.indexOf('const setStepSummary =') + const end = studio.indexOf('const selectTarget =', start) + assert.ok(start >= 0 && end > start) + const selected = { value: { summary: 'old', instruction: 'different' } } + const handler = new Function('selectedStep', 'markDirty', `${compileText(studio.slice(start, end))}; return setStepSummary`)(selected, () => {}) + for (const value of ['正式新操作说明', '']) { + handler(value) + assert.equal(selected.value.summary, value) + assert.equal(selected.value.instruction, value) + } + assert.match(studio, /@update:model-value="setStepSummary"/) +}) + +test('actual studio target selection synchronizes semantic, node and action references', () => { + const start = studio.indexOf('const selectTarget =') + const end = studio.indexOf('const selectMode =', start) + assert.ok(start >= 0 && end > start) + const selected = { value: { targetId: 'scene:A', targetRef: { interactionId: 'scene:A' }, mode: 'inspect', action: { targetId: 'scene:A' }, + toolPlacement: { attachedTargetId: 'scene:A', pose: 'target', position: [1, 2, 3] } } } + const catalog = { value: new Map([['scene:B', { id: 'scene:B', label: 'Target B', modes: ['inspect'] }]]) } + const handler = new Function('selectedStep', 'targetById', 'sceneContent', 'markDirty', 'selectMode', `${compileText(studio.slice(start, end))}; return selectTarget`)( + selected, catalog, { value: { objects: sceneObjects } }, () => {}, () => { throw new Error('Unexpected mode change') }, + ) + handler('scene:B') + assert.equal(selected.value.targetId, 'scene:B') + assert.equal(selected.value.action.targetId, 'scene:B') + assert.equal(selected.value.targetRef.interactionId, 'scene:B') + assert.equal(selected.value.targetRef.sceneObjectId, 'B') + assert.equal(selected.value.targetRef.deviceId, 'DEV-B') + assert.equal(selected.value.targetNodeId, 'scene:B') + assert.equal(selected.value.targetNodeName, 'Target B') + assert.equal(selected.value.toolPlacement.attachedTargetId, '') + assert.deepEqual(selected.value.toolPlacement.position, [1, 2, 3]) +})