瀏覽代碼

test: 覆盖虚拟仿真原型与教员预览

main
leiyun 3 週之前
父節點
當前提交
db95a8f0cf
共有 3 個檔案被更改,包括 162 行新增34 行删除
  1. +71
    -2
      tests/teaching-contract-mock.ts
  2. +60
    -26
      tests/teaching-module.spec.ts
  3. +31
    -6
      tests/teaching-preview-workbench.spec.ts

+ 71
- 2
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<string>()
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: '<!doctype html><html lang="zh-CN"><body><main data-testid="mock-ai-person">虚拟教员在线答疑</main></body></html>',
})
})
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') {


+ 60
- 26
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)


+ 31
- 6
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)



Loading…
取消
儲存