|
- import { expect, test, type Page, type Route } from '@playwright/test'
- import fs from 'node:fs'
- import path from 'node:path'
-
- /**
- * 训练编排工作台回归:科目与形态 → 场景与目标 → 训练步骤 → 判定规则 → 故障注入 → 评分与运行 → 发布校验。
- *
- * 内容制作接口按 wiki/11-内容制作模块.md 的契约打桩(与 content-guide-lifecycle 同一做法)。
- * 重点验证两件容易回归的事:步骤目标只能来自绑定场景的可操作对象;
- * 保存时必须产出 stepCode / actionCode / physicalEventRule.progressPercent,
- * 否则训练项目发布成教学任务时会被服务端 validRuntimeCode 拒绝。
- */
-
- type Json = Record<string, unknown>
-
- const SHOTS = path.resolve('artifacts/content-training-studio')
- fs.mkdirSync(SHOTS, { recursive: true })
-
- const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-training' })
- const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
- const fail = (route: Route, status: number, message: string) => route.fulfill({
- status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-training' }),
- })
- const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 })
-
- /** 绑定场景:两个可操作对象、一个环境设施、一个已软删除对象。 */
- const sceneContent = (): Json => ({
- schema: 'unreal-tran.scene',
- version: '2.0',
- objects: [
- {
- id: 'o1', name: '支腿液压缸总成', visible: true, deleted: false,
- semantic: { category: '液压执行机构', deviceId: 'DEV-01', interactionId: 'cylinder-main', operable: true, capabilities: ['select', 'move', 'inspect', 'measure'], tags: [] },
- },
- {
- id: 'o2', name: '活塞密封圈', visible: true, deleted: false,
- semantic: { category: '密封件', deviceId: 'DEV-02', interactionId: 'seal-ring', operable: true, capabilities: ['select', 'move'], tags: [] },
- },
- {
- id: 'o3', name: '车间地面', visible: true, deleted: false,
- semantic: { category: '环境设施', deviceId: '', interactionId: '', operable: false, capabilities: [], tags: [] },
- },
- {
- id: 'o4', name: '历史对象', visible: true, deleted: true,
- semantic: { category: '液压', deviceId: 'DEV-04', interactionId: 'ghost', operable: true, capabilities: ['select'], tags: [] },
- },
- ],
- })
-
- const legacyTrainingContent = (mode: string): Json => ({
- level: '中级',
- durationMinutes: 25,
- totalScore: 100,
- modeConfig: mode === 'CONFRONTATION'
- ? { type: 'CONFRONTATION', redTeamName: '红方', blueTeamName: '蓝方', teamSize: 3, roundDurationMinutes: 30, submitPositions: ['COMMANDER'], objective: '限时排除故障', winRule: '' }
- : { type: mode },
- // 历史工程只有 title/summary/score,缺 stepCode/actionCode/目标,归一化后必须补齐编码。
- steps: [
- { id: 'step_1c9f0e2a-8f11-4c0b-9d6e-77a1f3b5c9d0', title: '检查缸体外观', summary: '目视检查缸筒是否存在划痕', score: 40 },
- { id: 'step_2d8e1f3b-7a22-4b1c-8e5f-66b2e4c6d8e1', title: '拆卸密封圈', summary: '按对角顺序拆除固定螺栓', score: 60 },
- ],
- })
-
- class TrainingMock {
- readonly requests: Array<{ method: string; path: string; body: Json }> = []
- mode = 'VIRTUAL'
- content: Json = legacyTrainingContent('VIRTUAL')
- version = 2
- sceneBound = true
-
- private dependencies() {
- return this.sceneBound
- ? [{
- targetProjectId: '701', targetVersionId: '25', relationType: 'TRAINING_SCENE', required: true,
- targetType: 'SCENE', targetCode: 'SC-2026-014', targetName: '支腿检修车间', targetVersionNumber: 3,
- }]
- : []
- }
-
- private project() {
- return {
- id: '901', type: 'TRAINING', code: 'TR-2026-071', name: '支腿液压缸中修训练',
- description: '整机支腿液压缸中修标准训练', categoryCode: 'MEDIUM_REPAIR', coverUri: '',
- status: 'DRAFT', trainingMode: this.mode, version: this.version,
- currentVersionId: '31', currentVersionNo: 2, currentVersionCode: 'V1.1.0', currentVersionStatus: 'DRAFT',
- publishedVersionId: null, ownerUserId: '7', ownerName: '内容作者',
- departmentId: '20', departmentName: '维修保障中心',
- addTime: 1786387200, updateTime: 1786387200, publishedTime: null,
- usageCount: 0, readCount: 0, downloadCount: 0,
- dependencyCount: this.dependencies().length, assetCount: 0,
- }
- }
-
- private detail() {
- return {
- project: this.project(),
- currentVersion: {
- id: '31', projectId: '901', versionNo: 2, versionCode: 'V1.1.0', status: 'DRAFT',
- content: this.content, dependencies: this.dependencies(), assets: [],
- createdByName: '内容作者', publishedByName: '', publishedTime: null,
- addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
- },
- }
- }
-
- async install(page: Page) {
- await page.addInitScript(() => {
- sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token')
- })
- await page.route('**/api/auth/v1/auth/me', (route) => ok(route, {
- user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
- activeRoleId: 'role-admin',
- roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }],
- permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'],
- authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200,
- }))
- await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, []))
- await page.route('**/api/tran/v1/content/**', (route) => this.handle(route))
- }
-
- private async handle(route: Route) {
- const request = route.request()
- const url = new URL(request.url())
- const target = url.pathname.replace(/^.*\/v1\/content/, '')
- const method = request.method()
- let body: Json = {}
- try {
- const text = request.postData()
- if (text) body = JSON.parse(text) as Json
- } catch { body = {} }
- this.requests.push({ method, path: target, body })
-
- if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()]))
- if (method === 'GET' && target === '/projects/summary') {
- return ok(route, { total: 1, draft: 1, review: 0, published: 0, byType: [], byTrainingMode: [] })
- }
- if (method === 'GET' && target === '/catalog') return ok(route, pageOf([]))
- if (method === 'GET' && target === '/projects/901') return ok(route, this.detail())
- if (method === 'PUT' && target === '/projects/901') {
- if (body.content && typeof body.content === 'object') this.content = body.content as Json
- this.version += 1
- return ok(route, this.detail())
- }
- if (method === 'GET' && target === '/projects/901/versions') return ok(route, pageOf([this.detail().currentVersion]))
- if (method === 'GET' && target === '/projects/901/versions/31') return ok(route, this.detail().currentVersion)
- if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [])
- // 绑定场景的已发布版本,工作台据此解析可操作目标
- if (method === 'GET' && target === '/projects/701/versions/25') {
- return ok(route, {
- id: '25', projectId: '701', versionNo: 3, versionCode: 'V1.2.0', status: 'PUBLISHED',
- content: sceneContent(), dependencies: [], assets: [],
- createdByName: '内容作者', publishedByName: '内容作者', publishedTime: 1786387200,
- addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
- })
- }
- if (method === 'POST' && target === '/projects/901/metrics') return ok(route, {})
- return fail(route, 404, `未打桩的内容接口 ${method} ${target}`)
- }
-
- /** 最后一次保存提交的 content,用于断言工作台真正回写了什么。 */
- savedContent(): Json {
- const last = [...this.requests].reverse().find((item) => item.method === 'PUT')
- return (last?.body.content ?? {}) as Json
- }
-
- savedSteps(): Json[] {
- const content = this.savedContent()
- return Array.isArray(content.steps) ? content.steps as Json[] : []
- }
- }
-
- const summaryValue = (page: Page, label: string) => (
- page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText()
- )
-
- test.describe('训练编排工作台', () => {
- test('绑定场景解析目标、步骤编排与保存产出运行时编码', async ({ page }) => {
- const mock = new TrainingMock()
- await mock.install(page)
- const pageErrors: string[] = []
- page.on('pageerror', (error) => pageErrors.push(error.message))
-
- // 1. 场景与目标:只有可操作且未删除的对象进入目标清单
- await page.goto('/content/training-projects/901/studio?view=scene')
- await expect(page.locator('.training-flow-panel')).toBeVisible()
- await expect(page.locator('.scene-facts')).toContainText('支腿检修车间')
- await expect(page.locator('.scene-facts')).toContainText('2 个')
- const targetRows = page.locator('.single-panel tbody tr')
- await expect(targetRows).toHaveCount(2)
- await expect(page.locator('.single-panel tbody')).toContainText('cylinder-main')
- await expect(page.locator('.single-panel tbody')).toContainText('seal-ring')
- await expect(page.locator('.single-panel tbody')).not.toContainText('车间地面')
- await expect(page.locator('.single-panel tbody')).not.toContainText('历史对象')
- await page.screenshot({ path: path.join(SHOTS, '01-场景与目标.png'), fullPage: true })
-
- // 2. 训练步骤:历史工程的两步已被补上派生编码
- await page.goto('/content/training-projects/901/studio?view=steps')
- const stepRows = page.locator('.step-row')
- await expect(stepRows).toHaveCount(2)
- await expect(stepRows.first()).toContainText('VIRTUAL-S01')
- await expect(stepRows.nth(1)).toContainText('VIRTUAL-S02')
-
- // 3. 选中首步,关联目标;密封圈只支持拆卸/装配/检查,不出现测量
- await stepRows.first().click()
- await expect(page.locator('.inspector-panel')).toContainText('目标与动作')
- const targetSelect = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first()
- await targetSelect.click()
- await page.locator('.el-select-dropdown__item', { hasText: 'cylinder-main' }).first().click()
- await expect(page.locator('.step-row').first()).toContainText('支腿液压缸总成')
- await expect(page.locator('.studio-dirty')).toBeVisible()
-
- await stepRows.nth(1).click()
- const secondTarget = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first()
- await secondTarget.click()
- await page.locator('.el-select-dropdown__item', { hasText: 'seal-ring' }).first().click()
- const modeSelect = page.locator('.inspector-panel .el-form-item', { hasText: '操作模式' }).locator('.el-select').first()
- await modeSelect.click()
- const modeOptions = page.locator('.el-select-dropdown:visible .el-select-dropdown__item')
- // 密封圈的 capabilities 只有 select + move,所以没有「测量」这一项
- await expect(modeOptions).toHaveCount(3)
- await expect(modeOptions.filter({ hasText: '测量' })).toHaveCount(0)
- await modeOptions.filter({ hasText: '拆卸' }).first().click()
- await expect(page.locator('.inspector-panel .el-form-item', { hasText: '动作类型' }).locator('input')).toHaveValue('三维拖拽')
- await page.screenshot({ path: path.join(SHOTS, '02-步骤编排.png'), fullPage: true })
-
- // 4. 新增步骤后顺序与编码连续
- await page.getByRole('button', { name: '新增步骤' }).click()
- await expect(page.locator('.step-row')).toHaveCount(3)
- await expect(page.locator('.step-row').nth(2)).toContainText('VIRTUAL-S03')
-
- // 5. 保存:payload 必须带 stepCode / actionCode,且不再出现超长 uuid 编码
- await page.getByRole('button', { name: /^保存$/ }).click()
- await expect(page.locator('.studio-dirty')).toHaveCount(0)
- const saved = mock.savedSteps()
- expect(saved).toHaveLength(3)
- expect(saved.map((step) => step.stepCode)).toEqual(['VIRTUAL-S01', 'VIRTUAL-S02', 'VIRTUAL-S03'])
- expect(saved.map((step) => step.actionCode)).toEqual(['VIRTUAL-A01', 'VIRTUAL-A02', 'VIRTUAL-A03'])
- for (const step of saved) {
- // 与服务端 TrainingPreflightValidator / TeachingAssignmentService 的 validRuntimeCode 同一正则
- expect(String(step.stepCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)
- expect(String(step.actionCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)
- }
- expect(saved[0]?.targetId).toBe('cylinder-main')
- expect(saved[1]?.targetId).toBe('seal-ring')
- expect(saved[1]?.mode).toBe('remove')
- expect(saved[1]?.interaction).toBe('spatial-drag')
-
- // 6. 发布校验:第三步没有目标,必须阻断并能定位
- await page.goto('/content/training-projects/901/studio?view=preflight')
- await expect(page.locator('.preflight-summary')).toBeVisible()
- expect(Number(await summaryValue(page, '阻断'))).toBeGreaterThan(0)
- // 第一个 .issue-list 是阻断项,第二个是提示项
- await expect(page.locator('.issue-list').first()).toContainText('尚未关联场景交互对象')
- await page.screenshot({ path: path.join(SHOTS, '03-发布校验.png'), fullPage: true })
-
- expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([])
- })
-
- test('未固定场景时目标不可选并阻断发布', async ({ page }) => {
- const mock = new TrainingMock()
- mock.sceneBound = false
- await mock.install(page)
-
- await page.goto('/content/training-projects/901/studio?view=scene')
- await expect(page.locator('.el-alert')).toContainText('尚未固定必选场景版本')
-
- await page.goto('/content/training-projects/901/studio?view=preflight')
- await expect(page.locator('.issue-list').first()).toContainText('发布前必须固定一个必选的已发布场景版本')
- await page.screenshot({ path: path.join(SHOTS, '04-未绑定场景.png'), fullPage: true })
- })
-
- test('实装形态自动生成严格递增的事件进度', async ({ page }) => {
- const mock = new TrainingMock()
- mock.mode = 'PHYSICAL'
- mock.content = legacyTrainingContent('PHYSICAL')
- await mock.install(page)
-
- await page.goto('/content/training-projects/901/studio?view=steps')
- await expect(page.locator('.step-row')).toHaveCount(2)
- await page.locator('.step-row').first().click()
- await expect(page.locator('.inspector-panel')).toContainText('实装事件规则')
- await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('50%')
- await page.locator('.step-row').nth(1).click()
- await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('100%')
-
- await page.getByRole('button', { name: '新增步骤' }).click()
- await page.getByRole('button', { name: /^保存$/ }).click()
- await expect(page.locator('.studio-dirty')).toHaveCount(0)
- const saved = mock.savedSteps()
- const progress = saved.map((step) => Number((step.physicalEventRule as Json).progressPercent))
- expect(progress).toEqual([33, 67, 100])
- for (const step of saved) {
- const rule = step.physicalEventRule as Json
- expect(['DEVICE', 'VISION', 'MANUAL']).toContain(String(rule.source))
- expect(String(rule.eventType)).toMatch(/^[A-Za-z][A-Za-z0-9._-]{1,99}$/)
- }
- await page.screenshot({ path: path.join(SHOTS, '05-实装进度.png'), fullPage: true })
- })
-
- test('对抗形态校验每队人数、岗位与可提交岗位', async ({ page }) => {
- const mock = new TrainingMock()
- mock.mode = 'CONFRONTATION'
- mock.content = legacyTrainingContent('CONFRONTATION')
- await mock.install(page)
-
- await page.goto('/content/training-projects/901/studio?view=overview')
- await expect(page.locator('.single-panel')).toContainText('每队人数')
- await expect(page.locator('.single-panel')).toContainText('可提交结果的岗位')
-
- await page.goto('/content/training-projects/901/studio?view=steps')
- await page.locator('.step-row').first().click()
- await expect(page.locator('.inspector-panel')).toContainText('执行岗位')
-
- // 历史工程的对抗步骤没有岗位,发布校验必须逐步骤阻断
- await page.goto('/content/training-projects/901/studio?view=preflight')
- await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位')
- await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true })
- })
-
- test('高级编排从线性流程派生并能加分支节点与时间轴', async ({ page }) => {
- const mock = new TrainingMock()
- await mock.install(page)
-
- await page.goto('/content/training-projects/901/studio?view=flow')
- // 内容制作页面已去掉 SystemPageHeader,视图名称由各视图面板自己的标题承担
- await expect(page.locator('.studio-main')).toContainText('流程与时间轴')
- await expect(page.locator('button', { hasText: '上传音频' })).toBeVisible()
-
- // 历史工程没有 authoring,归一化必须派生出 开始 + 2 个步骤 + 结束
- const flowNodes = page.locator('.flow-node')
- await expect(flowNodes).toHaveCount(4)
- await expect(page.locator('.flow-node.type-start')).toHaveCount(1)
- await expect(page.locator('.flow-node.type-end')).toHaveCount(1)
- await expect(page.locator('.flow-edges line')).toHaveCount(3)
- // 派生的线性流程全部可达,不应出现不可达标记
- await expect(page.locator('.flow-node.unreachable')).toHaveCount(0)
-
- // 时间轴按步骤派生,讲解音频列按 assetCode 引用
- const timelineRows = page.locator('.system-panel > .el-table tbody tr')
- await expect(timelineRows).toHaveCount(2)
- await expect(page.getByRole('columnheader', { name: '讲解资源编码' })).toBeVisible()
- await expect(timelineRows.first().locator('.el-select')).toHaveCount(1)
-
- // 新增条件分支节点并连线,节点数与连线数同步增长
- await page.getByRole('button', { name: '条件分支' }).click()
- await expect(flowNodes).toHaveCount(5)
- await expect(page.locator('.flow-node.active')).toContainText('条件分支')
- await expect(page.locator('.flow-detail')).toContainText('判定表达式')
- // 新节点尚未连线,必须被标成不可达
- await expect(page.locator('.flow-node.unreachable')).toHaveCount(1)
-
- // 两个 select 的 popper 都留在 DOM 里,按文本取会命中已关闭的那个,
- // 改用键盘在当前打开的下拉里定位:选项顺序就是 authoring.nodes 顺序
- const selects = page.locator('.flow-edge-form .el-select')
- const pickOption = async (index: number, ordinal: number) => {
- await selects.nth(index).click()
- for (let i = 0; i < ordinal; i++) await page.keyboard.press('ArrowDown')
- await page.keyboard.press('Enter')
- }
- await pickOption(0, 1) // 训练开始
- await pickOption(1, 5) // 条件分支 1
- await page.getByRole('button', { name: '连线' }).click()
- await expect(page.locator('.flow-edges line')).toHaveCount(4)
- await expect(page.locator('.flow-node.unreachable')).toHaveCount(0)
- await expect(page.locator('.studio-dirty')).toBeVisible()
-
- // 保存后高级编排必须回写到 content.authoring
- await page.getByRole('button', { name: /^保存$/ }).click()
- await expect(page.locator('.studio-dirty')).toHaveCount(0)
- const authoring = mock.savedContent().authoring as Json
- expect(String(authoring.schema)).toBe('unreal-tran.training-authoring/v1')
- expect((authoring.nodes as Json[]).length).toBe(5)
- expect((authoring.edges as Json[]).length).toBe(4)
- const tracks = (authoring.timeline as Json).tracks as Record<string, Json>
- expect(Object.keys(tracks)).toHaveLength(2)
- await page.screenshot({ path: path.join(SHOTS, '08-高级编排.png'), fullPage: true })
- })
-
- test('九个流程视图与工具、故障、评分视图均可渲染', async ({ page }) => {
- const mock = new TrainingMock()
- await mock.install(page)
- const pageErrors: string[] = []
- page.on('pageerror', (error) => pageErrors.push(error.message))
-
- await page.goto('/content/training-projects/901/studio?view=overview')
- const navButtons = page.locator('.training-flow-group button')
- await expect(navButtons).toHaveCount(9)
-
- for (const [view, marker] of [
- ['overview', '科目与形态'],
- ['scene', '场景与目标'],
- ['steps', '训练步骤'],
- ['flow', '流程与时间轴'],
- ['motion', '工具与动画'],
- ['rules', '判定规则'],
- ['faults', '故障注入'],
- ['scoring', '评分与运行'],
- ['preflight', '发布校验'],
- ] as const) {
- await page.goto(`/content/training-projects/901/studio?view=${view}`)
- await expect(page.locator('.studio-main')).toContainText(marker)
- }
-
- // 故障与工装新增后能被步骤引用
- await page.goto('/content/training-projects/901/studio?view=faults')
- await page.getByRole('button', { name: '新增故障' }).click()
- await expect(page.locator('.single-panel tbody tr')).toHaveCount(1)
- await page.goto('/content/training-projects/901/studio?view=motion')
- await page.getByRole('button', { name: '新增工装' }).click()
- // 工装名称落在 input 的 value 上,textContent 取不到
- await expect(page.locator('.single-panel tbody input').nth(1)).toHaveValue('工装 1')
- await page.screenshot({ path: path.join(SHOTS, '07-工具与故障.png'), fullPage: true })
-
- expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([])
- })
- })
|