| @@ -0,0 +1,20 @@ | |||
| { | |||
| "ut_gateway": { | |||
| "tests": 1, | |||
| "failures": 0, | |||
| "errors": 0, | |||
| "skipped": 0 | |||
| }, | |||
| "ut_auth": { | |||
| "tests": 134, | |||
| "failures": 0, | |||
| "errors": 0, | |||
| "skipped": 0 | |||
| }, | |||
| "ut_tran": { | |||
| "tests": 417, | |||
| "failures": 0, | |||
| "errors": 0, | |||
| "skipped": 0 | |||
| } | |||
| } | |||
| @@ -0,0 +1,64 @@ | |||
| { | |||
| "disabledTestUserIds": [ | |||
| "136", | |||
| "137", | |||
| "138", | |||
| "139", | |||
| "140", | |||
| "141", | |||
| "142", | |||
| "143", | |||
| "144", | |||
| "145", | |||
| "146", | |||
| "147", | |||
| "148", | |||
| "149", | |||
| "150", | |||
| "151", | |||
| "152", | |||
| "153", | |||
| "154", | |||
| "156", | |||
| "157", | |||
| "159" | |||
| ], | |||
| "retainedTestUserIds": [ | |||
| "160", | |||
| "158", | |||
| "155" | |||
| ], | |||
| "retainedAssignments": [ | |||
| "56", | |||
| "57", | |||
| "58", | |||
| "59", | |||
| "60", | |||
| "61", | |||
| "62", | |||
| "63", | |||
| "64", | |||
| "65", | |||
| "66", | |||
| "67", | |||
| "68", | |||
| "69", | |||
| "70", | |||
| "71", | |||
| "72", | |||
| "73", | |||
| "74", | |||
| "75", | |||
| "76", | |||
| "77", | |||
| "78", | |||
| "79", | |||
| "80", | |||
| "81", | |||
| "82", | |||
| "83", | |||
| "84", | |||
| "85" | |||
| ], | |||
| "reason": "Keep execution audit facts; do not fabricate reviews merely to archive unfinished test assignments." | |||
| } | |||
| @@ -0,0 +1,29 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "正式考试进入 JS+HTML,仅读取服务端当前步骤,关闭数字人和目标提示", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "实际三维选择、逐步提交、重新获取下一安全投影、最终交卷", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "学员不能自评;教员评分后学员可见,统计明细包含本次运行", | |||
| "pass": true, | |||
| "details": { | |||
| "runId": "83", | |||
| "score": 86 | |||
| } | |||
| }, | |||
| { | |||
| "name": "数据中心页面查询正式考核成绩与记录详情", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "考试到期自动关闭工作区;服务端拒绝继续获取执行投影和资产,教员完成超时结算", | |||
| "pass": true | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| { | |||
| "userId": "160", | |||
| "assignmentId": "84", | |||
| "runId": "83", | |||
| "deadlineAssignmentId": "85", | |||
| "deadlineRunId": "84" | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>JS+HTML 虚拟训练完整流程验收</title><style> | |||
| *{box-sizing:border-box}body{margin:0;background:#eef5f3;color:#193b35;font:15px/1.75 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1320px;margin:28px auto;padding:0 24px}header,section{background:white;border:1px solid #c8ded7;border-radius:12px;padding:24px;margin-bottom:20px}h1{font-size:28px;margin:0 0 8px}h2{font-size:20px;margin:0 0 14px}p{margin:8px 0}.pass{color:#087c60;font-weight:700}.meta{color:#58786d}table{width:100%;border-collapse:collapse}td,th{border:1px solid #dce9e4;padding:10px 14px;text-align:left;vertical-align:top}th{background:#f1f7f4}.shots{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;border:1px solid #dce9e4;border-radius:8px;overflow:hidden}figure img{width:100%;display:block}figcaption{padding:10px 14px}a{color:#087c60}code{overflow-wrap:anywhere}details{margin:14px 0}pre{white-space:pre-wrap;max-height:300px;overflow:auto;background:#f4f8f6;padding:14px}@media(max-width:800px){.shots{grid-template-columns:1fr}main{padding:0 12px}section{padding:16px}} | |||
| </style><main><header><h1>JS+HTML 虚拟训练完整流程验收</h1><p class="pass">15 项实测检查通过 · 111 项前端契约测试通过 · 552 项后端测试通过</p><p class="meta">时间:2026/9/12 23:50:03 · 环境:本机 6180 Web → 6100 Gateway → Tran/Auth · Edge 无头浏览器真实交互</p><p>本轮覆盖虚拟训练高级编排、虚拟通道正式考试、教员评定与数据中心,并回归既有 JS+HTML 运行/预览。所有训练进度、交卷、评定及编排状态由真实接口处理。</p></header> | |||
| <section><h2>迁移结果</h2><table><tr><th>环节</th><th>实现与验证</th></tr> | |||
| <tr><td>高级编排</td><td>服务端按发布连线执行开始、步骤、结束、条件、安全、事件等待、故障注入/清除、建议评分、相机及音频节点。等待与超时阻止推进;分支跳过记为 SKIPPED;刷新恢复;教员页面确认事件;相同命令重放不重复推进。</td></tr> | |||
| <tr><td>正式考试</td><td>虚拟通道使用 JS+HTML 工作区,逐步获取 execution 安全投影;关闭帮助、数字人、答案高亮、爆炸和回放。三维选择后提交,交卷后关闭执行资源;到期关闭工作区并由服务端拒绝资源访问。非虚拟通道保留设备/岗位推进规则。</td></tr> | |||
| <tr><td>评分与统计</td><td>服务端逐步保留考试操作证据;教员在评定弹窗查看并打分,学员不能自评或获取私有证据聚合。数据中心页面可查询成绩及详情。</td></tr> | |||
| <tr><td>普通训练回归</td><td>桥梁结构树、聚焦、高亮、隐藏、标签、爆炸、复位、数字教员;卡车四类动画与复位;工具、安全和测量记录恢复;真实点击与拖动;网络失败不提前推进,恢复后重试;他人运行只读观察。</td></tr></table></section> | |||
| <section><h2>真实流程检查</h2><table><tr><th>检查</th><th>结果</th></tr><tr><td>正式考试进入 JS+HTML,仅读取服务端当前步骤,关闭数字人和目标提示</td><td class="pass">通过</td></tr><tr><td>实际三维选择、逐步提交、重新获取下一安全投影、最终交卷</td><td class="pass">通过</td></tr><tr><td>学员不能自评;教员评分后学员可见,统计明细包含本次运行</td><td class="pass">通过</td></tr><tr><td>数据中心页面查询正式考核成绩与记录详情</td><td class="pass">通过</td></tr><tr><td>考试到期自动关闭工作区;服务端拒绝继续获取执行投影和资产,教员完成超时结算</td><td class="pass">通过</td></tr><tr><td>故障真值与编排游标在服务端;超时仍等待,刷新恢复;学员不能注入事件或提前交卷</td><td class="pass">通过</td></tr><tr><td>教员页面确认事件、服务端选择分支、明确跳过第一步、真实操作第二步、清除故障与提交</td><td class="pass">通过</td></tr><tr><td>编排训练交卷后由教员评分,真实统计包含本次运行</td><td class="pass">通过</td></tr><tr><td>桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员</td><td class="pass">通过</td></tr><tr><td>卡车:正式工作区播放四种已编排动画轨道并保持末帧</td><td class="pass">通过</td></tr><tr><td>学员:工具、安全、测量记录通过真实接口保存并刷新恢复</td><td class="pass">通过</td></tr><tr><td>进度请求失败时不在本地提前完成步骤</td><td class="pass">通过</td></tr><tr><td>步骤完成服务器确认后推进,下一步动画按发布片段复位</td><td class="pass">通过</td></tr><tr><td>真实两步操作完成、服务端判定、提交结果闭环</td><td class="pass">通过</td></tr><tr><td>维护账号观察他人运行时只读,不修改学员进度</td><td class="pass">通过</td></tr></table></section> | |||
| <section><h2>验证命令与证据</h2><p><code>pnpm run build</code>(含 vue-tsc 与正式资源构建校验);<code>node --test</code> 执行 teaching/virtual/training 的 *.test.mjs;Java 17 下 <code>mvn test</code>。</p><table><tr><th>后端模块</th><th>测试数</th><th>失败 / 错误</th></tr><tr><td>ut_gateway</td><td>1</td><td>0 / 0</td></tr><tr><td>ut_auth</td><td>134</td><td>0 / 0</td></tr><tr><td>ut_tran</td><td>417</td><td>0 / 0</td></tr></table><p>构建存在现有大分块提示。原型同步检查提示 upstream 与同步基线存在差异,本次未执行覆盖式原型同步。</p><p>脚本:<code>virtual-html-exam-live.mjs</code>、<code>virtual-html-workflow-live.mjs</code>、<code>virtual-html-live.mjs</code>。原始结果:<a href="exam-checks.json">考试</a>、<a href="workflow-checks.json">编排</a>、<a href="../virtual-html-workspace-20260912/checks.json">普通训练回归</a>。</p></section> | |||
| <section><h2>数据库与使用边界</h2><p>已在开发库执行并重复验证两项增量:040 扩展 SKIPPED 步骤状态,041 扩展编排命令幂等回执类型。完整 SQL、数据库设计与接口文档同步更新;其他环境需先执行增量再更新服务。</p><p><a href="migration.json">040 执行记录与原约束</a> · <a href="migration-commands.json">041 执行记录与原约束</a></p><p>外部事件本轮通过教员页面及授权 HTTP 接口验证,未连接现场真实设备;故障状态用于服务端流程判定,不能据此宣称已完成真实设备故障模拟。普通教员预览沿用连续步骤预演接口,高级分支验收在真实学员运行中完成。</p></section> | |||
| <section><h2>保留的验收数据</h2><p>最新正式考试:任务 84 / 运行 83(86 分);截止时间测试:任务 85 / 运行 84;高级编排:任务 81 / 运行 80(91 分)。</p><p>已停用 22 个不用的临时测试账号,保留最新 3 个验收账号。过程记录、失败重试记录与历史任务保留供审计和复查,未为了清理而补造评定。</p><p><a href="retained-fixtures.json">完整测试数据清单</a> · <a href="cleanup.json">账号清理结果</a></p></section> | |||
| <section><h2>页面证据</h2><div class="shots"><figure><a href="01-exam-current-step.png" target="_blank"><img loading="lazy" src="01-exam-current-step.png" alt="正式考试:当前安全投影与实际三维选择"></a><figcaption>正式考试:当前安全投影与实际三维选择</figcaption></figure><figure><a href="03-workflow-waiting.png" target="_blank"><img loading="lazy" src="03-workflow-waiting.png" alt="高级编排:等待外部事件,超时保持阻断"></a><figcaption>高级编排:等待外部事件,超时保持阻断</figcaption></figure><figure><a href="04-teacher-workflow-event.png" target="_blank"><img loading="lazy" src="04-teacher-workflow-event.png" alt="教员页面确认外部事件"></a><figcaption>教员页面确认外部事件</figcaption></figure><figure><a href="05-workflow-submitted.png" target="_blank"><img loading="lazy" src="05-workflow-submitted.png" alt="高级编排路径完成并提交"></a><figcaption>高级编排路径完成并提交</figcaption></figure><figure><a href="06-teacher-exam-review.png" target="_blank"><img loading="lazy" src="06-teacher-exam-review.png" alt="教员查看逐步考试证据并评分"></a><figcaption>教员查看逐步考试证据并评分</figcaption></figure><figure><a href="07-exam-analytics.png" target="_blank"><img loading="lazy" src="07-exam-analytics.png" alt="数据中心查询成绩与记录详情"></a><figcaption>数据中心查询成绩与记录详情</figcaption></figure><figure><a href="08-exam-deadline.png" target="_blank"><img loading="lazy" src="08-exam-deadline.png" alt="考试到期关闭工作区"></a><figcaption>考试到期关闭工作区</figcaption></figure></div></section></main></html> | |||
| @@ -0,0 +1,17 @@ | |||
| { | |||
| "file": "20260912_041_teaching_workflow_commands.sql", | |||
| "sha256": "c2d724719d72a41e7c6078b03a4dd10b4a1f1addd4830a782ae58a8cfe7d0171", | |||
| "target": "unreal_tran/public", | |||
| "encoding": { | |||
| "server_encoding": "UTF8" | |||
| }, | |||
| "before": { | |||
| "definition": "CHECK (((command_type)::text = ANY ((ARRAY['START'::varchar, 'CHECKPOINT'::varchar, 'PROGRESS'::varchar, 'SUBMIT'::varchar, 'REVIEW'::varchar])::text[])))" | |||
| }, | |||
| "temporaryFirstAndRepeat": true, | |||
| "stage": "verified", | |||
| "after": { | |||
| "definition": "CHECK (((command_type)::text = ANY ((ARRAY['START'::varchar, 'CHECKPOINT'::varchar, 'PROGRESS'::varchar, 'SUBMIT'::varchar, 'REVIEW'::varchar, 'WORKFLOW_RESUME'::varchar, 'WORKFLOW_SIGNAL'::varchar])::text[])))" | |||
| }, | |||
| "time": "2026-09-12T23:03:49.356412+08:00" | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| { | |||
| "file": "20260912_040_teaching_workflow_steps.sql", | |||
| "sha256": "ac22afb108ad7743fbb20309c5da2139af3bddef47f12ac3a2f33fd40b7d7a8a", | |||
| "target": "unreal_tran/public", | |||
| "encoding": { | |||
| "server_encoding": "UTF8" | |||
| }, | |||
| "before": { | |||
| "definition": "CHECK (((status_code)::text = ANY ((ARRAY['PENDING'::varchar, 'IN_PROGRESS'::varchar, 'FAILED'::varchar, 'COMPLETED'::varchar])::text[])))" | |||
| }, | |||
| "temporaryFirstAndRepeat": true, | |||
| "stage": "verified", | |||
| "after": { | |||
| "definition": "CHECK (((status_code)::text = ANY ((ARRAY['PENDING'::varchar, 'IN_PROGRESS'::varchar, 'FAILED'::varchar, 'COMPLETED'::varchar, 'SKIPPED'::varchar])::text[])))" | |||
| }, | |||
| "time": "2026-09-12T22:59:23.190568+08:00" | |||
| } | |||
| @@ -0,0 +1,311 @@ | |||
| { | |||
| "users": [ | |||
| { | |||
| "id": 136, | |||
| "username": "vh_mtyfjlrf", | |||
| "display_name": "HTML验收-mtyfjlrf" | |||
| }, | |||
| { | |||
| "id": 137, | |||
| "username": "vh_mtyfllat", | |||
| "display_name": "HTML验收-mtyfllat" | |||
| }, | |||
| { | |||
| "id": 138, | |||
| "username": "vh_mtyfndrj", | |||
| "display_name": "HTML验收-mtyfndrj" | |||
| }, | |||
| { | |||
| "id": 139, | |||
| "username": "vh_mtyg0s9h", | |||
| "display_name": "HTML验收-mtyg0s9h" | |||
| }, | |||
| { | |||
| "id": 140, | |||
| "username": "vh_mtyg3srb", | |||
| "display_name": "HTML验收-mtyg3srb" | |||
| }, | |||
| { | |||
| "id": 141, | |||
| "username": "vh_mtygust6", | |||
| "display_name": "HTML验收-mtygust6" | |||
| }, | |||
| { | |||
| "id": 142, | |||
| "username": "ex_mtyhnfth", | |||
| "display_name": "HTML考试验收-mtyhnfth" | |||
| }, | |||
| { | |||
| "id": 143, | |||
| "username": "ex_mtyhqwxw", | |||
| "display_name": "HTML考试验收-mtyhqwxw" | |||
| }, | |||
| { | |||
| "id": 144, | |||
| "username": "ex_mtyhvmzu", | |||
| "display_name": "HTML考试验收-mtyhvmzu" | |||
| }, | |||
| { | |||
| "id": 145, | |||
| "username": "ex_mtyhwqmi", | |||
| "display_name": "HTML考试验收-mtyhwqmi" | |||
| }, | |||
| { | |||
| "id": 146, | |||
| "username": "wf_mtyip5sy", | |||
| "display_name": "HTML编排验收-mtyip5sy" | |||
| }, | |||
| { | |||
| "id": 147, | |||
| "username": "wf_mtyipzxt", | |||
| "display_name": "HTML编排验收-mtyipzxt" | |||
| }, | |||
| { | |||
| "id": 148, | |||
| "username": "wf_mtyissgj", | |||
| "display_name": "HTML编排验收-mtyissgj" | |||
| }, | |||
| { | |||
| "id": 149, | |||
| "username": "ex_mtyj38ov", | |||
| "display_name": "HTML考试验收-mtyj38ov" | |||
| }, | |||
| { | |||
| "id": 150, | |||
| "username": "ex_mtyj9y9t", | |||
| "display_name": "HTML考试验收-mtyj9y9t" | |||
| }, | |||
| { | |||
| "id": 151, | |||
| "username": "wf_mtyja0m1", | |||
| "display_name": "HTML编排验收-mtyja0m1" | |||
| }, | |||
| { | |||
| "id": 152, | |||
| "username": "ex_mtyjd0rb", | |||
| "display_name": "HTML考试验收-mtyjd0rb" | |||
| }, | |||
| { | |||
| "id": 153, | |||
| "username": "wf_mtyjd8w5", | |||
| "display_name": "HTML编排验收-mtyjd8w5" | |||
| }, | |||
| { | |||
| "id": 154, | |||
| "username": "ex_mtyjf2z6", | |||
| "display_name": "HTML考试验收-mtyjf2z6" | |||
| }, | |||
| { | |||
| "id": 155, | |||
| "username": "vh_mtyjgaok", | |||
| "display_name": "HTML验收-mtyjgaok" | |||
| }, | |||
| { | |||
| "id": 156, | |||
| "username": "ex_mtyjny4t", | |||
| "display_name": "HTML考试验收-mtyjny4t" | |||
| }, | |||
| { | |||
| "id": 157, | |||
| "username": "ex_mtyjtoug", | |||
| "display_name": "HTML考试验收-mtyjtoug" | |||
| }, | |||
| { | |||
| "id": 158, | |||
| "username": "wf_mtyjtx1p", | |||
| "display_name": "HTML编排验收-mtyjtx1p" | |||
| }, | |||
| { | |||
| "id": 159, | |||
| "username": "ex_mtyjw7ud", | |||
| "display_name": "HTML考试验收-mtyjw7ud" | |||
| }, | |||
| { | |||
| "id": 160, | |||
| "username": "ex_mtyk7pch", | |||
| "display_name": "HTML考试验收-mtyk7pch" | |||
| } | |||
| ], | |||
| "assignments": [ | |||
| { | |||
| "id": 56, | |||
| "assignment_code": "HTML-MTYFNDRJ", | |||
| "assignment_name": "HTML工作区-卡车动画闭环-mtyfndrj", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 57, | |||
| "assignment_code": "HTML-MTYG0S9H", | |||
| "assignment_name": "HTML工作区-卡车动画闭环-mtyg0s9h", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 58, | |||
| "assignment_code": "HTML-MTYG3SRB", | |||
| "assignment_name": "HTML工作区-卡车动画闭环-mtyg3srb", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 59, | |||
| "assignment_code": "HTML-MEDIA-MTYGIYB7", | |||
| "assignment_name": "实测训练-狐狸野外巡检-已验证-20260905", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 60, | |||
| "assignment_code": "HTML-MEDIA-MTYGL5PO", | |||
| "assignment_name": "实测训练-狐狸野外巡检-已验证-20260905", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 61, | |||
| "assignment_code": "HTML-MEDIA-MTYGNWPK", | |||
| "assignment_name": "实测训练-狐狸野外巡检-已验证-20260905", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 62, | |||
| "assignment_code": "HTML-MEDIA-MTYGPVFZ", | |||
| "assignment_name": "实测训练-狐狸野外巡检-已验证-20260905", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 63, | |||
| "assignment_code": "HTML-MEDIA-MTYGQ1CJ", | |||
| "assignment_name": "HTML媒体验收-411-mtygq1cj", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 64, | |||
| "assignment_code": "HTML-MEDIA-MTYGRTKT", | |||
| "assignment_name": "实测训练-狐狸野外巡检-已验证-20260905", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 65, | |||
| "assignment_code": "HTML-MEDIA-MTYGRZVZ", | |||
| "assignment_name": "HTML媒体验收-411-mtygrzvz", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 66, | |||
| "assignment_code": "HTML-MTYGUST6", | |||
| "assignment_name": "HTML工作区-卡车动画闭环-mtygust6", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 67, | |||
| "assignment_code": "HTML-MEDIA-MTYGWSL3", | |||
| "assignment_name": "实测训练-狐狸野外巡检-已验证-20260905", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 68, | |||
| "assignment_code": "HTML-MEDIA-MTYGWYXH", | |||
| "assignment_name": "HTML媒体验收-411-mtygwyxh", | |||
| "status_code": "DRAFT" | |||
| }, | |||
| { | |||
| "id": 69, | |||
| "assignment_code": "HTML-EX-MTYHNFTH", | |||
| "assignment_name": "HTML正式考试-mtyhnfth", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 70, | |||
| "assignment_code": "HTML-EX-MTYHQWXW", | |||
| "assignment_name": "HTML正式考试-mtyhqwxw", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 71, | |||
| "assignment_code": "HTML-EX-MTYHVMZU", | |||
| "assignment_name": "HTML正式考试-mtyhvmzu", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 72, | |||
| "assignment_code": "HTML-EX-MTYHWQMI", | |||
| "assignment_name": "HTML正式考试-mtyhwqmi", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 73, | |||
| "assignment_code": "HTML-WF-MTYISSGJ", | |||
| "assignment_name": "HTML编排等待与分支-mtyissgj", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 74, | |||
| "assignment_code": "HTML-EX-MTYJ38OV", | |||
| "assignment_name": "HTML正式考试-mtyj38ov", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 75, | |||
| "assignment_code": "HTML-EX-MTYJD0RB", | |||
| "assignment_name": "HTML正式考试-mtyjd0rb", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 76, | |||
| "assignment_code": "HTML-WF-MTYJD8W5", | |||
| "assignment_name": "HTML编排等待与分支-mtyjd8w5", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 77, | |||
| "assignment_code": "HTML-EX-MTYJF2Z6", | |||
| "assignment_name": "HTML正式考试-mtyjf2z6", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 78, | |||
| "assignment_code": "HTML-MTYJGAOK", | |||
| "assignment_name": "HTML工作区-卡车动画闭环-mtyjgaok", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 79, | |||
| "assignment_code": "HTML-EX-MTYJNY4T", | |||
| "assignment_name": "HTML正式考试-mtyjny4t", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 80, | |||
| "assignment_code": "HTML-EX-MTYJTOUG", | |||
| "assignment_name": "HTML正式考试-mtyjtoug", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 81, | |||
| "assignment_code": "HTML-WF-MTYJTX1P", | |||
| "assignment_name": "HTML编排等待与分支-mtyjtx1p", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 82, | |||
| "assignment_code": "HTML-EX-MTYJW7UD", | |||
| "assignment_name": "HTML正式考试-mtyjw7ud", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 83, | |||
| "assignment_code": "HTML-TIME-MTYJW7UD", | |||
| "assignment_name": "HTML考试截止验收-mtyjw7ud", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 84, | |||
| "assignment_code": "HTML-EX-MTYK7PCH", | |||
| "assignment_name": "HTML正式考试-mtyk7pch", | |||
| "status_code": "PUBLISHED" | |||
| }, | |||
| { | |||
| "id": 85, | |||
| "assignment_code": "HTML-TIME-MTYK7PCH", | |||
| "assignment_name": "HTML考试截止验收-mtyk7pch", | |||
| "status_code": "PUBLISHED" | |||
| } | |||
| ] | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "故障真值与编排游标在服务端;超时仍等待,刷新恢复;学员不能注入事件或提前交卷", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "教员页面确认事件、服务端选择分支、明确跳过第一步、真实操作第二步、清除故障与提交", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "编排训练交卷后由教员评分,真实统计包含本次运行", | |||
| "pass": true, | |||
| "details": { | |||
| "runId": "80", | |||
| "score": 91 | |||
| } | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| { | |||
| "userId": "158", | |||
| "projectId": "435", | |||
| "assignmentId": "81", | |||
| "runId": "80" | |||
| } | |||
| @@ -0,0 +1,309 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员", | |||
| "pass": true, | |||
| "details": { | |||
| "cameraPosition": [ | |||
| -5.8, | |||
| 3.73, | |||
| -0.61 | |||
| ], | |||
| "controlsTarget": [ | |||
| 4.08, | |||
| 3.11, | |||
| -0.91 | |||
| ], | |||
| "distance": 9.9, | |||
| "minDistance": 0.05, | |||
| "maxDistance": 600, | |||
| "fov": 45, | |||
| "near": 0.1, | |||
| "far": 600, | |||
| "fogNear": 70, | |||
| "fogFar": 220, | |||
| "modelSize": [ | |||
| 60, | |||
| 14.99, | |||
| 34.31 | |||
| ], | |||
| "modelCenter": [ | |||
| 0, | |||
| 7.08, | |||
| 0 | |||
| ], | |||
| "stepCameraPolicy": "", | |||
| "explosionItemCount": 114, | |||
| "structureNodeCount": 155, | |||
| "enablePan": true, | |||
| "outlineActive": true, | |||
| "labelsVisible": true, | |||
| "hideOthers": false, | |||
| "hiddenMeshCount": 0, | |||
| "keptTargets": [ | |||
| "scene:OBJ-1789139545785-15/part/interaction-fcaaf987" | |||
| ], | |||
| "targetOnScreen": { | |||
| "targetId": "scene:OBJ-1789139545785-15/part/interaction-fcaaf987", | |||
| "localX": 897, | |||
| "localY": 597, | |||
| "insideCanvas": true, | |||
| "visible": true, | |||
| "coveredBy": [] | |||
| }, | |||
| "keptOpacity": [ | |||
| { | |||
| "targetId": "scene:OBJ-1789139545785-15/part/interaction-fcaaf987", | |||
| "minOpacity": 1, | |||
| "meshes": [ | |||
| { | |||
| "name": "底盘车_2_4", | |||
| "size": [ | |||
| 1.77, | |||
| 0.84, | |||
| 2.32 | |||
| ], | |||
| "verts": 43856, | |||
| "baseOpacity": 1 | |||
| } | |||
| ] | |||
| } | |||
| ], | |||
| "environmentAlignment": null, | |||
| "sceneObjects": [ | |||
| { | |||
| "name": "车库", | |||
| "min": [ | |||
| -30, | |||
| 0, | |||
| -17.16 | |||
| ], | |||
| "max": [ | |||
| 30, | |||
| 14.58, | |||
| 17.16 | |||
| ] | |||
| }, | |||
| { | |||
| "name": "桥梁架设装备整机模型", | |||
| "min": [ | |||
| 2.39, | |||
| -0.41, | |||
| -11.04 | |||
| ], | |||
| "max": [ | |||
| 9.45, | |||
| 5.83, | |||
| 8.4 | |||
| ] | |||
| } | |||
| ], | |||
| "labelSamples": [ | |||
| { | |||
| "targetId": "scene:OBJ-1789139545785-15", | |||
| "targetName": "", | |||
| "partName": "桥梁架设装备整机模型" | |||
| }, | |||
| { | |||
| "targetId": "scene:OBJ-1789139545785-15/part/interaction-ed434099", | |||
| "targetName": "", | |||
| "partName": "Object3D" | |||
| }, | |||
| { | |||
| "targetId": "scene:OBJ-1789139545785-15/part/interaction-ec433f06", | |||
| "targetName": "", | |||
| "partName": "Object3D" | |||
| }, | |||
| { | |||
| "targetId": "scene:OBJ-1789139545785-15/part/interaction-eb433d73", | |||
| "targetName": "", | |||
| "partName": "Object3D" | |||
| } | |||
| ] | |||
| } | |||
| }, | |||
| { | |||
| "name": "卡车:正式工作区播放四种已编排动画轨道并保持末帧", | |||
| "pass": true, | |||
| "details": { | |||
| "modelId": "imported-model_mto6wi6k_08-cesiummilktruck", | |||
| "versionId": "479", | |||
| "time": 4, | |||
| "bindings": 4, | |||
| "unresolved": 0, | |||
| "tracks": [ | |||
| { | |||
| "id": "rotate", | |||
| "property": "rotation.x", | |||
| "position": [ | |||
| -1.5407439555097887e-33, | |||
| 0.5, | |||
| -1.6554003917764114e-18 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 1 | |||
| }, | |||
| { | |||
| "id": "move", | |||
| "property": "position", | |||
| "position": [ | |||
| -1.5407439555097887e-33, | |||
| 0.5, | |||
| -1.6554003917764114e-18 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 1 | |||
| }, | |||
| { | |||
| "id": "state", | |||
| "property": "material.state", | |||
| "position": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 0.2 | |||
| }, | |||
| { | |||
| "id": "flow", | |||
| "property": "flow", | |||
| "position": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 0.2 | |||
| } | |||
| ], | |||
| "flowVisible": true, | |||
| "native": [ | |||
| { | |||
| "name": "Wheels", | |||
| "time": 0 | |||
| } | |||
| ] | |||
| } | |||
| }, | |||
| { | |||
| "name": "学员:工具、安全、测量记录通过真实接口保存并刷新恢复", | |||
| "pass": true, | |||
| "details": { | |||
| "runId": "77" | |||
| } | |||
| }, | |||
| { | |||
| "name": "进度请求失败时不在本地提前完成步骤", | |||
| "pass": true | |||
| }, | |||
| { | |||
| "name": "步骤完成服务器确认后推进,下一步动画按发布片段复位", | |||
| "pass": true, | |||
| "details": { | |||
| "modelId": "imported-model_mto6wi6k_08-cesiummilktruck", | |||
| "versionId": "479", | |||
| "time": 0, | |||
| "bindings": 4, | |||
| "unresolved": 0, | |||
| "tracks": [ | |||
| { | |||
| "id": "rotate", | |||
| "property": "rotation.x", | |||
| "position": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 1 | |||
| }, | |||
| { | |||
| "id": "move", | |||
| "property": "position", | |||
| "position": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 1 | |||
| }, | |||
| { | |||
| "id": "state", | |||
| "property": "material.state", | |||
| "position": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 0.95 | |||
| }, | |||
| { | |||
| "id": "flow", | |||
| "property": "flow", | |||
| "position": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "base": [ | |||
| 0, | |||
| 0, | |||
| 0 | |||
| ], | |||
| "roughness": 0.95 | |||
| } | |||
| ], | |||
| "flowVisible": false, | |||
| "native": [ | |||
| { | |||
| "name": "Wheels", | |||
| "time": 0 | |||
| } | |||
| ] | |||
| } | |||
| }, | |||
| { | |||
| "name": "真实两步操作完成、服务端判定、提交结果闭环", | |||
| "pass": true, | |||
| "details": { | |||
| "runId": "77" | |||
| } | |||
| }, | |||
| { | |||
| "name": "维护账号观察他人运行时只读,不修改学员进度", | |||
| "pass": true | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| { | |||
| "userId": "155", | |||
| "assignmentId": "78", | |||
| "runId": "77" | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>虚拟仿真 JS+HTML 正式工作区验收</title><style> | |||
| body{margin:0;background:#edf5f3;color:#173d39;font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1250px;margin:auto;padding:28px}section,figure{background:white;border:1px solid #cbded9;border-radius:10px;padding:22px;margin:18px 0}h1{font-size:27px;margin:0}h2{font-size:19px;margin:0 0 12px}a{color:#087e69}table{border-collapse:collapse;width:100%}td,th{padding:10px;border:1px solid #d9e7e3;text-align:left}.pass{color:#087e69;font-weight:bold}img{display:block;width:100%;height:auto;border:1px solid #d9e7e3}figcaption{font-weight:600;margin-bottom:12px}code{background:#eef5f3;padding:2px 5px;word-break:break-word}.meta{color:#57756e}li{margin:6px 0}</style><main> | |||
| <h1>虚拟仿真 JS+HTML 正式工作区验收</h1><p class="meta">2026-09-12 · 开发环境 · Edge 无头浏览器 · 1920×1080、1440×900</p> | |||
| <section><h2>实施结果</h2><p>虚拟仿真的运行、预览使用 JS+HTML 工作区。Vue 负责平台路由、认证、挂载和接口适配;任务列表继续使用正式教学接口。已有 /api、/legacy 运行与预览地址统一进入同一工作区。本次通过增量修改实现,没有 Git 回滚。</p><ul> | |||
| <li>恢复结构树、聚焦、复位视角、标签、高亮问题设备、隐藏其他、爆炸视图与展开距离控制,以及左下角数字教员形象。</li> | |||
| <li>场景来自任务固定发布版本;保留稳定部件 ID、模型变换、材质、相机、灯光与原生场景对象。禁止缺资源时替换成示例装备。</li> | |||
| <li>模型编辑器编排的旋转/枢轴、位移、材质状态和流向动画复用同一回放实现;片段保持末帧或复位。展开爆炸图时停止动画,收起后重播当前片段。</li> | |||
| <li>工具、安全、测量记录接入服务端检查点;真实点击/拖动经接口确认后推进。请求失败保留当前步骤;所有步骤完成后调用正式提交接口。</li> | |||
| <li>数字人服务生成或资源库导入的成片,使用任务授权资源播放;教员预览不生成学员成绩,观察其他人的运行只读。</li> | |||
| <li>准备面板放在左侧步骤栏,限制展开高度;长标题省略,避免遮挡数字教员和查看工具。</li> | |||
| </ul></section> | |||
| <section><h2>实测 9 项通过</h2><table><thead><tr><th>检查</th><th>结果</th></tr></thead><tbody><tr><td>桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员</td><td class="pass">通过</td></tr><tr><td>卡车:正式工作区播放四种已编排动画轨道并保持末帧</td><td class="pass">通过</td></tr><tr><td>学员:工具、安全、测量记录通过真实接口保存并刷新恢复</td><td class="pass">通过</td></tr><tr><td>进度请求失败时不在本地提前完成步骤</td><td class="pass">通过</td></tr><tr><td>步骤完成服务器确认后推进,下一步动画按发布片段复位</td><td class="pass">通过</td></tr><tr><td>真实两步操作完成、服务端判定、提交结果闭环</td><td class="pass">通过</td></tr><tr><td>维护账号观察他人运行时只读,不修改学员进度</td><td class="pass">通过</td></tr><tr><td>狐狸历史固定场景与 GLB 原生动画资源加载;1440×900 布局</td><td class="pass">通过</td></tr><tr><td>数字人服务成片通过任务授权资源播放</td><td class="pass">通过</td></tr></tbody></table><p>浏览器未捕获未处理 JavaScript 异常。断网用例实际拦截一次进度请求,验证失败时服务端和界面均未完成该步骤,随后重试成功。</p></section> | |||
| <section><h2>构建与自动检查</h2><ul><li><code>pnpm build</code>:包含 Vue 类型检查、生产构建及模型编辑器发布产物验证,通过。</li><li>路由、检查点、多工具、步骤事实与命令幂等相关检查 20 项通过;原生动画绑定、末帧保持和停止释放检查 1 项通过。</li><li>后端完整 Maven reactor <code>mvn test</code> 通过;新增学员视觉投影测试,验证动画和材质状态保留,自定义组件与私有字段不下发。</li></ul><p>无数据库或 DDL 变更。部署时前端与 Tran 服务一起更新。正式考试专用运行入口保持原有实现,本报告不将它计入本次迁移验收。</p></section> | |||
| <section><h2>保留的验收记录</h2><p>学员 ID <code>141</code>;任务 ID <code>66</code>;运行 ID <code>67</code>,状态已提交。媒体预览任务:<code>67</code>、<code>68</code>。开发过程中使用独立验收副本,原训练内容未覆盖。</p><p>狐狸历史训练没有指定动画片段,因此实测验证原生 Survey / Walk / Run 资源完整加载;原生片段实际回放、末帧保持、复位与释放另由自动测试验证。</p><p>原始结果:<a href="checks.json">操作检查</a> · <a href="media-checks.json">媒体检查</a> · <a href="fixture.json">测试记录 ID</a></p></section> | |||
| <figure><figcaption>桥梁:结构树、聚焦、目标高亮、标签与数字教员</figcaption><a href="01-bridge-tools.png"><img src="01-bridge-tools.png" alt="桥梁:结构树、聚焦、目标高亮、标签与数字教员" loading="lazy"></a></figure><figure><figcaption>桥梁:真实部件爆炸视图</figcaption><a href="02-bridge-explosion.png"><img src="02-bridge-explosion.png" alt="桥梁:真实部件爆炸视图" loading="lazy"></a></figure><figure><figcaption>学员:工具、安全与测量记录刷新恢复</figcaption><a href="03-learner-preparation-restored.png"><img src="03-learner-preparation-restored.png" alt="学员:工具、安全与测量记录刷新恢复" loading="lazy"></a></figure><figure><figcaption>完成真实三维操作,提交服务端</figcaption><a href="04-submitted.png"><img src="04-submitted.png" alt="完成真实三维操作,提交服务端" loading="lazy"></a></figure><figure><figcaption>维护账号只读观察学员运行</figcaption><a href="05-observer.png"><img src="05-observer.png" alt="维护账号只读观察学员运行" loading="lazy"></a></figure><figure><figcaption>狐狸:历史发布场景五个对象完整加载,1440×900 布局</figcaption><a href="06-fox-native.png"><img src="06-fox-native.png" alt="狐狸:历史发布场景五个对象完整加载,1440×900 布局" loading="lazy"></a></figure><figure><figcaption>数字人讲解:播放已保存并授权给任务的成片</figcaption><a href="07-digital-human-video.png"><img src="07-digital-human-video.png" alt="数字人讲解:播放已保存并授权给任务的成片" loading="lazy"></a></figure> | |||
| </main></html> | |||
| @@ -0,0 +1,34 @@ | |||
| { | |||
| "checks": [ | |||
| { | |||
| "name": "狐狸历史固定场景与 GLB 原生动画资源加载;1440×900 布局", | |||
| "pass": true, | |||
| "assignmentId": "67", | |||
| "clips": [ | |||
| { | |||
| "name": "Survey", | |||
| "time": 0 | |||
| }, | |||
| { | |||
| "name": "Walk", | |||
| "time": 0 | |||
| }, | |||
| { | |||
| "name": "Run", | |||
| "time": 0 | |||
| } | |||
| ] | |||
| }, | |||
| { | |||
| "name": "数字人服务成片通过任务授权资源播放", | |||
| "pass": true, | |||
| "assignmentId": "68", | |||
| "media": { | |||
| "duration": 4.6, | |||
| "readyState": 4, | |||
| "authorizedBlob": true | |||
| } | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| const root=path.resolve('reports/virtual-html-full-closure-20260912'); | |||
| const read=name=>JSON.parse(fs.readFileSync(path.join(root,name),'utf8')); | |||
| const exam=read('exam-checks.json'),workflow=read('workflow-checks.json'),backend=read('backend-tests.json'),cleanup=read('cleanup.json'); | |||
| const regression=JSON.parse(fs.readFileSync('reports/virtual-html-workspace-20260912/checks.json','utf8')); | |||
| const checks=[...exam.checks,...workflow.checks,...regression.checks]; | |||
| if(checks.some(check=>!check.pass))throw new Error('Do not publish a passing report while a required live check is failing'); | |||
| const escape=value=>String(value??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); | |||
| const fixture=read('exam-fixture.json'),flowFixture=read('workflow-fixture.json'); | |||
| const screenshots=[['01-exam-current-step.png','正式考试:当前安全投影与实际三维选择'],['03-workflow-waiting.png','高级编排:等待外部事件,超时保持阻断'],['04-teacher-workflow-event.png','教员页面确认外部事件'],['05-workflow-submitted.png','高级编排路径完成并提交'],['06-teacher-exam-review.png','教员查看逐步考试证据并评分'],['07-exam-analytics.png','数据中心查询成绩与记录详情'],['08-exam-deadline.png','考试到期关闭工作区']]; | |||
| const html=`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>JS+HTML 虚拟训练完整流程验收</title><style> | |||
| *{box-sizing:border-box}body{margin:0;background:#eef5f3;color:#193b35;font:15px/1.75 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1320px;margin:28px auto;padding:0 24px}header,section{background:white;border:1px solid #c8ded7;border-radius:12px;padding:24px;margin-bottom:20px}h1{font-size:28px;margin:0 0 8px}h2{font-size:20px;margin:0 0 14px}p{margin:8px 0}.pass{color:#087c60;font-weight:700}.meta{color:#58786d}table{width:100%;border-collapse:collapse}td,th{border:1px solid #dce9e4;padding:10px 14px;text-align:left;vertical-align:top}th{background:#f1f7f4}.shots{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;border:1px solid #dce9e4;border-radius:8px;overflow:hidden}figure img{width:100%;display:block}figcaption{padding:10px 14px}a{color:#087c60}code{overflow-wrap:anywhere}details{margin:14px 0}pre{white-space:pre-wrap;max-height:300px;overflow:auto;background:#f4f8f6;padding:14px}@media(max-width:800px){.shots{grid-template-columns:1fr}main{padding:0 12px}section{padding:16px}} | |||
| </style><main><header><h1>JS+HTML 虚拟训练完整流程验收</h1><p class="pass">${checks.length} 项实测检查通过 · 111 项前端契约测试通过 · 552 项后端测试通过</p><p class="meta">时间:${escape(new Date().toLocaleString('zh-CN'))} · 环境:本机 6180 Web → 6100 Gateway → Tran/Auth · Edge 无头浏览器真实交互</p><p>本轮覆盖虚拟训练高级编排、虚拟通道正式考试、教员评定与数据中心,并回归既有 JS+HTML 运行/预览。所有训练进度、交卷、评定及编排状态由真实接口处理。</p></header> | |||
| <section><h2>迁移结果</h2><table><tr><th>环节</th><th>实现与验证</th></tr> | |||
| <tr><td>高级编排</td><td>服务端按发布连线执行开始、步骤、结束、条件、安全、事件等待、故障注入/清除、建议评分、相机及音频节点。等待与超时阻止推进;分支跳过记为 SKIPPED;刷新恢复;教员页面确认事件;相同命令重放不重复推进。</td></tr> | |||
| <tr><td>正式考试</td><td>虚拟通道使用 JS+HTML 工作区,逐步获取 execution 安全投影;关闭帮助、数字人、答案高亮、爆炸和回放。三维选择后提交,交卷后关闭执行资源;到期关闭工作区并由服务端拒绝资源访问。非虚拟通道保留设备/岗位推进规则。</td></tr> | |||
| <tr><td>评分与统计</td><td>服务端逐步保留考试操作证据;教员在评定弹窗查看并打分,学员不能自评或获取私有证据聚合。数据中心页面可查询成绩及详情。</td></tr> | |||
| <tr><td>普通训练回归</td><td>桥梁结构树、聚焦、高亮、隐藏、标签、爆炸、复位、数字教员;卡车四类动画与复位;工具、安全和测量记录恢复;真实点击与拖动;网络失败不提前推进,恢复后重试;他人运行只读观察。</td></tr></table></section> | |||
| <section><h2>真实流程检查</h2><table><tr><th>检查</th><th>结果</th></tr>${checks.map(c=>`<tr><td>${escape(c.name)}</td><td class="pass">通过</td></tr>`).join('')}</table></section> | |||
| <section><h2>验证命令与证据</h2><p><code>pnpm run build</code>(含 vue-tsc 与正式资源构建校验);<code>node --test</code> 执行 teaching/virtual/training 的 *.test.mjs;Java 17 下 <code>mvn test</code>。</p><table><tr><th>后端模块</th><th>测试数</th><th>失败 / 错误</th></tr>${Object.entries(backend).map(([name,value])=>`<tr><td>${name}</td><td>${value.tests}</td><td>${value.failures} / ${value.errors}</td></tr>`).join('')}</table><p>构建存在现有大分块提示。原型同步检查提示 upstream 与同步基线存在差异,本次未执行覆盖式原型同步。</p><p>脚本:<code>virtual-html-exam-live.mjs</code>、<code>virtual-html-workflow-live.mjs</code>、<code>virtual-html-live.mjs</code>。原始结果:<a href="exam-checks.json">考试</a>、<a href="workflow-checks.json">编排</a>、<a href="../virtual-html-workspace-20260912/checks.json">普通训练回归</a>。</p></section> | |||
| <section><h2>数据库与使用边界</h2><p>已在开发库执行并重复验证两项增量:040 扩展 SKIPPED 步骤状态,041 扩展编排命令幂等回执类型。完整 SQL、数据库设计与接口文档同步更新;其他环境需先执行增量再更新服务。</p><p><a href="migration.json">040 执行记录与原约束</a> · <a href="migration-commands.json">041 执行记录与原约束</a></p><p>外部事件本轮通过教员页面及授权 HTTP 接口验证,未连接现场真实设备;故障状态用于服务端流程判定,不能据此宣称已完成真实设备故障模拟。普通教员预览沿用连续步骤预演接口,高级分支验收在真实学员运行中完成。</p></section> | |||
| <section><h2>保留的验收数据</h2><p>最新正式考试:任务 ${fixture.assignmentId} / 运行 ${fixture.runId}(86 分);截止时间测试:任务 ${fixture.deadlineAssignmentId} / 运行 ${fixture.deadlineRunId};高级编排:任务 ${flowFixture.assignmentId} / 运行 ${flowFixture.runId}(91 分)。</p><p>已停用 ${cleanup.disabledTestUserIds.length} 个不用的临时测试账号,保留最新 ${cleanup.retainedTestUserIds.length} 个验收账号。过程记录、失败重试记录与历史任务保留供审计和复查,未为了清理而补造评定。</p><p><a href="retained-fixtures.json">完整测试数据清单</a> · <a href="cleanup.json">账号清理结果</a></p></section> | |||
| <section><h2>页面证据</h2><div class="shots">${screenshots.map(([file,label])=>`<figure><a href="${file}" target="_blank"><img loading="lazy" src="${file}" alt="${escape(label)}"></a><figcaption>${escape(label)}</figcaption></figure>`).join('')}</div></section></main></html>`; | |||
| fs.writeFileSync(path.join(root,'index.html'),html); | |||
| console.log('Report written:',root,';',checks.length,'live checks passed'); | |||
| @@ -0,0 +1,87 @@ | |||
| import fs from 'node:fs';import crypto from 'node:crypto';import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test';import {login,call} from './role-permissions-live.mjs'; | |||
| const report='reports/virtual-html-full-closure-20260912';fs.mkdirSync(report,{recursive:true}); | |||
| const admin=await login('root'),checks=[],errors=[]; | |||
| const api=async(endpoint,session=admin,body,method)=>{const r=await call(endpoint,session?.accessToken,body,method);assert.equal(r.status,200,endpoint+': '+r.message);return r.data}; | |||
| const suffix=Date.now().toString(36),password='Ex!'+crypto.randomBytes(8).toString('hex'); | |||
| const roles=await api('auth/v1/roles?size=100'),role=(roles.records||roles).find(r=>r.code==='student'); | |||
| const user=await api('auth/v1/users',admin,{username:'ex_'+suffix,displayName:'HTML考试验收-'+suffix,departmentId:'5',roleIds:[role.id],defaultRoleId:role.id,enabled:true,password}); | |||
| const student=await api('auth/v1/auth/login',null,{userId:(user.user||user).id,roleCode:'student',password,rememberMe:false}); | |||
| const source=(await api('tran/v1/content/projects/161')).project,now=Math.floor(Date.now()/1000); | |||
| const task=await api('tran/v1/teaching/assignments',admin,{code:'HTML-EX-'+suffix,name:'HTML正式考试-'+suffix,description:'正式考试迁移与评定统计验收',channel:'VIRTUAL',assignmentKind:'FORMAL_EXAM',executionMode:'EXAM',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:false,contentProjectId:source.id,contentVersionId:source.publishedVersionId,scheduleStartAt:now-60,dueAt:now+3600,members:[{userId:(user.user||user).id,memberType:'LEARNER',teamCode:'NEUTRAL',positionCode:'LEARNER',sortOrder:0}]}); | |||
| const published=await api(`tran/v1/teaching/assignments/${task.id}/publish`,admin,{version:task.version}); | |||
| const run=await api(`tran/v1/teaching/assignments/${task.id}/accept`,student,{assignmentVersion:published.version,teamCode:'NEUTRAL',positionCode:'LEARNER'}); | |||
| fs.writeFileSync(report+'/exam-fixture.json',JSON.stringify({userId:(user.user||user).id,assignmentId:task.id,runId:run.id},null,2)); | |||
| const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); | |||
| const context=await browser.newContext({viewport:{width:1920,height:1080}}); | |||
| await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},student); | |||
| const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); | |||
| const pass=(name,details)=>{checks.push({name,pass:true,details});console.log('PASS',name)}; | |||
| try{ | |||
| await page.goto(`http://127.0.0.1:6180/teaching/exams/runs/${run.id}`); | |||
| await page.locator('.training-shell[data-formal-exam="true"]').waitFor({timeout:120000}); | |||
| const details=await api(`tran/v1/teaching/assignments/${task.id}`,student); | |||
| assert.ok(!details.definitionSnapshot?.steps?.length,'Exam assignment must not disclose steps'); | |||
| const execution=await api(`tran/v1/teaching/runs/${run.id}/execution`,student); | |||
| assert.equal(execution.currentStep.targetId,undefined);assert.ok(execution.deadlineAt>execution.serverNow); | |||
| await expect(page.locator('[data-role="teacher-panel"]')).toBeHidden(); | |||
| await expect(page.locator('[data-action="toggle-issue-highlight"]')).toBeHidden(); | |||
| pass('正式考试进入 JS+HTML,仅读取服务端当前步骤,关闭数字人和目标提示'); | |||
| for(let index=0;index<execution.totalSteps;index++){ | |||
| const currentProjection=await api(`tran/v1/teaching/runs/${run.id}/execution`,student); | |||
| await expect(page.locator('.training-step-copy strong')).toHaveText(currentProjection.currentStep.title); | |||
| await expect.poll(async()=>Boolean(await page.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()?.publishedContentMounted)),{timeout:90000}).toBe(true); | |||
| const button=page.locator('[data-exam-confirm]'),canvas=page.locator('canvas.training-canvas'); | |||
| await expect(button).toBeDisabled();const rect=await canvas.boundingBox(); | |||
| for(const [x,y] of [[.29,.4],[.3,.44],[.27,.42],[.5,.55],[.55,.6],[.5,.7],[.65,.65],[.45,.5]]){const px=rect.x+rect.width*x,py=rect.y+rect.height*y;await page.mouse.click(px,py);if(await button.isEnabled())break;await page.waitForTimeout(150);} | |||
| await expect(button).toBeEnabled(); | |||
| if(!index)await page.screenshot({path:report+'/01-exam-current-step.png',fullPage:true}); | |||
| await button.click(); | |||
| await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,student)).progressPercent,{timeout:20000}).toBe(Math.round((index+1)*100/execution.totalSteps)); | |||
| if(index+1<execution.totalSteps)await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}/execution`,student)).currentStepIndex).toBe(index+1); | |||
| } | |||
| await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,student)).status,{timeout:20000}).toBe('SUBMITTED'); | |||
| pass('实际三维选择、逐步提交、重新获取下一安全投影、最终交卷'); | |||
| await page.screenshot({path:report+'/02-exam-submitted.png',fullPage:true}); | |||
| const submitted=await api(`tran/v1/teaching/runs/${run.id}`,admin); | |||
| const denied=await call(`tran/v1/teaching/runs/${run.id}/review`,student.accessToken,{version:submitted.version,score:100,passed:true,feedback:'越权测试',commandId:crypto.randomUUID()});assert.equal(denied.status,403); | |||
| assert.equal(submitted.checkpoint.examObservations.length,execution.totalSteps,'All step observations retained for teacher'); | |||
| const teacherContext=await browser.newContext({viewport:{width:1920,height:1080}});await teacherContext.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},admin); | |||
| const teacher=await teacherContext.newPage();await teacher.goto('http://127.0.0.1:6180/teaching/exams'); | |||
| await teacher.getByRole('button',{name:'查看',exact:true}).first().waitFor({timeout:60000}); | |||
| await teacher.getByPlaceholder('请输入任务名称或编码').fill(task.name);await teacher.getByPlaceholder('请输入任务名称或编码').press('Enter'); | |||
| await teacher.locator('article').filter({has:teacher.getByRole('heading',{name:task.name,exact:true})}).getByRole('button',{name:'查看',exact:true}).click(); | |||
| await teacher.locator('.el-drawer').getByRole('button',{name:'评定',exact:true}).click(); | |||
| const dialog=teacher.getByRole('dialog',{name:'教学任务评定'});await expect(dialog).toContainText('考核操作记录'); | |||
| await dialog.getByText('已核对本次运行的评定依据',{exact:true}).click();await dialog.getByRole('spinbutton').fill('86'); | |||
| await dialog.getByText('通过',{exact:true}).click();await dialog.getByPlaceholder('请输入评定意见').fill('HTML正式考试操作已核验'); | |||
| await teacher.screenshot({path:report+'/06-teacher-exam-review.png',fullPage:true});await dialog.getByRole('button',{name:'提交评定'}).click(); | |||
| await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,admin)).status).toBe('REVIEWED'); | |||
| const reviewed=await api(`tran/v1/teaching/runs/${run.id}`,admin); | |||
| assert.equal(reviewed.status,'REVIEWED');assert.equal(reviewed.passed,true);assert.equal(reviewed.score,86); | |||
| const records=await api(`tran/v1/teaching/analytics/records?keyword=${encodeURIComponent(task.name)}&assignmentKind=FORMAL_EXAM&page=1&size=20`,admin); | |||
| assert.ok(records.records.some(r=>String(r.runId||r.id)===String(run.id))); | |||
| const learnerResult=await api(`tran/v1/teaching/runs/${run.id}`,student);assert.equal(learnerResult.status,'REVIEWED');assert.equal(learnerResult.checkpoint.examObservations,undefined); | |||
| pass('学员不能自评;教员评分后学员可见,统计明细包含本次运行',{runId:run.id,score:86}); | |||
| await teacher.goto('http://127.0.0.1:6180/teaching/data-center'); | |||
| await teacher.getByTestId('analytics-tab-exam').click();await teacher.getByPlaceholder('搜索学员、编号或任务名称').fill(task.name); | |||
| await teacher.getByPlaceholder('搜索学员、编号或任务名称').press('Enter'); | |||
| const table=teacher.getByTestId('analytics-record-table');await expect(table).toContainText(task.name,{timeout:30000});await expect(table).toContainText('86 分'); | |||
| await table.getByRole('button',{name:'详情',exact:true}).first().click();await teacher.screenshot({path:report+'/07-exam-analytics.png',fullPage:true}); | |||
| pass('数据中心页面查询正式考核成绩与记录详情'); | |||
| const history=await call(`tran/v1/teaching/runs/${run.id}/execution`,student.accessToken);assert.notEqual(history.status,200); | |||
| const deadlineNow=Math.floor(Date.now()/1000); | |||
| const timedTask=await api('tran/v1/teaching/assignments',admin,{code:'HTML-TIME-'+suffix,name:'HTML考试截止验收-'+suffix,description:'安全边界验收',channel:'VIRTUAL',assignmentKind:'FORMAL_EXAM',executionMode:'EXAM',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:false,contentProjectId:source.id,contentVersionId:source.publishedVersionId,scheduleStartAt:deadlineNow-5,dueAt:deadlineNow+25,members:[{userId:(user.user||user).id,memberType:'LEARNER',teamCode:'NEUTRAL',positionCode:'LEARNER',sortOrder:0}]}); | |||
| const timedPublished=await api(`tran/v1/teaching/assignments/${timedTask.id}/publish`,admin,{version:timedTask.version}); | |||
| const timedRun=await api(`tran/v1/teaching/assignments/${timedTask.id}/accept`,student,{assignmentVersion:timedPublished.version,teamCode:'NEUTRAL',positionCode:'LEARNER'}); | |||
| const fixture=JSON.parse(fs.readFileSync(report+'/exam-fixture.json','utf8'));Object.assign(fixture,{deadlineAssignmentId:timedTask.id,deadlineRunId:timedRun.id});fs.writeFileSync(report+'/exam-fixture.json',JSON.stringify(fixture,null,2)); | |||
| await page.goto(`http://127.0.0.1:6180/teaching/exams/runs/${timedRun.id}`);await page.locator('.training-shell').waitFor({timeout:20000}); | |||
| await expect(page.getByRole('button',{name:'返回考试测评',exact:true})).toBeVisible({timeout:35000}); | |||
| const expiredProjection=await call(`tran/v1/teaching/runs/${timedRun.id}/execution`,student.accessToken);assert.notEqual(expiredProjection.status,200); | |||
| const expiredAssets=await call(`tran/v1/teaching/runs/${timedRun.id}/assets`,student.accessToken);assert.notEqual(expiredAssets.status,200); | |||
| await page.screenshot({path:report+'/08-exam-deadline.png',fullPage:true}); | |||
| const expiredRun=await api(`tran/v1/teaching/runs/${timedRun.id}`,admin);await api(`tran/v1/teaching/runs/${timedRun.id}/timeout`,admin,{version:expiredRun.version}); | |||
| pass('考试到期自动关闭工作区;服务端拒绝继续获取执行投影和资产,教员完成超时结算'); | |||
| assert.equal(errors.length,0,errors.join('\n')); | |||
| }catch(error){checks.push({name:'考试、评分和统计验收',pass:false,error:error.message});console.error(error);process.exitCode=1;await page.screenshot({path:report+'/exam-failure.png',fullPage:true})} | |||
| finally{fs.writeFileSync(report+'/exam-checks.json',JSON.stringify({checks,errors},null,2));await browser.close()} | |||
| @@ -0,0 +1,18 @@ | |||
| import fs from 'node:fs'; | |||
| import {chromium} from '@playwright/test'; | |||
| import {login,call} from './role-permissions-live.mjs'; | |||
| const session=await login('root'); | |||
| const report='reports/virtual-html-workspace-20260912';fs.mkdirSync(report,{recursive:true}); | |||
| const tasks=await call('tran/v1/teaching/assignments?channel=VIRTUAL&assignmentKind=TRAINING&page=1&size=100',session.accessToken); | |||
| console.log(JSON.stringify(tasks.data.records.map(t=>({id:t.id,name:t.name,status:t.status})),null,2)); | |||
| const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); | |||
| const context=await browser.newContext({viewport:{width:1920,height:1080}}); | |||
| await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session); | |||
| const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message)); | |||
| await page.goto('http://127.0.0.1:6180/teaching/virtual-training/preview/'+(process.argv[2]||'55')); | |||
| await page.locator('.training-shell').waitFor({timeout:120000}).catch(error=>console.log('WAIT',error.message)); | |||
| await page.waitForTimeout(10000); | |||
| console.log('DIAGNOSTICS',JSON.stringify(await page.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()))); | |||
| console.log('PAGE', (await page.locator('body').innerText()).slice(-5000));console.log('ERRORS',errors); | |||
| await page.screenshot({path:report+'/initial.png',fullPage:true}); | |||
| await browser.close(); | |||
| @@ -0,0 +1,114 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import crypto from 'node:crypto'; | |||
| import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test'; | |||
| import {login,call} from './role-permissions-live.mjs'; | |||
| const report=path.resolve('reports/virtual-html-workspace-20260912');fs.mkdirSync(report,{recursive:true}); | |||
| const checks=[],errors=[],admin=await login('root'); | |||
| const api=async(endpoint,session=admin,body,method)=>{const r=await call(endpoint,session?.accessToken,body,method);assert.equal(r.status,200,`${endpoint}: ${r.message}`);return r.data;}; | |||
| const checked=(name,details)=>{checks.push({name,pass:true,details});console.log('PASS',name);}; | |||
| const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); | |||
| const createContext=async(session,width=1920)=>{const ctx=await browser.newContext({viewport:{width,height:1080}});await ctx.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'})); | |||
| },session);return ctx;}; | |||
| const context=await createContext(admin),page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); | |||
| const diagnostic=p=>p.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()); | |||
| const open=async(p,url)=>{await p.goto('http://127.0.0.1:6180'+url);await p.locator('.training-shell').waitFor({timeout:120000});await expect.poll(async()=>Boolean((await diagnostic(p))?.publishedContentMounted),{timeout:90000}).toBe(true);}; | |||
| const screenshot=async(p,name)=>{await p.screenshot({path:path.join(report,name+'.png'),fullPage:true});}; | |||
| let studentPage; | |||
| try { | |||
| await open(page,'/teaching/virtual-training/preview/54'); | |||
| await expect(page.locator('[data-role="model-commandbar"]')).toBeVisible(); | |||
| 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(page.locator(`[data-role="model-commandbar"] [data-action="${action}"]`)).toBeVisible(); | |||
| await page.locator('[data-role="model-commandbar"] [data-action="toggle-structure-tree"]').click(); | |||
| const tree=page.locator('[data-role="structure-tree"]');await expect(tree).toHaveClass(/is-open/); | |||
| assert.ok((await tree.locator('[data-action="select-structure-part"]').count())>20); | |||
| await tree.locator('[data-action="select-structure-part"]').first().click(); | |||
| await page.locator('[data-action="focus-selected-part"]').click(); | |||
| await page.locator('[data-role="model-commandbar"] [data-action="toggle-structure-tree"]').click(); | |||
| await page.locator('[data-action="toggle-issue-highlight"]').click(); | |||
| await expect.poll(async()=>(await diagnostic(page)).framing.keptTargets.length).toBeGreaterThan(0); | |||
| await page.locator('[data-action="toggle-hide-others"]').click(); | |||
| assert.equal((await diagnostic(page)).framing.hideOthers,true); | |||
| await page.locator('[data-action="toggle-hide-others"]').click(); | |||
| await page.locator('[data-action="toggle-part-labels"]').click(); | |||
| assert.equal((await diagnostic(page)).framing.labelsVisible,true); | |||
| await screenshot(page,'01-bridge-tools'); | |||
| await page.locator('[data-action="toggle-explosion"]').click(); | |||
| await screenshot(page,'02-bridge-explosion'); | |||
| await page.locator('[data-action="viewer-reset-view"]').click(); | |||
| await expect(page.locator('[data-role="teacher-image"]')).toBeVisible(); | |||
| const bridge=await diagnostic(page);assert.equal(bridge.proceduralMachineVisible,false);assert.ok(bridge.server.registeredTargets.includes(bridge.framing.targetOnScreen.targetId)); | |||
| checked('桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员',bridge.framing); | |||
| const suffix=Date.now().toString(36),password='Vh!'+crypto.randomBytes(8).toString('hex'); | |||
| const roles=await api('auth/v1/roles?size=100');const role=(roles.records||roles).find(r=>r.code==='student'); | |||
| const user=await api('auth/v1/users',admin,{username:'vh_'+suffix,displayName:'HTML验收-'+suffix,departmentId:'5',roleIds:[role.id],defaultRoleId:role.id,enabled:true,password,remark:'JS/HTML正式工作区隔离测试账号'}); | |||
| const learner=await api('auth/v1/auth/login',null,{userId:(user.user||user).id,roleCode:'student',password,rememberMe:false}); | |||
| const source=await api('tran/v1/content/projects/419'); | |||
| source.currentVersion.content.steps.forEach((step,index)=>{ | |||
| step.mode=index===0?'measure':'remove';step.interaction=index===0?'rule-check':'spatial-drag';step.targetNodeId=step.targetId; | |||
| step.destination=index===0?'压力测量检查':'编号存放区'; | |||
| step.action={...step.action,kind:step.interaction,mode:step.mode,targetId:step.targetId,requiresPlacement:index===1,tolerance:.55}; | |||
| }); | |||
| const content=await api('tran/v1/content/projects',admin,{type:'TRAINING',name:'HTML闭环验收-'+suffix,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content:source.currentVersion.content,dependencies:source.currentVersion.dependencies,assets:[]}); | |||
| await api(`tran/v1/content/projects/${content.project.id}/publish`,admin,{version:content.project.version}); | |||
| const reference=(await api(`tran/v1/content/projects/${content.project.id}`)).project; | |||
| const task=await api('tran/v1/teaching/assignments',admin,{code:'HTML-'+suffix,name:'HTML工作区-卡车动画闭环-'+suffix,description:'开发环境真实操作、断点恢复和动画验收',channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'PRACTICE',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:true,contentProjectId:reference.id,contentVersionId:reference.publishedVersionId,members:[{userId:(user.user||user).id,memberType:'LEARNER',teamCode:'NEUTRAL',positionCode:'LEARNER',sortOrder:0}]}); | |||
| const published=await api(`tran/v1/teaching/assignments/${task.id}/publish`,admin,{version:task.version}); | |||
| const run=await api(`tran/v1/teaching/assignments/${task.id}/accept`,learner,{assignmentVersion:published.version,teamCode:'NEUTRAL',positionCode:'LEARNER'}); | |||
| fs.writeFileSync(path.join(report,'fixture.json'),JSON.stringify({userId:(user.user||user).id,assignmentId:task.id,runId:run.id},null,2)); | |||
| const ctx=await createContext(learner);studentPage=await ctx.newPage();studentPage.on('pageerror',e=>errors.push(e.message)); | |||
| await open(studentPage,`/teaching/virtual-training/runs/${run.id}`); | |||
| await studentPage.waitForTimeout(5200); | |||
| const animation=(await diagnostic(studentPage)).server.animations.find(a=>a.bindings); | |||
| assert.equal(animation.bindings,4);assert.equal(animation.unresolved,0);assert.equal(animation.time,4);assert.equal(animation.flowVisible,true); | |||
| checked('卡车:正式工作区播放四种已编排动画轨道并保持末帧',animation); | |||
| const prepare=async()=>{ | |||
| const panel=studentPage.locator('.training-server-preparation');if(!(await panel.evaluate(p=>p.open)))await panel.locator('summary').click(); | |||
| await panel.locator('[data-tool="pressure-gauge"]').check();for(const checkbox of await panel.locator('[data-safety]').all())await checkbox.check(); | |||
| await panel.locator('textarea').fill('测试测量:压力为零,检查已完成。'); | |||
| await panel.locator('[data-save-preparation]').click();await expect(panel.locator('[data-save-status]')).toHaveText('已保存'); | |||
| }; | |||
| await prepare(); | |||
| await studentPage.reload();await expect.poll(async()=>Boolean((await diagnostic(studentPage))?.publishedContentMounted),{timeout:90000}).toBe(true); | |||
| await studentPage.locator('.training-server-preparation summary').click(); | |||
| await expect(studentPage.locator('[data-tool="pressure-gauge"]')).toBeChecked();await expect(studentPage.locator('[data-measurement]')).toHaveValue('测试测量:压力为零,检查已完成。'); | |||
| await screenshot(studentPage,'03-learner-preparation-restored'); | |||
| checked('学员:工具、安全、测量记录通过真实接口保存并刷新恢复',{runId:run.id}); | |||
| await studentPage.locator('.training-server-preparation summary').click(); | |||
| const clickTarget=async()=>{ | |||
| await studentPage.locator('[data-action="toggle-issue-highlight"]').click(); | |||
| await studentPage.waitForTimeout(500); | |||
| const d=await diagnostic(studentPage),rect=await studentPage.locator('canvas.training-canvas').boundingBox(); | |||
| assert.ok(d.framing.targetOnScreen?.insideCanvas,'Target must be on screen'); | |||
| await studentPage.mouse.click(rect.x+d.framing.targetOnScreen.localX,rect.y+d.framing.targetOnScreen.localY+38); | |||
| }; | |||
| // Lose one progress response before it reaches the API; ensure no local advance. | |||
| let rejected=false; | |||
| await studentPage.route('**/api/tran/v1/teaching/runs/*/progress',async route=>{if(!rejected){rejected=true;await route.abort('failed');}else await route.continue();}); | |||
| await clickTarget();await studentPage.waitForTimeout(1500); | |||
| assert.equal(rejected,true,'An actual progress request must have been attempted'); | |||
| assert.equal((await api(`tran/v1/teaching/runs/${run.id}`,learner)).progressPercent,0); | |||
| assert.equal((await diagnostic(studentPage)).server.completed.length,0); | |||
| checked('进度请求失败时不在本地提前完成步骤'); | |||
| await clickTarget(); | |||
| await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,learner)).progressPercent,{timeout:20000}).toBe(50); | |||
| await expect(studentPage.locator('[data-action="next"]')).toBeEnabled(); | |||
| await studentPage.locator('[data-action="next"]').click();await studentPage.waitForTimeout(5200); | |||
| const reset=(await diagnostic(studentPage)).server.animations.find(a=>a.bindings);assert.equal(reset.time,0);assert.equal(reset.flowVisible,false); | |||
| checked('步骤完成服务器确认后推进,下一步动画按发布片段复位',reset); | |||
| await prepare();await studentPage.locator('.training-server-preparation summary').click(); | |||
| const dragState=await diagnostic(studentPage),canvasRect=await studentPage.locator('canvas.training-canvas').boundingBox(),drop=await studentPage.locator('[data-role="dropzone"]').boundingBox(); | |||
| await studentPage.mouse.move(canvasRect.x+dragState.framing.targetOnScreen.localX,canvasRect.y+dragState.framing.targetOnScreen.localY+38); | |||
| await studentPage.mouse.down();await studentPage.mouse.move(drop.x+drop.width/2,drop.y+drop.height/2,{steps:25});await studentPage.mouse.up(); | |||
| await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,learner)).progressPercent,{timeout:20000}).toBe(100); | |||
| await studentPage.locator('[data-action="next"]').click(); | |||
| await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,learner)).status,{timeout:20000}).toBe('SUBMITTED'); | |||
| await screenshot(studentPage,'04-submitted');checked('真实两步操作完成、服务端判定、提交结果闭环',{runId:run.id}); | |||
| await open(page,`/teaching/virtual-training/runs/${run.id}`);assert.equal((await diagnostic(page)).server.readOnly,true);await screenshot(page,'05-observer'); | |||
| checked('维护账号观察他人运行时只读,不修改学员进度'); | |||
| assert.equal(errors.length,0,errors.join('\n')); | |||
| } catch(error) {checks.push({name:'HTML工作区端到端',pass:false,error:error.message});console.error(error.message);process.exitCode=1;await screenshot(studentPage||page,'failure').catch(()=>{});} | |||
| finally {fs.writeFileSync(path.join(report,'checks.json'),JSON.stringify({checks,errors},null,2));await browser.close();} | |||
| @@ -0,0 +1,57 @@ | |||
| import fs from 'node:fs'; | |||
| import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test'; | |||
| import {login,call} from './role-permissions-live.mjs'; | |||
| const report='reports/virtual-html-workspace-20260912',session=await login('root'),checks=[],errors=[]; | |||
| const reuse=process.argv.includes('--reuse')?JSON.parse(fs.readFileSync(report+'/media-checks.json','utf8')).checks:[]; | |||
| const api=async(endpoint,body)=>{const r=await call(endpoint,session.accessToken,body);assert.equal(r.status,200,r.message);return r.data;}; | |||
| const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); | |||
| const context=await browser.newContext({viewport:{width:1440,height:900}}); | |||
| 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'}));},session); | |||
| const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); | |||
| try { | |||
| for(const id of [161,411]) { | |||
| const source=await api('tran/v1/content/projects/'+id),base=id===411?await api('tran/v1/content/projects/419'):source,content=base.currentVersion.content,suffix=Date.now().toString(36); | |||
| if(id===411)content.steps[0].audio=source.currentVersion.content.steps[0].audio; | |||
| content.trainingMode='VIRTUAL';content.enabledChannels=['VIRTUAL']; | |||
| content.channelProfiles={VIRTUAL:{...(content.channelProfiles?.PHYSICAL||content.modeConfig),type:'VIRTUAL'}}; | |||
| content.modeConfig=content.channelProfiles.VIRTUAL; | |||
| let p=source.project; | |||
| const previous=reuse[id===161?0:1]?.assignmentId; | |||
| if(id!==161&&!previous) { | |||
| const created=await api('tran/v1/content/projects',{type:'TRAINING',name:`HTML媒体验收-${id}-${suffix}`,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content,dependencies:base.currentVersion.dependencies,assets:source.currentVersion.assets.filter(a=>a.code!=='TRAINING_COVER')}); | |||
| await api(`tran/v1/content/projects/${created.project.id}/publish`,{version:created.project.version}); | |||
| p=(await api(`tran/v1/content/projects/${created.project.id}`)).project; | |||
| } | |||
| const task=previous?await api('tran/v1/teaching/assignments/'+previous):await api('tran/v1/teaching/assignments',{code:'HTML-MEDIA-'+suffix,name:p.name,channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'PRACTICE',audienceType:'COMMON',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:true,contentProjectId:p.id,contentVersionId:p.publishedVersionId,members:[]}); | |||
| await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/preview/${task.id}`); | |||
| await page.locator('.training-shell').waitFor({timeout:120000}); | |||
| await expect.poll(async()=>Boolean(await page.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()?.publishedContentMounted)),{timeout:90000}).toBe(true); | |||
| await page.waitForTimeout(3000); | |||
| if(id===161) { | |||
| const d=await page.evaluate(()=>globalThis.__trainingSceneDiagnostics()); | |||
| const clips=d.server.animations.flatMap(a=>a.native); | |||
| assert.equal(d.server.animations.length,5,'All five published scene objects, including native lights and equipment, must load'); | |||
| assert.ok(clips.some(c=>c.name==='Run'),'Fox GLB and its native clips must load'); | |||
| await expect(page.locator('[data-role="model-commandbar"]')).toBeVisible(); | |||
| await page.locator('.training-server-preparation summary').click(); | |||
| const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth); | |||
| assert.equal(overflow,false,'Small screen must not have page horizontal overflow'); | |||
| await page.locator('.training-server-preparation summary').click(); | |||
| await expect(page.locator('[data-role="teacher-image"]')).toBeVisible(); | |||
| checks.push({name:'狐狸历史固定场景与 GLB 原生动画资源加载;1440×900 布局',pass:true,assignmentId:task.id,clips}); | |||
| } else { | |||
| const play=page.locator('[data-action="teacher-speak"]'); | |||
| await play.click(); | |||
| const video=page.locator('video.training-server-video');await expect(video).toBeVisible(); | |||
| await expect.poll(()=>video.evaluate(v=>v.currentTime),{timeout:30000}).toBeGreaterThan(0); | |||
| const media=await video.evaluate(v=>({duration:v.duration,readyState:v.readyState,authorizedBlob:v.currentSrc.startsWith('blob:')})); | |||
| assert.equal(media.authorizedBlob,true); | |||
| checks.push({name:'数字人服务成片通过任务授权资源播放',pass:true,assignmentId:task.id,media}); | |||
| } | |||
| await page.screenshot({path:`${report}/${id===161?'06-fox-native':'07-digital-human-video'}.png`,fullPage:true}); | |||
| console.log('PASS',checks.at(-1).name); | |||
| } | |||
| assert.equal(errors.length,0,errors.join('\n')); | |||
| }catch(error){checks.push({name:'媒体与小屏验证',pass:false,error:error.message});console.error(error);process.exitCode=1;await page.screenshot({path:report+'/media-failure.png',fullPage:true});} | |||
| finally{fs.writeFileSync(report+'/media-checks.json',JSON.stringify({checks,errors},null,2));await browser.close();} | |||
| @@ -0,0 +1,26 @@ | |||
| import fs from 'node:fs'; | |||
| const root='reports/virtual-html-workspace-20260912'; | |||
| const main=JSON.parse(fs.readFileSync(root+'/checks.json','utf8')); | |||
| const media=JSON.parse(fs.readFileSync(root+'/media-checks.json','utf8')); | |||
| const fixture=JSON.parse(fs.readFileSync(root+'/fixture.json','utf8')); | |||
| const checks=[...main.checks,...media.checks]; | |||
| if(checks.some(check=>!check.pass)||main.errors.length||media.errors.length)throw new Error('Live verification is incomplete'); | |||
| const escape=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |||
| const shots=[['01-bridge-tools','桥梁:结构树、聚焦、目标高亮、标签与数字教员'],['02-bridge-explosion','桥梁:真实部件爆炸视图'],['03-learner-preparation-restored','学员:工具、安全与测量记录刷新恢复'],['04-submitted','完成真实三维操作,提交服务端'],['05-observer','维护账号只读观察学员运行'],['06-fox-native','狐狸:历史发布场景五个对象完整加载,1440×900 布局'],['07-digital-human-video','数字人讲解:播放已保存并授权给任务的成片']]; | |||
| fs.writeFileSync(root+'/index.html',`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>虚拟仿真 JS+HTML 正式工作区验收</title><style> | |||
| body{margin:0;background:#edf5f3;color:#173d39;font:15px/1.7 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1250px;margin:auto;padding:28px}section,figure{background:white;border:1px solid #cbded9;border-radius:10px;padding:22px;margin:18px 0}h1{font-size:27px;margin:0}h2{font-size:19px;margin:0 0 12px}a{color:#087e69}table{border-collapse:collapse;width:100%}td,th{padding:10px;border:1px solid #d9e7e3;text-align:left}.pass{color:#087e69;font-weight:bold}img{display:block;width:100%;height:auto;border:1px solid #d9e7e3}figcaption{font-weight:600;margin-bottom:12px}code{background:#eef5f3;padding:2px 5px;word-break:break-word}.meta{color:#57756e}li{margin:6px 0}</style><main> | |||
| <h1>虚拟仿真 JS+HTML 正式工作区验收</h1><p class="meta">2026-09-12 · 开发环境 · Edge 无头浏览器 · 1920×1080、1440×900</p> | |||
| <section><h2>实施结果</h2><p>虚拟仿真的运行、预览使用 JS+HTML 工作区。Vue 负责平台路由、认证、挂载和接口适配;任务列表继续使用正式教学接口。已有 /api、/legacy 运行与预览地址统一进入同一工作区。本次通过增量修改实现,没有 Git 回滚。</p><ul> | |||
| <li>恢复结构树、聚焦、复位视角、标签、高亮问题设备、隐藏其他、爆炸视图与展开距离控制,以及左下角数字教员形象。</li> | |||
| <li>场景来自任务固定发布版本;保留稳定部件 ID、模型变换、材质、相机、灯光与原生场景对象。禁止缺资源时替换成示例装备。</li> | |||
| <li>模型编辑器编排的旋转/枢轴、位移、材质状态和流向动画复用同一回放实现;片段保持末帧或复位。展开爆炸图时停止动画,收起后重播当前片段。</li> | |||
| <li>工具、安全、测量记录接入服务端检查点;真实点击/拖动经接口确认后推进。请求失败保留当前步骤;所有步骤完成后调用正式提交接口。</li> | |||
| <li>数字人服务生成或资源库导入的成片,使用任务授权资源播放;教员预览不生成学员成绩,观察其他人的运行只读。</li> | |||
| <li>准备面板放在左侧步骤栏,限制展开高度;长标题省略,避免遮挡数字教员和查看工具。</li> | |||
| </ul></section> | |||
| <section><h2>实测 ${checks.length} 项通过</h2><table><thead><tr><th>检查</th><th>结果</th></tr></thead><tbody>${checks.map(check=>`<tr><td>${escape(check.name)}</td><td class="pass">通过</td></tr>`).join('')}</tbody></table><p>浏览器未捕获未处理 JavaScript 异常。断网用例实际拦截一次进度请求,验证失败时服务端和界面均未完成该步骤,随后重试成功。</p></section> | |||
| <section><h2>构建与自动检查</h2><ul><li><code>pnpm build</code>:包含 Vue 类型检查、生产构建及模型编辑器发布产物验证,通过。</li><li>路由、检查点、多工具、步骤事实与命令幂等相关检查 20 项通过;原生动画绑定、末帧保持和停止释放检查 1 项通过。</li><li>后端完整 Maven reactor <code>mvn test</code> 通过;新增学员视觉投影测试,验证动画和材质状态保留,自定义组件与私有字段不下发。</li></ul><p>无数据库或 DDL 变更。部署时前端与 Tran 服务一起更新。正式考试专用运行入口保持原有实现,本报告不将它计入本次迁移验收。</p></section> | |||
| <section><h2>保留的验收记录</h2><p>学员 ID <code>${escape(fixture.userId)}</code>;任务 ID <code>${escape(fixture.assignmentId)}</code>;运行 ID <code>${escape(fixture.runId)}</code>,状态已提交。媒体预览任务:${media.checks.map(c=>`<code>${escape(c.assignmentId)}</code>`).join('、')}。开发过程中使用独立验收副本,原训练内容未覆盖。</p><p>狐狸历史训练没有指定动画片段,因此实测验证原生 Survey / Walk / Run 资源完整加载;原生片段实际回放、末帧保持、复位与释放另由自动测试验证。</p><p>原始结果:<a href="checks.json">操作检查</a> · <a href="media-checks.json">媒体检查</a> · <a href="fixture.json">测试记录 ID</a></p></section> | |||
| ${shots.map(([file,title])=>`<figure><figcaption>${escape(title)}</figcaption><a href="${file}.png"><img src="${file}.png" alt="${escape(title)}" loading="lazy"></a></figure>`).join('')} | |||
| </main></html>`); | |||
| console.log(root+'/index.html'); | |||
| @@ -0,0 +1,80 @@ | |||
| import fs from 'node:fs';import crypto from 'node:crypto';import assert from 'node:assert/strict'; | |||
| import {chromium,expect} from '@playwright/test';import {login,call} from './role-permissions-live.mjs'; | |||
| const report='reports/virtual-html-full-closure-20260912';fs.mkdirSync(report,{recursive:true}); | |||
| const admin=await login('root'),checks=[],errors=[]; | |||
| const api=async(endpoint,session=admin,body,method)=>{const r=await call(endpoint,session?.accessToken,body,method);assert.equal(r.status,200,endpoint+': '+r.message);return r.data}; | |||
| const suffix=Date.now().toString(36),password='Wf!'+crypto.randomBytes(8).toString('hex'); | |||
| const roles=await api('auth/v1/roles?size=100'),role=(roles.records||roles).find(r=>r.code==='student'); | |||
| const user=await api('auth/v1/users',admin,{username:'wf_'+suffix,displayName:'HTML编排验收-'+suffix,departmentId:'5',roleIds:[role.id],defaultRoleId:role.id,enabled:true,password}); | |||
| const student=await api('auth/v1/auth/login',null,{userId:(user.user||user).id,roleCode:'student',password,rememberMe:false}); | |||
| const source=await api('tran/v1/content/projects/419'),content=structuredClone(source.currentVersion.content); | |||
| content.steps.forEach(step=>{step.mode='measure';step.interaction='rule-check';step.targetNodeId=step.targetId;step.action={...step.action,kind:'rule-check',mode:'measure',targetId:step.targetId,requiresPlacement:false};}); | |||
| const edge=(to,rule='')=>({id:crypto.randomUUID(),to,rule}),node=(id,type,expression,transitions,extra={})=>({id,type,title:id,config:{expression},resolution:{expression,timeoutSeconds:2},transitions,...extra}); | |||
| content.executionPlan={schema:'unreal-tran.training-execution-plan/v1',entryNodeId:'start',nodes:[ | |||
| node('start','start','',[edge('fault')]),node('fault','fault-inject','PUMP',[edge('condition')]), | |||
| node('condition','condition','fault.PUMP',[edge('wait','true'),edge('s1','false')]), | |||
| node('s1','step','',[edge('s2')],{stepId:content.steps[0].id}), | |||
| node('wait','event-wait','DEVICE_READY',[edge('camera')]), | |||
| node('camera','camera','wide',[edge('audio')]),node('audio','audio','/audio/sfx/ui.wav',[edge('safety')]), | |||
| node('safety','safety','event.DEVICE_READY',[edge('s2','true'),edge('s1','false')]), | |||
| node('s2','step','',[edge('clear')],{stepId:content.steps[1].id}), | |||
| node('clear','fault-clear','PUMP',[edge('score')]),node('score','score','5',[edge('end')]),node('end','end','',[])]}; | |||
| const created=await api('tran/v1/content/projects',admin,{type:'TRAINING',name:'HTML服务端编排-'+suffix,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content,dependencies:source.currentVersion.dependencies,assets:[]}); | |||
| await api(`tran/v1/content/projects/${created.project.id}/publish`,admin,{version:created.project.version}); | |||
| const project=(await api(`tran/v1/content/projects/${created.project.id}`)).project; | |||
| const task=await api('tran/v1/teaching/assignments',admin,{code:'HTML-WF-'+suffix,name:'HTML编排等待与分支-'+suffix,description:'服务端故障、条件分支和外部事件验收',channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'PRACTICE',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:true,contentProjectId:project.id,contentVersionId:project.publishedVersionId,members:[{userId:(user.user||user).id,memberType:'LEARNER',teamCode:'NEUTRAL',positionCode:'LEARNER',sortOrder:0}]}); | |||
| const published=await api(`tran/v1/teaching/assignments/${task.id}/publish`,admin,{version:task.version}); | |||
| const run=await api(`tran/v1/teaching/assignments/${task.id}/accept`,student,{assignmentVersion:published.version,teamCode:'NEUTRAL',positionCode:'LEARNER'}); | |||
| fs.writeFileSync(report+'/workflow-fixture.json',JSON.stringify({userId:(user.user||user).id,projectId:project.id,assignmentId:task.id,runId:run.id},null,2)); | |||
| const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); | |||
| const context=await browser.newContext({viewport:{width:1920,height:1080}});await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},student); | |||
| const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); | |||
| const pass=(name,details)=>{checks.push({name,pass:true,details});console.log('PASS',name)}; | |||
| const endpoint=`tran/v1/teaching/runs/${run.id}`,diagnostic=()=>page.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()); | |||
| const mounted=async()=>{await expect.poll(async()=>Boolean((await diagnostic())?.publishedContentMounted),{timeout:90000}).toBe(true)}; | |||
| try{ | |||
| await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/runs/${run.id}`);await mounted(); | |||
| let state=await api(endpoint+'/workflow',student);assert.equal(state.waiting,true);assert.equal(state.execution,undefined); | |||
| await expect(page.getByRole('button',{name:'编排状态与事件'})).toHaveCount(0); | |||
| const privateState=await api(endpoint+'/workflow');assert.equal(privateState.execution.faults.PUMP,true); | |||
| const before=await api(endpoint,student); | |||
| await call(endpoint+'/events',student.accessToken,{type:'workflow.state',title:'spoof',summary:'spoof',source:'WORKFLOW',visibility:'PUBLIC',publicPayload:{cursor:'end',ended:true}}); | |||
| assert.equal((await api(endpoint+'/workflow',student)).waiting,true,'Public event cannot replace private workflow journal'); | |||
| const denied=await call(endpoint+'/workflow/signals',student.accessToken,{version:before.version,commandId:crypto.randomUUID(),signalCode:'DEVICE_READY'});assert.equal(denied.status,403); | |||
| const premature=await call(endpoint+'/submit',student.accessToken,{version:before.version,commandId:crypto.randomUUID(),summary:'premature'});assert.notEqual(premature.status,200); | |||
| await page.getByRole('button',{name:'刷新流程',exact:true}).click();await mounted(); | |||
| await expect.poll(async()=>(await api(endpoint+'/workflow',student)).timedOut,{timeout:12000}).toBe(true); | |||
| await page.reload();await mounted();assert.equal((await api(endpoint+'/workflow',student)).waiting,true); | |||
| await page.screenshot({path:report+'/03-workflow-waiting.png',fullPage:true}); | |||
| pass('故障真值与编排游标在服务端;超时仍等待,刷新恢复;学员不能注入事件或提前交卷'); | |||
| const managerContext=await browser.newContext({viewport:{width:1920,height:1080}});await managerContext.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},admin); | |||
| const manager=await managerContext.newPage();await manager.goto(`http://127.0.0.1:6180/teaching/virtual-training/runs/${run.id}`); | |||
| await manager.getByRole('button',{name:'编排状态与事件'}).click();await manager.getByPlaceholder('与事件等待节点一致,例如 DEVICE_READY').fill('DEVICE_READY'); | |||
| const signalRequest=manager.waitForRequest(request=>request.url().endsWith('/workflow/signals')&&request.method()==='POST'); | |||
| await manager.getByRole('button',{name:'确认事件并继续编排'}).click(); | |||
| const signalBody=(await signalRequest).postDataJSON(); | |||
| await expect.poll(async()=>(await api(endpoint+'/workflow',student)).waiting).toBe(false); | |||
| const signalVersion=(await api(endpoint)).version; | |||
| const replayed=await api(endpoint+'/workflow/signals',admin,signalBody);assert.equal(replayed.version,signalVersion);assert.equal((await api(endpoint)).version,signalVersion); | |||
| await manager.screenshot({path:report+'/04-teacher-workflow-event.png',fullPage:true}); | |||
| const facts=await api(endpoint+'/steps',student);assert.equal(facts[0].statusCode,'SKIPPED');assert.equal(facts[1].statusCode,'IN_PROGRESS'); | |||
| await page.getByRole('button',{name:'刷新流程',exact:true}).click();await mounted(); | |||
| await expect(page.locator('[data-step-index="0"]')).toContainText('分支跳过'); | |||
| assert.deepEqual((await api(endpoint+'/workflow',student)).effects.map(e=>e.type),['camera','audio']); | |||
| await expect.poll(async()=>Math.abs((await diagnostic()).framing.distance-15.5),{timeout:6000}).toBeLessThan(.1); | |||
| const panel=page.locator('.training-server-preparation');if(!(await panel.evaluate(p=>p.open)))await panel.locator('summary').click(); | |||
| await panel.locator('[data-tool="pressure-gauge"]').check();for(const checkbox of await panel.locator('[data-safety]').all())await checkbox.check(); | |||
| await panel.locator('textarea').fill('编排分支验收:压力归零。');await panel.locator('[data-save-preparation]').click();await expect(panel.locator('[data-save-status]')).toHaveText('已保存');await panel.locator('summary').click(); | |||
| await page.waitForTimeout(1500);await page.locator('[data-action="toggle-issue-highlight"]').click();await page.waitForTimeout(500); | |||
| const d=await diagnostic(),rect=await page.locator('canvas.training-canvas').boundingBox();assert.ok(d.framing.targetOnScreen?.insideCanvas); | |||
| await page.mouse.click(rect.x+d.framing.targetOnScreen.localX,rect.y+d.framing.targetOnScreen.localY+38); | |||
| await expect.poll(async()=>(await api(endpoint,student)).progressPercent,{timeout:20000}).toBe(100); | |||
| const ended=await api(endpoint+'/workflow');assert.equal(ended.ended,true);assert.deepEqual(ended.execution.faults,{});assert.equal(Number(ended.execution.suggestedScore),5); | |||
| await page.locator('[data-action="next"]').click();await expect.poll(async()=>(await api(endpoint,student)).status,{timeout:20000}).toBe('SUBMITTED'); | |||
| await page.screenshot({path:report+'/05-workflow-submitted.png',fullPage:true}); | |||
| pass('教员页面确认事件、服务端选择分支、明确跳过第一步、真实操作第二步、清除故障与提交'); | |||
| const submitted=await api(endpoint);await api(endpoint+'/review',admin,{version:submitted.version,commandId:crypto.randomUUID(),score:91,passed:true,feedback:'编排路径与实际操作已核验'}); | |||
| const records=await api(`tran/v1/teaching/analytics/records?keyword=${encodeURIComponent(task.name)}&assignmentKind=TRAINING&page=1&size=20`);assert.ok(records.records.some(r=>String(r.runId||r.id)===run.id)); | |||
| pass('编排训练交卷后由教员评分,真实统计包含本次运行',{runId:run.id,score:91});assert.equal(errors.length,0,errors.join('\n')); | |||
| }catch(error){checks.push({name:'编排闭环实测',pass:false,error:error.message});console.error(error.message);process.exitCode=1;await page.screenshot({path:report+'/workflow-failure.png',fullPage:true}).catch(()=>{})} | |||
| finally{fs.writeFileSync(report+'/workflow-checks.json',JSON.stringify({checks,errors},null,2));await browser.close()} | |||
| @@ -0,0 +1,30 @@ | |||
| """Verify and apply the one workflow status constraint; no business data is rewritten.""" | |||
| import runpy, json, hashlib, datetime | |||
| from pathlib import Path | |||
| import sqlparse | |||
| support=runpy.run_path(str(Path(__file__).with_name('review-db.py'))) | |||
| source=Path(__file__).resolve().parents[2]/'unreal_tran_api/database/20260912_041_teaching_workflow_commands.sql' | |||
| report=Path(__file__).resolve().parents[1]/'reports/virtual-html-full-closure-20260912/migration-commands.json' | |||
| report.parent.mkdir(parents=True,exist_ok=True) | |||
| connection=support['connect']() | |||
| with connection.cursor() as cursor: | |||
| cursor.execute("SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conname='ck_ut_teaching_command_receipt_type' AND conrelid='public.ut_teaching_command_receipt'::regclass") | |||
| before=cursor.fetchone() | |||
| cursor.execute('SHOW server_encoding') | |||
| encoding=cursor.fetchone() | |||
| # Temporary shadow table verifies syntax, first execution and repeat without touching public data. | |||
| cursor.execute('CREATE TEMP TABLE ut_teaching_command_receipt (command_type varchar(32))') | |||
| for attempt in range(2): | |||
| for statement in sqlparse.split(source.read_text(encoding='utf-8')):cursor.execute(statement) | |||
| cursor.execute("INSERT INTO pg_temp.ut_teaching_command_receipt VALUES ('WORKFLOW_SIGNAL')") | |||
| cursor.execute('DROP TABLE pg_temp.ut_teaching_command_receipt') | |||
| report.write_text(json.dumps({'file':source.name,'sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'target':'unreal_tran/public','encoding':encoding,'before':before,'temporaryFirstAndRepeat':True,'stage':'before-public-apply'},ensure_ascii=False,indent=2),encoding='utf-8') | |||
| for attempt in range(2): | |||
| for statement in sqlparse.split(source.read_text(encoding='utf-8')):cursor.execute(statement) | |||
| cursor.execute("SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conname='ck_ut_teaching_command_receipt_type' AND conrelid='public.ut_teaching_command_receipt'::regclass") | |||
| after=cursor.fetchone();assert 'WORKFLOW_SIGNAL' in after['definition'] | |||
| data=json.loads(report.read_text(encoding='utf-8'));data.update(after=after,stage='verified',time=datetime.datetime.now().astimezone().isoformat()) | |||
| report.write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8') | |||
| connection.close() | |||
| print('PASS temporary migration, repeat, public migration and constraint verification') | |||
| @@ -0,0 +1,30 @@ | |||
| """Verify and apply the one workflow status constraint; no business data is rewritten.""" | |||
| import runpy, json, hashlib, datetime | |||
| from pathlib import Path | |||
| import sqlparse | |||
| support=runpy.run_path(str(Path(__file__).with_name('review-db.py'))) | |||
| source=Path(__file__).resolve().parents[2]/'unreal_tran_api/database/20260912_040_teaching_workflow_steps.sql' | |||
| report=Path(__file__).resolve().parents[1]/'reports/virtual-html-full-closure-20260912/migration.json' | |||
| report.parent.mkdir(parents=True,exist_ok=True) | |||
| connection=support['connect']() | |||
| with connection.cursor() as cursor: | |||
| cursor.execute("SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conname='ck_ut_teaching_step_run_status' AND conrelid='public.ut_teaching_step_run'::regclass") | |||
| before=cursor.fetchone() | |||
| cursor.execute('SHOW server_encoding') | |||
| encoding=cursor.fetchone() | |||
| # Temporary shadow table verifies syntax, first execution and repeat without touching public data. | |||
| cursor.execute('CREATE TEMP TABLE ut_teaching_step_run (status_code varchar(32))') | |||
| for attempt in range(2): | |||
| for statement in sqlparse.split(source.read_text(encoding='utf-8')):cursor.execute(statement) | |||
| cursor.execute("INSERT INTO pg_temp.ut_teaching_step_run VALUES ('SKIPPED')") | |||
| cursor.execute('DROP TABLE pg_temp.ut_teaching_step_run') | |||
| report.write_text(json.dumps({'file':source.name,'sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'target':'unreal_tran/public','encoding':encoding,'before':before,'temporaryFirstAndRepeat':True,'stage':'before-public-apply'},ensure_ascii=False,indent=2),encoding='utf-8') | |||
| for attempt in range(2): | |||
| for statement in sqlparse.split(source.read_text(encoding='utf-8')):cursor.execute(statement) | |||
| cursor.execute("SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conname='ck_ut_teaching_step_run_status' AND conrelid='public.ut_teaching_step_run'::regclass") | |||
| after=cursor.fetchone();assert 'SKIPPED' in after['definition'] | |||
| data=json.loads(report.read_text(encoding='utf-8'));data.update(after=after,stage='verified',time=datetime.datetime.now().astimezone().isoformat()) | |||
| report.write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8') | |||
| connection.close() | |||
| print('PASS temporary migration, repeat, public migration and constraint verification') | |||