test: 添加回归测试脚本
基于 Playwright 的 UI 回归脚本,覆盖返回导航、数据处理向导、调优创建、模型管理、页面表层级、训练日志布局六个场景。
This commit is contained in:
35
frontend/scripts/regression-back-navigation.mjs
Normal file
35
frontend/scripts/regression-back-navigation.mjs
Normal 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')
|
||||
411
frontend/scripts/regression-data-process-wizard.mjs
Normal file
411
frontend/scripts/regression-data-process-wizard.mjs
Normal 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('数据处理四步向导回归检查通过')
|
||||
27
frontend/scripts/regression-fine-tune-create-ui.mjs
Normal file
27
frontend/scripts/regression-fine-tune-create-ui.mjs
Normal 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')
|
||||
30
frontend/scripts/regression-model-manage.mjs
Normal file
30
frontend/scripts/regression-model-manage.mjs
Normal 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('模型管理页面模板回归检查通过')
|
||||
185
frontend/scripts/regression-page-surface.mjs
Normal file
185
frontend/scripts/regression-page-surface.mjs
Normal 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('全局页面背景与内容表面回归检查通过')
|
||||
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