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 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 expect(Object.keys(tracks)).toHaveLength(2) await page.screenshot({ path: path.join(SHOTS, '08-高级编排.png'), fullPage: true }) }) test('训练目标支持 Enter 换行并按非空行保存', async ({ page }) => { const mock = new TrainingMock() await mock.install(page) await page.goto('/content/training-projects/901/studio?view=overview') const objectives = page.locator('.el-form-item', { hasText: '训练目标(每行一条)' }).locator('textarea') await expect(objectives).toBeVisible() await objectives.fill('完成停机卸压') await objectives.press('Enter') await objectives.type('采集故障基线') await expect(objectives).toHaveValue('完成停机卸压\n采集故障基线') await page.locator('.content-action-bar').getByRole('button', { name: '保存', exact: true }).click() await expect(page.locator('.el-message--success').filter({ hasText: '训练编排已保存' })).toBeVisible() expect(mock.savedContent().objectives).toEqual(['完成停机卸压', '采集故障基线']) }) 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([]) }) })