選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

469 行
26 KiB

  1. import { expect, test, type Page, type Route } from '@playwright/test'
  2. import { TeachingContractMock } from './teaching-contract-mock'
  3. import fs from 'node:fs'
  4. import path from 'node:path'
  5. /** Browser regressions for the 2026-09-05 review. All API responses are mocked.
  6. * These verify real Vue UI interactions and outgoing payloads, not database persistence.
  7. * TeachingRunView channel cases use its formal virtual route as a component host;
  8. * they do not claim that the default PHYSICAL legacy replay is a live API workflow.
  9. */
  10. type Json = Record<string, unknown>
  11. const SHOTS = path.resolve('reports/review-20260905/frontend-e2e/screenshots')
  12. fs.mkdirSync(SHOTS, { recursive: true })
  13. const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-training' })
  14. const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  15. const fail = (route: Route, status: number, message: string) => route.fulfill({
  16. status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-training' }),
  17. })
  18. const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 })
  19. /** 绑定场景:两个可操作对象、一个环境设施、一个已软删除对象。 */
  20. const sceneContent = (): Json => ({
  21. schema: 'unreal-tran.scene',
  22. version: '2.0',
  23. objects: [
  24. {
  25. id: 'o1', name: '支腿液压缸总成', visible: true, deleted: false,
  26. semantic: { category: '液压执行机构', deviceId: 'DEV-01', interactionId: 'cylinder-main', operable: true, capabilities: ['select', 'move', 'inspect', 'measure'], tags: [] },
  27. },
  28. {
  29. id: 'o2', name: '活塞密封圈', visible: true, deleted: false,
  30. semantic: { category: '密封件', deviceId: 'DEV-02', interactionId: 'seal-ring', operable: true, capabilities: ['select', 'move'], tags: [] },
  31. },
  32. {
  33. id: 'o3', name: '车间地面', visible: true, deleted: false,
  34. semantic: { category: '环境设施', deviceId: '', interactionId: '', operable: false, capabilities: [], tags: [] },
  35. },
  36. {
  37. id: 'o4', name: '历史对象', visible: true, deleted: true,
  38. semantic: { category: '液压', deviceId: 'DEV-04', interactionId: 'ghost', operable: true, capabilities: ['select'], tags: [] },
  39. },
  40. ],
  41. })
  42. const legacyTrainingContent = (mode: string): Json => ({
  43. level: '中级',
  44. durationMinutes: 25,
  45. totalScore: 100,
  46. modeConfig: mode === 'CONFRONTATION'
  47. ? { type: 'CONFRONTATION', redTeamName: '红方', blueTeamName: '蓝方', teamSize: 3, roundDurationMinutes: 30, submitPositions: ['COMMANDER'], objective: '限时排除故障', winRule: '' }
  48. : { type: mode },
  49. // 历史工程只有 title/summary/score,缺 stepCode/actionCode/目标,归一化后必须补齐编码。
  50. steps: [
  51. { id: 'step_1c9f0e2a-8f11-4c0b-9d6e-77a1f3b5c9d0', title: '检查缸体外观', summary: '目视检查缸筒是否存在划痕', score: 40 },
  52. { id: 'step_2d8e1f3b-7a22-4b1c-8e5f-66b2e4c6d8e1', title: '拆卸密封圈', summary: '按对角顺序拆除固定螺栓', score: 60 },
  53. ],
  54. })
  55. class TrainingMock {
  56. readonly requests: Array<{ method: string; path: string; body: Json }> = []
  57. mode = 'VIRTUAL'
  58. content: Json = legacyTrainingContent('VIRTUAL')
  59. version = 2
  60. sceneBound = true
  61. private dependencies() {
  62. return this.sceneBound
  63. ? [{
  64. targetProjectId: '701', targetVersionId: '25', relationType: 'TRAINING_SCENE', required: true,
  65. targetType: 'SCENE', targetCode: 'SC-2026-014', targetName: '支腿检修车间', targetVersionNumber: 3,
  66. }]
  67. : []
  68. }
  69. private project() {
  70. return {
  71. id: '901', type: 'TRAINING', code: 'TR-2026-071', name: '支腿液压缸中修训练',
  72. description: '整机支腿液压缸中修标准训练', categoryCode: 'MEDIUM_REPAIR', coverUri: '',
  73. status: 'DRAFT', trainingMode: this.mode, version: this.version,
  74. currentVersionId: '31', currentVersionNo: 2, currentVersionCode: 'V1.1.0', currentVersionStatus: 'DRAFT',
  75. publishedVersionId: null, ownerUserId: '7', ownerName: '内容作者',
  76. departmentId: '20', departmentName: '维修保障中心',
  77. addTime: 1786387200, updateTime: 1786387200, publishedTime: null,
  78. usageCount: 0, readCount: 0, downloadCount: 0,
  79. dependencyCount: this.dependencies().length, assetCount: 0,
  80. }
  81. }
  82. private detail() {
  83. return {
  84. project: this.project(),
  85. currentVersion: {
  86. id: '31', projectId: '901', versionNo: 2, versionCode: 'V1.1.0', status: 'DRAFT',
  87. content: this.content, dependencies: this.dependencies(), assets: [],
  88. createdByName: '内容作者', publishedByName: '', publishedTime: null,
  89. addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
  90. },
  91. }
  92. }
  93. async install(page: Page) {
  94. await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
  95. await page.addInitScript(() => {
  96. sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token')
  97. })
  98. await page.route('**/api/auth/v1/auth/me', (route) => ok(route, {
  99. user: { id: '1', username: 'review.teacher', displayName: '回归测试教员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
  100. activeRoleId: 'role-teacher',
  101. roles: [{ id: 'role-teacher', code: 'teacher', name: '教员', shortName: '教', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL' }],
  102. permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'],
  103. authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200,
  104. }))
  105. await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, []))
  106. await page.route('**/api/tran/v1/content/**', (route) => this.handle(route))
  107. }
  108. private async handle(route: Route) {
  109. const request = route.request()
  110. const url = new URL(request.url())
  111. const target = url.pathname.replace(/^.*\/v1\/content/, '')
  112. const method = request.method()
  113. let body: Json = {}
  114. try {
  115. const text = request.postData()
  116. if (text) body = JSON.parse(text) as Json
  117. } catch { body = {} }
  118. this.requests.push({ method, path: target, body })
  119. if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()]))
  120. if (method === 'GET' && target === '/projects/summary') {
  121. return ok(route, { total: 1, draft: 1, review: 0, published: 0, byType: [], byTrainingMode: [] })
  122. }
  123. if (method === 'GET' && target === '/catalog') return ok(route, pageOf([]))
  124. if (method === 'GET' && target === '/projects/901') return ok(route, this.detail())
  125. if (method === 'PUT' && target === '/projects/901') {
  126. if (body.content && typeof body.content === 'object') this.content = body.content as Json
  127. if (typeof body.trainingMode === 'string') this.mode = body.trainingMode
  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 multiChannelContent = (): Json => ({
  157. schema: 'unreal-tran.training/v1', trainingMode: 'VIRTUAL',
  158. enabledChannels: ['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'],
  159. modeConfig: { type: 'VIRTUAL', renderMode: '原有渲染方式' },
  160. channelProfiles: {
  161. VIRTUAL: { type: 'VIRTUAL', renderMode: '原有渲染方式' },
  162. PHYSICAL: { type: 'PHYSICAL', stationName: '保留的实装工位', manualConfirmAllowed: true },
  163. CONFRONTATION: { type: 'CONFRONTATION', teamSize: 6, roundDurationMinutes: 45, submitPositions: ['COMMANDER'], objective: '协同目标应保留' },
  164. },
  165. level: '中级', durationMinutes: 15, totalScore: 100,
  166. steps: [1, 2, 3].map((n) => ({
  167. id: `saved-${n}`, stepCode: `FIXED-S0${n}`, actionCode: `FIXED-A0${n}`, order: n,
  168. title: `已保存步骤${n}`, summary: '浏览器契约回归步骤', targetId: 'cylinder-main',
  169. targetName: '支腿液压缸总成', mode: 'inspect', interaction: 'target-confirm', score: n === 3 ? 34 : 33,
  170. physicalEventRule: { source: 'MANUAL', eventType: `REVIEW_EVENT_0${n}`, progressPercent: [33, 67, 100][n - 1], manualConfirmAllowed: true },
  171. allowedPositions: ['COMMANDER'],
  172. })),
  173. })
  174. const shot = async (page: Page, name: string) => {
  175. const filename = path.join(SHOTS, `${name}.png`)
  176. await page.screenshot({ path: filename, fullPage: true })
  177. await test.info().attach(name, { path: filename, contentType: 'image/png' })
  178. }
  179. const saveStudio = async (page: Page, mock: TrainingMock) => {
  180. const count = mock.requests.filter((item) => item.method === 'PUT').length
  181. await page.locator('.content-action-bar').getByRole('button', { name: '保存', exact: true }).click()
  182. await expect.poll(() => mock.requests.filter((item) => item.method === 'PUT').length).toBe(count + 1)
  183. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  184. }
  185. test.describe('API mock浏览器回归:已保存的实装步骤', () => {
  186. for (const action of ['move', 'delete', 'duplicate'] as const) {
  187. test(`${action}后保存重算顺序和实装进度`, async ({ page }) => {
  188. const mock = new TrainingMock()
  189. // The default remains VIRTUAL; PHYSICAL enabled as a secondary channel must still be correct.
  190. mock.content = multiChannelContent()
  191. await mock.install(page)
  192. await page.goto('/content/training-projects/901/studio?view=steps')
  193. const rows = page.locator('.step-list > li')
  194. await expect(rows).toHaveCount(3)
  195. if (action === 'move') {
  196. await rows.nth(2).locator('.step-row').click()
  197. await rows.nth(2).getByRole('button', { name: '上移', exact: true }).click()
  198. await expect(rows.nth(1)).toContainText('已保存步骤3')
  199. } else if (action === 'delete') {
  200. await rows.nth(2).locator('.step-row').click()
  201. await rows.nth(2).getByRole('button', { name: '删除', exact: true }).click()
  202. await page.getByRole('button', { name: '确认删除', exact: true }).click()
  203. await expect(rows).toHaveCount(2)
  204. } else {
  205. await rows.nth(0).getByRole('button', { name: '复制', exact: true }).click()
  206. await expect(rows).toHaveCount(4)
  207. }
  208. await saveStudio(page, mock)
  209. const saved = mock.savedSteps()
  210. expect(saved.map((step) => step.order)).toEqual(saved.map((_, index) => index + 1))
  211. expect(saved.map((step) => (step.physicalEventRule as Json).progressPercent)).toEqual(
  212. action === 'move' ? [33, 67, 100] : action === 'delete' ? [50, 100] : [25, 50, 75, 100],
  213. )
  214. if (action === 'move') expect(saved.map((step) => step.stepCode)).toEqual(['FIXED-S01', 'FIXED-S03', 'FIXED-S02'])
  215. if (action === 'delete') expect(saved.map((step) => step.stepCode)).toEqual(['FIXED-S01', 'FIXED-S02'])
  216. expect(new Set(saved.map((step) => step.stepCode)).size).toBe(saved.length)
  217. await test.info().attach('保存请求中的训练内容(API mock)', { body: Buffer.from(JSON.stringify(mock.savedContent(), null, 2)), contentType: 'application/json' })
  218. await page.reload()
  219. await expect(rows).toHaveCount(saved.length)
  220. await page.locator('.el-form-item', { hasText: '完成后进度' }).scrollIntoViewIfNeeded()
  221. await shot(page, `01-saved-step-${action}`)
  222. })
  223. }
  224. })
  225. test('API mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置', async ({ page }) => {
  226. const mock = new TrainingMock()
  227. mock.content = multiChannelContent()
  228. const originalProfiles = structuredClone(mock.content.channelProfiles) as Record<string, Json>
  229. await mock.install(page)
  230. await page.goto('/content/training-projects/901/edit')
  231. await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
  232. const renderMode = page.locator('.el-form-item', { hasText: '渲染方式' }).locator('input')
  233. await expect(renderMode).toHaveValue('原有渲染方式')
  234. await renderMode.fill('回归验证后的渲染方式')
  235. const save = async () => {
  236. const count = mock.requests.filter((item) => item.method === 'PUT').length
  237. await page.getByRole('button', { name: '保存草稿', exact: true }).click()
  238. await expect.poll(() => mock.requests.filter((item) => item.method === 'PUT').length).toBe(count + 1)
  239. await expect(page.getByRole('button', { name: '保存草稿', exact: true })).toBeEnabled()
  240. }
  241. await save()
  242. let saved = mock.savedContent()
  243. let profiles = saved.channelProfiles as Record<string, Json>
  244. expect(saved.enabledChannels).toEqual(['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'])
  245. expect(saved.modeConfig).toEqual(profiles.VIRTUAL)
  246. expect(profiles.VIRTUAL!.renderMode).toBe('回归验证后的渲染方式')
  247. // Normalization may add documented defaults; every existing value must survive.
  248. expect(profiles.PHYSICAL).toMatchObject(originalProfiles.PHYSICAL!)
  249. expect(profiles.CONFRONTATION).toMatchObject(originalProfiles.CONFRONTATION!)
  250. await page.reload()
  251. await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
  252. await expect(renderMode).toHaveValue('回归验证后的渲染方式')
  253. await shot(page, '02-multichannel-virtual-save')
  254. await page.getByRole('tab', { name: '基本信息', exact: true }).click()
  255. await page.locator('.el-form-item', { hasText: '训练模式' }).locator('.el-select').click()
  256. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '对抗' }).click()
  257. await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
  258. await expect(page.locator('.el-form-item', { hasText: '每队人数' }).locator('input')).toHaveValue('6')
  259. const roundDuration = page.locator('.el-form-item', { hasText: '单局时长' }).locator('input')
  260. await expect(roundDuration).toHaveValue('45')
  261. await roundDuration.fill('60')
  262. await roundDuration.press('Tab')
  263. await save()
  264. saved = mock.savedContent()
  265. profiles = saved.channelProfiles as Record<string, Json>
  266. expect(saved.trainingMode).toBe('CONFRONTATION')
  267. expect(saved.modeConfig).toEqual(profiles.CONFRONTATION)
  268. expect(profiles.CONFRONTATION!.roundDurationMinutes).toBe(60)
  269. expect(profiles.CONFRONTATION!.teamSize).toBe(6)
  270. expect(profiles.VIRTUAL!.renderMode).toBe('回归验证后的渲染方式')
  271. expect(profiles.PHYSICAL).toMatchObject(originalProfiles.PHYSICAL!)
  272. expect(saved.enabledChannels).toEqual(['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'])
  273. await page.reload()
  274. await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
  275. await expect(roundDuration).toHaveValue('60')
  276. await shot(page, '03-multichannel-confrontation-save')
  277. })
  278. const overlayCases = [
  279. { name: '普通虚拟训练', channel: 'VIRTUAL', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
  280. { name: '普通实装组件', channel: 'PHYSICAL', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
  281. { name: '普通对抗组件', channel: 'CONFRONTATION', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
  282. { name: '进行中正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'IN_PROGRESS', closed: false },
  283. { name: '未开始正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'ACCEPTED', closed: true },
  284. { name: '已提交正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'SUBMITTED', closed: true },
  285. ] as const
  286. test.describe('API mock浏览器回归:失败步骤重试', () => {
  287. for (const status of ['IN_PROGRESS', 'SUBMITTED', 'TERMINATED'] as const) {
  288. test(`运行${status}的失败步骤重试入口`, async ({ page }) => {
  289. await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
  290. const contract = new TeachingContractMock('studentLeader')
  291. const run: Json = contract.seedTerminableRun()
  292. run.status = status
  293. const task = contract.assignments.get('assignment-virtual-common')!
  294. task.name = 'API mock:失败步骤重试'
  295. task.digitalHumanAllowed = false
  296. task.digitalInstructorAgentSlug = ''
  297. ;(task.definitionSnapshot as Json).scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
  298. const facts = [{
  299. id: 'step-fact-1', runId: run.id, assignmentId: task.id, stepCode: 'STEP-01', stepOrder: 1,
  300. targetProgressPercent: 50, statusCode: 'FAILED', attemptCount: 1, failureCount: 1,
  301. lastFailureCode: 'REVIEW_RETRY', evidenceSourceCode: 'DEVICE', lastActorUserId: '4',
  302. lastRunMemberId: 'member-red-leader', lastEventId: 'event-failed', startedAt: 1786387200,
  303. completedAt: null, failedAt: 1786387210, isBackfilled: 0, dataVersion: 1,
  304. }]
  305. await contract.install(page)
  306. await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, facts))
  307. const commands: Json[] = []
  308. await page.route('**/api/tran/v1/teaching/runs/*/start', async (route) => {
  309. const body = route.request().postDataJSON() as Json
  310. commands.push(body)
  311. expect(route.request().headers()['idempotency-key']).toBe(body.commandId)
  312. Object.assign(facts[0]!, { statusCode: 'IN_PROGRESS', attemptCount: 2, dataVersion: 2 })
  313. run.version = Number(run.version) + 1
  314. await ok(route, run)
  315. })
  316. await page.goto(`/teaching/virtual-training/runs/${run.id}`)
  317. await expect(page.locator('.runtime-stage-card')).toBeVisible()
  318. const retry = page.getByRole('button', { name: '重试当前步骤', exact: true })
  319. if (status === 'IN_PROGRESS') {
  320. await expect(retry).toBeVisible()
  321. await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('未通过 1')
  322. await shot(page, '05-failed-before-retry')
  323. await retry.click()
  324. await expect(retry).toHaveCount(0)
  325. expect(commands).toHaveLength(1)
  326. expect(String(commands[0]!.commandId)).toMatch(/^[a-f0-9-]{36}$/i)
  327. await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 2')
  328. await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('未通过 1')
  329. await page.reload()
  330. await expect(retry).toHaveCount(0)
  331. await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 2')
  332. await shot(page, '06-failed-after-retry')
  333. } else {
  334. await expect(retry).toHaveCount(0)
  335. expect(commands).toHaveLength(0)
  336. await shot(page, `07-no-retry-${status}`)
  337. }
  338. })
  339. }
  340. })
  341. test.describe('API mock浏览器回归:执行场景关闭遮罩', () => {
  342. for (const [index, item] of overlayCases.entries()) {
  343. test(item.name, async ({ page }) => {
  344. await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
  345. const contract = new TeachingContractMock('studentLeader')
  346. const task = contract.assignments.get('assignment-exam')!
  347. Object.assign(task, { channel: item.channel, assignmentKind: item.kind, executionMode: item.kind === 'FORMAL_EXAM' ? 'EXAM' : 'PRACTICE', digitalHumanAllowed: false, digitalInstructorAgentSlug: '', name: `API mock:${item.name}` })
  348. const definition = task.definitionSnapshot as Json
  349. // Primitive-only fixture avoids unrelated large GLB downloads and external resources.
  350. definition.scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
  351. const run = contract.runs.get('run-exam')!
  352. Object.assign(run, { status: item.status, progressPercent: item.status === 'SUBMITTED' ? 100 : 0 })
  353. Object.assign(run.assignment as Json, { channel: item.channel, assignmentKind: item.kind, executionMode: task.executionMode, name: task.name, digitalHumanAllowed: false, digitalInstructorAgentSlug: '' })
  354. await contract.install(page)
  355. await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, []))
  356. const errors: string[] = []
  357. page.on('pageerror', (error) => errors.push(error.message))
  358. await page.goto('/teaching/virtual-training/runs/run-exam')
  359. await expect(page.locator('.runtime-stage-card')).toBeVisible()
  360. await expect(page.locator('.runtime-formal-scene-closed')).toHaveCount(item.closed ? 1 : 0)
  361. if (item.closed) {
  362. await expect(page.getByText('正式考核执行场景已关闭', { exact: true })).toBeVisible()
  363. await expect(page.locator('.runtime-scene-window canvas')).toHaveCount(0)
  364. } else {
  365. await expect(page.locator('.runtime-scene-window canvas')).toBeVisible()
  366. }
  367. expect(errors).toEqual([])
  368. await shot(page, `04-overlay-${index + 1}-${item.channel}-${item.status}`)
  369. })
  370. }
  371. })
  372. test.describe('API mock浏览器视觉回归:场景填充与安全确认区', () => {
  373. for (const theme of ['military', 'dark'] as const) {
  374. test(`${theme}主题画布铺满且安全文字可读`, async ({ page }) => {
  375. await page.addInitScript((themeMode) => {
  376. localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ themeMode }))
  377. }, theme)
  378. await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
  379. const contract = new TeachingContractMock('studentLeader')
  380. const run = contract.seedTerminableRun()
  381. const task = contract.assignments.get('assignment-virtual-common')!
  382. task.name = `API mock:${theme}场景与安全确认`
  383. task.digitalHumanAllowed = false
  384. task.digitalInstructorAgentSlug = ''
  385. const definition = task.definitionSnapshot as Json
  386. definition.scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
  387. ;(definition.steps as Json[])[0]!.safetyRules = [
  388. { key: 'safety-1', text: '确认作业区已隔离且无无关人员进入', required: true },
  389. { key: 'safety-2', text: '确认所需防护用品已佩戴并检查完好', required: true },
  390. ]
  391. await contract.install(page)
  392. await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, []))
  393. await page.goto(`/teaching/virtual-training/runs/${run.id}`)
  394. const gate = page.locator('.runtime-safety-gate')
  395. const scene = page.locator('.runtime-scene-window > .teaching-three-scene')
  396. const viewport = page.locator('.runtime-scene-window')
  397. await expect(gate).toBeVisible()
  398. await expect(gate.getByRole('checkbox')).toHaveCount(2)
  399. await expect(page.locator('html')).toHaveAttribute('data-theme', theme === 'dark' ? 'dark' : 'light')
  400. await expect.poll(async () => {
  401. const host = await scene.boundingBox()
  402. const window = await viewport.boundingBox()
  403. return host && window ? Math.abs(host.height - window.height) : 999
  404. }).toBeLessThanOrEqual(2)
  405. const sceneBox = (await scene.boundingBox())!
  406. const viewportBox = (await viewport.boundingBox())!
  407. expect(Math.abs(sceneBox.width - viewportBox.width)).toBeLessThanOrEqual(2)
  408. const colors = await gate.evaluate((element) => {
  409. const canvas = document.createElement('canvas')
  410. canvas.width = canvas.height = 1
  411. const context = canvas.getContext('2d')!
  412. const rgba = (value: string) => {
  413. context.clearRect(0, 0, 1, 1)
  414. context.fillStyle = value
  415. context.fillRect(0, 0, 1, 1)
  416. return Array.from(context.getImageData(0, 0, 1, 1).data)
  417. }
  418. const color = (target: Element, property: 'color' | 'backgroundColor') => rgba(getComputedStyle(target)[property])
  419. return {
  420. background: color(element, 'backgroundColor'),
  421. header: color(element.querySelector('header b')!, 'color'),
  422. label: color(element.querySelector('.el-checkbox__label')!, 'color'),
  423. }
  424. })
  425. const luminance = (color: number[]) => color.slice(0, 3).map((value) => {
  426. const channel = value / 255
  427. return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
  428. }).reduce((result, value, index) => result + value * [0.2126, 0.7152, 0.0722][index]!, 0)
  429. const contrast = (text: number[]) => {
  430. const values = [luminance(text), luminance(colors.background)].sort((a, b) => b - a)
  431. return (values[0]! + 0.05) / (values[1]! + 0.05)
  432. }
  433. expect(colors.background[3], '安全确认区应有独立不透明背景').toBe(255)
  434. expect(contrast(colors.header), '标题与背景对比度').toBeGreaterThanOrEqual(4.5)
  435. expect(contrast(colors.label), '复选框文字与背景对比度').toBeGreaterThanOrEqual(4.5)
  436. // Element Plus visually replaces its hidden native input; click the rendered label.
  437. await gate.locator('.el-checkbox').first().click()
  438. await expect(gate.locator('header em')).toHaveText('1 / 2')
  439. await expect(gate.getByRole('checkbox').first()).toBeChecked()
  440. await test.info().attach('视觉测量(API mock)', { body: Buffer.from(JSON.stringify({ theme, sceneBox, viewportBox, colors, headerContrast: contrast(colors.header), checkboxContrast: contrast(colors.label) }, null, 2)), contentType: 'application/json' })
  441. await shot(page, `08-visual-safety-${theme}`)
  442. })
  443. }
  444. })