775 lines
44 KiB
JavaScript
775 lines
44 KiB
JavaScript
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 viewSource = await readFile(viewPath, 'utf8')
|
||
const layoutSource = await readFile(layoutPath, 'utf8')
|
||
const [draftSource, stateSource, generationSource, viewStyleSource, apiSource, contractTypesSource] = await Promise.all([
|
||
readFile(path.join(createDir, 'useDataProcessDraft.ts'), 'utf8'),
|
||
readFile(path.join(createDir, 'dataProcessCreateState.ts'), 'utf8'),
|
||
readFile(path.join(createDir, 'useDataProcessGeneration.ts'), 'utf8'),
|
||
readFile(path.join(createDir, 'data-process-create.scss'), 'utf8'),
|
||
readFile(apiPath, 'utf8'),
|
||
readFile(contractTypesPath, 'utf8'),
|
||
])
|
||
const implementationSource = [viewSource, draftSource, stateSource, generationSource].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.match(draftSource, /localStorage\.setItem\(DATA_PROCESS_DRAFT_STORAGE_KEY/, '草稿没有持久化')
|
||
assert.match(draftSource, /localStorage\.getItem\(DATA_PROCESS_DRAFT_STORAGE_KEY\)/, '草稿没有恢复读取')
|
||
assert.match(viewSource, /restoreDraft\(\)/, '页面没有恢复草稿')
|
||
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')
|
||
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' \| 'model' \| 'upload' \| 'preview' \| 'generate' \| 'results'/, '步骤类型缺少独立大模型选择步骤')
|
||
assert.match(modelSource, /export function sourceLines/, '缺少源文件行偏移生成函数')
|
||
assert.doesNotMatch(modelSource, /buildPreviewItems/, '前端不应保留与后端重复的本地切片算法')
|
||
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
|
||
assert.match(
|
||
viewSource,
|
||
/buildDataProcessPreview\(taskId\.value,\s*\{[\s\S]*?source_file_ids:\s*uploadedFiles\.value\.map/,
|
||
'预览没有通过后端按已上传源文件构建',
|
||
)
|
||
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 = 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 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'\) return '继续:数据预览'/, '第三步主按钮没有指向数据预览')
|
||
const draftVersionMatch = draftSource.match(/DATA_PROCESS_DRAFT_SCHEMA_VERSION\s*=\s*(\d+)/)
|
||
assert.ok(draftVersionMatch, '草稿缺少数字版本标识')
|
||
assert.ok(Number(draftVersionMatch[1]) >= 7, '安全草稿格式版本不得低于 v7')
|
||
|
||
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, /buildDataProcessPreview\(/, '上传步骤没有调用后端构建预览')
|
||
assert.match(nextFromUploadSource, /getDataProcessPreview\(/, '上传步骤没有读取后端预览结果')
|
||
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(draftSource, /currentStepId:\s*bindings\.currentStepId\.value/, '草稿没有保存稳定步骤标识')
|
||
const draftSnapshotSource = draftSource.slice(
|
||
draftSource.indexOf('function draftSnapshot()'),
|
||
draftSource.indexOf('function writeDraft'),
|
||
)
|
||
for (const forbiddenField of ['uploadedFiles', 'previewItems', 'results', 'password', 'token']) {
|
||
assert.ok(!draftSnapshotSource.includes(forbiddenField), `安全草稿不应持久化:${forbiddenField}`)
|
||
}
|
||
assert.match(draftSource, /writeDraft\(false\)/, '恢复旧草稿后没有立即覆盖潜在敏感数据')
|
||
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(structuredOptionsSource.includes(option), `结构化预处理缺少选项:${option}`)
|
||
}
|
||
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} 缺少 0~100 的整数限制`,
|
||
)
|
||
}
|
||
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(draftSource, /structuredOptions:\s*\{[\s\S]*\.\.\.bindings\.structuredOptions\.value/, '结构化配置没有写入草稿')
|
||
assert.match(draftSource, /bindings\.structuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.structuredOptions/, '结构化配置没有从草稿恢复')
|
||
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(viewSource, /uploadDataProcessSourceFiles\(taskId\.value,\s*\[raw\]\)/, '文件上传没有调用真实 API')
|
||
assert.match(apiSource, /formData\.append\('files', file\)/, '上传 API 没有使用 files 多文件表单字段')
|
||
assert.match(apiSource, /\/preview\/build/, 'API 模块缺少后端预览构建路径')
|
||
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, /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/, '创建页没有加载模型列表')
|
||
assert.match(viewSource, /model\.type === 'LLM'/, '数据生成模型列表没有排除非大模型')
|
||
assert.match(viewSource, /<ModelSelectionStep[\s\S]*?:models="generationModels"/, '创建页没有向独立大模型选择步骤传递模型列表')
|
||
|
||
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(unstructuredOptionsSource.includes('智能预处理'), '简化后缺少智能预处理总开关')
|
||
assert.ok(unstructuredOptionsSource.includes('敏感信息脱敏'), '简化后缺少脱敏开关')
|
||
assert.match(unstructuredOptionsSource, /const smartPreprocessEnabled = computed/, '智能预处理没有映射到内部处理项')
|
||
assert.match(unstructuredOptionsSource, /function updateSmartPreprocess/, '智能预处理开关缺少更新逻辑')
|
||
assert.match(unstructuredOptionsSource, /function updateDesensitize/, '脱敏开关缺少更新逻辑')
|
||
|
||
assert.ok(unstructuredOptionsSource.includes('切分选项'), '非结构化配置缺少切分选项分类')
|
||
for (const method of ['自动语义切分', '按标题和段落', '按固定长度', '自定义分隔符']) {
|
||
assert.ok(unstructuredOptionsSource.includes(method), `切分方式缺少选项:${method}`)
|
||
}
|
||
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*'semantic'/, '非结构化默认切分方式必须为自动语义切分')
|
||
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(draftSource, /unstructuredOptions:\s*\{[\s\S]*\.\.\.bindings\.unstructuredOptions\.value/, '非结构化配置没有写入草稿')
|
||
assert.match(draftSource, /bindings\.unstructuredOptions\.value = \{[\s\S]*\.\.\.snapshot\.unstructuredOptions/, '非结构化配置没有从草稿恢复')
|
||
assert.match(viewSource, /v-model:unstructured-options="unstructuredOptions"/, '父页面没有双向绑定非结构化配置')
|
||
assert.match(draftSource, /DATA_PROCESS_DRAFT_SCHEMA_VERSION = \d+/, '草稿缺少版本标识')
|
||
assert.match(draftSource, /schemaVersion:\s*DATA_PROCESS_DRAFT_SCHEMA_VERSION/, '草稿快照没有写入版本标识')
|
||
assert.match(draftSource, /bindings\.goToStep\('create'\)/, '恢复配置后应回到安全的创建步骤')
|
||
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',
|
||
'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*<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\(\(\) => \{[\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('数据处理六步向导回归检查通过')
|