Files
YG_FT/frontend/scripts/regression-data-process-wizard.mjs
2026-07-24 14:36:11 +08:00

1029 lines
60 KiB
JavaScript
Raw 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 { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/data-process/DataProcessCreateView.vue')
const createDir = path.resolve(scriptDir, '../src/views/data-process/create')
const confirmDialogPath = path.resolve(scriptDir, '../src/components/AppConfirmDialog.vue')
const layoutPath = path.resolve(scriptDir, '../src/layouts/MainLayout.vue')
const apiPath = path.resolve(scriptDir, '../src/api/modules/dataProcess.ts')
const contractTypesPath = path.resolve(scriptDir, '../src/types/dataProcess.ts')
const sourceUploadWorkerPath = path.join(createDir, 'useDataProcessSourceUpload.ts')
const viewSource = await readFile(viewPath, 'utf8')
const layoutSource = await readFile(layoutPath, 'utf8')
const [stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource, viewStyleSource, apiSource, contractTypesSource] = await Promise.all([
readFile(path.join(createDir, 'dataProcessCreateState.ts'), 'utf8'),
readFile(path.join(createDir, 'useDataProcessGeneration.ts'), 'utf8'),
readFile(path.join(createDir, 'useDataProcessPreviewBuild.ts'), 'utf8'),
readFile(sourceUploadWorkerPath, 'utf8'),
readFile(path.join(createDir, 'data-process-create.scss'), 'utf8'),
readFile(apiPath, 'utf8'),
readFile(contractTypesPath, 'utf8'),
])
const implementationSource = [viewSource, stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource].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.equal(existsSync(path.join(createDir, 'useDataProcessDraft.ts')), false, '不应保留新建任务草稿模块')
assert.doesNotMatch(
viewSource,
/useDataProcessDraft|persistDraft|restoreDraft|restoringDraft|localStorage\.(?:setItem|getItem)/,
'新建任务不应保存或恢复草稿',
)
assert.match(viewSource, /localStorage\.removeItem\('yg-data-process-create-draft'\)/, '进入新建页时应清理遗留草稿')
assert.ok(viewSource.split('\n').length < 1000, 'DataProcessCreateView 拆分后仍超过 1000 行')
assert.match(viewSource, /useDataProcessGeneration\(\{/, '生成流程没有拆分到独立 composable')
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')
const pdfViewerPath = path.join(createDir, 'PdfSourceViewer.vue')
assert.ok(existsSync(typesPath), '缺少向导类型定义')
assert.ok(existsSync(modelPath), '缺少来源映射模型')
assert.ok(existsSync(pdfViewerPath), '缺少 PDF 原文件预览组件')
const [typesSource, modelSource, previewSource, pdfViewerSource] = await Promise.all([
readFile(typesPath, 'utf8'),
readFile(modelPath, 'utf8'),
readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'),
readFile(pdfViewerPath, '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 sourceLines/, '缺少源文件行偏移生成函数')
assert.doesNotMatch(modelSource, /buildPreviewItems/, '前端不应保留与后端重复的本地切片算法')
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
assert.match(
viewSource,
/const \{ buildPreviewsByFile \} = useDataProcessPreviewBuild\(\)/,
'父页面没有通过独立 composable 构建逐文件预览',
)
assert.match(viewSource, /backend-pipeline-v3/, '切分管线版本未升级,旧预览缓存可能被误用')
assert.match(viewSource, /getDataProcessPreview\(taskId\.value,\s*\{ page:\s*1, page_size:\s*500 \}\)/, '预览构建后没有分页读取后端数据')
assert.doesNotMatch(viewSource, /buildPreviewItems\(/, '创建向导仍在本地构建集成预览数据')
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 = 10/, '切片列表每页应展示 10 条')
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\)/, '第四步缺少窄屏上下布局')
assert.match(previewSource, /<PdfSourceViewer[\s\S]*v-if="isPdfSource"/, 'PDF 文件没有切换到原文件查看组件')
assert.match(previewSource, /:selected-item="selectedItem \?\? null"/, 'PDF 查看组件没有接收当前选中切片')
assert.match(previewSource, /:data-preview-id="item\.id"/, '切片行缺少稳定的交互定位标识')
assert.match(previewSource, /<div v-else ref="sourceViewerRef" class="source-viewer"/, '非 PDF 文件没有保留文本预览')
assert.match(pdfViewerSource, /getDataProcessSourceRawUrl/, 'PDF 查看组件没有使用受控原文件地址')
assert.match(pdfViewerSource, /getDataProcessPdfPages/, 'PDF 查看组件没有读取页码与全文偏移映射')
assert.match(pdfViewerSource, /pdfjs-dist/, 'PDF 查看组件没有使用可控的 PDF.js 渲染器')
assert.match(pdfViewerSource, /TextLayer/, 'PDF 查看组件没有渲染可定位的 PDF 文字层')
assert.match(pdfViewerSource, /page\.streamTextContent\(\)/, 'PDF 文字层没有使用兼容 WebKit 的流式读取方式')
assert.doesNotMatch(pdfViewerSource, /page\.getTextContent\(\)/, 'PDF 文字层仍依赖 WebKit 不完整支持的 ReadableStream 异步迭代')
assert.match(pdfViewerSource, /is-slice-highlighted/, 'PDF 查看组件没有实现选中切片高亮')
assert.match(pdfViewerSource, /:data-page-number="currentPage"/, 'PDF 查看组件缺少当前物理页标识')
assert.doesNotMatch(pdfViewerSource, /<iframe/, 'PDF 查看组件不应继续使用无法控制高亮的浏览器 iframe')
assert.match(pdfViewerSource, /:aria-label="`PDF 预览:\$\{fileName\}`"/, 'PDF 查看器缺少可访问标题')
assert.match(apiSource, /getDataProcessSourceRawUrl/, '前端 API 缺少 PDF 原文件预览地址')
assert.match(apiSource, /getDataProcessPdfPages/, '前端 API 缺少 PDF 页码映射接口')
assert.match(apiSource, /source-files\/\$\{encodeURIComponent\(fileId\)\}\/pdf-pages/, 'PDF 页码映射接口地址不正确')
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'\) \{[\s\S]*?sourceUploading\.value[\s\S]*?'正在上传'[\s\S]*?previewBuilding\.value \? '正在切分' : '继续:数据预览'/,
'第三步主按钮没有依次反映上传、切分状态并指向数据预览',
)
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|buildDataProcessPreview/, '创建步骤仍在校验文件或提前生成预览')
assert.match(nextFromModelSource, /modelSelectionRef\.value\?\.validate\(\)/, '大模型选择步骤继续前没有校验模型配置')
assert.match(nextFromModelSource, /createDataProcessTask\(taskPayload\(\)\)/, '大模型选择完成后没有通过真实 API 创建任务')
assert.match(nextFromModelSource, /goToStep\('upload'\)/, '大模型选择完成后没有进入上传文件')
assert.match(nextFromUploadSource, /uploadedFiles\.value\.length === 0/, '上传步骤继续前没有校验源数据')
assert.match(
nextFromUploadSource,
/const pendingFileIds = uploadedFiles\.value[\s\S]*?\.filter\(\(file\) => file\.previewStatus !== 'success' \|\| file\.previewConfigSignature !== configSignature\)[\s\S]*?\.map\(\(file\) => file\.sourceFileId\)[\s\S]*?\.filter\(\(fileId\): fileId is string => Boolean\(fileId\)\)/,
'上传步骤没有仅选择待处理或失败文件,无法跳过成功文件并重试失败文件',
)
assert.match(
nextFromUploadSource,
/await buildPreviewsByFile\(taskId\.value, pendingFileIds, \(progress\) => \{/,
'上传步骤没有通过独立 composable 串行构建待处理文件',
)
assert.match(
nextFromUploadSource,
/file\.previewStatus = progress\.status[\s\S]*?file\.previewProgress = progress\.progress[\s\S]*?file\.previewError = progress\.error/,
'上传步骤没有把单文件构建进度与错误回写到对应文件',
)
assert.doesNotMatch(
nextFromUploadSource,
/source_file_ids:\s*uploadedFiles\.value\.map/,
'上传步骤仍一次性批量构建全部文件预览',
)
assert.match(
previewBuildSource,
/for \(const sourceFileId of sourceFileIds\)[\s\S]*?onProgress\(\{ source_file_id: sourceFileId, status: 'processing', progress: 0 \}\)/,
'逐文件构建 composable 没有串行处理并先上报 processing 进度',
)
assert.match(
previewBuildSource,
/buildDataProcessPreview\(taskId,\s*\{[\s\S]*?replace_existing:\s*true[\s\S]*?source_file_ids:\s*\[sourceFileId\]/,
'逐文件构建 composable 没有按单个源文件 ID 替换预览',
)
assert.match(
previewBuildSource,
/status: 'success', progress: 100, preview_count: previewCount/,
'单文件构建成功后没有上报 100% 和预览数量',
)
assert.match(
previewBuildSource,
/catch \(error\)[\s\S]*?status: 'failed'[\s\S]*?progress: 0[\s\S]*?error:/,
'单文件构建失败后没有保留可重试错误',
)
assert.match(nextFromUploadSource, /getDataProcessPreview\(/, '上传步骤没有读取后端预览结果')
assert.match(
nextFromUploadSource,
/const allFilesSucceeded = \(\) => uploadedFiles\.value\.every\(\(file\) => \([\s\S]*?file\.previewStatus === 'success'[\s\S]*?file\.previewConfigSignature === configSignature/,
'上传步骤缺少全部文件 success 且配置签名一致的完成判定',
)
const previewSuccessGateIndex = nextFromUploadSource.indexOf('if (failedCount || !allFilesSucceeded())')
const previewStepIndex = nextFromUploadSource.lastIndexOf("goToStep('preview')")
assert.ok(previewSuccessGateIndex >= 0, '上传步骤没有在任一文件未成功时停留当前步骤')
assert.match(
nextFromUploadSource,
/if \(signature === previewSignature\.value && previewItems\.value\.length && allFilesSucceeded\(\)\) \{\s*goToStep\('preview'\)/,
'缓存预览快速路径没有要求全部文件 success',
)
assert.equal(
(nextFromUploadSource.match(/goToStep\('preview'\)/g) || []).length,
2,
'上传步骤只能通过全成功缓存路径或本轮全成功路径进入预览',
)
assert.ok(
previewStepIndex > previewSuccessGateIndex,
'上传步骤必须在全部文件 success 后才能进入数据预览',
)
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(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'"/, '结构化配置必须仅在结构化数据类型下显示')
const expectedStructuredOptions = [
['clean_invalid', '清理无效数据', '清理全空列,并剔除关键字段残缺的数据行'],
['detect_structure', '识别表格结构', '识别多级表头与合并单元格,并将嵌套字段展平'],
['deduplicate', '重复数据去重', '基于整行精确匹配和关键字段组合删除重复记录'],
['normalize_format', '数据格式标准化', '按所选规则统一编码、空白、字段名及 JSON 序列化格式'],
['filter_anomaly', '异常数据过滤', '使用 IQR 识别数值离群值,并过滤乱码等异常记录'],
['desensitize', '敏感信息脱敏', '识别并脱敏姓名、手机号、邮箱和身份证号'],
]
for (const [value, label, description] of expectedStructuredOptions) {
assert.ok(structuredOptionsSource.includes(`value: '${value}'`), `结构化预处理缺少值:${value}`)
assert.ok(structuredOptionsSource.includes(`label: '${label}'`), `结构化预处理缺少标签:${label}`)
assert.ok(structuredOptionsSource.includes(`description: '${description}'`), `结构化预处理语义不准确:${value}`)
}
const structuredOptionValues = [...structuredOptionsSource.matchAll(/\{ value: '([^']+)', label:/g)]
.map((match) => match[1])
assert.deepEqual(structuredOptionValues, expectedStructuredOptions.map(([value]) => value), '结构化预处理值集合不准确')
assert.equal(new Set(structuredOptionValues).size, structuredOptionValues.length, '结构化预处理 value 必须唯一')
assert.match(structuredOptionsSource, /Array\.from\(new Set\(value\.filter\(/, '结构化预处理选中值没有去重')
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} 缺少 0100 的整数限制`,
)
}
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(viewSource, /v-model:structured-options="structuredOptions"/, '父页面没有双向绑定结构化配置')
assert.match(generationSource, /generateDataProcess\(taskId\)/, '开始生成没有调用真实 API')
assert.match(generationSource, /getDataProcessProgress\(taskId\)/, '生成状态没有通过真实 API 轮询')
assert.match(generationSource, /getDataProcessResults\(taskId,[\s\S]*?page:[\s\S]*?page_size:/, '生成完成后没有分页加载真实结果')
assert.match(generationSource, /updateDataProcessResult\(taskId,\s*item\.id,[\s\S]*?expected_updated_at:/, '结果保存没有调用真实 API 或缺少并发版本')
assert.ok(generationSource.includes('item.quality_score?.overall'), '结果映射没有读取质量总分 overall')
assert.ok(generationSource.includes('item.quality_score?.flags || []'), '结果映射没有读取质量标记 flags')
assert.doesNotMatch(generationSource, /createResults\(/, '生成 composable 仍在本地伪造处理结果')
for (const apiName of [
'createDataProcessTask',
'uploadDataProcessSourceFiles',
'buildDataProcessPreview',
'getDataProcessPreview',
'generateDataProcess',
'getDataProcessProgress',
'getDataProcessResults',
'updateDataProcessResult',
'publishDataProcess',
]) {
assert.match(apiSource, new RegExp(`export (?:const|async function|function) ${apiName}\\b`), `API 模块缺少 ${apiName}`)
}
assert.match(sourceUploadWorkerSource, /uploadDataProcessSourceFiles\(currentTaskId,\s*\[job\.file\]/, '文件上传没有逐文件调用真实 API')
assert.match(sourceUploadWorkerSource, /STRUCTURED_FILE_EXTENSIONS = new Set\(\['json', 'jsonl', 'ndjson', 'csv', 'tsv', 'xlsx'\]\)/, '结构化文件扩展名白名单不完整')
for (const extension of ['txt', 'md', 'markdown', 'pdf', 'docx', 'pptx', 'json', 'jsonl', 'ndjson']) {
assert.ok(sourceUploadWorkerSource.includes(`'${extension}'`), `非结构化文件扩展名白名单缺少 ${extension}`)
}
assert.match(sourceUploadWorkerSource, /LEGACY_OFFICE_EXTENSIONS = new Set\(\['doc', 'xls', 'ppt'\]\)/, '缺少旧版 Office 格式识别')
assert.ok(sourceUploadWorkerSource.includes('请分别转换为 DOCX、XLSX、PPTX 后上传'), '旧版 Office 文件缺少转换提示')
assert.match(sourceUploadWorkerSource, /if \(!BINARY_FILE_EXTENSIONS\.has\(job\.extension\)\) \{[\s\S]*?TextDecoder/, '文本格式没有执行 UTF-8 客户端校验')
assert.match(sourceUploadWorkerSource, /if \(BINARY_FILE_EXTENSIONS\.has\(job\.extension\)\) \{[\s\S]*?getDataProcessSourceContent\(currentTaskId, source\.id,[\s\S]*?start_line:\s*1,[\s\S]*?line_count:\s*10_000/, '二进制文档上传后没有读取后端解析文本')
assert.match(apiSource, /formData\.append\('files', file\)/, '上传 API 没有使用 files 多文件表单字段')
assert.match(apiSource, /onUploadProgress:[\s\S]*?event\.loaded \/ event\.total[\s\S]*?Math\.min\(99,/, '上传 API 没有接入真实字节进度或响应前未限制在 99%')
assert.match(apiSource, /source-files`[\s\S]*?timeout: 5 \* 60 \* 1000/, '源文件上传缺少 5 分钟超时')
assert.match(apiSource, /\/preview\/build/, 'API 模块缺少后端预览构建路径')
assert.match(apiSource, /\/preview\/build`[\s\S]*?\{ timeout: 5 \* 60 \* 1000 \}/, '单文件切分请求缺少 5 分钟超时')
assert.match(apiSource, /\/progress`/, 'API 模块缺少生成进度路径')
assert.match(apiSource, /\/results`/, 'API 模块缺少结果分页路径')
assert.match(apiSource, /\/publish`/, 'API 模块缺少数据集发布路径')
assert.match(contractTypesSource, /source_file_ids\?: Array<string \| number>/, '预览构建契约缺少源文件 ID 列表')
assert.match(
contractTypesSource,
/export type DataProcessPreviewFileStatus = 'waiting' \| 'processing' \| 'success' \| 'failed'/,
'文件预览状态契约不完整',
)
for (const field of ['rawFile', 'status', 'uploadProgress', 'uploadError', 'previewStatus', 'previewProgress', 'previewError', 'previewConfigSignature']) {
assert.ok(typesSource.includes(field), `上传文件缺少逐文件预览字段:${field}`)
}
assert.match(typesSource, /status: 'queued' \| 'uploading' \| 'ready' \| 'failed'/, '上传文件状态机不完整')
assert.match(viewSource, /uploadedFiles\.value\.push\([\s\S]*?status: 'queued'[\s\S]*?enqueueSourceUpload/, '文件选择后没有先进入列表再加入上传队列')
assert.match(sourceUploadWorkerSource, /while \(queue\.length\) \{[\s\S]*?await uploadOne\(job\)/, '多文件上传没有由单一队列逐个等待')
assert.doesNotMatch(sourceUploadWorkerSource, /Promise\.(?:all|allSettled)/, '上传队列不得并发消费文件')
assert.match(sourceUploadWorkerSource, /pending\.status = 'ready'[\s\S]*?pending\.uploadProgress = 100/, '服务端响应成功后没有将文件置为上传完成')
assert.match(viewSource, /failedUploads[\s\S]*?hasUnfinishedUploads[\s\S]*?buildPreviewsByFile/, '上传失败或未完成时没有阻断切分')
assert.doesNotMatch(viewSource, /file\.sourceFileId \|\| file\.uid/, '切分或删除仍可能把本地临时 UID 当成后端文件 ID')
assert.match(contractTypesSource, /expected_updated_at\?: string/, '编辑契约缺少乐观并发版本字段')
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/, '创建页没有加载模型列表')
const generationModelsStart = viewSource.indexOf('const generationModels')
const generationModelsEnd = viewSource.indexOf('const taskSetupRef', generationModelsStart)
const generationModelsSource = viewSource.slice(generationModelsStart, generationModelsEnd)
assert.match(generationModelsSource, /computed\(\(\) => modelList\.value\)/, '数据生成模型候选没有直接使用模型管理完整列表')
assert.doesNotMatch(generationModelsSource, /\.filter\(|model\.type|model\.model_source|model\.status|model\.purpose/, '数据生成模型候选仍按类型、来源、状态或用途静默过滤')
assert.match(viewSource, /modelsStore\.load\(true\)/, '进入创建向导时没有强制刷新模型管理列表')
assert.match(viewSource, /<ModelSelectionStep[\s\S]*?:models="generationModels"/, '创建页没有向独立大模型选择步骤传递模型列表')
assert.match(generationControlSource, /v-for="model in models \|\| \[\]"/, '模型下拉没有遍历完整候选列表')
assert.match(generationControlSource, /:value="model\.id"/, '模型下拉没有使用模型管理 ID 作为选中值')
assert.match(generationControlSource, /modelMeta\(model\)/, '模型下拉缺少来源和类型说明')
assert.match(typesSource, /export interface UnstructuredProcessOptions/, '缺少非结构化处理选项类型')
assert.match(
typesSource,
/export type ChunkMethod = 'structure' \| 'fixed' \| 'custom'/,
'非结构化切分方式类型必须只保留 structure、fixed 和 custom',
)
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('敏感信息脱敏'), '简化后缺少脱敏开关')
const expectedSmartPreprocessOptions = [
'clean_invalid_content',
'detect_document_structure',
'merge_short_content',
'filter_low_quality',
'deduplicate_content',
'preserve_context',
]
const smartOptionsStart = unstructuredOptionsSource.indexOf('const SMART_PREPROCESS_OPTIONS')
const smartOptionsEnd = unstructuredOptionsSource.indexOf('const CHUNK_METHODS', smartOptionsStart)
const smartOptionsSource = unstructuredOptionsSource.slice(smartOptionsStart, smartOptionsEnd)
const smartOptionValues = [...smartOptionsSource.matchAll(/^\s*'([^']+)',?$/gm)].map((match) => match[1])
assert.deepEqual(smartOptionValues, expectedSmartPreprocessOptions, '智能预处理内部值与后端语义不一致')
assert.equal(new Set(smartOptionValues).size, smartOptionValues.length, '非结构化智能预处理 value 必须唯一')
for (const descriptionPart of [
'清理无效内容',
'感知文档结构',
'合并短块',
'预过滤低质量内容',
'近重复去重',
'重叠保护上下文',
]) {
assert.ok(unstructuredOptionsSource.includes(descriptionPart), `智能预处理说明缺少语义:${descriptionPart}`)
}
assert.ok(unstructuredOptionsSource.includes('姓名、手机号、邮箱和身份证号'), '非结构化脱敏说明缺少完整字段范围')
assert.match(unstructuredOptionsSource, /Array\.from\(new Set\(props\.options\.preprocessOptions\.filter\(/, '非结构化预处理选中值没有去重')
assert.match(unstructuredOptionsSource, /const smartPreprocessEnabled = computed/, '智能预处理没有映射到内部处理项')
assert.match(unstructuredOptionsSource, /function updateSmartPreprocess/, '智能预处理开关缺少更新逻辑')
assert.match(unstructuredOptionsSource, /function updateDesensitize/, '脱敏开关缺少更新逻辑')
assert.ok(unstructuredOptionsSource.includes('切分选项'), '非结构化配置缺少切分选项分类')
const expectedChunkMethods = [
['structure', '文档结构'],
['fixed', '固定 Token'],
['custom', '自定义分隔符'],
]
const chunkMethodsStart = unstructuredOptionsSource.indexOf('const CHUNK_METHODS')
const chunkMethodsEnd = unstructuredOptionsSource.indexOf('const UNSTRUCTURED_NUMBER_LIMITS', chunkMethodsStart)
const chunkMethodsSource = unstructuredOptionsSource.slice(chunkMethodsStart, chunkMethodsEnd)
const chunkMethodValues = [...chunkMethodsSource.matchAll(/value: '([^']+)'/g)].map((match) => match[1])
assert.deepEqual(chunkMethodValues, expectedChunkMethods.map(([value]) => value), '切分方式值集合不准确')
for (const [value, label] of expectedChunkMethods) {
assert.ok(chunkMethodsSource.includes(`value: '${value}', label: '${label}'`), `切分方式缺少选项:${label}`)
}
for (const removedMethod of ['semantic', 'heading']) {
assert.ok(!chunkMethodsSource.includes(`value: '${removedMethod}'`), `切分方式仍保留已移除值:${removedMethod}`)
}
assert.ok(unstructuredOptionsSource.includes('推荐使用文档结构'), '切分方式缺少文档结构默认推荐说明')
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*'structure'/, '非结构化默认切分方式必须为文档结构')
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(viewSource, /v-model:unstructured-options="unstructuredOptions"/, '父页面没有双向绑定非结构化配置')
assert.match(viewSource, /JSON\.stringify\(previewAffectingOptions\(\)\)/, '影响切分的非结构化配置没有纳入预览失效判断')
function defaultPreprocessValues(functionName, nextFunctionName) {
const start = stateSource.indexOf(`export function ${functionName}`)
const end = nextFunctionName ? stateSource.indexOf(`export function ${nextFunctionName}`, start) : stateSource.length
const functionSource = stateSource.slice(start, end)
const match = functionSource.match(/preprocessOptions:\s*\[([\s\S]*?)\]/)
assert.ok(match, `${functionName} 缺少 preprocessOptions 默认值`)
return [...match[1].matchAll(/'([^']+)'/g)].map((item) => item[1])
}
const defaultStructuredPreprocess = defaultPreprocessValues(
'createDefaultStructuredOptions',
'createDefaultUnstructuredOptions',
)
assert.deepEqual(
defaultStructuredPreprocess,
['clean_invalid', 'detect_structure', 'deduplicate', 'normalize_format'],
'结构化默认预处理配置不准确',
)
assert.equal(new Set(defaultStructuredPreprocess).size, defaultStructuredPreprocess.length, '结构化默认预处理值重复')
const defaultUnstructuredPreprocess = defaultPreprocessValues('createDefaultUnstructuredOptions')
assert.deepEqual(defaultUnstructuredPreprocess, expectedSmartPreprocessOptions, '智能预处理默认值不完整')
assert.equal(new Set(defaultUnstructuredPreprocess).size, defaultUnstructuredPreprocess.length, '非结构化默认预处理值重复')
const backendConfigStart = viewSource.indexOf('function toBackendConfig()')
const backendConfigEnd = viewSource.indexOf('function taskPayload()', backendConfigStart)
assert.ok(backendConfigStart >= 0 && backendConfigEnd > backendConfigStart, '缺少任务后端配置映射')
const backendConfigSource = viewSource.slice(backendConfigStart, backendConfigEnd)
assert.ok(backendConfigSource.includes('preprocess_options: [...options.preprocessOptions]'), '预处理选项没有完整传入任务配置')
assert.ok(backendConfigSource.includes('dataset_split: { ...options.datasetSplit }'), '数据集划分没有完整传入任务配置')
for (const [backendField, frontendField] of [
['semantic_enrichment', 'semanticEnrichment'],
['generation_model_id', 'generationModelId'],
['generation_prompt', 'generationPrompt'],
['temperature', 'temperature'],
['max_tokens', 'maxTokens'],
['json_mode', 'jsonMode'],
['quality_filter_enabled', 'qualityFilterEnabled'],
['filter_low_quality', 'filterLowQuality'],
['filter_short_content', 'filterShortContent'],
['min_output_length', 'minOutputLength'],
]) {
assert.ok(
backendConfigSource.includes(`${backendField}: options.${frontendField}`),
`公共配置 ${frontendField} 没有传入 task payload`,
)
}
for (const [backendField, frontendField] of [
['chunk_method', 'chunkMethod'],
['chunk_size', 'chunkSize'],
['chunk_overlap', 'chunkOverlap'],
['min_chunk_size', 'minChunkSize'],
['custom_delimiter', 'customDelimiter'],
['preserve_tables', 'preserveTables'],
['preserve_code_blocks', 'preserveCodeBlocks'],
['preserve_lists', 'preserveLists'],
['qa_pairs_per_chunk', 'qaPairsPerChunk'],
]) {
assert.ok(
backendConfigSource.includes(`${backendField}: unstructuredOptions.value.${frontendField}`),
`非结构化配置 ${frontendField} 没有传入 task payload`,
)
}
assert.ok(
backendConfigSource.includes('qa_pairs_per_row: structuredOptions.value.qaPairsPerRow'),
'结构化每行生成数量没有传入 task payload',
)
const taskPayloadStart = viewSource.indexOf('function taskPayload()')
const taskPayloadEnd = viewSource.indexOf('function externalPayload()', taskPayloadStart)
const taskPayloadSource = viewSource.slice(taskPayloadStart, taskPayloadEnd)
for (const marker of ['name: task.name.trim()', 'description: task.description.trim()', 'process_type: processType.value', 'config: toBackendConfig()']) {
assert.ok(taskPayloadSource.includes(marker), `任务创建 payload 缺少:${marker}`)
}
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.doesNotMatch(modelSource, /createResults\(/, '纯预览映射模块不应承担结果生成职责')
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\S]*?已选择 \{\{ uploadedFiles\.length \}\} 个文件[\s\S]*?正在逐个上传/,
'文件列表标题结构或文件数量文案缺失',
)
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+:disabled="previewBuilding"\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\S]*?<template\s+#tip>[\s\S]*?processType === 'unstructured'/,
'无文件时大拖拽上传区缺少按处理类型展示的格式提示',
)
assert.ok(
sourceUploadSource.includes("? '.txt,.md,.markdown,.pdf,.docx,.pptx,.json,.jsonl,.ndjson'"),
'非结构化上传 accept 不完整',
)
assert.ok(
sourceUploadSource.includes(": '.json,.jsonl,.ndjson,.csv,.tsv,.xlsx'"),
'结构化上传 accept 不完整',
)
assert.ok(sourceUploadSource.includes('旧版 DOC/PPT 请先转换'), '非结构化格式提示没有说明旧版 DOC/PPT 需转换')
assert.ok(sourceUploadSource.includes('旧版 XLS 请先转换'), '结构化格式提示没有说明旧版 XLS 需转换')
assert.match(
sourceUploadSource,
/<div\s+class="uploaded-file-list-header">[\s\S]*?已选择 \{\{ uploadedFiles\.length \}\} 个文件[\s\S]*?<div\s+class="continue-upload">\s*<el-upload\s+multiple\s+:accept="uploadAccept"\s+:disabled="previewBuilding"\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"\s+:disabled="previewBuilding">继续上传<\/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, /success:\s*\{ label: '切分完成', icon: 'fa-check-circle' \}/, '文件行缺少切分成功状态')
assert.match(sourceUploadSource, /previewBuilding/, '上传组件没有接收逐文件预览构建状态')
assert.match(sourceUploadSource, /sourceUploading/, '上传组件没有接收串行上传状态')
assert.match(
sourceUploadSource,
/<el-progress[\s\S]*?:percentage="getFileBarPercentage\(file\)"[\s\S]*?:indeterminate="isFileProcessing\(file\)"/,
'文件行缺少上传与切分阶段的独立进度',
)
for (const label of ['等待上传', '正在上传', '上传失败', '等待切分', '正在切分', '切分完成', '切分失败']) {
assert.ok(sourceUploadSource.includes(`label: '${label}'`), `文件行缺少状态:${label}`)
}
assert.match(sourceUploadSource, /file\.uploadProgress/, '文件行没有使用真实上传进度')
assert.match(sourceUploadSource, /file\.uploadError \|\| file\.previewError/, '文件行没有按阶段展示上传或切分失败原因')
assert.match(sourceUploadSource, /file\.previewError/, '文件行没有展示逐文件失败原因以支持重试')
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]*?\}\)/, '生成轮询计时器没有在卸载时清理')
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('数据处理六步向导回归检查通过')