Record live virtual, physical and confrontation checks with screenshots; cover scene loading, cover persistence, metadata compatibility, flow colors and return-to-list tab behavior. Preserve known runtime integration limitations in the reports.main
| @@ -3,6 +3,7 @@ node_modules/ | |||||
| .env.* | .env.* | ||||
| !.env.example | !.env.example | ||||
| reports/**/build.log | reports/**/build.log | ||||
| reports/**/.last-run.json | |||||
| artifacts/html-report/ | artifacts/html-report/ | ||||
| artifacts/test-results/ | artifacts/test-results/ | ||||
| artifacts/trace-inspect-*/ | artifacts/trace-inspect-*/ | ||||
| @@ -0,0 +1 @@ | |||||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>高级编排配色修复</title><style>body{margin:0;background:#f2f6f7;color:#17344f;font:16px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1200px;margin:auto;padding:28px}section{background:white;border:1px solid #d4e1e7;border-radius:12px;padding:24px;margin:18px 0}h1{margin:0}h2{font-size:20px}img{width:100%;display:block;border:1px solid #d4e1e7}a{color:#126b5b}li{margin:6px 0}code{overflow-wrap:anywhere}summary{cursor:pointer}p{overflow-wrap:anywhere}@media(max-width:600px){main{padding:12px}section{padding:16px}}</style><main><h1>高级编排配色修复</h1><p>2026-09-06 · 浏览器复核 9 项通过 · 构建通过</p><section><h2>修复内容</h2><p>原有 tone-* 强制背景覆盖节点白底,造成蓝绿底上的灰字难以辨认。现恢复浅底深字,类型色用于图标与边框;同时提高节点说明、节点库和时间轴文字对比。</p><p>当前检查的文字样式最低对比度 4.92:1。修改仅涉及 training-flow-authoring.css。</p><p>仅高级编排CSS。浏览器禁止内容业务写请求,节点添加/排布仅为本地样式检查。编辑器沿用固定浅色画布,分别切换四种宿主主题属性和系统暗色偏好验证。</p></section><section><h2>修复后:实际狐狸项目</h2><a href="screenshots/lan-after.png"><img src="screenshots/lan-after.png" alt="修复后的高级编排"></a><p>局域网实际页面;截图时在本地使用自动排布,不写入训练数据。</p></section><section><h2>修复前</h2><a href="screenshots/before.png"><img src="screenshots/before.png" alt="原有彩底灰字"></a></section><section><h2>全部类型及宿主主题检查</h2><details open><summary>military</summary><a href="screenshots/theme-military.png"><img loading="lazy" src="screenshots/theme-military.png" alt="military主题下全部节点类型"></a></details><details><summary>technology</summary><a href="screenshots/theme-technology.png"><img loading="lazy" src="screenshots/theme-technology.png" alt="technology主题下全部节点类型"></a></details><details><summary>graphite</summary><a href="screenshots/theme-graphite.png"><img loading="lazy" src="screenshots/theme-graphite.png" alt="graphite主题下全部节点类型"></a></details><details><summary>dark</summary><a href="screenshots/theme-dark.png"><img loading="lazy" src="screenshots/theme-dark.png" alt="dark主题下全部节点类型"></a></details></section><section><h2>验证记录</h2><ul><li>当前图节点、说明、图标及时间轴前景对比:通过</li><li>全部六类节点色调(本地增加节点,未保存):通过</li><li>宿主配色 military 下高级编排可读性:通过</li><li>宿主配色 technology 下高级编排可读性:通过</li><li>宿主配色 graphite 下高级编排可读性:通过</li><li>宿主配色 dark 下高级编排可读性:通过</li><li>系统暗色偏好下高级编排可读性:通过</li><li>选中、自动排布、时间轴播放/停止:通过</li><li>局域网6180真实页面及6100代理:通过</li><li>pnpm run build:通过(类型检查、Vite 构建与 iframe 分发校验)</li><li>控制台未发生页面异常</li></ul><p><a href="evidence.json">查看检查数据</a></p></section></main></html> | |||||
| @@ -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" | |||||
| ] | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -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": [] | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| @@ -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训练编排与发布实测 · 通过 | |||||
| - 使用真实已发布场景创建、编排并保留两条训练,列表显示截图封面:通过 | |||||
| - 狐狸两步编排、复制删除、排序撤销、高级时间轴、项目名称保存及发布:通过 | |||||
| - 狐狸三维点击选目标、调整视角并截图发布、异常恢复:通过 | |||||
| - 卡车三维拾取、项目名称及任务说明保存、隐藏外壳视角截图发布:通过 | |||||
| <details><summary>final-evidence.json · 脱敏完整记录</summary> | |||||
| <pre>{ | |||||
| "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": [] | |||||
| }</pre> | |||||
| </details> | |||||
| ### 独立浏览器重开、真实模型可见性及封面验证 · 通过 | |||||
| - 狐狸野外巡检:发布重开、模型实际可见、截图封面:通过 | |||||
| - 牛奶卡车进场检查:发布重开、模型实际可见、截图封面:通过 | |||||
| <details><summary>readonly-evidence.json · 脱敏完整记录</summary> | |||||
| <pre>{ | |||||
| "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" | |||||
| }</pre> | |||||
| </details> | |||||
| ### 真实浏览器异常分支验证(定向注入HTTP失败) · 通过 | |||||
| - 封面上传503:训练内容仍保存并保留原封面:通过 | |||||
| - 保存409:不覆盖服务端内容和封面,并保持未保存提示:通过 | |||||
| - 切换场景下载失败:保持原场景、绑定期间禁止保存发布、零项目写入:通过 | |||||
| <details><summary>error-evidence.json · 脱敏完整记录</summary> | |||||
| <pre>{ | |||||
| "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" | |||||
| ] | |||||
| } | |||||
| ] | |||||
| }</pre> | |||||
| </details> | |||||
| ### 执行命令与验证结果 | |||||
| - 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为准。 | |||||
| ## 最终及异常验证截图 | |||||
| [](screenshots/fox-final-cover.jpg) | |||||
| [](screenshots/truck-final-cover.jpg) | |||||
| [](screenshots/20-training-list-final.png) | |||||
| [](screenshots/09-fox-advanced-timeline.png) | |||||
| [](screenshots/10-fox-preflight.png) | |||||
| [](screenshots/16-fox-mesh-picking.png) | |||||
| [](screenshots/17-fox-final-published.png) | |||||
| [](screenshots/18-truck-mesh-picking.png) | |||||
| [](screenshots/19-truck-final-published.png) | |||||
| [](screenshots/fox-independent-reopen.png) | |||||
| [](screenshots/truck-independent-reopen.png) | |||||
| [](screenshots/12-cover-failure-preserves-old.png) | |||||
| [](screenshots/13-save-conflict.png) | |||||
| [](screenshots/14-binding-disables-save.png) | |||||
| [](screenshots/15-binding-failure-retains-scene.png) | |||||
| ## 过程截图 · 不计入最终通过结论 | |||||
| [](screenshots/01-before-scene-catalog.png) | |||||
| [](screenshots/02-published-scenes.png) | |||||
| [](screenshots/03-truck-bound.png) | |||||
| [](screenshots/04-truck-saved.png) | |||||
| ## 范围、限制与功能确认结论 | |||||
| - 本次范围为训练编排、草稿保存、发布与重新打开,不包含创建教学任务、下发学员或完成学员训练。 | |||||
| - “预览训练”仍提示需发布后在教学实施中下发;本报告不将该提示视作学员训练已运行。 | |||||
| - 本轮仅修复已实现功能的问题,无新增需要用户确认的功能改动。 | |||||
| ### 实测限制与后续项 | |||||
| - 本次未创建教学下发任务,也未验证学员端完成训练;现有“预览训练”入口仍提示先发布再从虚实训练下发任务。 | |||||
| - 未配置讲解音频和必选安全前置条件,编辑器校验给出非阻断提醒;两条为轻量素材功能测试,不作为正式维修教学内容。 | |||||
| - 狐狸源场景实际已更新至V3(版本210),使用目录当时的真实发布版;未修改两个源场景或源模型。 | |||||
| - 训练封面截取保存时的当前视角;卡车封面通过现有“隐藏外壳”按钮隐藏围护结构,立柱仍遮挡部分车身。这是继承的源场景及视角,不是模型未加载;重新打开时仍按固定场景还原相机。 | |||||
| - 本轮没有新增需确认的功能变更,截图封面按本次明确授权实现;未改变数据库结构,无DDL变更。 | |||||
| 报告生成:2026/9/5 23:26:58(Asia/Shanghai)。 | |||||
| @@ -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" | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| ] | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| @@ -0,0 +1,463 @@ | |||||
| <!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>训练编排返回列表与页面标签聚焦回归</title><style>*{box-sizing:border-box}body{margin:0;background:#f2f6f8;color:#203b46;font:15px/1.75 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:auto;padding:28px 22px}header{background:#103c45;color:white;padding:26px 30px;border-radius:12px}h1{font-size:26px;margin:0 0 12px}h2{font-size:21px}h3{font-size:17px;overflow-wrap:anywhere}section,article{background:white;border:1px solid #dce6ec;border-radius:10px;padding:22px 26px;margin:20px 0}.pass{color:#14744f}.fail{color:#b5373e}header .pass{color:#a9f0d2}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;padding:15px;max-height:440px;overflow:auto;font-size:12px}summary{color:#137d6e;cursor:pointer}figure{margin:18px 0 0}img{display:block;width:100%;height:auto;border:1px solid #dce6ec}figcaption,footer{font-size:13px;color:#637f8c;overflow-wrap:anywhere}li,p{overflow-wrap:anywhere}@media(max-width:700px){main{padding:14px 10px}header,section,article{padding:18px}h1{font-size:23px}}</style></head><body><main><header><h1>训练编排返回列表与页面标签聚焦回归 <span class="pass">PASS</span></h1><p>真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网</p><p>通过 8 / 8 项 · 业务真实写入 0 次</p></header><section><h2>资料 → 原型 → 设计 → 开发</h2><p>依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。</p><p>本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。</p><ul><li>仅验证顶部返回列表及现有离开确认。打开已有训练 161/162,业务写请求全部由浏览器拦截。</li><li>“保存并离开”的失败由浏览器模拟 HTTP 503;不代表真实服务端故障。没有真实保存、发布、上传或修改这两条项目。</li><li>本轮未测试成功保存或成功发布;其业务路径未被本次代码修改。</li></ul></section><section><h2>测试与构建</h2><p><b>pnpm run build:PASS</b> · 根代理执行,含 vue-tsc、Vite 与 iframe 校验;既有构建警告不影响退出码 0。</p><p><b>git diff --check:PASS</b> · 根代理执行,退出码 0。</p><p>JSON 证据:<a href="evidence.json">evidence.json</a>。所有截图来自实际浏览器,测试凭据没有写入报告。</p></section><article><h3>手动切换标签保留原编辑页 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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": "真实页面标签点击" | |||||
| }</pre></details><figure><a href="screenshots/01-manual-switch-keeps-editors.png" target="_blank"><img src="screenshots/01-manual-switch-keeps-editors.png" alt="手动切换标签保留原编辑页" loading="lazy"></a><figcaption>手动切换标签保留原编辑页 · screenshots/01-manual-switch-keeps-editors.png</figcaption></figure></article><article><h3>顶部返回只关闭当前编辑标签,保留列表及另一编辑标签 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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 | |||||
| } | |||||
| ] | |||||
| }</pre></details><figure><a href="screenshots/02-return-closes-current.png" target="_blank"><img src="screenshots/02-return-closes-current.png" alt="顶部返回只关闭当前编辑标签,保留列表及另一编辑标签" loading="lazy"></a><figcaption>顶部返回只关闭当前编辑标签,保留列表及另一编辑标签 · screenshots/02-return-closes-current.png</figcaption></figure></article><article><h3>刷新后已关闭标签不复活 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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 | |||||
| } | |||||
| ] | |||||
| }</pre></details><figure><a href="screenshots/03-refresh-no-resurrection.png" target="_blank"><img src="screenshots/03-refresh-no-resurrection.png" alt="刷新后已关闭标签不复活" loading="lazy"></a><figcaption>刷新后已关闭标签不复活 · screenshots/03-refresh-no-resurrection.png</figcaption></figure></article><article><h3>未保存时 Escape 取消返回,原页与修改保留 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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 | |||||
| } | |||||
| ] | |||||
| }</pre></details><figure><a href="screenshots/04-unsaved-return-confirmation.png" target="_blank"><img src="screenshots/04-unsaved-return-confirmation.png" alt="未保存时 Escape 取消返回,原页与修改保留" loading="lazy"></a><figcaption>未保存时 Escape 取消返回,原页与修改保留 · screenshots/04-unsaved-return-confirmation.png</figcaption></figure><figure><a href="screenshots/05-escape-keeps-editor.png" target="_blank"><img src="screenshots/05-escape-keeps-editor.png" alt="未保存时 Escape 取消返回,原页与修改保留" loading="lazy"></a><figcaption>未保存时 Escape 取消返回,原页与修改保留 · screenshots/05-escape-keeps-editor.png</figcaption></figure></article><article><h3>保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503) <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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 | |||||
| }</pre></details><figure><a href="screenshots/06-save-failure-keeps-editor.png" target="_blank"><img src="screenshots/06-save-failure-keeps-editor.png" alt="保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)" loading="lazy"></a><figcaption>保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503) · screenshots/06-save-failure-keeps-editor.png</figcaption></figure></article><article><h3>明确放弃修改后返回并关闭当前编辑标签 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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 | |||||
| } | |||||
| ] | |||||
| }</pre></details><figure><a href="screenshots/07-discard-closes-current.png" target="_blank"><img src="screenshots/07-discard-closes-current.png" alt="明确放弃修改后返回并关闭当前编辑标签" loading="lazy"></a><figcaption>明确放弃修改后返回并关闭当前编辑标签 · screenshots/07-discard-closes-current.png</figcaption></figure></article><article><h3>服务端两项目版本、封面与内容校验值未变 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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 | |||||
| }</pre></details></article><article><h3>局域网地址返回列表也关闭当前编辑标签 <span class="pass">PASS</span></h3><details><summary>查看真实观察记录</summary><pre>{ | |||||
| "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 | |||||
| } | |||||
| ] | |||||
| } | |||||
| }</pre></details><figure><a href="screenshots/08-lan-return-closes-current.png" target="_blank"><img src="screenshots/08-lan-return-closes-current.png" alt="局域网地址返回列表也关闭当前编辑标签" loading="lazy"></a><figcaption>局域网地址返回列表也关闭当前编辑标签 · screenshots/08-lan-return-closes-current.png</figcaption></figure></article><footer>开始 2026-09-05T16:40:46.677Z · 完成 2026-09-05T16:41:29.185Z · 新增业务数据 0;浏览器内临时修改已放弃。</footer></main></body></html> | |||||
| @@ -104,13 +104,14 @@ class TrainingMock { | |||||
| } | } | ||||
| async install(page: Page) { | 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(() => { | await page.addInitScript(() => { | ||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token') | sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token') | ||||
| }) | }) | ||||
| await page.route('**/api/auth/v1/auth/me', (route) => ok(route, { | 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'], | permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'], | ||||
| authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200, | authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200, | ||||
| })) | })) | ||||
| @@ -301,6 +302,9 @@ test.describe('训练编排工作台', () => { | |||||
| const mock = new TrainingMock() | const mock = new TrainingMock() | ||||
| mock.mode = 'CONFRONTATION' | mock.mode = 'CONFRONTATION' | ||||
| mock.content = legacyTrainingContent('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 mock.install(page) | ||||
| await page.goto('/content/training-projects/901/studio?view=overview') | await page.goto('/content/training-projects/901/studio?view=overview') | ||||
| @@ -311,7 +315,7 @@ test.describe('训练编排工作台', () => { | |||||
| await page.locator('.step-row').first().click() | await page.locator('.step-row').first().click() | ||||
| await expect(page.locator('.inspector-panel')).toContainText('执行岗位') | await expect(page.locator('.inspector-panel')).toContainText('执行岗位') | ||||
| // 历史工程的对抗步骤没有岗位,发布校验必须逐步骤阻断 | |||||
| // 明确清空对抗步骤岗位后,发布校验必须逐步骤阻断,不能恢复默认权限。 | |||||
| await page.goto('/content/training-projects/901/studio?view=preflight') | await page.goto('/content/training-projects/901/studio?view=preflight') | ||||
| await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位') | await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位') | ||||
| await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true }) | await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true }) | ||||
| @@ -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 => `<figure><a href="${html(imageUrl(ref.path))}" target="_blank" rel="noopener"><img src="${html(imageUrl(ref.path))}" alt="${html(ref.caption || ref.path)}" loading="lazy"></a><figcaption>${html(ref.caption || ref.path)} · 点击查看原图</figcaption></figure>` | |||||
| const mdFigure = ref => `[})](${imageUrl(ref.path)})` | |||||
| const details = (value, title = '查看脱敏后的完整记录') => `<details><summary>${html(title)}</summary><pre>${html(pretty(value))}</pre></details>` | |||||
| const bullets = items => `<ul>${items.map(item => `<li>${html(item)}</li>`).join('')}</ul>` | |||||
| const mdBullets = items => items.map(item => `- ${md(item)}`).join('\n') | |||||
| const badge = value => `<span class="badge ${statusClass(value)}">${statusLabel(value)}</span>` | |||||
| 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 ? `<div class="table-scroll"><table><thead><tr>${projectHeaders.map(value => `<th>${html(value)}</th>`).join('')}</tr></thead><tbody>${projects.map(project => `<tr>${projectRow(project).map(value => `<td>${html(value)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${projects.map(project => details(project, `训练 ${recorded(projectValue(project, ['id', 'projectId', 'trainingProjectId']))}:版本、步骤、封面与依赖记录`)).join('')}` | |||||
| : '<p class="pending-note">已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json,不以阶段性保存结果代替最终记录。</p>' | |||||
| 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 `<article class="test"><h3>${html(source.title)} ${badge('PENDING')}</h3><p>等待 ${html(source.file)}。未计为通过。</p></article>` | |||||
| return `<article class="test"><h3>${html(source.data.title || source.title)} ${badge(source.status)}</h3>${source.checks.map((check, index) => `<div class="check"><h4>${html(check.title || check.name || check.label || `检查 ${index + 1}`)} ${badge(check.status)}</h4>${check.superseded || check.historical ? '<p class="pending-note">历史记录,不计入当前通过数。</p>' : ''}${details(check)}</div>`).join('')}${details(source.data, `${source.file} · 完整记录`)}</article>` | |||||
| } | |||||
| 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<details><summary>${html(source.file)} · 脱敏完整记录</summary>\n\n<pre>${html(pretty(source.data))}</pre>\n\n</details>` | |||||
| } | |||||
| const summary = `证据组:${sources.filter(source => source.status === 'PASS').length}/${sources.length} 通过;明确检查:通过 ${passedChecks},失败 ${failedChecks},待验证 ${pendingChecks};已校验截图 ${imagePaths.size} 张。` | |||||
| const htmlCommands = commands.map(command => `<div class="check"><h4>${html(command.command || command.name || '验证命令')} ${badge(command.status)}</h4><p>${html(command.details || command.result || '')}</p></div>`).join('') | |||||
| const mdCommands = commands.map(command => `- ${md(command.command || command.name || '验证命令')}:${statusLabel(command.status)}。${md(command.details || command.result || '')}`).join('\n') | |||||
| const outputHtml = `<!doctype html> | |||||
| <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>训练编排真实测试报告 · 2026-09-05</title> | |||||
| <style> | |||||
| :root{color-scheme:light;--ink:#203544;--muted:#607789;--line:#dce6ec;--green:#137d6e;--soft:#eef6f7}*{box-sizing:border-box}body{margin:0;background:#f3f6f8;color:var(--ink);font:15px/1.75 system-ui,-apple-system,"Microsoft YaHei",sans-serif}main{max-width:1220px;margin:auto;padding:32px 24px 64px}header{padding:30px 34px;background:#103c45;color:white;border-radius:16px}h1{font-size:29px;margin:0 0 12px}header p{color:#d7e8eb;margin:7px 0}nav{display:flex;gap:10px;flex-wrap:wrap;margin:22px 0}nav a{color:var(--green);text-decoration:none;background:white;border:1px solid var(--line);padding:6px 13px;border-radius:7px}section{background:white;border:1px solid var(--line);border-radius:12px;padding:26px 30px;margin:20px 0}h2{font-size:21px;margin:0 0 16px}h3{font-size:17px;margin:0 0 12px}h4{font-size:15px;margin:0 0 7px}p{margin:10px 0}ul{padding-left:22px}li+li{margin-top:7px}.badge{display:inline-block;vertical-align:middle;font-size:12px;font-weight:600;line-height:1.7;border-radius:4px;padding:2px 8px;margin-left:5px}.pass{color:#11634f;background:#e3f5ed}.fail{color:#9e2a35;background:#ffe9ec}.pending{color:#7b6121;background:#fff2cc}.muted{color:var(--muted)}.pending-note{padding:12px 16px;background:#fff8e7;border-left:3px solid #c09a3b}.stages{display:grid;grid-template-columns:1fr 1fr;gap:18px}.stage{border:1px solid var(--line);border-radius:8px;padding:20px}.stage ul{margin-bottom:0}.test+.test{border-top:1px solid var(--line);margin-top:22px;padding-top:22px}.check{border-left:3px solid var(--line);padding:8px 16px;margin:12px 0}details{margin:12px 0}summary{cursor:pointer;color:var(--green)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:600px;overflow:auto;padding:16px;background:#f5f8fa;border:1px solid var(--line);border-radius:7px;font:12px/1.6 ui-monospace,Consolas,monospace}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:20px}figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fafcfd}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 13px;color:var(--muted);font-size:13px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid var(--line);text-align:left;padding:10px;white-space:nowrap}th{background:var(--soft)}footer{color:var(--muted);font-size:13px;text-align:center}a{color:var(--green)}@media(max-width:800px){main{padding:16px 12px 40px}header,section{padding:20px}h1{font-size:24px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}main{max-width:none;padding:0}header{color:var(--ink);background:white;border:1px solid var(--line)}header p{color:var(--muted)}nav{display:none}section{break-inside:avoid}.gallery{grid-template-columns:1fr}details{break-inside:avoid}} | |||||
| </style></head><body><main> | |||||
| <header><h1>训练编排真实测试报告 ${badge(overall)}</h1><p>狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布</p><p>2026-09-05 · ${html(final.baseURL || 'http://127.0.0.1:6180')} · ${html(final.browser || 'Microsoft Edge / Playwright')} · ${html(final.role || '教员编排与独立浏览器只读重开')}</p><p>${html(summary)}</p></header> | |||||
| <nav><a href="#method">资料与实施</a><a href="#projects">保留训练数据</a><a href="#tests">测试证据</a><a href="#screenshots">最终截图</a><a href="#history">过程记录</a><a href="#scope">范围与限制</a><a href="report.md">Markdown 报告</a></nav> | |||||
| ${overall === 'PENDING' ? '<p class="pending-note">报告草稿:最终证据尚不完整。已有过程截图和开发记录不能代替保存、发布及重新打开的最终验证。</p>' : ''} | |||||
| <section id="method"><h2>资料 → 产品原型 → 设计 → 开发</h2><div class="stages">${stages.map(stage => `<article class="stage"><h3>${html(stage.title)}</h3>${bullets(stage.items)}</article>`).join('')}</div></section> | |||||
| <section id="projects"><h2>保留训练数据</h2>${htmlProjects}<p class="muted">保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。</p></section> | |||||
| <section id="tests"><h2>5 · 测试</h2><p>${html(summary)}</p><p class="muted">过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。</p>${versionMismatches.length ? `<p class="pending-note">独立重开证据尚未覆盖最终发布版本,不能标记完整通过。</p>${details(versionMismatches, '待重验的版本')}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `<h3>执行命令与验证结果</h3>${htmlCommands}` : '<p class="muted">命令与最终构建结果等待证据记录。</p>'}</section> | |||||
| <section id="screenshots"><h2>最终及异常验证截图</h2>${finalImages.length ? `<div class="gallery">${finalImages.map(figure).join('')}</div>` : '<p class="pending-note">等待最终证据引用实际截图。尚无最终截图不等于模型已通过可见性检查。</p>'}</section> | |||||
| <section id="history"><h2>过程截图 · 不计入最终通过结论</h2><p class="muted">这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。</p><div class="gallery">${history.map(figure).join('')}</div></section> | |||||
| <section id="scope"><h2>范围、限制与功能确认结论</h2>${bullets(scopeItems)}${limitations.length ? `<h3>实测限制与后续项</h3>${bullets(limitations.map(pretty))}` : ''}</section> | |||||
| <footer>报告生成:${html(generatedAt)}(Asia/Shanghai) · 来源:${sourceDefinitions.map(item => item.file).join('、')} · 图片均经本地文件校验</footer> | |||||
| </main></body></html>` | |||||
| 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<pre>${html(pretty(versionMismatches))}</pre>\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 })) | |||||
| @@ -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 => `<span class="badge ${state(value).toLowerCase()}">${label(value)}</span>` | |||||
| const details = (value, title = '查看脱敏详细记录') => `<details><summary>${html(title)}</summary><pre>${html(pretty(value))}</pre></details>` | |||||
| const bullets = items => `<ul>${items.map(item => `<li>${html(pretty(item))}</li>`).join('')}</ul>` | |||||
| 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 => `<figure><a href="${html(imageUrl(image.path))}" target="_blank" rel="noopener"><img src="${html(imageUrl(image.path))}" alt="${html(image.caption || image.path)}" loading="lazy"></a><figcaption>${html(image.caption || image.path)} · 点击查看原图</figcaption></figure>` | |||||
| const mdFigure = image => `[})](${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 `<div class="table-scroll"><table><thead><tr>${headers.map(value => `<th>${html(value)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${html(recorded(value))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>` } | |||||
| 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 `<article class="test"><h3>${html(source.title)} ${badge('PENDING')}</h3><p>等待 ${html(source.file)},未计为通过。</p></article>` | |||||
| return `<article class="test"><h3>${html(source.data.title || source.title)} ${badge(source.status)}</h3><p class="muted">证据类型:${source.kind} · ${html(source.file)}</p>${source.checks.map(check => `<div class="check"><h4>${html(check.title || check.name || '检查')} ${badge(check.status)}</h4>${details(check)}</div>`).join('')}${details(source.data, `${source.file} · 完整记录`)}</article>` | |||||
| } | |||||
| 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 ? `<details><summary>${html(source.file)} · 脱敏完整记录</summary>\n\n<pre>${html(pretty(source.data))}</pre>\n\n</details>` : ''}` | |||||
| } | |||||
| 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 = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>实装与对抗训练实测报告 · 2026-09-05</title><style> | |||||
| :root{color-scheme:light;--ink:#203544;--muted:#607789;--line:#dce6ec;--green:#137d6e}*{box-sizing:border-box}body{margin:0;background:#f3f6f8;color:var(--ink);font:15px/1.75 system-ui,-apple-system,"Microsoft YaHei",sans-serif}main{max-width:1220px;margin:auto;padding:32px 24px 64px}header{padding:30px 34px;background:#103c45;color:white;border-radius:16px}h1{font-size:29px;margin:0 0 12px}header p{color:#d7e8eb;margin:7px 0}nav{display:flex;gap:10px;flex-wrap:wrap;margin:22px 0}nav a{color:var(--green);text-decoration:none;background:white;border:1px solid var(--line);padding:6px 13px;border-radius:7px}section{background:white;border:1px solid var(--line);border-radius:12px;padding:26px 30px;margin:20px 0}h2{font-size:21px;margin:0 0 16px}h3{font-size:17px;margin:0 0 12px}h4{font-size:15px;margin:0 0 7px}p{margin:10px 0}ul{padding-left:22px}li+li{margin-top:7px}.badge{display:inline-block;vertical-align:middle;font-size:12px;font-weight:600;line-height:1.7;border-radius:4px;padding:2px 8px;margin-left:5px}.pass{color:#11634f;background:#e3f5ed}.fail{color:#9e2a35;background:#ffe9ec}.pending,.blocked,.pass_with_limitations{color:#7b6121;background:#fff2cc}.muted{color:var(--muted)}.note{padding:12px 16px;background:#fff8e7;border-left:3px solid #c09a3b}.stages{display:grid;grid-template-columns:1fr 1fr;gap:18px}.stage{border:1px solid var(--line);border-radius:8px;padding:20px;min-width:0}.test+.test{border-top:1px solid var(--line);margin-top:22px;padding-top:22px}.check{border-left:3px solid var(--line);padding:8px 16px;margin:12px 0}details{margin:12px 0}summary{cursor:pointer;color:var(--green)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:600px;overflow:auto;padding:16px;background:#f5f8fa;border:1px solid var(--line);border-radius:7px;font:12px/1.6 ui-monospace,Consolas,monospace}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:20px}figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fafcfd;min-width:0}figure img{width:100%;height:auto;display:block}figcaption{padding:10px 13px;color:var(--muted);font-size:13px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid var(--line);text-align:left;padding:10px;min-width:90px}th{background:#eef6f7}footer{color:var(--muted);font-size:13px;text-align:center}a{color:var(--green)}p,li,figcaption,summary{overflow-wrap:anywhere}@media(max-width:800px){main{padding:16px 12px 40px}header,section{padding:20px}h1{font-size:24px}.stages,.gallery{grid-template-columns:1fr}}@media print{body{background:white}main{max-width:none;padding:0}header{color:var(--ink);background:white;border:1px solid var(--line)}header p{color:var(--muted)}nav{display:none}.gallery{grid-template-columns:1fr}} | |||||
| h1,h2,h3,h4{overflow-wrap:anywhere}.check{min-width:0} | |||||
| </style></head><body><main><header><h1>实装与对抗训练实测报告 ${badge(overall)}</h1><p>编排 UI · 默认教学入口 · 正式教学 API</p>${conclusion ? `<p>${html(conclusion)}</p>` : ''}<p>2026-09-05 · ${html(ui.baseURL || api.baseURL || 'http://127.0.0.1:6180')}</p><p>${html(summary)}</p></header><nav><a href="#method">资料与实施</a><a href="#data">保留数据</a><a href="#tests">测试证据</a><a href="#images">截图</a><a href="#scope">范围与待确认项</a><a href="report.md">Markdown 报告</a></nav> | |||||
| ${overall === 'PENDING' ? '<p class="note">报告草稿:最终证据尚不完整,不以配置完成或单独 API 成功替代界面全流程验证。</p>' : ''} | |||||
| <section id="method"><h2>资料 → 产品原型 → 设计 → 开发</h2><div class="stages">${stages.map(stage => `<article class="stage"><h3>${html(stage.title)}</h3>${bullets(stage.items)}</article>`).join('')}</div><h3 style="margin-top:24px">两种模式的验证边界</h3>${tableHtml(modeHeaders, modeRows)}</section> | |||||
| <section id="data"><h2>保留开发测试数据</h2>${projects.length ? tableHtml(projectHeaders, projectRows) + projects.map(project => `${project.apiVersionNote ? `<p class="note">训练 ${html(project.id || project.projectId)}:${html(project.apiVersionNote)}</p>` : ''}${details(project, `训练 ${project.id || project.projectId} · 发布与配置记录`)}`).join('') : '<p class="note">等待 UI 最终项目记录。</p>'}<h3>正式教学任务与运行</h3>${runRows.length ? tableHtml(runHeaders, runRows) : '<p class="note">等待 API 运行结果。</p>'}${api.retainedData ? details(api.retainedData, '本次新增数据 ID') : ''}<p class="muted">随机测试密码与登录会话不写入报告;保留新建数据用于追溯。</p></section> | |||||
| <section id="tests"><h2>5 · 测试</h2><p>${html(summary)}</p>${versionMismatches.length ? `<p class="note">UI 与 API 证据引用的发布版本尚未一致,不能标记完整通过。</p>${details(versionMismatches)}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `<h3>测试命令与构建</h3>${commands.map(command => `<div class="check"><h4>${html(command.command || command.name)} ${badge(command.status)}</h4><p>${html(command.details || command.result || '')}</p></div>`).join('')}` : ''}</section> | |||||
| <section id="images"><h2>真实浏览器截图</h2><p class="muted">截图只来自实际存在的文件;API 请求结果单列,不以 UI 截图代替。</p>${images.length ? `<div class="gallery">${images.map(figure).join('')}</div>` : '<p class="note">等待 evidence.json 引用实际截图。</p>'}</section> | |||||
| <section id="scope"><h2>范围、限制与待确认项</h2>${bullets(scope)}${limitations.length ? `<h3>实测边界与限制</h3>${bullets(limitations)}` : ''}<h3>需要另行确认的功能范围</h3>${followups.length ? bullets(followups) : '<p>本栏等待最终证据中的功能范围结论;默认教学 UI 与正式运行的接线现状需按实际结果评估,不自动扩展实现。</p>'}</section><footer>报告生成:${html(generatedAt)}(Asia/Shanghai) · evidence.json / api-evidence.json · 图片经本地文件校验</footer></main></body></html>` | |||||
| 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<pre>${html(pretty(versionMismatches))}</pre>\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 })) })) | |||||
| @@ -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) | |||||
| }) | |||||