第一次提交
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')
|
||||
64
frontend/scripts/regression-dashboard.mjs
Normal file
64
frontend/scripts/regression-dashboard.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const [routerSource, dashboardSource, echartsSource, mainLayoutSource] = await Promise.all([
|
||||
readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/views/dashboard/DashboardView.vue'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/plugins/echarts.ts'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), 'utf8'),
|
||||
])
|
||||
|
||||
assert.match(
|
||||
routerSource,
|
||||
/path:\s*['"]dashboard['"][\s\S]*?DashboardView\.vue/,
|
||||
'服务看板路由应使用独立 DashboardView',
|
||||
)
|
||||
|
||||
assert.match(echartsSource, /import\s*\{[^}]*BarChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 BarChart')
|
||||
assert.match(echartsSource, /use\(\[[\s\S]*?BarChart[\s\S]*?\]\)/, 'BarChart 未加入 ECharts 按需注册列表')
|
||||
assert.match(echartsSource, /import\s*\{[^}]*PieChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 PieChart')
|
||||
assert.match(echartsSource, /use\(\[[\s\S]*?PieChart[\s\S]*?\]\)/, 'PieChart 未加入 ECharts 按需注册列表')
|
||||
|
||||
for (const copy of [
|
||||
'平台运行状态',
|
||||
'近 7 天训练统计',
|
||||
'训练次数(次)',
|
||||
'GPU 使用数(个)',
|
||||
'平均准确率(%)',
|
||||
'服务状态',
|
||||
'训练任务',
|
||||
'查看全部任务',
|
||||
]) {
|
||||
assert.ok(dashboardSource.includes(copy), `服务看板缺少关键内容:${copy}`)
|
||||
}
|
||||
|
||||
assert.match(dashboardSource, /yAxis:\s*\[[\s\S]*?次数 \/ GPU 数[\s\S]*?准确率/, '柱状图应使用双 Y 轴表达不同单位')
|
||||
assert.match(dashboardSource, /name:\s*['"]平均准确率(%)['"][\s\S]*?yAxisIndex:\s*1/, '准确率柱应绑定右侧百分比坐标轴')
|
||||
assert.match(dashboardSource, /router\.push\(['"]\/fine-tune['"]\)/, '查看全部任务应进入模型微调列表')
|
||||
assert.match(dashboardSource, /router\.push\(`\/training-log\/\$\{task\.id\}`\)/, '训练任务详情应进入训练日志页')
|
||||
assert.doesNotMatch(dashboardSource, /class=["']dashboard-heading["']/, '服务看板不应重复展示页面标题栏')
|
||||
assert.doesNotMatch(dashboardSource, /查看告警/, '服务看板不应保留冗余的顶部告警按钮')
|
||||
assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?gap:\s*16px;/, '服务看板区块间距应保持舒展')
|
||||
assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?min-height:\s*100%;/, '服务看板应至少填满页面可用高度')
|
||||
assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?min-height:\s*0;/, '中间区域不应因新增统计卡片被压缩')
|
||||
assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*minmax\(300px,\s*0\.82fr\);/, '服务状态列应收窄,为训练图表释放更多宽度')
|
||||
assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度')
|
||||
assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸')
|
||||
assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高')
|
||||
assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*50px;[\s\S]*?th\s*\{[\s\S]*?height:\s*38px;/, '训练任务表格正文行应保持舒展')
|
||||
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*34px;/, '低高度桌面视口也应保留可读的训练任务行高')
|
||||
assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片')
|
||||
assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示')
|
||||
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数')
|
||||
assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称')
|
||||
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?grid:\s*\{\s*top:\s*8,\s*right:\s*12,\s*bottom:\s*6,\s*left:\s*8,[\s\S]*?max:\s*Math\.ceil\(Math\.max[\s\S]*?position:\s*['"]insideRight['"]/, '登录时长图应收紧左右边距、按数据范围拉伸,并将数值置于柱内')
|
||||
assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列')
|
||||
assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列')
|
||||
assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列')
|
||||
assert.match(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*auto;/, '服务看板应允许在内容超出视口时纵向滚动')
|
||||
assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 0 auto;[\s\S]*?overflow:\s*visible;/, '服务看板画布应允许新增卡片完整显示')
|
||||
|
||||
console.log('服务看板回归检查通过')
|
||||
56
frontend/scripts/regression-data-convert.mjs
Normal file
56
frontend/scripts/regression-data-convert.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const sourceRoot = path.resolve(scriptDir, '../src')
|
||||
|
||||
const [viewSource, routerSource, toolsSource] = await Promise.all([
|
||||
readFile(path.join(sourceRoot, 'views/data-convert/DataConvertView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/tools/ToolsView.vue'), 'utf8'),
|
||||
])
|
||||
|
||||
assert.match(viewSource, /JSON 转 JSONL/, '转换页缺少明确的格式标题')
|
||||
assert.match(viewSource, /class="upload-zone"/, '转换页缺少源文件上传区域')
|
||||
assert.match(viewSource, /class="converter-form"/, '转换页缺少标准表单区域')
|
||||
assert.match(viewSource, /class="form-row"/, '转换页缺少输出配置区域')
|
||||
assert.match(viewSource, /开始转换/, '转换页缺少主操作按钮')
|
||||
assert.match(viewSource, /当前为 UI 原型/, '转换页没有明确说明 UI 原型范围')
|
||||
assert.match(viewSource, /<el-button type="primary" disabled>/, '未接入功能前主操作按钮必须禁用')
|
||||
assert.match(viewSource, /var\(--primary-color\)/, '转换页没有使用项目主色变量')
|
||||
assert.match(viewSource, /var\(--el-color-primary-light-9\)/, '转换页没有使用项目主色浅色变量')
|
||||
assert.doesNotMatch(viewSource, /#1890ff/i, '转换页仍包含未对齐当前主题的旧蓝色')
|
||||
assert.match(viewSource, /\.converter-panel\s*\{[\s\S]*?width:\s*100%;/, '转换区域没有铺满页面宽度')
|
||||
assert.match(viewSource, /min-height:\s*calc\(100vh\s*-\s*220px\)/, '转换区域没有铺满页面可用高度')
|
||||
assert.doesNotMatch(viewSource, /max-width:\s*860px/, '转换区域仍被限制为窄卡片')
|
||||
|
||||
// 当前迭代只允许界面开发,防止误接入文件读取、解析、转换或下载逻辑。
|
||||
for (const forbiddenImplementation of [
|
||||
/FileReader/,
|
||||
/\.text\(\)/,
|
||||
/JSON\.parse/,
|
||||
/new Blob/,
|
||||
/createObjectURL/,
|
||||
]) {
|
||||
assert.doesNotMatch(viewSource, forbiddenImplementation, 'UI 原型中不应包含实际转换实现')
|
||||
}
|
||||
|
||||
assert.match(
|
||||
routerSource,
|
||||
/path:\s*['"]data-convert['"][\s\S]*?DataConvertView\.vue/,
|
||||
'数据类型转换路由未接入新页面',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
routerSource,
|
||||
/path:\s*['"]data-convert['"][\s\S]{0,180}?PlaceholderView\.vue/,
|
||||
'数据类型转换路由仍指向占位页',
|
||||
)
|
||||
assert.match(
|
||||
toolsSource,
|
||||
/id === ['"]json2jsonl['"][\s\S]{0,100}?router\.push\(['"]\/data-convert['"]\)/,
|
||||
'其他工具中的 JSON 转 JSONL 卡片未接入转换页',
|
||||
)
|
||||
|
||||
console.log('JSON 转 JSONL UI 原型回归检查通过')
|
||||
55
frontend/scripts/regression-data-process-detail.mjs
Normal file
55
frontend/scripts/regression-data-process-detail.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
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 sourceRoot = path.resolve(scriptDir, '../src')
|
||||
const [detailSource, listSource, routerSource] = await Promise.all([
|
||||
readFile(path.join(sourceRoot, 'views/data-process/DataProcessDetailView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/data-process/DataProcessListView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
|
||||
])
|
||||
|
||||
const { descriptor, errors } = parseSfc(detailSource, { filename: 'DataProcessDetailView.vue' })
|
||||
assert.equal(errors.length, 0, `数据处理详情页无法解析:${errors[0]}`)
|
||||
assert.ok(descriptor.template?.content.trim(), '数据处理详情页缺少可渲染模板')
|
||||
|
||||
assert.match(routerSource, /path:\s*['"]data-process\/:id['"]/, '缺少数据处理详情动态路由')
|
||||
assert.match(routerSource, /name:\s*['"]data-process-detail['"]/, '数据处理详情路由缺少名称')
|
||||
assert.match(routerSource, /DataProcessDetailView\.vue/, '数据处理详情路由未加载详情页面')
|
||||
assert.match(routerSource, /title:\s*['"]数据处理详情['"]/, '数据处理详情路由标题不正确')
|
||||
|
||||
assert.match(listSource, /name:\s*['"]data-process-detail['"]/, '列表详情按钮未使用详情命名路由')
|
||||
assert.match(listSource, /params:\s*\{\s*id:\s*taskId\s*\}/, '列表详情按钮未传递任务 ID')
|
||||
assert.doesNotMatch(listSource, /查看详情功能开发中/, '详情按钮仍保留开发中提示')
|
||||
|
||||
for (const requiredCopy of [
|
||||
'处理耗时',
|
||||
'开始时间',
|
||||
'完成时间',
|
||||
'输入数据',
|
||||
'输出结果',
|
||||
'处理统计',
|
||||
'处理配置',
|
||||
'结果明细',
|
||||
]) {
|
||||
assert.match(detailSource, new RegExp(requiredCopy), `详情页缺少必要信息:${requiredCopy}`)
|
||||
}
|
||||
|
||||
for (const status of ['completed', 'running', 'pending', 'failed']) {
|
||||
assert.match(detailSource, new RegExp(`status:\\s*['"]${status}['"]`), `详情 Mock 缺少 ${status} 状态`)
|
||||
}
|
||||
|
||||
assert.match(detailSource, /const completedResults:\s*ResultRow\[\]/, '完成任务缺少结果明细 Mock')
|
||||
assert.match(detailSource, /:data="paginatedResults"/, '结果表格未绑定分页后的处理结果')
|
||||
assert.match(detailSource, /v-model="keyword"/, '结果明细缺少搜索能力')
|
||||
assert.match(detailSource, /v-model="statusFilter"/, '结果明细缺少状态筛选')
|
||||
assert.match(detailSource, /router\.push\(`\/dataset\/\$\{detail\.outputDatasetId\}\/preview`\)/, '输出数据集未接入预览入口')
|
||||
assert.match(detailSource, /未找到数据处理任务/, '未知任务 ID 缺少明确空状态')
|
||||
assert.match(detailSource, /width:\s*100%/, '详情页没有铺满内容区域')
|
||||
assert.doesNotMatch(detailSource, /^\s*max-width:\s*\d+px/m, '详情页不应使用固定最大宽度')
|
||||
assert.doesNotMatch(detailSource, /\b(?:password|secret|token)\b/i, '详情页不得展示敏感凭据字段')
|
||||
|
||||
console.log('数据处理任务详情 UI 回归检查通过')
|
||||
19
frontend/scripts/regression-data-process-list.mjs
Normal file
19
frontend/scripts/regression-data-process-list.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
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/data-process/DataProcessListView.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.match(source, /:data="dataList"/, '任务表格必须直接展示完整任务数据')
|
||||
assert.doesNotMatch(source, /activeTab|filteredDataList/, '不应保留状态切换筛选逻辑')
|
||||
assert.doesNotMatch(source, /全部任务|处理中|已完成/, '不应保留状态切换按钮文案')
|
||||
assert.doesNotMatch(source, /capsule-tabs|capsule-tab-item/, '不应保留状态切换专用样式')
|
||||
|
||||
console.log('数据处理任务列表状态切换移除回归检查通过')
|
||||
886
frontend/scripts/regression-data-process-wizard.mjs
Normal file
886
frontend/scripts/regression-data-process-wizard.mjs
Normal file
@@ -0,0 +1,886 @@
|
||||
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'
|
||||
import ts from 'typescript'
|
||||
|
||||
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 confirmDialogPath = path.resolve(scriptDir, '../src/components/AppConfirmDialog.vue')
|
||||
const layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue')
|
||||
const viewSource = await readFile(viewPath, 'utf8')
|
||||
const layoutSource = await readFile(layoutPath, 'utf8')
|
||||
const [draftSource, stateSource, generationSource, viewStyleSource] = await Promise.all([
|
||||
readFile(path.join(createDir, 'useDataProcessDraft.ts'), 'utf8'),
|
||||
readFile(path.join(createDir, 'dataProcessCreateState.ts'), 'utf8'),
|
||||
readFile(path.join(createDir, 'useDataProcessGeneration.ts'), 'utf8'),
|
||||
readFile(path.join(createDir, 'data-process-create.scss'), 'utf8'),
|
||||
])
|
||||
const implementationSource = [viewSource, draftSource, stateSource, generationSource].join('\n')
|
||||
|
||||
assert.ok(existsSync(confirmDialogPath), '缺少公共确认弹窗组件 AppConfirmDialog')
|
||||
const confirmDialogSource = await readFile(confirmDialogPath, 'utf8')
|
||||
for (const marker of ['<Teleport to="body">', 'role="alertdialog"', ':aria-modal="true"', 'handleKeydown', 'Escape']) {
|
||||
assert.ok(confirmDialogSource.includes(marker), `公共确认弹窗缺少可访问性能力:${marker}`)
|
||||
}
|
||||
assert.match(confirmDialogSource, /min-height:\s*44px/, '公共确认弹窗按钮触控区域不足 44px')
|
||||
assert.match(confirmDialogSource, /focus\(\)/, '公共确认弹窗打开后没有管理键盘焦点')
|
||||
assert.match(confirmDialogSource, /defineExpose\(\{ open \}\)/, '公共确认弹窗没有暴露 Promise 式 open API')
|
||||
assert.match(confirmDialogSource, /width:\s*min\(480px,\s*100%\)/, '企业级确认弹窗宽度应保持紧凑的 480px')
|
||||
assert.match(confirmDialogSource, /border-radius:\s*8px/, '企业级确认弹窗应使用克制的 8px 圆角')
|
||||
assert.doesNotMatch(confirmDialogSource, /backdrop-filter/, '企业级确认弹窗不应使用装饰性背景模糊')
|
||||
assert.ok(confirmDialogSource.includes('app-confirm-header'), '企业级确认弹窗缺少独立标题栏')
|
||||
assert.match(confirmDialogSource, /\.app-confirm-button\s*\{[\s\S]*?height:\s*34px/, '桌面端操作按钮应使用紧凑的 34px 高度')
|
||||
assert.match(confirmDialogSource, /@media \(max-width: 520px\)[\s\S]*?\.app-confirm-button\s*\{[\s\S]*?min-height:\s*44px/, '移动端操作按钮仍需保留 44px 触控高度')
|
||||
assert.match(viewSource, /import AppConfirmDialog from '@\/components\/AppConfirmDialog\.vue'/, '创建页没有接入公共确认弹窗')
|
||||
assert.match(viewSource, /<AppConfirmDialog/, '创建页模板缺少公共确认弹窗实例')
|
||||
assert.match(viewSource, /onBeforeRouteLeave\(async \(\) =>/, '路由离开确认没有改为异步公共弹窗流程')
|
||||
assert.doesNotMatch(viewSource, /window\.confirm|ElMessageBox/, '创建页仍在使用系统或 Element Plus 确认框')
|
||||
|
||||
assert.match(viewSource, /const WIZARD_STEPS = \[/, '向导步骤尚未改为固定常量')
|
||||
for (const title of ['创建任务', '大模型选择', '上传文件', '数据预览', '开始生成', '结果编辑与保存']) {
|
||||
assert.ok(viewSource.includes(`title: '${title}'`), `缺少固定步骤:${title}`)
|
||||
}
|
||||
assert.match(
|
||||
viewSource,
|
||||
/\{ id: 'create',[\s\S]*?\{ id: 'model',[\s\S]*?\{ id: 'upload',[\s\S]*?\{ id: 'preview',[\s\S]*?\{ id: 'generate',[\s\S]*?\{ id: 'results'/,
|
||||
'六步向导顺序必须为创建任务、大模型选择、上传文件、数据预览、开始生成、结果编辑与保存',
|
||||
)
|
||||
assert.doesNotMatch(viewSource, /steps\s*=\s*computed|all\.filter/, '步骤仍根据处理类型动态增减')
|
||||
assert.match(
|
||||
viewStyleSource,
|
||||
/@media \(max-width: 1100px\)[\s\S]*?\.step-title\s*\{[\s\S]*?display:\s*none[\s\S]*?\.step-item\.is-active \.step-title\s*\{[\s\S]*?display:\s*block/,
|
||||
'六步向导在中等宽度下没有收起非当前步骤标题',
|
||||
)
|
||||
assert.match(draftSource, /localStorage\.setItem\(DATA_PROCESS_DRAFT_STORAGE_KEY/, '草稿没有持久化')
|
||||
assert.match(draftSource, /localStorage\.getItem\(DATA_PROCESS_DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
|
||||
assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
|
||||
assert.ok(viewSource.split('\n').length < 800, 'DataProcessCreateView 拆分后仍超过 800 行')
|
||||
|
||||
const expectedComponents = [
|
||||
'TaskSetupStep.vue',
|
||||
'ModelSelectionStep.vue',
|
||||
'SourceUploadStep.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(typesSource, /export type StepId = 'create' \| 'model' \| 'upload' \| 'preview' \| 'generate' \| 'results'/, '步骤类型缺少独立大模型选择步骤')
|
||||
assert.match(modelSource, /export function buildPreviewItems/, '缺少切片来源映射生成函数')
|
||||
assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数')
|
||||
assert.match(modelSource, /sourceFileId/, '切片生成没有写入来源文件标识')
|
||||
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
|
||||
assert.match(
|
||||
viewSource,
|
||||
/buildPreviewItems\([\s\S]*?file\.content,[\s\S]*?processType\.value,[\s\S]*?String\(file\.uid\),[\s\S]*?unstructuredOptions\.value/,
|
||||
'预览没有按文件分别生成或未传入非结构化切分配置',
|
||||
)
|
||||
|
||||
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 structuredOptionsPath = path.join(createDir, 'StructuredOptionsPanel.vue')
|
||||
const unstructuredOptionsPath = path.join(createDir, 'UnstructuredOptionsPanel.vue')
|
||||
const datasetSplitEditorPath = path.join(createDir, 'DatasetSplitEditor.vue')
|
||||
const generationOptionsPath = path.join(createDir, 'GenerationOptionsPanel.vue')
|
||||
const modelSelectionPath = path.join(createDir, 'ModelSelectionStep.vue')
|
||||
const sourceUploadPath = path.join(createDir, 'SourceUploadStep.vue')
|
||||
const [
|
||||
taskSetupSource,
|
||||
structuredOptionsSource,
|
||||
unstructuredOptionsSource,
|
||||
datasetSplitEditorSource,
|
||||
generationControlSource,
|
||||
modelSelectionSource,
|
||||
sourceUploadSource,
|
||||
] = await Promise.all([
|
||||
readFile(taskSetupPath, 'utf8'),
|
||||
readFile(structuredOptionsPath, 'utf8'),
|
||||
readFile(unstructuredOptionsPath, 'utf8'),
|
||||
readFile(datasetSplitEditorPath, 'utf8'),
|
||||
readFile(generationOptionsPath, 'utf8'),
|
||||
readFile(modelSelectionPath, 'utf8'),
|
||||
readFile(sourceUploadPath, 'utf8'),
|
||||
])
|
||||
const taskSetupFeatureSource = [
|
||||
taskSetupSource,
|
||||
structuredOptionsSource,
|
||||
unstructuredOptionsSource,
|
||||
datasetSplitEditorSource,
|
||||
].join('\n')
|
||||
|
||||
for (const componentPath of [structuredOptionsPath, unstructuredOptionsPath, datasetSplitEditorPath]) {
|
||||
assert.ok(existsSync(componentPath), `缺少任务配置拆分组件:${path.basename(componentPath)}`)
|
||||
}
|
||||
assert.ok(taskSetupSource.split('\n').length < 800, 'TaskSetupStep 拆分后仍超过 800 行')
|
||||
assert.match(taskSetupSource, /<StructuredOptionsPanel/, '任务配置没有挂载结构化选项面板')
|
||||
assert.match(taskSetupSource, /<UnstructuredOptionsPanel/, '任务配置没有挂载非结构化选项面板')
|
||||
assert.match(structuredOptionsSource, /<DatasetSplitEditor/, '结构化选项没有复用数据集划分编辑器')
|
||||
assert.match(unstructuredOptionsSource, /<DatasetSplitEditor/, '非结构化选项没有复用数据集划分编辑器')
|
||||
|
||||
for (const marker of ['<el-upload', '源数据上传', '数据源配置', 'uploadedFiles']) {
|
||||
assert.ok(!taskSetupFeatureSource.includes(marker), `第一步仍包含上传职责:${marker}`)
|
||||
}
|
||||
assert.match(viewSource, /<ModelSelectionStep\s+[\s\S]*?v-else-if="currentStepId === 'model'"/, '第二步没有挂载独立大模型选择组件')
|
||||
assert.match(viewSource, /<SourceUploadStep\s+[\s\S]*?v-else-if="currentStepId === 'upload'"/, '第三步没有挂载独立上传组件')
|
||||
assert.match(viewSource, /if \(currentStepId\.value === 'create'\) return '继续:选择大模型'/, '第一步主按钮没有指向大模型选择')
|
||||
assert.match(viewSource, /if \(currentStepId\.value === 'model'\) return '继续:上传文件'/, '第二步主按钮没有指向上传文件')
|
||||
assert.match(viewSource, /if \(currentStepId\.value === 'upload'\) return '继续:数据预览'/, '第三步主按钮没有指向数据预览')
|
||||
assert.match(draftSource, /DATA_PROCESS_DRAFT_SCHEMA_VERSION = 6/, '安全草稿格式必须升级到 v6')
|
||||
|
||||
const nextFromCreateStart = viewSource.indexOf('async function nextFromCreate()')
|
||||
const nextFromModelStart = viewSource.indexOf('async function nextFromModel()', nextFromCreateStart)
|
||||
const nextFromUploadStart = viewSource.indexOf('function nextFromUpload()', nextFromModelStart)
|
||||
const selectPreviewFileStart = viewSource.indexOf('function selectPreviewFile(', nextFromUploadStart)
|
||||
assert.ok(
|
||||
nextFromCreateStart >= 0 && nextFromModelStart > nextFromCreateStart && nextFromUploadStart > nextFromModelStart,
|
||||
'缺少创建、大模型选择与上传步骤的独立跳转函数',
|
||||
)
|
||||
const nextFromCreateSource = viewSource.slice(nextFromCreateStart, nextFromModelStart)
|
||||
const nextFromModelSource = viewSource.slice(nextFromModelStart, nextFromUploadStart)
|
||||
const nextFromUploadSource = viewSource.slice(nextFromUploadStart, selectPreviewFileStart)
|
||||
assert.match(nextFromCreateSource, /taskSetupRef\.value\?\.validate\(\)/, '创建步骤继续前没有校验任务配置')
|
||||
assert.match(nextFromCreateSource, /goToStep\('model'\)/, '创建步骤校验通过后没有进入大模型选择')
|
||||
assert.doesNotMatch(nextFromCreateSource, /uploadedFiles|buildPreviewItems/, '创建步骤仍在校验文件或提前生成预览')
|
||||
assert.match(nextFromModelSource, /modelSelectionRef\.value\?\.validate\(\)/, '大模型选择步骤继续前没有校验模型配置')
|
||||
assert.match(nextFromModelSource, /goToStep\('upload'\)/, '大模型选择完成后没有进入上传文件')
|
||||
assert.match(nextFromUploadSource, /uploadedFiles\.value\.length === 0/, '上传步骤继续前没有校验源数据')
|
||||
assert.match(nextFromUploadSource, /buildPreviewItems\(/, '上传步骤没有在进入预览前生成预览数据')
|
||||
assert.match(nextFromUploadSource, /goToStep\('preview'\)/, '上传步骤完成后没有进入数据预览')
|
||||
assert.match(viewSource, /function goToStep\(stepId: StepId\)[\s\S]*?WIZARD_STEPS\.findIndex/, '向导跳转没有使用稳定步骤标识')
|
||||
assert.match(viewSource, /currentStepId\.value === 'preview'[\s\S]*?goToStep\('generate'\)/, '数据预览步骤没有进入开始生成')
|
||||
assert.match(viewSource, /generation\.status === 'success'[\s\S]*?goToStep\('results'\)/, '生成成功后没有进入结果编辑与保存')
|
||||
assert.match(draftSource, /currentStepId:\s*bindings\.currentStepId\.value/, '草稿没有保存稳定步骤标识')
|
||||
const draftSnapshotSource = draftSource.slice(
|
||||
draftSource.indexOf('function draftSnapshot()'),
|
||||
draftSource.indexOf('function writeDraft'),
|
||||
)
|
||||
for (const forbiddenField of ['uploadedFiles', 'previewItems', 'results', 'password', 'token']) {
|
||||
assert.ok(!draftSnapshotSource.includes(forbiddenField), `安全草稿不应持久化:${forbiddenField}`)
|
||||
}
|
||||
assert.match(draftSource, /writeDraft\(false\)/, '恢复旧草稿后没有立即覆盖潜在敏感数据')
|
||||
assert.match(viewSource, /<ResultEditorStep\s+[\s\S]*?v-else-if="currentStepId === 'results'"/, '结果编辑器必须只在结果步骤渲染')
|
||||
assert.match(viewSource, /watch\(processType,[\s\S]*?resetSourceDataForProcessTypeChange\(\)/, '切换处理类型后没有失效旧源数据')
|
||||
assert.match(viewSource, /function resetSourceDataForProcessTypeChange\(\)[\s\S]*?uploadedFiles\.value = \[\][\s\S]*?selectedPreviewFileId\.value = null/, '旧源数据失效没有同步清理文件与预览选择')
|
||||
|
||||
assert.match(taskSetupSource, /v-if="processType === 'structured'"/, '结构化配置必须仅在结构化数据类型下显示')
|
||||
for (const option of [
|
||||
'清理无效数据',
|
||||
'识别表格结构',
|
||||
'重复数据去重',
|
||||
'数据格式标准化',
|
||||
'异常数据过滤',
|
||||
'敏感信息脱敏',
|
||||
]) {
|
||||
assert.ok(structuredOptionsSource.includes(option), `结构化预处理缺少选项:${option}`)
|
||||
}
|
||||
assert.ok(structuredOptionsSource.includes('生成选项'), '结构化配置缺少生成选项分类')
|
||||
for (const splitName of ['训练集', '验证集', '测试集']) {
|
||||
assert.ok(datasetSplitEditorSource.includes(splitName), `生成选项缺少数据集划分:${splitName}`)
|
||||
}
|
||||
assert.match(datasetSplitEditorSource, /const splitTotal = computed/, '数据集划分缺少比例总和计算')
|
||||
assert.match(taskSetupSource, /splitTotal\.value !== 100/, '数据集划分缺少总和 100% 校验')
|
||||
assert.ok(datasetSplitEditorSource.includes('训练集、验证集和测试集比例总和必须为 100%'), '数据集划分缺少就地错误提示')
|
||||
for (const splitField of ['train', 'validation', 'test']) {
|
||||
assert.match(
|
||||
datasetSplitEditorSource,
|
||||
new RegExp(`modelValue\\.${splitField}[\\s\\S]*?:min="0"[\\s\\S]*?:max="100"[\\s\\S]*?:step="1"[\\s\\S]*?:precision="0"`),
|
||||
`数据集划分字段 ${splitField} 缺少 0~100 的整数限制`,
|
||||
)
|
||||
}
|
||||
assert.match(structuredOptionsSource, /<el-input-number[\s\S]*options\.qaPairsPerRow[\s\S]*:min="1"[\s\S]*:max="5"/, '每行生成数量必须限制在 1 到 5')
|
||||
assert.match(viewSource, /const structuredOptions = ref<StructuredProcessOptions>/, '父页面缺少结构化配置状态')
|
||||
assert.match(stateSource, /datasetSplit:\s*\{ train: 80, validation: 10, test: 10 \}/, '数据集划分默认值必须为 80/10/10')
|
||||
assert.match(draftSource, /structuredOptions:\s*\{[\s\S]*\.\.\.bindings\.structuredOptions\.value/, '结构化配置没有写入草稿')
|
||||
assert.match(draftSource, /bindings\.structuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.structuredOptions/, '结构化配置没有从草稿恢复')
|
||||
assert.match(viewSource, /v-model:structured-options="structuredOptions"/, '父页面没有双向绑定结构化配置')
|
||||
assert.match(generationSource, /createResults\([\s\S]*bindings\.structuredOptions\.value/, '每行生成数量没有接入结果生成逻辑')
|
||||
|
||||
for (const field of [
|
||||
'generationModelId',
|
||||
'generationPrompt',
|
||||
'qualityFilterEnabled',
|
||||
'filterLowQuality',
|
||||
'filterShortContent',
|
||||
'minOutputLength',
|
||||
]) {
|
||||
assert.ok(typesSource.includes(field), `生成控制配置缺少字段:${field}`)
|
||||
assert.ok(implementationSource.includes(field), `父页面默认值或草稿状态缺少字段:${field}`)
|
||||
}
|
||||
assert.match(structuredOptionsSource, /GenerationOptionsPanel/, '结构化生成选项没有复用统一的质量筛选组件')
|
||||
assert.match(unstructuredOptionsSource, /GenerationOptionsPanel/, '非结构化生成选项没有复用统一的质量筛选组件')
|
||||
assert.match(structuredOptionsSource, /:options="options"/, '结构化生成选项未接入统一配置组件')
|
||||
assert.match(unstructuredOptionsSource, /:options="options"/, '非结构化生成选项未接入统一配置组件')
|
||||
assert.doesNotMatch(taskSetupFeatureSource, /<h3>大模型<\/h3>|section="model"/, '第一步不应继续承载大模型配置')
|
||||
assert.match(modelSelectionSource, /<h3[^>]*>大模型选择<\/h3>/, '独立步骤缺少大模型选择标题')
|
||||
assert.match(modelSelectionSource, /section="model"/, '独立步骤没有挂载模型配置')
|
||||
assert.match(modelSelectionSource, /defineExpose\(\{ validate \}\)/, '独立大模型选择步骤没有暴露继续前校验')
|
||||
assert.match(modelSelectionSource, /class="form-section"/, '大模型选择步骤没有沿用第一步的通栏表单分区')
|
||||
assert.doesNotMatch(modelSelectionSource, /max-width:\s*980px/, '大模型选择步骤不应使用比第一步更窄的固定内容宽度')
|
||||
assert.match(taskSetupFeatureSource, /section="quality"/, '质量筛选没有保留在生成选项分类中')
|
||||
assert.doesNotMatch(generationControlSource, /<h4>大模型<\/h4>/, '大模型不应继续作为生成选项内部子分类')
|
||||
for (const label of ['大模型', '数据生成模型', '默认提示语', '质量筛选', '过滤低质量内容', '过滤过短内容', '最少字数']) {
|
||||
assert.ok(generationControlSource.includes(label), `生成控制界面缺少:${label}`)
|
||||
}
|
||||
assert.match(generationControlSource, /filterable/, '数据生成模型下拉必须支持搜索')
|
||||
assert.match(generationControlSource, /maxlength="500"/, '默认提示语缺少合理的长度限制')
|
||||
assert.match(generationControlSource, /\.model-field\s*\{[\s\S]*?display:\s*flex[\s\S]*?flex-direction:\s*column/, '大模型字段没有使用稳定的纵向表单布局')
|
||||
assert.match(generationControlSource, /\.generation-config-group\s*\{[\s\S]*?border:\s*1px solid #e2e5ec/, '大模型配置没有保留统一配置面板边框')
|
||||
assert.match(generationControlSource, /\.model-config-group\s*\{[\s\S]*?padding:\s*0[\s\S]*?border:\s*0/, '独立大模型步骤仍存在嵌套卡片挤压')
|
||||
assert.match(
|
||||
generationControlSource,
|
||||
/\.model-config-group \.advanced-settings-grid\s*\{[\s\S]*?grid-template-columns:\s*1fr/,
|
||||
'大模型高级参数没有改为与第一步一致的纵向布局',
|
||||
)
|
||||
assert.match(stateSource, /const DEFAULT_GENERATION_PROMPT\s*=\s*['"][^'"]{40,}['"]/, '大模型配置缺少可直接使用的默认提示语')
|
||||
assert.equal((stateSource.match(/generationPrompt:\s*DEFAULT_GENERATION_PROMPT/g) || []).length, 2, '结构化与非结构化任务必须共用默认提示语')
|
||||
assert.match(generationControlSource, /v-if="options\.qualityFilterEnabled"/, '质量规则没有随总开关渐进显示')
|
||||
assert.match(generationControlSource, /v-if="options\.filterShortContent"/, '最少字数没有随短内容规则显示')
|
||||
assert.match(generationControlSource, /:min="1"[\s\S]*:max="1000"/, '最少字数缺少 1 到 1000 的边界限制')
|
||||
assert.match(taskSetupSource, /qualityValidationMessage/, '质量规则缺少继续前校验')
|
||||
assert.match(viewSource, /useModelsStore/, '创建页没有加载模型列表')
|
||||
assert.match(viewSource, /model\.type === 'LLM'/, '数据生成模型列表没有排除非大模型')
|
||||
assert.match(viewSource, /<ModelSelectionStep[\s\S]*?:models="generationModels"/, '创建页没有向独立大模型选择步骤传递模型列表')
|
||||
|
||||
assert.match(typesSource, /export interface UnstructuredProcessOptions/, '缺少非结构化处理选项类型')
|
||||
for (const field of [
|
||||
'preprocessOptions',
|
||||
'chunkMethod',
|
||||
'chunkSize',
|
||||
'chunkOverlap',
|
||||
'minChunkSize',
|
||||
'customDelimiter',
|
||||
'preserveTables',
|
||||
'preserveCodeBlocks',
|
||||
'preserveLists',
|
||||
'semanticEnrichment',
|
||||
'qaPairsPerChunk',
|
||||
'datasetSplit',
|
||||
]) {
|
||||
assert.ok(typesSource.includes(field), `非结构化处理选项缺少字段:${field}`)
|
||||
}
|
||||
for (const removedField of ['contextScope', 'generationTypes', 'skipUnanswerable']) {
|
||||
assert.ok(!typesSource.includes(removedField), `简化后仍保留低频生成字段:${removedField}`)
|
||||
}
|
||||
|
||||
assert.match(taskSetupSource, /v-if="processType === 'unstructured'"/, '非结构化配置必须仅在非结构化数据类型下显示')
|
||||
assert.ok(unstructuredOptionsSource.includes('智能预处理'), '简化后缺少智能预处理总开关')
|
||||
assert.ok(unstructuredOptionsSource.includes('敏感信息脱敏'), '简化后缺少脱敏开关')
|
||||
assert.match(unstructuredOptionsSource, /const smartPreprocessEnabled = computed/, '智能预处理没有映射到内部处理项')
|
||||
assert.match(unstructuredOptionsSource, /function updateSmartPreprocess/, '智能预处理开关缺少更新逻辑')
|
||||
assert.match(unstructuredOptionsSource, /function updateDesensitize/, '脱敏开关缺少更新逻辑')
|
||||
|
||||
assert.ok(unstructuredOptionsSource.includes('切分选项'), '非结构化配置缺少切分选项分类')
|
||||
for (const method of ['自动语义切分', '按标题和段落', '按固定长度', '自定义分隔符']) {
|
||||
assert.ok(unstructuredOptionsSource.includes(method), `切分方式缺少选项:${method}`)
|
||||
}
|
||||
for (const label of ['切片长度', '重叠长度', '最小切片长度', '保护表格、代码和列表']) {
|
||||
assert.ok(unstructuredOptionsSource.includes(label), `切分选项缺少配置:${label}`)
|
||||
}
|
||||
assert.doesNotMatch(unstructuredOptionsSource, /advancedChunkSettingsOpen|>高级设置</, '切分核心参数不应再隐藏在高级设置中')
|
||||
assert.match(taskSetupSource, /if \(chunkValidationMessage\.value\) \{[\s\S]*?revealValidation\(\)[\s\S]*?return false/, '高级切分配置校验失败时没有重新展开定位')
|
||||
assert.match(unstructuredOptionsSource, /const preserveSpecialContentEnabled = computed/, '特殊内容保护没有合并为单一开关')
|
||||
assert.match(unstructuredOptionsSource, /function updateSpecialContentProtection/, '特殊内容保护开关缺少更新逻辑')
|
||||
assert.match(unstructuredOptionsSource, /options\.chunkSize[\s\S]*?:min="200"[\s\S]*?:max="2000"/, '切片长度必须限制在 200 到 2000 Token')
|
||||
assert.match(unstructuredOptionsSource, /options\.chunkOverlap[\s\S]*?:min="0"[\s\S]*?:max="500"/, '重叠长度必须限制在 0 到 500 Token')
|
||||
assert.match(unstructuredOptionsSource, /options\.minChunkSize[\s\S]*?:min="20"[\s\S]*?:max="500"/, '最小切片长度必须限制在 20 到 500 Token')
|
||||
|
||||
for (const label of ['每个切片生成数量', '数据集划分']) {
|
||||
assert.ok(taskSetupFeatureSource.includes(label), `非结构化生成选项缺少:${label}`)
|
||||
}
|
||||
for (const removedLabel of ['上下文范围', '问题类型', '跳过无法回答的内容']) {
|
||||
assert.ok(!taskSetupFeatureSource.includes(removedLabel), `简化后仍显示低频选项:${removedLabel}`)
|
||||
}
|
||||
assert.match(unstructuredOptionsSource, /options\.qaPairsPerChunk[\s\S]*?:min="1"[\s\S]*?:max="3"/, '每个切片生成数量必须限制在 1 到 3')
|
||||
assert.match(taskSetupSource, /unstructuredSplitTotal\.value !== 100/, '非结构化数据集划分缺少总和 100% 校验')
|
||||
assert.match(taskSetupSource, /chunkOverlap \+ props\.unstructuredOptions\.minChunkSize[\s\S]*?> props\.unstructuredOptions\.chunkSize/, '切分配置未校验重叠长度与最小切片长度的组合边界')
|
||||
assert.ok(unstructuredOptionsSource.includes('Token 数为轻量估算值'), '切片长度缺少 Token 估算说明')
|
||||
assert.match(unstructuredOptionsSource, /\.chunk-settings-grid\s*\{[\s\S]*?grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '核心切分参数没有收紧为三列布局')
|
||||
|
||||
assert.match(viewSource, /const unstructuredOptions = ref<UnstructuredProcessOptions>/, '父页面缺少非结构化配置状态')
|
||||
assert.match(stateSource, /chunkMethod:\s*'semantic'/, '非结构化默认切分方式必须为自动语义切分')
|
||||
assert.match(stateSource, /chunkSize:\s*800/, '默认切片长度必须为 800 Token')
|
||||
assert.match(stateSource, /chunkOverlap:\s*100/, '默认重叠长度必须为 100 Token')
|
||||
assert.match(stateSource, /minChunkSize:\s*100/, '默认最小切片长度必须为 100 Token')
|
||||
assert.match(stateSource, /qaPairsPerChunk:\s*1/, '默认每个切片必须生成 1 个问答对')
|
||||
assert.match(draftSource, /unstructuredOptions:\s*\{[\s\S]*\.\.\.bindings\.unstructuredOptions\.value/, '非结构化配置没有写入草稿')
|
||||
assert.match(draftSource, /bindings\.unstructuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.unstructuredOptions/, '非结构化配置没有从草稿恢复')
|
||||
assert.match(viewSource, /v-model:unstructured-options="unstructuredOptions"/, '父页面没有双向绑定非结构化配置')
|
||||
assert.match(draftSource, /DATA_PROCESS_DRAFT_SCHEMA_VERSION = \d+/, '草稿缺少版本标识')
|
||||
assert.match(draftSource, /schemaVersion:\s*DATA_PROCESS_DRAFT_SCHEMA_VERSION/, '草稿快照没有写入版本标识')
|
||||
assert.match(draftSource, /bindings\.goToStep\('create'\)/, '恢复配置后应回到安全的创建步骤')
|
||||
assert.match(viewSource, /JSON\.stringify\(previewAffectingOptions\(\)\)/, '影响切分的非结构化配置没有纳入预览失效判断')
|
||||
|
||||
const previewOptionsStart = viewSource.indexOf('function previewAffectingOptions()')
|
||||
const previewOptionsEnd = viewSource.indexOf('function generationAffectingOptions()', previewOptionsStart)
|
||||
assert.ok(previewOptionsStart >= 0 && previewOptionsEnd > previewOptionsStart, '缺少预览影响配置签名函数')
|
||||
const previewOptionsSource = viewSource.slice(previewOptionsStart, previewOptionsEnd)
|
||||
for (const field of [
|
||||
'preprocessOptions',
|
||||
'chunkMethod',
|
||||
'chunkSize',
|
||||
'chunkOverlap',
|
||||
'minChunkSize',
|
||||
'customDelimiter',
|
||||
'preserveTables',
|
||||
'preserveCodeBlocks',
|
||||
'preserveLists',
|
||||
]) {
|
||||
assert.ok(previewOptionsSource.includes(field), `预览签名缺少切分影响字段:${field}`)
|
||||
}
|
||||
for (const field of ['semanticEnrichment', 'qaPairsPerChunk', 'datasetSplit']) {
|
||||
assert.ok(!previewOptionsSource.includes(field), `生成字段 ${field} 不应导致预览重建并丢失编辑`)
|
||||
}
|
||||
|
||||
const generationOptionsStart = viewSource.indexOf('function generationAffectingOptions()')
|
||||
const generationOptionsEnd = viewSource.indexOf('const generationOptionsSignature', generationOptionsStart)
|
||||
assert.ok(generationOptionsStart >= 0 && generationOptionsEnd > generationOptionsStart, '缺少生成影响配置签名函数')
|
||||
const generationOptionsSource = viewSource.slice(generationOptionsStart, generationOptionsEnd)
|
||||
for (const field of [
|
||||
'semanticEnrichment',
|
||||
'qaPairsPerChunk',
|
||||
'datasetSplit',
|
||||
'generationModelId',
|
||||
'generationPrompt',
|
||||
'qualityFilterEnabled',
|
||||
'filterLowQuality',
|
||||
'filterShortContent',
|
||||
'minOutputLength',
|
||||
]) {
|
||||
assert.ok(generationOptionsSource.includes(field), `生成签名缺少字段:${field}`)
|
||||
}
|
||||
assert.match(
|
||||
viewSource,
|
||||
/watch\(generationOptionsSignature,[\s\S]*?resetDownstream\(\)/,
|
||||
'生成配置变化后没有仅失效下游结果',
|
||||
)
|
||||
for (const mutationFunction of [
|
||||
'updatePreviewContent',
|
||||
'restorePreviewItem',
|
||||
'addPreviewItem',
|
||||
'removePreviewItem',
|
||||
]) {
|
||||
const mutationStart = viewSource.indexOf(`function ${mutationFunction}`)
|
||||
const mutationEnd = viewSource.indexOf('\nfunction ', mutationStart + 1)
|
||||
const mutationSource = viewSource.slice(mutationStart, mutationEnd === -1 ? undefined : mutationEnd)
|
||||
assert.ok(mutationSource.includes('resetDownstream()'), `预览变更 ${mutationFunction} 后没有失效旧生成结果`)
|
||||
}
|
||||
assert.match(modelSource, /unstructuredOptions\?: UnstructuredProcessOptions/, '切片预览没有接收非结构化配置')
|
||||
assert.match(modelSource, /qaPairsPerChunk/, '每个切片生成数量没有接入结果生成逻辑')
|
||||
|
||||
const transpiledModel = ts.transpileModule(modelSource, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
const previewModelModule = await import(`data:text/javascript;base64,${Buffer.from(transpiledModel).toString('base64')}`)
|
||||
const longDocument = Array.from(
|
||||
{ length: 180 },
|
||||
(_, index) => `${index + 1}. 这是用于验证非结构化切分边界的完整文本段落。`,
|
||||
).join('\n')
|
||||
const baseUnstructuredOptions = {
|
||||
preprocessOptions: [],
|
||||
chunkMethod: 'semantic',
|
||||
chunkSize: 200,
|
||||
chunkOverlap: 50,
|
||||
minChunkSize: 50,
|
||||
customDelimiter: '',
|
||||
preserveTables: false,
|
||||
preserveCodeBlocks: false,
|
||||
preserveLists: false,
|
||||
semanticEnrichment: false,
|
||||
qaPairsPerChunk: 3,
|
||||
datasetSplit: { train: 80, validation: 10, test: 10 },
|
||||
generationModelId: 1,
|
||||
generationPrompt: '仅输出问答对',
|
||||
qualityFilterEnabled: false,
|
||||
filterLowQuality: true,
|
||||
filterShortContent: true,
|
||||
minOutputLength: 20,
|
||||
}
|
||||
|
||||
for (const chunkMethod of ['semantic', 'heading', 'fixed', 'custom']) {
|
||||
const options = {
|
||||
...baseUnstructuredOptions,
|
||||
chunkMethod,
|
||||
customDelimiter: chunkMethod === 'custom' ? '\\n' : '',
|
||||
}
|
||||
const previewItems = previewModelModule.buildPreviewItems(longDocument, 'unstructured', chunkMethod, options)
|
||||
assert.ok(previewItems.length > 1, `${chunkMethod} 切分方式未生成多个切片`)
|
||||
assert.ok(
|
||||
previewItems.every((item) => longDocument.slice(item.sourceStart, item.sourceEnd) === item.originalContent),
|
||||
`${chunkMethod} 切分方式的来源偏移不准确`,
|
||||
)
|
||||
assert.ok(
|
||||
previewItems.every((item) => item.sourceStartLine <= item.sourceEndLine),
|
||||
`${chunkMethod} 切分方式的来源行号不准确`,
|
||||
)
|
||||
}
|
||||
|
||||
const overlapDocument = '甲'.repeat(1200)
|
||||
const overlapItems = previewModelModule.buildPreviewItems(overlapDocument, 'unstructured', 'overlap-check', {
|
||||
...baseUnstructuredOptions,
|
||||
chunkMethod: 'fixed',
|
||||
})
|
||||
assert.equal(
|
||||
overlapItems[0].sourceEnd - overlapItems[1].sourceStart,
|
||||
100,
|
||||
'固定长度切分没有按配置保留 50 个估算 Token 的重叠内容',
|
||||
)
|
||||
|
||||
const headingDocument = `${'甲'.repeat(150)}\n# 第二章\n${'乙'.repeat(600)}`
|
||||
const headingItems = previewModelModule.buildPreviewItems(headingDocument, 'unstructured', 'heading-check', {
|
||||
...baseUnstructuredOptions,
|
||||
chunkMethod: 'heading',
|
||||
chunkOverlap: 0,
|
||||
})
|
||||
assert.ok(!headingItems[0].originalContent.includes('# 第二章'), '按标题切分未在新标题前结束上一切片')
|
||||
assert.ok(headingItems[1].originalContent.startsWith('# 第二章'), '按标题切分未从新标题开始下一切片')
|
||||
|
||||
const customDocument = `${'甲'.repeat(150)}<CUT>${'乙'.repeat(600)}`
|
||||
const customItems = previewModelModule.buildPreviewItems(customDocument, 'unstructured', 'custom-check', {
|
||||
...baseUnstructuredOptions,
|
||||
chunkMethod: 'custom',
|
||||
chunkOverlap: 0,
|
||||
customDelimiter: '<CUT>',
|
||||
})
|
||||
assert.ok(customItems[0].originalContent.endsWith('<CUT>'), '自定义切分未在指定分隔符处结束切片')
|
||||
|
||||
function assertProtectedContent(optionField, block, label) {
|
||||
const document = `${'前言。'.repeat(50)}\n${block}\n${'结尾。'.repeat(100)}`
|
||||
const enabledItems = previewModelModule.buildPreviewItems(document, 'unstructured', `${optionField}-on`, {
|
||||
...baseUnstructuredOptions,
|
||||
chunkMethod: 'fixed',
|
||||
chunkOverlap: 0,
|
||||
preserveTables: false,
|
||||
preserveCodeBlocks: false,
|
||||
preserveLists: false,
|
||||
[optionField]: true,
|
||||
})
|
||||
const disabledItems = previewModelModule.buildPreviewItems(document, 'unstructured', `${optionField}-off`, {
|
||||
...baseUnstructuredOptions,
|
||||
chunkMethod: 'fixed',
|
||||
chunkOverlap: 0,
|
||||
preserveTables: false,
|
||||
preserveCodeBlocks: false,
|
||||
preserveLists: false,
|
||||
})
|
||||
assert.ok(enabledItems.some((item) => item.originalContent.includes(block)), `${label}开启后仍被从内部切断`)
|
||||
assert.ok(!disabledItems.some((item) => item.originalContent.includes(block)), `${label}关闭后的对照用例未命中切分边界`)
|
||||
}
|
||||
|
||||
const codeBlock = ['```ts', ...Array.from({ length: 36 }, (_, index) => `const value${index} = ${index};`), '```'].join('\n')
|
||||
const tableBlock = [
|
||||
'| 字段 | 说明 |',
|
||||
'| --- | --- |',
|
||||
...Array.from({ length: 36 }, (_, index) => `| field_${index} | 字段说明 ${index} |`),
|
||||
].join('\n')
|
||||
const listBlock = Array.from({ length: 42 }, (_, index) => `- 列表项 ${index + 1}:这是需要完整保留的内容。`).join('\n')
|
||||
assertProtectedContent('preserveCodeBlocks', codeBlock, '代码块')
|
||||
assertProtectedContent('preserveTables', tableBlock, '表格')
|
||||
assertProtectedContent('preserveLists', listBlock, '列表')
|
||||
|
||||
const samplePreviewItems = previewModelModule.buildPreviewItems(
|
||||
longDocument,
|
||||
'unstructured',
|
||||
'generation-check',
|
||||
baseUnstructuredOptions,
|
||||
)
|
||||
assert.ok(samplePreviewItems.length > 12, '测试文档未生成足够的切片')
|
||||
const generatedResults = previewModelModule.createResults(samplePreviewItems.slice(0, 13), baseUnstructuredOptions)
|
||||
assert.equal(generatedResults.length, 39, '每个切片生成 3 个问答对未完整应用到所有切片')
|
||||
|
||||
const shortContentItems = [{
|
||||
...samplePreviewItems[0],
|
||||
editedContent: '问:示例\n短回答',
|
||||
}]
|
||||
const filteredShortResults = previewModelModule.createResults(shortContentItems, {
|
||||
...baseUnstructuredOptions,
|
||||
qualityFilterEnabled: true,
|
||||
filterLowQuality: false,
|
||||
filterShortContent: true,
|
||||
minOutputLength: 20,
|
||||
})
|
||||
assert.equal(filteredShortResults.length, 0, '开启过短内容过滤后仍保留低于最少字数的结果')
|
||||
|
||||
const invalidContentItems = [{
|
||||
...samplePreviewItems[0],
|
||||
status: 'invalid',
|
||||
}]
|
||||
const filteredInvalidResults = previewModelModule.createResults(invalidContentItems, {
|
||||
...baseUnstructuredOptions,
|
||||
qualityFilterEnabled: true,
|
||||
filterLowQuality: true,
|
||||
filterShortContent: false,
|
||||
})
|
||||
assert.equal(filteredInvalidResults.length, 0, '开启低质量过滤后仍保留标记为无效的结果')
|
||||
|
||||
const legacyExternalItems = previewModelModule.buildPreviewItems('a\nb\nc\nd', 'external', 'legacy-check')
|
||||
assert.equal(legacyExternalItems.length, 2, '外来数据原有的每 3 行分组行为被破坏')
|
||||
|
||||
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(sourceUploadSource.includes(marker), `源数据文件列表缺少:${marker}`)
|
||||
}
|
||||
assert.match(
|
||||
sourceUploadSource,
|
||||
/^const[ \t]+FILE_PAGE_SIZE[ \t]*=[ \t]*10[ \t]*;?[ \t]*$/m,
|
||||
'文件分页大小必须固定为整数 10',
|
||||
)
|
||||
assert.match(
|
||||
sourceUploadSource,
|
||||
/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(
|
||||
sourceUploadSource,
|
||||
/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 = [...sourceUploadSource.matchAll(/<el-pagination\b[\s\S]*?\/>/g)]
|
||||
assert.ok(filePaginationTags.length >= 1, '文件列表必须包含分页器')
|
||||
for (const [filePaginationTag] of 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.includes(attribute), `文件分页器缺少属性:${attribute}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { descriptor: sourceUploadDescriptor } = parseSfc(sourceUploadSource, { filename: sourceUploadPath })
|
||||
const uploadedFileItemsStyles = sourceUploadDescriptor.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(
|
||||
sourceUploadSource,
|
||||
/<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(
|
||||
sourceUploadSource,
|
||||
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>/,
|
||||
'文件列表标题结构或文件数量文案缺失',
|
||||
)
|
||||
assert.match(
|
||||
sourceUploadSource,
|
||||
/<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">/,
|
||||
'文件列表缺少分页后的文件行容器',
|
||||
)
|
||||
assert.match(
|
||||
sourceUploadSource,
|
||||
/<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\)"[^>]*>/,
|
||||
'无文件时未保留原有大拖拽上传区或上传配置',
|
||||
)
|
||||
assert.match(
|
||||
sourceUploadSource,
|
||||
/<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]*?<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(
|
||||
sourceUploadSource,
|
||||
/<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*<el-button\s+size="small"\s+type="primary">继续上传<\/el-button>\s*<\/el-upload>\s*<\/div>\s*<\/div>/,
|
||||
'有文件时缺少标题右侧的继续上传触发器或上传配置',
|
||||
)
|
||||
assert.match(
|
||||
sourceUploadSource,
|
||||
/\.uploaded-file-list-header\s*\{[^}]*display:\s*flex[^}]*justify-content:\s*space-between/,
|
||||
'文件列表标题未布局为右侧继续上传按钮',
|
||||
)
|
||||
assert.match(
|
||||
sourceUploadSource,
|
||||
/\.continue-upload\s+:deep\(\.el-upload\)\s*\{[^}]*width:\s*auto;?[^}]*margin-top:\s*0;?/,
|
||||
'继续上传未覆盖内层上传节点的宽度和顶部间距',
|
||||
)
|
||||
assert.match(sourceUploadSource, /\.uploaded-file\s*\{[^}]*min-height:\s*48px/, '文件行没有保持 48px 最小高度')
|
||||
assert.match(sourceUploadSource, /<span class="file-status"><i class="fa fa-check-circle"[^>]*\/> 校验通过<\/span>/, '文件行缺少校验成功状态')
|
||||
assert.match(sourceUploadSource, /@click="emit\('remove-file', file\.uid\)"/, '文件行缺少 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\(\(\) => \{[\s\S]*?stopGenerationTimer\(\)[\s\S]*?clearTimeout\(connectionTimer\)[\s\S]*?clearTimeout\(pullTimer\)/, '生成与外部数据源计时器没有在卸载时清理')
|
||||
assert.match(viewSource, /function scrollToStepTop/, '步骤切换后没有恢复页面顶部上下文')
|
||||
assert.match(viewSource, /nextTick\(scrollToStepTop\)/, '步骤切换没有触发页面滚动复位')
|
||||
assert.match(viewStyleSource, /\.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('数据处理六步向导回归检查通过')
|
||||
180
frontend/scripts/regression-dataset-preview.mjs
Normal file
180
frontend/scripts/regression-dataset-preview.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = process.cwd()
|
||||
const previewPath = path.join(root, 'src/views/dataset/DatasetPreviewView.vue')
|
||||
const previewComponentsDir = path.join(root, 'src/views/dataset/preview')
|
||||
const previewComponentPaths = [
|
||||
'DatasetVersionBar.vue',
|
||||
'DatasetRecordTable.vue',
|
||||
'DatasetRecordEditorDialog.vue',
|
||||
'DatasetRawPreview.vue',
|
||||
].map((name) => path.join(previewComponentsDir, name))
|
||||
const apiPath = path.join(root, 'src/api/modules/dataset.ts')
|
||||
const adapterPath = path.join(root, 'src/mock/adapter.ts')
|
||||
const mockPath = path.join(root, 'src/mock/data.ts')
|
||||
const recordsPath = path.join(root, 'src/views/dataset/datasetRecords.ts')
|
||||
const versionsPath = path.join(root, 'src/mock/datasetVersions.ts')
|
||||
const source = fs.readFileSync(previewPath, 'utf8')
|
||||
const previewLogicSource = source.slice(0, source.indexOf('<style'))
|
||||
const previewComponentSources = previewComponentPaths.map((componentPath) => {
|
||||
expect(fs.existsSync(componentPath), `缺少数据集预览叶子组件:${path.basename(componentPath)}`)
|
||||
return fs.readFileSync(componentPath, 'utf8')
|
||||
})
|
||||
const previewSurfaceSource = [source, ...previewComponentSources].join('\n')
|
||||
const apiSource = fs.readFileSync(apiPath, 'utf8')
|
||||
const adapterSource = fs.readFileSync(adapterPath, 'utf8')
|
||||
const mockSource = fs.readFileSync(mockPath, 'utf8')
|
||||
const recordsSource = fs.readFileSync(recordsPath, 'utf8')
|
||||
const versionsSource = fs.readFileSync(versionsPath, 'utf8')
|
||||
const recordsModuleCode = ts.transpileModule(recordsSource, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
const { parseDatasetRecords, updateDatasetRecord } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(recordsModuleCode).toString('base64')}`
|
||||
)
|
||||
const versionsModuleCode = ts.transpileModule(versionsSource, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
const {
|
||||
activateDatasetVersion,
|
||||
appendDatasetVersion,
|
||||
createInitialVersionState,
|
||||
deleteDatasetVersion,
|
||||
getActiveDatasetVersion,
|
||||
} = await import(`data:text/javascript;base64,${Buffer.from(versionsModuleCode).toString('base64')}`)
|
||||
|
||||
function expect(condition, message) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
expect(previewLogicSource.split('\n').length < 520, 'DatasetPreviewView.vue 的逻辑与模板应保持在 520 行以内')
|
||||
for (const componentName of ['DatasetVersionBar', 'DatasetRecordTable', 'DatasetRecordEditorDialog', 'DatasetRawPreview']) {
|
||||
expect(source.includes(`import ${componentName}`), `主页面未导入叶子组件:${componentName}`)
|
||||
expect(source.includes(`<${componentName}`), `主页面未挂载叶子组件:${componentName}`)
|
||||
}
|
||||
expect(source.includes('class="preview-workspace"'), '详情页应提供数据文件工作区')
|
||||
expect(!source.includes('class="file-pane"'), '单文件数据集详情不应展示文件选择侧栏')
|
||||
expect(previewSurfaceSource.includes('class="code-line"'), '内容查看器应提供逐行展示')
|
||||
expect(previewSurfaceSource.includes('class="line-number"'), '内容查看器应显示行号')
|
||||
expect(source.includes('previewLoading'), '切换文件时应提供独立加载状态')
|
||||
expect(source.includes('previewError'), '内容加载失败时应提供错误状态')
|
||||
expect(previewSurfaceSource.includes('class="records-viewer"'), '详情页应将结构化文件展示为样本列表')
|
||||
expect(previewSurfaceSource.includes('class="record-table"'), '样本数据应使用企业级表格展示')
|
||||
expect(previewSurfaceSource.includes('fixed="right"'), '逐条编辑操作列应固定在表格右侧')
|
||||
expect(previewSurfaceSource.includes('v-for="fieldKey in tableFieldKeys"'), '企业表格应根据数据结构动态生成字段列')
|
||||
expect(previewSurfaceSource.includes("emit('edit', asDatasetRecord(row))"), '每条样本应向主页面发送编辑事件')
|
||||
expect(previewSurfaceSource.includes('暂存修改'), '单条编辑应先暂存到当前页面')
|
||||
expect(source.includes('hasPendingVersionChanges && isViewingActiveVersion'), '存在暂存修改时才应显示保存版本按钮')
|
||||
expect(source.includes('保存版本'), '文件工具栏应提供保存版本按钮')
|
||||
expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本')
|
||||
expect(previewSurfaceSource.includes('class="version-control-bar"'), '详情页应提供独立版本控制区')
|
||||
expect(previewSurfaceSource.includes('设为当前版本'), '历史版本应支持显式切换为当前版本')
|
||||
expect(previewSurfaceSource.includes('class="version-actions"'), '历史版本操作应收纳到省略号菜单')
|
||||
expect(previewSurfaceSource.includes('删除版本'), '历史版本操作菜单应提供删除入口')
|
||||
expect(previewSurfaceSource.includes('历史版本(只读)'), '历史版本应明确展示只读状态')
|
||||
expect(source.includes('AppConfirmDialog'), '删除历史版本前应使用公共危险确认弹窗')
|
||||
expect(source.includes("tone: 'danger'"), '删除历史版本确认弹窗应使用危险态')
|
||||
expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本')
|
||||
expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容')
|
||||
expect(previewSurfaceSource.includes('v-model:current-page="currentPage"'), '样本列表应支持分页浏览')
|
||||
expect(!source.includes('class="content-editor"'), '详情页不应继续提供整文件编辑器')
|
||||
expect(source.includes('saveRecord'), '逐条编辑器应提供单条保存动作')
|
||||
expect(source.includes('hasUnsavedChanges'), '在线编辑器应跟踪未保存修改')
|
||||
expect(source.includes('onBeforeRouteLeave'), '离开页面时应保护未保存修改')
|
||||
expect(previewSurfaceSource.includes("event.key.toLowerCase() === 's'"), '在线编辑器应支持快捷键保存')
|
||||
expect(source.includes('loadVersions(selectedFile)'), '版本内容加载失败后应支持重新加载')
|
||||
expect(source.includes('function resetRecordEditor()'), '主页面应提供统一的编辑器状态清理函数')
|
||||
const resetEditorStart = source.indexOf('function resetRecordEditor()')
|
||||
const updateFieldStart = source.indexOf('function updateEditField', resetEditorStart)
|
||||
const resetEditorSource = source.slice(resetEditorStart, updateFieldStart)
|
||||
for (const marker of [
|
||||
'editorVisible.value = false',
|
||||
'editingRecord.value = null',
|
||||
'editFields.value = []',
|
||||
"rawDraft.value = ''",
|
||||
"originalDraft.value = ''",
|
||||
]) {
|
||||
expect(resetEditorSource.includes(marker), `编辑器状态清理不完整:${marker}`)
|
||||
}
|
||||
const versionChangeStart = source.indexOf('async function handleViewedVersionChange')
|
||||
const activateVersionStart = source.indexOf('async function activateViewedVersion', versionChangeStart)
|
||||
const versionChangeSource = source.slice(versionChangeStart, activateVersionStart)
|
||||
expect(versionChangeSource.includes('resetRecordEditor()'), '成功切换版本后必须关闭并清空旧编辑器状态')
|
||||
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
|
||||
expect(!source.includes('deleteDataset('), '详情页不应恢复删除整个数据集的逻辑')
|
||||
expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑')
|
||||
expect(mockSource.includes('files: ['), 'Mock 数据集应包含可验收的文件列表')
|
||||
expect(mockSource.includes('mockDatasetPreviews'), 'Mock 数据应包含文件预览内容')
|
||||
expect(mockSource.includes("'mock-jsonl'"), 'Mock 数据应为通用数据集提供模拟样本')
|
||||
expect(mockSource.includes("'mock-readme'"), 'Mock 数据应为通用数据集提供模拟说明')
|
||||
expect(mockSource.includes('.map((dataset)'), '所有 Mock 数据集都应自动补齐预览文件')
|
||||
expect(mockSource.includes(': [{ id: `dataset-${dataset.id}-samples`'), '每个 Mock 数据集应只对应一个数据文件')
|
||||
expect(apiSource.includes('createDatasetFileVersion'), '数据集 API 应提供创建新版本方法')
|
||||
expect(apiSource.includes('activateDatasetFileVersion'), '数据集 API 应提供切换当前版本方法')
|
||||
expect(apiSource.includes('deleteDatasetFileVersion'), '数据集 API 应提供删除历史版本方法')
|
||||
expect(apiSource.includes('getDatasetFileVersionContent'), '数据集 API 应支持读取历史版本内容')
|
||||
expect(apiSource.includes('expected_current_version_id'), '创建和激活版本应携带乐观锁版本指针')
|
||||
expect(!apiSource.includes('updateDatasetFileContent'), '数据编辑不应继续覆盖历史版本内容')
|
||||
expect(adapterSource.includes('mock:dataset-versions:'), 'Mock 版本历史应支持刷新持久化')
|
||||
expect(adapterSource.includes('当前版本已被其他用户更新'), 'Mock 应拒绝基于过期版本的并发覆盖')
|
||||
expect(adapterSource.includes('历史版本不可覆盖'), '旧的覆盖保存接口应明确拒绝修改历史版本')
|
||||
expect(versionsSource.includes('当前版本不可删除'), 'Mock 应拒绝删除当前版本')
|
||||
expect(versionsSource.includes('初始版本不可删除'), 'Mock 应拒绝删除初始版本')
|
||||
expect(adapterSource.includes('Mock 存储空间不足'), 'Mock 存储失败时应返回明确错误且保留编辑草稿')
|
||||
|
||||
const jsonl = [
|
||||
'{"instruction":"第一条","output":"A"}',
|
||||
'',
|
||||
'{"instruction":"第二条","output":["B","C"]}',
|
||||
].join('\n')
|
||||
const parsed = parseDatasetRecords(jsonl, 'samples.jsonl')
|
||||
expect(parsed.supported && parsed.records.length === 2, 'JSONL 应跳过空行并解析为逐条样本')
|
||||
expect(parsed.records[1].sourceIndex === 2, '样本应保留原文件物理行位置')
|
||||
const updated = updateDatasetRecord(jsonl, 'samples.jsonl', 2, {
|
||||
instruction: '第二条已修改',
|
||||
output: ['B', 'C'],
|
||||
})
|
||||
const beforeLines = jsonl.split('\n')
|
||||
const afterLines = updated.split('\n')
|
||||
expect(afterLines[0] === beforeLines[0] && afterLines[1] === '', '单条保存不得改写其他样本或空行')
|
||||
expect(JSON.parse(afterLines[2]).instruction === '第二条已修改', '单条保存应准确更新目标样本')
|
||||
expect(Array.isArray(JSON.parse(afterLines[2]).output), '单条保存不应丢失数组等非字符串字段类型')
|
||||
const invalid = parseDatasetRecords('{invalid json}', 'samples.jsonl')
|
||||
expect(invalid.records[0].kind === 'invalid' && invalid.records[0].error, '非法 JSON 行应被单独标记并给出错误')
|
||||
const repairedJson = updateDatasetRecord('{invalid json}', 'samples.json', 0, { repaired: true })
|
||||
expect(JSON.parse(repairedJson).repaired === true, '非法 JSON 文件应支持按单条方式修复')
|
||||
|
||||
const v1State = createInitialVersionState('A', '2026-07-13T00:00:00Z')
|
||||
const v2State = appendDatasetVersion(v1State, 'B', '2026-07-13T01:00:00Z', '修改第一条')
|
||||
expect(v2State !== v1State, '创建版本应返回新的不可变状态')
|
||||
expect(v1State.versions.length === 1 && v1State.versions[0].content === 'A', '创建新版本不得修改历史版本')
|
||||
expect(v2State.versions[1].version === 2 && v2State.active_version_id === 'v2', '新版本编号应递增并自动激活')
|
||||
const switchedToV1 = activateDatasetVersion(v2State, 'v1')
|
||||
expect(getActiveDatasetVersion(switchedToV1).content === 'A', '切换当前版本后应读取对应历史内容')
|
||||
expect(v2State.active_version_id === 'v2' && v2State.versions[1].content === 'B', '切换版本不得污染原状态或历史快照')
|
||||
const v3State = appendDatasetVersion(v2State, 'C', '2026-07-13T02:00:00Z', '修改第二条')
|
||||
const deletedV2State = deleteDatasetVersion(v3State, 'v2')
|
||||
expect(!deletedV2State.versions.some((item) => item.id === 'v2'), '删除历史版本后不应继续返回该版本')
|
||||
expect(deletedV2State.active_version_id === 'v3', '删除历史版本不得改变当前版本指针')
|
||||
const v4State = appendDatasetVersion(deletedV2State, 'D', '2026-07-13T03:00:00Z', '继续编辑')
|
||||
expect(v4State.versions.at(-1)?.version === 4, '删除历史版本后不得复用旧版本号')
|
||||
for (const protectedVersionId of ['v1', 'v3']) {
|
||||
let protectedVersionRejected = false
|
||||
try {
|
||||
deleteDatasetVersion(v3State, protectedVersionId)
|
||||
} catch {
|
||||
protectedVersionRejected = true
|
||||
}
|
||||
expect(protectedVersionRejected, `${protectedVersionId} 受保护版本不得删除`)
|
||||
}
|
||||
let invalidVersionRejected = false
|
||||
try {
|
||||
activateDatasetVersion(v2State, 'missing')
|
||||
} catch {
|
||||
invalidVersionRejected = true
|
||||
}
|
||||
expect(invalidVersionRejected, '不存在的版本不得切换为当前版本')
|
||||
|
||||
console.log('dataset preview regression checks passed')
|
||||
42
frontend/scripts/regression-dataset-task-tab.mjs
Normal file
42
frontend/scripts/regression-dataset-task-tab.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const typesPath = path.resolve(scriptDir, '../src/types/index.ts')
|
||||
const dataPath = path.resolve(scriptDir, '../src/mock/data.ts')
|
||||
const viewPath = path.resolve(scriptDir, '../src/views/dataset/DatasetListView.vue')
|
||||
|
||||
const [typesSource, dataSource, viewSource] = await Promise.all([
|
||||
readFile(typesPath, 'utf8'),
|
||||
readFile(dataPath, 'utf8'),
|
||||
readFile(viewPath, 'utf8'),
|
||||
])
|
||||
|
||||
assert.match(typesSource, /export type DatasetSource = 'upload' \| 'task'/)
|
||||
assert.match(typesSource, /source\?: DatasetSource/)
|
||||
|
||||
assert.equal((dataSource.match(/source: 'upload'/g) || []).length, 6)
|
||||
assert.equal((dataSource.match(/source: 'task'/g) || []).length, 4)
|
||||
|
||||
for (const name of [
|
||||
'客服对话清洗集',
|
||||
'通用指令构造集',
|
||||
'用户反馈脱敏集',
|
||||
'多轮对话增强集',
|
||||
]) {
|
||||
const datasetLine = dataSource.split('\n').find((line) => line.includes(`name: '${name}'`))
|
||||
assert.ok(datasetLine, `缺少数据任务 Mock:${name}`)
|
||||
assert.match(datasetLine, /source: 'task'/, `${name} 必须标记为数据任务来源`)
|
||||
}
|
||||
|
||||
assert.match(viewSource, /const activeTab = ref<DatasetSource>\('upload'\)/)
|
||||
assert.match(
|
||||
viewSource,
|
||||
/if \(activeTab\.value === 'task'\) \{\s*return dataList\.value\.filter\(\(item\) => item\.source === 'task'\)\s*\}/,
|
||||
)
|
||||
assert.match(viewSource, /return dataList\.value\.filter\(\(item\) => item\.source !== 'task'\)/)
|
||||
assert.doesNotMatch(viewSource, /数据任务产生的数据集[\s\S]*?return \[\]/)
|
||||
|
||||
console.log('数据任务 Mock 数据与页签分流回归检查通过')
|
||||
39
frontend/scripts/regression-default-dashboard.mjs
Normal file
39
frontend/scripts/regression-default-dashboard.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
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 router = read('src/router/index.ts')
|
||||
const login = read('src/views/login/LoginView.vue')
|
||||
const sidebar = read('src/components/AppSidebar.vue')
|
||||
|
||||
assert(
|
||||
router.includes("redirect: '/dashboard'"),
|
||||
'The authenticated root and fallback routes should default to 服务看板',
|
||||
)
|
||||
|
||||
assert(
|
||||
router.includes("next('/dashboard')"),
|
||||
'An authenticated user visiting the login page should enter 服务看板',
|
||||
)
|
||||
|
||||
assert(
|
||||
login.includes("router.push('/dashboard')"),
|
||||
'A successful login should navigate to 服务看板',
|
||||
)
|
||||
|
||||
assert(
|
||||
sidebar.includes("route.path.split('/')[1] || 'dashboard'"),
|
||||
'The sidebar fallback active item should be 服务看板',
|
||||
)
|
||||
|
||||
console.log('default-dashboard regression checks passed')
|
||||
194
frontend/scripts/regression-eval-create-wizard.mjs
Normal file
194
frontend/scripts/regression-eval-create-wizard.mjs
Normal file
@@ -0,0 +1,194 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const sourceRoot = path.resolve(scriptDir, '../src')
|
||||
const evalViewDir = path.join(sourceRoot, 'views/eval')
|
||||
|
||||
const [
|
||||
createSource,
|
||||
taskStepSource,
|
||||
ruleStepSource,
|
||||
basicMetricStepSource,
|
||||
startStepSource,
|
||||
dimensionFieldsSource,
|
||||
listSource,
|
||||
routerSource,
|
||||
apiSource,
|
||||
] = await Promise.all([
|
||||
readFile(path.join(evalViewDir, 'EvalCreateView.vue'), 'utf8'),
|
||||
readFile(path.join(evalViewDir, 'create/EvalTaskSetupStep.vue'), 'utf8'),
|
||||
readFile(path.join(evalViewDir, 'create/EvalRuleSetupStep.vue'), 'utf8'),
|
||||
readFile(path.join(evalViewDir, 'create/BasicMetricSetupStep.vue'), 'utf8'),
|
||||
readFile(path.join(evalViewDir, 'create/StartEvalStep.vue'), 'utf8'),
|
||||
readFile(path.join(evalViewDir, 'create/DimensionFormFields.vue'), 'utf8'),
|
||||
readFile(path.join(evalViewDir, 'EvalView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'api/modules/eval.ts'), 'utf8'),
|
||||
])
|
||||
|
||||
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 createDimensionRoute = extractRouteBlock(routerSource, 'model-eval/dimension/create')
|
||||
assert.match(
|
||||
createDimensionRoute,
|
||||
/redirect:\s*['"]\/model-eval\/create['"]/,
|
||||
'独立创建维度入口必须收敛到新建评测向导',
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
createDimensionRoute,
|
||||
/DimensionCreateView/,
|
||||
'独立创建维度路由不应继续加载旧创建页',
|
||||
)
|
||||
|
||||
const editDimensionRoute = extractRouteBlock(routerSource, 'model-eval/dimension/:id/edit')
|
||||
assert.match(
|
||||
editDimensionRoute,
|
||||
/DimensionCreateView\.vue/,
|
||||
'编辑维度路由必须继续复用 DimensionCreateView.vue',
|
||||
)
|
||||
|
||||
assert.doesNotMatch(listSource, /评测维度/, '模型评测页不应保留评测维度页签及内容')
|
||||
assert.doesNotMatch(listSource, /activeTab\s*===\s*['"]dimensions['"]/, '模型评测页不应保留评测维度状态分支')
|
||||
assert.doesNotMatch(listSource, /getDimensionList|deleteDimension|dimensionList/, '模型评测页不应继续加载维度列表数据')
|
||||
|
||||
assert.match(
|
||||
apiSource,
|
||||
/createDimension\s*=\s*\([^)]*\)\s*=>\s*[\s\S]*?post<\{\s*id:\s*string\s*\|\s*number\s*\}>\(['"]\/dimension['"]\s*,\s*data\)/,
|
||||
'createDimension 必须声明返回包含 string | number 类型 id',
|
||||
)
|
||||
|
||||
assert.match(createSource, /EvalTaskSetupStep/, '向导缺少任务配置子组件')
|
||||
assert.match(createSource, /EvalRuleSetupStep/, '向导缺少评测规则子组件')
|
||||
assert.match(createSource, /BasicMetricSetupStep/, '向导缺少基础评测指标子组件')
|
||||
assert.match(createSource, /StartEvalStep/, '向导缺少开始评测确认子组件')
|
||||
assert.match(
|
||||
createSource,
|
||||
/任务配置[\s\S]*?大模型评测指标[\s\S]*?基础评测指标[\s\S]*?开始评测/,
|
||||
'评测创建向导必须按“任务配置、大模型评测指标、基础评测指标、开始评测”定义四个步骤',
|
||||
)
|
||||
assert.match(
|
||||
ruleStepSource,
|
||||
/:show-description=["']false["']/,
|
||||
'大模型评测指标步骤不应显示重复的规则描述输入框',
|
||||
)
|
||||
assert.match(
|
||||
ruleStepSource,
|
||||
/:show-status-settings=["']false["']/,
|
||||
'大模型评测指标步骤不应显示维度管理状态字段',
|
||||
)
|
||||
assert.match(
|
||||
ruleStepSource,
|
||||
/LLM_METRIC_TYPES[^=]*=\s*\[['"]classification['"],\s*['"]metric['"]\][\s\S]*?:allowed-types=["']LLM_METRIC_TYPES["']/,
|
||||
'大模型评测指标步骤只应提供依赖大模型的分类与评分指标',
|
||||
)
|
||||
assert.match(
|
||||
dimensionFieldsSource,
|
||||
/v-if=["']showDescription["'][^>]*label=["']描述["']/,
|
||||
'共享维度字段必须支持按场景隐藏描述输入框',
|
||||
)
|
||||
assert.match(
|
||||
dimensionFieldsSource,
|
||||
/score_min[\s\S]*?score_max[\s\S]*?pass_threshold[\s\S]*?Math\.min[\s\S]*?Math\.max/,
|
||||
'指标型评分区间变化时必须把通过阈值约束在合法范围内',
|
||||
)
|
||||
assert.match(
|
||||
dimensionFieldsSource,
|
||||
/prop=["']score_min["'][\s\S]*?:max=["'][^"']*score_max[^"']*["'][\s\S]*?prop=["']score_max["'][\s\S]*?:min=["'][^"']*score_min[^"']*["']/,
|
||||
'评分最小值和最大值输入必须具备交叉边界约束',
|
||||
)
|
||||
assert.match(createSource, /currentStep\s*=\s*ref\(0\)/, '评测创建向导缺少当前步骤状态')
|
||||
assert.match(createSource, /class=["']custom-wizard-steps["']/, '评测向导未复用数据处理的自定义步骤条结构')
|
||||
assert.match(createSource, /class=["']step-connector["']/, '评测向导步骤条缺少连接线')
|
||||
assert.match(createSource, /:aria-current=/, '步骤条缺少当前步骤的可访问性绑定')
|
||||
assert.match(createSource, /currentStep === index \? 'step' : undefined/, '步骤条当前步骤语义不正确')
|
||||
assert.doesNotMatch(createSource, /<el-steps\b|<el-step\b/, '评测向导不应继续使用 Element Plus 默认步骤条')
|
||||
assert.match(
|
||||
createSource,
|
||||
/<EvalTaskSetupStep[\s\S]*?v-if=["'][^"']*currentStep[^"']*0[^"']*["']/,
|
||||
'任务配置子组件没有绑定第一步',
|
||||
)
|
||||
assert.match(
|
||||
createSource,
|
||||
/<EvalRuleSetupStep[\s\S]*?currentStep[^"']*1/,
|
||||
'大模型评测指标子组件没有绑定第二步',
|
||||
)
|
||||
assert.match(
|
||||
createSource,
|
||||
/<BasicMetricSetupStep[\s\S]*?currentStep[^"']*2/,
|
||||
'基础评测指标子组件没有绑定第三步',
|
||||
)
|
||||
assert.match(
|
||||
createSource,
|
||||
/<StartEvalStep[\s\S]*?v-else/,
|
||||
'开始评测确认子组件没有绑定第四步',
|
||||
)
|
||||
|
||||
assert.match(taskStepSource, /prop=["']dataset_id["']/, '数据集字段缺少表单校验标识')
|
||||
assert.match(
|
||||
`${createSource}\n${taskStepSource}`,
|
||||
/data_source[\s\S]*?dataset[\s\S]*?dataset_id/,
|
||||
'选择评测数据集时必须校验 dataset_id',
|
||||
)
|
||||
|
||||
assert.match(
|
||||
basicMetricStepSource,
|
||||
/BLEU[\s\S]*?ROUGE[\s\S]*?(?:Cosine|余弦)/,
|
||||
'基础评测指标步骤必须提供 BLEU、ROUGE 与余弦相似度配置',
|
||||
)
|
||||
assert.match(
|
||||
basicMetricStepSource,
|
||||
/bleu_enabled[\s\S]*?rouge_enabled[\s\S]*?cosine_enabled/,
|
||||
'基础评测指标必须支持分别决定是否启用',
|
||||
)
|
||||
|
||||
assert.match(
|
||||
createSource,
|
||||
/await\s+createDimension\([\s\S]*?await\s+startEval\(/,
|
||||
'最终提交必须先创建新维度,再使用维度 id 启动评测',
|
||||
)
|
||||
assert.match(
|
||||
createSource,
|
||||
/createdDimension(?:\.value)?\.id|createdDimensionId|dimensionResult\.id/,
|
||||
'启动评测前必须读取新建维度返回的 id',
|
||||
)
|
||||
assert.match(
|
||||
createSource,
|
||||
/basic_metrics[\s\S]*?bleu[\s\S]*?rouge[\s\S]*?cosine/,
|
||||
'启动评测时必须提交基础评测指标配置',
|
||||
)
|
||||
assert.match(
|
||||
startStepSource,
|
||||
/任务信息[\s\S]*?大模型评测指标[\s\S]*?基础评测指标/,
|
||||
'开始评测步骤必须展示前三步配置摘要',
|
||||
)
|
||||
|
||||
assert.match(createSource, /:loading=["']submitting["']/, '最终提交按钮缺少 loading 状态')
|
||||
assert.match(createSource, /:disabled=["'][^"']*submitting/, '提交期间必须禁用重复操作')
|
||||
assert.match(createSource, />\s*开始评测\s*</, '最终提交按钮必须命名为“开始评测”')
|
||||
assert.match(createSource, /class=["'][^"']*wizard-footer/, '评测向导缺少统一底部操作栏')
|
||||
assert.match(
|
||||
createSource,
|
||||
/\.wizard-footer\s*\{[\s\S]*?display:\s*flex[\s\S]*?justify-content:\s*flex-end/,
|
||||
'底部操作栏必须保持企业表单常用的右对齐布局',
|
||||
)
|
||||
|
||||
console.log('评测创建向导回归检查通过')
|
||||
99
frontend/scripts/regression-eval-detail.mjs
Normal file
99
frontend/scripts/regression-eval-detail.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const sourceRoot = path.resolve(scriptDir, '../src')
|
||||
|
||||
const [routerSource, listSource, detailSource, apiSource, mockSource] = await Promise.all([
|
||||
readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/eval/EvalView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'views/eval/EvalDetailView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'api/modules/eval.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'mock/adapter.ts'), 'utf8'),
|
||||
])
|
||||
|
||||
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 detailRoute = extractRouteBlock(routerSource, 'model-eval/:id')
|
||||
assert.match(detailRoute, /name:\s*['"]model-eval-detail['"]/, '评测详情路由名称不正确')
|
||||
assert.match(
|
||||
detailRoute,
|
||||
/component:\s*\(\)\s*=>\s*import\(['"]@\/views\/eval\/EvalDetailView\.vue['"]\)/,
|
||||
'评测详情路由未加载 EvalDetailView.vue',
|
||||
)
|
||||
|
||||
assert.match(
|
||||
listSource,
|
||||
/router\.push\(\s*\{\s*name:\s*['"]model-eval-detail['"]\s*,\s*params:\s*\{\s*id:\s*row\.id\s*\}\s*\}\s*\)/,
|
||||
'评测任务详情按钮必须使用命名路由并携带任务 id',
|
||||
)
|
||||
assert.doesNotMatch(listSource, /详情功能开发中/, '评测任务详情入口仍是占位提示')
|
||||
|
||||
assert.match(apiSource, /export\s+const\s+getEvalDetail\b/, '评测 API 缺少 getEvalDetail')
|
||||
assert.match(
|
||||
apiSource,
|
||||
/get(?:<[^>]+>)?\(`\/model-eval\/\$\{id\}`\)/,
|
||||
'getEvalDetail 未请求 GET /model-eval/:id',
|
||||
)
|
||||
assert.ok(
|
||||
mockSource.includes('/^\\/model-eval\\/([^/]+)$/'),
|
||||
'Mock 未按任务 id 匹配 /model-eval/:id',
|
||||
)
|
||||
assert.match(mockSource, /method\s*===\s*['"]get['"]/, '评测详情 Mock 未处理 GET 请求')
|
||||
|
||||
for (const className of [
|
||||
'overall-review',
|
||||
'score-hero',
|
||||
'improvement-list',
|
||||
'sample-results-table',
|
||||
'dimension-summary',
|
||||
]) {
|
||||
assert.match(
|
||||
detailSource,
|
||||
new RegExp(`class=["'][^"']*\\b${className}\\b`),
|
||||
`评测详情页缺少 .${className} 结构`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const label of ['输入', '标准答案', '模型回答', '得分', '判定']) {
|
||||
assert.match(
|
||||
detailSource,
|
||||
new RegExp(`(?:label=["'][^"']*${label}[^"']*["']|>${label}<)`),
|
||||
`样本结果列表缺少“${label}”语义`,
|
||||
)
|
||||
}
|
||||
|
||||
assert.match(detailSource, /\bloading\b/, '评测详情页缺少加载状态')
|
||||
assert.match(detailSource, /\berror\b/, '评测详情页缺少错误状态')
|
||||
assert.match(
|
||||
detailSource,
|
||||
/(?:el-empty|empty-text|样本结果为空|暂无样本|暂无数据)/,
|
||||
'评测详情页缺少空状态',
|
||||
)
|
||||
assert.match(detailSource, /<el-pagination\b/, '评测详情页缺少样本分页')
|
||||
|
||||
for (const marketingLabel of ['OVERALL SCORE', 'LLM REVIEW', 'DIMENSIONS', 'SAMPLE RESULTS']) {
|
||||
assert.doesNotMatch(detailSource, new RegExp(marketingLabel), `企业详情页不应保留展示型英文标签:${marketingLabel}`)
|
||||
}
|
||||
assert.doesNotMatch(detailSource, /linear-gradient\s*\(/, '企业详情页不应使用大面积渐变背景')
|
||||
assert.match(detailSource, /grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)/, '桌面端概览或维度区应采用紧凑四列布局')
|
||||
|
||||
console.log('评测详情页回归检查通过')
|
||||
57
frontend/scripts/regression-fine-tune-create-ui.mjs
Normal file
57
frontend/scripts/regression-fine-tune-create-ui.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
const source = readFileSync(resolve(root, 'src/views/fine-tune/FineTuneCreateView.vue'), 'utf8')
|
||||
const modelDialogSource = readFileSync(resolve(root, 'src/components/ModelSelectDialog.vue'), 'utf8')
|
||||
const formModelSource = readFileSync(resolve(root, 'src/views/fine-tune/fineTuneFormModel.ts'), '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(!modelDialogSource.includes('width="78vw"'), 'Model dialog should not use the previous oversized 78vw width')
|
||||
assert(modelDialogSource.includes('width="860px"'), 'Model dialog should use a compact enterprise modal width')
|
||||
assert(modelDialogSource.includes('model-series-list'), 'Model dialog should include a model series list column')
|
||||
assert(modelDialogSource.includes('model-version-list'), 'Model dialog should include a snapshot/version list column')
|
||||
assert(modelDialogSource.includes('handleConfirm'), 'Model dialog should confirm the selected model before updating the form')
|
||||
assert(formModelSource.includes('auto_merge: false'), 'Auto merge should default to disabled')
|
||||
assert(source.includes('v-if="form.train_type === \'SFT\'"'), 'Merge model settings should only be visible for SFT')
|
||||
assert(source.includes('content-position="left">合并模型'), 'SFT form should include a merge model section below data configuration')
|
||||
assert(source.includes('v-model="form.auto_merge"'), 'Merge model section should provide an auto merge selector')
|
||||
assert(source.includes('label="自动合并权重并保存"'), 'Auto merge selector should use a clear visible label')
|
||||
assert(formModelSource.includes("auto_merge: form.train_type === 'SFT' && form.auto_merge"), 'Auto merge should be normalized by the shared payload builder')
|
||||
assert(source.includes('const payload = buildFineTunePayload(form, selectedGpus.value)'), 'Create and start should share one normalized payload')
|
||||
assert(source.includes('startFineTune({ ...payload, task_id: taskId })'), 'Start request should reuse the normalized payload')
|
||||
assert(source.includes('Object.assign(form, DEFAULT_TRAINING_PARAMS)'), 'Reset should reuse the canonical defaults')
|
||||
assert(!source.includes('const taskData = {'), 'The duplicated create payload should be removed')
|
||||
assert(!source.includes('const createRes: any'), 'Create response should use the API return type')
|
||||
assert(!source.includes('(check as any).exists'), 'Name-check response should use its API return type')
|
||||
assert(source.includes('任务名校验失败'), 'Name-check failures should be visible and block submission')
|
||||
|
||||
const runnableSource = ts.transpileModule(
|
||||
formModelSource.replace("import type { FineTuneStartPayload, FineTuneTask } from '@/types'", ''),
|
||||
{ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } },
|
||||
).outputText
|
||||
const model = await import(`data:text/javascript;base64,${Buffer.from(runnableSource).toString('base64')}`)
|
||||
const defaults = model.createDefaultFineTuneForm()
|
||||
const customized = { ...defaults, train_type: 'DPO', train_method: 'full', auto_merge: true, quantization_bit: 4 }
|
||||
const payload = model.buildFineTunePayload(customized, [0, 2])
|
||||
assert(payload.auto_merge === false, 'Non-SFT tasks must never enable auto merge')
|
||||
assert(payload.quantization_bit === 0, 'Full fine-tuning must never send QLoRA quantization')
|
||||
assert(payload.gpus.join(',') === '0,2', 'Selected GPUs should be preserved in the shared payload')
|
||||
const resetDefaults = { ...model.DEFAULT_TRAINING_PARAMS }
|
||||
assert(resetDefaults.lora_alpha === defaults.lora_alpha, 'Reset and initial defaults must share LoRA values')
|
||||
|
||||
console.log('fine-tune create UI regression checks passed')
|
||||
41
frontend/scripts/regression-hardware-dashboard.mjs
Normal file
41
frontend/scripts/regression-hardware-dashboard.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const sourceRoot = path.resolve(scriptDir, '../src')
|
||||
|
||||
const [viewSource, typeSource, mockSource] = await Promise.all([
|
||||
readFile(path.join(sourceRoot, 'views/system/HardwareView.vue'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'types/index.ts'), 'utf8'),
|
||||
readFile(path.join(sourceRoot, 'mock/data.ts'), 'utf8'),
|
||||
])
|
||||
|
||||
assert.match(viewSource, /title="平台性能"/, '平台性能页缺少明确标题')
|
||||
assert.match(viewSource, /硬件资源概览/, '平台性能页缺少硬件资源概览')
|
||||
assert.match(viewSource, /系统资源趋势/, '平台性能页缺少资源趋势')
|
||||
assert.match(viewSource, /GPU 资源池/, '平台性能页缺少 GPU 资源池')
|
||||
assert.match(viewSource, /gpu-detail-drawer/, 'GPU 卡片缺少详情抽屉')
|
||||
assert.match(viewSource, /GPU 进程/, 'GPU 详情缺少占用进程表')
|
||||
assert.match(viewSource, /visibilitychange/, '轮询没有在页面不可见时暂停')
|
||||
assert.match(viewSource, /requesting \|\| disposed/, '轮询缺少防重入保护')
|
||||
assert.match(viewSource, /slice\(-60\)/, '趋势快照没有限制为最近 60 个采样点')
|
||||
assert.match(viewSource, /@keydown="handleGpuKeydown/, 'GPU 卡片缺少键盘访问能力')
|
||||
assert.match(viewSource, /download_mb_s/, '网络概览没有使用明确的实时速率字段')
|
||||
assert.doesNotMatch(
|
||||
viewSource,
|
||||
/download_mb_s\s*\?\?\s*info\.network\?\.download_mb/,
|
||||
'不能把累计网络流量回退显示为实时速率',
|
||||
)
|
||||
|
||||
assert.match(typeSource, /export interface GpuProcess/, '系统监控类型缺少 GPU 进程结构')
|
||||
assert.match(typeSource, /processes\?: GpuProcess\[\]/, 'GPU 类型缺少进程详情')
|
||||
assert.match(typeSource, /download_mb_s\?: number/, '系统类型缺少实时网络接收速率')
|
||||
assert.match(typeSource, /power_limit_w\?: number/, 'GPU 类型缺少功耗上限')
|
||||
|
||||
for (let id = 0; id < 8; id += 1) {
|
||||
assert.match(mockSource, new RegExp(`id:\\s*${id},[\\s\\S]*?uuid:\\s*'GPU-MOCK-A800-0${id}'`), `Mock 缺少 GPU ${id} 详情`)
|
||||
}
|
||||
|
||||
console.log('平台性能页回归检查通过')
|
||||
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('模型管理页面模板回归检查通过')
|
||||
191
frontend/scripts/regression-page-surface.mjs
Normal file
191
frontend/scripts/regression-page-surface.mjs
Normal file
@@ -0,0 +1,191 @@
|
||||
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, trainingOverviewSource, 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/system/training-log/TrainingTaskOverview.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-inference/chat/:id',
|
||||
'model-manage',
|
||||
'data-process',
|
||||
'data-process/create',
|
||||
'dataset',
|
||||
'user-settings',
|
||||
]
|
||||
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, '\n.page-canvas {')
|
||||
assert.match(pageCanvasBlock, /flex:\s*1 0 auto;/, '白色页面画布没有铺满可用高度')
|
||||
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 = [trainingLogSource, trainingOverviewSource]
|
||||
.flatMap((source) => parseSfc(source).descriptor.styles.map((item) => item.content))
|
||||
.join('\n')
|
||||
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.task-overview')
|
||||
assert.match(
|
||||
businessSurfaceBlock,
|
||||
/background:\s*var\(--app-surface-bg\);/,
|
||||
'任务、数据集和运行概况未使用统一白色内容表面',
|
||||
)
|
||||
|
||||
console.log('全局页面背景与内容表面回归检查通过')
|
||||
224
frontend/scripts/regression-training-log-layout.mjs
Normal file
224
frontend/scripts/regression-training-log-layout.mjs
Normal file
@@ -0,0 +1,224 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import ts from 'typescript'
|
||||
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 overviewPath = path.resolve(scriptDir, '../src/views/system/training-log/TrainingTaskOverview.vue')
|
||||
const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts')
|
||||
const [source, overviewSource, modelSource] = await Promise.all([
|
||||
readFile(viewPath, 'utf8'),
|
||||
readFile(overviewPath, 'utf8'),
|
||||
readFile(modelPath, 'utf8'),
|
||||
])
|
||||
const { descriptor } = parseSfc(source, { filename: viewPath })
|
||||
const { descriptor: overviewDescriptor } = parseSfc(overviewSource, { filename: overviewPath })
|
||||
const template = [descriptor.template?.content, overviewDescriptor.template?.content].filter(Boolean).join('\n')
|
||||
const viewStyle = descriptor.styles.map((item) => item.content).join('\n')
|
||||
const overviewStyle = overviewDescriptor.styles.map((item) => item.content).join('\n')
|
||||
const style = `${viewStyle}\n${overviewStyle}`
|
||||
const templateAst = parseTemplate(template)
|
||||
|
||||
const modelModuleCode = ts.transpileModule(modelSource, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
const {
|
||||
parseTrainingLog,
|
||||
resolveTrainingLogFile,
|
||||
} = await import(`data:text/javascript;base64,${Buffer.from(modelModuleCode).toString('base64')}`)
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
const logFiles = [
|
||||
{ file: 'first_pid111.log', name: 'first-task', size: '1 KB', pid: 111 },
|
||||
{ file: 'target_pid222.log', name: 'target-task', size: '1 KB', pid: 222 },
|
||||
]
|
||||
assert.equal(
|
||||
resolveTrainingLogFile(logFiles, { process_id: 222, name: 'target-task' })?.file,
|
||||
'target_pid222.log',
|
||||
'训练日志必须按 task.process_id 精确匹配文件 pid,不能只判断两者是否存在',
|
||||
)
|
||||
assert.equal(
|
||||
resolveTrainingLogFile(logFiles, { process_id: 999, name: 'target-task' })?.file,
|
||||
'target_pid222.log',
|
||||
'PID 无匹配时应按任务名回退选择日志',
|
||||
)
|
||||
assert.equal(
|
||||
resolveTrainingLogFile(logFiles, { process_id: 999, name: 'missing-task' }),
|
||||
undefined,
|
||||
'任务无匹配日志时不得静默退回第一份日志',
|
||||
)
|
||||
|
||||
const parsed = parseTrainingLog([
|
||||
"INFO {'epoch': .5, 'learning_rate': 1.2E-5, 'grad_norm': -2.5e+0, 'loss': +3.25}",
|
||||
'***** train metrics *****',
|
||||
'train_runtime = 1.7852e3',
|
||||
"'train_loss': -3.42e-1",
|
||||
'epoch: 1.0',
|
||||
'***** train metrics end *****',
|
||||
].join('\n'))
|
||||
assert.deepEqual(parsed.metrics.loss, [3.25], '指标解析应允许字段乱序和带符号数值')
|
||||
assert.deepEqual(parsed.metrics.gradNorm, [-2.5], '梯度范数应支持科学计数法')
|
||||
assert.deepEqual(parsed.metrics.lr, [1.2e-5], '学习率应支持大小写科学计数法')
|
||||
assert.deepEqual(
|
||||
parsed.summary,
|
||||
{ epoch: '1.0', trainLoss: '-3.42e-1', runtime: '1.7852e3' },
|
||||
'训练汇总应同时支持等号、冒号、可选引号和科学计数法',
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseTrainingLog('普通日志,无训练指标').summary,
|
||||
{ epoch: '', trainLoss: '', runtime: '' },
|
||||
'每次解析必须返回全新的空汇总,避免保留上一份日志的旧值',
|
||||
)
|
||||
assert.match(
|
||||
source,
|
||||
/const currentTask = await loadTask\(\)[\s\S]*?loadLog\(currentTask\)/,
|
||||
'刷新流程必须先加载 task,再使用该 task 选择日志',
|
||||
)
|
||||
assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源')
|
||||
assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新')
|
||||
assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠')
|
||||
assert.match(source, /usePolling/, '训练日志必须使用统一轮询机制')
|
||||
assert.match(source, /stopPolling\(\)/, '训练结束后必须停止轮询')
|
||||
|
||||
const overview = findElements(
|
||||
templateAst,
|
||||
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
|
||||
)
|
||||
assert.equal(overview.length, 1, '标准任务概况容器必须且只能存在一个')
|
||||
assert.doesNotMatch(overviewSource, /<aside\b/, '任务概况仍保留左右侧栏结构')
|
||||
assert.doesNotMatch(overviewSource, /<i class="fa\b/, '任务概况仍包含过多装饰性图标')
|
||||
|
||||
const toggleButtons = findElements(
|
||||
templateAst,
|
||||
(node) => node.tag === 'el-button'
|
||||
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
|
||||
)
|
||||
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的标准按钮')
|
||||
|
||||
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 pageTitleIndex = source.indexOf('id="task-page-title"')
|
||||
const summaryCardIndex = source.indexOf('id="training-overview-title"')
|
||||
const taskOverviewIndex = source.indexOf('<TrainingTaskOverview')
|
||||
assert.notEqual(pageTitleIndex, -1, '页面缺少独立的训练任务标题')
|
||||
assert.ok(pageTitleIndex < summaryCardIndex, '训练任务标题必须出现在训练概览之前')
|
||||
assert.ok(summaryCardIndex < taskOverviewIndex, '训练概览必须出现在任务信息之前')
|
||||
assert.equal((source.match(/<h1\b/g) || []).length, 1, '训练详情页必须且只能有一个一级标题')
|
||||
assert.doesNotMatch(overviewSource, /<h1\b/, '任务信息卡片不得重复渲染页面一级标题')
|
||||
assert.ok(overviewSource.includes('title="任务信息"'), '任务详情卡片缺少明确的“任务信息”标题')
|
||||
|
||||
const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
|
||||
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
|
||||
assert.ok(template.includes('training-progress-panel'), '训练概览缺少独立的主进度区域')
|
||||
assert.ok(template.includes('training-metric-grid'), '训练概览缺少次级训练指标摘要')
|
||||
assert.ok(template.includes('gpu-device-list'), '训练概览缺少紧凑的 GPU 设备列表')
|
||||
assert.ok(template.includes('gpu-list-header'), 'GPU 设备列表缺少企业表格式列标题')
|
||||
assert.ok(template.includes('gpu-device-row'), 'GPU 设备列表缺少统一行结构')
|
||||
assert.doesNotMatch(template, /training-summary-strip|gpu-device-card/, '训练概览仍保留同级宫格或 GPU 嵌套卡片结构')
|
||||
assert.ok(template.includes('id="training-overview-title"'), '训练概览缺少可访问的卡片标题')
|
||||
assert.ok(
|
||||
template.indexOf('id="training-overview-title"') < template.indexOf('<TrainingTaskOverview'),
|
||||
'训练概览必须位于任务档案之前,成为页面第一块内容',
|
||||
)
|
||||
assert.ok(
|
||||
template.indexOf('training-progress-panel') < template.indexOf('training-metric-grid')
|
||||
&& template.indexOf('training-metric-grid') < template.indexOf('gpu-device-list'),
|
||||
'训练概览必须按主进度、次级指标、GPU 设备列表的顺序展示',
|
||||
)
|
||||
assert.ok(template.includes('id="gpu-monitor-title"'), '训练概览缺少 GPU 运行状态区域')
|
||||
assert.ok(template.includes('计算利用率'), 'GPU 运行状态缺少计算利用率')
|
||||
assert.ok(template.includes('显存占用'), 'GPU 运行状态缺少显存占用')
|
||||
assert.ok(template.includes('每 5 秒刷新'), 'GPU 运行状态缺少刷新频率说明')
|
||||
assert.ok(template.includes(':aria-label="`GPU ${item.index} 计算利用率'), 'GPU 强度条缺少可访问说明')
|
||||
assert.match(source, /const GPU_PREVIEW_LIMIT = 4/, '多 GPU 默认预览数量必须限制为 4 张')
|
||||
assert.match(source, /const visibleGpuItems = computed/, '多 GPU 缺少渐进披露列表计算')
|
||||
assert.match(source, /gpuRuntimePriority/, '折叠状态必须优先展示异常和运行中的 GPU')
|
||||
assert.ok(template.includes('v-for="item in visibleGpuItems"'), 'GPU 列表没有使用受控预览数据')
|
||||
assert.ok(template.includes('taskGpuItems.length > GPU_PREVIEW_LIMIT'), 'GPU 数量超过预览上限时没有展开入口')
|
||||
|
||||
const gpuToggleButtons = findElements(
|
||||
templateAst,
|
||||
(node) => node.tag === 'el-button'
|
||||
&& staticAttribute(node, 'class')?.split(/\s+/).includes('gpu-toggle-button'),
|
||||
)
|
||||
assert.equal(gpuToggleButtons.length, 1, '多 GPU 展开控制必须且只能存在一个')
|
||||
assert.equal(boundExpression(gpuToggleButtons[0], 'aria-expanded'), 'gpuExpanded', 'GPU 展开按钮未绑定 aria-expanded')
|
||||
assert.equal(staticAttribute(gpuToggleButtons[0], 'aria-controls'), 'gpu-device-list', 'GPU 展开按钮缺少正确的 aria-controls')
|
||||
assert.ok(template.includes('title="训练曲线"'), '训练曲线没有使用标准 PageCard 标题')
|
||||
assert.ok(template.includes('title="训练日志"'), '训练日志没有使用标准 PageCard 标题')
|
||||
assert.ok(template.includes('每 5 秒刷新'), '训练监控区缺少自动刷新状态说明')
|
||||
assert.ok((template.match(/<el-descriptions\b/g) || []).length >= 3, '任务概况和训练参数没有使用标准详情表格')
|
||||
assert.equal((template.match(/class="chart-section"/g) || []).length, 3, '三组训练曲线没有按上下结构完整展示')
|
||||
assert.doesNotMatch(template, /<el-row\b|<el-col\b/, '训练曲线仍保留左右分栏布局')
|
||||
assert.doesNotMatch(template, /class="chart-card"/, '训练曲线仍存在卡片嵌套')
|
||||
assert.doesNotMatch(template, /summary-metric is-primary/, '训练概览仍保留大块装饰性主色背景')
|
||||
|
||||
for (const selector of ['.summary-card', '.parameters-card', '.metrics-panel', '.log-card']) {
|
||||
const cardBlock = extractCssBlock(style, selector)
|
||||
assert.match(cardBlock, /border-radius:\s*8px/, `${selector} 未统一为 8px 企业卡片圆角`)
|
||||
assert.match(cardBlock, /box-shadow:\s*none/, `${selector} 仍保留不统一的浮层阴影`)
|
||||
}
|
||||
|
||||
assert.ok(template.includes("task?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
|
||||
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
|
||||
|
||||
const overviewLayoutBlock = extractCssBlock(overviewStyle, '.overview-layout')
|
||||
assert.match(overviewLayoutBlock, /width:\s*100%/, '任务概况没有使用全宽单列布局')
|
||||
assert.doesNotMatch(overviewLayoutBlock, /grid-template-columns/, '任务档案仍保留左右分栏规则')
|
||||
|
||||
const overviewMedia700 = extractCssBlock(overviewStyle, '@media (max-width: 700px)')
|
||||
assert.ok(overviewMedia700.includes('.task-descriptions'), '700px 断点缺少任务详情表单列规则')
|
||||
assert.match(overviewMedia700, /display:\s*block/, '移动端任务详情表没有转换为单列')
|
||||
const viewMedia600 = extractCssBlock(viewStyle, '@media (max-width: 600px)')
|
||||
assert.ok(viewMedia600.includes('.parameter-descriptions'), '600px 断点缺少训练参数表单列规则')
|
||||
assert.match(viewMedia600, /display:\s*block/, '移动端训练参数表没有转换为单列')
|
||||
|
||||
console.log('训练日志详情布局回归检查通过')
|
||||
77
frontend/scripts/regression-training-log-mock.mjs
Normal file
77
frontend/scripts/regression-training-log-mock.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const mockDataPath = path.resolve(scriptDir, '../src/mock/data.ts')
|
||||
const mockAdapterPath = path.resolve(scriptDir, '../src/mock/adapter.ts')
|
||||
const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts')
|
||||
const [mockData, mockAdapter, modelSource] = await Promise.all([
|
||||
readFile(mockDataPath, 'utf8'),
|
||||
readFile(mockAdapterPath, 'utf8'),
|
||||
readFile(modelPath, 'utf8'),
|
||||
])
|
||||
|
||||
function objectFromMarker(source, marker) {
|
||||
const markerIndex = source.indexOf(marker)
|
||||
assert.notEqual(markerIndex, -1, `未找到 Mock 数据标记:${marker}`)
|
||||
const openBrace = source.lastIndexOf('{', markerIndex)
|
||||
assert.notEqual(openBrace, -1, `Mock 数据缺少对象起点:${marker}`)
|
||||
|
||||
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, index + 1)
|
||||
}
|
||||
|
||||
assert.fail(`Mock 数据缺少对象终点:${marker}`)
|
||||
}
|
||||
|
||||
const taskNames = [
|
||||
'finance-sft-001',
|
||||
'legal-sft-002',
|
||||
'medical-cpt-001',
|
||||
'service-dpo-001',
|
||||
'finance-sft-002',
|
||||
'general-sft-001',
|
||||
]
|
||||
const commonTrainingFields = [
|
||||
'output_model_name',
|
||||
'batch_size',
|
||||
'learning_rate',
|
||||
'n_epochs',
|
||||
'save_steps',
|
||||
'lr_scheduler_type',
|
||||
'max_length',
|
||||
'warmup_ratio',
|
||||
'weight_decay',
|
||||
]
|
||||
|
||||
for (const taskName of taskNames) {
|
||||
const task = objectFromMarker(mockData, `name: '${taskName}'`)
|
||||
for (const field of commonTrainingFields) {
|
||||
assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少训练参数:${field}`)
|
||||
}
|
||||
|
||||
if (/train_method:\s*'lora'/.test(task)) {
|
||||
for (const field of ['lora_rank', 'lora_alpha', 'lora_dropout']) {
|
||||
assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少 LoRA 参数:${field}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const medicalTask = objectFromMarker(mockData, "name: 'medical-cpt-001'")
|
||||
assert.match(medicalTask, /\bprocess_id\s*:/, '运行中的医疗训练任务缺少进程 ID')
|
||||
assert.match(mockData, /medical-cpt-001_pid28741\.log/, '医疗训练任务缺少 PID 对应的日志文件')
|
||||
assert.match(mockData, /export const mockTrainingLogContents/, '训练日志没有按文件提供独立 Mock 内容')
|
||||
assert.match(mockData, /Num examples\s*=\s*9,800/, '医疗训练日志缺少样本规模信息')
|
||||
assert.match(mockData, /Total optimization steps\s*=\s*921/, '医疗训练日志缺少真实训练步数信息')
|
||||
assert.match(mockData, /\\'epoch\\':\s*1\.92/, '医疗训练日志的 Epoch 未与 64% 进度对齐')
|
||||
assert.match(mockData, /\\'loss\\':\s*1\.146/, '医疗训练日志缺少当前 Loss 指标')
|
||||
assert.match(mockAdapter, /const file = String\(params\.file/, '训练日志接口没有读取请求中的日志文件名')
|
||||
assert.match(mockAdapter, /mockTrainingLogContents\[file\]/, '训练日志接口没有按请求文件返回对应内容')
|
||||
assert.match(modelSource, /epoch:\s*number\[\]/, '训练指标模型没有保留逐步 Epoch')
|
||||
|
||||
console.log('训练日志 Mock 数据回归检查通过')
|
||||
15
frontend/scripts/run-regressions.mjs
Normal file
15
frontend/scripts/run-regressions.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptsDir = path.resolve(process.cwd(), 'scripts')
|
||||
const regressionScripts = (await readdir(scriptsDir))
|
||||
.filter((file) => file.startsWith('regression-') && file.endsWith('.mjs'))
|
||||
.sort()
|
||||
|
||||
for (const script of regressionScripts) {
|
||||
console.log(`\n▶ ${script}`)
|
||||
await import(pathToFileURL(path.join(scriptsDir, script)).href)
|
||||
}
|
||||
|
||||
console.log(`\n✅ ${regressionScripts.length} 个前端回归脚本全部通过`)
|
||||
Reference in New Issue
Block a user