test: 添加回归测试脚本

基于 Playwright 的 UI 回归脚本,覆盖返回导航、数据处理向导、调优创建、模型管理、页面表层级、训练日志布局六个场景。
This commit is contained in:
caoxiaozhu
2026-07-10 16:46:23 +08:00
parent 6f6609dae5
commit e2eabe3525
6 changed files with 816 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseTemplate } from '@vue/compiler-dom'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue')
const source = await readFile(viewPath, 'utf8')
const { descriptor } = parseSfc(source, { filename: viewPath })
const template = descriptor.template?.content || ''
const style = descriptor.styles.map((item) => item.content).join('\n')
const templateAst = parseTemplate(template)
function findElements(node, predicate, result = []) {
if (node?.type === 1 && predicate(node)) result.push(node)
for (const child of node?.children || []) findElements(child, predicate, result)
return result
}
function staticAttribute(node, name) {
const prop = node.props.find((item) => item.type === 6 && item.name === name)
return prop?.value?.content
}
function boundExpression(node, name) {
const prop = node.props.find(
(item) => item.type === 7
&& item.name === 'bind'
&& item.arg?.type === 4
&& item.arg.content === name,
)
return prop?.exp?.type === 4 ? prop.exp.content : undefined
}
function extractCssBlock(css, marker) {
const markerIndex = css.indexOf(marker)
assert.notEqual(markerIndex, -1, `未找到样式规则:${marker}`)
const openBrace = css.indexOf('{', markerIndex)
assert.notEqual(openBrace, -1, `样式规则缺少左花括号:${marker}`)
let depth = 0
for (let index = openBrace; index < css.length; index += 1) {
if (css[index] === '{') depth += 1
if (css[index] === '}') depth -= 1
if (depth === 0) return css.slice(openBrace + 1, index)
}
assert.fail(`样式规则缺少右花括号:${marker}`)
}
function relativeLuminance(hex) {
const channels = hex
.replace('#', '')
.match(/.{2}/g)
.map((channel) => Number.parseInt(channel, 16) / 255)
.map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4))
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
}
function contrastRatio(foreground, background) {
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background))
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background))
return (lighter + 0.05) / (darker + 0.05)
}
const overview = findElements(
templateAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
)
assert.equal(overview.length, 1, '双栏任务档案容器必须且只能存在一个')
for (const expectedClass of ['task-profile', 'dataset-profile', 'runtime-panel', 'parameter-groups']) {
const matched = findElements(
templateAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes(expectedClass),
)
assert.equal(matched.length, 1, `缺少或重复布局结构:${expectedClass}`)
}
const toggleButtons = findElements(
templateAst,
(node) => node.tag === 'button'
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
)
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的原生 button')
const toggleButton = toggleButtons[0]
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
assert.equal(staticAttribute(toggleButton, 'aria-controls'), 'training-parameter-content', '折叠按钮缺少正确的 aria-controls')
const controlledRegions = findElements(
templateAst,
(node) => staticAttribute(node, 'id') === 'training-parameter-content',
)
assert.equal(controlledRegions.length, 1, 'aria-controls 指向的参数内容区域不存在或重复')
const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
assert.equal(
template.slice(0, firstChartIndex).includes('<el-descriptions'),
false,
'任务概览、数据集和训练参数仍使用带表格感的 el-descriptions',
)
assert.ok(template.includes("task?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
const media1100 = extractCssBlock(style, '@media (max-width: 1100px)')
const overviewAt1100 = extractCssBlock(media1100, '.overview-layout')
assert.match(overviewAt1100, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '1100px 断点未将双栏改为单栏')
const media700 = extractCssBlock(style, '@media (max-width: 700px)')
for (const selector of ['.dataset-metrics', '.runtime-list', '.parameter-grid']) {
assert.ok(media700.includes(selector), `700px 断点缺少单列规则:${selector}`)
}
assert.match(media700, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '700px 断点未设置单列网格')
const mutedBlock = extractCssBlock(style, '.is-muted')
const mutedColor = mutedBlock.match(/color:\s*(#[0-9a-f]{6})/i)?.[1]
assert.ok(mutedColor, '未配置文本缺少明确颜色')
assert.ok(
contrastRatio(mutedColor, '#ffffff') >= 4.5,
`未配置文本颜色 ${mutedColor} 与白色背景对比度不足 4.5:1`,
)
console.log('训练日志详情布局回归检查通过')