import type { Page, Route } from '@playwright/test' export type TeachingIdentity = 'admin' | 'administrative' | 'teacher' | 'studentLeader' | 'studentOperator' | 'unionAdministrativeStudent' type Channel = 'VIRTUAL' | 'PHYSICAL' | 'CONFRONTATION' type AssignmentKind = 'TRAINING' | 'FORMAL_EXAM' type JsonRecord = Record const stepCodeOf = (step: JsonRecord) => String(step.stepCode ?? step.code ?? step.id ?? '') export interface RecordedTeachingRequest { method: string path: string query: URLSearchParams body: JsonRecord } const allTeachingPages = [ 'teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', 'teaching.analytics', ] const managerActions = ['teaching.tasks.create', 'teaching.tasks.publish', 'teaching.tasks.withdraw', 'teaching.tasks.review', 'teaching.tasks.archive'] const learnerActions = ['teaching.tasks.accept', 'teaching.tasks.submit', 'teaching.virtual.execute', 'teaching.physical.execute', 'teaching.confrontation.execute', 'teaching.immersive.execute'] const permissions: Record = { admin: [...allTeachingPages, ...managerActions, ...learnerActions, 'teaching.confrontation.configure'], administrative: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.immersive', 'teaching.exams', 'teaching.analytics'], teacher: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.immersive', 'teaching.exams', 'teaching.analytics', ...managerActions, 'teaching.confrontation.configure', 'teaching.immersive.execute'], studentLeader: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', ...learnerActions], studentOperator: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', ...learnerActions], unionAdministrativeStudent: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', 'teaching.analytics', ...learnerActions], } const roleNames = { admin: '管理员', administrative: '行政', teacher: '教员', student: '学员' } const member = ( id: string, userId: string, username: string, displayName: string, memberType: 'INSTRUCTOR' | 'LEARNER' | 'OBSERVER', positionCode: string, teamCode: 'NEUTRAL' | 'RED' | 'BLUE', groupCode: string, sortOrder: number, ) => ({ id, userId, username, displayName, departmentId: '110', departmentName: '维修教研室', memberType, roleCode: memberType === 'INSTRUCTOR' ? 'teacher' : memberType === 'LEARNER' ? 'student' : 'observer', teamCode, groupCode, positionCode, stationCode: '', sortOrder, version: 1, }) export const teachingMembers = { instructor: member('member-teacher', '2', 'teacher', '周教员', 'INSTRUCTOR', 'INSTRUCTOR', 'NEUTRAL', 'INSTRUCTORS', 0), leader: member('member-red-leader', '4', 'student.leader', '红方队长', 'LEARNER', 'TEAM_LEADER', 'RED', 'GROUP-RED', 1), operator: member('member-red-operator', '5', 'student.operator', '红方操作员', 'LEARNER', 'OPERATOR', 'RED', 'GROUP-RED', 2), commander: member('member-blue-commander', '7', 'student.commander', '蓝方指挥员', 'LEARNER', 'COMMANDER', 'BLUE', 'GROUP-BLUE', 3), observer: member('member-observer', '6', 'observer', '安全观察员', 'OBSERVER', 'OBSERVER', 'NEUTRAL', 'OBSERVERS', 4), } const twoStepDefinition = (channel: Channel) => ({ schema: 'unreal-tran.training', version: '2.0', scenario: { schema: 'unreal-tran.scene', version: '2.0', objects: [{ id: `${channel.toLowerCase()}-equipment`, type: 'model', name: '履带式挖掘机训练模型', assetUrl: 'content://sha256/contract-excavator', assetCode: 'EXCAVATOR_A', targetProjectId: 'asset-project-1', targetVersionId: 'asset-version-1', position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], visible: true, semantic: { interactionId: 'excavator', operable: true }, }], }, steps: channel === 'PHYSICAL' ? [ { code: 'STEP-01', title: '确认设备断电', publicSummary: '完成现场断电与挂牌。', targetId: `${channel.toLowerCase()}-equipment`, physicalExecution: { source: 'MANUAL', eventType: 'MANUAL_CONFIRM_POWER_OFF', manualConfirmAllowed: true } }, { code: 'STEP-02', title: '确认液压卸压', publicSummary: '完成液压系统卸压。', targetId: `${channel.toLowerCase()}-equipment`, physicalExecution: { source: 'MANUAL', eventType: 'MANUAL_CONFIRM_PRESSURE_RELEASED', manualConfirmAllowed: true } }, ] : channel === 'CONFRONTATION' ? [ { code: 'STEP-01', title: '队长下达处置指令', publicSummary: '共享任务由队长发起。', actionCode: 'ISSUE_COMMAND', allowedPositions: ['TEAM_LEADER'], targetId: `${channel.toLowerCase()}-equipment` }, { code: 'STEP-02', title: '操作员执行故障隔离', publicSummary: '操作员按指令隔离故障。', actionCode: 'ISOLATE_FAULT', requiredPositions: ['OPERATOR'], targetId: `${channel.toLowerCase()}-equipment` }, ] : [ { stepCode: 'STEP-01', title: '识别作业对象', publicSummary: '确认训练对象与安全边界。', actionCode: 'IDENTIFY_TARGET', targetId: `${channel.toLowerCase()}-equipment` }, { stepCode: 'STEP-02', title: '完成检修操作', publicSummary: '按流程完成检修并复核。', actionCode: 'COMPLETE_REPAIR', targetId: `${channel.toLowerCase()}-equipment` }, ], runtime: { submitPositionCodes: channel === 'CONFRONTATION' ? ['TEAM_LEADER', 'COMMANDER'] : [] }, modeConfig: channel === 'CONFRONTATION' ? { objective: '红蓝双方按岗位协同完成液压故障隔离', roundDurationMinutes: 20, teamSize: 2, winRule: '在安全约束下按完成度与用时综合判定', redTeamName: '红方维修组', blueTeamName: '蓝方保障组', submitPositions: ['TEAM_LEADER', 'COMMANDER'], teams: [ { code: 'RED', name: '红方维修组', color: '#d25c5c', size: 2 }, { code: 'BLUE', name: '蓝方保障组', color: '#4b7ed6', size: 2 }, ], } : {}, }) const virtualPreviewDefinition = () => ({ schema: 'unreal-tran.training', version: '1.0', level: '中修', skills: ['总成拆检', '部件更换', '功能复验'], scenario: { schema: 'unreal-tran.scene', version: '2.0', name: '支腿液压缸中修训练场景', sceneType: 'WORKSHOP', objects: [{ id: 'virtual-equipment', type: 'model', name: '支腿液压缸工位', assetUrl: 'content://sha256/contract-excavator', assetCode: 'EXCAVATOR_A', targetProjectId: 'asset-project-1', targetVersionId: 'asset-version-1', position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], visible: true, semantic: { interactionId: 'hydraulic-cylinder', operable: true }, }], }, steps: [ ['STEP-01', '卸压确认与故障基线采集', 'RELEASE_PRESSURE_BASELINE', '确认系统卸压并采集故障基线。', '确认压力表回零,记录故障压力和泄漏现象。'], ['STEP-02', '油管与接头外泄检查', 'CHECK_EXTERNAL_LEAK', '检查油管、接头与密封面的外泄。', '沿管路逐点检查,确认无高压喷射风险。'], ['STEP-03', '测量系统故障压力', 'MEASURE_FAULT_PRESSURE', '连接压力表并测量系统故障压力。', '按量程选择测点,读取并记录稳定压力值。'], ['STEP-04', '保压定位液压缸内泄', 'LOCATE_INTERNAL_LEAK', '执行保压试验并定位液压缸内泄。', '隔离负载后观察压力衰减,判断内泄位置。'], ['STEP-05', '更换活塞密封组件', 'REPLACE_PISTON_SEAL', '拆检并更换活塞密封组件。', '核对密封方向,避免划伤密封唇口和缸筒。'], ['STEP-06', '缸盖复位与力矩紧固', 'TORQUE_CYLINDER_HEAD', '复位缸盖并按规定力矩紧固。', '按对角顺序分级紧固,记录最终力矩。'], ['STEP-07', '复测工作压力与响应', 'VERIFY_PRESSURE_RESPONSE', '复测工作压力、动作速度与响应。', '低压试运转后逐级升压,观察动作是否平稳。'], ['STEP-08', '密封复验与中修闭环', 'VERIFY_SEAL_AND_CLOSE', '完成密封复验并形成中修闭环。', '确认无泄漏、参数合格,清点工具并恢复防护。'], ].map(([stepCode, title, actionCode, publicSummary, narrationText], index) => ({ stepCode, title, actionCode, publicSummary, instruction: narrationText, narrationText, safety: index === 0 ? '执行前确认能量隔离与作业区域安全。' : '按维修作业规程佩戴防护用品并保持工位整洁。', targetId: 'virtual-equipment', targetName: '支腿液压缸工位', score: index === 7 ? 16 : 12, })), runtime: { teacherEnabled: true, teacherVoiceEnabled: true, allowReplay: true }, digitalInstructor: { enabled: true, agentSlug: 'dh-b69424ed0904', agentName: '点火系统维修教研员', webBaseUrl: '/ute2e-ai-person', }, modeConfig: { allowedModes: ['DEMO', 'PRACTICE', 'MOCK_EXAM'] }, }) const assignment = (id: string, channel: Channel, kind: AssignmentKind, audienceType: 'ASSIGNED' | 'COMMON', collaborationMode: 'INDIVIDUAL' | 'TEAM') => ({ id, code: kind === 'FORMAL_EXAM' ? 'EXAM-2026-001' : `TASK-${channel}-001`, name: kind === 'FORMAL_EXAM' ? `${channel === 'PHYSICAL' ? '实装' : channel === 'CONFRONTATION' ? '对抗' : '虚拟'}维修正式考核` : `${channel === 'PHYSICAL' ? '实装' : channel === 'CONFRONTATION' ? '对抗' : '虚拟'}维修训练`, description: '状态化 UI 契约测试任务。', channel, assignmentKind: kind, executionMode: kind === 'FORMAL_EXAM' ? 'EXAM' : 'PRACTICE', audienceType, collaborationMode, contentProjectId: 'training-project-1', contentVersionId: 'training-version-1', definitionSnapshot: twoStepDefinition(channel), definitionChecksum: `checksum-${id}`, digitalHumanAllowed: kind !== 'FORMAL_EXAM', ownerUserId: '2', ownerUsername: 'teacher', ownerName: '周教员', departmentId: '110', departmentName: '维修教研室', status: 'PUBLISHED', scheduleStartAt: 1786383600, dueAt: 1786473600, publishedAt: 1786380000, withdrawnAt: null, archivedAt: null, lifecycleReason: '', version: 3, addTime: 1786300800, updateTime: '2026-08-11T08:00:00+08:00', serverNow: 1786387200, members: audienceType === 'COMMON' ? [teachingMembers.instructor] : channel === 'CONFRONTATION' ? Object.values(teachingMembers) : [teachingMembers.instructor, teachingMembers.leader], runCount: 1, pendingReviewCount: 0, }) const assignmentBrief = (item: JsonRecord) => ({ id: item.id, code: item.code, name: item.name, channel: item.channel, assignmentKind: item.assignmentKind, executionMode: item.executionMode, audienceType: item.audienceType, collaborationMode: item.collaborationMode, digitalHumanAllowed: item.digitalHumanAllowed, status: item.status, scheduleStartAt: item.scheduleStartAt, dueAt: item.dueAt, serverNow: item.serverNow, }) const runFor = (id: string, task: JsonRecord, status: 'PENDING' | 'ACCEPTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'REVIEWED', learner = teachingMembers.leader) => ({ id, code: `RUN-${String(task.channel)}-${id.slice(-3).toUpperCase()}`, assignmentId: task.id, memberId: learner.id, subjectType: task.collaborationMode === 'TEAM' ? 'TEAM' : 'MEMBER', subjectCode: task.collaborationMode === 'TEAM' ? learner.teamCode : learner.id, attemptNo: 1, status, progressPercent: status === 'SUBMITTED' || status === 'REVIEWED' ? 100 : 0, currentStepCode: 'STEP-01', checkpoint: {}, automaticResult: {}, submission: status === 'SUBMITTED' || status === 'REVIEWED' ? { summary: '已完成' } : null, score: status === 'REVIEWED' ? 88 : null, passed: status === 'REVIEWED' ? true : null, reviewFeedback: status === 'REVIEWED' ? '流程与操作符合要求。' : '', reviewRubric: {}, acceptedAt: status === 'PENDING' ? null : 1786386000, startedAt: ['PENDING', 'ACCEPTED'].includes(status) ? null : 1786387200, submittedAt: status === 'SUBMITTED' || status === 'REVIEWED' ? 1786390800 : null, reviewedAt: status === 'REVIEWED' ? 1786394400 : null, reviewedByUserId: status === 'REVIEWED' ? '2' : null, reviewerName: status === 'REVIEWED' ? '周教员' : '', archivedAt: null, version: 2, addTime: 1786386000, updateTime: '2026-08-11T10:00:00+08:00', assignment: assignmentBrief(task), member: learner, members: task.collaborationMode === 'TEAM' ? Object.values(teachingMembers) : [teachingMembers.instructor, learner], }) const envelope = (data: unknown, requestId = 'ute2e-teaching-state') => ({ code: 200, message: '成功', data, timestamp: '2026-08-11T10:00:00+08:00', requestId, }) const pageResult = (records: unknown[], page = 1, size = 10) => ({ records, total: records.length, page, size }) const bodyOf = (route: Route): JsonRecord => { const text = route.request().postData() if (!text) return {} const value = JSON.parse(text) as unknown return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} } const fulfill = (route: Route, data: unknown, requestId?: string) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data, requestId)), }) const reject = (route: Route, status: number, message: string) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-rejected' }), }) export class TeachingContractMock { identity: TeachingIdentity readonly requests: RecordedTeachingRequest[] = [] readonly assignments = new Map() readonly runs = new Map() readonly events = new Map() readonly interventions: JsonRecord[] = [] readonly deniedPermissions = new Set() administrativeRunsRejected = 0 administrativeAnalyticsRecordsRejected = 0 digitalInstructorFrameLoads = 0 constructor(identity: TeachingIdentity) { this.identity = identity const previewTask = { ...assignment('assignment-virtual-preview', 'VIRTUAL', 'TRAINING', 'ASSIGNED', 'INDIVIDUAL'), code: 'TASK-VIRTUAL-PREVIEW-001', name: '支腿液压缸中修训练(虚拟仿真)', description: '面向液压密封件检查与更换的八步虚拟训练任务。', definitionSnapshot: virtualPreviewDefinition(), runCount: 0, pendingReviewCount: 0, listed: false, } const tasks = [ assignment('assignment-virtual-common', 'VIRTUAL', 'TRAINING', 'COMMON', 'INDIVIDUAL'), previewTask, assignment('assignment-physical', 'PHYSICAL', 'TRAINING', 'ASSIGNED', 'INDIVIDUAL'), assignment('assignment-confrontation', 'CONFRONTATION', 'TRAINING', 'ASSIGNED', 'TEAM'), assignment('assignment-exam', 'VIRTUAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'), assignment('assignment-exam-physical', 'PHYSICAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'), assignment('assignment-exam-confrontation', 'CONFRONTATION', 'FORMAL_EXAM', 'ASSIGNED', 'TEAM'), ] tasks.forEach((task) => this.assignments.set(String(task.id), task)) this.runs.set('run-physical', runFor('run-physical', this.requireAssignment('assignment-physical'), 'ACCEPTED')) this.runs.set('run-confrontation', runFor('run-confrontation', this.requireAssignment('assignment-confrontation'), 'IN_PROGRESS')) this.runs.set('run-exam', runFor('run-exam', this.requireAssignment('assignment-exam'), 'IN_PROGRESS')) } setIdentity(identity: TeachingIdentity) { this.identity = identity } denyPermissions(...codes: string[]) { codes.forEach((code) => this.deniedPermissions.add(code)) } seedImmersiveRun() { const task = this.requireAssignment('assignment-virtual-common') const runtime = runFor('run-immersive', task, 'IN_PROGRESS') runtime.currentStepCode = 'STEP-01' this.runs.set(String(runtime.id), runtime) } seedVirtualReviewedRun() { const task = this.requireAssignment('assignment-virtual-common') const runtime = runFor('run-virtual-reviewed', task, 'REVIEWED') runtime.currentStepCode = 'STEP-02' runtime.progressPercent = 100 runtime.score = 92 runtime.passed = true runtime.reviewFeedback = '步骤完整,安全确认和检修复核符合要求。' runtime.checkpoint = { completedStepCodes: ['STEP-01', 'STEP-02'] } this.runs.set(String(runtime.id), runtime) this.events.set(String(runtime.id), [ { id: 'event-virtual-reviewed-1', code: 'EVENT-VIRTUAL-001', assignmentId: task.id, runId: runtime.id, targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:识别作业对象', summary: '确认训练对象与安全边界。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' }, actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387260, }, { id: 'event-virtual-reviewed-2', code: 'EVENT-VIRTUAL-002', assignmentId: task.id, runId: runtime.id, targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:完成检修操作', summary: '按流程完成检修并复核。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-02' }, actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387560, }, ]) return runtime } seedConfrontationMonitorRuns() { const task = this.requireAssignment('assignment-confrontation') const active = this.runs.get('run-confrontation')! active.status = 'IN_PROGRESS' active.subjectCode = 'RED' active.members = [teachingMembers.instructor, teachingMembers.leader, teachingMembers.operator] active.progressPercent = 50 active.currentStepCode = 'STEP-02' this.events.set(String(active.id), [{ id: 'event-confrontation-red-1', code: 'EVENT-CON-RED-001', assignmentId: task.id, runId: active.id, targetType: 'RUN', targetId: active.id, type: 'training.action.completed', title: '队长已下达处置指令', summary: '红方进入故障隔离阶段。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' }, actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387320, }]) const blue = runFor('run-confrontation-blue', task, 'ACCEPTED', teachingMembers.commander) blue.subjectCode = 'BLUE' blue.members = [teachingMembers.instructor, teachingMembers.commander] blue.currentStepCode = 'STEP-01' blue.progressPercent = 0 this.runs.set(String(blue.id), blue) this.events.set(String(blue.id), []) return { active, blue } } seedExpiredRun() { const task = this.requireAssignment('assignment-virtual-common') task.dueAt = Number(task.serverNow) - 60 const runtime = runFor('run-timeout', task, 'IN_PROGRESS') runtime.startedAt = Number(task.serverNow) - 3_600 runtime.progressPercent = 50 this.runs.set(String(runtime.id), runtime) return runtime } seedTerminableRun() { const task = this.requireAssignment('assignment-virtual-common') task.dueAt = null const runtime = runFor('run-terminate', task, 'IN_PROGRESS') runtime.startedAt = Number(task.serverNow) - 600 runtime.progressPercent = 25 this.runs.set(String(runtime.id), runtime) return runtime } seedConfrontationTerminationCases() { const task = this.requireAssignment('assignment-confrontation') task.dueAt = null const active = this.runs.get('run-confrontation')! active.status = 'IN_PROGRESS' active.startedAt = Number(task.serverNow) - 21 * 60 active.progressPercent = 50 const accepted = runFor('run-confrontation-queued', task, 'ACCEPTED', teachingMembers.operator) this.runs.set(String(accepted.id), accepted) return { active, accepted } } keepOnlyRuns(...runIds: string[]) { const selected = runIds.map((id) => this.runs.get(id)).filter((item): item is JsonRecord => Boolean(item)) this.runs.clear() selected.forEach((item) => this.runs.set(String(item.id), item)) } count(method: string, path: string | RegExp) { return this.requests.filter((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path))).length } last(method: string, path: string | RegExp) { return [...this.requests].reverse().find((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path))) } private requireAssignment(id: string) { const item = this.assignments.get(id) if (!item) throw new Error(`测试任务不存在:${id}`) return item } private currentUserId() { if (this.identity === 'studentOperator') return '5' if (this.identity === 'studentLeader' || this.identity === 'unionAdministrativeStudent') return '4' if (this.identity === 'teacher') return '2' if (this.identity === 'administrative') return '3' return '1' } private profile() { const union = this.identity === 'unionAdministrativeStudent' const role = this.identity === 'teacher' ? 'teacher' : this.identity === 'administrative' || union ? 'administrative' : this.identity === 'studentLeader' || this.identity === 'studentOperator' ? 'student' : 'admin' const userId = this.currentUserId() const displayName = this.identity === 'studentOperator' ? '红方操作员' : this.identity === 'studentLeader' || union ? '红方队长' : roleNames[role] const roles = union ? [ { id: 'role-administrative', code: 'administrative', name: '行政', shortName: '行', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'DEPARTMENT' }, { id: 'role-student', code: 'student', name: '学员', shortName: '学', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'SELF' }, ] : [{ 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' }] return { user: { id: userId, username: this.identity, displayName, departmentId: '110', departmentName: '维修教研室', mustChangePassword: false, version: 1 }, activeRoleId: union ? 'role-administrative' : `role-${role}`, roles, permissions: permissions[this.identity].filter((permission) => !this.deniedPermissions.has(permission)), authorizationMode: union ? 'UNION' : 'SINGLE_ACTIVE', loginTime: 1786387200, } } private currentMember(run: JsonRecord) { const values = Array.isArray(run.members) ? run.members as JsonRecord[] : [] return values.find((item) => item.userId === this.currentUserId() && item.memberType === 'LEARNER') } private visibleRuns() { const values = [...this.runs.values()] if (this.identity === 'teacher' || this.identity === 'admin') return values const userId = this.currentUserId() return values.filter((item) => { const values = Array.isArray(item.members) ? item.members as JsonRecord[] : [] return values.some((member) => member.userId === userId && member.memberType === 'LEARNER') }) } private stepsFor(run: JsonRecord) { const task = this.requireAssignment(String(run.assignmentId)) const definition = task.definitionSnapshot as JsonRecord return (definition.steps as JsonRecord[]) ?? [] } private advanceRun(route: Route, run: JsonRecord, completedStepCode: string, actionCode?: string, respond = true) { const steps = this.stepsFor(run) const currentCode = String(run.currentStepCode) if (completedStepCode !== currentCode) return reject(route, 409, `完成步骤必须等于当前待办步骤 ${currentCode}`) const index = steps.findIndex((step) => stepCodeOf(step) === completedStepCode) if (index < 0) return reject(route, 400, '步骤不属于发布任务') const task = this.requireAssignment(String(run.assignmentId)) if (task.channel === 'CONFRONTATION') { const current = this.currentMember(run) const allowed = (steps[index]!.allowedPositions ?? steps[index]!.requiredPositions ?? []) as string[] if (!current || !allowed.includes(String(current.positionCode))) return reject(route, 403, '当前岗位不能执行此动作') if (actionCode !== steps[index]!.actionCode) return reject(route, 400, 'actionCode 与当前步骤不匹配') } const next = steps[Math.min(index + 1, steps.length - 1)]! run.progressPercent = Math.round(((index + 1) * 100) / steps.length) run.currentStepCode = stepCodeOf(next) run.version = Number(run.version) + 1 const events = this.events.get(String(run.id)) ?? [] events.push({ id: `event-${events.length + 1}`, code: `EVENT-${events.length + 1}`, assignmentId: run.assignmentId, runId: run.id, targetType: 'RUN', targetId: run.id, type: 'training.action.completed', title: `完成:${steps[index]!.title}`, summary: steps[index]!.publicSummary, source: 'web', visibility: 'PUBLIC', payload: { completedStepCode, actionCode }, actorUserId: this.currentUserId(), actorDisplayName: this.profile().user.displayName, occurredAt: 1786387200 + events.length, }) this.events.set(String(run.id), events) return respond ? fulfill(route, run, 'ute2e-progress-authoritative') : Promise.resolve() } async install(page: Page) { await page.addInitScript(() => sessionStorage.setItem('unreal-tran:web:access-token:v1', 'teaching-contract-token')) await page.route('**/api/auth/v1/auth/me', (route) => fulfill(route, this.profile(), 'ute2e-teaching-me')) await page.route('**/api/auth/v1/menus/navigation', (route) => fulfill(route, [], 'ute2e-teaching-menu')) await page.route('**/api/auth/v1/directory/teaching-members**', (route) => fulfill(route, [ { id: '4', username: 'student.leader', displayName: '红方队长', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] }, { id: '5', username: 'student.operator', displayName: '红方操作员', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] }, ], 'ute2e-teaching-directory')) await page.route('**/api/tran/v1/content/catalog**', (route) => fulfill(route, pageResult([{ 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: [] }, }]), 'ute2e-teaching-catalog')) await page.route(/\/embed\/dh-b69424ed0904(?:\?.*)?$/, (route) => { this.digitalInstructorFrameLoads += 1 return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '
虚拟教员在线答疑
', }) }) await page.route('**/api/tran/v1/teaching/**', (route) => this.handleTeaching(route)) } private async handleTeaching(route: Route) { const request = route.request() const url = new URL(request.url()) const path = url.pathname.replace(/^.*\/teaching/, '') const method = request.method() const body = bodyOf(route) this.requests.push({ method, path, query: new URLSearchParams(url.searchParams), body }) if (method === 'GET' && path === '/guides') return fulfill(route, pageResult([{ 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, }]), 'ute2e-guides') if (method === 'GET' && path === '/guides/guide-project-1') return fulfill(route, { projectId: 'guide-project-1', versionId: 'guide-version-1', code: 'GUIDE-001', name: '挖掘机检修作业指导书', description: '标准维修作业流程', categoryCode: '维修作业', coverUri: '', versionNumber: 3, versionCode: 'V3', publishedAt: 1786387200, content: { equipment: '履带式挖掘机', specification: '教学训练型', chapters: [ { id: 'chapter-1', title: '安全准备', order: 1, steps: [ { id: 'step-1', title: '断电隔离', summary: '确认停机、断电并完成安全隔离。', safety: '执行挂牌上锁。', acceptance: '设备处于零能量状态。', duration: '5分钟', type: 'CHECK', tools: '安全锁具', parameters: {}, media: '', part: '动力总成', anchor: '', animation: '', order: 1 }, { id: 'step-2', title: '压力释放', summary: '缓慢释放液压系统残余压力。', safety: '佩戴护目镜并确认卸压方向无人。', acceptance: '压力表回零且系统无残压。', duration: '8分钟', type: 'SAFETY', tools: '压力表', parameters: { '目标压力': '0 MPa' }, media: '', part: '液压回路', anchor: '', animation: '', order: 2 }, ], }, { id: 'chapter-2', title: '检修复核', order: 2, steps: [{ id: 'step-3', title: '复装与复测', summary: '完成部件复装并按标准执行功能复测。', safety: '清点工具并恢复防护装置。', acceptance: '动作平稳、无泄漏且记录完整。', duration: '12分钟', type: 'VERIFY', tools: '扭矩扳手', parameters: { '复测次数': '2 次' }, media: '', part: '执行机构', anchor: '', animation: '', order: 1 }], }, ], }, }, 'ute2e-guide-detail') if (method === 'GET' && path === '/assignments/summary') { const channel = url.searchParams.get('channel') const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING' const tasks = [...this.assignments.values()].filter((item) => item.listed !== false && (!channel || item.channel === channel) && item.assignmentKind === kind) 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') } if (method === 'GET' && path === '/assignments') { const channel = url.searchParams.get('channel') const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING' const tasks = [...this.assignments.values()].filter((item) => item.listed !== false && (!channel || item.channel === channel) && item.assignmentKind === kind) return fulfill(route, pageResult(tasks), 'ute2e-assignments') } if (method === 'POST' && path === '/assignments') { 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 秒') if (body.assignmentKind === 'FORMAL_EXAM' && (body.executionMode !== 'EXAM' || body.digitalHumanAllowed !== false)) return reject(route, 400, '正式考核必须使用 EXAM 且禁用数字教员') 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' } this.assignments.set(String(created.id), created) return fulfill(route, created, 'ute2e-assignment-created') } const assignmentAssetMatch = path.match(/^\/assignments\/([^/]+)\/assets$/) if (method === 'GET' && assignmentAssetMatch) return fulfill(route, this.assetList(), 'ute2e-assignment-assets') const assignmentFaultMatch = path.match(/^\/assignments\/([^/]+)\/faults$/) if (method === 'GET' && assignmentFaultMatch) { if (this.identity === 'administrative') return reject(route, 403, '行政身份不可查看故障细节') return fulfill(route, [{ id: 'assignment-fault-public', assignmentId: assignmentFaultMatch[1], faultId: this.identity === 'teacher' || this.identity === 'admin' ? 'fault-1' : null, faultCode: this.identity === 'teacher' || this.identity === 'admin' ? 'HYDRAULIC.VALVE.STUCK' : '', faultName: this.identity === 'teacher' || this.identity === 'admin' ? '液压阀卡滞' : '', severity: this.identity === 'teacher' || this.identity === 'admin' ? 'HIGH' : '', publicSymptom: '动臂响应迟缓且压力波动', privateTruth: this.identity === 'teacher' || this.identity === 'admin' ? { text: '液压阀芯卡滞真值' } : null, triggerConfig: this.identity === 'teacher' || this.identity === 'admin' ? { hidden: '内部触发条件' } : null, targetScope: 'TEAM', targetTeamCode: 'RED', targetGroupCode: 'GROUP-RED', sortOrder: 0, }], 'ute2e-assignment-faults') } const interventionListMatch = path.match(/^\/assignments\/([^/]+)\/interventions$/) if (interventionListMatch && method === 'GET') return fulfill(route, this.interventions, 'ute2e-interventions') if (interventionListMatch && method === 'POST') { if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无教学干预权限') if (!['ASSIGNMENT', 'TEAM', 'MEMBER'].includes(String(body.targetScope))) return reject(route, 400, '干预作用范围不符合契约') if (body.targetScope === 'TEAM' && !['RED', 'BLUE', 'NEUTRAL'].includes(String(body.targetTeamCode))) return reject(route, 400, 'TEAM 干预必须携带目标队伍') if (body.targetScope === 'MEMBER' && !body.targetMemberId) return reject(route, 400, 'MEMBER 干预必须携带目标成员') if (typeof body.publicSummary !== 'string' || !body.publicSummary.trim()) return reject(route, 400, '干预必须提供公开说明') if (/privateTruth|triggerConfig|solution|answer|truth/i.test(JSON.stringify(body.publicPayload ?? {}))) return reject(route, 400, '公开干预载荷不能包含故障真值或解决方案') const privatePayload = body.privatePayload ?? {} const payload = Object.keys(privatePayload as JsonRecord).length ? { public: body.publicPayload ?? {}, private: privatePayload } : body.publicPayload ?? {} const item = { id: `intervention-${this.interventions.length + 1}`, assignmentId: interventionListMatch[1], code: body.code, type: body.type, targetScope: body.targetScope, targetMemberId: body.targetScope === 'MEMBER' ? body.targetMemberId : '', targetTeamCode: body.targetScope === 'TEAM' ? body.targetTeamCode : '', targetGroupCode: body.targetScope === 'TEAM' ? body.targetGroupCode ?? '' : '', publicSummary: body.publicSummary, payload, status: 'DRAFT', createdByUserId: '2', createdByName: '周教员', activatedAt: null, revokedAt: null, version: 0, } this.interventions.push(item) return fulfill(route, item, 'ute2e-intervention-created') } const assignmentDetailMatch = path.match(/^\/assignments\/([^/]+)$/) if (method === 'GET' && assignmentDetailMatch) { const item = this.requireAssignment(assignmentDetailMatch[1]!) const learnerProjection = !['admin', 'teacher'].includes(this.identity) && item.assignmentKind === 'FORMAL_EXAM' ? { ...item, definitionSnapshot: null } : item return fulfill(route, learnerProjection, 'ute2e-assignment-detail') } const acceptMatch = path.match(/^\/assignments\/([^/]+)\/accept$/) if (method === 'POST' && acceptMatch) { const task = this.requireAssignment(acceptMatch[1]!) if (body.assignmentVersion !== task.version || body.teamCode !== 'NEUTRAL' || body.groupCode !== 'INDIVIDUAL' || body.positionCode !== 'LEARNER') return reject(route, 400, '领取 DTO 不符合契约') const pending = [...this.runs.values()].find((item) => item.assignmentId === task.id && item.status === 'PENDING') if (pending) { pending.status = 'ACCEPTED' pending.acceptedAt = 1786386000 pending.version = Number(pending.version) + 1 return fulfill(route, pending, 'ute2e-assigned-task-accepted') } const created = runFor('run-virtual-common', task, 'ACCEPTED', teachingMembers.leader) this.runs.set(String(created.id), created) return fulfill(route, created, 'ute2e-assignment-accepted') } if (method === 'GET' && path === '/runs') { if (this.identity === 'administrative') { this.administrativeRunsRejected += 1; return reject(route, 403, '行政身份不可读取运行明细') } const assignmentId = url.searchParams.get('assignmentId') const channel = url.searchParams.get('channel') const kind = url.searchParams.get('assignmentKind') const values = this.visibleRuns().filter((item) => (!assignmentId || item.assignmentId === assignmentId) && (!channel || (item.assignment as JsonRecord).channel === channel) && (!kind || (item.assignment as JsonRecord).assignmentKind === kind)) return fulfill(route, pageResult(values), 'ute2e-runs') } const runAssetsMatch = path.match(/^\/runs\/([^/]+)\/assets$/) if (method === 'GET' && runAssetsMatch) return fulfill(route, this.assetList(), 'ute2e-run-assets') const runExecutionMatch = path.match(/^\/runs\/([^/]+)\/execution$/) if (method === 'GET' && runExecutionMatch) { const item = this.runs.get(runExecutionMatch[1]!) if (!item) return reject(route, 404, '运行不存在') const task = this.requireAssignment(String(item.assignmentId)) if (task.assignmentKind !== 'FORMAL_EXAM' || item.status !== 'IN_PROGRESS') return reject(route, 409, '仅正式考核进行中运行提供执行投影') const definition = task.definitionSnapshot as JsonRecord const steps = (definition.steps as JsonRecord[]) ?? [] const index = Math.max(0, steps.findIndex((step) => stepCodeOf(step) === item.currentStepCode)) return fulfill(route, { runId: item.id, assignmentId: item.assignmentId, currentStepCode: item.currentStepCode, currentStepIndex: index, totalSteps: steps.length, progressPercent: item.progressPercent, currentStep: steps[index] ?? null, resources: definition.resources ?? [], scenario: definition.scenario ?? {}, tools: [], runtime: { showStepPanel: false, allowReplay: false, allowRollback: false, digitalHumanAllowed: false }, modeConfig: definition.modeConfig ?? {}, examPolicy: { hintsAllowed: false, demoAllowed: false, replayAllowed: false, rollbackAllowed: false, digitalHumanAllowed: false }, deadlineAt: task.dueAt, serverNow: task.serverNow, }, 'ute2e-run-execution') } const runEventsMatch = path.match(/^\/runs\/([^/]+)\/events$/) if (method === 'GET' && runEventsMatch) return fulfill(route, this.events.get(runEventsMatch[1]!) ?? [], 'ute2e-events') const runDetailMatch = path.match(/^\/runs\/([^/]+)$/) if (method === 'GET' && runDetailMatch) { const item = this.runs.get(runDetailMatch[1]!) return item ? fulfill(route, item, 'ute2e-run-detail') : reject(route, 404, '运行不存在') } const startMatch = path.match(/^\/runs\/([^/]+)\/start$/) if (method === 'POST' && startMatch) { const item = this.runs.get(startMatch[1]!) if (!item || item.status !== 'ACCEPTED' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突') item.status = 'IN_PROGRESS'; item.startedAt = 1786387200; item.version = Number(item.version) + 1 return fulfill(route, item, 'ute2e-run-started') } const progressMatch = path.match(/^\/runs\/([^/]+)\/progress$/) if (method === 'PUT' && progressMatch) { const item = this.runs.get(progressMatch[1]!) if (!item || item.status !== 'IN_PROGRESS' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突') if ('percent' in body || 'currentStepCode' in body) return reject(route, 400, '进度与下一步骤必须由服务端派生') if (typeof body.completedStepCode !== 'string') return reject(route, 400, '缺少 completedStepCode') return this.advanceRun(route, item, body.completedStepCode, typeof body.actionCode === 'string' ? body.actionCode : undefined) } const physicalMatch = path.match(/^\/runs\/([^/]+)\/physical-events$/) if (method === 'POST' && physicalMatch) { const item = this.runs.get(physicalMatch[1]!) if (!item || item.status !== 'IN_PROGRESS' || body.runVersion !== item.version) return reject(route, 409, '实装运行版本或状态冲突') if (body.source !== 'MANUAL' || body.manualConfirmed !== true || typeof body.eventId !== 'string' || body.stepCode !== item.currentStepCode) return reject(route, 400, '实装事件 DTO 不符合契约') if (typeof body.occurredAt !== 'number' || body.occurredAt >= 1_000_000_000_000) return reject(route, 400, '现场事件时间必须是 Unix 秒') const currentStep = this.stepsFor(item).find((step) => stepCodeOf(step) === body.stepCode) const execution = currentStep?.physicalExecution as JsonRecord | undefined if (!execution || execution.source !== body.source || execution.eventType !== body.eventType || execution.manualConfirmAllowed === false) return reject(route, 400, '现场事件与当前步骤执行规则不匹配') await this.advanceRun(route, item, String(body.stepCode), undefined, false) 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') } const submitMatch = path.match(/^\/runs\/([^/]+)\/submit$/) if (method === 'POST' && submitMatch) { const item = this.runs.get(submitMatch[1]!) if (!item || item.status !== 'IN_PROGRESS' || item.progressPercent !== 100 || body.version !== item.version) return reject(route, 409, '运行未完成或版本冲突') const task = this.requireAssignment(String(item.assignmentId)) if (task.collaborationMode === 'TEAM') { const current = this.currentMember(item) if (!current || !['TEAM_LEADER', 'COMMANDER'].includes(String(current.positionCode))) return reject(route, 403, '共享运行仅负责人可提交') } item.status = 'SUBMITTED'; item.submittedAt = 1786390800; item.submission = { summary: body.summary }; item.version = Number(item.version) + 1 return fulfill(route, item, 'ute2e-run-submitted') } const timeoutMatch = path.match(/^\/runs\/([^/]+)\/timeout$/) if (method === 'POST' && timeoutMatch) { const item = this.runs.get(timeoutMatch[1]!) if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '超时结算运行版本或状态冲突') if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限') const task = this.requireAssignment(String(item.assignmentId)) const now = Number(task.serverNow) const definition = task.definitionSnapshot as JsonRecord const modeConfig = (definition.modeConfig ?? {}) as JsonRecord const roundSeconds = Number(modeConfig.durationSeconds ?? 0) || Number(modeConfig.roundDurationMinutes ?? modeConfig.roundDuration ?? 0) * 60 const dueExpired = typeof task.dueAt === 'number' && now >= task.dueAt const roundExpired = task.channel === 'CONFRONTATION' && typeof item.startedAt === 'number' && roundSeconds > 0 && now >= item.startedAt + roundSeconds if (!dueExpired && !roundExpired) return reject(route, 409, '运行尚未超时') item.status = 'SUBMITTED' item.submission = { summary: '运行已由服务端执行超时结算。', timeout: true } item.submittedAt = now item.version = Number(item.version) + 1 return fulfill(route, item, 'ute2e-run-timeout-submitted') } const terminateMatch = path.match(/^\/runs\/([^/]+)\/terminate$/) if (method === 'POST' && terminateMatch) { const item = this.runs.get(terminateMatch[1]!) if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '结束运行版本或状态冲突') if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限') const task = this.requireAssignment(String(item.assignmentId)) if (task.assignmentKind !== 'TRAINING' || task.dueAt != null || (task.channel === 'CONFRONTATION' && item.status === 'IN_PROGRESS')) return reject(route, 409, '当前运行应使用超时结算') if (typeof body.reason !== 'string' || !body.reason.trim() || body.reason.length > 500) return reject(route, 400, '结束运行必须提供 1 至 500 字原因') item.status = 'SUBMITTED' item.submission = { summary: `运行由教员结束:${body.reason.trim()}`, terminated: true } item.submittedAt = Number(task.serverNow) item.version = Number(item.version) + 1 return fulfill(route, item, 'ute2e-run-terminated-submitted') } const reviewMatch = path.match(/^\/runs\/([^/]+)\/review$/) if (method === 'POST' && reviewMatch) { const item = this.runs.get(reviewMatch[1]!) if (!item || item.status !== 'SUBMITTED' || body.version !== item.version || typeof body.score !== 'number') return reject(route, 409, '评定 DTO 或状态冲突') 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 return fulfill(route, item, 'ute2e-run-reviewed') } const activateMatch = path.match(/^\/interventions\/([^/]+)\/activate$/) if (method === 'POST' && activateMatch) { const item = this.interventions.find((value) => value.id === activateMatch[1]) if (!item || body.version !== item.version) return reject(route, 409, '干预版本冲突') item.status = 'ACTIVE'; item.activatedAt = 1786387200; item.version = Number(item.version) + 1 return fulfill(route, item, 'ute2e-intervention-active') } if (method === 'GET' && path === '/faults') return fulfill(route, pageResult([]), 'ute2e-faults') 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') if (method === 'GET' && path === '/analytics/records') { if (this.identity === 'administrative') { this.administrativeAnalyticsRecordsRejected += 1 return reject(route, 403, '行政身份仅可读取部门聚合,不可读取个人运行明细') } if (this.identity === 'unionAdministrativeStudent' && url.searchParams.get('userId') !== this.currentUserId()) { return reject(route, 403, '受限学员只能读取本人训练明细') } return fulfill(route, pageResult([{ 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, }]), 'ute2e-analytics-records') } 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') 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') const xrStartMatch = path.match(/^\/runs\/([^/]+)\/xr-sessions$/) if (method === 'POST' && xrStartMatch) { const item = this.runs.get(xrStartMatch[1]!) if (!item || body.runVersion !== item.version || body.mode !== 'INLINE') return reject(route, 400, 'XR 会话 DTO 不符合契约') 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') } const xrEndMatch = path.match(/^\/xr-sessions\/([^/]+)\/end$/) 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') return reject(route, 404, `未模拟教学端点 ${method} ${path}`) } private assetList() { return [{ sourceProjectId: 'asset-project-1', sourceVersionId: 'asset-version-1', assetCode: 'EXCAVATOR_A', name: 'excavator-a.glb', type: 'MODEL_FILE', contentUri: 'content://sha256/contract-excavator', mimeType: 'model/gltf-binary', sizeBytes: 11919032, sha256: 'contract-sha256', downloadable: true, downloadUrl: '/models/excavator-a.glb', }] } }