From db95a8f0cf0b561d18019d5e0272f7bdbde63b60 Mon Sep 17 00:00:00 2001 From: leiyun Date: Thu, 27 Aug 2026 02:19:28 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E4=BB=BF=E7=9C=9F=E5=8E=9F=E5=9E=8B=E4=B8=8E=E6=95=99=E5=91=98?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/teaching-contract-mock.ts | 73 +++++++++++++++++++- tests/teaching-module.spec.ts | 86 +++++++++++++++++------- tests/teaching-preview-workbench.spec.ts | 37 ++++++++-- 3 files changed, 162 insertions(+), 34 deletions(-) diff --git a/tests/teaching-contract-mock.ts b/tests/teaching-contract-mock.ts index cd2a1ae..532b811 100644 --- a/tests/teaching-contract-mock.ts +++ b/tests/teaching-contract-mock.ts @@ -98,6 +98,55 @@ const twoStepDefinition = (channel: Channel) => ({ } : {}, }) +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`, @@ -173,11 +222,23 @@ export class TeachingContractMock { 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'), @@ -393,6 +454,14 @@ export class TeachingContractMock { 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)) } @@ -430,13 +499,13 @@ export class TeachingContractMock { 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) => (!channel || item.channel === channel) && item.assignmentKind === kind) + 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) => (!channel || item.channel === channel) && item.assignmentKind === kind) + 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') { diff --git a/tests/teaching-module.spec.ts b/tests/teaching-module.spec.ts index 7cf2d70..2b6bafc 100644 --- a/tests/teaching-module.spec.ts +++ b/tests/teaching-module.spec.ts @@ -54,6 +54,33 @@ test.describe('教学实施模块状态化 UI 契约', () => { 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) } @@ -61,7 +88,7 @@ test.describe('教学实施模块状态化 UI 契约', () => { 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.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') @@ -262,7 +289,7 @@ test.describe('教学实施模块状态化 UI 契约', () => { expect(contract.count('GET', '/runs')).toBe(0) expect(contract.administrativeRunsRejected).toBe(0) - await page.getByRole('button', { name: '查看', exact: true }).click() + 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) @@ -325,10 +352,10 @@ test.describe('教学实施模块状态化 UI 契约', () => { await switchIdentity(page, contract, 'teacher') await page.goto('/teaching/virtual-training') - await page.getByRole('button', { name: '查看', exact: true }).click() - const drawer = page.locator('.el-drawer:visible') - await expect(drawer.getByRole('button', { name: '评定', exact: true })).toBeVisible() - await drawer.getByRole('button', { name: '评定', exact: true }).click() + 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') @@ -342,16 +369,16 @@ test.describe('教学实施模块状态化 UI 契约', () => { await contract.install(page) await page.goto('/teaching/virtual-training') - await page.getByRole('button', { name: '查看', exact: true }).click() + 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: '查看', exact: true }).click() - const drawer = page.locator('.el-drawer:visible') - await expect(drawer.getByRole('button', { name: '超时结算', exact: true })).toBeVisible() - await expect(drawer.getByRole('button', { name: '结束运行', exact: true })).toHaveCount(0) - await drawer.getByRole('button', { name: '超时结算', exact: true }).click() + 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('结算后该运行进入待评定状态') @@ -359,11 +386,12 @@ test.describe('教学实施模块状态化 UI 契约', () => { await expect(page.getByText('运行已超时结算,可继续进行评定', { exact: true })).toBeVisible() await expect(timeoutDialog).toBeHidden() - await expect(drawer.locator('.el-loading-mask:visible')).toHaveCount(0) expect(contract.last('POST', '/runs/run-timeout/timeout')?.body).toEqual({ version: 2 }) expect(contract.runs.get('run-timeout')?.status).toBe('SUBMITTED') - await expect(drawer.getByRole('button', { name: '评定', exact: true })).toBeVisible() - await drawer.getByRole('button', { name: '评定', exact: true }).click() + 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') @@ -377,11 +405,11 @@ test.describe('教学实施模块状态化 UI 契约', () => { await contract.install(page) await page.goto('/teaching/virtual-training') - await page.getByRole('button', { name: '查看', exact: true }).click() - const drawer = page.locator('.el-drawer:visible') - await expect(drawer.getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0) - await expect(drawer.getByRole('button', { name: '结束运行', exact: true })).toBeVisible() - await drawer.getByRole('button', { name: '结束运行', exact: true }).click() + 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() @@ -396,11 +424,12 @@ test.describe('教学实施模块状态化 UI 契约', () => { await expect(page.getByText('运行已结束并进入待评定状态', { exact: true })).toBeVisible() await expect(terminateDialog).toBeHidden() - await expect(drawer.locator('.el-loading-mask:visible')).toHaveCount(0) expect(contract.last('POST', '/runs/run-terminate/terminate')?.body).toEqual({ version: 2, reason }) expect(contract.runs.get('run-terminate')?.status).toBe('SUBMITTED') - await expect(drawer.getByRole('button', { name: '评定', exact: true })).toBeVisible() - await drawer.getByRole('button', { name: '评定', exact: true }).click() + 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') @@ -588,7 +617,7 @@ test.describe('教学实施模块状态化 UI 契约', () => { await expect(preflight).toContainText('当前身份仅可查看,无沉浸训练执行权限') await expect(preflight).toContainText('不会创建服务端会话,也不会申请浏览器 WebXR 会话') const desktopStarts = page.getByRole('button', { name: /只读查看,无法启动/ }) - await expect(desktopStarts).toHaveCount(2) + 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) @@ -635,8 +664,13 @@ test.describe('教学实施模块状态化 UI 契约', () => { 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') - await toolbar.getByRole('combobox', { name: '开始时间' }).fill('2026-08-01 00:00:00') - await toolbar.getByRole('combobox', { name: '结束时间' }).fill('2026-08-12 00:00:00') + 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) diff --git a/tests/teaching-preview-workbench.spec.ts b/tests/teaching-preview-workbench.spec.ts index 760804f..f86eb57 100644 --- a/tests/teaching-preview-workbench.spec.ts +++ b/tests/teaching-preview-workbench.spec.ts @@ -7,32 +7,57 @@ test.describe('教员只读预览工作台', () => { const contract = new TeachingContractMock('admin') await contract.install(page) - await page.goto('/teaching/virtual-training/preview/assignment-virtual-common') + await page.goto('/teaching/virtual-training/preview/assignment-virtual-preview') const workbench = page.locator('.teaching-preview-workbench') await expect(workbench).toBeVisible() await expect(workbench).toContainText('教员预览 · 不计成绩') - await expect(workbench.locator('.preview-step-panel')).toContainText('识别作业对象') + await expect(workbench.locator('.preview-step-panel > header')).toContainText('1 / 8') + await expect(workbench.locator('.preview-step-panel li')).toHaveCount(8) + await expect(workbench.locator('.preview-step-panel')).toContainText('卸压确认与故障基线采集') + await expect(workbench.locator('.preview-step-panel')).toContainText('密封复验与中修闭环') await expect(workbench.locator('.preview-runtime-viewport canvas')).toBeVisible() - await expect(workbench.locator('.preview-digital-teacher')).toContainText('语音服务未接入') + await expect(workbench.locator('.preview-digital-teacher')).toContainText('点火系统维修教研员') + await expect(workbench.locator('.preview-digital-teacher')).toContainText('确认压力表回零,记录故障压力和泄漏现象。') await expect(workbench.locator('.preview-digital-teacher img')).toHaveJSProperty('complete', true) await expect(workbench.locator('iframe')).toHaveCount(0) await captureScreenshot(page, testInfo, 'teaching-teacher-preview-initial') const writesBefore = contract.requests.filter((request) => ['POST', 'PUT', 'DELETE'].includes(request.method)).length + + await workbench.getByRole('button', { name: '打开在线答疑', exact: true }).click() + const onlineDrawer = page.locator('.el-drawer:visible').filter({ hasText: '点火系统维修教研员 · 在线答疑' }).last() + const digitalInstructor = onlineDrawer.getByTestId('digital-instructor-embed') + await expect(digitalInstructor).toBeVisible() + const instructorFrame = digitalInstructor.locator('iframe') + await expect(instructorFrame).toHaveAttribute('src', /\/embed\/dh-b69424ed0904\?open=1$/) + await expect(instructorFrame).toHaveAttribute('allow', 'autoplay; microphone') + await expect(digitalInstructor.locator('.digital-instructor-embed__status')).toHaveText('页面已加载') + await expect(page.frameLocator('[data-testid="digital-instructor-embed"] iframe').getByTestId('mock-ai-person')).toHaveText('虚拟教员在线答疑') + await expect.poll(() => contract.digitalInstructorFrameLoads).toBe(1) + expect(contract.requests.filter((request) => ['POST', 'PUT', 'DELETE'].includes(request.method))).toHaveLength(writesBefore) + await page.keyboard.press('Escape') + await expect(onlineDrawer).toBeHidden() + await expect(page.locator('[data-testid="digital-instructor-embed"] iframe')).toHaveCount(0) + await workbench.getByRole('button', { name: '判定详情', exact: true }).click() - await expect(workbench.locator('.preview-rule-drawer')).toContainText('IDENTIFY_TARGET') + await expect(workbench.locator('.preview-rule-drawer')).toContainText('RELEASE_PRESSURE_BASELINE') await expect(workbench.locator('.preview-rule-drawer')).toContainText('这里只展示发布快照中的公开字段') + await workbench.getByRole('button', { name: '规则演示', exact: true }).click() + await workbench.getByRole('button', { name: '步骤演示', exact: true }).click() await workbench.getByRole('button', { name: '引导完成', exact: true }).click() await expect(workbench.getByRole('button', { name: /下一步/ })).toBeEnabled() await workbench.getByRole('button', { name: /下一步/ }).click() - await expect(workbench.getByRole('heading', { name: /第 2 步:完成检修操作/ })).toBeVisible() + await expect(workbench.getByRole('heading', { name: /第 2 步:油管与接头外泄检查/ })).toBeVisible() + await expect(workbench.locator('.preview-step-panel > header')).toContainText('2 / 8') await workbench.getByRole('button', { name: /过程记录/ }).click() - const drawer = page.locator('.el-drawer:visible') + const drawer = page.locator('.el-drawer:visible').last() await expect(drawer).toContainText('不会提交到教学事件接口') await expect(drawer).toContainText('本地试走步骤') + await expect(drawer).toContainText('规则演示') + await expect(drawer).toContainText('步骤演示') const writesAfter = contract.requests.filter((request) => ['POST', 'PUT', 'DELETE'].includes(request.method)).length expect(writesAfter).toBe(writesBefore)