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 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 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 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 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}`) }) } })