Files
YG_FT/frontend/scripts/regression-training-log-layout.mjs
caoxiaozhu 4173b53b1b refactor: 前端构建按需化与 Mock 懒加载
移除全量 Element Plus 与全局 VChart 注册,改为按需引入样式与组件内局部图表;Mock 适配器改为按 VITE_ENABLE_MOCK 环境变量懒加载,生产默认不拦截请求;ECharts 精简为看板所需图表,各视图与 stores 同步适配。
2026-07-16 11:03:54 +08:00

225 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('训练日志详情布局回归检查通过')