Files
YG_FT/frontend/scripts/regression-data-process-wizard.mjs
caoxiaozhu c0f5f4a30a refactor: 数据处理向导拆分源数据上传步骤
将任务设置中的文件上传与外来数据源拉取抽离为独立 SourceUploadStep 组件,TaskSetupStep 聚焦任务信息与处理配置,CreateView 同步接入新步骤与草稿同步,回归脚本补充上传步骤断言。
2026-07-12 15:39:43 +08:00

748 lines
39 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'
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')
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: 'upload',[\s\S]*?\{ id: 'preview',[\s\S]*?\{ id: 'generate',[\s\S]*?\{ id: 'results'/,
'五步向导顺序必须为创建任务、上传文件、数据预览、开始生成、结果编辑与保存',
)
assert.doesNotMatch(viewSource, /steps\s*=\s*computed|all\.filter/, '步骤仍根据处理类型动态增减')
assert.match(viewSource, /localStorage\.setItem\(DRAFT_STORAGE_KEY/, '草稿没有持久化')
assert.match(viewSource, /localStorage\.getItem\(DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
const expectedComponents = [
'TaskSetupStep.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' \| '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 taskSetupSource = await readFile(taskSetupPath, 'utf8')
const sourceUploadPath = path.join(createDir, 'SourceUploadStep.vue')
const sourceUploadSource = await readFile(sourceUploadPath, 'utf8')
for (const marker of ['<el-upload', '源数据上传', '数据源配置', 'uploadedFiles']) {
assert.ok(!taskSetupSource.includes(marker), `第一步仍包含上传职责:${marker}`)
}
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 === 'upload'\) return '继续:数据预览'/, '第二步主按钮没有指向数据预览')
assert.match(viewSource, /const DRAFT_SCHEMA_VERSION = 4/, '五步索引变更后必须升级草稿版本')
const nextFromCreateStart = viewSource.indexOf('async function nextFromCreate()')
const nextFromUploadStart = viewSource.indexOf('function nextFromUpload()', nextFromCreateStart)
const selectPreviewFileStart = viewSource.indexOf('function selectPreviewFile(', nextFromUploadStart)
assert.ok(nextFromCreateStart >= 0 && nextFromUploadStart > nextFromCreateStart, '缺少创建步骤与上传步骤的独立跳转函数')
const nextFromCreateSource = viewSource.slice(nextFromCreateStart, nextFromUploadStart)
const nextFromUploadSource = viewSource.slice(nextFromUploadStart, selectPreviewFileStart)
assert.match(nextFromCreateSource, /taskSetupRef\.value\?\.validate\(\)/, '创建步骤继续前没有校验任务配置')
assert.match(nextFromCreateSource, /goToStep\('upload'\)/, '创建步骤校验通过后没有进入上传文件')
assert.doesNotMatch(nextFromCreateSource, /uploadedFiles|buildPreviewItems/, '创建步骤仍在校验文件或提前生成预览')
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(viewSource, /currentStepId:\s*currentStepId\.value/, '草稿没有保存稳定步骤标识')
assert.match(viewSource, /const LEGACY_V3_STEP_IDS:[^=]*= \['create', 'preview', 'generate', 'results'\]/, 'v3 草稿缺少旧步骤索引映射')
assert.match(viewSource, /snapshotSchemaVersion === 3[\s\S]*?LEGACY_V3_STEP_IDS/, 'v3 草稿没有迁移到五步索引')
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(taskSetupSource.includes(option), `结构化预处理缺少选项:${option}`)
}
assert.ok(taskSetupSource.includes('生成选项'), '结构化配置缺少生成选项分类')
assert.ok(taskSetupSource.includes('语义丰富表达'), '生成选项缺少语义丰富表达开关')
assert.ok(taskSetupSource.includes('使用大模型将问答表述得更自然、柔和'), '语义丰富表达缺少辅助说明')
for (const splitName of ['训练集', '验证集', '测试集']) {
assert.ok(taskSetupSource.includes(splitName), `生成选项缺少数据集划分:${splitName}`)
}
assert.match(taskSetupSource, /const splitTotal = computed/, '数据集划分缺少比例总和计算')
assert.match(taskSetupSource, /splitTotal\.value !== 100/, '数据集划分缺少总和 100% 校验')
assert.ok(taskSetupSource.includes('训练集、验证集和测试集比例总和必须为 100%'), '数据集划分缺少就地错误提示')
for (const splitField of ['train', 'validation', 'test']) {
assert.match(
taskSetupSource,
new RegExp(`structuredOptions\\.datasetSplit\\.${splitField}[\\s\\S]*?:min="0"[\\s\\S]*?:max="100"[\\s\\S]*?:step="1"[\\s\\S]*?:precision="0"`),
`数据集划分字段 ${splitField} 缺少 0100 的整数限制`,
)
}
assert.match(taskSetupSource, /<el-switch[\s\S]*structuredOptions\.semanticEnrichment/, '语义丰富表达必须使用开关控件')
assert.match(taskSetupSource, /<el-input-number[\s\S]*structuredOptions\.qaPairsPerRow[\s\S]*:min="1"[\s\S]*:max="5"/, '每行生成数量必须限制在 1 到 5')
assert.match(viewSource, /const structuredOptions = ref<StructuredProcessOptions>/, '父页面缺少结构化配置状态')
assert.match(viewSource, /datasetSplit:\s*\{ train: 80, validation: 10, test: 10 \}/, '数据集划分默认值必须为 80/10/10')
assert.match(viewSource, /structuredOptions:\s*\{[\s\S]*\.\.\.structuredOptions\.value/, '结构化配置没有写入草稿')
assert.match(viewSource, /structuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.structuredOptions/, '结构化配置没有从草稿恢复')
assert.match(viewSource, /v-model:structured-options="structuredOptions"/, '父页面没有双向绑定结构化配置')
assert.match(viewSource, /createResults\([\s\S]*structuredOptions\.value/, '每行生成数量没有接入结果生成逻辑')
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(taskSetupSource.includes('智能预处理'), '简化后缺少智能预处理总开关')
assert.ok(taskSetupSource.includes('敏感信息脱敏'), '简化后缺少脱敏开关')
assert.match(taskSetupSource, /const smartPreprocessEnabled = computed/, '智能预处理没有映射到内部处理项')
assert.match(taskSetupSource, /function updateSmartPreprocess/, '智能预处理开关缺少更新逻辑')
assert.match(taskSetupSource, /function updateDesensitize/, '脱敏开关缺少更新逻辑')
assert.ok(taskSetupSource.includes('切分选项'), '非结构化配置缺少切分选项分类')
for (const method of ['自动语义切分', '按标题和段落', '按固定长度', '自定义分隔符']) {
assert.ok(taskSetupSource.includes(method), `切分方式缺少选项:${method}`)
}
for (const label of ['切片长度', '重叠长度', '最小切片长度', '保护表格、代码和列表']) {
assert.ok(taskSetupSource.includes(label), `切分选项缺少配置:${label}`)
}
assert.ok(taskSetupSource.includes('高级设置'), '切分选项缺少渐进式高级设置入口')
assert.match(taskSetupSource, /:aria-expanded="advancedChunkSettingsOpen"/, '高级设置入口缺少展开状态的可访问性标记')
assert.match(taskSetupSource, /v-if="advancedChunkSettingsOpen"/, '高级设置内容没有默认收起')
assert.match(taskSetupSource, /watch\(\(\) => props\.unstructuredOptions\.chunkMethod,[\s\S]*?method === 'custom'[\s\S]*?advancedChunkSettingsOpen\.value = true/, '选择自定义分隔符时没有自动展开高级设置')
assert.match(taskSetupSource, /if \(chunkValidationMessage\.value\) \{[\s\S]*?advancedChunkSettingsOpen\.value = true[\s\S]*?return false/, '高级切分配置校验失败时没有重新展开定位')
assert.match(taskSetupSource, /const preserveSpecialContentEnabled = computed/, '特殊内容保护没有合并为单一开关')
assert.match(taskSetupSource, /function updateSpecialContentProtection/, '特殊内容保护开关缺少更新逻辑')
assert.match(taskSetupSource, /unstructuredOptions\.chunkSize[\s\S]*?:min="200"[\s\S]*?:max="2000"/, '切片长度必须限制在 200 到 2000 Token')
assert.match(taskSetupSource, /unstructuredOptions\.chunkOverlap[\s\S]*?:min="0"[\s\S]*?:max="500"/, '重叠长度必须限制在 0 到 500 Token')
assert.match(taskSetupSource, /unstructuredOptions\.minChunkSize[\s\S]*?:min="20"[\s\S]*?:max="500"/, '最小切片长度必须限制在 20 到 500 Token')
for (const label of ['语义丰富表达', '每个切片生成数量', '数据集划分']) {
assert.ok(taskSetupSource.includes(label), `非结构化生成选项缺少:${label}`)
}
for (const removedLabel of ['上下文范围', '问题类型', '跳过无法回答的内容']) {
assert.ok(!taskSetupSource.includes(removedLabel), `简化后仍显示低频选项:${removedLabel}`)
}
assert.match(taskSetupSource, /unstructuredOptions\.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(taskSetupSource.includes('Token 数为轻量估算值'), '切片长度缺少 Token 估算说明')
assert.match(taskSetupSource, /\.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(viewSource, /chunkMethod:\s*'semantic'/, '非结构化默认切分方式必须为自动语义切分')
assert.match(viewSource, /chunkSize:\s*800/, '默认切片长度必须为 800 Token')
assert.match(viewSource, /chunkOverlap:\s*100/, '默认重叠长度必须为 100 Token')
assert.match(viewSource, /minChunkSize:\s*100/, '默认最小切片长度必须为 100 Token')
assert.match(viewSource, /qaPairsPerChunk:\s*1/, '默认每个切片必须生成 1 个问答对')
assert.match(viewSource, /unstructuredOptions:\s*\{[\s\S]*\.\.\.unstructuredOptions\.value/, '非结构化配置没有写入草稿')
assert.match(viewSource, /unstructuredOptions\.value = \{[\s\S]*restoredUnstructuredOptions\.chunkSize/, '非结构化配置没有从草稿恢复')
assert.match(viewSource, /v-model:unstructured-options="unstructuredOptions"/, '父页面没有双向绑定非结构化配置')
assert.match(viewSource, /const DRAFT_SCHEMA_VERSION = \d+/, '草稿缺少版本标识')
assert.match(viewSource, /schemaVersion:\s*DRAFT_SCHEMA_VERSION/, '草稿快照没有写入版本标识')
assert.match(viewSource, /requiresPreviewMigration/, '旧草稿没有失效旧切片预览')
assert.doesNotMatch(
viewSource,
/snapshot\.unstructuredOptions\s*&&\s*!requiresPreviewMigration/,
'草稿版本迁移不应丢失仍受支持的非结构化配置',
)
assert.match(viewSource, /const restoredUnstructuredOptions = snapshot\.unstructuredOptions/, '旧草稿没有通过白名单恢复仍受支持的配置')
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']) {
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 },
}
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 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\(stopGenerationTimer\)/, '生成计时器没有在卸载时清理')
assert.match(viewSource, /function scrollToStepTop/, '步骤切换后没有恢复页面顶部上下文')
assert.match(viewSource, /nextTick\(scrollToStepTop\)/, '步骤切换没有触发页面滚动复位')
assert.match(viewSource, /\.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('数据处理五步向导回归检查通过')