選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

751 行
50 KiB

  1. import type { Page, Route } from '@playwright/test'
  2. export type TeachingIdentity = 'admin' | 'administrative' | 'teacher' | 'studentLeader' | 'studentOperator' | 'unionAdministrativeStudent'
  3. type Channel = 'VIRTUAL' | 'PHYSICAL' | 'CONFRONTATION'
  4. type AssignmentKind = 'TRAINING' | 'FORMAL_EXAM'
  5. type JsonRecord = Record<string, unknown>
  6. const stepCodeOf = (step: JsonRecord) => String(step.stepCode ?? step.code ?? step.id ?? '')
  7. export interface RecordedTeachingRequest {
  8. method: string
  9. path: string
  10. query: URLSearchParams
  11. body: JsonRecord
  12. }
  13. const allTeachingPages = [
  14. 'teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides',
  15. 'teaching.immersive', 'teaching.exams', 'teaching.analytics',
  16. ]
  17. const managerActions = ['teaching.tasks.create', 'teaching.tasks.publish', 'teaching.tasks.withdraw', 'teaching.tasks.review', 'teaching.tasks.archive']
  18. const learnerActions = ['teaching.tasks.accept', 'teaching.tasks.submit', 'teaching.virtual.execute', 'teaching.physical.execute', 'teaching.confrontation.execute', 'teaching.immersive.execute']
  19. const permissions: Record<TeachingIdentity, string[]> = {
  20. admin: [...allTeachingPages, ...managerActions, ...learnerActions, 'teaching.confrontation.configure'],
  21. administrative: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.immersive', 'teaching.exams', 'teaching.analytics'],
  22. teacher: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.immersive', 'teaching.exams', 'teaching.analytics', ...managerActions, 'teaching.confrontation.configure', 'teaching.immersive.execute'],
  23. studentLeader: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', ...learnerActions],
  24. studentOperator: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', ...learnerActions],
  25. unionAdministrativeStudent: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', 'teaching.analytics', ...learnerActions],
  26. }
  27. const roleNames = { admin: '管理员', administrative: '行政', teacher: '教员', student: '学员' }
  28. const member = (
  29. id: string,
  30. userId: string,
  31. username: string,
  32. displayName: string,
  33. memberType: 'INSTRUCTOR' | 'LEARNER' | 'OBSERVER',
  34. positionCode: string,
  35. teamCode: 'NEUTRAL' | 'RED' | 'BLUE',
  36. groupCode: string,
  37. sortOrder: number,
  38. ) => ({
  39. id, userId, username, displayName, departmentId: '110', departmentName: '维修教研室',
  40. memberType, roleCode: memberType === 'INSTRUCTOR' ? 'teacher' : memberType === 'LEARNER' ? 'student' : 'observer',
  41. teamCode, groupCode, positionCode, stationCode: '', sortOrder, version: 1,
  42. })
  43. export const teachingMembers = {
  44. instructor: member('member-teacher', '2', 'teacher', '周教员', 'INSTRUCTOR', 'INSTRUCTOR', 'NEUTRAL', 'INSTRUCTORS', 0),
  45. leader: member('member-red-leader', '4', 'student.leader', '红方队长', 'LEARNER', 'TEAM_LEADER', 'RED', 'GROUP-RED', 1),
  46. operator: member('member-red-operator', '5', 'student.operator', '红方操作员', 'LEARNER', 'OPERATOR', 'RED', 'GROUP-RED', 2),
  47. commander: member('member-blue-commander', '7', 'student.commander', '蓝方指挥员', 'LEARNER', 'COMMANDER', 'BLUE', 'GROUP-BLUE', 3),
  48. observer: member('member-observer', '6', 'observer', '安全观察员', 'OBSERVER', 'OBSERVER', 'NEUTRAL', 'OBSERVERS', 4),
  49. }
  50. const twoStepDefinition = (channel: Channel) => ({
  51. schema: 'unreal-tran.training',
  52. version: '2.0',
  53. scenario: {
  54. schema: 'unreal-tran.scene', version: '2.0',
  55. objects: [{
  56. id: `${channel.toLowerCase()}-equipment`, type: 'model', name: '履带式挖掘机训练模型',
  57. assetUrl: 'content://sha256/contract-excavator', assetCode: 'EXCAVATOR_A',
  58. targetProjectId: 'asset-project-1', targetVersionId: 'asset-version-1',
  59. position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], visible: true,
  60. semantic: { interactionId: 'excavator', operable: true },
  61. }],
  62. },
  63. steps: channel === 'PHYSICAL'
  64. ? [
  65. { code: 'STEP-01', title: '确认设备断电', publicSummary: '完成现场断电与挂牌。', targetId: `${channel.toLowerCase()}-equipment`, physicalExecution: { source: 'MANUAL', eventType: 'MANUAL_CONFIRM_POWER_OFF', manualConfirmAllowed: true } },
  66. { code: 'STEP-02', title: '确认液压卸压', publicSummary: '完成液压系统卸压。', targetId: `${channel.toLowerCase()}-equipment`, physicalExecution: { source: 'MANUAL', eventType: 'MANUAL_CONFIRM_PRESSURE_RELEASED', manualConfirmAllowed: true } },
  67. ]
  68. : channel === 'CONFRONTATION'
  69. ? [
  70. { code: 'STEP-01', title: '队长下达处置指令', publicSummary: '共享任务由队长发起。', actionCode: 'ISSUE_COMMAND', allowedPositions: ['TEAM_LEADER'], targetId: `${channel.toLowerCase()}-equipment` },
  71. { code: 'STEP-02', title: '操作员执行故障隔离', publicSummary: '操作员按指令隔离故障。', actionCode: 'ISOLATE_FAULT', requiredPositions: ['OPERATOR'], targetId: `${channel.toLowerCase()}-equipment` },
  72. ]
  73. : [
  74. { stepCode: 'STEP-01', title: '识别作业对象', publicSummary: '确认训练对象与安全边界。', actionCode: 'IDENTIFY_TARGET', targetId: `${channel.toLowerCase()}-equipment` },
  75. { stepCode: 'STEP-02', title: '完成检修操作', publicSummary: '按流程完成检修并复核。', actionCode: 'COMPLETE_REPAIR', targetId: `${channel.toLowerCase()}-equipment` },
  76. ],
  77. runtime: { submitPositionCodes: channel === 'CONFRONTATION' ? ['TEAM_LEADER', 'COMMANDER'] : [] },
  78. modeConfig: channel === 'CONFRONTATION' ? {
  79. objective: '红蓝双方按岗位协同完成液压故障隔离',
  80. roundDurationMinutes: 20,
  81. teamSize: 2,
  82. winRule: '在安全约束下按完成度与用时综合判定',
  83. redTeamName: '红方维修组',
  84. blueTeamName: '蓝方保障组',
  85. submitPositions: ['TEAM_LEADER', 'COMMANDER'],
  86. teams: [
  87. { code: 'RED', name: '红方维修组', color: '#d25c5c', size: 2 },
  88. { code: 'BLUE', name: '蓝方保障组', color: '#4b7ed6', size: 2 },
  89. ],
  90. } : {},
  91. })
  92. const virtualPreviewDefinition = () => ({
  93. schema: 'unreal-tran.training',
  94. version: '1.0',
  95. level: '中修',
  96. skills: ['总成拆检', '部件更换', '功能复验'],
  97. scenario: {
  98. schema: 'unreal-tran.scene',
  99. version: '2.0',
  100. name: '支腿液压缸中修训练场景',
  101. sceneType: 'WORKSHOP',
  102. objects: [{
  103. id: 'virtual-equipment', type: 'model', name: '支腿液压缸工位',
  104. assetUrl: 'content://sha256/contract-excavator', assetCode: 'EXCAVATOR_A',
  105. targetProjectId: 'asset-project-1', targetVersionId: 'asset-version-1',
  106. position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], visible: true,
  107. semantic: { interactionId: 'hydraulic-cylinder', operable: true },
  108. }],
  109. },
  110. steps: [
  111. ['STEP-01', '卸压确认与故障基线采集', 'RELEASE_PRESSURE_BASELINE', '确认系统卸压并采集故障基线。', '确认压力表回零,记录故障压力和泄漏现象。'],
  112. ['STEP-02', '油管与接头外泄检查', 'CHECK_EXTERNAL_LEAK', '检查油管、接头与密封面的外泄。', '沿管路逐点检查,确认无高压喷射风险。'],
  113. ['STEP-03', '测量系统故障压力', 'MEASURE_FAULT_PRESSURE', '连接压力表并测量系统故障压力。', '按量程选择测点,读取并记录稳定压力值。'],
  114. ['STEP-04', '保压定位液压缸内泄', 'LOCATE_INTERNAL_LEAK', '执行保压试验并定位液压缸内泄。', '隔离负载后观察压力衰减,判断内泄位置。'],
  115. ['STEP-05', '更换活塞密封组件', 'REPLACE_PISTON_SEAL', '拆检并更换活塞密封组件。', '核对密封方向,避免划伤密封唇口和缸筒。'],
  116. ['STEP-06', '缸盖复位与力矩紧固', 'TORQUE_CYLINDER_HEAD', '复位缸盖并按规定力矩紧固。', '按对角顺序分级紧固,记录最终力矩。'],
  117. ['STEP-07', '复测工作压力与响应', 'VERIFY_PRESSURE_RESPONSE', '复测工作压力、动作速度与响应。', '低压试运转后逐级升压,观察动作是否平稳。'],
  118. ['STEP-08', '密封复验与中修闭环', 'VERIFY_SEAL_AND_CLOSE', '完成密封复验并形成中修闭环。', '确认无泄漏、参数合格,清点工具并恢复防护。'],
  119. ].map(([stepCode, title, actionCode, publicSummary, narrationText], index) => ({
  120. stepCode,
  121. title,
  122. actionCode,
  123. publicSummary,
  124. instruction: narrationText,
  125. narrationText,
  126. safety: index === 0 ? '执行前确认能量隔离与作业区域安全。' : '按维修作业规程佩戴防护用品并保持工位整洁。',
  127. targetId: 'virtual-equipment',
  128. targetName: '支腿液压缸工位',
  129. score: index === 7 ? 16 : 12,
  130. })),
  131. runtime: { teacherEnabled: true, teacherVoiceEnabled: true, allowReplay: true },
  132. digitalInstructor: {
  133. enabled: true,
  134. agentSlug: 'dh-b69424ed0904',
  135. agentName: '点火系统维修教研员',
  136. webBaseUrl: '/ute2e-ai-person',
  137. },
  138. modeConfig: { allowedModes: ['DEMO', 'PRACTICE', 'MOCK_EXAM'] },
  139. })
  140. const assignment = (id: string, channel: Channel, kind: AssignmentKind, audienceType: 'ASSIGNED' | 'COMMON', collaborationMode: 'INDIVIDUAL' | 'TEAM') => ({
  141. id,
  142. code: kind === 'FORMAL_EXAM' ? 'EXAM-2026-001' : `TASK-${channel}-001`,
  143. name: kind === 'FORMAL_EXAM' ? `${channel === 'PHYSICAL' ? '实装' : channel === 'CONFRONTATION' ? '对抗' : '虚拟'}维修正式考核` : `${channel === 'PHYSICAL' ? '实装' : channel === 'CONFRONTATION' ? '对抗' : '虚拟'}维修训练`,
  144. description: '状态化 UI 契约测试任务。', channel, assignmentKind: kind,
  145. executionMode: kind === 'FORMAL_EXAM' ? 'EXAM' : 'PRACTICE', audienceType, collaborationMode,
  146. contentProjectId: 'training-project-1', contentVersionId: 'training-version-1',
  147. definitionSnapshot: twoStepDefinition(channel), definitionChecksum: `checksum-${id}`,
  148. digitalHumanAllowed: kind !== 'FORMAL_EXAM', ownerUserId: '2', ownerUsername: 'teacher', ownerName: '周教员',
  149. departmentId: '110', departmentName: '维修教研室', status: 'PUBLISHED',
  150. scheduleStartAt: 1786383600, dueAt: 1786473600, publishedAt: 1786380000,
  151. withdrawnAt: null, archivedAt: null, lifecycleReason: '', version: 3,
  152. addTime: 1786300800, updateTime: '2026-08-11T08:00:00+08:00', serverNow: 1786387200,
  153. members: audienceType === 'COMMON'
  154. ? [teachingMembers.instructor]
  155. : channel === 'CONFRONTATION'
  156. ? Object.values(teachingMembers)
  157. : [teachingMembers.instructor, teachingMembers.leader],
  158. runCount: 1, pendingReviewCount: 0,
  159. })
  160. const assignmentBrief = (item: JsonRecord) => ({
  161. id: item.id, code: item.code, name: item.name, channel: item.channel, assignmentKind: item.assignmentKind,
  162. executionMode: item.executionMode, audienceType: item.audienceType, collaborationMode: item.collaborationMode,
  163. digitalHumanAllowed: item.digitalHumanAllowed, status: item.status,
  164. scheduleStartAt: item.scheduleStartAt, dueAt: item.dueAt, serverNow: item.serverNow,
  165. })
  166. const runFor = (id: string, task: JsonRecord, status: 'PENDING' | 'ACCEPTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'REVIEWED', learner = teachingMembers.leader) => ({
  167. id, code: `RUN-${String(task.channel)}-${id.slice(-3).toUpperCase()}`, assignmentId: task.id,
  168. memberId: learner.id, subjectType: task.collaborationMode === 'TEAM' ? 'TEAM' : 'MEMBER',
  169. subjectCode: task.collaborationMode === 'TEAM' ? learner.teamCode : learner.id,
  170. attemptNo: 1, status, progressPercent: status === 'SUBMITTED' || status === 'REVIEWED' ? 100 : 0,
  171. currentStepCode: 'STEP-01', checkpoint: {}, automaticResult: {}, submission: status === 'SUBMITTED' || status === 'REVIEWED' ? { summary: '已完成' } : null,
  172. score: status === 'REVIEWED' ? 88 : null, passed: status === 'REVIEWED' ? true : null,
  173. reviewFeedback: status === 'REVIEWED' ? '流程与操作符合要求。' : '', reviewRubric: {},
  174. acceptedAt: status === 'PENDING' ? null : 1786386000, startedAt: ['PENDING', 'ACCEPTED'].includes(status) ? null : 1786387200,
  175. submittedAt: status === 'SUBMITTED' || status === 'REVIEWED' ? 1786390800 : null,
  176. reviewedAt: status === 'REVIEWED' ? 1786394400 : null, reviewedByUserId: status === 'REVIEWED' ? '2' : null,
  177. reviewerName: status === 'REVIEWED' ? '周教员' : '', archivedAt: null, version: 2,
  178. addTime: 1786386000, updateTime: '2026-08-11T10:00:00+08:00', assignment: assignmentBrief(task),
  179. member: learner, members: task.collaborationMode === 'TEAM' ? Object.values(teachingMembers) : [teachingMembers.instructor, learner],
  180. })
  181. const envelope = (data: unknown, requestId = 'ute2e-teaching-state') => ({
  182. code: 200, message: '成功', data, timestamp: '2026-08-11T10:00:00+08:00', requestId,
  183. })
  184. const pageResult = (records: unknown[], page = 1, size = 10) => ({ records, total: records.length, page, size })
  185. const bodyOf = (route: Route): JsonRecord => {
  186. const text = route.request().postData()
  187. if (!text) return {}
  188. const value = JSON.parse(text) as unknown
  189. return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
  190. }
  191. const fulfill = (route: Route, data: unknown, requestId?: string) => route.fulfill({
  192. status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data, requestId)),
  193. })
  194. const reject = (route: Route, status: number, message: string) => route.fulfill({
  195. status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-rejected' }),
  196. })
  197. export class TeachingContractMock {
  198. identity: TeachingIdentity
  199. readonly requests: RecordedTeachingRequest[] = []
  200. readonly assignments = new Map<string, JsonRecord>()
  201. readonly runs = new Map<string, JsonRecord>()
  202. readonly events = new Map<string, JsonRecord[]>()
  203. readonly interventions: JsonRecord[] = []
  204. readonly deniedPermissions = new Set<string>()
  205. administrativeRunsRejected = 0
  206. administrativeAnalyticsRecordsRejected = 0
  207. digitalInstructorFrameLoads = 0
  208. constructor(identity: TeachingIdentity) {
  209. this.identity = identity
  210. const previewTask = {
  211. ...assignment('assignment-virtual-preview', 'VIRTUAL', 'TRAINING', 'ASSIGNED', 'INDIVIDUAL'),
  212. code: 'TASK-VIRTUAL-PREVIEW-001',
  213. name: '支腿液压缸中修训练(虚拟仿真)',
  214. description: '面向液压密封件检查与更换的八步虚拟训练任务。',
  215. definitionSnapshot: virtualPreviewDefinition(),
  216. runCount: 0,
  217. pendingReviewCount: 0,
  218. listed: false,
  219. }
  220. const tasks = [
  221. assignment('assignment-virtual-common', 'VIRTUAL', 'TRAINING', 'COMMON', 'INDIVIDUAL'),
  222. previewTask,
  223. assignment('assignment-physical', 'PHYSICAL', 'TRAINING', 'ASSIGNED', 'INDIVIDUAL'),
  224. assignment('assignment-confrontation', 'CONFRONTATION', 'TRAINING', 'ASSIGNED', 'TEAM'),
  225. assignment('assignment-exam', 'VIRTUAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'),
  226. assignment('assignment-exam-physical', 'PHYSICAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'),
  227. assignment('assignment-exam-confrontation', 'CONFRONTATION', 'FORMAL_EXAM', 'ASSIGNED', 'TEAM'),
  228. ]
  229. tasks.forEach((task) => this.assignments.set(String(task.id), task))
  230. this.runs.set('run-physical', runFor('run-physical', this.requireAssignment('assignment-physical'), 'ACCEPTED'))
  231. this.runs.set('run-confrontation', runFor('run-confrontation', this.requireAssignment('assignment-confrontation'), 'IN_PROGRESS'))
  232. this.runs.set('run-exam', runFor('run-exam', this.requireAssignment('assignment-exam'), 'IN_PROGRESS'))
  233. }
  234. setIdentity(identity: TeachingIdentity) { this.identity = identity }
  235. denyPermissions(...codes: string[]) { codes.forEach((code) => this.deniedPermissions.add(code)) }
  236. seedImmersiveRun() {
  237. const task = this.requireAssignment('assignment-virtual-common')
  238. const runtime = runFor('run-immersive', task, 'IN_PROGRESS')
  239. runtime.currentStepCode = 'STEP-01'
  240. this.runs.set(String(runtime.id), runtime)
  241. }
  242. seedVirtualReviewedRun() {
  243. const task = this.requireAssignment('assignment-virtual-common')
  244. const runtime = runFor('run-virtual-reviewed', task, 'REVIEWED')
  245. runtime.currentStepCode = 'STEP-02'
  246. runtime.progressPercent = 100
  247. runtime.score = 92
  248. runtime.passed = true
  249. runtime.reviewFeedback = '步骤完整,安全确认和检修复核符合要求。'
  250. runtime.checkpoint = { completedStepCodes: ['STEP-01', 'STEP-02'] }
  251. this.runs.set(String(runtime.id), runtime)
  252. this.events.set(String(runtime.id), [
  253. {
  254. id: 'event-virtual-reviewed-1', code: 'EVENT-VIRTUAL-001', assignmentId: task.id, runId: runtime.id,
  255. targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:识别作业对象',
  256. summary: '确认训练对象与安全边界。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' },
  257. actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387260,
  258. },
  259. {
  260. id: 'event-virtual-reviewed-2', code: 'EVENT-VIRTUAL-002', assignmentId: task.id, runId: runtime.id,
  261. targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:完成检修操作',
  262. summary: '按流程完成检修并复核。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-02' },
  263. actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387560,
  264. },
  265. ])
  266. return runtime
  267. }
  268. seedConfrontationMonitorRuns() {
  269. const task = this.requireAssignment('assignment-confrontation')
  270. const active = this.runs.get('run-confrontation')!
  271. active.status = 'IN_PROGRESS'
  272. active.subjectCode = 'RED'
  273. active.members = [teachingMembers.instructor, teachingMembers.leader, teachingMembers.operator]
  274. active.progressPercent = 50
  275. active.currentStepCode = 'STEP-02'
  276. this.events.set(String(active.id), [{
  277. id: 'event-confrontation-red-1', code: 'EVENT-CON-RED-001', assignmentId: task.id, runId: active.id,
  278. targetType: 'RUN', targetId: active.id, type: 'training.action.completed', title: '队长已下达处置指令',
  279. summary: '红方进入故障隔离阶段。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' },
  280. actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387320,
  281. }])
  282. const blue = runFor('run-confrontation-blue', task, 'ACCEPTED', teachingMembers.commander)
  283. blue.subjectCode = 'BLUE'
  284. blue.members = [teachingMembers.instructor, teachingMembers.commander]
  285. blue.currentStepCode = 'STEP-01'
  286. blue.progressPercent = 0
  287. this.runs.set(String(blue.id), blue)
  288. this.events.set(String(blue.id), [])
  289. return { active, blue }
  290. }
  291. seedExpiredRun() {
  292. const task = this.requireAssignment('assignment-virtual-common')
  293. task.dueAt = Number(task.serverNow) - 60
  294. const runtime = runFor('run-timeout', task, 'IN_PROGRESS')
  295. runtime.startedAt = Number(task.serverNow) - 3_600
  296. runtime.progressPercent = 50
  297. this.runs.set(String(runtime.id), runtime)
  298. return runtime
  299. }
  300. seedTerminableRun() {
  301. const task = this.requireAssignment('assignment-virtual-common')
  302. task.dueAt = null
  303. const runtime = runFor('run-terminate', task, 'IN_PROGRESS')
  304. runtime.startedAt = Number(task.serverNow) - 600
  305. runtime.progressPercent = 25
  306. this.runs.set(String(runtime.id), runtime)
  307. return runtime
  308. }
  309. seedConfrontationTerminationCases() {
  310. const task = this.requireAssignment('assignment-confrontation')
  311. task.dueAt = null
  312. const active = this.runs.get('run-confrontation')!
  313. active.status = 'IN_PROGRESS'
  314. active.startedAt = Number(task.serverNow) - 21 * 60
  315. active.progressPercent = 50
  316. const accepted = runFor('run-confrontation-queued', task, 'ACCEPTED', teachingMembers.operator)
  317. this.runs.set(String(accepted.id), accepted)
  318. return { active, accepted }
  319. }
  320. keepOnlyRuns(...runIds: string[]) {
  321. const selected = runIds.map((id) => this.runs.get(id)).filter((item): item is JsonRecord => Boolean(item))
  322. this.runs.clear()
  323. selected.forEach((item) => this.runs.set(String(item.id), item))
  324. }
  325. count(method: string, path: string | RegExp) {
  326. return this.requests.filter((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path))).length
  327. }
  328. last(method: string, path: string | RegExp) {
  329. return [...this.requests].reverse().find((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path)))
  330. }
  331. private requireAssignment(id: string) {
  332. const item = this.assignments.get(id)
  333. if (!item) throw new Error(`测试任务不存在:${id}`)
  334. return item
  335. }
  336. private currentUserId() {
  337. if (this.identity === 'studentOperator') return '5'
  338. if (this.identity === 'studentLeader' || this.identity === 'unionAdministrativeStudent') return '4'
  339. if (this.identity === 'teacher') return '2'
  340. if (this.identity === 'administrative') return '3'
  341. return '1'
  342. }
  343. private profile() {
  344. const union = this.identity === 'unionAdministrativeStudent'
  345. const role = this.identity === 'teacher' ? 'teacher'
  346. : this.identity === 'administrative' || union ? 'administrative'
  347. : this.identity === 'studentLeader' || this.identity === 'studentOperator' ? 'student' : 'admin'
  348. const userId = this.currentUserId()
  349. const displayName = this.identity === 'studentOperator' ? '红方操作员' : this.identity === 'studentLeader' || union ? '红方队长' : roleNames[role]
  350. const roles = union
  351. ? [
  352. { id: 'role-administrative', code: 'administrative', name: '行政', shortName: '行', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'DEPARTMENT' },
  353. { id: 'role-student', code: 'student', name: '学员', shortName: '学', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'SELF' },
  354. ]
  355. : [{ id: `role-${role}`, code: role, name: roleNames[role], shortName: roleNames[role].slice(0, 1), status: 1, builtIn: 1, isSuperAdmin: role === 'admin' ? 1 : 0, dataScopeCode: role === 'admin' ? 'ALL' : role === 'student' ? 'SELF' : 'DEPARTMENT' }]
  356. return {
  357. user: { id: userId, username: this.identity, displayName, departmentId: '110', departmentName: '维修教研室', mustChangePassword: false, version: 1 },
  358. activeRoleId: union ? 'role-administrative' : `role-${role}`, roles,
  359. permissions: permissions[this.identity].filter((permission) => !this.deniedPermissions.has(permission)), authorizationMode: union ? 'UNION' : 'SINGLE_ACTIVE', loginTime: 1786387200,
  360. }
  361. }
  362. private currentMember(run: JsonRecord) {
  363. const values = Array.isArray(run.members) ? run.members as JsonRecord[] : []
  364. return values.find((item) => item.userId === this.currentUserId() && item.memberType === 'LEARNER')
  365. }
  366. private visibleRuns() {
  367. const values = [...this.runs.values()]
  368. if (this.identity === 'teacher' || this.identity === 'admin') return values
  369. const userId = this.currentUserId()
  370. return values.filter((item) => {
  371. const values = Array.isArray(item.members) ? item.members as JsonRecord[] : []
  372. return values.some((member) => member.userId === userId && member.memberType === 'LEARNER')
  373. })
  374. }
  375. private stepsFor(run: JsonRecord) {
  376. const task = this.requireAssignment(String(run.assignmentId))
  377. const definition = task.definitionSnapshot as JsonRecord
  378. return (definition.steps as JsonRecord[]) ?? []
  379. }
  380. private advanceRun(route: Route, run: JsonRecord, completedStepCode: string, actionCode?: string, respond = true) {
  381. const steps = this.stepsFor(run)
  382. const currentCode = String(run.currentStepCode)
  383. if (completedStepCode !== currentCode) return reject(route, 409, `完成步骤必须等于当前待办步骤 ${currentCode}`)
  384. const index = steps.findIndex((step) => stepCodeOf(step) === completedStepCode)
  385. if (index < 0) return reject(route, 400, '步骤不属于发布任务')
  386. const task = this.requireAssignment(String(run.assignmentId))
  387. if (task.channel === 'CONFRONTATION') {
  388. const current = this.currentMember(run)
  389. const allowed = (steps[index]!.allowedPositions ?? steps[index]!.requiredPositions ?? []) as string[]
  390. if (!current || !allowed.includes(String(current.positionCode))) return reject(route, 403, '当前岗位不能执行此动作')
  391. if (actionCode !== steps[index]!.actionCode) return reject(route, 400, 'actionCode 与当前步骤不匹配')
  392. }
  393. const next = steps[Math.min(index + 1, steps.length - 1)]!
  394. run.progressPercent = Math.round(((index + 1) * 100) / steps.length)
  395. run.currentStepCode = stepCodeOf(next)
  396. run.version = Number(run.version) + 1
  397. const events = this.events.get(String(run.id)) ?? []
  398. events.push({
  399. id: `event-${events.length + 1}`, code: `EVENT-${events.length + 1}`, assignmentId: run.assignmentId, runId: run.id,
  400. targetType: 'RUN', targetId: run.id, type: 'training.action.completed', title: `完成:${steps[index]!.title}`,
  401. summary: steps[index]!.publicSummary, source: 'web', visibility: 'PUBLIC', payload: { completedStepCode, actionCode },
  402. actorUserId: this.currentUserId(), actorDisplayName: this.profile().user.displayName, occurredAt: 1786387200 + events.length,
  403. })
  404. this.events.set(String(run.id), events)
  405. return respond ? fulfill(route, run, 'ute2e-progress-authoritative') : Promise.resolve()
  406. }
  407. async install(page: Page) {
  408. await page.addInitScript(() => sessionStorage.setItem('unreal-tran:web:access-token:v1', 'teaching-contract-token'))
  409. await page.route('**/api/auth/v1/auth/me', (route) => fulfill(route, this.profile(), 'ute2e-teaching-me'))
  410. await page.route('**/api/auth/v1/menus/navigation', (route) => fulfill(route, [], 'ute2e-teaching-menu'))
  411. await page.route('**/api/auth/v1/directory/teaching-members**', (route) => fulfill(route, [
  412. { id: '4', username: 'student.leader', displayName: '红方队长', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] },
  413. { id: '5', username: 'student.operator', displayName: '红方操作员', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] },
  414. ], 'ute2e-teaching-directory'))
  415. await page.route('**/api/tran/v1/content/catalog**', (route) => fulfill(route, pageResult([{
  416. projectId: 'training-project-1', versionId: 'training-version-1', type: 'TRAINING', code: 'TRAIN-001', name: '挖掘机液压系统训练', categoryCode: 'VIRTUAL', coverUri: '', versionNumber: 2, versionStatus: 'PUBLISHED', publishedAt: '2026-08-11T08:00:00+08:00', content: { steps: [] },
  417. }]), 'ute2e-teaching-catalog'))
  418. await page.route(/\/embed\/dh-b69424ed0904(?:\?.*)?$/, (route) => {
  419. this.digitalInstructorFrameLoads += 1
  420. return route.fulfill({
  421. status: 200,
  422. contentType: 'text/html; charset=utf-8',
  423. body: '<!doctype html><html lang="zh-CN"><body><main data-testid="mock-ai-person">虚拟教员在线答疑</main></body></html>',
  424. })
  425. })
  426. await page.route('**/api/tran/v1/teaching/**', (route) => this.handleTeaching(route))
  427. }
  428. private async handleTeaching(route: Route) {
  429. const request = route.request()
  430. const url = new URL(request.url())
  431. const path = url.pathname.replace(/^.*\/teaching/, '')
  432. const method = request.method()
  433. const body = bodyOf(route)
  434. this.requests.push({ method, path, query: new URLSearchParams(url.searchParams), body })
  435. if (method === 'GET' && path === '/guides') return fulfill(route, pageResult([{
  436. projectId: 'guide-project-1', versionId: 'guide-version-1', code: 'GUIDE-001', name: '挖掘机检修作业指导书', description: '标准维修作业流程', categoryCode: '维修作业', coverUri: '', equipment: '履带式挖掘机', specification: '教学训练型', chapterCount: 2, stepCount: 3, versionNumber: 3, versionCode: 'V3', publishedAt: 1786387200,
  437. }]), 'ute2e-guides')
  438. if (method === 'GET' && path === '/guides/guide-project-1') return fulfill(route, {
  439. projectId: 'guide-project-1', versionId: 'guide-version-1', code: 'GUIDE-001', name: '挖掘机检修作业指导书', description: '标准维修作业流程', categoryCode: '维修作业', coverUri: '', versionNumber: 3, versionCode: 'V3', publishedAt: 1786387200,
  440. content: {
  441. equipment: '履带式挖掘机', specification: '教学训练型',
  442. chapters: [
  443. {
  444. id: 'chapter-1', title: '安全准备', order: 1,
  445. steps: [
  446. { id: 'step-1', title: '断电隔离', summary: '确认停机、断电并完成安全隔离。', safety: '执行挂牌上锁。', acceptance: '设备处于零能量状态。', duration: '5分钟', type: 'CHECK', tools: '安全锁具', parameters: {}, media: '', part: '动力总成', anchor: '', animation: '', order: 1 },
  447. { id: 'step-2', title: '压力释放', summary: '缓慢释放液压系统残余压力。', safety: '佩戴护目镜并确认卸压方向无人。', acceptance: '压力表回零且系统无残压。', duration: '8分钟', type: 'SAFETY', tools: '压力表', parameters: { '目标压力': '0 MPa' }, media: '', part: '液压回路', anchor: '', animation: '', order: 2 },
  448. ],
  449. },
  450. {
  451. id: 'chapter-2', title: '检修复核', order: 2,
  452. steps: [{ id: 'step-3', title: '复装与复测', summary: '完成部件复装并按标准执行功能复测。', safety: '清点工具并恢复防护装置。', acceptance: '动作平稳、无泄漏且记录完整。', duration: '12分钟', type: 'VERIFY', tools: '扭矩扳手', parameters: { '复测次数': '2 次' }, media: '', part: '执行机构', anchor: '', animation: '', order: 1 }],
  453. },
  454. ],
  455. },
  456. }, 'ute2e-guide-detail')
  457. if (method === 'GET' && path === '/assignments/summary') {
  458. const channel = url.searchParams.get('channel')
  459. const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING'
  460. const tasks = [...this.assignments.values()].filter((item) => item.listed !== false && (!channel || item.channel === channel) && item.assignmentKind === kind)
  461. return fulfill(route, { assignments: tasks.length, draft: 0, published: tasks.length, withdrawn: 0, archived: 0, pendingRuns: 0, activeRuns: this.visibleRuns().filter((item) => item.status === 'IN_PROGRESS').length, submittedRuns: this.visibleRuns().filter((item) => item.status === 'SUBMITTED').length, reviewedRuns: this.visibleRuns().filter((item) => item.status === 'REVIEWED').length, byChannel: Object.fromEntries(tasks.map((item) => [String(item.channel), 1])) }, 'ute2e-summary')
  462. }
  463. if (method === 'GET' && path === '/assignments') {
  464. const channel = url.searchParams.get('channel')
  465. const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING'
  466. const tasks = [...this.assignments.values()].filter((item) => item.listed !== false && (!channel || item.channel === channel) && item.assignmentKind === kind)
  467. return fulfill(route, pageResult(tasks), 'ute2e-assignments')
  468. }
  469. if (method === 'POST' && path === '/assignments') {
  470. if (typeof body.scheduleStartAt !== 'number' || typeof body.dueAt !== 'number' || body.scheduleStartAt >= 1_000_000_000_000 || body.dueAt >= 1_000_000_000_000) return reject(route, 400, '正式考核时间必须是 Unix 秒')
  471. if (body.assignmentKind === 'FORMAL_EXAM' && (body.executionMode !== 'EXAM' || body.digitalHumanAllowed !== false)) return reject(route, 400, '正式考核必须使用 EXAM 且禁用数字教员')
  472. const created = { ...assignment('assignment-created-exam', String(body.channel) as Channel, String(body.assignmentKind) as AssignmentKind, 'ASSIGNED', 'INDIVIDUAL'), ...body, id: 'assignment-created-exam', version: 0, status: 'DRAFT' }
  473. this.assignments.set(String(created.id), created)
  474. return fulfill(route, created, 'ute2e-assignment-created')
  475. }
  476. const assignmentAssetMatch = path.match(/^\/assignments\/([^/]+)\/assets$/)
  477. if (method === 'GET' && assignmentAssetMatch) return fulfill(route, this.assetList(), 'ute2e-assignment-assets')
  478. const assignmentFaultMatch = path.match(/^\/assignments\/([^/]+)\/faults$/)
  479. if (method === 'GET' && assignmentFaultMatch) {
  480. if (this.identity === 'administrative') return reject(route, 403, '行政身份不可查看故障细节')
  481. return fulfill(route, [{
  482. id: 'assignment-fault-public', assignmentId: assignmentFaultMatch[1], faultId: this.identity === 'teacher' || this.identity === 'admin' ? 'fault-1' : null,
  483. faultCode: this.identity === 'teacher' || this.identity === 'admin' ? 'HYDRAULIC.VALVE.STUCK' : '',
  484. faultName: this.identity === 'teacher' || this.identity === 'admin' ? '液压阀卡滞' : '', severity: this.identity === 'teacher' || this.identity === 'admin' ? 'HIGH' : '',
  485. publicSymptom: '动臂响应迟缓且压力波动', privateTruth: this.identity === 'teacher' || this.identity === 'admin' ? { text: '液压阀芯卡滞真值' } : null,
  486. triggerConfig: this.identity === 'teacher' || this.identity === 'admin' ? { hidden: '内部触发条件' } : null,
  487. targetScope: 'TEAM', targetTeamCode: 'RED', targetGroupCode: 'GROUP-RED', sortOrder: 0,
  488. }], 'ute2e-assignment-faults')
  489. }
  490. const interventionListMatch = path.match(/^\/assignments\/([^/]+)\/interventions$/)
  491. if (interventionListMatch && method === 'GET') return fulfill(route, this.interventions, 'ute2e-interventions')
  492. if (interventionListMatch && method === 'POST') {
  493. if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无教学干预权限')
  494. if (!['ASSIGNMENT', 'TEAM', 'MEMBER'].includes(String(body.targetScope))) return reject(route, 400, '干预作用范围不符合契约')
  495. if (body.targetScope === 'TEAM' && !['RED', 'BLUE', 'NEUTRAL'].includes(String(body.targetTeamCode))) return reject(route, 400, 'TEAM 干预必须携带目标队伍')
  496. if (body.targetScope === 'MEMBER' && !body.targetMemberId) return reject(route, 400, 'MEMBER 干预必须携带目标成员')
  497. if (typeof body.publicSummary !== 'string' || !body.publicSummary.trim()) return reject(route, 400, '干预必须提供公开说明')
  498. if (/privateTruth|triggerConfig|solution|answer|truth/i.test(JSON.stringify(body.publicPayload ?? {}))) return reject(route, 400, '公开干预载荷不能包含故障真值或解决方案')
  499. const privatePayload = body.privatePayload ?? {}
  500. const payload = Object.keys(privatePayload as JsonRecord).length
  501. ? { public: body.publicPayload ?? {}, private: privatePayload }
  502. : body.publicPayload ?? {}
  503. const item = {
  504. id: `intervention-${this.interventions.length + 1}`, assignmentId: interventionListMatch[1],
  505. code: body.code, type: body.type, targetScope: body.targetScope,
  506. targetMemberId: body.targetScope === 'MEMBER' ? body.targetMemberId : '',
  507. targetTeamCode: body.targetScope === 'TEAM' ? body.targetTeamCode : '',
  508. targetGroupCode: body.targetScope === 'TEAM' ? body.targetGroupCode ?? '' : '',
  509. publicSummary: body.publicSummary, payload, status: 'DRAFT', createdByUserId: '2', createdByName: '周教员', activatedAt: null, revokedAt: null, version: 0,
  510. }
  511. this.interventions.push(item)
  512. return fulfill(route, item, 'ute2e-intervention-created')
  513. }
  514. const assignmentDetailMatch = path.match(/^\/assignments\/([^/]+)$/)
  515. if (method === 'GET' && assignmentDetailMatch) {
  516. const item = this.requireAssignment(assignmentDetailMatch[1]!)
  517. const learnerProjection = !['admin', 'teacher'].includes(this.identity) && item.assignmentKind === 'FORMAL_EXAM'
  518. ? { ...item, definitionSnapshot: null }
  519. : item
  520. return fulfill(route, learnerProjection, 'ute2e-assignment-detail')
  521. }
  522. const acceptMatch = path.match(/^\/assignments\/([^/]+)\/accept$/)
  523. if (method === 'POST' && acceptMatch) {
  524. const task = this.requireAssignment(acceptMatch[1]!)
  525. if (body.assignmentVersion !== task.version || body.teamCode !== 'NEUTRAL' || body.groupCode !== 'INDIVIDUAL' || body.positionCode !== 'LEARNER') return reject(route, 400, '领取 DTO 不符合契约')
  526. const pending = [...this.runs.values()].find((item) => item.assignmentId === task.id && item.status === 'PENDING')
  527. if (pending) {
  528. pending.status = 'ACCEPTED'
  529. pending.acceptedAt = 1786386000
  530. pending.version = Number(pending.version) + 1
  531. return fulfill(route, pending, 'ute2e-assigned-task-accepted')
  532. }
  533. const created = runFor('run-virtual-common', task, 'ACCEPTED', teachingMembers.leader)
  534. this.runs.set(String(created.id), created)
  535. return fulfill(route, created, 'ute2e-assignment-accepted')
  536. }
  537. if (method === 'GET' && path === '/runs') {
  538. if (this.identity === 'administrative') { this.administrativeRunsRejected += 1; return reject(route, 403, '行政身份不可读取运行明细') }
  539. const assignmentId = url.searchParams.get('assignmentId')
  540. const channel = url.searchParams.get('channel')
  541. const kind = url.searchParams.get('assignmentKind')
  542. const values = this.visibleRuns().filter((item) => (!assignmentId || item.assignmentId === assignmentId) && (!channel || (item.assignment as JsonRecord).channel === channel) && (!kind || (item.assignment as JsonRecord).assignmentKind === kind))
  543. return fulfill(route, pageResult(values), 'ute2e-runs')
  544. }
  545. const runAssetsMatch = path.match(/^\/runs\/([^/]+)\/assets$/)
  546. if (method === 'GET' && runAssetsMatch) return fulfill(route, this.assetList(), 'ute2e-run-assets')
  547. const runExecutionMatch = path.match(/^\/runs\/([^/]+)\/execution$/)
  548. if (method === 'GET' && runExecutionMatch) {
  549. const item = this.runs.get(runExecutionMatch[1]!)
  550. if (!item) return reject(route, 404, '运行不存在')
  551. const task = this.requireAssignment(String(item.assignmentId))
  552. if (task.assignmentKind !== 'FORMAL_EXAM' || item.status !== 'IN_PROGRESS') return reject(route, 409, '仅正式考核进行中运行提供执行投影')
  553. const definition = task.definitionSnapshot as JsonRecord
  554. const steps = (definition.steps as JsonRecord[]) ?? []
  555. const index = Math.max(0, steps.findIndex((step) => stepCodeOf(step) === item.currentStepCode))
  556. return fulfill(route, {
  557. runId: item.id,
  558. assignmentId: item.assignmentId,
  559. currentStepCode: item.currentStepCode,
  560. currentStepIndex: index,
  561. totalSteps: steps.length,
  562. progressPercent: item.progressPercent,
  563. currentStep: steps[index] ?? null,
  564. resources: definition.resources ?? [],
  565. scenario: definition.scenario ?? {},
  566. tools: [],
  567. runtime: { showStepPanel: false, allowReplay: false, allowRollback: false, digitalHumanAllowed: false },
  568. modeConfig: definition.modeConfig ?? {},
  569. examPolicy: { hintsAllowed: false, demoAllowed: false, replayAllowed: false, rollbackAllowed: false, digitalHumanAllowed: false },
  570. deadlineAt: task.dueAt,
  571. serverNow: task.serverNow,
  572. }, 'ute2e-run-execution')
  573. }
  574. const runEventsMatch = path.match(/^\/runs\/([^/]+)\/events$/)
  575. if (method === 'GET' && runEventsMatch) return fulfill(route, this.events.get(runEventsMatch[1]!) ?? [], 'ute2e-events')
  576. const runDetailMatch = path.match(/^\/runs\/([^/]+)$/)
  577. if (method === 'GET' && runDetailMatch) {
  578. const item = this.runs.get(runDetailMatch[1]!)
  579. return item ? fulfill(route, item, 'ute2e-run-detail') : reject(route, 404, '运行不存在')
  580. }
  581. const startMatch = path.match(/^\/runs\/([^/]+)\/start$/)
  582. if (method === 'POST' && startMatch) {
  583. const item = this.runs.get(startMatch[1]!)
  584. if (!item || item.status !== 'ACCEPTED' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突')
  585. item.status = 'IN_PROGRESS'; item.startedAt = 1786387200; item.version = Number(item.version) + 1
  586. return fulfill(route, item, 'ute2e-run-started')
  587. }
  588. const progressMatch = path.match(/^\/runs\/([^/]+)\/progress$/)
  589. if (method === 'PUT' && progressMatch) {
  590. const item = this.runs.get(progressMatch[1]!)
  591. if (!item || item.status !== 'IN_PROGRESS' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突')
  592. if ('percent' in body || 'currentStepCode' in body) return reject(route, 400, '进度与下一步骤必须由服务端派生')
  593. if (typeof body.completedStepCode !== 'string') return reject(route, 400, '缺少 completedStepCode')
  594. return this.advanceRun(route, item, body.completedStepCode, typeof body.actionCode === 'string' ? body.actionCode : undefined)
  595. }
  596. const physicalMatch = path.match(/^\/runs\/([^/]+)\/physical-events$/)
  597. if (method === 'POST' && physicalMatch) {
  598. const item = this.runs.get(physicalMatch[1]!)
  599. if (!item || item.status !== 'IN_PROGRESS' || body.runVersion !== item.version) return reject(route, 409, '实装运行版本或状态冲突')
  600. if (body.source !== 'MANUAL' || body.manualConfirmed !== true || typeof body.eventId !== 'string' || body.stepCode !== item.currentStepCode) return reject(route, 400, '实装事件 DTO 不符合契约')
  601. if (typeof body.occurredAt !== 'number' || body.occurredAt >= 1_000_000_000_000) return reject(route, 400, '现场事件时间必须是 Unix 秒')
  602. const currentStep = this.stepsFor(item).find((step) => stepCodeOf(step) === body.stepCode)
  603. const execution = currentStep?.physicalExecution as JsonRecord | undefined
  604. if (!execution || execution.source !== body.source || execution.eventType !== body.eventType || execution.manualConfirmAllowed === false) return reject(route, 400, '现场事件与当前步骤执行规则不匹配')
  605. await this.advanceRun(route, item, String(body.stepCode), undefined, false)
  606. return fulfill(route, { id: `physical-${this.count('POST', /physical-events$/)}`, eventId: body.eventId, assignmentId: item.assignmentId, runId: item.id, source: body.source, eventType: body.eventType, stepCode: body.stepCode, outcome: 'MATCHED', matchedRule: true, progressApplied: true, occurredAt: body.occurredAt, runVersion: item.version }, 'ute2e-physical-event')
  607. }
  608. const submitMatch = path.match(/^\/runs\/([^/]+)\/submit$/)
  609. if (method === 'POST' && submitMatch) {
  610. const item = this.runs.get(submitMatch[1]!)
  611. if (!item || item.status !== 'IN_PROGRESS' || item.progressPercent !== 100 || body.version !== item.version) return reject(route, 409, '运行未完成或版本冲突')
  612. const task = this.requireAssignment(String(item.assignmentId))
  613. if (task.collaborationMode === 'TEAM') {
  614. const current = this.currentMember(item)
  615. if (!current || !['TEAM_LEADER', 'COMMANDER'].includes(String(current.positionCode))) return reject(route, 403, '共享运行仅负责人可提交')
  616. }
  617. item.status = 'SUBMITTED'; item.submittedAt = 1786390800; item.submission = { summary: body.summary }; item.version = Number(item.version) + 1
  618. return fulfill(route, item, 'ute2e-run-submitted')
  619. }
  620. const timeoutMatch = path.match(/^\/runs\/([^/]+)\/timeout$/)
  621. if (method === 'POST' && timeoutMatch) {
  622. const item = this.runs.get(timeoutMatch[1]!)
  623. if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '超时结算运行版本或状态冲突')
  624. if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限')
  625. const task = this.requireAssignment(String(item.assignmentId))
  626. const now = Number(task.serverNow)
  627. const definition = task.definitionSnapshot as JsonRecord
  628. const modeConfig = (definition.modeConfig ?? {}) as JsonRecord
  629. const roundSeconds = Number(modeConfig.durationSeconds ?? 0)
  630. || Number(modeConfig.roundDurationMinutes ?? modeConfig.roundDuration ?? 0) * 60
  631. const dueExpired = typeof task.dueAt === 'number' && now >= task.dueAt
  632. const roundExpired = task.channel === 'CONFRONTATION' && typeof item.startedAt === 'number' && roundSeconds > 0 && now >= item.startedAt + roundSeconds
  633. if (!dueExpired && !roundExpired) return reject(route, 409, '运行尚未超时')
  634. item.status = 'SUBMITTED'
  635. item.submission = { summary: '运行已由服务端执行超时结算。', timeout: true }
  636. item.submittedAt = now
  637. item.version = Number(item.version) + 1
  638. return fulfill(route, item, 'ute2e-run-timeout-submitted')
  639. }
  640. const terminateMatch = path.match(/^\/runs\/([^/]+)\/terminate$/)
  641. if (method === 'POST' && terminateMatch) {
  642. const item = this.runs.get(terminateMatch[1]!)
  643. if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '结束运行版本或状态冲突')
  644. if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限')
  645. const task = this.requireAssignment(String(item.assignmentId))
  646. if (task.assignmentKind !== 'TRAINING' || task.dueAt != null || (task.channel === 'CONFRONTATION' && item.status === 'IN_PROGRESS')) return reject(route, 409, '当前运行应使用超时结算')
  647. if (typeof body.reason !== 'string' || !body.reason.trim() || body.reason.length > 500) return reject(route, 400, '结束运行必须提供 1 至 500 字原因')
  648. item.status = 'SUBMITTED'
  649. item.submission = { summary: `运行由教员结束:${body.reason.trim()}`, terminated: true }
  650. item.submittedAt = Number(task.serverNow)
  651. item.version = Number(item.version) + 1
  652. return fulfill(route, item, 'ute2e-run-terminated-submitted')
  653. }
  654. const reviewMatch = path.match(/^\/runs\/([^/]+)\/review$/)
  655. if (method === 'POST' && reviewMatch) {
  656. const item = this.runs.get(reviewMatch[1]!)
  657. if (!item || item.status !== 'SUBMITTED' || body.version !== item.version || typeof body.score !== 'number') return reject(route, 409, '评定 DTO 或状态冲突')
  658. item.status = 'REVIEWED'; item.score = body.score; item.passed = body.passed; item.reviewFeedback = body.feedback; item.reviewerName = '周教员'; item.reviewedAt = 1786394400; item.version = Number(item.version) + 1
  659. return fulfill(route, item, 'ute2e-run-reviewed')
  660. }
  661. const activateMatch = path.match(/^\/interventions\/([^/]+)\/activate$/)
  662. if (method === 'POST' && activateMatch) {
  663. const item = this.interventions.find((value) => value.id === activateMatch[1])
  664. if (!item || body.version !== item.version) return reject(route, 409, '干预版本冲突')
  665. item.status = 'ACTIVE'; item.activatedAt = 1786387200; item.version = Number(item.version) + 1
  666. return fulfill(route, item, 'ute2e-intervention-active')
  667. }
  668. if (method === 'GET' && path === '/faults') return fulfill(route, pageResult([]), 'ute2e-faults')
  669. if (method === 'GET' && path === '/analytics') return fulfill(route, { assignmentCount: 6, participantCount: 12, runCount: 10, completedRunCount: 8, pendingReviewCount: 1, averageScore: 86.5, assignmentsByChannel: { VIRTUAL: 3, PHYSICAL: 2, CONFRONTATION: 1 }, runsByStatus: { ACCEPTED: 1, IN_PROGRESS: 1, REVIEWED: 8 }, membersByRole: { TEAM_LEADER: 3, OPERATOR: 5, COMMANDER: 2 } }, 'ute2e-analytics')
  670. if (method === 'GET' && path === '/analytics/records') {
  671. if (this.identity === 'administrative') {
  672. this.administrativeAnalyticsRecordsRejected += 1
  673. return reject(route, 403, '行政身份仅可读取部门聚合,不可读取个人运行明细')
  674. }
  675. if (this.identity === 'unionAdministrativeStudent' && url.searchParams.get('userId') !== this.currentUserId()) {
  676. return reject(route, 403, '受限学员只能读取本人训练明细')
  677. }
  678. return fulfill(route, pageResult([{
  679. runId: 'run-reviewed-analytics', runCode: 'RUN-ANALYTICS-001', assignmentId: 'assignment-exam', assignmentCode: 'EXAM-2026-001', assignmentName: '虚拟维修正式考核', assignmentKind: 'FORMAL_EXAM', channel: 'VIRTUAL', executionMode: 'EXAM', userId: '4', username: 'student.leader', displayName: '红方队长', departmentId: '110', departmentName: '维修教研室', status: 'REVIEWED', progressPercent: 100, score: 88, passed: true, acceptedAt: 1786386000, startedAt: 1786387200, submittedAt: 1786390800, reviewedAt: 1786394400, reviewerName: '周教员', serverNow: 1786394400,
  680. }]), 'ute2e-analytics-records')
  681. }
  682. if (method === 'GET' && path === '/xr/capabilities') return fulfill(route, { framework: 'three@0.185.0 + WebXR', sessionModes: ['INLINE', 'IMMERSIVE_VR'], referenceSpaces: ['LOCAL', 'LOCAL_FLOOR'], secureContextRequired: true, scoreAcceptedFromClient: false, resultPolicy: 'SERVER_AUTHORITATIVE', serverTimeSource: 'KINGBASE' }, 'ute2e-xr-capabilities')
  683. if (method === 'GET' && path === '/adapters') return fulfill(route, [{ id: 'adapter-dh', code: 'LOCAL_DIGITAL_HUMAN', name: '本地数字教员适配器', provider: 'LOCAL', mode: 'RESERVED', status: 'DISABLED', health: 'UNKNOWN', configSummary: {}, lastHealthAt: null, version: 1 }], 'ute2e-adapters')
  684. const xrStartMatch = path.match(/^\/runs\/([^/]+)\/xr-sessions$/)
  685. if (method === 'POST' && xrStartMatch) {
  686. const item = this.runs.get(xrStartMatch[1]!)
  687. if (!item || body.runVersion !== item.version || body.mode !== 'INLINE') return reject(route, 400, 'XR 会话 DTO 不符合契约')
  688. return fulfill(route, { id: 'xr-session-1', code: 'XR-SESSION-001', assignmentId: item.assignmentId, runId: item.id, userId: this.currentUserId(), mode: 'INLINE', referenceSpaceType: 'LOCAL', deviceName: body.deviceName, userAgentSummary: body.userAgentSummary, capabilities: body.capabilities, stateSnapshot: {}, status: 'ACTIVE', startedAt: 1786387200, lastHeartbeatAt: 1786387200, endedAt: null, failureReason: '', version: 0 }, 'ute2e-xr-session')
  689. }
  690. const xrEndMatch = path.match(/^\/xr-sessions\/([^/]+)\/end$/)
  691. if (method === 'PUT' && xrEndMatch) return fulfill(route, { id: xrEndMatch[1], code: 'XR-SESSION-001', assignmentId: 'assignment-virtual-common', runId: 'run-immersive', userId: this.currentUserId(), mode: 'INLINE', referenceSpaceType: 'LOCAL', deviceName: '桌面浏览器', userAgentSummary: '', capabilities: {}, stateSnapshot: {}, status: body.status, startedAt: 1786387200, lastHeartbeatAt: 1786387200, endedAt: 1786387260, failureReason: body.failureReason, version: 1 }, 'ute2e-xr-ended')
  692. return reject(route, 404, `未模拟教学端点 ${method} ${path}`)
  693. }
  694. private assetList() {
  695. return [{
  696. sourceProjectId: 'asset-project-1', sourceVersionId: 'asset-version-1', assetCode: 'EXCAVATOR_A',
  697. name: 'excavator-a.glb', type: 'MODEL_FILE', contentUri: 'content://sha256/contract-excavator',
  698. mimeType: 'model/gltf-binary', sizeBytes: 11919032, sha256: 'contract-sha256', downloadable: true,
  699. downloadUrl: '/models/excavator-a.glb',
  700. }]
  701. }
  702. }