import type { Locator, Page } from '@playwright/test' import { captureScreenshot, expect, test } from './fixtures' import { TeachingContractMock } from './teaching-contract-mock' const visibleDialog = (page: Page) => page.locator('.el-dialog:visible').last() const chooseSelectOption = async (page: Page, scope: Locator, label: string, option: string) => { const formSelect = scope.locator('.el-form-item').filter({ hasText: label }).first().locator('.el-select') const select = await formSelect.count() ? formSelect : scope.locator('.el-select').filter({ hasText: label }).first() await select.click() const dropdown = page.locator('.el-select-dropdown:visible').last() await expect(dropdown.getByText(option, { exact: true })).toBeVisible() await dropdown.getByText(option, { exact: true }).click() } const switchIdentity = async (page: Page, contract: TeachingContractMock, identity: Parameters[0]) => { contract.setIdentity(identity) await page.reload() } test.describe('教学实施模块状态化 UI 契约', () => { test('六个侧栏入口可达,指导书和训练记录可在模块内完成完整往返', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1920, height: 1080 }) const contract = new TeachingContractMock('admin') contract.seedVirtualReviewedRun() await contract.install(page) const entries = [ ['/teaching/virtual-training', 'teaching-task-center'], ['/teaching/physical-training', 'teaching-task-center'], ['/teaching/confrontation', 'teaching-task-center'], ['/teaching/immersive', 'immersive-viewer-panel'], ['/teaching/exams', 'teaching-task-center'], ['/teaching/data-center', 'analytics-workspace'], ] as const for (const [path, workspaceTestId] of entries) { await page.goto(path) await expect(page.getByTestId(workspaceTestId)).toBeVisible() await expect(page.locator('.ttc-hero, .teaching-v2-hero, .guide-standalone-hero')).toHaveCount(0) if (workspaceTestId === 'teaching-task-center') { const toolbar = page.locator('.ttc-task-workspace > .ttc-filter-row') const keyword = toolbar.locator('.ttc-keyword') const createButton = toolbar.getByRole('button', { name: /新建/ }) await expect(toolbar.locator('.el-select').first()).toBeVisible() await expect(page.locator('.ttc-status-bar, .ttc-metrics')).toHaveCount(0) await expect(page.locator('.ttc-task-workspace > .ttc-inline-summary')).toBeVisible() await expect(createButton).toBeVisible() const [toolbarBox, keywordBox, createBox] = await Promise.all([toolbar.boundingBox(), keyword.boundingBox(), createButton.boundingBox()]) for (const box of [toolbarBox, keywordBox, createBox]) expect(box).not.toBeNull() expect(keywordBox!.width).toBeGreaterThan(230) expect(keywordBox!.width).toBeLessThanOrEqual(270) expect(createBox!.x + createBox!.width).toBeGreaterThan(toolbarBox!.x + toolbarBox!.width - 40) if (path === '/teaching/virtual-training') { const grid = page.locator('.ttc-assignment-grid') const card = page.getByTestId('teaching-assignment-TASK-VIRTUAL-001') const assignmentRunRequests = () => contract.requests.filter((request) => request.method === 'GET' && request.path === '/runs' && request.query.get('assignmentId') === 'assignment-virtual-common').length const assignmentRunRequestsBefore = assignmentRunRequests() await expect(grid).toHaveClass(/ttc-assignment-grid--single/) await expect(card).toHaveClass(/virtual-prototype-card/) await expect(card).toContainText('固化定义版本 2.0') await expect(card.locator('.ttc-mode-summary')).toHaveText('演示 / 训练 / 模拟考试') await expect(card).toContainText('分配学员') expect(assignmentRunRequestsBefore).toBe(0) const detailsButton = card.getByRole('button', { name: /学员明细 1/ }) await expect(detailsButton).toHaveAttribute('aria-expanded', 'false') await detailsButton.click() const details = card.locator('.ttc-assignment-runs') await expect(details).toBeVisible() await expect(details.locator('tbody tr')).toHaveCount(1) await expect(card.locator('.ttc-progress-block')).toContainText('100%') await expect.poll(assignmentRunRequests).toBeGreaterThan(assignmentRunRequestsBefore) await expect(detailsButton).toHaveAttribute('aria-expanded', 'true') await detailsButton.click() await expect(details).toHaveCount(0) } } await expect(page.locator('iframe')).toHaveCount(0) } await page.goto('/teaching/virtual-training') await page.getByTestId('teaching-tab-guides').click() await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=guides$/) await expect(page.getByRole('heading', { name: '虚拟仿真可用指导书', exact: true })).toBeVisible() await expect(page.getByTestId('teaching-guide-library').locator('.guide-card-v2')).toHaveCount(1) await page.getByRole('button', { name: /打开阅读/ }).first().click() const guideReader = page.locator('.guide-reader-v2') await expect(guideReader).toBeVisible() await expect(guideReader).toContainText('01 安全准备') await expect(guideReader).toContainText('断电隔离') await guideReader.getByRole('button', { name: '下一步', exact: true }).click() await expect(guideReader.getByRole('heading', { name: '压力释放', exact: true })).toBeVisible() await expect(guideReader).toContainText('目标压力') await guideReader.getByRole('button', { name: '下一步', exact: true }).click() await expect(guideReader.getByRole('heading', { name: '复装与复测', exact: true })).toBeVisible() await expect(guideReader.getByRole('button', { name: '下一步', exact: true })).toBeDisabled() await captureScreenshot(page, testInfo, 'teaching-guide-full-reader') await page.getByRole('button', { name: /返回指导书列表/ }).click() await expect(page.locator('.guide-list-card')).toBeVisible() await page.getByTestId('teaching-tab-tasks').click() await expect(page).toHaveURL(/\/teaching\/virtual-training$/) await expect(page.getByTestId('teaching-assignment-TASK-VIRTUAL-001')).toBeVisible() await page.getByTestId('teaching-tab-records').click() await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records$/) await expect(page.locator('.ttc-record-table')).toBeVisible() await expect(page.locator('.ttc-record-table tbody tr')).toHaveCount(1) await page.getByRole('button', { name: '查看详情', exact: true }).first().click() const recordDialog = visibleDialog(page) await expect(recordDialog).toContainText('详情页仅展示任务结果与步骤快照') await expect(recordDialog).toContainText('92 分') await expect(recordDialog).toContainText('2 / 2') await captureScreenshot(page, testInfo, 'teaching-training-record-detail') await recordDialog.getByRole('button', { name: /返回记录列表/ }).click() await expect(page.locator('.ttc-record-table')).toBeVisible() await page.getByRole('button', { name: '完整过程', exact: true }).first().click() await expect(page).toHaveURL(/\/teaching\/virtual-training\/runs\/run-virtual-reviewed\?returnView=records&observe=1$/) await expect(page.locator('.teaching-runtime-v2')).toBeVisible() await expect(page.getByText('当前为教员/管理员观察席', { exact: true })).toBeVisible() await expect(page.getByText('完成:识别作业对象', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: /完成|提交训练结果|开始训练/ })).toHaveCount(0) await captureScreenshot(page, testInfo, 'teaching-training-record-full-process') await page.locator('.runtime-back').click() await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records$/) await expect(page.locator('.ttc-record-table')).toBeVisible() await page.getByTestId('teaching-tab-tasks').click() await expect(page).toHaveURL(/\/teaching\/virtual-training$/) await expect(page.locator('iframe')).toHaveCount(0) await captureScreenshot(page, testInfo, 'teaching-module-guide-record-roundtrip') }) test('对抗任务监控可切换运行、进入三维只读观察并按最终 API 契约注入故障', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1920, height: 1080 }) const contract = new TeachingContractMock('teacher') contract.seedConfrontationMonitorRuns() await contract.install(page) await page.goto('/teaching/confrontation') await page.getByTestId('teaching-monitor-TASK-CONFRONTATION-001').click() await expect(page).toHaveURL(/\/teaching\/confrontation\/tasks\/assignment-confrontation\/monitor$/) const monitor = page.getByTestId('confrontation-monitor') await expect(monitor).toBeVisible() await expect(monitor.locator('.monitor-run-list button')).toHaveCount(2) await expect(page.getByTestId('confrontation-monitor-scene').locator('canvas')).toBeVisible({ timeout: 30_000 }) await expect(page.getByTestId('confrontation-monitor-run-run-confrontation')).toHaveClass(/active/) await expect(page.getByTestId('confrontation-monitor-run-run-confrontation')).toContainText('RED') await page.getByTestId('confrontation-monitor-run-run-confrontation-blue').click() await expect(page.getByTestId('confrontation-monitor-run-run-confrontation-blue')).toHaveClass(/active/) await expect(page.getByTestId('confrontation-monitor-run-run-confrontation-blue')).toContainText('BLUE') await expect(monitor.locator('.monitor-event-timeline')).toContainText('尚未产生过程事件') await captureScreenshot(page, testInfo, 'teaching-confrontation-monitor-switch') await page.getByTestId('confrontation-monitor-inject').click() const injectionDialog = visibleDialog(page) await expect(injectionDialog).toContainText('故障真值仅写入服务端私密载荷') const publicSummary = '训练设备出现动臂响应迟缓与压力波动,请按岗位流程排查。' await injectionDialog.locator('textarea').fill(publicSummary) await injectionDialog.getByRole('button', { name: '确认注入', exact: true }).click() await expect(injectionDialog).toBeHidden() await expect(monitor.locator('.monitor-interventions')).toContainText(publicSummary) const createIntervention = contract.last('POST', '/assignments/assignment-confrontation/interventions') expect(createIntervention?.body).toMatchObject({ type: 'FAULT', targetScope: 'ASSIGNMENT', publicSummary, publicPayload: { assignmentFaultId: 'assignment-fault-public', faultCode: 'HYDRAULIC.VALVE.STUCK' }, privatePayload: { assignmentFaultId: 'assignment-fault-public', faultCode: 'HYDRAULIC.VALVE.STUCK', privateTruth: { text: '液压阀芯卡滞真值' }, }, }) expect(JSON.stringify(createIntervention?.body.publicPayload)).not.toContain('液压阀芯卡滞真值') expect(contract.count('POST', /\/interventions\/[^/]+\/activate$/)).toBe(1) await page.getByTestId('confrontation-monitor-run-run-confrontation').click() await page.getByTestId('confrontation-monitor-observe').click() await expect(page).toHaveURL(/\/teaching\/confrontation\/runs\/run-confrontation\?observe=1$/) const observerWorkbench = page.locator('.teaching-runtime-v2[data-channel="confrontation"]') await expect(observerWorkbench).toBeVisible() await expect(observerWorkbench.getByText('只读观察', { exact: true })).toBeVisible() await expect(observerWorkbench.getByText('当前为教员/管理员观察席', { exact: true })).toBeVisible() await expect(observerWorkbench.locator('.runtime-stage-card canvas')).toBeVisible({ timeout: 30_000 }) await expect(observerWorkbench.getByRole('button', { name: /提交岗位动作|完成当前岗位动作|提交团队结果/ })).toHaveCount(0) await captureScreenshot(page, testInfo, 'teaching-confrontation-observer-runtime') }) test('虚拟、实装、对抗三通道运行工作台结构完整,正式考核仅投影当前步骤并锁定辅助能力', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1920, height: 1080 }) const contract = new TeachingContractMock('studentLeader') contract.seedImmersiveRun() await contract.install(page) const workbenchCases = [ { path: '/teaching/virtual-training/runs/run-immersive', channel: 'virtual', expected: ['虚拟仿真 · 训练工作台', '数字教员', '规则检测目标'], screenshot: 'teaching-runtime-virtual-workbench', }, { path: '/teaching/physical-training/runs/run-physical', channel: 'physical', expected: ['实装实训 · 训练工作台', '现场事件接入', '当前事件通道'], screenshot: 'teaching-runtime-physical-workbench', }, { path: '/teaching/confrontation/runs/run-confrontation', channel: 'confrontation', expected: ['对抗训练 · 训练工作台', '团队与岗位', '故障与异常'], screenshot: 'teaching-runtime-confrontation-workbench', }, ] as const for (const item of workbenchCases) { await page.goto(item.path) const workbench = page.locator(`.teaching-runtime-v2[data-channel="${item.channel}"]`) await expect(workbench).toBeVisible() await expect(workbench.locator('.runtime-left-rail')).toBeVisible() await expect(workbench.locator('.runtime-stage-card canvas')).toBeVisible({ timeout: 30_000 }) await expect(workbench.locator('.runtime-right-rail')).toBeVisible() await expect(workbench.locator('.runtime-operation-dock')).toBeVisible() for (const copy of item.expected) await expect(workbench.getByText(copy, { exact: true }).first()).toBeVisible() await expect(workbench.locator('iframe')).toHaveCount(0) await captureScreenshot(page, testInfo, item.screenshot) } await page.goto('/teaching/exams/runs/run-exam') const examWorkbench = page.locator('.teaching-runtime-v2[data-formal-exam="true"]') await expect(examWorkbench).toBeVisible() await expect(examWorkbench.getByText('正式考核保护已启用', { exact: true })).toBeVisible() await expect(examWorkbench.locator('.runtime-step-rail li')).toHaveCount(1) await expect(examWorkbench.locator('.runtime-step-rail>header')).toContainText('1 / 2') await expect(examWorkbench.getByRole('button', { name: /操作帮助|过程记录|上一步|回放|演示|提示/ })).toHaveCount(0) await expect(examWorkbench.locator('.digital-instructor-card')).toHaveCount(0) await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(0) await captureScreenshot(page, testInfo, 'teaching-runtime-formal-exam-lock') }) test('沉浸交互的本地按键配置可保存、刷新恢复并一键回到默认值', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1920, height: 1080 }) const contract = new TeachingContractMock('studentLeader') contract.seedImmersiveRun() contract.keepOnlyRuns('run-immersive') await contract.install(page) await page.goto('/teaching/immersive') const mappingPanel = page.locator('.xr-v2-mapping-panel') await expect(mappingPanel).toBeVisible() const triggerMapping = mappingPanel.locator('label').filter({ hasText: '扳机键' }) await triggerMapping.locator('.el-select').click() await page.locator('.el-select-dropdown:visible').last().getByText('确认当前操作', { exact: true }).click() await page.getByRole('button', { name: '保存本地配置', exact: true }).click() const saved = await page.evaluate(() => JSON.parse(window.localStorage.getItem('unreal-tran:teaching-xr-preferences:v1') || '{}') as { interactionMode?: string; mappings?: Record }) expect(saved).toMatchObject({ interactionMode: 'DESKTOP', mappings: { trigger: '确认当前操作' } }) await expect(page.getByText('本地交互配置已保存', { exact: true }).first()).toBeVisible() await captureScreenshot(page, testInfo, 'teaching-immersive-local-config-saved') await page.reload() const restoredPanel = page.locator('.xr-v2-mapping-panel') const restoredTrigger = restoredPanel.locator('label').filter({ hasText: '扳机键' }) await expect(restoredTrigger.locator('.el-select')).toContainText('确认当前操作') await restoredPanel.getByRole('button', { name: '恢复默认', exact: true }).click() await expect(restoredTrigger.locator('.el-select')).toContainText('抓取 / 释放零件') expect(await page.evaluate(() => window.localStorage.getItem('unreal-tran:teaching-xr-preferences:v1'))).toBeNull() await expect(page.getByTestId('immersive-event-log')).toContainText('本地交互配置已恢复默认') await captureScreenshot(page, testInfo, 'teaching-immersive-local-config-restored') }) test('纯行政只读加载任务时不请求 runs,数据中心仅展示部门聚合', async ({ page }, testInfo) => { const contract = new TeachingContractMock('administrative') await contract.install(page) await page.goto('/teaching/virtual-training') await expect(page.getByText('虚拟维修训练', { exact: true })).toBeVisible() await expect(page.getByText('部门范围只读', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: /新建任务/ })).toHaveCount(0) expect(contract.count('GET', '/runs')).toBe(0) expect(contract.administrativeRunsRejected).toBe(0) await page.getByRole('button', { name: '任务详情', exact: true }).click() const drawer = page.locator('.el-drawer:visible') await expect(drawer).toContainText('虚拟维修训练') await expect(drawer.getByText('执行与评定记录', { exact: true })).toHaveCount(0) await expect(drawer.getByRole('button', { name: '查看运行' })).toHaveCount(0) expect(contract.count('GET', /\/runs$/)).toBe(0) await page.goto('/teaching/data-center') await expect(page.getByText('部门范围只读汇总', { exact: true })).toBeVisible() await expect(page.getByTestId('analytics-learner-table')).toHaveCount(0) expect(contract.count('GET', '/analytics/records')).toBe(0) expect(contract.administrativeAnalyticsRecordsRejected).toBe(0) await captureScreenshot(page, testInfo, 'teaching-administrative-readonly') }) test('UNION 行政加学员可识别本人运行,并完成 COMMON 领取到教员评定闭环', async ({ page }, testInfo) => { const contract = new TeachingContractMock('unionAdministrativeStudent') const definition = contract.assignments.get('assignment-virtual-common')?.definitionSnapshot as { steps: Array> } expect(definition.steps.every((step) => typeof step.stepCode === 'string' && !('code' in step) && !('id' in step))).toBe(true) await contract.install(page) await page.goto('/teaching/virtual-training') await expect(page.getByText('虚拟维修训练', { exact: true })).toBeVisible() await expect(page.getByText('部门范围只读', { exact: true })).toHaveCount(0) await page.getByRole('button', { name: '领取任务', exact: true }).click() await expect(page).toHaveURL(/\/teaching\/virtual-training\/runs\/run-virtual-common$/) await expect(page.getByRole('button', { name: '开始训练', exact: true })).toBeVisible() const acceptRequest = contract.last('POST', '/assignments/assignment-virtual-common/accept') expect(acceptRequest?.body).toEqual({ assignmentVersion: 3, teamCode: 'NEUTRAL', groupCode: 'INDIVIDUAL', positionCode: 'LEARNER', }) await page.getByRole('button', { name: '开始训练', exact: true }).click() await page.getByRole('button', { name: '完成并进入下一步', exact: true }).click() await expect(page.getByRole('button', { name: '完成当前步骤', exact: true })).toBeVisible() await page.getByRole('button', { name: '完成当前步骤', exact: true }).click() await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible() const progressRequests = contract.requests.filter((item) => item.method === 'PUT' && item.path === '/runs/run-virtual-common/progress') expect(progressRequests).toHaveLength(2) expect(progressRequests.map((item) => item.body.completedStepCode)).toEqual(['STEP-01', 'STEP-02']) for (const request of progressRequests) { expect(request.body).not.toHaveProperty('percent') expect(request.body).not.toHaveProperty('currentStepCode') } await page.getByRole('button', { name: '提交训练结果', exact: true }).click() await visibleDialog(page).getByRole('button', { name: '确认提交', exact: true }).click() await expect(page.getByText('待评定', { exact: true }).first()).toBeVisible() expect(contract.runs.get('run-virtual-common')?.status).toBe('SUBMITTED') await page.goto('/teaching/data-center') await expect.poll(() => contract.count('GET', '/analytics/records')).toBeGreaterThan(0) expect(contract.last('GET', '/analytics/records')?.query.get('userId')).toBe('4') await expect(page.locator('.teaching-v2-filter-toolbar').getByText('全部部门', { exact: true })).toHaveCount(0) await expect(page.locator('.teaching-v2-filter-toolbar').getByText('全部学员', { exact: true })).toHaveCount(0) await switchIdentity(page, contract, 'teacher') await page.goto('/teaching/virtual-training') await page.getByRole('button', { name: /学员明细/ }).click() const details = page.locator('.ttc-assignment-runs') await expect(details.getByRole('button', { name: '评定', exact: true })).toBeVisible() await details.getByRole('button', { name: '评定', exact: true }).click() await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() expect(contract.runs.get('run-virtual-common')?.status).toBe('REVIEWED') expect(contract.count('POST', '/runs/run-virtual-common/review')).toBe(1) await captureScreenshot(page, testInfo, 'teaching-common-stateful-review') }) test('无评定权限不显示超时入口,教员可将超时运行结算后评定', async ({ page }, testInfo) => { const contract = new TeachingContractMock('administrative') contract.seedExpiredRun() await contract.install(page) await page.goto('/teaching/virtual-training') await page.getByRole('button', { name: '任务详情', exact: true }).click() await expect(page.locator('.el-drawer:visible').getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0) expect(contract.count('POST', '/runs/run-timeout/timeout')).toBe(0) await switchIdentity(page, contract, 'teacher') await page.getByRole('button', { name: /学员明细/ }).click() let details = page.locator('.ttc-assignment-runs') await expect(details.getByRole('button', { name: '超时结算', exact: true })).toBeVisible() await expect(details.getByRole('button', { name: '结束运行', exact: true })).toHaveCount(0) await details.getByRole('button', { name: '超时结算', exact: true }).click() const timeoutDialog = visibleDialog(page) await expect(timeoutDialog.getByRole('heading', { name: '确认超时结算', exact: true })).toBeVisible() await expect(timeoutDialog).toContainText('结算后该运行进入待评定状态') await timeoutDialog.getByRole('button', { name: '确认结算', exact: true }).click() await expect(page.getByText('运行已超时结算,可继续进行评定', { exact: true })).toBeVisible() await expect(timeoutDialog).toBeHidden() expect(contract.last('POST', '/runs/run-timeout/timeout')?.body).toEqual({ version: 2 }) expect(contract.runs.get('run-timeout')?.status).toBe('SUBMITTED') await page.getByRole('button', { name: /学员明细/ }).click() details = page.locator('.ttc-assignment-runs') await expect(details.getByRole('button', { name: '评定', exact: true })).toBeVisible() await details.getByRole('button', { name: '评定', exact: true }).click() await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() expect(contract.runs.get('run-timeout')?.status).toBe('REVIEWED') expect(contract.last('POST', '/runs/run-timeout/review')?.body).toMatchObject({ version: 3, score: 80, passed: true }) await captureScreenshot(page, testInfo, 'teaching-timeout-settlement-review') }) test('教员可填写原因中途结束未超时运行并继续评定', async ({ page }, testInfo) => { const contract = new TeachingContractMock('teacher') contract.seedTerminableRun() await contract.install(page) await page.goto('/teaching/virtual-training') await page.getByRole('button', { name: /学员明细/ }).click() let details = page.locator('.ttc-assignment-runs') await expect(details.getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0) await expect(details.getByRole('button', { name: '结束运行', exact: true })).toBeVisible() await details.getByRole('button', { name: '结束运行', exact: true }).click() const terminateDialog = visibleDialog(page) await expect(terminateDialog.getByRole('heading', { name: '结束运行', exact: true })).toBeVisible() await expect(terminateDialog).toContainText('人工中止') await terminateDialog.getByRole('button', { name: '确认结束', exact: true }).click() expect(contract.count('POST', '/runs/run-terminate/terminate')).toBe(0) await expect(terminateDialog).toBeVisible() const reason = '现场安全条件不满足,教员人工结束运行。' await terminateDialog.getByPlaceholder('请输入结束运行原因').fill(reason) await terminateDialog.getByRole('button', { name: '确认结束', exact: true }).click() await expect(page.getByText('运行已结束并进入待评定状态', { exact: true })).toBeVisible() await expect(terminateDialog).toBeHidden() expect(contract.last('POST', '/runs/run-terminate/terminate')?.body).toEqual({ version: 2, reason }) expect(contract.runs.get('run-terminate')?.status).toBe('SUBMITTED') await page.getByRole('button', { name: /学员明细/ }).click() details = page.locator('.ttc-assignment-runs') await expect(details.getByRole('button', { name: '评定', exact: true })).toBeVisible() await details.getByRole('button', { name: '评定', exact: true }).click() await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() expect(contract.runs.get('run-terminate')?.status).toBe('REVIEWED') expect(contract.last('POST', '/runs/run-terminate/review')?.body).toMatchObject({ version: 3, score: 80, passed: true }) await captureScreenshot(page, testInfo, 'teaching-terminate-settlement-review') }) test('对抗未开训运行可人工结束,已开训运行只能走回合超时结算', async ({ page }, testInfo) => { const contract = new TeachingContractMock('teacher') contract.seedConfrontationTerminationCases() await contract.install(page) await page.goto('/teaching/confrontation') await page.getByRole('button', { name: '查看', exact: true }).click() const drawer = page.locator('.el-drawer:visible') const activeRow = drawer.getByRole('row').filter({ hasText: '进行中' }) await expect(activeRow.getByRole('button', { name: '超时结算', exact: true })).toBeVisible() await expect(activeRow.getByRole('button', { name: '结束运行', exact: true })).toHaveCount(0) const acceptedRow = drawer.getByRole('row').filter({ hasText: '待开始' }) await expect(acceptedRow.getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0) await acceptedRow.getByRole('button', { name: '结束运行', exact: true }).click() const dialog = visibleDialog(page) const reason = '对抗编组条件未就绪,开训前结束本次运行。' await dialog.getByPlaceholder('请输入结束运行原因').fill(reason) await dialog.getByRole('button', { name: '确认结束', exact: true }).click() await expect(page.getByText('运行已结束并进入待评定状态', { exact: true })).toBeVisible() await expect(dialog).toBeHidden() await expect(drawer.locator('.el-loading-mask:visible')).toHaveCount(0) expect(contract.last('POST', '/runs/run-confrontation-queued/terminate')?.body).toEqual({ version: 2, reason }) expect(contract.runs.get('run-confrontation-queued')?.status).toBe('SUBMITTED') expect(contract.count('POST', '/runs/run-confrontation/terminate')).toBe(0) const submittedRow = drawer.getByRole('row').filter({ hasText: '待评定' }) await submittedRow.getByRole('button', { name: '评定', exact: true }).click() await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() expect(contract.runs.get('run-confrontation-queued')?.status).toBe('REVIEWED') await captureScreenshot(page, testInfo, 'teaching-confrontation-prestart-terminate') }) test('实装训练两步只走 physical-events,服务端响应驱动下一待办步骤', async ({ page }, testInfo) => { const contract = new TeachingContractMock('studentLeader') await contract.install(page) await page.goto('/teaching/physical-training/runs/run-physical') await expect(page.getByText('现场事件接入', { exact: true })).toBeVisible() await page.getByRole('button', { name: '开始训练', exact: true }).click() const confirm = page.getByRole('button', { name: '确认已完成当前实装步骤', exact: true }) await expect(confirm).toBeVisible() await confirm.click() await expect(page.getByText('确认液压卸压', { exact: true })).toBeVisible() await confirm.click() await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible() const requests = contract.requests.filter((item) => item.method === 'POST' && item.path === '/runs/run-physical/physical-events') expect(requests).toHaveLength(2) expect(requests.map((item) => item.body.stepCode)).toEqual(['STEP-01', 'STEP-02']) expect(requests.map((item) => item.body.eventType)).toEqual(['MANUAL_CONFIRM_POWER_OFF', 'MANUAL_CONFIRM_PRESSURE_RELEASED']) expect(requests.every((item) => item.body.source === 'MANUAL' && item.body.manualConfirmed === true)).toBe(true) expect(requests.every((item) => Number(item.body.occurredAt) < 1_000_000_000_000)).toBe(true) expect(contract.count('PUT', '/runs/run-physical/progress')).toBe(0) expect(contract.runs.get('run-physical')?.progressPercent).toBe(100) await captureScreenshot(page, testInfo, 'teaching-physical-two-step-events') }) test('指定任务预创建的待接收运行可完成接收并进入任务', async ({ page }) => { const contract = new TeachingContractMock('studentLeader') const pendingRun = contract.runs.get('run-physical') if (!pendingRun) throw new Error('缺少指定任务运行夹具') pendingRun.status = 'PENDING' pendingRun.acceptedAt = null await contract.install(page) await page.goto('/teaching/physical-training') const card = page.locator('.ttc-assignment-card').filter({ hasText: '实装维修训练' }) await expect(card.getByRole('button', { name: '接收任务', exact: true })).toBeVisible() await card.getByRole('button', { name: '接收任务', exact: true }).click() await expect(page).toHaveURL(/\/teaching\/physical-training\/runs\/run-physical$/) expect(contract.count('POST', '/assignments/assignment-physical/accept')).toBe(1) expect(contract.runs.get('run-physical')?.status).toBe('ACCEPTED') }) test('对抗共享运行按岗位推进、负责人提交,并仅展示故障公开投影与 TEAM 编组干预', async ({ page }, testInfo) => { const contract = new TeachingContractMock('studentOperator') await contract.install(page) const faultResponsePromise = page.waitForResponse((response) => response.url().includes('/assignments/assignment-confrontation/faults')) await page.goto('/teaching/confrontation/runs/run-confrontation') const faultResponse = await faultResponsePromise const faultPayload = await faultResponse.json() as { data: unknown } expect(JSON.stringify(faultPayload.data)).not.toContain('液压阀芯卡滞真值') expect(JSON.stringify(faultPayload.data)).not.toContain('内部触发条件') await expect(page.getByText('动臂响应迟缓且压力波动', { exact: true })).toBeVisible() await expect(page.getByText('液压阀芯卡滞真值')).toHaveCount(0) await expect(page.getByText('红蓝双方按岗位协同完成液压故障隔离', { exact: true })).toBeVisible() await expect(page.getByText('在安全约束下按完成度与用时综合判定', { exact: true })).toBeVisible() await expect(page.getByText(/红方维修组 · OPERATOR/)).toBeVisible() await expect(page.getByText(/此动作需由 TEAM_LEADER/)).toBeVisible() await expect(page.getByRole('button', { name: '提交岗位动作', exact: true })).toBeDisabled() await switchIdentity(page, contract, 'studentLeader') await page.getByRole('button', { name: '提交岗位动作', exact: true }).click() expect(contract.last('PUT', '/runs/run-confrontation/progress')?.body).toMatchObject({ completedStepCode: 'STEP-01', actionCode: 'ISSUE_COMMAND' }) await switchIdentity(page, contract, 'studentOperator') await expect(page.getByText(/当前动作由岗位“OPERATOR”执行/)).toBeVisible() await page.getByRole('button', { name: '完成当前岗位动作', exact: true }).click() await expect(page.getByText('等待组长/指挥员提交', { exact: true })).toBeVisible() expect(contract.last('PUT', '/runs/run-confrontation/progress')?.body).toMatchObject({ completedStepCode: 'STEP-02', actionCode: 'ISOLATE_FAULT' }) await switchIdentity(page, contract, 'studentLeader') await page.getByRole('button', { name: '提交团队结果', exact: true }).click() await visibleDialog(page).getByRole('button', { name: '确认提交', exact: true }).click() expect(contract.runs.get('run-confrontation')?.status).toBe('SUBMITTED') await switchIdentity(page, contract, 'teacher') await page.getByRole('button', { name: '新建干预', exact: true }).click() const dialog = visibleDialog(page) await chooseSelectOption(page, dialog, '作用层级', '指定队伍') await chooseSelectOption(page, dialog, '目标队伍', '红方') await dialog.locator('input[placeholder="可选:进一步限定编组"]').fill('GROUP-RED') await dialog.locator('textarea[placeholder="请输入不泄露故障答案的公开说明"]').fill('红方液压压力波动,请按岗位流程处置。') await dialog.getByRole('button', { name: '保存干预', exact: true }).click() await expect(page.getByText('红方液压压力波动,请按岗位流程处置。', { exact: true })).toBeVisible() await page.getByRole('button', { name: '激活', exact: true }).click() const intervention = contract.last('POST', '/assignments/assignment-confrontation/interventions') expect(intervention?.body).toMatchObject({ targetScope: 'TEAM', targetTeamCode: 'RED', targetGroupCode: 'GROUP-RED' }) expect(intervention?.body).not.toHaveProperty('privatePayload') expect(contract.interventions[0]?.status).toBe('ACTIVE') await captureScreenshot(page, testInfo, 'teaching-confrontation-role-and-intervention') }) test('正式考核提交 Unix 秒并锁定所有辅助能力', async ({ page }, testInfo) => { const contract = new TeachingContractMock('teacher') await contract.install(page) await page.goto('/teaching/exams') await page.getByRole('button', { name: '新建正式考核', exact: true }).click() const dialog = visibleDialog(page) await dialog.getByRole('textbox', { name: /任务名称/ }).fill('状态化契约正式考核') await chooseSelectOption(page, dialog, '训练内容', '挖掘机液压系统训练 · V2') await dialog.getByRole('combobox', { name: '开始时间' }).fill('2026-08-11 09:00:00') await dialog.getByRole('combobox', { name: '截止时间' }).fill('2026-08-11 11:00:00') await chooseSelectOption(page, dialog, '参训学员', '红方队长 · 维修教研室') await page.keyboard.press('Escape') await dialog.getByRole('button', { name: '保存草稿', exact: true }).click() await expect(dialog).toBeHidden() const createRequest = contract.last('POST', '/assignments') expect(createRequest?.body).toMatchObject({ assignmentKind: 'FORMAL_EXAM', executionMode: 'EXAM', digitalHumanAllowed: false }) expect(typeof createRequest?.body.scheduleStartAt).toBe('number') expect(typeof createRequest?.body.dueAt).toBe('number') expect(Number(createRequest?.body.scheduleStartAt)).toBeLessThan(1_000_000_000_000) expect(Number(createRequest?.body.dueAt)).toBeGreaterThan(Number(createRequest?.body.scheduleStartAt)) contract.keepOnlyRuns('run-exam') await switchIdentity(page, contract, 'studentLeader') await page.goto('/teaching/exams/runs/run-exam') await expect(page.getByText('正式考核保护已启用', { exact: true })).toBeVisible() await expect(page.getByText(/关闭演示、步骤提示、回退、答案和数字教员/)).toBeVisible() await expect(page.getByRole('button', { name: /演示|提示|回退|回放|上一步/ })).toHaveCount(0) await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(0) await page.goto('/teaching/immersive') await expect(page.getByText('正式考核禁止数字人、提示和演示。', { exact: true })).toBeVisible() await expect(page.getByText('本地数字人服务', { exact: true })).toHaveCount(0) await expect.poll(() => contract.count('GET', '/runs/run-exam/assets')).toBeGreaterThan(0) await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(1) await captureScreenshot(page, testInfo, 'teaching-formal-exam-seconds-and-lock') }) test('沉浸执行权限缺失时启动检查与桌面 WebXR 入口前置只读', async ({ page }) => { const contract = new TeachingContractMock('studentLeader') contract.seedImmersiveRun() contract.keepOnlyRuns('run-immersive') contract.denyPermissions('teaching.immersive.execute') await contract.install(page) await page.goto('/teaching/immersive') const preflight = page.getByTestId('immersive-preflight') await expect(preflight).toContainText('当前身份仅可查看,无沉浸训练执行权限') await expect(preflight).toContainText('不会创建服务端会话,也不会申请浏览器 WebXR 会话') const desktopStarts = page.getByRole('button', { name: /只读查看,无法启动/ }) await expect(desktopStarts).toHaveCount(1) for (const button of await desktopStarts.all()) await expect(button).toBeDisabled() await expect(page.getByRole('button', { name: 'VR 沉浸模式' })).toBeDisabled() expect(contract.count('POST', /\/runs\/[^/]+\/xr-sessions$/)).toBe(0) }) test('运行场景与 WebXR 消费 definitionSnapshot 和教学资产,并完成桌面会话登记与结束', async ({ page }, testInfo) => { const contract = new TeachingContractMock('studentLeader') contract.seedImmersiveRun() contract.keepOnlyRuns('run-immersive') await contract.install(page) let modelRequests = 0 page.on('request', (request) => { if (new URL(request.url()).pathname === '/models/excavator-a.glb') modelRequests += 1 }) await page.goto('/teaching/immersive') await expect(page.getByTestId('immersive-viewer-panel')).toBeVisible() await expect(page.locator('.teaching-v2-hero')).toHaveCount(0) await expect.poll(() => contract.count('GET', '/runs/run-immersive/assets')).toBeGreaterThan(0) await expect(page.locator('.immersive-scene-status')).toContainText('已加载“虚拟维修训练”发布场景', { timeout: 30_000 }) await expect.poll(() => modelRequests).toBeGreaterThan(0) await expect(page.locator('.immersive-viewer canvas')).toBeVisible() await expect(page.locator('iframe')).toHaveCount(0) await expect(page.getByTestId('immersive-device-panel')).toBeVisible() await expect(page.getByTestId('immersive-calibration')).toBeVisible() await expect(page.getByTestId('immersive-preflight')).toBeVisible() await page.getByRole('button', { name: '启动桌面会话', exact: true }).click() await expect(page.getByRole('button', { name: '结束会话', exact: true })).toBeVisible() expect(contract.last('POST', '/runs/run-immersive/xr-sessions')?.body).toMatchObject({ mode: 'INLINE', referenceSpaceType: 'LOCAL' }) await page.getByRole('button', { name: '结束会话', exact: true }).click() await expect(page.getByRole('button', { name: '结束会话', exact: true })).toHaveCount(0) expect(contract.count('PUT', '/xr-sessions/xr-session-1/end')).toBe(1) await captureScreenshot(page, testInfo, 'teaching-real-assets-and-xr-session') }) test('数据中心查询分页安全明细,并以 Unix 秒提交时间筛选', async ({ page }, testInfo) => { const contract = new TeachingContractMock('teacher') await contract.install(page) await page.goto('/teaching/data-center') await expect(page.getByTestId('analytics-channel-summary')).toBeVisible() await page.getByTestId('analytics-tab-exam').click() await expect(page.getByText('虚拟维修正式考核', { exact: true })).toBeVisible() await page.locator('input[placeholder="搜索学员、编号或任务名称"]').fill('红方队长') await chooseSelectOption(page, page.locator('.teaching-v2-filter-toolbar'), '全部渠道', '虚拟仿真') await chooseSelectOption(page, page.locator('.teaching-v2-filter-toolbar'), '全部状态', '已评定') const toolbar = page.locator('.teaching-v2-filter-toolbar') const startAtInput = toolbar.getByRole('combobox', { name: '开始时间' }) const endAtInput = toolbar.getByRole('combobox', { name: '结束时间' }) await startAtInput.fill('2026-08-01 00:00:00') await startAtInput.press('Enter') await endAtInput.fill('2026-08-12 00:00:00') await endAtInput.press('Enter') await page.keyboard.press('Escape') await page.getByRole('button', { name: '搜索', exact: true }).click() await expect.poll(() => contract.count('GET', '/analytics/records')).toBeGreaterThan(1) const query = contract.last('GET', '/analytics/records')?.query expect(query?.get('keyword')).toBe('红方队长') expect(query?.get('channel')).toBe('VIRTUAL') // 页签在已授权的安全明细内做客户端分组;服务端组合筛选不再重复提交类型字段。 expect(query?.get('assignmentKind')).toBeNull() expect(query?.get('status')).toBe('REVIEWED') expect(Number(query?.get('startAt'))).toBeLessThan(1_000_000_000_000) expect(Number(query?.get('endAt'))).toBeGreaterThan(Number(query?.get('startAt'))) await page.getByRole('button', { name: '详情', exact: true }).click() const drawer = page.locator('.el-drawer:visible') await expect(drawer).toContainText('教学记录详情') await expect(drawer).toContainText('此处只展示服务端授权的安全明细') await expect(drawer).not.toContainText('checkpoint') await expect(drawer).not.toContainText('privatePayload') await captureScreenshot(page, testInfo, 'teaching-analytics-safe-records') }) })