| @@ -0,0 +1,51 @@ | |||||
| { | |||||
| "pass": true, | |||||
| "checks": [ | |||||
| { | |||||
| "name": "四个接口任务在现有列表呈现", | |||||
| "ms": 4453 | |||||
| }, | |||||
| { | |||||
| "name": "运行记录请求失败时任务入口保留" | |||||
| }, | |||||
| { | |||||
| "name": "重型支援桥液压泵应急替代", | |||||
| "steps": 7, | |||||
| "reset": true | |||||
| }, | |||||
| { | |||||
| "name": "重型支援桥前泵换件修复", | |||||
| "steps": 11, | |||||
| "reset": true | |||||
| }, | |||||
| { | |||||
| "name": "重型支援桥平衡阀拆拼互换", | |||||
| "steps": 16, | |||||
| "reset": true | |||||
| }, | |||||
| { | |||||
| "name": "TASK-VIRTUAL-005 五步六次操作完整执行,工具栏保留,结束不显示评分" | |||||
| }, | |||||
| { | |||||
| "name": "刷新重置本轮预演,未调用运行写入接口,未增加评分运行" | |||||
| }, | |||||
| { | |||||
| "name": "服务端拒绝将脚本演示领取为评分运行" | |||||
| } | |||||
| ], | |||||
| "errors": [], | |||||
| "failures": [], | |||||
| "externalFailures": [ | |||||
| { | |||||
| "origin": "http://1.14.103.234:7180", | |||||
| "path": "/api/v1/files/0d8864a4fe8f46c2bf4fd4902f1b3646/content", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "origin": "http://1.14.103.234:7180", | |||||
| "path": "/api/v1/files/0d8864a4fe8f46c2bf4fd4902f1b3646/content", | |||||
| "status": 404 | |||||
| } | |||||
| ], | |||||
| "writes": [] | |||||
| } | |||||
| @@ -0,0 +1,11 @@ | |||||
| { | |||||
| "pass": true, | |||||
| "before": { | |||||
| "projects": 37, | |||||
| "tasks": 29 | |||||
| }, | |||||
| "after": { | |||||
| "projects": 37, | |||||
| "tasks": 29 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>桥梁脚本普通训练闭环验收</title> | |||||
| <style>body{font:16px/1.7 system-ui,"Microsoft YaHei";margin:0;background:#edf4f2;color:#173f36}main{max-width:1200px;margin:auto;padding:28px}section{background:white;border:1px solid #cce0da;border-radius:10px;padding:20px;margin:18px 0}h1{font-size:28px}h2{font-size:20px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #d7e5e0;padding:10px;text-align:left}th{background:#eef6f3}a{color:#087b65}img{width:100%;height:auto;border:1px solid #d7e5e0}.warning{background:#fff5e6;padding:12px;border-left:4px solid #ba7b17}code{background:#f0f5f3;padding:2px 4px}</style> | |||||
| <main><h1>桥梁脚本普通训练闭环验收</h1><p>2026-09-13 · 本机开发环境 · 修正“静态流程不计成绩”的错误设计。</p> | |||||
| <section><h2>统一规则</h2><p>静态 JS 只提供步骤及交互定义。任务使用现有接口与数据库,领取、开始、准备记录、步骤保存、提交、教员评定及统计均与其他普通训练一致。学员训练保存成绩;教员预览按现有规则不写入学员成绩。</p> | |||||
| <table><tr><th>任务 / ID</th><th>流程</th><th>固定版本</th><th>验收</th></tr> | |||||
| <tr><td>TASK-VIRTUAL-005 / 86</td><td>液压泵应急切换,5步</td><td>515</td><td>五步六次机构操作、恢复、提交、评分、统计通过</td></tr> | |||||
| <tr><td>TASK-VIRTUAL-006 / 87</td><td>液压泵应急替代,7步</td><td>516</td><td>全部步骤、保存失败重试、恢复、提交、评分、统计通过</td></tr> | |||||
| <tr><td>TASK-VIRTUAL-007 / 88</td><td>前泵换件修复,11步</td><td>517</td><td>全部步骤、恢复、提交、评分、统计通过</td></tr> | |||||
| <tr><td>TASK-VIRTUAL-008 / 89</td><td>平衡阀拆拼互换,16步</td><td>518</td><td>全部步骤、必填核对记录、恢复、提交、评分、统计通过</td></tr></table> | |||||
| <p>原任务编号和 ID 保留,当前均为 PUBLISHED / PRACTICE / COMMON;runtime.definitionSource=SCRIPT,scriptVersion="2"。版本通过 HTTP 内容修订及教学任务更新生成,没有直接执行 SQL。</p> | |||||
| <p>入口:虚实训练 → 虚拟仿真 → 领取任务 / 进入任务。已评定运行再次进入可查看教员评分与反馈。原四条任务没有写入测试成绩,可正常领取。</p></section> | |||||
| <section><h2>验证证据</h2><ul> | |||||
| <li>前端 pnpm run build 通过,包括 vue-tsc 与发布资源校验。</li> | |||||
| <li>后端 Java 17 mvn test 通过;新增后续脚本操作证据检查后,ScriptedTrainingContractTest 与 VirtualRunContractTest 共8项通过。</li> | |||||
| <li>四个任务副本39步均经过浏览器实际交互,API创建运行、保存步骤、刷新恢复、提交;教员API评定85分,学员可读且统计可查询。85分只属于自动化测试副本。</li> | |||||
| <li>模拟progress返回503时不推进,重试成功;平衡阀两处待核对步骤无记录不能推进。</li> | |||||
| <li>四个原任务的教员预览调用preview/validate,未创建学员运行;右侧结构树、聚焦、高亮、标签、隐藏与爆炸工具保留。</li></ul> | |||||
| <p><a href="normal-acceptance.json">正式训练整轮验收</a> · <a href="normal-preview.json">教员预览隔离验收</a> · <a href="registered.json">四条任务登记</a> · <a href="normal-attempts.jsonl">测试尝试与副本归档</a> · <a href="build.log">前端构建日志</a></p> | |||||
| <p>测试副本任务90~105及其运行85~100均已归档,临时学员账号已删除;保留归档记录供审计。任务90/运行85为首次失败清理,后续尝试详见记录文件。</p> | |||||
| <div class="warning">三套后续流程目前提供脚本步骤操作与记录,对应三维拆装、软管和加油动画尚未制作。平衡阀原稿第11、15步有相反指令,必须记录依据交教员核对,不自动认定机械操作正确。一次与本地构建并行的验收遇到认证服务503,重新独立执行完整回归通过;不能据此宣称系统所有历史访问异常已解决。</div> | |||||
| <p>此前 acceptance.json、student-acceptance.json 和旧截图为已被本页替代的历史演示验收,不代表当前评分规则。</p></section> | |||||
| <section><h2>原四条任务的正常入口</h2><img src="normal-task-list.png" alt="正常领取入口"></section> | |||||
| <section><h2>三维脚本提交与评定</h2><img src="normal-TASK-VIRTUAL-005-submitted.png" alt="原五步三维操作已提交"><img src="normal-TASK-VIRTUAL-005-reviewed.png" alt="教员85分测试评定"></section> | |||||
| <section><h2>其他三套普通训练</h2><img src="normal-TASK-VIRTUAL-006-reviewed.png" alt="液压泵应急替代评定"><img src="normal-TASK-VIRTUAL-007-reviewed.png" alt="前泵换件修复评定"><img src="normal-TASK-VIRTUAL-008-reviewed.png" alt="平衡阀拆拼互换评定"></section></main></html> | |||||
| @@ -0,0 +1,42 @@ | |||||
| { | |||||
| "pass": true, | |||||
| "checks": [ | |||||
| "原四条任务保留正常领取入口,不显示静态演示或禁评分标签", | |||||
| "步骤保存失败不前进,允许幂等重试", | |||||
| "TASK-VIRTUAL-006 刷新从服务端恢复已完成步骤", | |||||
| "TASK-VIRTUAL-006 7 步:领取、开始、保存、恢复、提交、教员85分评定、统计记录通过", | |||||
| "TASK-VIRTUAL-007 刷新从服务端恢复已完成步骤", | |||||
| "TASK-VIRTUAL-007 11 步:领取、开始、保存、恢复、提交、教员85分评定、统计记录通过", | |||||
| "TASK-VIRTUAL-008 刷新从服务端恢复已完成步骤", | |||||
| "TASK-VIRTUAL-008 16 步:领取、开始、保存、恢复、提交、教员85分评定、统计记录通过", | |||||
| "TASK-VIRTUAL-005 刷新从服务端恢复已完成步骤", | |||||
| "TASK-VIRTUAL-005 5 步:领取、开始、保存、恢复、提交、教员85分评定、统计记录通过" | |||||
| ], | |||||
| "fixtures": [ | |||||
| { | |||||
| "taskId": "102", | |||||
| "code": "TASK-VIRTUAL-006", | |||||
| "runId": "97", | |||||
| "archived": true | |||||
| }, | |||||
| { | |||||
| "taskId": "103", | |||||
| "code": "TASK-VIRTUAL-007", | |||||
| "runId": "98", | |||||
| "archived": true | |||||
| }, | |||||
| { | |||||
| "taskId": "104", | |||||
| "code": "TASK-VIRTUAL-008", | |||||
| "runId": "99", | |||||
| "archived": true | |||||
| }, | |||||
| { | |||||
| "taskId": "105", | |||||
| "code": "TASK-VIRTUAL-005", | |||||
| "runId": "100", | |||||
| "archived": true | |||||
| } | |||||
| ], | |||||
| "errors": [] | |||||
| } | |||||
| @@ -0,0 +1,7 @@ | |||||
| {"time":"2026-09-13T15:02:55.842Z","fixtures":[{"taskId":"91","code":"TASK-VIRTUAL-006","runId":"86","archived":true}]} | |||||
| {"time":"2026-09-13T15:04:35.752Z","fixtures":[{"taskId":"92","code":"TASK-VIRTUAL-006","runId":"87","archived":true},{"taskId":"93","code":"TASK-VIRTUAL-007","runId":"88","archived":true},{"taskId":"94","code":"TASK-VIRTUAL-008","runId":"89","archived":true},{"taskId":"95","code":"TASK-VIRTUAL-005","runId":"90","archived":true}]} | |||||
| {"time":"2026-09-13T15:07:38.165Z","fixtures":[{"taskId":"96","code":"TASK-VIRTUAL-006","runId":"91","archived":true},{"taskId":"97","code":"TASK-VIRTUAL-007","runId":"92","archived":true},{"taskId":"98","code":"TASK-VIRTUAL-008","runId":"93","archived":true},{"taskId":"99","code":"TASK-VIRTUAL-005","runId":"94","archived":true}]} | |||||
| {"time":"2026-09-13T15:09:12.152Z","fixtures":[{"taskId":"100","code":"TASK-VIRTUAL-005","runId":"95","archived":true}]} | |||||
| {"time":"2026-09-13T15:11:18.615Z","fixtures":[{"taskId":"101","code":"TASK-VIRTUAL-005","runId":"96","archived":true}]} | |||||
| {"time":"2026-09-13T15:12:41.105Z","fixtures":[]} | |||||
| {"time":"2026-09-13T15:15:29.140Z","fixtures":[{"taskId":"102","code":"TASK-VIRTUAL-006","runId":"97","archived":true},{"taskId":"103","code":"TASK-VIRTUAL-007","runId":"98","archived":true},{"taskId":"104","code":"TASK-VIRTUAL-008","runId":"99","archived":true},{"taskId":"105","code":"TASK-VIRTUAL-005","runId":"100","archived":true}]} | |||||
| @@ -0,0 +1,26 @@ | |||||
| [ | |||||
| { | |||||
| "taskId": "102", | |||||
| "code": "TASK-VIRTUAL-006", | |||||
| "runId": "97", | |||||
| "archived": true | |||||
| }, | |||||
| { | |||||
| "taskId": "103", | |||||
| "code": "TASK-VIRTUAL-007", | |||||
| "runId": "98", | |||||
| "archived": true | |||||
| }, | |||||
| { | |||||
| "taskId": "104", | |||||
| "code": "TASK-VIRTUAL-008", | |||||
| "runId": "99", | |||||
| "archived": true | |||||
| }, | |||||
| { | |||||
| "taskId": "105", | |||||
| "code": "TASK-VIRTUAL-005", | |||||
| "runId": "100", | |||||
| "archived": true | |||||
| } | |||||
| ] | |||||
| @@ -0,0 +1,15 @@ | |||||
| { | |||||
| "pass": true, | |||||
| "checks": [ | |||||
| "TASK-VIRTUAL-005 教员预览使用服务端校验,未创建训练运行", | |||||
| "TASK-VIRTUAL-006 教员预览使用服务端校验,未创建训练运行", | |||||
| "TASK-VIRTUAL-007 教员预览使用服务端校验,未创建训练运行", | |||||
| "TASK-VIRTUAL-008 教员预览使用服务端校验,未创建训练运行" | |||||
| ], | |||||
| "writes": [ | |||||
| "/api/tran/v1/teaching/assignments/86/preview/validate", | |||||
| "/api/tran/v1/teaching/assignments/87/preview/validate", | |||||
| "/api/tran/v1/teaching/assignments/88/preview/validate", | |||||
| "/api/tran/v1/teaching/assignments/89/preview/validate" | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,50 @@ | |||||
| [ | |||||
| { | |||||
| "code": "TASK-VIRTUAL-005", | |||||
| "id": "86", | |||||
| "projectId": "438", | |||||
| "versionId": "515", | |||||
| "name": "重型支援桥液压泵应急切换", | |||||
| "scriptId": "bridge-pump-switch", | |||||
| "scriptVersion": "2", | |||||
| "steps": 5, | |||||
| "previewUrl": "/teaching/virtual-training/preview/86", | |||||
| "presentation": "original-3d" | |||||
| }, | |||||
| { | |||||
| "code": "TASK-VIRTUAL-006", | |||||
| "id": "87", | |||||
| "projectId": "439", | |||||
| "versionId": "516", | |||||
| "name": "重型支援桥液压泵应急替代", | |||||
| "scriptId": "bridge-pump-substitute", | |||||
| "scriptVersion": "2", | |||||
| "steps": 7, | |||||
| "previewUrl": "/teaching/virtual-training/preview/87", | |||||
| "presentation": "procedure" | |||||
| }, | |||||
| { | |||||
| "code": "TASK-VIRTUAL-007", | |||||
| "id": "88", | |||||
| "projectId": "440", | |||||
| "versionId": "517", | |||||
| "name": "重型支援桥前泵换件修复", | |||||
| "scriptId": "bridge-pump-replace", | |||||
| "scriptVersion": "2", | |||||
| "steps": 11, | |||||
| "previewUrl": "/teaching/virtual-training/preview/88", | |||||
| "presentation": "procedure" | |||||
| }, | |||||
| { | |||||
| "code": "TASK-VIRTUAL-008", | |||||
| "id": "89", | |||||
| "projectId": "441", | |||||
| "versionId": "518", | |||||
| "name": "重型支援桥平衡阀拆拼互换", | |||||
| "scriptId": "bridge-balance-valve-swap", | |||||
| "scriptVersion": "2", | |||||
| "steps": 16, | |||||
| "previewUrl": "/teaching/virtual-training/preview/89", | |||||
| "presentation": "procedure" | |||||
| } | |||||
| ] | |||||
| @@ -0,0 +1,15 @@ | |||||
| { | |||||
| "removedUnreferencedVerificationDuplicates": [ | |||||
| 437, | |||||
| 442, | |||||
| 443, | |||||
| 444, | |||||
| 445 | |||||
| ], | |||||
| "retainedProjects": [ | |||||
| 438, | |||||
| 439, | |||||
| 440, | |||||
| 441 | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| { | |||||
| "beforeMs": 13814, | |||||
| "after": [ | |||||
| { | |||||
| "status": 200, | |||||
| "ms": 965, | |||||
| "records": 30 | |||||
| }, | |||||
| { | |||||
| "status": 200, | |||||
| "ms": 1378, | |||||
| "records": 30 | |||||
| }, | |||||
| { | |||||
| "status": 200, | |||||
| "ms": 1373, | |||||
| "records": 30 | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,7 @@ | |||||
| { | |||||
| "pass": true, | |||||
| "visibleScripts": 4, | |||||
| "ungradedMetadataPreserved": true, | |||||
| "studentCanEnter": true, | |||||
| "anonymousRejected": true | |||||
| } | |||||
| @@ -0,0 +1,39 @@ | |||||
| { | |||||
| "pass": true, | |||||
| "stages": [ | |||||
| { | |||||
| "step": 1, | |||||
| "target": "front-pump-hydraulic-pump", | |||||
| "status": "当前步骤操作正确,可以进入下一步" | |||||
| }, | |||||
| { | |||||
| "step": 2, | |||||
| "target": "front-pump-pto-switch", | |||||
| "status": "当前步骤操作正确,可以进入下一步" | |||||
| }, | |||||
| { | |||||
| "step": 3, | |||||
| "target": "front-pump-ball-valve-switch", | |||||
| "status": "当前步骤操作正确,可以进入下一步" | |||||
| }, | |||||
| { | |||||
| "step": 4, | |||||
| "target": "front-pump-pto-switch", | |||||
| "status": "当前步骤操作正确,可以进入下一步" | |||||
| }, | |||||
| { | |||||
| "step": 5, | |||||
| "target": "front-pump-aux-control-box-cover", | |||||
| "status": "当前步骤操作正确,可以进入下一步" | |||||
| } | |||||
| ], | |||||
| "actualModels": 2, | |||||
| "structureNodes": 44, | |||||
| "originalBubbleFlow": true, | |||||
| "twoActionsRequiredInStep5": true, | |||||
| "restart": true, | |||||
| "definitionDownload": true, | |||||
| "errors": [], | |||||
| "failed": [], | |||||
| "api": [] | |||||
| } | |||||
| @@ -0,0 +1,7 @@ | |||||
| { | |||||
| "message": "expect(received).toBe(expected) // Object.is equality\n\nExpected: \"front-pump-rescue-switch\"\nReceived: \"front-pump-aux-control-box-cover\"\n\nCall Log:\n- Timeout 5000ms exceeded while waiting on the predicate", | |||||
| "text": "液压泵应急切换 · 原流程演示\nTASK-VIRTUAL-005 · 本地演示,不写入训练成绩\n重新开始\n下载原流程数据\n返回数字车间\n装备数字车间\n架桥车前泵故障互救处置训练(虚拟仿真)\n教员预览 · 前泵互救五步原流程 · 不计成绩\n本地静态演示\n训练模式\n过程记录\n29\n帮助说明\n退出训练\n任务步骤\n5 / 5\n根据故障现象确定是哪一个液压泵发生故障\n已完成\n断开架桥车取力器\n已完成\n将架桥车备胎斜上方链接两个红色滤油器的球阀打开\n已完成\n重新联通取力器\n已完成\n5\n打开辅助控制盒内的“互救”开关\n进行中\n三维实时交互\n结构树\n聚焦\n复位视角\n标签\n高亮问题设备\n隐藏其他\n爆炸视图\n展开 0%\n当前步骤\n第 5 步:打开辅助控制盒内的“互救”开关\n\n第 2/2 项:拖动互救开关,按往上搬旋转 30° 后释放。\n\n操作帮助\n判定详情\n中修 · 独立 3D 科目\n架桥车互救操作训练场\n前泵故障 · 待执行互救处置 · 已加载编辑场景 2 个对象\n真实整车步骤模型\n前泵故障互救\n机构顺序操作\n本地数字教员\n数字教员\n周教员 · 维修训练指导\n男性教员 · 本地离线讲解\n步骤讲解\n\n拖动高亮机构,按沿控制盒盖边缘铰链打开旋转 100°,到位后释放。系统将自动记录三维交互、顺序与判定证据。\n\n请确认目标零件为辅助控制盒盖,并按标准顺序操作。\n播放讲解\n自动语音\n提示音\n拖动旋转 · 互救开关\n装配位置\n操作状态\n请拖动高亮的互救开关旋转到 30°(2/2)\n规则演示\n操作演示\n完成训练\n已完成第 1 项,请继续操作互救开关\n上一步\n训练列表\n步骤演示\n复位视角", | |||||
| "errors": [], | |||||
| "failed": [], | |||||
| "api": [] | |||||
| } | |||||
| @@ -0,0 +1,7 @@ | |||||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>前泵互救原流程恢复验收</title> | |||||
| <style>body{max-width:1200px;margin:32px auto;padding:0 20px;background:#f4f8f7;color:#153f39;font:16px/1.7 system-ui,sans-serif}section{background:white;padding:24px;margin:18px 0;border:1px solid #c8ddd7;border-radius:10px}a{color:#07836b}img{max-width:100%;border:1px solid #c8ddd7}code{background:#edf4f1;padding:2px 5px}</style> | |||||
| <h1>前泵互救原流程恢复验收</h1><p>2026-09-13 · 本机前端 6180 · 独立静态演示,无登录账号、无数据库写入和正式成绩。</p> | |||||
| <section><h2>找回内容</h2><p><code>TASK-VIRTUAL-005</code> 架桥车前泵故障互救处置训练:确认故障液压泵 → 断开取力器 → 打开互救球阀 → 重新联通取力器 → 打开辅助控制盒盖及互救开关。</p><p>五步原定义、步骤模型和地形模型与 yaozijun 的 <code>ba747df</code> 提交完全一致。<code>1576728</code> 将主列表切到正式接口,<code>4cb8788</code> 将旧兼容地址重定向,导致静态任务失去入口;原数据未删除。</p><p><a href="http://127.0.0.1:6180/front-pump-demo.html">打开恢复的演示</a> · <a href="TASK-VIRTUAL-005-front-pump-original.json">原流程 JSON</a> · <a href="acceptance.json">机器验证结果</a></p></section> | |||||
| <section><h2>已通过检查</h2><ul><li>真实整车和地形加载,44 个结构树节点,七个业务部件与 GLB 节点契约一致。</li><li>原静态气泡操作完成全部五步;第五步只完成盒盖时不能结束,继续操作互救开关后才可完成。</li><li>结构树、聚焦、标签、高亮、隐藏其他、爆炸与复位入口;重启回到第一步;原定义下载。</li><li>浏览器未出现脚本异常、HTTP 失败或教学 API 请求。</li><li>两个前端契约检查与生产构建通过。</li></ul><p>气泡确认使用原静态到位逻辑。本次没有把此方式当作真实考核证据,也未运行正式教学接口写入测试。报告目录保留调试阶段的 failure 文件;最终结论以 acceptance.json 为准。</p></section> | |||||
| <section><h2>恢复画面</h2><img src="01-loaded.png" alt="原五步、整车和数字教员"><h2>完成五步</h2><img src="03-complete.png" alt="五步静态演示完成"></section> | |||||
| </html> | |||||
| @@ -0,0 +1,451 @@ | |||||
| { | |||||
| "scene": { | |||||
| "mounted": true, | |||||
| "hasEnvironment": true, | |||||
| "hasEnvironmentTarget": true, | |||||
| "toneMappingExposure": 1.05, | |||||
| "background": "a8cbe1", | |||||
| "meshCount": 45, | |||||
| "materials": [ | |||||
| { | |||||
| "name": "M_3dfreehub_mat_2", | |||||
| "color": "ffffff", | |||||
| "metalness": 0, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "M_3dfreehub_mat_3", | |||||
| "color": "ffffff", | |||||
| "metalness": 0, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "M_3dfreehub_mat_4", | |||||
| "color": "ffffff", | |||||
| "metalness": 0, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "M_3dfreehub_mat_5", | |||||
| "color": "ffffff", | |||||
| "metalness": 0, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Material__85", | |||||
| "color": "695c5b", | |||||
| "metalness": 0, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "BaiSe", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Cai1", | |||||
| "color": "3d4b3b", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "HeiSe", | |||||
| "color": "4f4f4f", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "JinShu", | |||||
| "color": "929292", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "LanSe", | |||||
| "color": "3b76d0", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "LvSe", | |||||
| "color": "3d4b3b", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Mat_black", | |||||
| "color": "322e2e", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Mat_supportBridgeBridge_01", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Mat_supportBridgeBridge_02", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Mat_supportBridge_05", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Mat_white", | |||||
| "color": "9da3a4", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Material__1676", | |||||
| "color": "d96e6e", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "MoShaJinShu", | |||||
| "color": "cbcbcb", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.35, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "__", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "adskMatMat_supportBridge_05", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "adskMatMat_white", | |||||
| "color": "9da3a4", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "(未命名)", | |||||
| "color": "ffffff", | |||||
| "metalness": null, | |||||
| "roughness": null, | |||||
| "envMapIntensity": null, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Material__579", | |||||
| "color": "404040", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "(未命名)", | |||||
| "color": "ffffff", | |||||
| "metalness": null, | |||||
| "roughness": null, | |||||
| "envMapIntensity": null, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "Material__1673", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": true, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "QianlvSe2", | |||||
| "color": "749f62", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.6, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "WaiQiang", | |||||
| "color": "ffffff", | |||||
| "metalness": 0.8, | |||||
| "roughness": 0.5, | |||||
| "envMapIntensity": 1, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "(未命名)", | |||||
| "color": "ffffff", | |||||
| "metalness": null, | |||||
| "roughness": null, | |||||
| "envMapIntensity": null, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| }, | |||||
| { | |||||
| "name": "(未命名)", | |||||
| "color": "ffffff", | |||||
| "metalness": null, | |||||
| "roughness": null, | |||||
| "envMapIntensity": null, | |||||
| "hasMap": false, | |||||
| "preset": null, | |||||
| "rule": null, | |||||
| "authored": null | |||||
| } | |||||
| ], | |||||
| "server": null, | |||||
| "publishedContentMounted": true, | |||||
| "publishedContentName": "架桥车前泵故障互救处置训练场景", | |||||
| "publishedTrainingContent": { | |||||
| "sceneId": "SCENE-FRONT-PUMP-RESCUE-01", | |||||
| "objectCount": 2 | |||||
| }, | |||||
| "proceduralMachineVisible": false, | |||||
| "publishedModelRootCount": 2, | |||||
| "framing": { | |||||
| "cameraPosition": [ | |||||
| 4.51, | |||||
| 2.78, | |||||
| 8.51 | |||||
| ], | |||||
| "controlsTarget": [ | |||||
| -0.66, | |||||
| 0.59, | |||||
| 2.08 | |||||
| ], | |||||
| "distance": 8.54, | |||||
| "minDistance": 1.88, | |||||
| "maxDistance": 24, | |||||
| "fov": 45, | |||||
| "near": 0.43, | |||||
| "far": 300, | |||||
| "fogNear": 70, | |||||
| "fogFar": 240, | |||||
| "modelSize": [ | |||||
| 140, | |||||
| 28.34, | |||||
| 116.19 | |||||
| ], | |||||
| "modelCenter": [ | |||||
| -8.4, | |||||
| 7.33, | |||||
| -34.86 | |||||
| ], | |||||
| "stepCameraPolicy": "exterior", | |||||
| "explosionItemCount": 36, | |||||
| "structureNodeCount": 44, | |||||
| "enablePan": true, | |||||
| "outlineActive": false, | |||||
| "labelsVisible": false, | |||||
| "hideOthers": false, | |||||
| "hiddenMeshCount": 0, | |||||
| "keptTargets": [], | |||||
| "targetOnScreen": { | |||||
| "targetId": "front-pump-hydraulic-pump", | |||||
| "localX": 725, | |||||
| "localY": 390, | |||||
| "insideCanvas": true, | |||||
| "visible": true, | |||||
| "coveredBy": [] | |||||
| }, | |||||
| "keptOpacity": [], | |||||
| "environmentAlignment": { | |||||
| "pickedAt": [ | |||||
| 8.4, | |||||
| -10.31, | |||||
| 34.86 | |||||
| ], | |||||
| "localRelief": 0.265, | |||||
| "samples": 115 | |||||
| }, | |||||
| "sceneObjects": [ | |||||
| { | |||||
| "name": "野外山地训练场", | |||||
| "min": [ | |||||
| -78.4, | |||||
| -6.84, | |||||
| -92.96 | |||||
| ], | |||||
| "max": [ | |||||
| 61.6, | |||||
| 21.5, | |||||
| 23.24 | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "name": "架桥车前泵互救操作整车模型", | |||||
| "min": [ | |||||
| -0.92, | |||||
| 0, | |||||
| -4 | |||||
| ], | |||||
| "max": [ | |||||
| 0.92, | |||||
| 1.97, | |||||
| 4 | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "labelSamples": [ | |||||
| { | |||||
| "targetId": "scene:front-pump-rescue-vehicle", | |||||
| "targetName": "", | |||||
| "partName": "架桥车前泵互救操作整车模型" | |||||
| }, | |||||
| { | |||||
| "targetId": "front-pump-hydraulic-pump", | |||||
| "targetName": "", | |||||
| "partName": "液压泵" | |||||
| }, | |||||
| { | |||||
| "targetId": "front-pump-power-take-off", | |||||
| "targetName": "", | |||||
| "partName": "取力器" | |||||
| }, | |||||
| { | |||||
| "targetId": "front-pump-pto-switch", | |||||
| "targetName": "", | |||||
| "partName": "取力器开关" | |||||
| } | |||||
| ] | |||||
| } | |||||
| }, | |||||
| "text": "液压泵应急切换 · 原流程演示\nTASK-VIRTUAL-005 · 本地演示,不写入训练成绩\n重新开始\n下载原流程数据\n返回数字车间\n装备数字车间\n架桥车前泵故障互救处置训练(虚拟仿真)\n教员预览 · 前泵互救五步原流程 · 不计成绩\n本地静态演示\n训练模式\n过程记录\n1\n帮助说明\n退出训练\n任务步骤\n1 / 5\n1\n根据故障现象确定是哪一个液压泵发生故障\n进行中\n2\n断开架桥车取力器\n待进行\n3\n将架桥车备胎斜上方链接两个红色滤油器的球阀打开\n待进行\n4\n重新联通取力器\n待进行\n5\n打开辅助控制盒内的“互救”开关\n待进行\n三维实时交互\n结构树\n聚焦\n复位视角\n标签\n高亮问题设备\n隐藏其他\n爆炸视图\n展开 0%\n当前步骤\n第 1 步:根据故障现象确定是哪一个液压泵发生故障\n\n点击高亮的液压泵完成确认。\n\n操作帮助\n判定详情\n中修 · 独立 3D 科目\n架桥车互救操作训练场\n前泵故障 · 待执行互救处置 · 已加载编辑场景 2 个对象\n真实整车步骤模型\n前泵故障互救\n机构顺序操作\n本地数字教员\n数字教员\n周教员 · 维修训练指导\n男性教员 · 本地离线讲解\n步骤讲解\n\n点击高亮机构目标,完成识别确认。系统将自动记录三维交互、顺序与判定证据。\n\n请确认目标零件为液压泵,并按标准顺序操作。\n播放讲解\n自动语音\n提示音\n点击确认 · 液压泵\n装配位置\n操作状态\n请点击高亮的液压泵\n规则演示\n操作演示\n下一步\n位置不正确,请重新放置\n上一步\n训练列表\n步骤演示\n复位视角", | |||||
| "errors": [], | |||||
| "failed": [], | |||||
| "api": [] | |||||
| } | |||||
| @@ -0,0 +1,149 @@ | |||||
| import { chromium, expect } from '@playwright/test'; | |||||
| import crypto from 'node:crypto'; | |||||
| import fs from 'node:fs'; | |||||
| import assert from 'node:assert/strict'; | |||||
| import { login, call } from './role-permissions-live.mjs'; | |||||
| const dir = 'reports/bridge-scripted-tasks-20260913'; | |||||
| const admin = await login('root'), checks = [], fixtures = [], errors = []; | |||||
| const adminApi = async (url, data, method) => { const r = await call(url, admin.accessToken, data, method); assert.equal(r.status, 200, `${url}: ${r.message}`); return r.data; }; | |||||
| const roles = await adminApi('auth/v1/roles/all'), role = (roles.records || roles).find(item => item.code === 'student'); | |||||
| const suffix = Date.now().toString(36), password = 'Br!' + crypto.randomBytes(8).toString('hex'); | |||||
| const created = await adminApi('auth/v1/users', {username: 'bridge_' + suffix, password, displayName: '桥梁脚本闭环验收', departmentId: '5', roleIds: [role.id], defaultRoleId: role.id, enabled: true, remark: '临时验收账号'}); | |||||
| const userId = (created.user || created).id; | |||||
| const browser = await chromium.launch({channel: 'msedge', headless: true, args: ['--enable-unsafe-swiftshader']}); | |||||
| let page; | |||||
| try { | |||||
| const signed = await call('auth/v1/auth/login', null, {userId, password, roleCode: 'student', rememberMe: false}); | |||||
| assert.equal(signed.status, 200); | |||||
| const api = async (url, data) => { const r = await call(`tran/v1/${url}`, signed.data.accessToken, data); assert.equal(r.status, 200, `${url}: ${r.message}`); return r.data; }; | |||||
| const registered = JSON.parse(fs.readFileSync(`${dir}/registered.json`, 'utf8')); | |||||
| const context = await browser.newContext({viewport: {width: 1600, height: 1000}}); | |||||
| await context.addInitScript(s => { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', s.accessToken); | |||||
| sessionStorage.setItem('unreal-tran:web:refresh-token:v1', s.refreshToken); | |||||
| localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({themeMode: 'military'})); | |||||
| }, signed.data); | |||||
| page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); | |||||
| page.on('response', async response => { | |||||
| if (response.status() >= 400 && response.url().includes('/api/tran/')) { | |||||
| const body = await response.json().catch(() => ({})); | |||||
| console.log(JSON.stringify({path: new URL(response.url()).pathname, status: response.status(), message: body.message})); | |||||
| } | |||||
| }); | |||||
| await page.goto('http://127.0.0.1:6180/teaching/virtual-training'); | |||||
| await page.getByPlaceholder('请输入任务名称或编码').fill('TASK-VIRTUAL-00'); | |||||
| await page.getByPlaceholder('请输入任务名称或编码').press('Enter'); | |||||
| for (const item of registered) { | |||||
| const card = page.getByTestId(`teaching-assignment-${item.code}`); | |||||
| await expect(card).toBeVisible({timeout:30000}); | |||||
| await expect(card.getByRole('button',{name:'领取任务',exact:true})).toBeEnabled(); | |||||
| } | |||||
| await expect(page.getByText('静态流程 · 不计成绩',{exact:true})).toHaveCount(0); | |||||
| await page.screenshot({path:`${dir}/normal-task-list.png`}); | |||||
| checks.push('原四条任务保留正常领取入口,不显示静态演示或禁评分标签'); | |||||
| // Test copies keep the four delivery tasks free of test runs and test grades. | |||||
| for (const item of [...registered.slice(1), registered[0]].filter(item => !process.env.BRIDGE_CODE || item.code === process.env.BRIDGE_CODE)) { | |||||
| const original = await api(`teaching/assignments/${item.id}`); | |||||
| assert.equal(original.executionMode, 'PRACTICE'); assert.equal(original.definitionSnapshot.runtime.definitionSource, 'SCRIPT'); | |||||
| let task = await adminApi('tran/v1/teaching/assignments', {code: `CHECK-${suffix}-${item.code}`, name: `验收·${item.name}`, | |||||
| description: '脚本训练闭环自动化验证,完成后归档', channel: 'VIRTUAL', assignmentKind: 'TRAINING', executionMode: 'PRACTICE', | |||||
| audienceType: 'COMMON', collaborationMode: 'INDIVIDUAL', digitalHumanAllowed: false, contentProjectId: item.projectId, contentVersionId: item.versionId, members: []}); | |||||
| const fixture = {taskId: task.id, code: item.code}; fixtures.push(fixture); | |||||
| task = await adminApi(`tran/v1/teaching/assignments/${task.id}/publish`, {version: task.version}); | |||||
| let run = await api(`teaching/assignments/${task.id}/accept`, {assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'LEARNER'}); | |||||
| fixture.runId = run.id; | |||||
| await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/runs/${run.id}`); | |||||
| const procedure = item.presentation === 'procedure'; | |||||
| if (procedure) await expect(page.locator('.bridge-procedure h2')).toContainText(item.name); | |||||
| else await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout: 120000}); | |||||
| run = await api(`teaching/runs/${run.id}`); assert.equal(run.status, 'IN_PROGRESS'); | |||||
| for (let i = 0; i < item.steps; i++) { | |||||
| if (procedure) { | |||||
| await expect(page.locator('.bridge-procedure-current > small')).toHaveText(`第 ${i + 1} / ${item.steps} 步`); | |||||
| if (item.code === 'TASK-VIRTUAL-008' && [10,14].includes(i)) { | |||||
| await page.getByRole('button', {name: '完成本步并保存', exact: true}).click(); | |||||
| await expect(page.locator('.bridge-procedure-progress')).toContainText(`已保存 ${i} /`); | |||||
| await page.getByRole('textbox', {name: '操作记录'}).fill('验收记录:原稿相反指令已标记,连接状态与处理依据交教员核对。'); | |||||
| } | |||||
| // A rejected save must leave both UI and server at the same step. | |||||
| if (i === 0 && item.code === 'TASK-VIRTUAL-006') { | |||||
| await page.route(`**/teaching/runs/${run.id}/progress`, route => route.fulfill({status: 503, contentType:'application/json', body:JSON.stringify({code:'SERVICE_UNAVAILABLE',message:'验收模拟保存失败'})}), {times:1}); | |||||
| await page.getByRole('button', {name:'完成本步并保存', exact:true}).click(); | |||||
| await expect(page.locator('.bridge-procedure-progress')).toContainText('已保存 0 /'); | |||||
| assert.equal((await api(`teaching/runs/${run.id}`)).progressPercent, 0); | |||||
| checks.push('步骤保存失败不前进,允许幂等重试'); | |||||
| } | |||||
| await page.getByRole('button', {name:'完成本步并保存', exact:true}).click(); | |||||
| await expect(page.getByRole('button', {name:'本步已保存', exact:true})).toBeDisabled(); | |||||
| } else { | |||||
| await expect(page.locator('[data-role="step-count"]')).toHaveText(`${i + 1} / 5`); | |||||
| await page.locator('[data-role="part-label"]').click(); | |||||
| if (i === 4) { | |||||
| await expect(page.locator('[data-role="part-label"]')).toContainText('互救开关'); | |||||
| await page.locator('[data-role="part-label"]').click(); | |||||
| } | |||||
| await expect(page.locator('[data-action="next"]')).toBeEnabled(); | |||||
| } | |||||
| const saved = await api(`teaching/runs/${run.id}`); | |||||
| assert.ok(saved.progressPercent > (i / item.steps) * 100 - 1); | |||||
| const steps = await api(`teaching/runs/${run.id}/steps`); | |||||
| assert.equal(steps.filter(s => s.statusCode === 'COMPLETED').length, i + 1); | |||||
| if (i === 0) { | |||||
| await page.reload(); | |||||
| if (procedure) await expect(page.locator('.bridge-procedure-progress')).toContainText('已保存 1 /'); | |||||
| else { | |||||
| await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout:120000}); | |||||
| await expect(page.locator('[data-role="step-count"]')).toHaveText('2 / 5'); | |||||
| const bar = page.locator('[data-role="model-commandbar"]'); | |||||
| for (const action of ['toggle-structure-tree','focus-selected-part','viewer-reset-view','toggle-part-labels','toggle-issue-highlight','toggle-hide-others','toggle-explosion']) await expect(bar.locator(`[data-action="${action}"]`)).toBeVisible(); | |||||
| } | |||||
| checks.push(`${item.code} 刷新从服务端恢复已完成步骤`); | |||||
| } else if (i < item.steps - 1) { | |||||
| await (procedure ? page.getByRole('button', {name:'下一步', exact:true}) : page.locator('[data-action="next"]')).click(); | |||||
| } | |||||
| } | |||||
| await (procedure ? page.getByRole('button', {name:'提交训练', exact:true}) : page.locator('[data-action="next"]')).click(); | |||||
| await expect.poll(async () => (await api(`teaching/runs/${run.id}`)).status).toBe('SUBMITTED'); | |||||
| run = await api(`teaching/runs/${run.id}`); assert.equal(run.score, null); | |||||
| await page.screenshot({path:`${dir}/normal-${item.code}-submitted.png`}); | |||||
| run = await adminApi(`tran/v1/teaching/runs/${run.id}/review`, {version:run.version,commandId:crypto.randomUUID(),score:85,passed:true,feedback:'自动化验收样例评分,验证教员评定链路',rubric:{testFixture:true}}); | |||||
| assert.equal(run.status,'REVIEWED'); assert.equal(run.score,85); | |||||
| const visible = await api(`teaching/runs/${run.id}`); assert.equal(visible.score,85); | |||||
| await page.reload(); | |||||
| await expect(page.locator('.virtual-server-status')).toContainText('教员评定:85 分'); | |||||
| if (!procedure) await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout:120000}); | |||||
| await page.screenshot({path:`${dir}/normal-${item.code}-reviewed.png`}); | |||||
| const analytics = await adminApi(`tran/v1/teaching/analytics/records?userId=${userId}&channel=VIRTUAL&assignmentKind=TRAINING&size=100`); | |||||
| assert.ok(analytics.records.some(r => String(r.runId || r.id) === run.id)); | |||||
| checks.push(`${item.code} ${item.steps} 步:领取、开始、保存、恢复、提交、教员85分评定、统计记录通过`); | |||||
| await adminApi(`tran/v1/teaching/runs/${run.id}/archive`, {version:run.version}); | |||||
| task = await adminApi(`tran/v1/teaching/assignments/${task.id}`); | |||||
| await adminApi(`tran/v1/teaching/assignments/${task.id}/archive`, {version:task.version, reason:'闭环验收完成,归档测试副本'}); | |||||
| fixture.archived = true; | |||||
| fs.writeFileSync(`${dir}/normal-fixtures.json`,JSON.stringify(fixtures,null,2)); | |||||
| console.log(checks.at(-1)); | |||||
| } | |||||
| assert.deepEqual(errors,[]); | |||||
| fs.writeFileSync(`${dir}/normal-acceptance.json`,JSON.stringify({pass:true,checks,fixtures,errors},null,2)); | |||||
| } catch (error) { | |||||
| console.log('Visible feedback:', await page?.locator('.el-message').allTextContents()); | |||||
| await page?.screenshot({path:`${dir}/normal-failure.png`}).catch(()=>{}); | |||||
| fs.writeFileSync(`${dir}/normal-acceptance.json`,JSON.stringify({pass:false,error:error.message,checks,fixtures,errors},null,2)); | |||||
| throw error; | |||||
| } finally { | |||||
| await browser.close(); | |||||
| for (const fixture of fixtures.filter(item => !item.archived)) { | |||||
| if (fixture.runId) { | |||||
| let run = await adminApi(`tran/v1/teaching/runs/${fixture.runId}`); | |||||
| if (['IN_PROGRESS','ACCEPTED','PENDING'].includes(run.status)) run = await adminApi(`tran/v1/teaching/runs/${run.id}/terminate`, {version:run.version,commandId:crypto.randomUUID(),reason:'自动化验收中止,清理测试副本'}); | |||||
| if (run.status === 'SUBMITTED') run = await adminApi(`tran/v1/teaching/runs/${run.id}/review`, {version:run.version,commandId:crypto.randomUUID(),score:0,passed:false,feedback:'未完成的自动化验收副本'}); | |||||
| if (run.status === 'REVIEWED') await adminApi(`tran/v1/teaching/runs/${run.id}/archive`, {version:run.version}); | |||||
| } | |||||
| const task = await adminApi(`tran/v1/teaching/assignments/${fixture.taskId}`); | |||||
| await adminApi(`tran/v1/teaching/assignments/${task.id}/archive`, {version:task.version,reason:'自动化测试副本归档'}); | |||||
| fixture.archived = true; | |||||
| } | |||||
| fs.appendFileSync(`${dir}/normal-attempts.jsonl`, JSON.stringify({time:new Date().toISOString(),fixtures})+'\n'); | |||||
| const user = await adminApi(`auth/v1/users/${userId}`); | |||||
| await adminApi(`auth/v1/users/${userId}`, {version:user.version}, 'DELETE'); | |||||
| } | |||||
| @@ -0,0 +1,27 @@ | |||||
| import { chromium, expect } from '@playwright/test'; | |||||
| import fs from 'node:fs'; | |||||
| import assert from 'node:assert/strict'; | |||||
| import { login, call } from './role-permissions-live.mjs'; | |||||
| const dir='reports/bridge-scripted-tasks-20260913', session=await login('root'); | |||||
| const tasks=JSON.parse(fs.readFileSync(`${dir}/registered.json`,'utf8')), checks=[], writes=[]; | |||||
| const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); | |||||
| try { | |||||
| const page=await browser.newPage({viewport:{width:1600,height:1000}}); | |||||
| await page.addInitScript(s=>sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken),session); | |||||
| page.on('request',r=>{if(r.method()!=='GET'&&new URL(r.url()).pathname.startsWith('/api/tran/'))writes.push(new URL(r.url()).pathname)}); | |||||
| for(const task of tasks){ | |||||
| await page.goto('http://127.0.0.1:6180'+task.previewUrl); | |||||
| const procedure=task.presentation==='procedure'; | |||||
| if(procedure)await expect(page.locator('.bridge-procedure h2')).toHaveText(task.name); | |||||
| else await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/,{timeout:120000}); | |||||
| const response=page.waitForResponse(r=>r.url().includes(`/assignments/${task.id}/preview/validate`)); | |||||
| await (procedure?page.getByRole('button',{name:'完成本步并保存',exact:true}):page.locator('[data-role="part-label"]')).click(); | |||||
| assert.equal((await response).status(),200); | |||||
| const runs=await call(`tran/v1/teaching/runs?channel=VIRTUAL&assignmentKind=TRAINING&assignmentId=${task.id}&size=100`,session.accessToken); | |||||
| assert.equal(runs.status,200);assert.equal(runs.data.total,0); | |||||
| checks.push(`${task.code} 教员预览使用服务端校验,未创建训练运行`); | |||||
| } | |||||
| assert.ok(writes.every(path=>path.endsWith('/preview/validate'))); | |||||
| fs.writeFileSync(`${dir}/normal-preview.json`,JSON.stringify({pass:true,checks,writes},null,2)); | |||||
| console.log('PASS: four previews validate without writing learner progress or scores'); | |||||
| } finally {await browser.close()} | |||||
| @@ -0,0 +1,2 @@ | |||||
| // Student permissions and execution are covered by the normal lifecycle acceptance. | |||||
| import './bridge-script-normal-live.mjs'; | |||||
| @@ -0,0 +1,2 @@ | |||||
| // Compatibility entry: scripted tasks use the normal graded training lifecycle. | |||||
| import './bridge-script-normal-live.mjs'; | |||||
| @@ -0,0 +1,3 @@ | |||||
| // The recovered flow now enters through authenticated database tasks. | |||||
| // Run register-bridge-scripted-tasks.mjs once before this compatibility test. | |||||
| import './bridge-scripted-tasks-live.mjs'; | |||||
| @@ -0,0 +1,59 @@ | |||||
| import fs from 'node:fs'; | |||||
| import assert from 'node:assert/strict'; | |||||
| import { login, call } from './role-permissions-live.mjs'; | |||||
| import { scripts, scriptDefinition } from '../../unreal_tran_web/src/features/virtual-training-scripts/definition.js'; | |||||
| // Explicitly run to register/migrate these four tasks. Existing task codes and IDs are preserved. | |||||
| const session = await login('root'); | |||||
| const api = async (endpoint, body, method) => { | |||||
| const r = await call(`tran/v1/${endpoint}`, session.accessToken, body, method); | |||||
| assert.equal(r.status, 200, `${endpoint}: ${r.message}`); return r.data; | |||||
| }; | |||||
| const registered = []; | |||||
| for (const script of scripts) { | |||||
| const tasks = await api(`teaching/assignments?channel=VIRTUAL&assignmentKind=TRAINING&keyword=${script.code}&size=100`); | |||||
| let task = tasks.records.find(item => item.code === script.code); | |||||
| if (task) assert.equal(task.definitionSnapshot.runtime?.scriptId, script.id, 'Refuse to replace an unrelated task'); | |||||
| let detail = task ? await api(`content/projects/${task.contentProjectId}`) : null; | |||||
| if (!detail) { | |||||
| const found = await api(`content/projects?type=TRAINING&keyword=${encodeURIComponent(script.title)}&size=100`); | |||||
| for (const candidate of found.records) { | |||||
| const item = await api(`content/projects/${candidate.id}`); | |||||
| if (item.currentVersion.content?.runtime?.scriptId === script.id) { detail = item; break; } | |||||
| } | |||||
| } | |||||
| const correct = task?.definitionSnapshot.runtime?.definitionSource === 'SCRIPT' | |||||
| && task?.definitionSnapshot.runtime?.scriptVersion === script.version && task.executionMode === 'PRACTICE'; | |||||
| if (task?.status === 'PUBLISHED' && !correct) task = await api(`teaching/assignments/${task.id}/withdraw`, | |||||
| { version: task.version, reason: '静态 JS 作为流程来源,恢复普通训练与成绩评定' }); | |||||
| const projectCommand = { type: 'TRAINING', name: script.title, description: `原稿:${script.source}`, | |||||
| categoryCode: 'MAINTENANCE', trainingMode: 'VIRTUAL', content: scriptDefinition(script), dependencies: [], assets: [], | |||||
| changeSummary: '脚本来源 v2:复用正式教学运行、步骤记录与成绩评定' }; | |||||
| if (!detail) detail = await api('content/projects', projectCommand); | |||||
| else if (detail.currentVersion.content?.runtime?.definitionSource !== 'SCRIPT' | |||||
| || detail.currentVersion.content?.runtime?.scriptVersion !== script.version) { | |||||
| detail = await api(`content/projects/${detail.project.id}`, { ...projectCommand, version: detail.project.version }, 'PUT'); | |||||
| } | |||||
| let project = detail.project; | |||||
| if (project.publishedVersionId !== detail.currentVersion.id) { | |||||
| project = (await api(`content/projects/${project.id}/publish`, { version: project.version })).project; | |||||
| } | |||||
| const command = { code: script.code, name: script.title, description: `原稿:${script.source}`, | |||||
| channel: 'VIRTUAL', assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'COMMON', | |||||
| collaborationMode: 'INDIVIDUAL', digitalHumanAllowed: false, contentProjectId: project.id, | |||||
| contentVersionId: project.publishedVersionId, members: [] }; | |||||
| if (!task) task = await api('teaching/assignments', command); | |||||
| else if (!correct || task.contentVersionId !== project.publishedVersionId) { | |||||
| assert.ok(['DRAFT', 'WITHDRAWN'].includes(task.status)); | |||||
| task = await api(`teaching/assignments/${task.id}`, { ...command, version: task.version }, 'PUT'); | |||||
| } | |||||
| if (task.status !== 'PUBLISHED') task = await api(`teaching/assignments/${task.id}/publish`, { version: task.version }); | |||||
| assert.equal(task.executionMode, 'PRACTICE'); | |||||
| assert.equal(task.definitionSnapshot.runtime.definitionSource, 'SCRIPT'); | |||||
| registered.push({ code: script.code, id: task.id, projectId: project.id, versionId: task.contentVersionId, | |||||
| name: script.title, scriptId: script.id, scriptVersion: script.version, steps: script.steps.length, | |||||
| previewUrl: `/teaching/virtual-training/preview/${task.id}`, presentation: script.presentation }); | |||||
| console.log(JSON.stringify(registered.at(-1))); | |||||
| } | |||||
| fs.mkdirSync('reports/bridge-scripted-tasks-20260913', { recursive: true }); | |||||
| fs.writeFileSync('reports/bridge-scripted-tasks-20260913/registered.json', JSON.stringify(registered, null, 2)); | |||||