Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 

417 rader
22 KiB

  1. import { expect, test, type Page, type Route } from '@playwright/test'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. /**
  5. * 训练编排工作台回归:科目与形态 → 场景与目标 → 训练步骤 → 判定规则 → 故障注入 → 评分与运行 → 发布校验。
  6. *
  7. * 内容制作接口按 wiki/11-内容制作模块.md 的契约打桩(与 content-guide-lifecycle 同一做法)。
  8. * 重点验证两件容易回归的事:步骤目标只能来自绑定场景的可操作对象;
  9. * 保存时必须产出 stepCode / actionCode / physicalEventRule.progressPercent,
  10. * 否则训练项目发布成教学任务时会被服务端 validRuntimeCode 拒绝。
  11. */
  12. type Json = Record<string, unknown>
  13. const SHOTS = path.resolve('artifacts/content-training-studio')
  14. fs.mkdirSync(SHOTS, { recursive: true })
  15. const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-training' })
  16. const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  17. const fail = (route: Route, status: number, message: string) => route.fulfill({
  18. status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-training' }),
  19. })
  20. const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 })
  21. /** 绑定场景:两个可操作对象、一个环境设施、一个已软删除对象。 */
  22. const sceneContent = (): Json => ({
  23. schema: 'unreal-tran.scene',
  24. version: '2.0',
  25. objects: [
  26. {
  27. id: 'o1', name: '支腿液压缸总成', visible: true, deleted: false,
  28. semantic: { category: '液压执行机构', deviceId: 'DEV-01', interactionId: 'cylinder-main', operable: true, capabilities: ['select', 'move', 'inspect', 'measure'], tags: [] },
  29. },
  30. {
  31. id: 'o2', name: '活塞密封圈', visible: true, deleted: false,
  32. semantic: { category: '密封件', deviceId: 'DEV-02', interactionId: 'seal-ring', operable: true, capabilities: ['select', 'move'], tags: [] },
  33. },
  34. {
  35. id: 'o3', name: '车间地面', visible: true, deleted: false,
  36. semantic: { category: '环境设施', deviceId: '', interactionId: '', operable: false, capabilities: [], tags: [] },
  37. },
  38. {
  39. id: 'o4', name: '历史对象', visible: true, deleted: true,
  40. semantic: { category: '液压', deviceId: 'DEV-04', interactionId: 'ghost', operable: true, capabilities: ['select'], tags: [] },
  41. },
  42. ],
  43. })
  44. const legacyTrainingContent = (mode: string): Json => ({
  45. level: '中级',
  46. durationMinutes: 25,
  47. totalScore: 100,
  48. modeConfig: mode === 'CONFRONTATION'
  49. ? { type: 'CONFRONTATION', redTeamName: '红方', blueTeamName: '蓝方', teamSize: 3, roundDurationMinutes: 30, submitPositions: ['COMMANDER'], objective: '限时排除故障', winRule: '' }
  50. : { type: mode },
  51. // 历史工程只有 title/summary/score,缺 stepCode/actionCode/目标,归一化后必须补齐编码。
  52. steps: [
  53. { id: 'step_1c9f0e2a-8f11-4c0b-9d6e-77a1f3b5c9d0', title: '检查缸体外观', summary: '目视检查缸筒是否存在划痕', score: 40 },
  54. { id: 'step_2d8e1f3b-7a22-4b1c-8e5f-66b2e4c6d8e1', title: '拆卸密封圈', summary: '按对角顺序拆除固定螺栓', score: 60 },
  55. ],
  56. })
  57. class TrainingMock {
  58. readonly requests: Array<{ method: string; path: string; body: Json }> = []
  59. mode = 'VIRTUAL'
  60. content: Json = legacyTrainingContent('VIRTUAL')
  61. version = 2
  62. sceneBound = true
  63. private dependencies() {
  64. return this.sceneBound
  65. ? [{
  66. targetProjectId: '701', targetVersionId: '25', relationType: 'TRAINING_SCENE', required: true,
  67. targetType: 'SCENE', targetCode: 'SC-2026-014', targetName: '支腿检修车间', targetVersionNumber: 3,
  68. }]
  69. : []
  70. }
  71. private project() {
  72. return {
  73. id: '901', type: 'TRAINING', code: 'TR-2026-071', name: '支腿液压缸中修训练',
  74. description: '整机支腿液压缸中修标准训练', categoryCode: 'MEDIUM_REPAIR', coverUri: '',
  75. status: 'DRAFT', trainingMode: this.mode, version: this.version,
  76. currentVersionId: '31', currentVersionNo: 2, currentVersionCode: 'V1.1.0', currentVersionStatus: 'DRAFT',
  77. publishedVersionId: null, ownerUserId: '7', ownerName: '内容作者',
  78. departmentId: '20', departmentName: '维修保障中心',
  79. addTime: 1786387200, updateTime: 1786387200, publishedTime: null,
  80. usageCount: 0, readCount: 0, downloadCount: 0,
  81. dependencyCount: this.dependencies().length, assetCount: 0,
  82. }
  83. }
  84. private detail() {
  85. return {
  86. project: this.project(),
  87. currentVersion: {
  88. id: '31', projectId: '901', versionNo: 2, versionCode: 'V1.1.0', status: 'DRAFT',
  89. content: this.content, dependencies: this.dependencies(), assets: [],
  90. createdByName: '内容作者', publishedByName: '', publishedTime: null,
  91. addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
  92. },
  93. }
  94. }
  95. async install(page: Page) {
  96. await page.addInitScript(() => {
  97. sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token')
  98. })
  99. await page.route('**/api/auth/v1/auth/me', (route) => ok(route, {
  100. user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
  101. activeRoleId: 'role-admin',
  102. roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }],
  103. permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'],
  104. authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200,
  105. }))
  106. await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, []))
  107. await page.route('**/api/tran/v1/content/**', (route) => this.handle(route))
  108. }
  109. private async handle(route: Route) {
  110. const request = route.request()
  111. const url = new URL(request.url())
  112. const target = url.pathname.replace(/^.*\/v1\/content/, '')
  113. const method = request.method()
  114. let body: Json = {}
  115. try {
  116. const text = request.postData()
  117. if (text) body = JSON.parse(text) as Json
  118. } catch { body = {} }
  119. this.requests.push({ method, path: target, body })
  120. if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()]))
  121. if (method === 'GET' && target === '/projects/summary') {
  122. return ok(route, { total: 1, draft: 1, review: 0, published: 0, byType: [], byTrainingMode: [] })
  123. }
  124. if (method === 'GET' && target === '/catalog') return ok(route, pageOf([]))
  125. if (method === 'GET' && target === '/projects/901') return ok(route, this.detail())
  126. if (method === 'PUT' && target === '/projects/901') {
  127. if (body.content && typeof body.content === 'object') this.content = body.content as Json
  128. this.version += 1
  129. return ok(route, this.detail())
  130. }
  131. if (method === 'GET' && target === '/projects/901/versions') return ok(route, pageOf([this.detail().currentVersion]))
  132. if (method === 'GET' && target === '/projects/901/versions/31') return ok(route, this.detail().currentVersion)
  133. if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [])
  134. // 绑定场景的已发布版本,工作台据此解析可操作目标
  135. if (method === 'GET' && target === '/projects/701/versions/25') {
  136. return ok(route, {
  137. id: '25', projectId: '701', versionNo: 3, versionCode: 'V1.2.0', status: 'PUBLISHED',
  138. content: sceneContent(), dependencies: [], assets: [],
  139. createdByName: '内容作者', publishedByName: '内容作者', publishedTime: 1786387200,
  140. addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
  141. })
  142. }
  143. if (method === 'POST' && target === '/projects/901/metrics') return ok(route, {})
  144. return fail(route, 404, `未打桩的内容接口 ${method} ${target}`)
  145. }
  146. /** 最后一次保存提交的 content,用于断言工作台真正回写了什么。 */
  147. savedContent(): Json {
  148. const last = [...this.requests].reverse().find((item) => item.method === 'PUT')
  149. return (last?.body.content ?? {}) as Json
  150. }
  151. savedSteps(): Json[] {
  152. const content = this.savedContent()
  153. return Array.isArray(content.steps) ? content.steps as Json[] : []
  154. }
  155. }
  156. const summaryValue = (page: Page, label: string) => (
  157. page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText()
  158. )
  159. test.describe('训练编排工作台', () => {
  160. test('绑定场景解析目标、步骤编排与保存产出运行时编码', async ({ page }) => {
  161. const mock = new TrainingMock()
  162. await mock.install(page)
  163. const pageErrors: string[] = []
  164. page.on('pageerror', (error) => pageErrors.push(error.message))
  165. // 1. 场景与目标:只有可操作且未删除的对象进入目标清单
  166. await page.goto('/content/training-projects/901/studio?view=scene')
  167. await expect(page.locator('.training-flow-panel')).toBeVisible()
  168. await expect(page.locator('.scene-facts')).toContainText('支腿检修车间')
  169. await expect(page.locator('.scene-facts')).toContainText('2 个')
  170. const targetRows = page.locator('.single-panel tbody tr')
  171. await expect(targetRows).toHaveCount(2)
  172. await expect(page.locator('.single-panel tbody')).toContainText('cylinder-main')
  173. await expect(page.locator('.single-panel tbody')).toContainText('seal-ring')
  174. await expect(page.locator('.single-panel tbody')).not.toContainText('车间地面')
  175. await expect(page.locator('.single-panel tbody')).not.toContainText('历史对象')
  176. await page.screenshot({ path: path.join(SHOTS, '01-场景与目标.png'), fullPage: true })
  177. // 2. 训练步骤:历史工程的两步已被补上派生编码
  178. await page.goto('/content/training-projects/901/studio?view=steps')
  179. const stepRows = page.locator('.step-row')
  180. await expect(stepRows).toHaveCount(2)
  181. await expect(stepRows.first()).toContainText('VIRTUAL-S01')
  182. await expect(stepRows.nth(1)).toContainText('VIRTUAL-S02')
  183. // 3. 选中首步,关联目标;密封圈只支持拆卸/装配/检查,不出现测量
  184. await stepRows.first().click()
  185. await expect(page.locator('.inspector-panel')).toContainText('目标与动作')
  186. const targetSelect = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first()
  187. await targetSelect.click()
  188. await page.locator('.el-select-dropdown__item', { hasText: 'cylinder-main' }).first().click()
  189. await expect(page.locator('.step-row').first()).toContainText('支腿液压缸总成')
  190. await expect(page.locator('.studio-dirty')).toBeVisible()
  191. await stepRows.nth(1).click()
  192. const secondTarget = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first()
  193. await secondTarget.click()
  194. await page.locator('.el-select-dropdown__item', { hasText: 'seal-ring' }).first().click()
  195. const modeSelect = page.locator('.inspector-panel .el-form-item', { hasText: '操作模式' }).locator('.el-select').first()
  196. await modeSelect.click()
  197. const modeOptions = page.locator('.el-select-dropdown:visible .el-select-dropdown__item')
  198. // 密封圈的 capabilities 只有 select + move,所以没有「测量」这一项
  199. await expect(modeOptions).toHaveCount(3)
  200. await expect(modeOptions.filter({ hasText: '测量' })).toHaveCount(0)
  201. await modeOptions.filter({ hasText: '拆卸' }).first().click()
  202. await expect(page.locator('.inspector-panel .el-form-item', { hasText: '动作类型' }).locator('input')).toHaveValue('三维拖拽')
  203. await page.screenshot({ path: path.join(SHOTS, '02-步骤编排.png'), fullPage: true })
  204. // 4. 新增步骤后顺序与编码连续
  205. await page.getByRole('button', { name: '新增步骤' }).click()
  206. await expect(page.locator('.step-row')).toHaveCount(3)
  207. await expect(page.locator('.step-row').nth(2)).toContainText('VIRTUAL-S03')
  208. // 5. 保存:payload 必须带 stepCode / actionCode,且不再出现超长 uuid 编码
  209. await page.getByRole('button', { name: /^保存$/ }).click()
  210. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  211. const saved = mock.savedSteps()
  212. expect(saved).toHaveLength(3)
  213. expect(saved.map((step) => step.stepCode)).toEqual(['VIRTUAL-S01', 'VIRTUAL-S02', 'VIRTUAL-S03'])
  214. expect(saved.map((step) => step.actionCode)).toEqual(['VIRTUAL-A01', 'VIRTUAL-A02', 'VIRTUAL-A03'])
  215. for (const step of saved) {
  216. // 与服务端 TrainingPreflightValidator / TeachingAssignmentService 的 validRuntimeCode 同一正则
  217. expect(String(step.stepCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)
  218. expect(String(step.actionCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)
  219. }
  220. expect(saved[0]?.targetId).toBe('cylinder-main')
  221. expect(saved[1]?.targetId).toBe('seal-ring')
  222. expect(saved[1]?.mode).toBe('remove')
  223. expect(saved[1]?.interaction).toBe('spatial-drag')
  224. // 6. 发布校验:第三步没有目标,必须阻断并能定位
  225. await page.goto('/content/training-projects/901/studio?view=preflight')
  226. await expect(page.locator('.preflight-summary')).toBeVisible()
  227. expect(Number(await summaryValue(page, '阻断'))).toBeGreaterThan(0)
  228. // 第一个 .issue-list 是阻断项,第二个是提示项
  229. await expect(page.locator('.issue-list').first()).toContainText('尚未关联场景交互对象')
  230. await page.screenshot({ path: path.join(SHOTS, '03-发布校验.png'), fullPage: true })
  231. expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([])
  232. })
  233. test('未固定场景时目标不可选并阻断发布', async ({ page }) => {
  234. const mock = new TrainingMock()
  235. mock.sceneBound = false
  236. await mock.install(page)
  237. await page.goto('/content/training-projects/901/studio?view=scene')
  238. await expect(page.locator('.el-alert')).toContainText('尚未固定必选场景版本')
  239. await page.goto('/content/training-projects/901/studio?view=preflight')
  240. await expect(page.locator('.issue-list').first()).toContainText('发布前必须固定一个必选的已发布场景版本')
  241. await page.screenshot({ path: path.join(SHOTS, '04-未绑定场景.png'), fullPage: true })
  242. })
  243. test('实装形态自动生成严格递增的事件进度', async ({ page }) => {
  244. const mock = new TrainingMock()
  245. mock.mode = 'PHYSICAL'
  246. mock.content = legacyTrainingContent('PHYSICAL')
  247. await mock.install(page)
  248. await page.goto('/content/training-projects/901/studio?view=steps')
  249. await expect(page.locator('.step-row')).toHaveCount(2)
  250. await page.locator('.step-row').first().click()
  251. await expect(page.locator('.inspector-panel')).toContainText('实装事件规则')
  252. await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('50%')
  253. await page.locator('.step-row').nth(1).click()
  254. await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('100%')
  255. await page.getByRole('button', { name: '新增步骤' }).click()
  256. await page.getByRole('button', { name: /^保存$/ }).click()
  257. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  258. const saved = mock.savedSteps()
  259. const progress = saved.map((step) => Number((step.physicalEventRule as Json).progressPercent))
  260. expect(progress).toEqual([33, 67, 100])
  261. for (const step of saved) {
  262. const rule = step.physicalEventRule as Json
  263. expect(['DEVICE', 'VISION', 'MANUAL']).toContain(String(rule.source))
  264. expect(String(rule.eventType)).toMatch(/^[A-Za-z][A-Za-z0-9._-]{1,99}$/)
  265. }
  266. await page.screenshot({ path: path.join(SHOTS, '05-实装进度.png'), fullPage: true })
  267. })
  268. test('对抗形态校验每队人数、岗位与可提交岗位', async ({ page }) => {
  269. const mock = new TrainingMock()
  270. mock.mode = 'CONFRONTATION'
  271. mock.content = legacyTrainingContent('CONFRONTATION')
  272. await mock.install(page)
  273. await page.goto('/content/training-projects/901/studio?view=overview')
  274. await expect(page.locator('.single-panel')).toContainText('每队人数')
  275. await expect(page.locator('.single-panel')).toContainText('可提交结果的岗位')
  276. await page.goto('/content/training-projects/901/studio?view=steps')
  277. await page.locator('.step-row').first().click()
  278. await expect(page.locator('.inspector-panel')).toContainText('执行岗位')
  279. // 历史工程的对抗步骤没有岗位,发布校验必须逐步骤阻断
  280. await page.goto('/content/training-projects/901/studio?view=preflight')
  281. await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位')
  282. await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true })
  283. })
  284. test('高级编排从线性流程派生并能加分支节点与时间轴', async ({ page }) => {
  285. const mock = new TrainingMock()
  286. await mock.install(page)
  287. await page.goto('/content/training-projects/901/studio?view=flow')
  288. // 内容制作页面已去掉 SystemPageHeader,视图名称由各视图面板自己的标题承担
  289. await expect(page.locator('.studio-main')).toContainText('流程与时间轴')
  290. await expect(page.locator('button', { hasText: '上传音频' })).toBeVisible()
  291. // 历史工程没有 authoring,归一化必须派生出 开始 + 2 个步骤 + 结束
  292. const flowNodes = page.locator('.flow-node')
  293. await expect(flowNodes).toHaveCount(4)
  294. await expect(page.locator('.flow-node.type-start')).toHaveCount(1)
  295. await expect(page.locator('.flow-node.type-end')).toHaveCount(1)
  296. await expect(page.locator('.flow-edges line')).toHaveCount(3)
  297. // 派生的线性流程全部可达,不应出现不可达标记
  298. await expect(page.locator('.flow-node.unreachable')).toHaveCount(0)
  299. // 时间轴按步骤派生,讲解音频列按 assetCode 引用
  300. const timelineRows = page.locator('.system-panel > .el-table tbody tr')
  301. await expect(timelineRows).toHaveCount(2)
  302. await expect(page.getByRole('columnheader', { name: '讲解资源编码' })).toBeVisible()
  303. await expect(timelineRows.first().locator('.el-select')).toHaveCount(1)
  304. // 新增条件分支节点并连线,节点数与连线数同步增长
  305. await page.getByRole('button', { name: '条件分支' }).click()
  306. await expect(flowNodes).toHaveCount(5)
  307. await expect(page.locator('.flow-node.active')).toContainText('条件分支')
  308. await expect(page.locator('.flow-detail')).toContainText('判定表达式')
  309. // 新节点尚未连线,必须被标成不可达
  310. await expect(page.locator('.flow-node.unreachable')).toHaveCount(1)
  311. // 两个 select 的 popper 都留在 DOM 里,按文本取会命中已关闭的那个,
  312. // 改用键盘在当前打开的下拉里定位:选项顺序就是 authoring.nodes 顺序
  313. const selects = page.locator('.flow-edge-form .el-select')
  314. const pickOption = async (index: number, ordinal: number) => {
  315. await selects.nth(index).click()
  316. for (let i = 0; i < ordinal; i++) await page.keyboard.press('ArrowDown')
  317. await page.keyboard.press('Enter')
  318. }
  319. await pickOption(0, 1) // 训练开始
  320. await pickOption(1, 5) // 条件分支 1
  321. await page.getByRole('button', { name: '连线' }).click()
  322. await expect(page.locator('.flow-edges line')).toHaveCount(4)
  323. await expect(page.locator('.flow-node.unreachable')).toHaveCount(0)
  324. await expect(page.locator('.studio-dirty')).toBeVisible()
  325. // 保存后高级编排必须回写到 content.authoring
  326. await page.getByRole('button', { name: /^保存$/ }).click()
  327. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  328. const authoring = mock.savedContent().authoring as Json
  329. expect(String(authoring.schema)).toBe('unreal-tran.training-authoring/v1')
  330. expect((authoring.nodes as Json[]).length).toBe(5)
  331. expect((authoring.edges as Json[]).length).toBe(4)
  332. const tracks = (authoring.timeline as Json).tracks as Record<string, Json>
  333. expect(Object.keys(tracks)).toHaveLength(2)
  334. await page.screenshot({ path: path.join(SHOTS, '08-高级编排.png'), fullPage: true })
  335. })
  336. test('九个流程视图与工具、故障、评分视图均可渲染', async ({ page }) => {
  337. const mock = new TrainingMock()
  338. await mock.install(page)
  339. const pageErrors: string[] = []
  340. page.on('pageerror', (error) => pageErrors.push(error.message))
  341. await page.goto('/content/training-projects/901/studio?view=overview')
  342. const navButtons = page.locator('.training-flow-group button')
  343. await expect(navButtons).toHaveCount(9)
  344. for (const [view, marker] of [
  345. ['overview', '科目与形态'],
  346. ['scene', '场景与目标'],
  347. ['steps', '训练步骤'],
  348. ['flow', '流程与时间轴'],
  349. ['motion', '工具与动画'],
  350. ['rules', '判定规则'],
  351. ['faults', '故障注入'],
  352. ['scoring', '评分与运行'],
  353. ['preflight', '发布校验'],
  354. ] as const) {
  355. await page.goto(`/content/training-projects/901/studio?view=${view}`)
  356. await expect(page.locator('.studio-main')).toContainText(marker)
  357. }
  358. // 故障与工装新增后能被步骤引用
  359. await page.goto('/content/training-projects/901/studio?view=faults')
  360. await page.getByRole('button', { name: '新增故障' }).click()
  361. await expect(page.locator('.single-panel tbody tr')).toHaveCount(1)
  362. await page.goto('/content/training-projects/901/studio?view=motion')
  363. await page.getByRole('button', { name: '新增工装' }).click()
  364. // 工装名称落在 input 的 value 上,textContent 取不到
  365. await expect(page.locator('.single-panel tbody input').nth(1)).toHaveValue('工装 1')
  366. await page.screenshot({ path: path.join(SHOTS, '07-工具与故障.png'), fullPage: true })
  367. expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([])
  368. })
  369. })