Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

725 строки
45 KiB

  1. import type { Locator, Page } from '@playwright/test'
  2. import { captureScreenshot, expect, test } from './fixtures'
  3. import { TeachingContractMock } from './teaching-contract-mock'
  4. const visibleDialog = (page: Page) => page.locator('.el-dialog:visible').last()
  5. const chooseSelectOption = async (page: Page, scope: Locator, label: string, option: string) => {
  6. const formSelect = scope.locator('.el-form-item').filter({ hasText: label }).first().locator('.el-select')
  7. const select = await formSelect.count()
  8. ? formSelect
  9. : scope.locator('.el-select').filter({ hasText: label }).first()
  10. await select.click()
  11. const dropdown = page.locator('.el-select-dropdown:visible').last()
  12. await expect(dropdown.getByText(option, { exact: true })).toBeVisible()
  13. await dropdown.getByText(option, { exact: true }).click()
  14. }
  15. const switchIdentity = async (page: Page, contract: TeachingContractMock, identity: Parameters<TeachingContractMock['setIdentity']>[0]) => {
  16. contract.setIdentity(identity)
  17. await page.reload()
  18. }
  19. test.describe('教学实施模块状态化 UI 契约', () => {
  20. test('六个侧栏入口可达,指导书和训练记录可在模块内完成完整往返', async ({ page }, testInfo) => {
  21. await page.setViewportSize({ width: 1920, height: 1080 })
  22. const contract = new TeachingContractMock('admin')
  23. contract.seedVirtualReviewedRun()
  24. await contract.install(page)
  25. const entries = [
  26. ['/teaching/virtual-training', 'teaching-task-center'],
  27. ['/teaching/physical-training', 'teaching-task-center'],
  28. ['/teaching/confrontation', 'teaching-task-center'],
  29. ['/teaching/immersive', 'immersive-viewer-panel'],
  30. ['/teaching/exams', 'teaching-task-center'],
  31. ['/teaching/data-center', 'analytics-workspace'],
  32. ] as const
  33. for (const [path, workspaceTestId] of entries) {
  34. await page.goto(path)
  35. await expect(page.getByTestId(workspaceTestId)).toBeVisible()
  36. await expect(page.locator('.ttc-hero, .teaching-v2-hero, .guide-standalone-hero')).toHaveCount(0)
  37. if (workspaceTestId === 'teaching-task-center') {
  38. const toolbar = page.locator('.ttc-task-workspace > .ttc-filter-row')
  39. const keyword = toolbar.locator('.ttc-keyword')
  40. const createButton = toolbar.getByRole('button', { name: /新建/ })
  41. await expect(toolbar.locator('.el-select').first()).toBeVisible()
  42. await expect(page.locator('.ttc-status-bar, .ttc-metrics')).toHaveCount(0)
  43. await expect(page.locator('.ttc-task-workspace > .ttc-inline-summary')).toBeVisible()
  44. await expect(createButton).toBeVisible()
  45. const [toolbarBox, keywordBox, createBox] = await Promise.all([toolbar.boundingBox(), keyword.boundingBox(), createButton.boundingBox()])
  46. for (const box of [toolbarBox, keywordBox, createBox]) expect(box).not.toBeNull()
  47. expect(keywordBox!.width).toBeGreaterThan(230)
  48. expect(keywordBox!.width).toBeLessThanOrEqual(270)
  49. expect(createBox!.x + createBox!.width).toBeGreaterThan(toolbarBox!.x + toolbarBox!.width - 40)
  50. if (path === '/teaching/virtual-training') {
  51. const grid = page.locator('.ttc-assignment-grid')
  52. const card = page.getByTestId('teaching-assignment-TASK-VIRTUAL-001')
  53. const assignmentRunRequests = () => contract.requests.filter((request) => request.method === 'GET'
  54. && request.path === '/runs'
  55. && request.query.get('assignmentId') === 'assignment-virtual-common').length
  56. const assignmentRunRequestsBefore = assignmentRunRequests()
  57. await expect(grid).toHaveClass(/ttc-assignment-grid--single/)
  58. await expect(card).toHaveClass(/virtual-prototype-card/)
  59. await expect(card).toContainText('固化定义版本 2.0')
  60. await expect(card.locator('.ttc-mode-summary')).toHaveText('演示 / 训练 / 模拟考试')
  61. await expect(card).toContainText('分配学员')
  62. expect(assignmentRunRequestsBefore).toBe(0)
  63. const detailsButton = card.getByRole('button', { name: /学员明细 1/ })
  64. await expect(detailsButton).toHaveAttribute('aria-expanded', 'false')
  65. await detailsButton.click()
  66. const details = card.locator('.ttc-assignment-runs')
  67. await expect(details).toBeVisible()
  68. await expect(details.locator('tbody tr')).toHaveCount(1)
  69. await expect(card.locator('.ttc-progress-block')).toContainText('100%')
  70. await expect.poll(assignmentRunRequests).toBeGreaterThan(assignmentRunRequestsBefore)
  71. await expect(detailsButton).toHaveAttribute('aria-expanded', 'true')
  72. await detailsButton.click()
  73. await expect(details).toHaveCount(0)
  74. }
  75. }
  76. await expect(page.locator('iframe')).toHaveCount(0)
  77. }
  78. await page.goto('/teaching/virtual-training')
  79. await page.getByTestId('teaching-tab-guides').click()
  80. await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=guides$/)
  81. await expect(page.getByRole('heading', { name: '虚拟仿真可用指导书', exact: true })).toBeVisible()
  82. await expect(page.getByTestId('teaching-guide-library').locator('.guide-card-v2')).toHaveCount(1)
  83. await page.getByRole('button', { name: /打开阅读/ }).first().click()
  84. const guideReader = page.getByTestId('teaching-guide-reader')
  85. await expect(guideReader).toBeVisible()
  86. await guideReader.getByRole('button', { name: /断电隔离/ }).click()
  87. await expect(guideReader).toContainText('安全准备')
  88. await expect(guideReader).toContainText('断电隔离')
  89. await guideReader.getByRole('button', { name: '下一页', exact: true }).click()
  90. await expect(guideReader.locator('.a4-paper')).toHaveAttribute('aria-label', /压力释放/)
  91. await expect(guideReader).toContainText('目标压力')
  92. await guideReader.getByRole('button', { name: '下一页', exact: true }).click()
  93. await expect(guideReader.locator('.a4-paper')).toHaveAttribute('aria-label', /复装与复测/)
  94. await expect(guideReader.getByRole('button', { name: '下一页', exact: true })).toBeDisabled()
  95. await captureScreenshot(page, testInfo, 'teaching-guide-full-reader')
  96. await page.getByRole('button', { name: /返回列表/ }).click()
  97. await expect(page.locator('.guide-list-card')).toBeVisible()
  98. await page.getByTestId('teaching-tab-tasks').click()
  99. await expect(page).toHaveURL(/\/teaching\/virtual-training$/)
  100. await expect(page.getByTestId('teaching-assignment-TASK-VIRTUAL-001')).toBeVisible()
  101. await page.getByTestId('teaching-tab-records').click()
  102. await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records$/)
  103. await expect(page.locator('.ttc-record-table')).toBeVisible()
  104. await expect(page.locator('.ttc-record-table tbody tr')).toHaveCount(1)
  105. await page.getByRole('button', { name: '查看详情', exact: true }).first().click()
  106. const recordDialog = visibleDialog(page)
  107. await expect(recordDialog).toContainText('详情页仅展示任务结果与步骤快照')
  108. await expect(recordDialog).toContainText('92 分')
  109. await expect(recordDialog).toContainText('2 / 2')
  110. await captureScreenshot(page, testInfo, 'teaching-training-record-detail')
  111. await recordDialog.getByRole('button', { name: /返回记录列表/ }).click()
  112. await expect(page.locator('.ttc-record-table')).toBeVisible()
  113. await page.getByRole('button', { name: '完整过程', exact: true }).first().click()
  114. await expect(page).toHaveURL(/\/teaching\/virtual-training\/runs\/run-virtual-reviewed\?returnView=records&observe=1$/)
  115. await expect(page.locator('.teaching-runtime-v2')).toBeVisible()
  116. await expect(page.getByText('当前为教员/管理员观察席', { exact: true })).toBeVisible()
  117. await expect(page.getByText('完成:识别作业对象', { exact: true })).toBeVisible()
  118. await expect(page.getByRole('button', { name: /完成|提交训练结果|开始训练/ })).toHaveCount(0)
  119. await captureScreenshot(page, testInfo, 'teaching-training-record-full-process')
  120. await page.locator('.runtime-back').click()
  121. await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records$/)
  122. await expect(page.locator('.ttc-record-table')).toBeVisible()
  123. await page.getByTestId('teaching-tab-tasks').click()
  124. await expect(page).toHaveURL(/\/teaching\/virtual-training$/)
  125. await expect(page.locator('iframe')).toHaveCount(0)
  126. await captureScreenshot(page, testInfo, 'teaching-module-guide-record-roundtrip')
  127. })
  128. test('虚拟任务从数字人目录绑定教员并按任务选择数字车间知识库', async ({ page }) => {
  129. const contract = new TeachingContractMock('admin')
  130. await contract.install(page)
  131. await page.goto('/teaching/virtual-training')
  132. await page.getByRole('button', { name: '新建虚拟仿真任务', exact: true }).click()
  133. const dialog = visibleDialog(page)
  134. await expect(dialog.getByTestId('digital-instructor-agent-select')).toContainText('装备维修虚拟教员')
  135. await expect(dialog.getByTestId('digital-instructor-knowledge-source')).toContainText('数字人内置知识库')
  136. await expect(dialog.getByTestId('digital-instructor-knowledge-source')).toContainText('数字车间知识库')
  137. await dialog.getByText('数字车间知识库', { exact: true }).click()
  138. await expect(dialog).toContainText('当前授权:数字车间维修知识')
  139. await dialog.getByRole('textbox', { name: /任务名称/ }).fill('虚拟教员双知识来源契约任务')
  140. await chooseSelectOption(page, dialog, '训练内容', '挖掘机液压系统训练 · V2')
  141. await chooseSelectOption(page, dialog, '参训学员', '红方队长 · 维修教研室')
  142. await page.keyboard.press('Escape')
  143. await dialog.getByRole('button', { name: '保存草稿', exact: true }).click()
  144. await expect(dialog).toBeHidden()
  145. expect(contract.last('GET', '/digital-instructors')).toBeTruthy()
  146. expect(contract.last('POST', '/assignments')?.body).toMatchObject({
  147. channel: 'VIRTUAL',
  148. digitalHumanAllowed: true,
  149. digitalInstructorAgentSlug: 'demo-equipment-instructor',
  150. digitalInstructorKnowledgeSource: 'WORKSHOP',
  151. })
  152. })
  153. test('对抗任务监控可切换运行、进入三维只读观察并按最终 API 契约注入故障', async ({ page }, testInfo) => {
  154. await page.setViewportSize({ width: 1920, height: 1080 })
  155. const contract = new TeachingContractMock('teacher')
  156. contract.seedConfrontationMonitorRuns()
  157. await contract.install(page)
  158. await page.goto('/teaching/confrontation')
  159. await page.getByTestId('teaching-monitor-TASK-CONFRONTATION-001').click()
  160. await expect(page).toHaveURL(/\/teaching\/confrontation\/tasks\/assignment-confrontation\/monitor$/)
  161. const monitor = page.getByTestId('confrontation-monitor')
  162. await expect(monitor).toBeVisible()
  163. await expect(monitor.locator('.monitor-run-list button')).toHaveCount(2)
  164. await expect(page.getByTestId('confrontation-monitor-scene').locator('canvas')).toBeVisible({ timeout: 30_000 })
  165. await expect(page.getByTestId('confrontation-monitor-run-run-confrontation')).toHaveClass(/active/)
  166. await expect(page.getByTestId('confrontation-monitor-run-run-confrontation')).toContainText('RED')
  167. await page.getByTestId('confrontation-monitor-run-run-confrontation-blue').click()
  168. await expect(page.getByTestId('confrontation-monitor-run-run-confrontation-blue')).toHaveClass(/active/)
  169. await expect(page.getByTestId('confrontation-monitor-run-run-confrontation-blue')).toContainText('BLUE')
  170. await expect(monitor.locator('.monitor-event-timeline')).toContainText('尚未产生过程事件')
  171. await captureScreenshot(page, testInfo, 'teaching-confrontation-monitor-switch')
  172. await page.getByTestId('confrontation-monitor-inject').click()
  173. const injectionDialog = visibleDialog(page)
  174. await expect(injectionDialog).toContainText('故障真值仅写入服务端私密载荷')
  175. const publicSummary = '训练设备出现动臂响应迟缓与压力波动,请按岗位流程排查。'
  176. await injectionDialog.locator('textarea').fill(publicSummary)
  177. await injectionDialog.getByRole('button', { name: '确认注入', exact: true }).click()
  178. await expect(injectionDialog).toBeHidden()
  179. await expect(monitor.locator('.monitor-interventions')).toContainText(publicSummary)
  180. const createIntervention = contract.last('POST', '/assignments/assignment-confrontation/interventions')
  181. expect(createIntervention?.body).toMatchObject({
  182. type: 'FAULT',
  183. targetScope: 'ASSIGNMENT',
  184. publicSummary,
  185. publicPayload: { assignmentFaultId: 'assignment-fault-public', faultCode: 'HYDRAULIC.VALVE.STUCK' },
  186. privatePayload: {
  187. assignmentFaultId: 'assignment-fault-public',
  188. faultCode: 'HYDRAULIC.VALVE.STUCK',
  189. privateTruth: { text: '液压阀芯卡滞真值' },
  190. },
  191. })
  192. expect(JSON.stringify(createIntervention?.body.publicPayload)).not.toContain('液压阀芯卡滞真值')
  193. expect(contract.count('POST', /\/interventions\/[^/]+\/activate$/)).toBe(1)
  194. await page.getByTestId('confrontation-monitor-run-run-confrontation').click()
  195. await page.getByTestId('confrontation-monitor-observe').click()
  196. await expect(page).toHaveURL(/\/teaching\/confrontation\/runs\/run-confrontation\?observe=1$/)
  197. const observerWorkbench = page.locator('.teaching-runtime-v2[data-channel="confrontation"]')
  198. await expect(observerWorkbench).toBeVisible()
  199. await expect(observerWorkbench.getByText('只读观察', { exact: true })).toBeVisible()
  200. await expect(observerWorkbench.getByText('当前为教员/管理员观察席', { exact: true })).toBeVisible()
  201. await expect(observerWorkbench.locator('.runtime-stage-card canvas')).toBeVisible({ timeout: 30_000 })
  202. await expect(observerWorkbench.getByRole('button', { name: /提交岗位动作|完成当前岗位动作|提交团队结果/ })).toHaveCount(0)
  203. await captureScreenshot(page, testInfo, 'teaching-confrontation-observer-runtime')
  204. })
  205. test('虚拟、实装、对抗三通道运行工作台结构完整,正式考核仅投影当前步骤并锁定辅助能力', async ({ page }, testInfo) => {
  206. await page.setViewportSize({ width: 1920, height: 1080 })
  207. const contract = new TeachingContractMock('studentLeader')
  208. contract.seedImmersiveRun()
  209. await contract.install(page)
  210. const workbenchCases = [
  211. {
  212. path: '/teaching/virtual-training/runs/run-immersive',
  213. channel: 'virtual',
  214. expected: ['虚拟仿真 · 训练工作台', '数字教员', '规则检测目标'],
  215. screenshot: 'teaching-runtime-virtual-workbench',
  216. },
  217. {
  218. path: '/teaching/physical-training/runs/run-physical',
  219. channel: 'physical',
  220. expected: ['实装实训 · 训练工作台', '现场事件接入', '当前事件通道'],
  221. screenshot: 'teaching-runtime-physical-workbench',
  222. },
  223. {
  224. path: '/teaching/confrontation/runs/run-confrontation',
  225. channel: 'confrontation',
  226. expected: ['对抗训练 · 训练工作台', '团队与岗位', '故障与异常'],
  227. screenshot: 'teaching-runtime-confrontation-workbench',
  228. },
  229. ] as const
  230. for (const item of workbenchCases) {
  231. await page.goto(item.path)
  232. const workbench = page.locator(`.teaching-runtime-v2[data-channel="${item.channel}"]`)
  233. await expect(workbench).toBeVisible()
  234. await expect(workbench.locator('.runtime-left-rail')).toBeVisible()
  235. await expect(workbench.locator('.runtime-stage-card canvas')).toBeVisible({ timeout: 30_000 })
  236. await expect(workbench.locator('.runtime-right-rail')).toBeVisible()
  237. await expect(workbench.locator('.runtime-operation-dock')).toBeVisible()
  238. for (const copy of item.expected) await expect(workbench.getByText(copy, { exact: true }).first()).toBeVisible()
  239. await expect(workbench.locator('iframe')).toHaveCount(0)
  240. await captureScreenshot(page, testInfo, item.screenshot)
  241. }
  242. await page.goto('/teaching/exams/runs/run-exam')
  243. const examWorkbench = page.locator('.teaching-runtime-v2[data-formal-exam="true"]')
  244. await expect(examWorkbench).toBeVisible()
  245. await expect(examWorkbench.getByText('正式考核保护已启用', { exact: true })).toBeVisible()
  246. await expect(examWorkbench.locator('.runtime-step-rail li')).toHaveCount(1)
  247. await expect(examWorkbench.locator('.runtime-step-rail>header')).toContainText('1 / 2')
  248. await expect(examWorkbench.getByRole('button', { name: /操作帮助|过程记录|上一步|回放|演示|提示/ })).toHaveCount(0)
  249. await expect(examWorkbench.locator('.digital-instructor-card')).toHaveCount(0)
  250. await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(0)
  251. await captureScreenshot(page, testInfo, 'teaching-runtime-formal-exam-lock')
  252. })
  253. test('沉浸交互的本地按键配置可保存、刷新恢复并一键回到默认值', async ({ page }, testInfo) => {
  254. await page.setViewportSize({ width: 1920, height: 1080 })
  255. const contract = new TeachingContractMock('studentLeader')
  256. contract.seedImmersiveRun()
  257. contract.keepOnlyRuns('run-immersive')
  258. await contract.install(page)
  259. await page.goto('/teaching/immersive')
  260. const mappingPanel = page.locator('.xr-v2-mapping-panel')
  261. await expect(mappingPanel).toBeVisible()
  262. const triggerMapping = mappingPanel.locator('label').filter({ hasText: '扳机键' })
  263. await triggerMapping.locator('.el-select').click()
  264. await page.locator('.el-select-dropdown:visible').last().getByText('确认当前操作', { exact: true }).click()
  265. await page.getByRole('button', { name: '保存本地配置', exact: true }).click()
  266. const saved = await page.evaluate(() => JSON.parse(window.localStorage.getItem('unreal-tran:teaching-xr-preferences:v1') || '{}') as { interactionMode?: string; mappings?: Record<string, string> })
  267. expect(saved).toMatchObject({ interactionMode: 'DESKTOP', mappings: { trigger: '确认当前操作' } })
  268. await expect(page.getByText('本地交互配置已保存', { exact: true }).first()).toBeVisible()
  269. await captureScreenshot(page, testInfo, 'teaching-immersive-local-config-saved')
  270. await page.reload()
  271. const restoredPanel = page.locator('.xr-v2-mapping-panel')
  272. const restoredTrigger = restoredPanel.locator('label').filter({ hasText: '扳机键' })
  273. await expect(restoredTrigger.locator('.el-select')).toContainText('确认当前操作')
  274. await restoredPanel.getByRole('button', { name: '恢复默认', exact: true }).click()
  275. await expect(restoredTrigger.locator('.el-select')).toContainText('抓取 / 释放零件')
  276. expect(await page.evaluate(() => window.localStorage.getItem('unreal-tran:teaching-xr-preferences:v1'))).toBeNull()
  277. await expect(page.getByTestId('immersive-event-log')).toContainText('本地交互配置已恢复默认')
  278. await captureScreenshot(page, testInfo, 'teaching-immersive-local-config-restored')
  279. })
  280. test('纯行政只读加载任务时不请求 runs,数据中心仅展示部门聚合', async ({ page }, testInfo) => {
  281. const contract = new TeachingContractMock('administrative')
  282. await contract.install(page)
  283. await page.goto('/teaching/virtual-training')
  284. await expect(page.getByText('虚拟维修训练', { exact: true })).toBeVisible()
  285. await expect(page.getByText('部门范围只读', { exact: true })).toBeVisible()
  286. await expect(page.getByRole('button', { name: /新建任务/ })).toHaveCount(0)
  287. expect(contract.count('GET', '/runs')).toBe(0)
  288. expect(contract.administrativeRunsRejected).toBe(0)
  289. await page.getByRole('button', { name: '任务详情', exact: true }).click()
  290. const drawer = page.locator('.el-drawer:visible')
  291. await expect(drawer).toContainText('虚拟维修训练')
  292. await expect(drawer.getByText('执行与评定记录', { exact: true })).toHaveCount(0)
  293. await expect(drawer.getByRole('button', { name: '查看运行' })).toHaveCount(0)
  294. expect(contract.count('GET', /\/runs$/)).toBe(0)
  295. await page.goto('/teaching/data-center')
  296. await expect(page.getByText('部门范围只读汇总', { exact: true })).toBeVisible()
  297. await expect(page.getByTestId('analytics-learner-table')).toHaveCount(0)
  298. expect(contract.count('GET', '/analytics/records')).toBe(0)
  299. expect(contract.administrativeAnalyticsRecordsRejected).toBe(0)
  300. await captureScreenshot(page, testInfo, 'teaching-administrative-readonly')
  301. })
  302. test('UNION 行政加学员可识别本人运行,并完成 COMMON 领取到教员评定闭环', async ({ page }, testInfo) => {
  303. const contract = new TeachingContractMock('unionAdministrativeStudent')
  304. const definition = contract.assignments.get('assignment-virtual-common')?.definitionSnapshot as { steps: Array<Record<string, unknown>> }
  305. expect(definition.steps.every((step) => typeof step.stepCode === 'string' && !('code' in step) && !('id' in step))).toBe(true)
  306. await contract.install(page)
  307. await page.goto('/teaching/virtual-training')
  308. await expect(page.getByText('虚拟维修训练', { exact: true })).toBeVisible()
  309. await expect(page.getByText('部门范围只读', { exact: true })).toHaveCount(0)
  310. await page.getByRole('button', { name: '领取任务', exact: true }).click()
  311. await expect(page).toHaveURL(/\/teaching\/virtual-training\/runs\/run-virtual-common$/)
  312. await expect(page.getByRole('button', { name: '开始训练', exact: true })).toBeVisible()
  313. const acceptRequest = contract.last('POST', '/assignments/assignment-virtual-common/accept')
  314. expect(acceptRequest?.body).toEqual({
  315. assignmentVersion: 3,
  316. teamCode: 'NEUTRAL',
  317. groupCode: 'INDIVIDUAL',
  318. positionCode: 'LEARNER',
  319. })
  320. await page.getByRole('button', { name: '开始训练', exact: true }).click()
  321. await page.getByRole('button', { name: '完成并进入下一步', exact: true }).click()
  322. await expect(page.getByRole('button', { name: '完成当前步骤', exact: true })).toBeVisible()
  323. await page.getByRole('button', { name: '完成当前步骤', exact: true }).click()
  324. await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible()
  325. const progressRequests = contract.requests.filter((item) => item.method === 'PUT' && item.path === '/runs/run-virtual-common/progress')
  326. expect(progressRequests).toHaveLength(2)
  327. expect(progressRequests.map((item) => item.body.completedStepCode)).toEqual(['STEP-01', 'STEP-02'])
  328. for (const request of progressRequests) {
  329. expect(request.body).not.toHaveProperty('percent')
  330. expect(request.body).not.toHaveProperty('currentStepCode')
  331. }
  332. await page.getByRole('button', { name: '提交训练结果', exact: true }).click()
  333. await visibleDialog(page).getByRole('button', { name: '确认提交', exact: true }).click()
  334. await expect(page.getByText('待评定', { exact: true }).first()).toBeVisible()
  335. expect(contract.runs.get('run-virtual-common')?.status).toBe('SUBMITTED')
  336. await page.goto('/teaching/data-center')
  337. await expect.poll(() => contract.count('GET', '/analytics/records')).toBeGreaterThan(0)
  338. expect(contract.last('GET', '/analytics/records')?.query.get('userId')).toBe('4')
  339. await expect(page.locator('.teaching-v2-filter-toolbar').getByText('全部部门', { exact: true })).toHaveCount(0)
  340. await expect(page.locator('.teaching-v2-filter-toolbar').getByText('全部学员', { exact: true })).toHaveCount(0)
  341. await switchIdentity(page, contract, 'teacher')
  342. await page.goto('/teaching/virtual-training')
  343. await page.getByRole('button', { name: /学员明细/ }).click()
  344. const details = page.locator('.ttc-assignment-runs')
  345. await expect(details.getByRole('button', { name: '评定', exact: true })).toBeVisible()
  346. await details.getByRole('button', { name: '评定', exact: true }).click()
  347. await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click()
  348. await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible()
  349. expect(contract.runs.get('run-virtual-common')?.status).toBe('REVIEWED')
  350. expect(contract.count('POST', '/runs/run-virtual-common/review')).toBe(1)
  351. await captureScreenshot(page, testInfo, 'teaching-common-stateful-review')
  352. })
  353. test('无评定权限不显示超时入口,教员可将超时运行结算后评定', async ({ page }, testInfo) => {
  354. const contract = new TeachingContractMock('administrative')
  355. contract.seedExpiredRun()
  356. await contract.install(page)
  357. await page.goto('/teaching/virtual-training')
  358. await page.getByRole('button', { name: '任务详情', exact: true }).click()
  359. await expect(page.locator('.el-drawer:visible').getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0)
  360. expect(contract.count('POST', '/runs/run-timeout/timeout')).toBe(0)
  361. await switchIdentity(page, contract, 'teacher')
  362. await page.getByRole('button', { name: /学员明细/ }).click()
  363. let details = page.locator('.ttc-assignment-runs')
  364. await expect(details.getByRole('button', { name: '超时结算', exact: true })).toBeVisible()
  365. await expect(details.getByRole('button', { name: '结束运行', exact: true })).toHaveCount(0)
  366. await details.getByRole('button', { name: '超时结算', exact: true }).click()
  367. const timeoutDialog = visibleDialog(page)
  368. await expect(timeoutDialog.getByRole('heading', { name: '确认超时结算', exact: true })).toBeVisible()
  369. await expect(timeoutDialog).toContainText('结算后该运行进入待评定状态')
  370. await timeoutDialog.getByRole('button', { name: '确认结算', exact: true }).click()
  371. await expect(page.getByText('运行已超时结算,可继续进行评定', { exact: true })).toBeVisible()
  372. await expect(timeoutDialog).toBeHidden()
  373. expect(contract.last('POST', '/runs/run-timeout/timeout')?.body).toEqual({ version: 2 })
  374. expect(contract.runs.get('run-timeout')?.status).toBe('SUBMITTED')
  375. await page.getByRole('button', { name: /学员明细/ }).click()
  376. details = page.locator('.ttc-assignment-runs')
  377. await expect(details.getByRole('button', { name: '评定', exact: true })).toBeVisible()
  378. await details.getByRole('button', { name: '评定', exact: true }).click()
  379. await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click()
  380. await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible()
  381. expect(contract.runs.get('run-timeout')?.status).toBe('REVIEWED')
  382. expect(contract.last('POST', '/runs/run-timeout/review')?.body).toMatchObject({ version: 3, score: 80, passed: true })
  383. await captureScreenshot(page, testInfo, 'teaching-timeout-settlement-review')
  384. })
  385. test('教员可填写原因中途结束未超时运行并继续评定', async ({ page }, testInfo) => {
  386. const contract = new TeachingContractMock('teacher')
  387. contract.seedTerminableRun()
  388. await contract.install(page)
  389. await page.goto('/teaching/virtual-training')
  390. await page.getByRole('button', { name: /学员明细/ }).click()
  391. let details = page.locator('.ttc-assignment-runs')
  392. await expect(details.getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0)
  393. await expect(details.getByRole('button', { name: '结束运行', exact: true })).toBeVisible()
  394. await details.getByRole('button', { name: '结束运行', exact: true }).click()
  395. const terminateDialog = visibleDialog(page)
  396. await expect(terminateDialog.getByRole('heading', { name: '结束运行', exact: true })).toBeVisible()
  397. await expect(terminateDialog).toContainText('人工中止')
  398. await terminateDialog.getByRole('button', { name: '确认结束', exact: true }).click()
  399. expect(contract.count('POST', '/runs/run-terminate/terminate')).toBe(0)
  400. await expect(terminateDialog).toBeVisible()
  401. const reason = '现场安全条件不满足,教员人工结束运行。'
  402. await terminateDialog.getByPlaceholder('请输入结束运行原因').fill(reason)
  403. await terminateDialog.getByRole('button', { name: '确认结束', exact: true }).click()
  404. await expect(page.getByText('运行已结束并进入待评定状态', { exact: true })).toBeVisible()
  405. await expect(terminateDialog).toBeHidden()
  406. expect(contract.last('POST', '/runs/run-terminate/terminate')?.body).toEqual({ version: 2, reason })
  407. expect(contract.runs.get('run-terminate')?.status).toBe('SUBMITTED')
  408. await page.getByRole('button', { name: /学员明细/ }).click()
  409. details = page.locator('.ttc-assignment-runs')
  410. await expect(details.getByRole('button', { name: '评定', exact: true })).toBeVisible()
  411. await details.getByRole('button', { name: '评定', exact: true }).click()
  412. await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click()
  413. await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible()
  414. expect(contract.runs.get('run-terminate')?.status).toBe('REVIEWED')
  415. expect(contract.last('POST', '/runs/run-terminate/review')?.body).toMatchObject({ version: 3, score: 80, passed: true })
  416. await captureScreenshot(page, testInfo, 'teaching-terminate-settlement-review')
  417. })
  418. test('对抗未开训运行可人工结束,已开训运行只能走回合超时结算', async ({ page }, testInfo) => {
  419. const contract = new TeachingContractMock('teacher')
  420. contract.seedConfrontationTerminationCases()
  421. await contract.install(page)
  422. await page.goto('/teaching/confrontation')
  423. await page.getByRole('button', { name: '查看', exact: true }).click()
  424. const drawer = page.locator('.el-drawer:visible')
  425. const activeRow = drawer.getByRole('row').filter({ hasText: '进行中' })
  426. await expect(activeRow.getByRole('button', { name: '超时结算', exact: true })).toBeVisible()
  427. await expect(activeRow.getByRole('button', { name: '结束运行', exact: true })).toHaveCount(0)
  428. const acceptedRow = drawer.getByRole('row').filter({ hasText: '待开始' })
  429. await expect(acceptedRow.getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0)
  430. await acceptedRow.getByRole('button', { name: '结束运行', exact: true }).click()
  431. const dialog = visibleDialog(page)
  432. const reason = '对抗编组条件未就绪,开训前结束本次运行。'
  433. await dialog.getByPlaceholder('请输入结束运行原因').fill(reason)
  434. await dialog.getByRole('button', { name: '确认结束', exact: true }).click()
  435. await expect(page.getByText('运行已结束并进入待评定状态', { exact: true })).toBeVisible()
  436. await expect(dialog).toBeHidden()
  437. await expect(drawer.locator('.el-loading-mask:visible')).toHaveCount(0)
  438. expect(contract.last('POST', '/runs/run-confrontation-queued/terminate')?.body).toEqual({ version: 2, reason })
  439. expect(contract.runs.get('run-confrontation-queued')?.status).toBe('SUBMITTED')
  440. expect(contract.count('POST', '/runs/run-confrontation/terminate')).toBe(0)
  441. const submittedRow = drawer.getByRole('row').filter({ hasText: '待评定' })
  442. await submittedRow.getByRole('button', { name: '评定', exact: true }).click()
  443. await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click()
  444. await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible()
  445. expect(contract.runs.get('run-confrontation-queued')?.status).toBe('REVIEWED')
  446. await captureScreenshot(page, testInfo, 'teaching-confrontation-prestart-terminate')
  447. })
  448. test('实装训练两步只走 physical-events,服务端响应驱动下一待办步骤', async ({ page }, testInfo) => {
  449. const contract = new TeachingContractMock('studentLeader')
  450. await contract.install(page)
  451. await page.goto('/teaching/physical-training/runs/run-physical')
  452. await expect(page.getByText('现场事件接入', { exact: true })).toBeVisible()
  453. await page.getByRole('button', { name: '开始训练', exact: true }).click()
  454. const confirm = page.getByRole('button', { name: '确认已完成当前实装步骤', exact: true })
  455. await expect(confirm).toBeVisible()
  456. await confirm.click()
  457. await expect(page.getByText('确认液压卸压', { exact: true })).toBeVisible()
  458. await confirm.click()
  459. await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible()
  460. const requests = contract.requests.filter((item) => item.method === 'POST' && item.path === '/runs/run-physical/physical-events')
  461. expect(requests).toHaveLength(2)
  462. expect(requests.map((item) => item.body.stepCode)).toEqual(['STEP-01', 'STEP-02'])
  463. expect(requests.map((item) => item.body.eventType)).toEqual(['MANUAL_CONFIRM_POWER_OFF', 'MANUAL_CONFIRM_PRESSURE_RELEASED'])
  464. expect(requests.every((item) => item.body.source === 'MANUAL' && item.body.manualConfirmed === true)).toBe(true)
  465. expect(requests.every((item) => Number(item.body.occurredAt) < 1_000_000_000_000)).toBe(true)
  466. expect(contract.count('PUT', '/runs/run-physical/progress')).toBe(0)
  467. expect(contract.runs.get('run-physical')?.progressPercent).toBe(100)
  468. await captureScreenshot(page, testInfo, 'teaching-physical-two-step-events')
  469. })
  470. test('指定任务预创建的待接收运行可完成接收并进入任务', async ({ page }) => {
  471. const contract = new TeachingContractMock('studentLeader')
  472. const pendingRun = contract.runs.get('run-physical')
  473. if (!pendingRun) throw new Error('缺少指定任务运行夹具')
  474. pendingRun.status = 'PENDING'
  475. pendingRun.acceptedAt = null
  476. await contract.install(page)
  477. await page.goto('/teaching/physical-training')
  478. const card = page.locator('.ttc-assignment-card').filter({ hasText: '实装维修训练' })
  479. await expect(card.getByRole('button', { name: '接收任务', exact: true })).toBeVisible()
  480. await card.getByRole('button', { name: '接收任务', exact: true }).click()
  481. await expect(page).toHaveURL(/\/teaching\/physical-training\/runs\/run-physical$/)
  482. expect(contract.count('POST', '/assignments/assignment-physical/accept')).toBe(1)
  483. expect(contract.runs.get('run-physical')?.status).toBe('ACCEPTED')
  484. })
  485. test('对抗共享运行按岗位推进、负责人提交,并仅展示故障公开投影与 TEAM 编组干预', async ({ page }, testInfo) => {
  486. const contract = new TeachingContractMock('studentOperator')
  487. await contract.install(page)
  488. const faultResponsePromise = page.waitForResponse((response) => response.url().includes('/assignments/assignment-confrontation/faults'))
  489. await page.goto('/teaching/confrontation/runs/run-confrontation')
  490. const faultResponse = await faultResponsePromise
  491. const faultPayload = await faultResponse.json() as { data: unknown }
  492. expect(JSON.stringify(faultPayload.data)).not.toContain('液压阀芯卡滞真值')
  493. expect(JSON.stringify(faultPayload.data)).not.toContain('内部触发条件')
  494. await expect(page.getByText('动臂响应迟缓且压力波动', { exact: true })).toBeVisible()
  495. await expect(page.getByText('液压阀芯卡滞真值')).toHaveCount(0)
  496. await expect(page.getByText('红蓝双方按岗位协同完成液压故障隔离', { exact: true })).toBeVisible()
  497. await expect(page.getByText('在安全约束下按完成度与用时综合判定', { exact: true })).toBeVisible()
  498. await expect(page.getByText(/红方维修组 · OPERATOR/)).toBeVisible()
  499. await expect(page.getByText(/此动作需由 TEAM_LEADER/)).toBeVisible()
  500. await expect(page.getByRole('button', { name: '提交岗位动作', exact: true })).toBeDisabled()
  501. await switchIdentity(page, contract, 'studentLeader')
  502. await page.getByRole('button', { name: '提交岗位动作', exact: true }).click()
  503. expect(contract.last('PUT', '/runs/run-confrontation/progress')?.body).toMatchObject({ completedStepCode: 'STEP-01', actionCode: 'ISSUE_COMMAND' })
  504. await switchIdentity(page, contract, 'studentOperator')
  505. await expect(page.getByText(/当前动作由岗位“OPERATOR”执行/)).toBeVisible()
  506. await page.getByRole('button', { name: '完成当前岗位动作', exact: true }).click()
  507. await expect(page.getByText('等待组长/指挥员提交', { exact: true })).toBeVisible()
  508. expect(contract.last('PUT', '/runs/run-confrontation/progress')?.body).toMatchObject({ completedStepCode: 'STEP-02', actionCode: 'ISOLATE_FAULT' })
  509. await switchIdentity(page, contract, 'studentLeader')
  510. await page.getByRole('button', { name: '提交团队结果', exact: true }).click()
  511. await visibleDialog(page).getByRole('button', { name: '确认提交', exact: true }).click()
  512. expect(contract.runs.get('run-confrontation')?.status).toBe('SUBMITTED')
  513. await switchIdentity(page, contract, 'teacher')
  514. await page.getByRole('button', { name: '新建干预', exact: true }).click()
  515. const dialog = visibleDialog(page)
  516. await chooseSelectOption(page, dialog, '作用层级', '指定队伍')
  517. await chooseSelectOption(page, dialog, '目标队伍', '红方')
  518. await dialog.locator('input[placeholder="可选:进一步限定编组"]').fill('GROUP-RED')
  519. await dialog.locator('textarea[placeholder="请输入不泄露故障答案的公开说明"]').fill('红方液压压力波动,请按岗位流程处置。')
  520. await dialog.getByRole('button', { name: '保存干预', exact: true }).click()
  521. await expect(page.getByText('红方液压压力波动,请按岗位流程处置。', { exact: true })).toBeVisible()
  522. await page.getByRole('button', { name: '激活', exact: true }).click()
  523. const intervention = contract.last('POST', '/assignments/assignment-confrontation/interventions')
  524. expect(intervention?.body).toMatchObject({ targetScope: 'TEAM', targetTeamCode: 'RED', targetGroupCode: 'GROUP-RED' })
  525. expect(intervention?.body).not.toHaveProperty('privatePayload')
  526. expect(contract.interventions[0]?.status).toBe('ACTIVE')
  527. await captureScreenshot(page, testInfo, 'teaching-confrontation-role-and-intervention')
  528. })
  529. test('正式考核提交 Unix 秒并锁定所有辅助能力', async ({ page }, testInfo) => {
  530. const contract = new TeachingContractMock('teacher')
  531. await contract.install(page)
  532. await page.goto('/teaching/exams')
  533. await page.getByRole('button', { name: '新建正式考核', exact: true }).click()
  534. const dialog = visibleDialog(page)
  535. await dialog.getByRole('textbox', { name: /任务名称/ }).fill('状态化契约正式考核')
  536. await chooseSelectOption(page, dialog, '训练内容', '挖掘机液压系统训练 · V2')
  537. await dialog.getByRole('combobox', { name: '开始时间' }).fill('2026-08-11 09:00:00')
  538. await dialog.getByRole('combobox', { name: '截止时间' }).fill('2026-08-11 11:00:00')
  539. await chooseSelectOption(page, dialog, '参训学员', '红方队长 · 维修教研室')
  540. await page.keyboard.press('Escape')
  541. await dialog.getByRole('button', { name: '保存草稿', exact: true }).click()
  542. await expect(dialog).toBeHidden()
  543. const createRequest = contract.last('POST', '/assignments')
  544. expect(createRequest?.body).toMatchObject({ assignmentKind: 'FORMAL_EXAM', executionMode: 'EXAM', digitalHumanAllowed: false })
  545. expect(typeof createRequest?.body.scheduleStartAt).toBe('number')
  546. expect(typeof createRequest?.body.dueAt).toBe('number')
  547. expect(Number(createRequest?.body.scheduleStartAt)).toBeLessThan(1_000_000_000_000)
  548. expect(Number(createRequest?.body.dueAt)).toBeGreaterThan(Number(createRequest?.body.scheduleStartAt))
  549. contract.keepOnlyRuns('run-exam')
  550. await switchIdentity(page, contract, 'studentLeader')
  551. await page.goto('/teaching/exams/runs/run-exam')
  552. await expect(page.getByText('正式考核保护已启用', { exact: true })).toBeVisible()
  553. await expect(page.getByText(/关闭演示、步骤提示、回退、答案和数字教员/)).toBeVisible()
  554. await expect(page.getByRole('button', { name: /演示|提示|回退|回放|上一步/ })).toHaveCount(0)
  555. await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(0)
  556. await page.goto('/teaching/immersive')
  557. await expect(page.getByText('正式考核禁止数字人、提示和演示。', { exact: true })).toBeVisible()
  558. await expect(page.getByText('本地数字人服务', { exact: true })).toHaveCount(0)
  559. await expect.poll(() => contract.count('GET', '/runs/run-exam/assets')).toBeGreaterThan(0)
  560. await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(1)
  561. await captureScreenshot(page, testInfo, 'teaching-formal-exam-seconds-and-lock')
  562. })
  563. test('沉浸执行权限缺失时启动检查与桌面 WebXR 入口前置只读', async ({ page }) => {
  564. const contract = new TeachingContractMock('studentLeader')
  565. contract.seedImmersiveRun()
  566. contract.keepOnlyRuns('run-immersive')
  567. contract.denyPermissions('teaching.immersive.execute')
  568. await contract.install(page)
  569. await page.goto('/teaching/immersive')
  570. const preflight = page.getByTestId('immersive-preflight')
  571. await expect(preflight).toContainText('当前身份仅可查看,无沉浸训练执行权限')
  572. await expect(preflight).toContainText('不会创建服务端会话,也不会申请浏览器 WebXR 会话')
  573. const desktopStarts = page.getByRole('button', { name: /只读查看,无法启动/ })
  574. await expect(desktopStarts).toHaveCount(1)
  575. for (const button of await desktopStarts.all()) await expect(button).toBeDisabled()
  576. await expect(page.getByRole('button', { name: 'VR 沉浸模式' })).toBeDisabled()
  577. expect(contract.count('POST', /\/runs\/[^/]+\/xr-sessions$/)).toBe(0)
  578. })
  579. test('运行场景与 WebXR 消费 definitionSnapshot 和教学资产,并完成桌面会话登记与结束', async ({ page }, testInfo) => {
  580. const contract = new TeachingContractMock('studentLeader')
  581. contract.seedImmersiveRun()
  582. contract.keepOnlyRuns('run-immersive')
  583. await contract.install(page)
  584. let modelRequests = 0
  585. page.on('request', (request) => { if (new URL(request.url()).pathname === '/models/excavator-a.glb') modelRequests += 1 })
  586. await page.goto('/teaching/immersive')
  587. await expect(page.getByTestId('immersive-viewer-panel')).toBeVisible()
  588. await expect(page.locator('.teaching-v2-hero')).toHaveCount(0)
  589. await expect.poll(() => contract.count('GET', '/runs/run-immersive/assets')).toBeGreaterThan(0)
  590. await expect(page.locator('.immersive-scene-status')).toContainText('已加载“虚拟维修训练”发布场景', { timeout: 30_000 })
  591. await expect.poll(() => modelRequests).toBeGreaterThan(0)
  592. await expect(page.locator('.immersive-viewer canvas')).toBeVisible()
  593. await expect(page.locator('iframe')).toHaveCount(0)
  594. await expect(page.getByTestId('immersive-device-panel')).toBeVisible()
  595. await expect(page.getByTestId('immersive-calibration')).toBeVisible()
  596. await expect(page.getByTestId('immersive-preflight')).toBeVisible()
  597. await page.getByRole('button', { name: '启动桌面会话', exact: true }).click()
  598. await expect(page.getByRole('button', { name: '结束会话', exact: true })).toBeVisible()
  599. expect(contract.last('POST', '/runs/run-immersive/xr-sessions')?.body).toMatchObject({ mode: 'INLINE', referenceSpaceType: 'LOCAL' })
  600. await page.getByRole('button', { name: '结束会话', exact: true }).click()
  601. await expect(page.getByRole('button', { name: '结束会话', exact: true })).toHaveCount(0)
  602. expect(contract.count('PUT', '/xr-sessions/xr-session-1/end')).toBe(1)
  603. await captureScreenshot(page, testInfo, 'teaching-real-assets-and-xr-session')
  604. })
  605. test('数据中心查询分页安全明细,并以 Unix 秒提交时间筛选', async ({ page }, testInfo) => {
  606. const contract = new TeachingContractMock('teacher')
  607. await contract.install(page)
  608. await page.goto('/teaching/data-center')
  609. await expect(page.getByTestId('analytics-channel-summary')).toBeVisible()
  610. await page.getByTestId('analytics-tab-exam').click()
  611. await expect(page.getByText('虚拟维修正式考核', { exact: true })).toBeVisible()
  612. await page.locator('input[placeholder="搜索学员、编号或任务名称"]').fill('红方队长')
  613. await chooseSelectOption(page, page.locator('.teaching-v2-filter-toolbar'), '全部渠道', '虚拟仿真')
  614. await chooseSelectOption(page, page.locator('.teaching-v2-filter-toolbar'), '全部状态', '已评定')
  615. const toolbar = page.locator('.teaching-v2-filter-toolbar')
  616. const startAtInput = toolbar.getByRole('combobox', { name: '开始时间' })
  617. const endAtInput = toolbar.getByRole('combobox', { name: '结束时间' })
  618. await startAtInput.fill('2026-08-01 00:00:00')
  619. await startAtInput.press('Enter')
  620. await endAtInput.fill('2026-08-12 00:00:00')
  621. await endAtInput.press('Enter')
  622. await page.keyboard.press('Escape')
  623. await page.getByRole('button', { name: '搜索', exact: true }).click()
  624. await expect.poll(() => contract.count('GET', '/analytics/records')).toBeGreaterThan(1)
  625. const query = contract.last('GET', '/analytics/records')?.query
  626. expect(query?.get('keyword')).toBe('红方队长')
  627. expect(query?.get('channel')).toBe('VIRTUAL')
  628. // 页签在已授权的安全明细内做客户端分组;服务端组合筛选不再重复提交类型字段。
  629. expect(query?.get('assignmentKind')).toBeNull()
  630. expect(query?.get('status')).toBe('REVIEWED')
  631. expect(Number(query?.get('startAt'))).toBeLessThan(1_000_000_000_000)
  632. expect(Number(query?.get('endAt'))).toBeGreaterThan(Number(query?.get('startAt')))
  633. await page.getByRole('button', { name: '详情', exact: true }).click()
  634. const drawer = page.locator('.el-drawer:visible')
  635. await expect(drawer).toContainText('教学记录详情')
  636. await expect(drawer).toContainText('此处只展示服务端授权的安全明细')
  637. await expect(drawer).not.toContainText('checkpoint')
  638. await expect(drawer).not.toContainText('privatePayload')
  639. await captureScreenshot(page, testInfo, 'teaching-analytics-safe-records')
  640. })
  641. })