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