Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 

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