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_gateway10 / 0
ut_auth1340 / 0
ut_tran4170 / 0

构建存在现有大分块提示。原型同步检查提示 upstream 与同步基线存在差异,本次未执行覆盖式原型同步。

脚本:virtual-html-exam-live.mjsvirtual-html-workflow-live.mjsvirtual-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 回滚。

+

实测 9 项通过

检查结果
桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员通过
卡车:正式工作区播放四种已编排动画轨道并保持末帧通过
学员:工具、安全、测量记录通过真实接口保存并刷新恢复通过
进度请求失败时不在本地提前完成步骤通过
步骤完成服务器确认后推进,下一步动画按发布片段复位通过
真实两步操作完成、服务端判定、提交结果闭环通过
维护账号观察他人运行时只读,不修改学员进度通过
狐狸历史固定场景与 GLB 原生动画资源加载;1440×900 布局通过
数字人服务成片通过任务授权资源播放通过

浏览器未捕获未处理 JavaScript 异常。断网用例实际拦截一次进度请求,验证失败时服务端和界面均未完成该步骤,随后重试成功。

+

构建与自动检查

无数据库或 DDL 变更。部署时前端与 Tran 服务一起更新。正式考试专用运行入口保持原有实现,本报告不将它计入本次迁移验收。

+

保留的验收记录

学员 ID 141;任务 ID 66;运行 ID 67,状态已提交。媒体预览任务:6768。开发过程中使用独立验收副本,原训练内容未覆盖。

狐狸历史训练没有指定动画片段,因此实测验证原生 Survey / Walk / Run 资源完整加载;原生片段实际回放、末帧保持、复位与释放另由自动测试验证。

原始结果:操作检查 · 媒体检查 · 测试记录 ID

+
桥梁:结构树、聚焦、目标高亮、标签与数字教员
桥梁:结构树、聚焦、目标高亮、标签与数字教员
桥梁:真实部件爆炸视图
桥梁:真实部件爆炸视图
学员:工具、安全与测量记录刷新恢复
学员:工具、安全与测量记录刷新恢复
完成真实三维操作,提交服务端
完成真实三维操作,提交服务端
维护账号只读观察学员运行
维护账号只读观察学员运行
狐狸:历史发布场景五个对象完整加载,1440×900 布局
狐狸:历史发布场景五个对象完整加载,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=>``).join('')}
检查结果
${escape(c.name)}通过
+

验证命令与证据

pnpm run build(含 vue-tsc 与正式资源构建校验);node --test 执行 teaching/virtual/training 的 *.test.mjs;Java 17 下 mvn test

${Object.entries(backend).map(([name,value])=>``).join('')}
后端模块测试数失败 / 错误
${name}${value.tests}${value.failures} / ${value.errors}

构建存在现有大分块提示。原型同步检查提示 upstream 与同步基线存在差异,本次未执行覆盖式原型同步。

脚本:virtual-html-exam-live.mjsvirtual-html-workflow-live.mjsvirtual-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)}
${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=>``).join('')}
检查结果
${escape(check.name)}通过

浏览器未捕获未处理 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)}
${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')