浏览代码

test(teaching): verify HTML workspace workflows and formal exams

main
leiyun 1周前
父节点
当前提交
943475ae38
共有 41 个文件被更改,包括 1337 次插入0 次删除
  1. 二进制
     
  2. 二进制
     
  3. 二进制
     
  4. 二进制
     
  5. 二进制
     
  6. 二进制
     
  7. 二进制
     
  8. 二进制
     
  9. +20
    -0
      reports/virtual-html-full-closure-20260912/backend-tests.json
  10. +64
    -0
      reports/virtual-html-full-closure-20260912/cleanup.json
  11. +29
    -0
      reports/virtual-html-full-closure-20260912/exam-checks.json
  12. 二进制
     
  13. +7
    -0
      reports/virtual-html-full-closure-20260912/exam-fixture.json
  14. +13
    -0
      reports/virtual-html-full-closure-20260912/index.html
  15. +17
    -0
      reports/virtual-html-full-closure-20260912/migration-commands.json
  16. +17
    -0
      reports/virtual-html-full-closure-20260912/migration.json
  17. +311
    -0
      reports/virtual-html-full-closure-20260912/retained-fixtures.json
  18. 二进制
     
  19. +21
    -0
      reports/virtual-html-full-closure-20260912/workflow-checks.json
  20. +6
    -0
      reports/virtual-html-full-closure-20260912/workflow-fixture.json
  21. 二进制
     
  22. 二进制
     
  23. 二进制
     
  24. 二进制
     
  25. 二进制
     
  26. 二进制
     
  27. 二进制
     
  28. +309
    -0
      reports/virtual-html-workspace-20260912/checks.json
  29. 二进制
     
  30. +5
    -0
      reports/virtual-html-workspace-20260912/fixture.json
  31. +16
    -0
      reports/virtual-html-workspace-20260912/index.html
  32. +34
    -0
      reports/virtual-html-workspace-20260912/media-checks.json
  33. +26
    -0
      tools/virtual-html-closure-report.mjs
  34. +87
    -0
      tools/virtual-html-exam-live.mjs
  35. +18
    -0
      tools/virtual-html-inspect.mjs
  36. +114
    -0
      tools/virtual-html-live.mjs
  37. +57
    -0
      tools/virtual-html-media.mjs
  38. +26
    -0
      tools/virtual-html-report.mjs
  39. +80
    -0
      tools/virtual-html-workflow-live.mjs
  40. +30
    -0
      tools/workflow-command-migration.py
  41. +30
    -0
      tools/workflow-migration.py

二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


+ 20
- 0
reports/virtual-html-full-closure-20260912/backend-tests.json 查看文件

@@ -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
}
}

+ 64
- 0
reports/virtual-html-full-closure-20260912/cleanup.json 查看文件

@@ -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."
}

+ 29
- 0
reports/virtual-html-full-closure-20260912/exam-checks.json 查看文件

@@ -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": []
}

二进制
查看文件


+ 7
- 0
reports/virtual-html-full-closure-20260912/exam-fixture.json 查看文件

@@ -0,0 +1,7 @@
{
"userId": "160",
"assignmentId": "84",
"runId": "83",
"deadlineAssignmentId": "85",
"deadlineRunId": "84"
}

+ 13
- 0
reports/virtual-html-full-closure-20260912/index.html 查看文件

@@ -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>

+ 17
- 0
reports/virtual-html-full-closure-20260912/migration-commands.json 查看文件

@@ -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"
}

+ 17
- 0
reports/virtual-html-full-closure-20260912/migration.json 查看文件

@@ -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"
}

+ 311
- 0
reports/virtual-html-full-closure-20260912/retained-fixtures.json 查看文件

@@ -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"
}
]
}

二进制
查看文件


+ 21
- 0
reports/virtual-html-full-closure-20260912/workflow-checks.json 查看文件

@@ -0,0 +1,21 @@
{
"checks": [
{
"name": "故障真值与编排游标在服务端;超时仍等待,刷新恢复;学员不能注入事件或提前交卷",
"pass": true
},
{
"name": "教员页面确认事件、服务端选择分支、明确跳过第一步、真实操作第二步、清除故障与提交",
"pass": true
},
{
"name": "编排训练交卷后由教员评分,真实统计包含本次运行",
"pass": true,
"details": {
"runId": "80",
"score": 91
}
}
],
"errors": []
}

+ 6
- 0
reports/virtual-html-full-closure-20260912/workflow-fixture.json 查看文件

@@ -0,0 +1,6 @@
{
"userId": "158",
"projectId": "435",
"assignmentId": "81",
"runId": "80"
}

二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


二进制
查看文件


+ 309
- 0
reports/virtual-html-workspace-20260912/checks.json 查看文件

@@ -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": []
}

二进制
查看文件


+ 5
- 0
reports/virtual-html-workspace-20260912/fixture.json 查看文件

@@ -0,0 +1,5 @@
{
"userId": "155",
"assignmentId": "78",
"runId": "77"
}

+ 16
- 0
reports/virtual-html-workspace-20260912/index.html 查看文件

@@ -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>

+ 34
- 0
reports/virtual-html-workspace-20260912/media-checks.json 查看文件

@@ -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": []
}

+ 26
- 0
tools/virtual-html-closure-report.mjs 查看文件

@@ -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('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
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');

+ 87
- 0
tools/virtual-html-exam-live.mjs 查看文件

@@ -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()}

+ 18
- 0
tools/virtual-html-inspect.mjs 查看文件

@@ -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();

+ 114
- 0
tools/virtual-html-live.mjs 查看文件

@@ -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();}

+ 57
- 0
tools/virtual-html-media.mjs 查看文件

@@ -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();}

+ 26
- 0
tools/virtual-html-report.mjs 查看文件

@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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');

+ 80
- 0
tools/virtual-html-workflow-live.mjs 查看文件

@@ -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()}

+ 30
- 0
tools/workflow-command-migration.py 查看文件

@@ -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')

+ 30
- 0
tools/workflow-migration.py 查看文件

@@ -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')

正在加载...
取消
保存