test: 添加回归测试脚本
基于 Playwright 的 UI 回归脚本,覆盖返回导航、数据处理向导、调优创建、模型管理、页面表层级、训练日志布局六个场景。
This commit is contained in:
128
frontend/scripts/regression-training-log-layout.mjs
Normal file
128
frontend/scripts/regression-training-log-layout.mjs
Normal 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('训练日志详情布局回归检查通过')
|
||||
Reference in New Issue
Block a user