diff --git a/reports/virtual-html-full-closure-20260912/01-exam-current-step.png b/reports/virtual-html-full-closure-20260912/01-exam-current-step.png
new file mode 100644
index 0000000..ac55ff6
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/01-exam-current-step.png differ
diff --git a/reports/virtual-html-full-closure-20260912/02-exam-submitted.png b/reports/virtual-html-full-closure-20260912/02-exam-submitted.png
new file mode 100644
index 0000000..f12e8a5
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/02-exam-submitted.png differ
diff --git a/reports/virtual-html-full-closure-20260912/03-workflow-waiting.png b/reports/virtual-html-full-closure-20260912/03-workflow-waiting.png
new file mode 100644
index 0000000..9a3123f
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/03-workflow-waiting.png differ
diff --git a/reports/virtual-html-full-closure-20260912/04-teacher-workflow-event.png b/reports/virtual-html-full-closure-20260912/04-teacher-workflow-event.png
new file mode 100644
index 0000000..536dd75
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/04-teacher-workflow-event.png differ
diff --git a/reports/virtual-html-full-closure-20260912/05-workflow-submitted.png b/reports/virtual-html-full-closure-20260912/05-workflow-submitted.png
new file mode 100644
index 0000000..07b11e5
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/05-workflow-submitted.png differ
diff --git a/reports/virtual-html-full-closure-20260912/06-teacher-exam-review.png b/reports/virtual-html-full-closure-20260912/06-teacher-exam-review.png
new file mode 100644
index 0000000..1e2c412
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/06-teacher-exam-review.png differ
diff --git a/reports/virtual-html-full-closure-20260912/07-exam-analytics.png b/reports/virtual-html-full-closure-20260912/07-exam-analytics.png
new file mode 100644
index 0000000..6c9e1a5
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/07-exam-analytics.png differ
diff --git a/reports/virtual-html-full-closure-20260912/08-exam-deadline.png b/reports/virtual-html-full-closure-20260912/08-exam-deadline.png
new file mode 100644
index 0000000..8151cb4
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/08-exam-deadline.png differ
diff --git a/reports/virtual-html-full-closure-20260912/backend-tests.json b/reports/virtual-html-full-closure-20260912/backend-tests.json
new file mode 100644
index 0000000..aa17595
--- /dev/null
+++ b/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
+ }
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/cleanup.json b/reports/virtual-html-full-closure-20260912/cleanup.json
new file mode 100644
index 0000000..6e1cab4
--- /dev/null
+++ b/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."
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/exam-checks.json b/reports/virtual-html-full-closure-20260912/exam-checks.json
new file mode 100644
index 0000000..c59d8f3
--- /dev/null
+++ b/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": []
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/exam-failure.png b/reports/virtual-html-full-closure-20260912/exam-failure.png
new file mode 100644
index 0000000..f9e9f95
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/exam-failure.png differ
diff --git a/reports/virtual-html-full-closure-20260912/exam-fixture.json b/reports/virtual-html-full-closure-20260912/exam-fixture.json
new file mode 100644
index 0000000..91b7185
--- /dev/null
+++ b/reports/virtual-html-full-closure-20260912/exam-fixture.json
@@ -0,0 +1,7 @@
+{
+ "userId": "160",
+ "assignmentId": "84",
+ "runId": "83",
+ "deadlineAssignmentId": "85",
+ "deadlineRunId": "84"
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/index.html b/reports/virtual-html-full-closure-20260912/index.html
new file mode 100644
index 0000000..1970016
--- /dev/null
+++ b/reports/virtual-html-full-closure-20260912/index.html
@@ -0,0 +1,13 @@
+
JS+HTML 虚拟训练完整流程验收 JS+HTML 虚拟训练完整流程验收 15 项实测检查通过 · 111 项前端契约测试通过 · 552 项后端测试通过
时间:2026/9/12 23:50:03 · 环境:本机 6180 Web → 6100 Gateway → Tran/Auth · Edge 无头浏览器真实交互
本轮覆盖虚拟训练高级编排、虚拟通道正式考试、教员评定与数据中心,并回归既有 JS+HTML 运行/预览。所有训练进度、交卷、评定及编排状态由真实接口处理。
+迁移结果 环节 实现与验证
+高级编排 服务端按发布连线执行开始、步骤、结束、条件、安全、事件等待、故障注入/清除、建议评分、相机及音频节点。等待与超时阻止推进;分支跳过记为 SKIPPED;刷新恢复;教员页面确认事件;相同命令重放不重复推进。
+正式考试 虚拟通道使用 JS+HTML 工作区,逐步获取 execution 安全投影;关闭帮助、数字人、答案高亮、爆炸和回放。三维选择后提交,交卷后关闭执行资源;到期关闭工作区并由服务端拒绝资源访问。非虚拟通道保留设备/岗位推进规则。
+评分与统计 服务端逐步保留考试操作证据;教员在评定弹窗查看并打分,学员不能自评或获取私有证据聚合。数据中心页面可查询成绩及详情。
+普通训练回归 桥梁结构树、聚焦、高亮、隐藏、标签、爆炸、复位、数字教员;卡车四类动画与复位;工具、安全和测量记录恢复;真实点击与拖动;网络失败不提前推进,恢复后重试;他人运行只读观察。
+真实流程检查 检查 结果 正式考试进入 JS+HTML,仅读取服务端当前步骤,关闭数字人和目标提示 通过 实际三维选择、逐步提交、重新获取下一安全投影、最终交卷 通过 学员不能自评;教员评分后学员可见,统计明细包含本次运行 通过 数据中心页面查询正式考核成绩与记录详情 通过 考试到期自动关闭工作区;服务端拒绝继续获取执行投影和资产,教员完成超时结算 通过 故障真值与编排游标在服务端;超时仍等待,刷新恢复;学员不能注入事件或提前交卷 通过 教员页面确认事件、服务端选择分支、明确跳过第一步、真实操作第二步、清除故障与提交 通过 编排训练交卷后由教员评分,真实统计包含本次运行 通过 桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员 通过 卡车:正式工作区播放四种已编排动画轨道并保持末帧 通过 学员:工具、安全、测量记录通过真实接口保存并刷新恢复 通过 进度请求失败时不在本地提前完成步骤 通过 步骤完成服务器确认后推进,下一步动画按发布片段复位 通过 真实两步操作完成、服务端判定、提交结果闭环 通过 维护账号观察他人运行时只读,不修改学员进度 通过
+验证命令与证据 pnpm run build(含 vue-tsc 与正式资源构建校验);node --test 执行 teaching/virtual/training 的 *.test.mjs;Java 17 下 mvn test。
后端模块 测试数 失败 / 错误 ut_gateway 1 0 / 0 ut_auth 134 0 / 0 ut_tran 417 0 / 0
构建存在现有大分块提示。原型同步检查提示 upstream 与同步基线存在差异,本次未执行覆盖式原型同步。
脚本:virtual-html-exam-live.mjs、virtual-html-workflow-live.mjs、virtual-html-live.mjs。原始结果:考试 、编排 、普通训练回归 。
+数据库与使用边界 已在开发库执行并重复验证两项增量:040 扩展 SKIPPED 步骤状态,041 扩展编排命令幂等回执类型。完整 SQL、数据库设计与接口文档同步更新;其他环境需先执行增量再更新服务。
040 执行记录与原约束 · 041 执行记录与原约束
外部事件本轮通过教员页面及授权 HTTP 接口验证,未连接现场真实设备;故障状态用于服务端流程判定,不能据此宣称已完成真实设备故障模拟。普通教员预览沿用连续步骤预演接口,高级分支验收在真实学员运行中完成。
+保留的验收数据 最新正式考试:任务 84 / 运行 83(86 分);截止时间测试:任务 85 / 运行 84;高级编排:任务 81 / 运行 80(91 分)。
已停用 22 个不用的临时测试账号,保留最新 3 个验收账号。过程记录、失败重试记录与历史任务保留供审计和复查,未为了清理而补造评定。
完整测试数据清单 · 账号清理结果
+页面证据 正式考试:当前安全投影与实际三维选择 高级编排:等待外部事件,超时保持阻断 教员页面确认外部事件 高级编排路径完成并提交 教员查看逐步考试证据并评分 数据中心查询成绩与记录详情 考试到期关闭工作区
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/migration-commands.json b/reports/virtual-html-full-closure-20260912/migration-commands.json
new file mode 100644
index 0000000..5431618
--- /dev/null
+++ b/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"
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/migration.json b/reports/virtual-html-full-closure-20260912/migration.json
new file mode 100644
index 0000000..256ec8a
--- /dev/null
+++ b/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"
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/retained-fixtures.json b/reports/virtual-html-full-closure-20260912/retained-fixtures.json
new file mode 100644
index 0000000..5e05d79
--- /dev/null
+++ b/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"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/teacher-debug.png b/reports/virtual-html-full-closure-20260912/teacher-debug.png
new file mode 100644
index 0000000..111f967
Binary files /dev/null and b/reports/virtual-html-full-closure-20260912/teacher-debug.png differ
diff --git a/reports/virtual-html-full-closure-20260912/workflow-checks.json b/reports/virtual-html-full-closure-20260912/workflow-checks.json
new file mode 100644
index 0000000..2665f36
--- /dev/null
+++ b/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": []
+}
\ No newline at end of file
diff --git a/reports/virtual-html-full-closure-20260912/workflow-fixture.json b/reports/virtual-html-full-closure-20260912/workflow-fixture.json
new file mode 100644
index 0000000..7e6d81e
--- /dev/null
+++ b/reports/virtual-html-full-closure-20260912/workflow-fixture.json
@@ -0,0 +1,6 @@
+{
+ "userId": "158",
+ "projectId": "435",
+ "assignmentId": "81",
+ "runId": "80"
+}
\ No newline at end of file
diff --git a/reports/virtual-html-workspace-20260912/01-bridge-tools.png b/reports/virtual-html-workspace-20260912/01-bridge-tools.png
new file mode 100644
index 0000000..433388c
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/01-bridge-tools.png differ
diff --git a/reports/virtual-html-workspace-20260912/02-bridge-explosion.png b/reports/virtual-html-workspace-20260912/02-bridge-explosion.png
new file mode 100644
index 0000000..a874498
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/02-bridge-explosion.png differ
diff --git a/reports/virtual-html-workspace-20260912/03-learner-preparation-restored.png b/reports/virtual-html-workspace-20260912/03-learner-preparation-restored.png
new file mode 100644
index 0000000..d7a7bdb
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/03-learner-preparation-restored.png differ
diff --git a/reports/virtual-html-workspace-20260912/04-submitted.png b/reports/virtual-html-workspace-20260912/04-submitted.png
new file mode 100644
index 0000000..cbee2f8
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/04-submitted.png differ
diff --git a/reports/virtual-html-workspace-20260912/05-observer.png b/reports/virtual-html-workspace-20260912/05-observer.png
new file mode 100644
index 0000000..d75ed1c
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/05-observer.png differ
diff --git a/reports/virtual-html-workspace-20260912/06-fox-native.png b/reports/virtual-html-workspace-20260912/06-fox-native.png
new file mode 100644
index 0000000..851e887
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/06-fox-native.png differ
diff --git a/reports/virtual-html-workspace-20260912/07-digital-human-video.png b/reports/virtual-html-workspace-20260912/07-digital-human-video.png
new file mode 100644
index 0000000..8b5702a
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/07-digital-human-video.png differ
diff --git a/reports/virtual-html-workspace-20260912/checks.json b/reports/virtual-html-workspace-20260912/checks.json
new file mode 100644
index 0000000..86209c2
--- /dev/null
+++ b/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": []
+}
\ No newline at end of file
diff --git a/reports/virtual-html-workspace-20260912/failure.png b/reports/virtual-html-workspace-20260912/failure.png
new file mode 100644
index 0000000..9480227
Binary files /dev/null and b/reports/virtual-html-workspace-20260912/failure.png differ
diff --git a/reports/virtual-html-workspace-20260912/fixture.json b/reports/virtual-html-workspace-20260912/fixture.json
new file mode 100644
index 0000000..77d082f
--- /dev/null
+++ b/reports/virtual-html-workspace-20260912/fixture.json
@@ -0,0 +1,5 @@
+{
+ "userId": "155",
+ "assignmentId": "78",
+ "runId": "77"
+}
\ No newline at end of file
diff --git a/reports/virtual-html-workspace-20260912/index.html b/reports/virtual-html-workspace-20260912/index.html
new file mode 100644
index 0000000..8f6174b
--- /dev/null
+++ b/reports/virtual-html-workspace-20260912/index.html
@@ -0,0 +1,16 @@
+虚拟仿真 JS+HTML 正式工作区验收
+虚拟仿真 JS+HTML 正式工作区验收 2026-09-12 · 开发环境 · Edge 无头浏览器 · 1920×1080、1440×900
+实施结果 虚拟仿真的运行、预览使用 JS+HTML 工作区。Vue 负责平台路由、认证、挂载和接口适配;任务列表继续使用正式教学接口。已有 /api、/legacy 运行与预览地址统一进入同一工作区。本次通过增量修改实现,没有 Git 回滚。
+恢复结构树、聚焦、复位视角、标签、高亮问题设备、隐藏其他、爆炸视图与展开距离控制,以及左下角数字教员形象。
+场景来自任务固定发布版本;保留稳定部件 ID、模型变换、材质、相机、灯光与原生场景对象。禁止缺资源时替换成示例装备。
+模型编辑器编排的旋转/枢轴、位移、材质状态和流向动画复用同一回放实现;片段保持末帧或复位。展开爆炸图时停止动画,收起后重播当前片段。
+工具、安全、测量记录接入服务端检查点;真实点击/拖动经接口确认后推进。请求失败保留当前步骤;所有步骤完成后调用正式提交接口。
+数字人服务生成或资源库导入的成片,使用任务授权资源播放;教员预览不生成学员成绩,观察其他人的运行只读。
+准备面板放在左侧步骤栏,限制展开高度;长标题省略,避免遮挡数字教员和查看工具。
+
+实测 9 项通过 检查 结果 桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员 通过 卡车:正式工作区播放四种已编排动画轨道并保持末帧 通过 学员:工具、安全、测量记录通过真实接口保存并刷新恢复 通过 进度请求失败时不在本地提前完成步骤 通过 步骤完成服务器确认后推进,下一步动画按发布片段复位 通过 真实两步操作完成、服务端判定、提交结果闭环 通过 维护账号观察他人运行时只读,不修改学员进度 通过 狐狸历史固定场景与 GLB 原生动画资源加载;1440×900 布局 通过 数字人服务成片通过任务授权资源播放 通过
浏览器未捕获未处理 JavaScript 异常。断网用例实际拦截一次进度请求,验证失败时服务端和界面均未完成该步骤,随后重试成功。
+构建与自动检查 pnpm build:包含 Vue 类型检查、生产构建及模型编辑器发布产物验证,通过。路由、检查点、多工具、步骤事实与命令幂等相关检查 20 项通过;原生动画绑定、末帧保持和停止释放检查 1 项通过。 后端完整 Maven reactor mvn test 通过;新增学员视觉投影测试,验证动画和材质状态保留,自定义组件与私有字段不下发。 无数据库或 DDL 变更。部署时前端与 Tran 服务一起更新。正式考试专用运行入口保持原有实现,本报告不将它计入本次迁移验收。
+保留的验收记录 学员 ID 141;任务 ID 66;运行 ID 67,状态已提交。媒体预览任务:67、68。开发过程中使用独立验收副本,原训练内容未覆盖。
狐狸历史训练没有指定动画片段,因此实测验证原生 Survey / Walk / Run 资源完整加载;原生片段实际回放、末帧保持、复位与释放另由自动测试验证。
原始结果:操作检查 · 媒体检查 · 测试记录 ID
+桥梁:结构树、聚焦、目标高亮、标签与数字教员 桥梁:真实部件爆炸视图 学员:工具、安全与测量记录刷新恢复 完成真实三维操作,提交服务端 维护账号只读观察学员运行 狐狸:历史发布场景五个对象完整加载,1440×900 布局 数字人讲解:播放已保存并授权给任务的成片
+
\ No newline at end of file
diff --git a/reports/virtual-html-workspace-20260912/media-checks.json b/reports/virtual-html-workspace-20260912/media-checks.json
new file mode 100644
index 0000000..d97ccb4
--- /dev/null
+++ b/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": []
+}
\ No newline at end of file
diff --git a/tools/virtual-html-closure-report.mjs b/tools/virtual-html-closure-report.mjs
new file mode 100644
index 0000000..be75bac
--- /dev/null
+++ b/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('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"');
+const fixture=read('exam-fixture.json'),flowFixture=read('workflow-fixture.json');
+const screenshots=[['01-exam-current-step.png','正式考试:当前安全投影与实际三维选择'],['03-workflow-waiting.png','高级编排:等待外部事件,超时保持阻断'],['04-teacher-workflow-event.png','教员页面确认外部事件'],['05-workflow-submitted.png','高级编排路径完成并提交'],['06-teacher-exam-review.png','教员查看逐步考试证据并评分'],['07-exam-analytics.png','数据中心查询成绩与记录详情'],['08-exam-deadline.png','考试到期关闭工作区']];
+const html=`JS+HTML 虚拟训练完整流程验收 JS+HTML 虚拟训练完整流程验收 ${checks.length} 项实测检查通过 · 111 项前端契约测试通过 · 552 项后端测试通过
时间:${escape(new Date().toLocaleString('zh-CN'))} · 环境:本机 6180 Web → 6100 Gateway → Tran/Auth · Edge 无头浏览器真实交互
本轮覆盖虚拟训练高级编排、虚拟通道正式考试、教员评定与数据中心,并回归既有 JS+HTML 运行/预览。所有训练进度、交卷、评定及编排状态由真实接口处理。
+迁移结果 环节 实现与验证
+高级编排 服务端按发布连线执行开始、步骤、结束、条件、安全、事件等待、故障注入/清除、建议评分、相机及音频节点。等待与超时阻止推进;分支跳过记为 SKIPPED;刷新恢复;教员页面确认事件;相同命令重放不重复推进。
+正式考试 虚拟通道使用 JS+HTML 工作区,逐步获取 execution 安全投影;关闭帮助、数字人、答案高亮、爆炸和回放。三维选择后提交,交卷后关闭执行资源;到期关闭工作区并由服务端拒绝资源访问。非虚拟通道保留设备/岗位推进规则。
+评分与统计 服务端逐步保留考试操作证据;教员在评定弹窗查看并打分,学员不能自评或获取私有证据聚合。数据中心页面可查询成绩及详情。
+普通训练回归 桥梁结构树、聚焦、高亮、隐藏、标签、爆炸、复位、数字教员;卡车四类动画与复位;工具、安全和测量记录恢复;真实点击与拖动;网络失败不提前推进,恢复后重试;他人运行只读观察。
+真实流程检查 检查 结果 ${checks.map(c=>`${escape(c.name)} 通过 `).join('')}
+验证命令与证据 pnpm run build(含 vue-tsc 与正式资源构建校验);node --test 执行 teaching/virtual/training 的 *.test.mjs;Java 17 下 mvn test。
后端模块 测试数 失败 / 错误 ${Object.entries(backend).map(([name,value])=>`${name} ${value.tests} ${value.failures} / ${value.errors} `).join('')}
构建存在现有大分块提示。原型同步检查提示 upstream 与同步基线存在差异,本次未执行覆盖式原型同步。
脚本:virtual-html-exam-live.mjs、virtual-html-workflow-live.mjs、virtual-html-live.mjs。原始结果:考试 、编排 、普通训练回归 。
+数据库与使用边界 已在开发库执行并重复验证两项增量:040 扩展 SKIPPED 步骤状态,041 扩展编排命令幂等回执类型。完整 SQL、数据库设计与接口文档同步更新;其他环境需先执行增量再更新服务。
040 执行记录与原约束 · 041 执行记录与原约束
外部事件本轮通过教员页面及授权 HTTP 接口验证,未连接现场真实设备;故障状态用于服务端流程判定,不能据此宣称已完成真实设备故障模拟。普通教员预览沿用连续步骤预演接口,高级分支验收在真实学员运行中完成。
+保留的验收数据 最新正式考试:任务 ${fixture.assignmentId} / 运行 ${fixture.runId}(86 分);截止时间测试:任务 ${fixture.deadlineAssignmentId} / 运行 ${fixture.deadlineRunId};高级编排:任务 ${flowFixture.assignmentId} / 运行 ${flowFixture.runId}(91 分)。
已停用 ${cleanup.disabledTestUserIds.length} 个不用的临时测试账号,保留最新 ${cleanup.retainedTestUserIds.length} 个验收账号。过程记录、失败重试记录与历史任务保留供审计和复查,未为了清理而补造评定。
完整测试数据清单 · 账号清理结果
+页面证据 ${screenshots.map(([file,label])=>`
${escape(label)} `).join('')}
`;
+fs.writeFileSync(path.join(root,'index.html'),html);
+console.log('Report written:',root,';',checks.length,'live checks passed');
diff --git a/tools/virtual-html-exam-live.mjs b/tools/virtual-html-exam-live.mjs
new file mode 100644
index 0000000..3214669
--- /dev/null
+++ b/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;indexBoolean(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(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()}
diff --git a/tools/virtual-html-inspect.mjs b/tools/virtual-html-inspect.mjs
new file mode 100644
index 0000000..f9a6585
--- /dev/null
+++ b/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();
diff --git a/tools/virtual-html-live.mjs b/tools/virtual-html-live.mjs
new file mode 100644
index 0000000..36821da
--- /dev/null
+++ b/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();}
diff --git a/tools/virtual-html-media.mjs b/tools/virtual-html-media.mjs
new file mode 100644
index 0000000..c4b4ec8
--- /dev/null
+++ b/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();}
diff --git a/tools/virtual-html-report.mjs b/tools/virtual-html-report.mjs
new file mode 100644
index 0000000..406f5b7
--- /dev/null
+++ b/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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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',`虚拟仿真 JS+HTML 正式工作区验收
+虚拟仿真 JS+HTML 正式工作区验收 2026-09-12 · 开发环境 · Edge 无头浏览器 · 1920×1080、1440×900
+实施结果 虚拟仿真的运行、预览使用 JS+HTML 工作区。Vue 负责平台路由、认证、挂载和接口适配;任务列表继续使用正式教学接口。已有 /api、/legacy 运行与预览地址统一进入同一工作区。本次通过增量修改实现,没有 Git 回滚。
+恢复结构树、聚焦、复位视角、标签、高亮问题设备、隐藏其他、爆炸视图与展开距离控制,以及左下角数字教员形象。
+场景来自任务固定发布版本;保留稳定部件 ID、模型变换、材质、相机、灯光与原生场景对象。禁止缺资源时替换成示例装备。
+模型编辑器编排的旋转/枢轴、位移、材质状态和流向动画复用同一回放实现;片段保持末帧或复位。展开爆炸图时停止动画,收起后重播当前片段。
+工具、安全、测量记录接入服务端检查点;真实点击/拖动经接口确认后推进。请求失败保留当前步骤;所有步骤完成后调用正式提交接口。
+数字人服务生成或资源库导入的成片,使用任务授权资源播放;教员预览不生成学员成绩,观察其他人的运行只读。
+准备面板放在左侧步骤栏,限制展开高度;长标题省略,避免遮挡数字教员和查看工具。
+
+实测 ${checks.length} 项通过 检查 结果 ${checks.map(check=>`${escape(check.name)} 通过 `).join('')}
浏览器未捕获未处理 JavaScript 异常。断网用例实际拦截一次进度请求,验证失败时服务端和界面均未完成该步骤,随后重试成功。
+构建与自动检查 pnpm build:包含 Vue 类型检查、生产构建及模型编辑器发布产物验证,通过。路由、检查点、多工具、步骤事实与命令幂等相关检查 20 项通过;原生动画绑定、末帧保持和停止释放检查 1 项通过。 后端完整 Maven reactor mvn test 通过;新增学员视觉投影测试,验证动画和材质状态保留,自定义组件与私有字段不下发。 无数据库或 DDL 变更。部署时前端与 Tran 服务一起更新。正式考试专用运行入口保持原有实现,本报告不将它计入本次迁移验收。
+保留的验收记录 学员 ID ${escape(fixture.userId)};任务 ID ${escape(fixture.assignmentId)};运行 ID ${escape(fixture.runId)},状态已提交。媒体预览任务:${media.checks.map(c=>`${escape(c.assignmentId)}`).join('、')}。开发过程中使用独立验收副本,原训练内容未覆盖。
狐狸历史训练没有指定动画片段,因此实测验证原生 Survey / Walk / Run 资源完整加载;原生片段实际回放、末帧保持、复位与释放另由自动测试验证。
原始结果:操作检查 · 媒体检查 · 测试记录 ID
+${shots.map(([file,title])=>`${escape(title)} `).join('')}
+ `);
+console.log(root+'/index.html');
diff --git a/tools/virtual-html-workflow-live.mjs b/tools/virtual-html-workflow-live.mjs
new file mode 100644
index 0000000..256706d
--- /dev/null
+++ b/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()}
diff --git a/tools/workflow-command-migration.py b/tools/workflow-command-migration.py
new file mode 100644
index 0000000..10207aa
--- /dev/null
+++ b/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')
diff --git a/tools/workflow-migration.py b/tools/workflow-migration.py
new file mode 100644
index 0000000..a548c5c
--- /dev/null
+++ b/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')