You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

795 regels
53 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',
  149. digitalInstructorAgentSlug: channel === 'VIRTUAL' && kind !== 'FORMAL_EXAM' ? 'demo-equipment-instructor' : '',
  150. digitalInstructorAgentName: channel === 'VIRTUAL' && kind !== 'FORMAL_EXAM' ? '装备维修虚拟教员' : '',
  151. digitalInstructorKnowledgeSource: 'AGENT',
  152. ownerUserId: '2', ownerUsername: 'teacher', ownerName: '周教员',
  153. departmentId: '110', departmentName: '维修教研室', status: 'PUBLISHED',
  154. scheduleStartAt: 1786383600, dueAt: 1786473600, publishedAt: 1786380000,
  155. withdrawnAt: null, archivedAt: null, lifecycleReason: '', version: 3,
  156. addTime: 1786300800, updateTime: '2026-08-11T08:00:00+08:00', serverNow: 1786387200,
  157. members: audienceType === 'COMMON'
  158. ? [teachingMembers.instructor]
  159. : channel === 'CONFRONTATION'
  160. ? Object.values(teachingMembers)
  161. : [teachingMembers.instructor, teachingMembers.leader],
  162. runCount: 1, pendingReviewCount: 0,
  163. })
  164. const assignmentBrief = (item: JsonRecord) => ({
  165. id: item.id, code: item.code, name: item.name, channel: item.channel, assignmentKind: item.assignmentKind,
  166. executionMode: item.executionMode, audienceType: item.audienceType, collaborationMode: item.collaborationMode,
  167. digitalHumanAllowed: item.digitalHumanAllowed,
  168. digitalInstructorAgentSlug: item.digitalInstructorAgentSlug,
  169. digitalInstructorAgentName: item.digitalInstructorAgentName,
  170. digitalInstructorKnowledgeSource: item.digitalInstructorKnowledgeSource,
  171. status: item.status,
  172. scheduleStartAt: item.scheduleStartAt, dueAt: item.dueAt, serverNow: item.serverNow,
  173. })
  174. const runFor = (id: string, task: JsonRecord, status: 'PENDING' | 'ACCEPTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'REVIEWED', learner = teachingMembers.leader) => ({
  175. id, code: `RUN-${String(task.channel)}-${id.slice(-3).toUpperCase()}`, assignmentId: task.id,
  176. memberId: learner.id, subjectType: task.collaborationMode === 'TEAM' ? 'TEAM' : 'MEMBER',
  177. subjectCode: task.collaborationMode === 'TEAM' ? learner.teamCode : learner.id,
  178. attemptNo: 1, status, progressPercent: status === 'SUBMITTED' || status === 'REVIEWED' ? 100 : 0,
  179. currentStepCode: 'STEP-01', checkpoint: {}, automaticResult: {}, submission: status === 'SUBMITTED' || status === 'REVIEWED' ? { summary: '已完成' } : null,
  180. score: status === 'REVIEWED' ? 88 : null, passed: status === 'REVIEWED' ? true : null,
  181. reviewFeedback: status === 'REVIEWED' ? '流程与操作符合要求。' : '', reviewRubric: {},
  182. acceptedAt: status === 'PENDING' ? null : 1786386000, startedAt: ['PENDING', 'ACCEPTED'].includes(status) ? null : 1786387200,
  183. submittedAt: status === 'SUBMITTED' || status === 'REVIEWED' ? 1786390800 : null,
  184. reviewedAt: status === 'REVIEWED' ? 1786394400 : null, reviewedByUserId: status === 'REVIEWED' ? '2' : null,
  185. reviewerName: status === 'REVIEWED' ? '周教员' : '', archivedAt: null, version: 2,
  186. addTime: 1786386000, updateTime: '2026-08-11T10:00:00+08:00', assignment: assignmentBrief(task),
  187. member: learner, members: task.collaborationMode === 'TEAM' ? Object.values(teachingMembers) : [teachingMembers.instructor, learner],
  188. })
  189. const envelope = (data: unknown, requestId = 'ute2e-teaching-state') => ({
  190. code: 200, message: '成功', data, timestamp: '2026-08-11T10:00:00+08:00', requestId,
  191. })
  192. const pageResult = (records: unknown[], page = 1, size = 10) => ({ records, total: records.length, page, size })
  193. const bodyOf = (route: Route): JsonRecord => {
  194. const text = route.request().postData()
  195. if (!text) return {}
  196. const value = JSON.parse(text) as unknown
  197. return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
  198. }
  199. const fulfill = (route: Route, data: unknown, requestId?: string) => route.fulfill({
  200. status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data, requestId)),
  201. })
  202. const reject = (route: Route, status: number, message: string) => route.fulfill({
  203. status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-rejected' }),
  204. })
  205. export class TeachingContractMock {
  206. identity: TeachingIdentity
  207. readonly requests: RecordedTeachingRequest[] = []
  208. readonly assignments = new Map<string, JsonRecord>()
  209. readonly runs = new Map<string, JsonRecord>()
  210. readonly events = new Map<string, JsonRecord[]>()
  211. readonly interventions: JsonRecord[] = []
  212. readonly deniedPermissions = new Set<string>()
  213. administrativeRunsRejected = 0
  214. administrativeAnalyticsRecordsRejected = 0
  215. digitalInstructorFrameLoads = 0
  216. constructor(identity: TeachingIdentity) {
  217. this.identity = identity
  218. const previewTask = {
  219. ...assignment('assignment-virtual-preview', 'VIRTUAL', 'TRAINING', 'ASSIGNED', 'INDIVIDUAL'),
  220. code: 'TASK-VIRTUAL-PREVIEW-001',
  221. name: '支腿液压缸中修训练(虚拟仿真)',
  222. description: '面向液压密封件检查与更换的八步虚拟训练任务。',
  223. definitionSnapshot: virtualPreviewDefinition(),
  224. digitalInstructorAgentSlug: 'demo-equipment-instructor',
  225. digitalInstructorAgentName: '装备维修虚拟教员',
  226. digitalInstructorKnowledgeSource: 'WORKSHOP',
  227. runCount: 0,
  228. pendingReviewCount: 0,
  229. listed: false,
  230. }
  231. const tasks = [
  232. assignment('assignment-virtual-common', 'VIRTUAL', 'TRAINING', 'COMMON', 'INDIVIDUAL'),
  233. previewTask,
  234. assignment('assignment-physical', 'PHYSICAL', 'TRAINING', 'ASSIGNED', 'INDIVIDUAL'),
  235. assignment('assignment-confrontation', 'CONFRONTATION', 'TRAINING', 'ASSIGNED', 'TEAM'),
  236. assignment('assignment-exam', 'VIRTUAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'),
  237. assignment('assignment-exam-physical', 'PHYSICAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'),
  238. assignment('assignment-exam-confrontation', 'CONFRONTATION', 'FORMAL_EXAM', 'ASSIGNED', 'TEAM'),
  239. ]
  240. tasks.forEach((task) => this.assignments.set(String(task.id), task))
  241. this.runs.set('run-physical', runFor('run-physical', this.requireAssignment('assignment-physical'), 'ACCEPTED'))
  242. this.runs.set('run-confrontation', runFor('run-confrontation', this.requireAssignment('assignment-confrontation'), 'IN_PROGRESS'))
  243. this.runs.set('run-exam', runFor('run-exam', this.requireAssignment('assignment-exam'), 'IN_PROGRESS'))
  244. }
  245. setIdentity(identity: TeachingIdentity) { this.identity = identity }
  246. denyPermissions(...codes: string[]) { codes.forEach((code) => this.deniedPermissions.add(code)) }
  247. seedImmersiveRun() {
  248. const task = this.requireAssignment('assignment-virtual-common')
  249. const runtime = runFor('run-immersive', task, 'IN_PROGRESS')
  250. runtime.currentStepCode = 'STEP-01'
  251. this.runs.set(String(runtime.id), runtime)
  252. }
  253. seedVirtualReviewedRun() {
  254. const task = this.requireAssignment('assignment-virtual-common')
  255. const runtime = runFor('run-virtual-reviewed', task, 'REVIEWED')
  256. runtime.currentStepCode = 'STEP-02'
  257. runtime.progressPercent = 100
  258. runtime.score = 92
  259. runtime.passed = true
  260. runtime.reviewFeedback = '步骤完整,安全确认和检修复核符合要求。'
  261. runtime.checkpoint = { completedStepCodes: ['STEP-01', 'STEP-02'] }
  262. this.runs.set(String(runtime.id), runtime)
  263. this.events.set(String(runtime.id), [
  264. {
  265. id: 'event-virtual-reviewed-1', code: 'EVENT-VIRTUAL-001', assignmentId: task.id, runId: runtime.id,
  266. targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:识别作业对象',
  267. summary: '确认训练对象与安全边界。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' },
  268. actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387260,
  269. },
  270. {
  271. id: 'event-virtual-reviewed-2', code: 'EVENT-VIRTUAL-002', assignmentId: task.id, runId: runtime.id,
  272. targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:完成检修操作',
  273. summary: '按流程完成检修并复核。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-02' },
  274. actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387560,
  275. },
  276. ])
  277. return runtime
  278. }
  279. seedConfrontationMonitorRuns() {
  280. const task = this.requireAssignment('assignment-confrontation')
  281. const active = this.runs.get('run-confrontation')!
  282. active.status = 'IN_PROGRESS'
  283. active.subjectCode = 'RED'
  284. active.members = [teachingMembers.instructor, teachingMembers.leader, teachingMembers.operator]
  285. active.progressPercent = 50
  286. active.currentStepCode = 'STEP-02'
  287. this.events.set(String(active.id), [{
  288. id: 'event-confrontation-red-1', code: 'EVENT-CON-RED-001', assignmentId: task.id, runId: active.id,
  289. targetType: 'RUN', targetId: active.id, type: 'training.action.completed', title: '队长已下达处置指令',
  290. summary: '红方进入故障隔离阶段。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' },
  291. actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387320,
  292. }])
  293. const blue = runFor('run-confrontation-blue', task, 'ACCEPTED', teachingMembers.commander)
  294. blue.subjectCode = 'BLUE'
  295. blue.members = [teachingMembers.instructor, teachingMembers.commander]
  296. blue.currentStepCode = 'STEP-01'
  297. blue.progressPercent = 0
  298. this.runs.set(String(blue.id), blue)
  299. this.events.set(String(blue.id), [])
  300. return { active, blue }
  301. }
  302. seedExpiredRun() {
  303. const task = this.requireAssignment('assignment-virtual-common')
  304. task.dueAt = Number(task.serverNow) - 60
  305. const runtime = runFor('run-timeout', task, 'IN_PROGRESS')
  306. runtime.startedAt = Number(task.serverNow) - 3_600
  307. runtime.progressPercent = 50
  308. this.runs.set(String(runtime.id), runtime)
  309. return runtime
  310. }
  311. seedTerminableRun() {
  312. const task = this.requireAssignment('assignment-virtual-common')
  313. task.dueAt = null
  314. const runtime = runFor('run-terminate', task, 'IN_PROGRESS')
  315. runtime.startedAt = Number(task.serverNow) - 600
  316. runtime.progressPercent = 25
  317. this.runs.set(String(runtime.id), runtime)
  318. return runtime
  319. }
  320. seedConfrontationTerminationCases() {
  321. const task = this.requireAssignment('assignment-confrontation')
  322. task.dueAt = null
  323. const active = this.runs.get('run-confrontation')!
  324. active.status = 'IN_PROGRESS'
  325. active.startedAt = Number(task.serverNow) - 21 * 60
  326. active.progressPercent = 50
  327. const accepted = runFor('run-confrontation-queued', task, 'ACCEPTED', teachingMembers.operator)
  328. this.runs.set(String(accepted.id), accepted)
  329. return { active, accepted }
  330. }
  331. keepOnlyRuns(...runIds: string[]) {
  332. const selected = runIds.map((id) => this.runs.get(id)).filter((item): item is JsonRecord => Boolean(item))
  333. this.runs.clear()
  334. selected.forEach((item) => this.runs.set(String(item.id), item))
  335. }
  336. count(method: string, path: string | RegExp) {
  337. return this.requests.filter((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path))).length
  338. }
  339. last(method: string, path: string | RegExp) {
  340. return [...this.requests].reverse().find((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path)))
  341. }
  342. private requireAssignment(id: string) {
  343. const item = this.assignments.get(id)
  344. if (!item) throw new Error(`测试任务不存在:${id}`)
  345. return item
  346. }
  347. private currentUserId() {
  348. if (this.identity === 'studentOperator') return '5'
  349. if (this.identity === 'studentLeader' || this.identity === 'unionAdministrativeStudent') return '4'
  350. if (this.identity === 'teacher') return '2'
  351. if (this.identity === 'administrative') return '3'
  352. return '1'
  353. }
  354. private profile() {
  355. const union = this.identity === 'unionAdministrativeStudent'
  356. const role = this.identity === 'teacher' ? 'teacher'
  357. : this.identity === 'administrative' || union ? 'administrative'
  358. : this.identity === 'studentLeader' || this.identity === 'studentOperator' ? 'student' : 'admin'
  359. const userId = this.currentUserId()
  360. const displayName = this.identity === 'studentOperator' ? '红方操作员' : this.identity === 'studentLeader' || union ? '红方队长' : roleNames[role]
  361. const roles = union
  362. ? [
  363. { id: 'role-administrative', code: 'administrative', name: '行政', shortName: '行', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'DEPARTMENT' },
  364. { id: 'role-student', code: 'student', name: '学员', shortName: '学', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'SELF' },
  365. ]
  366. : [{ 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' }]
  367. return {
  368. user: { id: userId, username: this.identity, displayName, departmentId: '110', departmentName: '维修教研室', mustChangePassword: false, version: 1 },
  369. activeRoleId: union ? 'role-administrative' : `role-${role}`, roles,
  370. permissions: permissions[this.identity].filter((permission) => !this.deniedPermissions.has(permission)), authorizationMode: union ? 'UNION' : 'SINGLE_ACTIVE', loginTime: 1786387200,
  371. }
  372. }
  373. private currentMember(run: JsonRecord) {
  374. const values = Array.isArray(run.members) ? run.members as JsonRecord[] : []
  375. return values.find((item) => item.userId === this.currentUserId() && item.memberType === 'LEARNER')
  376. }
  377. private visibleRuns() {
  378. const values = [...this.runs.values()]
  379. if (this.identity === 'teacher' || this.identity === 'admin') return values
  380. const userId = this.currentUserId()
  381. return values.filter((item) => {
  382. const values = Array.isArray(item.members) ? item.members as JsonRecord[] : []
  383. return values.some((member) => member.userId === userId && member.memberType === 'LEARNER')
  384. })
  385. }
  386. private stepsFor(run: JsonRecord) {
  387. const task = this.requireAssignment(String(run.assignmentId))
  388. const definition = task.definitionSnapshot as JsonRecord
  389. return (definition.steps as JsonRecord[]) ?? []
  390. }
  391. private advanceRun(route: Route, run: JsonRecord, completedStepCode: string, actionCode?: string, respond = true) {
  392. const steps = this.stepsFor(run)
  393. const currentCode = String(run.currentStepCode)
  394. if (completedStepCode !== currentCode) return reject(route, 409, `完成步骤必须等于当前待办步骤 ${currentCode}`)
  395. const index = steps.findIndex((step) => stepCodeOf(step) === completedStepCode)
  396. if (index < 0) return reject(route, 400, '步骤不属于发布任务')
  397. const task = this.requireAssignment(String(run.assignmentId))
  398. if (task.channel === 'CONFRONTATION') {
  399. const current = this.currentMember(run)
  400. const allowed = (steps[index]!.allowedPositions ?? steps[index]!.requiredPositions ?? []) as string[]
  401. if (!current || !allowed.includes(String(current.positionCode))) return reject(route, 403, '当前岗位不能执行此动作')
  402. if (actionCode !== steps[index]!.actionCode) return reject(route, 400, 'actionCode 与当前步骤不匹配')
  403. }
  404. const next = steps[Math.min(index + 1, steps.length - 1)]!
  405. run.progressPercent = Math.round(((index + 1) * 100) / steps.length)
  406. run.currentStepCode = stepCodeOf(next)
  407. run.version = Number(run.version) + 1
  408. const events = this.events.get(String(run.id)) ?? []
  409. events.push({
  410. id: `event-${events.length + 1}`, code: `EVENT-${events.length + 1}`, assignmentId: run.assignmentId, runId: run.id,
  411. targetType: 'RUN', targetId: run.id, type: 'training.action.completed', title: `完成:${steps[index]!.title}`,
  412. summary: steps[index]!.publicSummary, source: 'web', visibility: 'PUBLIC', payload: { completedStepCode, actionCode },
  413. actorUserId: this.currentUserId(), actorDisplayName: this.profile().user.displayName, occurredAt: 1786387200 + events.length,
  414. })
  415. this.events.set(String(run.id), events)
  416. return respond ? fulfill(route, run, 'ute2e-progress-authoritative') : Promise.resolve()
  417. }
  418. async install(page: Page) {
  419. await page.addInitScript(() => sessionStorage.setItem('unreal-tran:web:access-token:v1', 'teaching-contract-token'))
  420. await page.route('**/api/auth/v1/auth/me', (route) => fulfill(route, this.profile(), 'ute2e-teaching-me'))
  421. await page.route('**/api/auth/v1/menus/navigation', (route) => fulfill(route, [], 'ute2e-teaching-menu'))
  422. await page.route('**/api/auth/v1/directory/teaching-members**', (route) => fulfill(route, [
  423. { id: '4', username: 'student.leader', displayName: '红方队长', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] },
  424. { id: '5', username: 'student.operator', displayName: '红方操作员', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] },
  425. ], 'ute2e-teaching-directory'))
  426. await page.route('**/api/tran/v1/content/catalog**', (route) => fulfill(route, pageResult([{
  427. 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: [] },
  428. }]), 'ute2e-teaching-catalog'))
  429. await page.route(/\/embed\/(?:dh-b69424ed0904|demo-equipment-instructor)(?:\?.*)?$/, (route) => {
  430. this.digitalInstructorFrameLoads += 1
  431. return route.fulfill({
  432. status: 200,
  433. contentType: 'text/html; charset=utf-8',
  434. body: '<!doctype html><html lang="zh-CN"><body><main data-testid="mock-ai-person">虚拟教员在线答疑</main></body></html>',
  435. })
  436. })
  437. await page.route('**/api/tran/v1/teaching/**', (route) => this.handleTeaching(route))
  438. }
  439. private async handleTeaching(route: Route) {
  440. const request = route.request()
  441. const url = new URL(request.url())
  442. const path = url.pathname.replace(/^.*\/teaching/, '')
  443. const method = request.method()
  444. const body = bodyOf(route)
  445. this.requests.push({ method, path, query: new URLSearchParams(url.searchParams), body })
  446. if (method === 'GET' && path === '/guides') return fulfill(route, pageResult([{
  447. 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,
  448. }]), 'ute2e-guides')
  449. if (method === 'GET' && path === '/guides/guide-project-1') return fulfill(route, {
  450. projectId: 'guide-project-1', versionId: 'guide-version-1', code: 'GUIDE-001', name: '挖掘机检修作业指导书', description: '标准维修作业流程', categoryCode: '维修作业', coverUri: '', versionNumber: 3, versionCode: 'V3', publishedAt: 1786387200,
  451. editorDocument: null,
  452. resources: {},
  453. assets: [],
  454. capabilities: {
  455. publishedAssetDownload: false,
  456. serverGeneratesOfd: false,
  457. serverGeneratesOfflineHtml: false,
  458. clientGenerationSupported: false,
  459. availableDeliveryFormats: [],
  460. },
  461. reading: {
  462. projectId: 'guide-project-1', versionId: 'guide-version-1', status: 'NOT_STARTED',
  463. startedAt: null, lastReadAt: null, completedAt: null,
  464. },
  465. content: {
  466. equipment: '履带式挖掘机', specification: '教学训练型',
  467. chapters: [
  468. {
  469. id: 'chapter-1', title: '安全准备', order: 1,
  470. steps: [
  471. { id: 'step-1', title: '断电隔离', summary: '确认停机、断电并完成安全隔离。', safety: '执行挂牌上锁。', acceptance: '设备处于零能量状态。', duration: '5分钟', type: 'CHECK', tools: '安全锁具', parameters: {}, media: '', part: '动力总成', anchor: '', animation: '', order: 1 },
  472. { id: 'step-2', title: '压力释放', summary: '缓慢释放液压系统残余压力。', safety: '佩戴护目镜并确认卸压方向无人。', acceptance: '压力表回零且系统无残压。', duration: '8分钟', type: 'SAFETY', tools: '压力表', parameters: { '目标压力': '0 MPa' }, media: '', part: '液压回路', anchor: '', animation: '', order: 2 },
  473. ],
  474. },
  475. {
  476. id: 'chapter-2', title: '检修复核', order: 2,
  477. steps: [{ id: 'step-3', title: '复装与复测', summary: '完成部件复装并按标准执行功能复测。', safety: '清点工具并恢复防护装置。', acceptance: '动作平稳、无泄漏且记录完整。', duration: '12分钟', type: 'VERIFY', tools: '扭矩扳手', parameters: { '复测次数': '2 次' }, media: '', part: '执行机构', anchor: '', animation: '', order: 1 }],
  478. },
  479. ],
  480. },
  481. }, 'ute2e-guide-detail')
  482. if (method === 'GET' && path === '/guides/guide-project-1/deliveries') return fulfill(route, [], 'ute2e-guide-deliveries')
  483. if (method === 'GET' && path === '/guides/guide-project-1/assets') return fulfill(route, [], 'ute2e-guide-assets')
  484. if (method === 'GET' && path === '/guides/guide-project-1/statistics') return fulfill(route, {
  485. projectId: 'guide-project-1', versionId: 'guide-version-1', readerCount: 6, completedCount: 4,
  486. completionRate: 67, feedbackCount: 2, openFeedbackCount: 1, handledFeedbackCount: 1,
  487. }, 'ute2e-guide-statistics')
  488. if (method === 'GET' && path === '/assignments/summary') {
  489. const channel = url.searchParams.get('channel')
  490. const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING'
  491. const tasks = [...this.assignments.values()].filter((item) => item.listed !== false && (!channel || item.channel === channel) && item.assignmentKind === kind)
  492. 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')
  493. }
  494. if (method === 'GET' && path === '/digital-instructors') return fulfill(route, [{
  495. id: 'agent-equipment-instructor',
  496. slug: 'demo-equipment-instructor',
  497. name: '装备维修虚拟教员',
  498. description: '装备维修训练在线答疑',
  499. accessMode: 'internal',
  500. interactionModes: ['text', 'voice'],
  501. knowledgeSources: ['AGENT', 'WORKSHOP'],
  502. agentKnowledgeBaseCount: 1,
  503. workshopKnowledgeProviderName: '数字车间维修知识',
  504. }], 'ute2e-digital-instructors')
  505. if (method === 'GET' && path === '/assignments') {
  506. const channel = url.searchParams.get('channel')
  507. const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING'
  508. const tasks = [...this.assignments.values()].filter((item) => item.listed !== false && (!channel || item.channel === channel) && item.assignmentKind === kind)
  509. return fulfill(route, pageResult(tasks), 'ute2e-assignments')
  510. }
  511. if (method === 'POST' && path === '/assignments') {
  512. if (body.assignmentKind === 'FORMAL_EXAM'
  513. && (typeof body.scheduleStartAt !== 'number' || typeof body.dueAt !== 'number'
  514. || body.scheduleStartAt >= 1_000_000_000_000 || body.dueAt >= 1_000_000_000_000)) return reject(route, 400, '正式考核时间必须是 Unix 秒')
  515. if (body.assignmentKind === 'FORMAL_EXAM' && (body.executionMode !== 'EXAM' || body.digitalHumanAllowed !== false)) return reject(route, 400, '正式考核必须使用 EXAM 且禁用数字教员')
  516. 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' }
  517. this.assignments.set(String(created.id), created)
  518. return fulfill(route, created, 'ute2e-assignment-created')
  519. }
  520. const assignmentAssetMatch = path.match(/^\/assignments\/([^/]+)\/assets$/)
  521. if (method === 'GET' && assignmentAssetMatch) return fulfill(route, this.assetList(), 'ute2e-assignment-assets')
  522. const assignmentFaultMatch = path.match(/^\/assignments\/([^/]+)\/faults$/)
  523. if (method === 'GET' && assignmentFaultMatch) {
  524. if (this.identity === 'administrative') return reject(route, 403, '行政身份不可查看故障细节')
  525. return fulfill(route, [{
  526. id: 'assignment-fault-public', assignmentId: assignmentFaultMatch[1], faultId: this.identity === 'teacher' || this.identity === 'admin' ? 'fault-1' : null,
  527. faultCode: this.identity === 'teacher' || this.identity === 'admin' ? 'HYDRAULIC.VALVE.STUCK' : '',
  528. faultName: this.identity === 'teacher' || this.identity === 'admin' ? '液压阀卡滞' : '', severity: this.identity === 'teacher' || this.identity === 'admin' ? 'HIGH' : '',
  529. publicSymptom: '动臂响应迟缓且压力波动', privateTruth: this.identity === 'teacher' || this.identity === 'admin' ? { text: '液压阀芯卡滞真值' } : null,
  530. triggerConfig: this.identity === 'teacher' || this.identity === 'admin' ? { hidden: '内部触发条件' } : null,
  531. targetScope: 'TEAM', targetTeamCode: 'RED', targetGroupCode: 'GROUP-RED', sortOrder: 0,
  532. }], 'ute2e-assignment-faults')
  533. }
  534. const interventionListMatch = path.match(/^\/assignments\/([^/]+)\/interventions$/)
  535. if (interventionListMatch && method === 'GET') return fulfill(route, this.interventions, 'ute2e-interventions')
  536. if (interventionListMatch && method === 'POST') {
  537. if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无教学干预权限')
  538. if (!['ASSIGNMENT', 'TEAM', 'MEMBER'].includes(String(body.targetScope))) return reject(route, 400, '干预作用范围不符合契约')
  539. if (body.targetScope === 'TEAM' && !['RED', 'BLUE', 'NEUTRAL'].includes(String(body.targetTeamCode))) return reject(route, 400, 'TEAM 干预必须携带目标队伍')
  540. if (body.targetScope === 'MEMBER' && !body.targetMemberId) return reject(route, 400, 'MEMBER 干预必须携带目标成员')
  541. if (typeof body.publicSummary !== 'string' || !body.publicSummary.trim()) return reject(route, 400, '干预必须提供公开说明')
  542. if (/privateTruth|triggerConfig|solution|answer|truth/i.test(JSON.stringify(body.publicPayload ?? {}))) return reject(route, 400, '公开干预载荷不能包含故障真值或解决方案')
  543. const privatePayload = body.privatePayload ?? {}
  544. const payload = Object.keys(privatePayload as JsonRecord).length
  545. ? { public: body.publicPayload ?? {}, private: privatePayload }
  546. : body.publicPayload ?? {}
  547. const item = {
  548. id: `intervention-${this.interventions.length + 1}`, assignmentId: interventionListMatch[1],
  549. code: body.code, type: body.type, targetScope: body.targetScope,
  550. targetMemberId: body.targetScope === 'MEMBER' ? body.targetMemberId : '',
  551. targetTeamCode: body.targetScope === 'TEAM' ? body.targetTeamCode : '',
  552. targetGroupCode: body.targetScope === 'TEAM' ? body.targetGroupCode ?? '' : '',
  553. publicSummary: body.publicSummary, payload, status: 'DRAFT', createdByUserId: '2', createdByName: '周教员', activatedAt: null, revokedAt: null, version: 0,
  554. }
  555. this.interventions.push(item)
  556. return fulfill(route, item, 'ute2e-intervention-created')
  557. }
  558. const assignmentDetailMatch = path.match(/^\/assignments\/([^/]+)$/)
  559. if (method === 'GET' && assignmentDetailMatch) {
  560. const item = this.requireAssignment(assignmentDetailMatch[1]!)
  561. const learnerProjection = !['admin', 'teacher'].includes(this.identity) && item.assignmentKind === 'FORMAL_EXAM'
  562. ? { ...item, definitionSnapshot: null }
  563. : item
  564. return fulfill(route, learnerProjection, 'ute2e-assignment-detail')
  565. }
  566. const acceptMatch = path.match(/^\/assignments\/([^/]+)\/accept$/)
  567. if (method === 'POST' && acceptMatch) {
  568. const task = this.requireAssignment(acceptMatch[1]!)
  569. if (body.assignmentVersion !== task.version || body.teamCode !== 'NEUTRAL' || body.groupCode !== 'INDIVIDUAL' || body.positionCode !== 'LEARNER') return reject(route, 400, '领取 DTO 不符合契约')
  570. const pending = [...this.runs.values()].find((item) => item.assignmentId === task.id && item.status === 'PENDING')
  571. if (pending) {
  572. pending.status = 'ACCEPTED'
  573. pending.acceptedAt = 1786386000
  574. pending.version = Number(pending.version) + 1
  575. return fulfill(route, pending, 'ute2e-assigned-task-accepted')
  576. }
  577. const created = runFor('run-virtual-common', task, 'ACCEPTED', teachingMembers.leader)
  578. this.runs.set(String(created.id), created)
  579. return fulfill(route, created, 'ute2e-assignment-accepted')
  580. }
  581. if (method === 'GET' && path === '/runs') {
  582. if (this.identity === 'administrative') { this.administrativeRunsRejected += 1; return reject(route, 403, '行政身份不可读取运行明细') }
  583. const assignmentId = url.searchParams.get('assignmentId')
  584. const channel = url.searchParams.get('channel')
  585. const kind = url.searchParams.get('assignmentKind')
  586. const values = this.visibleRuns().filter((item) => (!assignmentId || item.assignmentId === assignmentId) && (!channel || (item.assignment as JsonRecord).channel === channel) && (!kind || (item.assignment as JsonRecord).assignmentKind === kind))
  587. return fulfill(route, pageResult(values), 'ute2e-runs')
  588. }
  589. const runAssetsMatch = path.match(/^\/runs\/([^/]+)\/assets$/)
  590. if (method === 'GET' && runAssetsMatch) return fulfill(route, this.assetList(), 'ute2e-run-assets')
  591. const runExecutionMatch = path.match(/^\/runs\/([^/]+)\/execution$/)
  592. if (method === 'GET' && runExecutionMatch) {
  593. const item = this.runs.get(runExecutionMatch[1]!)
  594. if (!item) return reject(route, 404, '运行不存在')
  595. const task = this.requireAssignment(String(item.assignmentId))
  596. if (task.assignmentKind !== 'FORMAL_EXAM' || item.status !== 'IN_PROGRESS') return reject(route, 409, '仅正式考核进行中运行提供执行投影')
  597. const definition = task.definitionSnapshot as JsonRecord
  598. const steps = (definition.steps as JsonRecord[]) ?? []
  599. const index = Math.max(0, steps.findIndex((step) => stepCodeOf(step) === item.currentStepCode))
  600. return fulfill(route, {
  601. runId: item.id,
  602. assignmentId: item.assignmentId,
  603. currentStepCode: item.currentStepCode,
  604. currentStepIndex: index,
  605. totalSteps: steps.length,
  606. progressPercent: item.progressPercent,
  607. currentStep: steps[index] ?? null,
  608. resources: definition.resources ?? [],
  609. scenario: definition.scenario ?? {},
  610. tools: [],
  611. runtime: { showStepPanel: false, allowReplay: false, allowRollback: false, digitalHumanAllowed: false },
  612. modeConfig: definition.modeConfig ?? {},
  613. examPolicy: { hintsAllowed: false, demoAllowed: false, replayAllowed: false, rollbackAllowed: false, digitalHumanAllowed: false },
  614. deadlineAt: task.dueAt,
  615. serverNow: task.serverNow,
  616. }, 'ute2e-run-execution')
  617. }
  618. const runEventsMatch = path.match(/^\/runs\/([^/]+)\/events$/)
  619. if (method === 'GET' && runEventsMatch) return fulfill(route, this.events.get(runEventsMatch[1]!) ?? [], 'ute2e-events')
  620. const runDetailMatch = path.match(/^\/runs\/([^/]+)$/)
  621. if (method === 'GET' && runDetailMatch) {
  622. const item = this.runs.get(runDetailMatch[1]!)
  623. return item ? fulfill(route, item, 'ute2e-run-detail') : reject(route, 404, '运行不存在')
  624. }
  625. const startMatch = path.match(/^\/runs\/([^/]+)\/start$/)
  626. if (method === 'POST' && startMatch) {
  627. const item = this.runs.get(startMatch[1]!)
  628. if (!item || item.status !== 'ACCEPTED' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突')
  629. item.status = 'IN_PROGRESS'; item.startedAt = 1786387200; item.version = Number(item.version) + 1
  630. return fulfill(route, item, 'ute2e-run-started')
  631. }
  632. const progressMatch = path.match(/^\/runs\/([^/]+)\/progress$/)
  633. if (method === 'PUT' && progressMatch) {
  634. const item = this.runs.get(progressMatch[1]!)
  635. if (!item || item.status !== 'IN_PROGRESS' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突')
  636. if ('percent' in body || 'currentStepCode' in body) return reject(route, 400, '进度与下一步骤必须由服务端派生')
  637. if (typeof body.completedStepCode !== 'string') return reject(route, 400, '缺少 completedStepCode')
  638. return this.advanceRun(route, item, body.completedStepCode, typeof body.actionCode === 'string' ? body.actionCode : undefined)
  639. }
  640. const physicalMatch = path.match(/^\/runs\/([^/]+)\/physical-events$/)
  641. if (method === 'POST' && physicalMatch) {
  642. const item = this.runs.get(physicalMatch[1]!)
  643. if (!item || item.status !== 'IN_PROGRESS' || body.runVersion !== item.version) return reject(route, 409, '实装运行版本或状态冲突')
  644. if (body.source !== 'MANUAL' || body.manualConfirmed !== true || typeof body.eventId !== 'string' || body.stepCode !== item.currentStepCode) return reject(route, 400, '实装事件 DTO 不符合契约')
  645. if (typeof body.occurredAt !== 'number' || body.occurredAt >= 1_000_000_000_000) return reject(route, 400, '现场事件时间必须是 Unix 秒')
  646. const currentStep = this.stepsFor(item).find((step) => stepCodeOf(step) === body.stepCode)
  647. const execution = currentStep?.physicalExecution as JsonRecord | undefined
  648. if (!execution || execution.source !== body.source || execution.eventType !== body.eventType || execution.manualConfirmAllowed === false) return reject(route, 400, '现场事件与当前步骤执行规则不匹配')
  649. await this.advanceRun(route, item, String(body.stepCode), undefined, false)
  650. 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')
  651. }
  652. const submitMatch = path.match(/^\/runs\/([^/]+)\/submit$/)
  653. if (method === 'POST' && submitMatch) {
  654. const item = this.runs.get(submitMatch[1]!)
  655. if (!item || item.status !== 'IN_PROGRESS' || item.progressPercent !== 100 || body.version !== item.version) return reject(route, 409, '运行未完成或版本冲突')
  656. const task = this.requireAssignment(String(item.assignmentId))
  657. if (task.collaborationMode === 'TEAM') {
  658. const current = this.currentMember(item)
  659. if (!current || !['TEAM_LEADER', 'COMMANDER'].includes(String(current.positionCode))) return reject(route, 403, '共享运行仅负责人可提交')
  660. }
  661. item.status = 'SUBMITTED'; item.submittedAt = 1786390800; item.submission = { summary: body.summary }; item.version = Number(item.version) + 1
  662. return fulfill(route, item, 'ute2e-run-submitted')
  663. }
  664. const timeoutMatch = path.match(/^\/runs\/([^/]+)\/timeout$/)
  665. if (method === 'POST' && timeoutMatch) {
  666. const item = this.runs.get(timeoutMatch[1]!)
  667. if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '超时结算运行版本或状态冲突')
  668. if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限')
  669. const task = this.requireAssignment(String(item.assignmentId))
  670. const now = Number(task.serverNow)
  671. const definition = task.definitionSnapshot as JsonRecord
  672. const modeConfig = (definition.modeConfig ?? {}) as JsonRecord
  673. const roundSeconds = Number(modeConfig.durationSeconds ?? 0)
  674. || Number(modeConfig.roundDurationMinutes ?? modeConfig.roundDuration ?? 0) * 60
  675. const dueExpired = typeof task.dueAt === 'number' && now >= task.dueAt
  676. const roundExpired = task.channel === 'CONFRONTATION' && typeof item.startedAt === 'number' && roundSeconds > 0 && now >= item.startedAt + roundSeconds
  677. if (!dueExpired && !roundExpired) return reject(route, 409, '运行尚未超时')
  678. item.status = 'SUBMITTED'
  679. item.submission = { summary: '运行已由服务端执行超时结算。', timeout: true }
  680. item.submittedAt = now
  681. item.version = Number(item.version) + 1
  682. return fulfill(route, item, 'ute2e-run-timeout-submitted')
  683. }
  684. const terminateMatch = path.match(/^\/runs\/([^/]+)\/terminate$/)
  685. if (method === 'POST' && terminateMatch) {
  686. const item = this.runs.get(terminateMatch[1]!)
  687. if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '结束运行版本或状态冲突')
  688. if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限')
  689. const task = this.requireAssignment(String(item.assignmentId))
  690. if (task.assignmentKind !== 'TRAINING' || task.dueAt != null || (task.channel === 'CONFRONTATION' && item.status === 'IN_PROGRESS')) return reject(route, 409, '当前运行应使用超时结算')
  691. if (typeof body.reason !== 'string' || !body.reason.trim() || body.reason.length > 500) return reject(route, 400, '结束运行必须提供 1 至 500 字原因')
  692. item.status = 'SUBMITTED'
  693. item.submission = { summary: `运行由教员结束:${body.reason.trim()}`, terminated: true }
  694. item.submittedAt = Number(task.serverNow)
  695. item.version = Number(item.version) + 1
  696. return fulfill(route, item, 'ute2e-run-terminated-submitted')
  697. }
  698. const reviewMatch = path.match(/^\/runs\/([^/]+)\/review$/)
  699. if (method === 'POST' && reviewMatch) {
  700. const item = this.runs.get(reviewMatch[1]!)
  701. if (!item || item.status !== 'SUBMITTED' || body.version !== item.version || typeof body.score !== 'number') return reject(route, 409, '评定 DTO 或状态冲突')
  702. 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
  703. return fulfill(route, item, 'ute2e-run-reviewed')
  704. }
  705. const activateMatch = path.match(/^\/interventions\/([^/]+)\/activate$/)
  706. if (method === 'POST' && activateMatch) {
  707. const item = this.interventions.find((value) => value.id === activateMatch[1])
  708. if (!item || body.version !== item.version) return reject(route, 409, '干预版本冲突')
  709. item.status = 'ACTIVE'; item.activatedAt = 1786387200; item.version = Number(item.version) + 1
  710. return fulfill(route, item, 'ute2e-intervention-active')
  711. }
  712. if (method === 'GET' && path === '/faults') return fulfill(route, pageResult([]), 'ute2e-faults')
  713. 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')
  714. if (method === 'GET' && path === '/analytics/records') {
  715. if (this.identity === 'administrative') {
  716. this.administrativeAnalyticsRecordsRejected += 1
  717. return reject(route, 403, '行政身份仅可读取部门聚合,不可读取个人运行明细')
  718. }
  719. if (this.identity === 'unionAdministrativeStudent' && url.searchParams.get('userId') !== this.currentUserId()) {
  720. return reject(route, 403, '受限学员只能读取本人训练明细')
  721. }
  722. return fulfill(route, pageResult([{
  723. 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,
  724. }]), 'ute2e-analytics-records')
  725. }
  726. 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')
  727. 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')
  728. const xrStartMatch = path.match(/^\/runs\/([^/]+)\/xr-sessions$/)
  729. if (method === 'POST' && xrStartMatch) {
  730. const item = this.runs.get(xrStartMatch[1]!)
  731. if (!item || body.runVersion !== item.version || body.mode !== 'INLINE') return reject(route, 400, 'XR 会话 DTO 不符合契约')
  732. 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')
  733. }
  734. const xrEndMatch = path.match(/^\/xr-sessions\/([^/]+)\/end$/)
  735. 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')
  736. return reject(route, 404, `未模拟教学端点 ${method} ${path}`)
  737. }
  738. private assetList() {
  739. return [{
  740. sourceProjectId: 'asset-project-1', sourceVersionId: 'asset-version-1', assetCode: 'EXCAVATOR_A',
  741. name: 'excavator-a.glb', type: 'MODEL_FILE', contentUri: 'content://sha256/contract-excavator',
  742. mimeType: 'model/gltf-binary', sizeBytes: 11919032, sha256: 'contract-sha256', downloadable: true,
  743. downloadUrl: '/models/excavator-a.glb',
  744. }]
  745. }
  746. }