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, /') 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(' 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(/= 3, '任务概况和训练参数没有使用标准详情表格') assert.equal((template.match(/class="chart-section"/g) || []).length, 3, '三组训练曲线没有按上下结构完整展示') assert.doesNotMatch(template, /