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,35 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
function read(relativePath) {
return readFileSync(resolve(root, relativePath), 'utf8')
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
const appHeader = read('src/components/AppHeader.vue')
const trainingLog = read('src/views/system/TrainingLogView.vue')
assert(
appHeader.includes('showBackButton'),
'AppHeader should gate the global back button behind showBackButton',
)
assert(
appHeader.includes('v-if="showBackButton"'),
'AppHeader should hide 返回上一页 when the current route is not a detail/sub page',
)
assert(
!trainingLog.includes('返回列表'),
'TrainingLogView should rely on the global 返回上一页 button instead of rendering 返回列表',
)
console.log('back-navigation regression checks passed')

View File

@@ -0,0 +1,411 @@
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue')
const createDir = path.resolve(scriptDir, '../src/views/data-process/create')
const layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue')
const viewSource = await readFile(viewPath, 'utf8')
const layoutSource = await readFile(layoutPath, 'utf8')
assert.match(viewSource, /const WIZARD_STEPS = \[/, '向导步骤尚未改为固定常量')
for (const title of ['创建任务', '数据预览', '开始生成', '结果编辑与保存']) {
assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`)
}
assert.doesNotMatch(viewSource, /steps\s*=\s*computed|all\.filter/, '步骤仍根据处理类型动态增减')
assert.match(viewSource, /localStorage\.setItem\(DRAFT_STORAGE_KEY/, '草稿没有持久化')
assert.match(viewSource, /localStorage\.getItem\(DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
const expectedComponents = [
'TaskSetupStep.vue',
'PreviewCompareStep.vue',
'GenerationStep.vue',
'ResultEditorStep.vue',
]
for (const component of expectedComponents) {
assert.ok(existsSync(path.join(createDir, component)), `缺少步骤组件:${component}`)
assert.ok(viewSource.includes(component.replace('.vue', '')), `父页面未使用:${component}`)
}
const typesPath = path.join(createDir, 'types.ts')
const modelPath = path.join(createDir, 'previewModel.ts')
assert.ok(existsSync(typesPath), '缺少向导类型定义')
assert.ok(existsSync(modelPath), '缺少来源映射模型')
const [typesSource, modelSource, previewSource] = await Promise.all([
readFile(typesPath, 'utf8'),
readFile(modelPath, 'utf8'),
readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'),
])
for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) {
assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`)
}
assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识')
assert.match(modelSource, /export function buildPreviewItems/, '缺少切片来源映射生成函数')
assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数')
assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识')
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
assert.match(
viewSource,
/buildPreviewItems\(file\.content, processType\.value, String\(file\.uid\)\)/,
'预览没有按文件分别生成',
)
for (const marker of [
'preview-workspace',
'source-viewer',
'source-line',
'is-highlighted',
'preview-item',
'preview-editor',
'scrollIntoView',
]) {
assert.ok(previewSource.includes(marker), `第二步缺少结构或行为:${marker}`)
}
assert.match(previewSource, /sourceStart/, '第二步未使用来源起始偏移')
assert.match(previewSource, /sourceEnd/, '第二步未使用来源结束偏移')
assert.match(previewSource, /filterable/, '文件选择器必须可搜索')
assert.match(previewSource, /当前文件/, '预览缺少当前文件切换器')
assert.doesNotMatch(previewSource, /located-badge|sync-label|已定位到/, '源文件栏不应显示冗余定位提示')
assert.match(previewSource, /const PREVIEW_PAGE_SIZE = 6/, '切片列表必须限制每页展示数量')
assert.match(previewSource, /const pagedItems = computed/, '切片列表缺少分页数据')
assert.match(previewSource, /v-for="item in pagedItems"/, '切片列表没有使用分页数据')
assert.match(previewSource, /<el-pagination[\s\S]*:page-size="PREVIEW_PAGE_SIZE"/, '切片列表缺少分页控件')
assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '预览工作区高度不足以展示切片正文')
assert.match(previewSource, /const editingItemId = ref<string \| null>\(null\)/, '缺少切片编辑模式状态')
assert.match(previewSource, /const editorDraft = ref\(''\)/, '缺少编辑临时草稿')
assert.match(previewSource, /function openEditor\(item: PreviewItem\)/, '列表缺少打开切片编辑器的动作')
assert.match(previewSource, /function closeEditor\(\)/, '编辑器缺少返回列表的动作')
assert.match(previewSource, /function saveEditor\(\)/, '编辑器缺少保存动作')
assert.match(previewSource, /<template v-if="!editingItem">[\s\S]*?<template v-else>/, '切片列表与编辑器必须互斥展示')
assert.match(previewSource, /fa-pencil/, '切片列表缺少铅笔编辑按钮')
assert.match(previewSource, /fa-trash-o/, '切片列表缺少垃圾桶删除按钮')
assert.match(previewSource, /class="preview-item"[\s\S]*?@click="selectItem\(item\.id\)"/, '点击切片行必须更新当前选中切片')
assert.match(previewSource, /v-model="editorDraft"/, '编辑器必须绑定临时草稿')
assert.match(previewSource, />取消<\/el-button>/, '编辑器缺少取消按钮')
assert.doesNotMatch(previewSource, /返回列表/, '编辑器不应同时显示返回列表和取消两个相同作用的按钮')
assert.match(previewSource, />保存修改<\/el-button>/, '编辑器缺少保存修改按钮')
assert.match(previewSource, /\.editor-actions\s*\{[\s\S]*?justify-content:\s*flex-end/, '取消和保存按钮必须在编辑器右侧对齐')
assert.doesNotMatch(previewSource, /item-token|item-status|modifiedOnly|仅看已修改/, '切片列表不应再显示 Token 或修改状态')
assert.match(previewSource, /\.preview-editor\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?overflow-y:\s*auto/, '编辑模式必须占据右侧剩余区域并可滚动')
assert.match(previewSource, /@media \(max-width: 900px\)/, '第二步缺少窄屏上下布局')
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
const taskSetupSource = await readFile(taskSetupPath, 'utf8')
function findNextStyleBlockStart(source, startIndex) {
let quote = null
for (let index = startIndex; index < source.length; index += 1) {
const character = source[index]
const nextCharacter = source[index + 1]
if (quote) {
if (character === '\\') {
index += 1
} else if (character === quote) {
quote = null
}
continue
}
if (character === '/' && nextCharacter === '*') {
const commentEnd = source.indexOf('*/', index + 2)
index = commentEnd === -1 ? source.length : commentEnd + 1
continue
}
if (character === '/' && nextCharacter === '/') {
const commentEnd = source.indexOf('\n', index + 2)
index = commentEnd === -1 ? source.length : commentEnd
continue
}
if (character === '\'' || character === '"') {
quote = character
continue
}
if (character === '{') return index
}
return -1
}
function findStyleBlockEnd(source, blockStart) {
let depth = 0
let quote = null
for (let index = blockStart; index < source.length; index += 1) {
const character = source[index]
const nextCharacter = source[index + 1]
if (quote) {
if (character === '\\') {
index += 1
} else if (character === quote) {
quote = null
}
continue
}
if (character === '/' && nextCharacter === '*') {
const commentEnd = source.indexOf('*/', index + 2)
index = commentEnd === -1 ? source.length : commentEnd + 1
continue
}
if (character === '/' && nextCharacter === '/') {
const commentEnd = source.indexOf('\n', index + 2)
index = commentEnd === -1 ? source.length : commentEnd
continue
}
if (character === '\'' || character === '"') {
quote = character
continue
}
if (character === '{') {
depth += 1
} else if (character === '}' && --depth === 0) {
return index
}
}
return -1
}
function resolveNestedSelector(selector, parentSelector) {
if (!parentSelector) return selector
if (selector.includes('&')) return selector.replace(/&/g, parentSelector)
return `${parentSelector} ${selector}`
}
function collectStyleRules(source, parentSelector = '') {
const rules = []
let ruleStart = 0
let cursor = 0
while (cursor < source.length) {
const blockStart = findNextStyleBlockStart(source, cursor)
if (blockStart === -1) break
const blockEnd = findStyleBlockEnd(source, blockStart)
if (blockEnd === -1) break
const rawSelector = source.slice(ruleStart, blockStart).trim()
const declarations = source.slice(blockStart + 1, blockEnd)
if (rawSelector) {
const isAtRule = rawSelector.startsWith('@')
const selector = isAtRule ? rawSelector : resolveNestedSelector(rawSelector, parentSelector)
rules.push({ selector, declarations })
rules.push(...collectStyleRules(declarations, isAtRule ? parentSelector : selector))
}
cursor = blockEnd + 1
ruleStart = cursor
}
return rules
}
function directStyleDeclarations(source) {
let result = ''
let nestedDepth = 0
let quote = null
for (let index = 0; index < source.length; index += 1) {
const character = source[index]
const nextCharacter = source[index + 1]
if (quote) {
if (character === '\\') {
index += 1
} else if (character === quote) {
quote = null
}
if (nestedDepth === 0) result += ' '
continue
}
if (character === '/' && nextCharacter === '*') {
const commentEnd = source.indexOf('*/', index + 2)
index = commentEnd === -1 ? source.length : commentEnd + 1
if (nestedDepth === 0) result += ' '
continue
}
if (character === '/' && nextCharacter === '/') {
const commentEnd = source.indexOf('\n', index + 2)
index = commentEnd === -1 ? source.length : commentEnd
if (nestedDepth === 0) result += ' '
continue
}
if (character === '\'' || character === '"') {
quote = character
if (nestedDepth === 0) result += ' '
continue
}
if (character === '{') {
nestedDepth += 1
if (nestedDepth === 1) result += ' '
continue
}
if (character === '}') {
nestedDepth = Math.max(0, nestedDepth - 1)
if (nestedDepth === 0) result += ' '
continue
}
if (nestedDepth === 0) result += character
}
return result
}
const nestedUploadedFileItemsStyles = collectStyleRules(`
.upload-context {
.uploaded-file {
&-items {
max-height: 20rem;
}
@media (min-width: 1px) {
&-items {
overflow: auto;
}
}
@supports (display: grid) {
&-items {
overflow-x: hidden;
overflow-y: auto;
}
}
}
}
`).filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector))
assert.equal(
nestedUploadedFileItemsStyles.length,
3,
'嵌套的 &-items 选择器必须展开为 .uploaded-file-items且不能被 at-rule 上下文遮蔽',
)
for (const property of ['max-height', 'overflow', 'overflow-x', 'overflow-y']) {
assert.ok(
nestedUploadedFileItemsStyles.some(({ declarations }) =>
new RegExp(`(?:^|;)\\s*${property}\\s*:`, 'i').test(directStyleDeclarations(declarations)),
),
`嵌套 .uploaded-file-items 必须识别受限样式属性:${property}`,
)
}
for (const marker of [
'uploaded-file-list-header',
'uploaded-file-items',
'已添加 {{ uploadedFiles.length }} 个文件',
':title="file.name"',
]) {
assert.ok(taskSetupSource.includes(marker), `源数据文件列表缺少:${marker}`)
}
assert.match(
taskSetupSource,
/^const[ \t]+FILE_PAGE_SIZE[ \t]*=[ \t]*10[ \t]*;?[ \t]*$/m,
'文件分页大小必须固定为整数 10',
)
assert.match(
taskSetupSource,
/const pagedUploadedFiles\s*=\s*computed\(\(\)\s*=>\s*\{\s*const start = \(currentFilePage\.value - 1\) \* FILE_PAGE_SIZE\s*return props\.uploadedFiles\.slice\(start, start \+ FILE_PAGE_SIZE\)\s*\}\)/,
'文件分页必须按当前页偏移切片完整文件列表',
)
assert.match(
taskSetupSource,
/watch\(\(\)\s*=>\s*props\.uploadedFiles\.length,\s*\(newLength, oldLength\)\s*=>\s*\{[\s\S]*?if \(newLength > oldLength\)\s*\{\s*currentFilePage\.value = totalPages[\s\S]*?\}[\s\S]*?currentFilePage\.value = Math\.min\(currentFilePage\.value, totalPages\)[\s\S]*?\}\)/,
'文件数变化时必须新增跳至末页、删除回退到有效页',
)
const filePaginationTags = [...taskSetupSource.matchAll(/<el-pagination\b[\s\S]*?\/>/g)]
assert.equal(filePaginationTags.length, 1, '文件列表必须只有一个分页器')
const [filePaginationTag] = filePaginationTags
for (const attribute of [
'v-if="uploadedFiles.length > FILE_PAGE_SIZE"',
'v-model:current-page="currentFilePage"',
':page-size="FILE_PAGE_SIZE"',
':total="uploadedFiles.length"',
]) {
assert.ok(filePaginationTag[0].includes(attribute), `文件分页器缺少属性:${attribute}`)
}
const { descriptor: taskSetupDescriptor } = parseSfc(taskSetupSource, { filename: taskSetupPath })
const uploadedFileItemsStyles = taskSetupDescriptor.styles
.flatMap(({ content }) => collectStyleRules(content))
.filter(({ selector }) => /(?:^|[^\w-])\.uploaded-file-items(?![\w-])/.test(selector))
for (const { declarations } of uploadedFileItemsStyles) {
assert.doesNotMatch(
directStyleDeclarations(declarations),
/(?:^|;)\s*(?:max-height|overflow|overflow-x|overflow-y)\s*:/i,
'文件列表不能用内部滚动替代分页',
)
}
assert.match(
taskSetupSource,
/<el-upload\s+v-if="uploadedFiles\.length === 0"[\s\S]*?<\/el-upload>\s*<section\s+v-else\s+class="uploaded-file-list"\s+aria-label="已上传文件列表">/,
'有文件状态缺少带 aria-label="已上传文件列表" 的语义列表容器',
)
assert.match(
taskSetupSource,
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>/,
'文件列表标题结构或文件数量文案缺失',
)
assert.match(
taskSetupSource,
/<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">/,
'文件列表缺少分页后的文件行容器',
)
assert.match(
taskSetupSource,
/<el-upload\s+v-if="uploadedFiles\.length === 0"\s+drag\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"\s*>/,
'无文件时未保留原有大拖拽上传区或上传配置',
)
assert.match(
taskSetupSource,
/<el-upload\s+v-if="uploadedFiles\.length === 0"\s+drag\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"\s*>[\s\S]*?<template\s+#tip>\s*<div\s+class="el-upload__tip">\s*\{\{\s*processType === 'unstructured'\s*\? '支持 TXT、Markdown、PDF、Word、JSON、JSONL单文件不超过 200MB'\s*:\s*'支持 JSON、JSONL、CSV、Excel单文件不超过 200MB'\s*\}\}/,
'无文件时大拖拽上传区缺少格式提示槽、处理类型分支或完整格式提示',
)
assert.match(
taskSetupSource,
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>\s*<div\s+class="continue-upload">\s*<el-upload\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"\s*>\s*<el-button\s+size="small"\s+type="primary">继续上传<\/el-button>\s*<\/el-upload>\s*<\/div>\s*<\/div>/,
'有文件时缺少标题右侧的继续上传触发器或上传配置',
)
assert.match(
taskSetupSource,
/\.uploaded-file-list-header\s*\{[^}]*display:\s*flex[^}]*justify-content:\s*space-between/,
'文件列表标题未布局为右侧继续上传按钮',
)
assert.match(
taskSetupSource,
/\.continue-upload\s+:deep\(\.el-upload\)\s*\{[^}]*width:\s*auto;?[^}]*margin-top:\s*0;?/,
'继续上传未覆盖内层上传节点的宽度和顶部间距',
)
assert.match(taskSetupSource, /\.uploaded-file\s*\{[^}]*min-height:\s*48px/, '文件行没有保持 48px 最小高度')
assert.match(
taskSetupSource,
/<section\s+v-else\s+class="uploaded-file-list"\s+aria-label="已上传文件列表">[\s\S]*?<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">[\s\S]*?<span\s+class="file-status"><i\s+class="fa fa-check-circle"\s*\/>\s*校验通过<\/span>\s*<el-button\s+link\s+type="danger"\s+@click="emit\('remove-file', file\.uid\)">删除<\/el-button>\s*<\/div>\s*<\/div>/,
'文件行没有将成功状态与对应 remove-file 删除按钮关联',
)
const { descriptor } = parseSfc(viewSource, { filename: viewPath })
const template = descriptor.template?.content || ''
assert.equal((template.match(/class="wizard-primary-action"/g) || []).length, 1, '页面必须只有一个主操作入口')
assert.match(viewSource, /onBeforeUnmount\(stopGenerationTimer\)/, '生成计时器没有在卸载时清理')
assert.match(viewSource, /function scrollToStepTop/, '步骤切换后没有恢复页面顶部上下文')
assert.match(viewSource, /nextTick\(scrollToStepTop\)/, '步骤切换没有触发页面滚动复位')
assert.match(viewSource, /\.wizard-content\s*\{[\s\S]*min-height:\s*400px/, '第一步内容区必须保留足够高度以显示底部操作栏')
assert.match(
layoutSource,
/&:has\(\.create-wizard-layout\)\s*\{[\s\S]*?overflow-y:\s*hidden[\s\S]*?\.page-canvas\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?min-height:\s*0/,
'创建任务页必须约束画布高度,避免底部操作栏被裁掉',
)
assert.match(previewSource, /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/, '对照预览高度不足以展示切片正文')
console.log('数据处理四步向导回归检查通过')

View File

@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
const source = readFileSync(resolve(root, 'src/views/fine-tune/FineTuneCreateView.vue'), 'utf8')
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
assert(source.includes(':rows="4"'), '任务描述 textarea should be taller than the original 2 rows')
assert(source.includes('gpu-index'), 'GPU cards should use a restrained index label')
assert(source.includes('GPU-{{ idx }}'), 'GPU cards should show the GPU number in a compact format')
assert(source.includes('gpu-usage-bar'), 'GPU cards should visualize usage with an enterprise-style progress bar')
assert(source.includes('is-busy'), 'GPU cards should have a distinct busy state for usage over 80%')
assert(source.includes('gpu.gpu_percent > 80'), 'GPU busy state should be driven by usage over 80%')
assert(source.includes('modelDialogVisible'), 'Model selection should open a dialog instead of a plain select')
assert(!source.includes('width="78vw"'), 'Model dialog should not use the previous oversized 78vw width')
assert(source.includes('width="860px"'), 'Model dialog should use a compact enterprise modal width')
assert(source.includes('model-series-list'), 'Model dialog should include a model series list column')
assert(source.includes('model-version-list'), 'Model dialog should include a snapshot/version list column')
assert(source.includes('confirmModelSelection'), 'Model dialog should confirm the selected model before updating the form')
console.log('fine-tune create UI regression checks passed')

View File

@@ -0,0 +1,30 @@
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 parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/model/ModelManageView.vue')
const source = await readFile(viewPath, 'utf8')
const { descriptor, errors } = parseSfc(source, { filename: viewPath })
assert.equal(errors.length, 0, `模型管理页面模板无法解析:${errors[0]}`)
assert.ok(descriptor.template?.content.trim(), '模型管理页面缺少可渲染模板')
assert.equal((source.match(/<template>/g) || []).length, 1, '模型管理页面只能有一个根模板标签')
const configTableStart = source.indexOf('v-if="activeTab === \'config\'"')
const trainedTableStart = source.indexOf('<DataTablePage\n v-else')
assert.ok(configTableStart >= 0 && trainedTableStart > configTableStart, '模型管理页面缺少两个标签对应的列表分支')
assert.doesNotMatch(
source.slice(configTableStart, trainedTableStart),
/activeTab === 'trained'/,
'配置模型分支不应再比较已不可能的训练模型状态',
)
assert.doesNotMatch(
source.slice(trainedTableStart),
/activeTab === 'config'/,
'训练模型分支不应再比较已不可能的配置模型状态',
)
console.log('模型管理页面模板回归检查通过')

View File

@@ -0,0 +1,185 @@
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 [globalStyles, routerSource, mainLayoutSource, trainingLogSource, fineTuneCreateSource] = await Promise.all([
readFile(path.resolve(scriptDir, '../src/styles/index.scss'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue'), 'utf8'),
readFile(path.resolve(scriptDir, '../src/views/fine-tune/FineTuneCreateView.vue'), 'utf8'),
])
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 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 boundAttribute(node, name) {
const prop = node.props.find(
(item) => item.type === 7 && item.name === 'bind' && item.arg?.content === name,
)
return prop?.exp?.content
}
function extractRouteBlock(source, routePath) {
const pathPattern = new RegExp(`path:\\s*['"]${routePath.replaceAll('/', '\\/')}['"]`)
const pathIndex = source.search(pathPattern)
assert.notEqual(pathIndex, -1, `未找到路由 /${routePath}`)
const openBrace = source.lastIndexOf('{', pathIndex)
assert.notEqual(openBrace, -1, `路由 /${routePath} 缺少左花括号`)
let depth = 0
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') depth += 1
if (source[index] === '}') depth -= 1
if (depth === 0) return source.slice(openBrace + 1, index)
}
assert.fail(`路由 /${routePath} 缺少右花括号`)
}
const selfSurfaceRoutes = [
'fine-tune',
'model-eval',
'model-inference',
'model-manage',
'data-process',
'dataset',
]
for (const routePath of selfSurfaceRoutes) {
const routeBlock = extractRouteBlock(routerSource, routePath)
assert.match(
routeBlock,
/meta:\s*\{[^}]*pageSurface:\s*['"]self['"][^}]*\}/,
`列表路由 /${routePath} 未声明 pageSurface: 'self'`,
)
}
assert.equal(
routerSource.match(/pageSurface:\s*['"]self['"]/g)?.length,
selfSurfaceRoutes.length,
"只能给指定的自带白色表面的列表路由声明 pageSurface: 'self'",
)
const rootBlock = extractCssBlock(globalStyles, ':root')
assert.match(rootBlock, /--app-shell-bg:\s*#f3f5f8;/, '全局样式缺少灰色外层背景 token')
assert.match(rootBlock, /--app-page-bg:\s*#ffffff;/, '全局样式缺少白色页面画布 token')
assert.match(rootBlock, /--app-surface-bg:\s*#ffffff;/, '全局样式缺少统一内容表面 token')
const bodyBlock = extractCssBlock(globalStyles, '\nbody {')
assert.match(bodyBlock, /background-color:\s*var\(--app-shell-bg\);/, 'body 未使用灰色外层背景')
const cardBlock = extractCssBlock(globalStyles, '.el-card {')
assert.match(cardBlock, /background-color:\s*var\(--app-surface-bg\);/, '全局卡片未使用统一内容表面')
const mainLayoutDescriptor = parseSfc(mainLayoutSource).descriptor
const mainLayoutScript = mainLayoutDescriptor.scriptSetup?.content || ''
const mainLayoutStyle = mainLayoutDescriptor.styles.map((item) => item.content).join('\n')
const mainLayoutTemplate = mainLayoutDescriptor.template?.content || ''
const mainLayoutAst = parseTemplate(mainLayoutTemplate)
assert.match(mainLayoutScript, /import\s*\{[^}]*\buseRoute\b[^}]*\}\s*from\s*['"]vue-router['"]/, '主布局未引入 useRoute')
assert.match(mainLayoutScript, /const\s+route\s*=\s*useRoute\(\)/, '主布局未获取当前路由')
const pageCanvases = findElements(
mainLayoutAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('page-canvas'),
)
assert.equal(pageCanvases.length, 1, '主布局必须且只能提供一个全局白色页面画布')
assert.match(
boundAttribute(pageCanvases[0], 'class') || '',
/['"]is-self-surface['"]\s*:\s*route\.meta\.pageSurface\s*===\s*['"]self['"]/,
'主布局未根据 route.meta.pageSurface 为页面画布添加 is-self-surface 类',
)
assert.equal(
findElements(pageCanvases[0], (node) => node.tag === 'router-view').length,
1,
'所有业务路由必须渲染在全局页面画布内部',
)
assert.equal(
findElements(mainLayoutAst, (node) => node.tag.toLowerCase() === 'transition').length,
0,
'主布局仍包含会造成列表与二级页面表面错位的页面级 Transition',
)
assert.doesNotMatch(
mainLayoutStyle,
/\.fade-(?:enter|leave)-(?:active|from|to)/,
'主布局仍包含页面级透明度转场样式',
)
const layoutContentBlock = extractCssBlock(mainLayoutStyle, '.layout-content')
assert.match(layoutContentBlock, /background-color:\s*var\(--app-shell-bg\);/, '主内容区未使用灰色外层背景')
const pageCanvasBlock = extractCssBlock(mainLayoutStyle, '.page-canvas')
assert.match(pageCanvasBlock, /min-height:\s*100%;/, '白色页面画布没有铺满可用高度')
assert.match(pageCanvasBlock, /padding:\s*24px;/, '白色页面画布缺少统一内容内边距')
assert.match(pageCanvasBlock, /border-radius:\s*16px;/, '白色页面画布圆角与参考不一致')
assert.match(pageCanvasBlock, /background-color:\s*var\(--app-page-bg\);/, '全局页面画布未使用白色背景')
const selfSurfaceBlock = extractCssBlock(mainLayoutStyle, '.page-canvas.is-self-surface')
assert.match(selfSurfaceBlock, /padding:\s*0;/, '自带表面的页面仍保留全局画布内边距')
assert.match(selfSurfaceBlock, /border-radius:\s*0;/, '自带表面的页面仍保留全局画布圆角')
assert.match(selfSurfaceBlock, /background-color:\s*transparent;/, '自带表面的页面未透出灰色应用背景')
assert.match(selfSurfaceBlock, /box-shadow:\s*none;/, '自带表面的页面仍保留全局画布阴影')
assert.match(
mainLayoutStyle,
/\.page-canvas:not\(\.is-self-surface\)\s*>\s*:deep\(\.page-card-host\s*>\s*\.page-card\)/,
'显式根卡片宿主内的 PageCard 未被识别为页面根卡片',
)
assert.doesNotMatch(
mainLayoutStyle,
/:deep\(\*\s*>\s*\.page-card\)/,
'通用层级选择器会误伤训练日志等页面的内部业务卡片',
)
assert.match(
fineTuneCreateSource,
/class=["'][^"']*\bfine-tune-create\b[^"']*\bpage-card-host\b[^"']*["']/,
'创建训练任务页未显式标记根 PageCard 宿主',
)
const rootPageCardBlock = extractCssBlock(
mainLayoutStyle,
'.page-canvas:not(.is-self-surface) > :deep(.page-card)',
)
assert.match(rootPageCardBlock, /margin-bottom:\s*0;/, '页面根卡片仍在白色画布内保留额外外边距')
assert.match(rootPageCardBlock, /background-color:\s*transparent;/, '页面根卡片仍形成第二层白色背景')
assert.match(rootPageCardBlock, /border-radius:\s*0\s*!important;/, '页面根卡片仍形成第二层圆角边界')
assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根卡片仍形成重复卡片层级')
const trainingLogStyle = parseSfc(trainingLogSource).descriptor.styles.map((item) => item.content).join('\n')
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.profile-section,\n.runtime-panel')
assert.match(
businessSurfaceBlock,
/background:\s*var\(--app-surface-bg\);/,
'任务、数据集和运行概况未使用统一白色内容表面',
)
console.log('全局页面背景与内容表面回归检查通过')

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('训练日志详情布局回归检查通过')