瀏覽代碼

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

Loading…
取消
儲存