|
- import { expect, test, type Page, type Route } from '@playwright/test'
- import { TeachingContractMock } from './teaching-contract-mock'
- import fs from 'node:fs'
- import path from 'node:path'
-
- /** Browser regressions for the 2026-09-05 review. All API responses are mocked.
- * These verify real Vue UI interactions and outgoing payloads, not database persistence.
- * TeachingRunView channel cases use its formal virtual route as a component host;
- * they do not claim that the default PHYSICAL legacy replay is a live API workflow.
- */
-
- type Json = Record<string, unknown>
-
- const SHOTS = path.resolve('reports/review-20260905/frontend-e2e/screenshots')
- 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.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
- 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: 'review.teacher', displayName: '回归测试教员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
- activeRoleId: 'role-teacher',
- roles: [{ id: 'role-teacher', code: 'teacher', name: '教员', shortName: '教', status: 1, builtIn: 1, isSuperAdmin: 0, 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
- if (typeof body.trainingMode === 'string') this.mode = body.trainingMode
- 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 multiChannelContent = (): Json => ({
- schema: 'unreal-tran.training/v1', trainingMode: 'VIRTUAL',
- enabledChannels: ['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'],
- modeConfig: { type: 'VIRTUAL', renderMode: '原有渲染方式' },
- channelProfiles: {
- VIRTUAL: { type: 'VIRTUAL', renderMode: '原有渲染方式' },
- PHYSICAL: { type: 'PHYSICAL', stationName: '保留的实装工位', manualConfirmAllowed: true },
- CONFRONTATION: { type: 'CONFRONTATION', teamSize: 6, roundDurationMinutes: 45, submitPositions: ['COMMANDER'], objective: '协同目标应保留' },
- },
- level: '中级', durationMinutes: 15, totalScore: 100,
- steps: [1, 2, 3].map((n) => ({
- id: `saved-${n}`, stepCode: `FIXED-S0${n}`, actionCode: `FIXED-A0${n}`, order: n,
- title: `已保存步骤${n}`, summary: '浏览器契约回归步骤', targetId: 'cylinder-main',
- targetName: '支腿液压缸总成', mode: 'inspect', interaction: 'target-confirm', score: n === 3 ? 34 : 33,
- physicalEventRule: { source: 'MANUAL', eventType: `REVIEW_EVENT_0${n}`, progressPercent: [33, 67, 100][n - 1], manualConfirmAllowed: true },
- allowedPositions: ['COMMANDER'],
- })),
- })
-
- const shot = async (page: Page, name: string) => {
- const filename = path.join(SHOTS, `${name}.png`)
- await page.screenshot({ path: filename, fullPage: true })
- await test.info().attach(name, { path: filename, contentType: 'image/png' })
- }
-
- const saveStudio = async (page: Page, mock: TrainingMock) => {
- const count = mock.requests.filter((item) => item.method === 'PUT').length
- await page.locator('.content-action-bar').getByRole('button', { name: '保存', exact: true }).click()
- await expect.poll(() => mock.requests.filter((item) => item.method === 'PUT').length).toBe(count + 1)
- await expect(page.locator('.studio-dirty')).toHaveCount(0)
- }
-
- test.describe('API mock浏览器回归:已保存的实装步骤', () => {
- for (const action of ['move', 'delete', 'duplicate'] as const) {
- test(`${action}后保存重算顺序和实装进度`, async ({ page }) => {
- const mock = new TrainingMock()
- // The default remains VIRTUAL; PHYSICAL enabled as a secondary channel must still be correct.
- mock.content = multiChannelContent()
- await mock.install(page)
- await page.goto('/content/training-projects/901/studio?view=steps')
- const rows = page.locator('.step-list > li')
- await expect(rows).toHaveCount(3)
- if (action === 'move') {
- await rows.nth(2).locator('.step-row').click()
- await rows.nth(2).getByRole('button', { name: '上移', exact: true }).click()
- await expect(rows.nth(1)).toContainText('已保存步骤3')
- } else if (action === 'delete') {
- await rows.nth(2).locator('.step-row').click()
- await rows.nth(2).getByRole('button', { name: '删除', exact: true }).click()
- await page.getByRole('button', { name: '确认删除', exact: true }).click()
- await expect(rows).toHaveCount(2)
- } else {
- await rows.nth(0).getByRole('button', { name: '复制', exact: true }).click()
- await expect(rows).toHaveCount(4)
- }
- await saveStudio(page, mock)
- const saved = mock.savedSteps()
- expect(saved.map((step) => step.order)).toEqual(saved.map((_, index) => index + 1))
- expect(saved.map((step) => (step.physicalEventRule as Json).progressPercent)).toEqual(
- action === 'move' ? [33, 67, 100] : action === 'delete' ? [50, 100] : [25, 50, 75, 100],
- )
- if (action === 'move') expect(saved.map((step) => step.stepCode)).toEqual(['FIXED-S01', 'FIXED-S03', 'FIXED-S02'])
- if (action === 'delete') expect(saved.map((step) => step.stepCode)).toEqual(['FIXED-S01', 'FIXED-S02'])
- expect(new Set(saved.map((step) => step.stepCode)).size).toBe(saved.length)
- await test.info().attach('保存请求中的训练内容(API mock)', { body: Buffer.from(JSON.stringify(mock.savedContent(), null, 2)), contentType: 'application/json' })
- await page.reload()
- await expect(rows).toHaveCount(saved.length)
- await page.locator('.el-form-item', { hasText: '完成后进度' }).scrollIntoViewIfNeeded()
- await shot(page, `01-saved-step-${action}`)
- })
- }
- })
-
- test('API mock浏览器回归:通用编辑器保存并切换默认渠道不丢配置', async ({ page }) => {
- const mock = new TrainingMock()
- mock.content = multiChannelContent()
- const originalProfiles = structuredClone(mock.content.channelProfiles) as Record<string, Json>
- await mock.install(page)
- await page.goto('/content/training-projects/901/edit')
- await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
- const renderMode = page.locator('.el-form-item', { hasText: '渲染方式' }).locator('input')
- await expect(renderMode).toHaveValue('原有渲染方式')
- await renderMode.fill('回归验证后的渲染方式')
- const save = async () => {
- const count = mock.requests.filter((item) => item.method === 'PUT').length
- await page.getByRole('button', { name: '保存草稿', exact: true }).click()
- await expect.poll(() => mock.requests.filter((item) => item.method === 'PUT').length).toBe(count + 1)
- await expect(page.getByRole('button', { name: '保存草稿', exact: true })).toBeEnabled()
- }
- await save()
- let saved = mock.savedContent()
- let profiles = saved.channelProfiles as Record<string, Json>
- expect(saved.enabledChannels).toEqual(['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'])
- expect(saved.modeConfig).toEqual(profiles.VIRTUAL)
- expect(profiles.VIRTUAL!.renderMode).toBe('回归验证后的渲染方式')
- // Normalization may add documented defaults; every existing value must survive.
- expect(profiles.PHYSICAL).toMatchObject(originalProfiles.PHYSICAL!)
- expect(profiles.CONFRONTATION).toMatchObject(originalProfiles.CONFRONTATION!)
- await page.reload()
- await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
- await expect(renderMode).toHaveValue('回归验证后的渲染方式')
- await shot(page, '02-multichannel-virtual-save')
- await page.getByRole('tab', { name: '基本信息', exact: true }).click()
- await page.locator('.el-form-item', { hasText: '训练模式' }).locator('.el-select').click()
- await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '对抗' }).click()
- await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
- await expect(page.locator('.el-form-item', { hasText: '每队人数' }).locator('input')).toHaveValue('6')
- const roundDuration = page.locator('.el-form-item', { hasText: '单局时长' }).locator('input')
- await expect(roundDuration).toHaveValue('45')
- await roundDuration.fill('60')
- await roundDuration.press('Tab')
- await save()
- saved = mock.savedContent()
- profiles = saved.channelProfiles as Record<string, Json>
- expect(saved.trainingMode).toBe('CONFRONTATION')
- expect(saved.modeConfig).toEqual(profiles.CONFRONTATION)
- expect(profiles.CONFRONTATION!.roundDurationMinutes).toBe(60)
- expect(profiles.CONFRONTATION!.teamSize).toBe(6)
- expect(profiles.VIRTUAL!.renderMode).toBe('回归验证后的渲染方式')
- expect(profiles.PHYSICAL).toMatchObject(originalProfiles.PHYSICAL!)
- expect(saved.enabledChannels).toEqual(['VIRTUAL', 'PHYSICAL', 'CONFRONTATION'])
- await page.reload()
- await page.getByRole('tab', { name: '结构化内容', exact: true }).click()
- await expect(roundDuration).toHaveValue('60')
- await shot(page, '03-multichannel-confrontation-save')
- })
-
- const overlayCases = [
- { name: '普通虚拟训练', channel: 'VIRTUAL', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
- { name: '普通实装组件', channel: 'PHYSICAL', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
- { name: '普通对抗组件', channel: 'CONFRONTATION', kind: 'TRAINING', status: 'IN_PROGRESS', closed: false },
- { name: '进行中正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'IN_PROGRESS', closed: false },
- { name: '未开始正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'ACCEPTED', closed: true },
- { name: '已提交正式考核', channel: 'VIRTUAL', kind: 'FORMAL_EXAM', status: 'SUBMITTED', closed: true },
- ] as const
-
- test.describe('API mock浏览器回归:失败步骤重试', () => {
- for (const status of ['IN_PROGRESS', 'SUBMITTED', 'TERMINATED'] as const) {
- test(`运行${status}的失败步骤重试入口`, async ({ page }) => {
- await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
- const contract = new TeachingContractMock('studentLeader')
- const run: Json = contract.seedTerminableRun()
- run.status = status
- const task = contract.assignments.get('assignment-virtual-common')!
- task.name = 'API mock:失败步骤重试'
- task.digitalHumanAllowed = false
- task.digitalInstructorAgentSlug = ''
- ;(task.definitionSnapshot as Json).scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
- const facts = [{
- id: 'step-fact-1', runId: run.id, assignmentId: task.id, stepCode: 'STEP-01', stepOrder: 1,
- targetProgressPercent: 50, statusCode: 'FAILED', attemptCount: 1, failureCount: 1,
- lastFailureCode: 'REVIEW_RETRY', evidenceSourceCode: 'DEVICE', lastActorUserId: '4',
- lastRunMemberId: 'member-red-leader', lastEventId: 'event-failed', startedAt: 1786387200,
- completedAt: null, failedAt: 1786387210, isBackfilled: 0, dataVersion: 1,
- }]
- await contract.install(page)
- await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, facts))
- const commands: Json[] = []
- await page.route('**/api/tran/v1/teaching/runs/*/start', async (route) => {
- const body = route.request().postDataJSON() as Json
- commands.push(body)
- expect(route.request().headers()['idempotency-key']).toBe(body.commandId)
- Object.assign(facts[0]!, { statusCode: 'IN_PROGRESS', attemptCount: 2, dataVersion: 2 })
- run.version = Number(run.version) + 1
- await ok(route, run)
- })
- await page.goto(`/teaching/virtual-training/runs/${run.id}`)
- await expect(page.locator('.runtime-stage-card')).toBeVisible()
- const retry = page.getByRole('button', { name: '重试当前步骤', exact: true })
- if (status === 'IN_PROGRESS') {
- await expect(retry).toBeVisible()
- await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('未通过 1')
- await shot(page, '05-failed-before-retry')
- await retry.click()
- await expect(retry).toHaveCount(0)
- expect(commands).toHaveLength(1)
- expect(String(commands[0]!.commandId)).toMatch(/^[a-f0-9-]{36}$/i)
- await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 2')
- await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('未通过 1')
- await page.reload()
- await expect(retry).toHaveCount(0)
- await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 2')
- await shot(page, '06-failed-after-retry')
- } else {
- await expect(retry).toHaveCount(0)
- expect(commands).toHaveLength(0)
- await shot(page, `07-no-retry-${status}`)
- }
- })
- }
- })
-
- test.describe('API mock浏览器回归:执行场景关闭遮罩', () => {
- for (const [index, item] of overlayCases.entries()) {
- test(item.name, async ({ page }) => {
- await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
- const contract = new TeachingContractMock('studentLeader')
- const task = contract.assignments.get('assignment-exam')!
- Object.assign(task, { channel: item.channel, assignmentKind: item.kind, executionMode: item.kind === 'FORMAL_EXAM' ? 'EXAM' : 'PRACTICE', digitalHumanAllowed: false, digitalInstructorAgentSlug: '', name: `API mock:${item.name}` })
- const definition = task.definitionSnapshot as Json
- // Primitive-only fixture avoids unrelated large GLB downloads and external resources.
- definition.scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
- const run = contract.runs.get('run-exam')!
- Object.assign(run, { status: item.status, progressPercent: item.status === 'SUBMITTED' ? 100 : 0 })
- Object.assign(run.assignment as Json, { channel: item.channel, assignmentKind: item.kind, executionMode: task.executionMode, name: task.name, digitalHumanAllowed: false, digitalInstructorAgentSlug: '' })
- await contract.install(page)
- await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, []))
- const errors: string[] = []
- page.on('pageerror', (error) => errors.push(error.message))
- await page.goto('/teaching/virtual-training/runs/run-exam')
- await expect(page.locator('.runtime-stage-card')).toBeVisible()
- await expect(page.locator('.runtime-formal-scene-closed')).toHaveCount(item.closed ? 1 : 0)
- if (item.closed) {
- await expect(page.getByText('正式考核执行场景已关闭', { exact: true })).toBeVisible()
- await expect(page.locator('.runtime-scene-window canvas')).toHaveCount(0)
- } else {
- await expect(page.locator('.runtime-scene-window canvas')).toBeVisible()
- }
- expect(errors).toEqual([])
- await shot(page, `04-overlay-${index + 1}-${item.channel}-${item.status}`)
- })
- }
- })
-
- test.describe('API mock浏览器视觉回归:场景填充与安全确认区', () => {
- for (const theme of ['military', 'dark'] as const) {
- test(`${theme}主题画布铺满且安全文字可读`, async ({ page }) => {
- await page.addInitScript((themeMode) => {
- localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ themeMode }))
- }, theme)
- await page.route('http://127.0.0.1:6180/api/**', (route) => fail(route, 404, 'API mock: endpoint not covered'))
- const contract = new TeachingContractMock('studentLeader')
- const run = contract.seedTerminableRun()
- const task = contract.assignments.get('assignment-virtual-common')!
- task.name = `API mock:${theme}场景与安全确认`
- task.digitalHumanAllowed = false
- task.digitalInstructorAgentSlug = ''
- const definition = task.definitionSnapshot as Json
- definition.scenario = { schema: 'unreal-tran.scene', version: '2.0', objects: [] }
- ;(definition.steps as Json[])[0]!.safetyRules = [
- { key: 'safety-1', text: '确认作业区已隔离且无无关人员进入', required: true },
- { key: 'safety-2', text: '确认所需防护用品已佩戴并检查完好', required: true },
- ]
- await contract.install(page)
- await page.route('**/api/tran/v1/teaching/runs/*/steps', (route) => ok(route, []))
- await page.goto(`/teaching/virtual-training/runs/${run.id}`)
- const gate = page.locator('.runtime-safety-gate')
- const scene = page.locator('.runtime-scene-window > .teaching-three-scene')
- const viewport = page.locator('.runtime-scene-window')
- await expect(gate).toBeVisible()
- await expect(gate.getByRole('checkbox')).toHaveCount(2)
- await expect(page.locator('html')).toHaveAttribute('data-theme', theme === 'dark' ? 'dark' : 'light')
- await expect.poll(async () => {
- const host = await scene.boundingBox()
- const window = await viewport.boundingBox()
- return host && window ? Math.abs(host.height - window.height) : 999
- }).toBeLessThanOrEqual(2)
- const sceneBox = (await scene.boundingBox())!
- const viewportBox = (await viewport.boundingBox())!
- expect(Math.abs(sceneBox.width - viewportBox.width)).toBeLessThanOrEqual(2)
- const colors = await gate.evaluate((element) => {
- const canvas = document.createElement('canvas')
- canvas.width = canvas.height = 1
- const context = canvas.getContext('2d')!
- const rgba = (value: string) => {
- context.clearRect(0, 0, 1, 1)
- context.fillStyle = value
- context.fillRect(0, 0, 1, 1)
- return Array.from(context.getImageData(0, 0, 1, 1).data)
- }
- const color = (target: Element, property: 'color' | 'backgroundColor') => rgba(getComputedStyle(target)[property])
- return {
- background: color(element, 'backgroundColor'),
- header: color(element.querySelector('header b')!, 'color'),
- label: color(element.querySelector('.el-checkbox__label')!, 'color'),
- }
- })
- const luminance = (color: number[]) => color.slice(0, 3).map((value) => {
- const channel = value / 255
- return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
- }).reduce((result, value, index) => result + value * [0.2126, 0.7152, 0.0722][index]!, 0)
- const contrast = (text: number[]) => {
- const values = [luminance(text), luminance(colors.background)].sort((a, b) => b - a)
- return (values[0]! + 0.05) / (values[1]! + 0.05)
- }
- expect(colors.background[3], '安全确认区应有独立不透明背景').toBe(255)
- expect(contrast(colors.header), '标题与背景对比度').toBeGreaterThanOrEqual(4.5)
- expect(contrast(colors.label), '复选框文字与背景对比度').toBeGreaterThanOrEqual(4.5)
- // Element Plus visually replaces its hidden native input; click the rendered label.
- await gate.locator('.el-checkbox').first().click()
- await expect(gate.locator('header em')).toHaveText('1 / 2')
- await expect(gate.getByRole('checkbox').first()).toBeChecked()
- 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' })
- await shot(page, `08-visual-safety-${theme}`)
- })
- }
- })
|