No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

438 líneas
23 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.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
  97. await page.addInitScript(() => {
  98. sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token')
  99. })
  100. await page.route('**/api/auth/v1/auth/me', (route) => ok(route, {
  101. user: { id: '1', username: 'review.teacher', displayName: '回归测试教员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
  102. activeRoleId: 'role-teacher',
  103. roles: [{ id: 'role-teacher', code: 'teacher', name: '教员', shortName: '教', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL' }],
  104. permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'],
  105. authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200,
  106. }))
  107. await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, []))
  108. await page.route('**/api/tran/v1/content/**', (route) => this.handle(route))
  109. }
  110. private async handle(route: Route) {
  111. const request = route.request()
  112. const url = new URL(request.url())
  113. const target = url.pathname.replace(/^.*\/v1\/content/, '')
  114. const method = request.method()
  115. let body: Json = {}
  116. try {
  117. const text = request.postData()
  118. if (text) body = JSON.parse(text) as Json
  119. } catch { body = {} }
  120. this.requests.push({ method, path: target, body })
  121. if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()]))
  122. if (method === 'GET' && target === '/projects/summary') {
  123. return ok(route, { total: 1, draft: 1, review: 0, published: 0, byType: [], byTrainingMode: [] })
  124. }
  125. if (method === 'GET' && target === '/catalog') return ok(route, pageOf([]))
  126. if (method === 'GET' && target === '/projects/901') return ok(route, this.detail())
  127. if (method === 'PUT' && target === '/projects/901') {
  128. if (body.content && typeof body.content === 'object') this.content = body.content as Json
  129. this.version += 1
  130. return ok(route, this.detail())
  131. }
  132. if (method === 'GET' && target === '/projects/901/versions') return ok(route, pageOf([this.detail().currentVersion]))
  133. if (method === 'GET' && target === '/projects/901/versions/31') return ok(route, this.detail().currentVersion)
  134. if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [])
  135. // 绑定场景的已发布版本,工作台据此解析可操作目标
  136. if (method === 'GET' && target === '/projects/701/versions/25') {
  137. return ok(route, {
  138. id: '25', projectId: '701', versionNo: 3, versionCode: 'V1.2.0', status: 'PUBLISHED',
  139. content: sceneContent(), dependencies: [], assets: [],
  140. createdByName: '内容作者', publishedByName: '内容作者', publishedTime: 1786387200,
  141. addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
  142. })
  143. }
  144. if (method === 'POST' && target === '/projects/901/metrics') return ok(route, {})
  145. return fail(route, 404, `未打桩的内容接口 ${method} ${target}`)
  146. }
  147. /** 最后一次保存提交的 content,用于断言工作台真正回写了什么。 */
  148. savedContent(): Json {
  149. const last = [...this.requests].reverse().find((item) => item.method === 'PUT')
  150. return (last?.body.content ?? {}) as Json
  151. }
  152. savedSteps(): Json[] {
  153. const content = this.savedContent()
  154. return Array.isArray(content.steps) ? content.steps as Json[] : []
  155. }
  156. }
  157. const summaryValue = (page: Page, label: string) => (
  158. page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText()
  159. )
  160. test.describe('训练编排工作台', () => {
  161. test('绑定场景解析目标、步骤编排与保存产出运行时编码', async ({ page }) => {
  162. const mock = new TrainingMock()
  163. await mock.install(page)
  164. const pageErrors: string[] = []
  165. page.on('pageerror', (error) => pageErrors.push(error.message))
  166. // 1. 场景与目标:只有可操作且未删除的对象进入目标清单
  167. await page.goto('/content/training-projects/901/studio?view=scene')
  168. await expect(page.locator('.training-flow-panel')).toBeVisible()
  169. await expect(page.locator('.scene-facts')).toContainText('支腿检修车间')
  170. await expect(page.locator('.scene-facts')).toContainText('2 个')
  171. const targetRows = page.locator('.single-panel tbody tr')
  172. await expect(targetRows).toHaveCount(2)
  173. await expect(page.locator('.single-panel tbody')).toContainText('cylinder-main')
  174. await expect(page.locator('.single-panel tbody')).toContainText('seal-ring')
  175. await expect(page.locator('.single-panel tbody')).not.toContainText('车间地面')
  176. await expect(page.locator('.single-panel tbody')).not.toContainText('历史对象')
  177. await page.screenshot({ path: path.join(SHOTS, '01-场景与目标.png'), fullPage: true })
  178. // 2. 训练步骤:历史工程的两步已被补上派生编码
  179. await page.goto('/content/training-projects/901/studio?view=steps')
  180. const stepRows = page.locator('.step-row')
  181. await expect(stepRows).toHaveCount(2)
  182. await expect(stepRows.first()).toContainText('VIRTUAL-S01')
  183. await expect(stepRows.nth(1)).toContainText('VIRTUAL-S02')
  184. // 3. 选中首步,关联目标;密封圈只支持拆卸/装配/检查,不出现测量
  185. await stepRows.first().click()
  186. await expect(page.locator('.inspector-panel')).toContainText('目标与动作')
  187. const targetSelect = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first()
  188. await targetSelect.click()
  189. await page.locator('.el-select-dropdown__item', { hasText: 'cylinder-main' }).first().click()
  190. await expect(page.locator('.step-row').first()).toContainText('支腿液压缸总成')
  191. await expect(page.locator('.studio-dirty')).toBeVisible()
  192. await stepRows.nth(1).click()
  193. const secondTarget = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first()
  194. await secondTarget.click()
  195. await page.locator('.el-select-dropdown__item', { hasText: 'seal-ring' }).first().click()
  196. const modeSelect = page.locator('.inspector-panel .el-form-item', { hasText: '操作模式' }).locator('.el-select').first()
  197. await modeSelect.click()
  198. const modeOptions = page.locator('.el-select-dropdown:visible .el-select-dropdown__item')
  199. // 密封圈的 capabilities 只有 select + move,所以没有「测量」这一项
  200. await expect(modeOptions).toHaveCount(3)
  201. await expect(modeOptions.filter({ hasText: '测量' })).toHaveCount(0)
  202. await modeOptions.filter({ hasText: '拆卸' }).first().click()
  203. await expect(page.locator('.inspector-panel .el-form-item', { hasText: '动作类型' }).locator('input')).toHaveValue('三维拖拽')
  204. await page.screenshot({ path: path.join(SHOTS, '02-步骤编排.png'), fullPage: true })
  205. // 4. 新增步骤后顺序与编码连续
  206. await page.getByRole('button', { name: '新增步骤' }).click()
  207. await expect(page.locator('.step-row')).toHaveCount(3)
  208. await expect(page.locator('.step-row').nth(2)).toContainText('VIRTUAL-S03')
  209. // 5. 保存:payload 必须带 stepCode / actionCode,且不再出现超长 uuid 编码
  210. await page.getByRole('button', { name: /^保存$/ }).click()
  211. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  212. const saved = mock.savedSteps()
  213. expect(saved).toHaveLength(3)
  214. expect(saved.map((step) => step.stepCode)).toEqual(['VIRTUAL-S01', 'VIRTUAL-S02', 'VIRTUAL-S03'])
  215. expect(saved.map((step) => step.actionCode)).toEqual(['VIRTUAL-A01', 'VIRTUAL-A02', 'VIRTUAL-A03'])
  216. for (const step of saved) {
  217. // 与服务端 TrainingPreflightValidator / TeachingAssignmentService 的 validRuntimeCode 同一正则
  218. expect(String(step.stepCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)
  219. expect(String(step.actionCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)
  220. }
  221. expect(saved[0]?.targetId).toBe('cylinder-main')
  222. expect(saved[1]?.targetId).toBe('seal-ring')
  223. expect(saved[1]?.mode).toBe('remove')
  224. expect(saved[1]?.interaction).toBe('spatial-drag')
  225. // 6. 发布校验:第三步没有目标,必须阻断并能定位
  226. await page.goto('/content/training-projects/901/studio?view=preflight')
  227. await expect(page.locator('.preflight-summary')).toBeVisible()
  228. expect(Number(await summaryValue(page, '阻断'))).toBeGreaterThan(0)
  229. // 第一个 .issue-list 是阻断项,第二个是提示项
  230. await expect(page.locator('.issue-list').first()).toContainText('尚未关联场景交互对象')
  231. await page.screenshot({ path: path.join(SHOTS, '03-发布校验.png'), fullPage: true })
  232. expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([])
  233. })
  234. test('未固定场景时目标不可选并阻断发布', async ({ page }) => {
  235. const mock = new TrainingMock()
  236. mock.sceneBound = false
  237. await mock.install(page)
  238. await page.goto('/content/training-projects/901/studio?view=scene')
  239. await expect(page.locator('.el-alert')).toContainText('尚未固定必选场景版本')
  240. await page.goto('/content/training-projects/901/studio?view=preflight')
  241. await expect(page.locator('.issue-list').first()).toContainText('发布前必须固定一个必选的已发布场景版本')
  242. await page.screenshot({ path: path.join(SHOTS, '04-未绑定场景.png'), fullPage: true })
  243. })
  244. test('实装形态自动生成严格递增的事件进度', async ({ page }) => {
  245. const mock = new TrainingMock()
  246. mock.mode = 'PHYSICAL'
  247. mock.content = legacyTrainingContent('PHYSICAL')
  248. await mock.install(page)
  249. await page.goto('/content/training-projects/901/studio?view=steps')
  250. await expect(page.locator('.step-row')).toHaveCount(2)
  251. await page.locator('.step-row').first().click()
  252. await expect(page.locator('.inspector-panel')).toContainText('实装事件规则')
  253. await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('50%')
  254. await page.locator('.step-row').nth(1).click()
  255. await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('100%')
  256. await page.getByRole('button', { name: '新增步骤' }).click()
  257. await page.getByRole('button', { name: /^保存$/ }).click()
  258. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  259. const saved = mock.savedSteps()
  260. const progress = saved.map((step) => Number((step.physicalEventRule as Json).progressPercent))
  261. expect(progress).toEqual([33, 67, 100])
  262. for (const step of saved) {
  263. const rule = step.physicalEventRule as Json
  264. expect(['DEVICE', 'VISION', 'MANUAL']).toContain(String(rule.source))
  265. expect(String(rule.eventType)).toMatch(/^[A-Za-z][A-Za-z0-9._-]{1,99}$/)
  266. }
  267. await page.screenshot({ path: path.join(SHOTS, '05-实装进度.png'), fullPage: true })
  268. })
  269. test('对抗形态校验每队人数、岗位与可提交岗位', async ({ page }) => {
  270. const mock = new TrainingMock()
  271. mock.mode = 'CONFRONTATION'
  272. mock.content = legacyTrainingContent('CONFRONTATION')
  273. // Missing fields in old documents now use compatibility defaults; an explicit empty
  274. // grant must stay empty and be blocked by publish validation.
  275. for (const step of mock.content.steps as Json[]) step.allowedPositions = []
  276. await mock.install(page)
  277. await page.goto('/content/training-projects/901/studio?view=overview')
  278. await expect(page.locator('.single-panel')).toContainText('每队人数')
  279. await expect(page.locator('.single-panel')).toContainText('可提交结果的岗位')
  280. await page.goto('/content/training-projects/901/studio?view=steps')
  281. await page.locator('.step-row').first().click()
  282. await expect(page.locator('.inspector-panel')).toContainText('执行岗位')
  283. // 明确清空对抗步骤岗位后,发布校验必须逐步骤阻断,不能恢复默认权限。
  284. await page.goto('/content/training-projects/901/studio?view=preflight')
  285. await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位')
  286. await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true })
  287. })
  288. test('高级编排从线性流程派生并能加分支节点与时间轴', async ({ page }) => {
  289. const mock = new TrainingMock()
  290. await mock.install(page)
  291. await page.goto('/content/training-projects/901/studio?view=flow')
  292. // 内容制作页面已去掉 SystemPageHeader,视图名称由各视图面板自己的标题承担
  293. await expect(page.locator('.studio-main')).toContainText('流程与时间轴')
  294. await expect(page.locator('button', { hasText: '上传音频' })).toBeVisible()
  295. // 历史工程没有 authoring,归一化必须派生出 开始 + 2 个步骤 + 结束
  296. const flowNodes = page.locator('.flow-node')
  297. await expect(flowNodes).toHaveCount(4)
  298. await expect(page.locator('.flow-node.type-start')).toHaveCount(1)
  299. await expect(page.locator('.flow-node.type-end')).toHaveCount(1)
  300. await expect(page.locator('.flow-edges line')).toHaveCount(3)
  301. // 派生的线性流程全部可达,不应出现不可达标记
  302. await expect(page.locator('.flow-node.unreachable')).toHaveCount(0)
  303. // 时间轴按步骤派生,讲解音频列按 assetCode 引用
  304. const timelineRows = page.locator('.system-panel > .el-table tbody tr')
  305. await expect(timelineRows).toHaveCount(2)
  306. await expect(page.getByRole('columnheader', { name: '讲解资源编码' })).toBeVisible()
  307. await expect(timelineRows.first().locator('.el-select')).toHaveCount(1)
  308. // 新增条件分支节点并连线,节点数与连线数同步增长
  309. await page.getByRole('button', { name: '条件分支' }).click()
  310. await expect(flowNodes).toHaveCount(5)
  311. await expect(page.locator('.flow-node.active')).toContainText('条件分支')
  312. await expect(page.locator('.flow-detail')).toContainText('判定表达式')
  313. // 新节点尚未连线,必须被标成不可达
  314. await expect(page.locator('.flow-node.unreachable')).toHaveCount(1)
  315. // 两个 select 的 popper 都留在 DOM 里,按文本取会命中已关闭的那个,
  316. // 改用键盘在当前打开的下拉里定位:选项顺序就是 authoring.nodes 顺序
  317. const selects = page.locator('.flow-edge-form .el-select')
  318. const pickOption = async (index: number, ordinal: number) => {
  319. await selects.nth(index).click()
  320. for (let i = 0; i < ordinal; i++) await page.keyboard.press('ArrowDown')
  321. await page.keyboard.press('Enter')
  322. }
  323. await pickOption(0, 1) // 训练开始
  324. await pickOption(1, 5) // 条件分支 1
  325. await page.getByRole('button', { name: '连线' }).click()
  326. await expect(page.locator('.flow-edges line')).toHaveCount(4)
  327. await expect(page.locator('.flow-node.unreachable')).toHaveCount(0)
  328. await expect(page.locator('.studio-dirty')).toBeVisible()
  329. // 保存后高级编排必须回写到 content.authoring
  330. await page.getByRole('button', { name: /^保存$/ }).click()
  331. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  332. const authoring = mock.savedContent().authoring as Json
  333. expect(String(authoring.schema)).toBe('unreal-tran.training-authoring/v1')
  334. expect((authoring.nodes as Json[]).length).toBe(5)
  335. expect((authoring.edges as Json[]).length).toBe(4)
  336. const tracks = (authoring.timeline as Json).tracks as Record<string, Json>
  337. expect(Object.keys(tracks)).toHaveLength(2)
  338. await page.screenshot({ path: path.join(SHOTS, '08-高级编排.png'), fullPage: true })
  339. })
  340. test('训练目标支持 Enter 换行并按非空行保存', async ({ page }) => {
  341. const mock = new TrainingMock()
  342. await mock.install(page)
  343. await page.goto('/content/training-projects/901/studio?view=overview')
  344. const objectives = page.locator('.el-form-item', { hasText: '训练目标(每行一条)' }).locator('textarea')
  345. await expect(objectives).toBeVisible()
  346. await objectives.fill('完成停机卸压')
  347. await objectives.press('Enter')
  348. await objectives.type('采集故障基线')
  349. await expect(objectives).toHaveValue('完成停机卸压\n采集故障基线')
  350. await page.locator('.content-action-bar').getByRole('button', { name: '保存', exact: true }).click()
  351. await expect(page.locator('.el-message--success').filter({ hasText: '训练编排已保存' })).toBeVisible()
  352. expect(mock.savedContent().objectives).toEqual(['完成停机卸压', '采集故障基线'])
  353. })
  354. test('九个流程视图与工具、故障、评分视图均可渲染', async ({ page }) => {
  355. const mock = new TrainingMock()
  356. await mock.install(page)
  357. const pageErrors: string[] = []
  358. page.on('pageerror', (error) => pageErrors.push(error.message))
  359. await page.goto('/content/training-projects/901/studio?view=overview')
  360. const navButtons = page.locator('.training-flow-group button')
  361. await expect(navButtons).toHaveCount(9)
  362. for (const [view, marker] of [
  363. ['overview', '科目与形态'],
  364. ['scene', '场景与目标'],
  365. ['steps', '训练步骤'],
  366. ['flow', '流程与时间轴'],
  367. ['motion', '工具与动画'],
  368. ['rules', '判定规则'],
  369. ['faults', '故障注入'],
  370. ['scoring', '评分与运行'],
  371. ['preflight', '发布校验'],
  372. ] as const) {
  373. await page.goto(`/content/training-projects/901/studio?view=${view}`)
  374. await expect(page.locator('.studio-main')).toContainText(marker)
  375. }
  376. // 故障与工装新增后能被步骤引用
  377. await page.goto('/content/training-projects/901/studio?view=faults')
  378. await page.getByRole('button', { name: '新增故障' }).click()
  379. await expect(page.locator('.single-panel tbody tr')).toHaveCount(1)
  380. await page.goto('/content/training-projects/901/studio?view=motion')
  381. await page.getByRole('button', { name: '新增工装' }).click()
  382. // 工装名称落在 input 的 value 上,textContent 取不到
  383. await expect(page.locator('.single-panel tbody input').nth(1)).toHaveValue('工装 1')
  384. await page.screenshot({ path: path.join(SHOTS, '07-工具与故障.png'), fullPage: true })
  385. expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([])
  386. })
  387. })