- 支持从 PostgreSQL 数据库拉取结构化数据作为训练来源 - 新增 DPO (Direct Preference Optimization) 输出类型 - 支持 chosen/rejected 字段的编辑、校验和发布 - 完善数据预处理切分逻辑和元数据管理 - 移除 OCR 扫描 PDF 功能,保持基础文本解析能力 Co-Authored-By: Claude <noreply@anthropic.com>
1489 lines
104 KiB
JavaScript
1489 lines
104 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 appHeaderPath = path.resolve(scriptDir, '../src/components/AppHeader.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 requestPath = path.resolve(scriptDir, '../src/api/request.ts')
|
||
const authStorePath = path.resolve(scriptDir, '../src/stores/auth.ts')
|
||
const modelsStorePath = path.resolve(scriptDir, '../src/stores/models.ts')
|
||
const routerPath = path.resolve(scriptDir, '../src/router/index.ts')
|
||
const sessionActivityPath = path.resolve(scriptDir, '../src/utils/sessionActivity.ts')
|
||
const sourceUploadWorkerPath = path.join(createDir, 'useDataProcessSourceUpload.ts')
|
||
const regenerationPath = path.join(createDir, 'useDataProcessRegeneration.ts')
|
||
const generationStepPath = path.join(createDir, 'GenerationStep.vue')
|
||
const viewSource = await readFile(viewPath, 'utf8')
|
||
const layoutSource = await readFile(layoutPath, 'utf8')
|
||
const [stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource, regenerationSource, 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(regenerationPath, 'utf8'),
|
||
readFile(path.join(createDir, 'data-process-create.scss'), 'utf8'),
|
||
readFile(apiPath, 'utf8'),
|
||
readFile(contractTypesPath, 'utf8'),
|
||
])
|
||
const implementationSource = [viewSource, stateSource, generationSource, previewBuildSource, sourceUploadWorkerSource, regenerationSource].join('\n')
|
||
const [requestSource, authStoreSource, modelsStoreSource, routerSource, sessionActivitySource] = await Promise.all([
|
||
readFile(requestPath, 'utf8'),
|
||
readFile(authStorePath, 'utf8'),
|
||
readFile(modelsStorePath, 'utf8'),
|
||
readFile(routerPath, 'utf8'),
|
||
readFile(sessionActivityPath, 'utf8'),
|
||
])
|
||
|
||
assert.match(requestSource, /if \(res\.code === 0\) \{[\s\S]*?touchSessionActivity\(\)/, '成功 API 请求没有刷新会话活跃时间')
|
||
assert.match(authStoreSource, /const loginTime = sessionActivityTime/, '认证状态没有共享请求层的会话活跃时间')
|
||
assert.match(authStoreSource, /if \(currentUser\.value\) touchSessionActivity\(\)/, '会话续期仍可能在长任务结束后失效')
|
||
assert.match(routerSource, /const auth = useAuthStore\(\)[\s\S]*?auth\.syncSession\(\)[\s\S]*?if \(!auth\.isLoggedIn\)/, '路由守卫没有在登录判断前同步 API 活跃时间')
|
||
assert.match(sessionActivitySource, /export function touchSessionActivity\(\)/, '缺少统一会话活跃续期函数')
|
||
|
||
assert.ok(existsSync(confirmDialogPath), '缺少公共确认弹窗组件 AppConfirmDialog')
|
||
const confirmDialogSource = await readFile(confirmDialogPath, 'utf8')
|
||
const appHeaderSource = await readFile(appHeaderPath, '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 \(to\) =>/, '路由离开守卫没有根据目标路由异步保存或确认')
|
||
assert.doesNotMatch(viewSource, /window\.confirm|ElMessageBox/, '创建页仍在使用系统或 Element Plus 确认框')
|
||
assert.match(routerSource, /path:\s*'data-process\/:id\/regenerate'[\s\S]*?name:\s*'data-process-regenerate'[\s\S]*?DataProcessCreateView\.vue/, '重新生成路由没有复用创建向导')
|
||
for (const routeName of ['data-process-create', 'data-process-regenerate', 'data-process-workflow']) {
|
||
assert.match(
|
||
routerSource,
|
||
new RegExp(`name:\\s*['"]${routeName}['"][\\s\\S]*?backRouteName:\\s*['"]data-process['"]`),
|
||
`数据处理向导路由 ${routeName} 没有声明顶部返回列表的命名路由`,
|
||
)
|
||
}
|
||
assert.match(
|
||
appHeaderSource,
|
||
/const backRouteName = route\.meta\.backRouteName[\s\S]*?router\.push\(\{ name: backRouteName \}\)[\s\S]*?router\.back\(\)/,
|
||
'顶部返回没有优先使用路由 meta 中的命名路由',
|
||
)
|
||
const persistWorkspaceForStepStart = viewSource.indexOf('async function persistWorkspaceForStep(targetStep: StepId)')
|
||
const persistWorkspaceBeforeLeaveStart = viewSource.indexOf('async function persistWorkspaceBeforeLeave()', persistWorkspaceForStepStart)
|
||
const persistWorkspaceEnd = viewSource.indexOf('onBeforeRouteLeave(', persistWorkspaceBeforeLeaveStart)
|
||
assert.ok(
|
||
persistWorkspaceForStepStart >= 0
|
||
&& persistWorkspaceBeforeLeaveStart > persistWorkspaceForStepStart
|
||
&& persistWorkspaceEnd > persistWorkspaceBeforeLeaveStart,
|
||
'已创建任务离开向导前没有持久化当前工作区',
|
||
)
|
||
const persistWorkspaceForStepSource = viewSource.slice(persistWorkspaceForStepStart, persistWorkspaceBeforeLeaveStart)
|
||
for (const persistenceAction of ['saveTaskConfiguration', 'syncPreviewChanges', 'persistResultChanges', 'persistWorkflowStep']) {
|
||
assert.ok(persistWorkspaceForStepSource.includes(persistenceAction), `已创建任务离开前缺少保存动作:${persistenceAction}`)
|
||
}
|
||
const persistWorkspaceBeforeLeaveSource = viewSource.slice(persistWorkspaceBeforeLeaveStart, persistWorkspaceEnd)
|
||
assert.match(
|
||
persistWorkspaceBeforeLeaveSource,
|
||
/persistWorkspaceForStep\(currentStepId\.value\)/,
|
||
'无参离开包装没有按当前步骤保存工作区',
|
||
)
|
||
assert.match(
|
||
viewSource,
|
||
/async function saveTaskConfiguration\(\)[\s\S]*?updateDataProcessTask\(taskId\.value, taskPayload\(\)\)[\s\S]*?createDataProcessTask\(taskPayload\(\)\)/,
|
||
'任务配置保存没有覆盖已创建更新和首次创建',
|
||
)
|
||
const routeLeaveStart = viewSource.indexOf('onBeforeRouteLeave(')
|
||
const routeLeaveEnd = viewSource.indexOf('async function initializeExistingWorkflow()', routeLeaveStart)
|
||
assert.ok(routeLeaveStart >= 0 && routeLeaveEnd > routeLeaveStart, '创建向导缺少路由离开守卫')
|
||
const routeLeaveSource = viewSource.slice(routeLeaveStart, routeLeaveEnd)
|
||
assert.match(
|
||
routeLeaveSource,
|
||
/to\.name === 'data-process'[\s\S]*?taskId\.value[\s\S]*?await persistWorkspaceBeforeLeave\(\)[\s\S]*?allowLeave = true[\s\S]*?return true/,
|
||
'已创建任务返回列表时没有自动保存并直接放行',
|
||
)
|
||
const persistedLeaveBranch = routeLeaveSource.match(
|
||
/if \([^)]*to\.name === 'data-process'[\s\S]*?\) \{([\s\S]*?)return true\s*\}/,
|
||
)?.[1] || ''
|
||
assert.ok(persistedLeaveBranch, '无法识别已创建任务返回列表的放行分支')
|
||
assert.doesNotMatch(persistedLeaveBranch, /confirmDialogRef|confirmDialog|confirm\(/, '已创建任务返回列表不应再弹出离开确认')
|
||
assert.match(regenerationSource, /route\.name === 'data-process-regenerate'[\s\S]*?route\.params\.id/, '创建向导没有从路由参数识别重新生成来源任务')
|
||
assert.match(viewSource, /const currentStep = ref\(0\)/, '重新生成必须从向导第一步开始')
|
||
|
||
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 < 1200, 'DataProcessCreateView 拆分后仍超过 1200 行')
|
||
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')
|
||
const officeViewerPath = path.join(createDir, 'OfficeSourceViewer.vue')
|
||
const resultEditorPath = path.join(createDir, 'ResultEditorStep.vue')
|
||
assert.ok(existsSync(typesPath), '缺少向导类型定义')
|
||
assert.ok(existsSync(modelPath), '缺少来源映射模型')
|
||
assert.ok(existsSync(pdfViewerPath), '缺少 PDF 原文件预览组件')
|
||
assert.ok(existsSync(officeViewerPath), '缺少 Word/XLSX 原文件预览组件')
|
||
|
||
const [typesSource, modelSource, previewSource, pdfViewerSource, officeViewerSource, resultEditorSource, generationStepSource] = await Promise.all([
|
||
readFile(typesPath, 'utf8'),
|
||
readFile(modelPath, 'utf8'),
|
||
readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'),
|
||
readFile(pdfViewerPath, 'utf8'),
|
||
readFile(officeViewerPath, 'utf8'),
|
||
readFile(resultEditorPath, 'utf8'),
|
||
readFile(generationStepPath, 'utf8'),
|
||
])
|
||
|
||
for (const field of ['sourceStart', 'sourceEnd', 'originalContent', 'editedContent']) {
|
||
assert.ok(typesSource.includes(field), `PreviewItem 缺少字段:${field}`)
|
||
}
|
||
assert.match(typesSource, /sourceFileId/, 'PreviewItem 缺少来源文件标识')
|
||
assert.match(typesSource, /sourceLocator\?: PreviewSourceLocator/, 'PreviewItem 缺少结构化来源定位契约')
|
||
assert.match(typesSource, /headingPath\?: string\[\]/, 'PreviewItem 缺少非结构化标题路径')
|
||
assert.match(typesSource, /PreviewSourceLocatorKind = 'json' \| 'jsonl' \| 'csv' \| 'xlsx'/, '前端来源定位 kind 未使用明确联合类型')
|
||
assert.match(contractTypesSource, /DataProcessSourceLocatorKind = 'json' \| 'jsonl' \| 'csv' \| 'xlsx'/, 'API 来源定位 kind 未使用明确联合类型')
|
||
for (const field of ['kind', 'record_index', 'start_line', 'end_line', 'source_start', 'source_end', 'json_pointer', 'sheet_index', 'sheet_name', 'row_number', 'sheet_record_index']) {
|
||
assert.ok(typesSource.includes(field), `PreviewSourceLocator 缺少字段:${field}`)
|
||
assert.ok(contractTypesSource.includes(field), `后端来源定位契约缺少字段:${field}`)
|
||
}
|
||
assert.match(contractTypesSource, /source_locator\?: DataProcessSourceLocator/, '质量信息缺少来源定位契约')
|
||
assert.match(contractTypesSource, /heading_path\?: string\[\]/, '质量信息缺少标题路径契约')
|
||
assert.match(viewSource, /const sourceLocator = item\.quality_score\?\.source_locator/, '预览映射丢失来源定位')
|
||
assert.match(viewSource, /sourceStart:\s*item\.source_start\s*\?\?\s*sourceLocator\?\.source_start/, 'JSON locator 的字符起点没有映射到预览项')
|
||
assert.match(viewSource, /sourceEnd:\s*item\.source_end\s*\?\?\s*sourceLocator\?\.source_end/, 'JSON locator 的字符终点没有映射到预览项')
|
||
assert.match(viewSource, /sourceStartLine:\s*item\.source_start_line\s*\?\?\s*sourceLocator\?\.start_line/, 'JSON locator 的起始行没有映射到预览项')
|
||
assert.match(viewSource, /sourceEndLine:\s*item\.source_end_line\s*\?\?\s*sourceLocator\?\.end_line/, 'JSON locator 的结束行没有映射到预览项')
|
||
assert.match(viewSource, /headingPath:[\s\S]*?item\.quality_score\?\.heading_path/, '预览映射丢失标题路径')
|
||
assert.match(typesSource, /export type StepId = 'create' \| 'model' \| 'upload' \| 'preview' \| 'generate' \| 'results'/, '步骤类型缺少独立大模型选择步骤')
|
||
assert.match(modelSource, /export function sourceLineWindow/, '缺少有界源文件行窗口函数')
|
||
assert.match(modelSource, /maxLines:\s*number/, '源文件行窗口缺少最大渲染行数参数')
|
||
assert.doesNotMatch(modelSource, /\.split\(\s*['"]\\n['"]\s*\)/, '源文件行窗口仍会先对全文 split')
|
||
assert.match(modelSource, /lines\.length < limit/, '源文件行扫描没有受最大行数约束')
|
||
assert.match(modelSource, /unicodeCodePointLength/, '源文件字符偏移未与后端 Unicode code point 计数保持一致')
|
||
assert.match(modelSource, /export function sourceLineNumberAtOffset/, '字符偏移缺少无数组的行号解析函数')
|
||
const manualPreviewHelperStart = modelSource.indexOf('export function isManualPreviewItem(')
|
||
const manualPreviewHelperEnd = modelSource.indexOf('\n}', manualPreviewHelperStart)
|
||
assert.ok(manualPreviewHelperStart >= 0, '缺少统一的手动预览项判定函数')
|
||
const manualPreviewHelperSource = modelSource.slice(manualPreviewHelperStart, manualPreviewHelperEnd + 2)
|
||
for (const field of ['status', 'originalContent', 'sourceStart', 'sourceEnd', 'sourceStartLine', 'sourceEndLine', 'sourcePages', 'sourceLocator']) {
|
||
assert.ok(manualPreviewHelperSource.includes(field), `手动预览项判定缺少来源字段:${field}`)
|
||
}
|
||
assert.doesNotMatch(modelSource, /buildPreviewItems/, '前端不应保留与后端重复的本地切片算法')
|
||
assert.match(viewSource, /selectedPreviewFileId/, '父页面缺少当前预览文件状态')
|
||
const previewBuildBindingStart = viewSource.indexOf('useDataProcessPreviewBuild()')
|
||
assert.ok(previewBuildBindingStart >= 0, '父页面没有接入后台切分 composable')
|
||
const previewBuildBindingSource = viewSource.slice(Math.max(0, previewBuildBindingStart - 160), previewBuildBindingStart + 40)
|
||
for (const action of ['startPreviewBuild', 'resumePreviewBuild', 'stopPreviewPolling']) {
|
||
assert.ok(previewBuildBindingSource.includes(action), `父页面缺少后台切分能力:${action}`)
|
||
}
|
||
assert.match(viewSource, /backend-pipeline-v4/, '切分管线版本未升级,旧预览缓存可能被误用')
|
||
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/, '第四步未使用来源结束偏移')
|
||
const lineRangeStart = previewSource.indexOf('function lineRange(item: PreviewItem)')
|
||
const lineRangeEnd = previewSource.indexOf('\n}', lineRangeStart)
|
||
const lineRangeSource = previewSource.slice(lineRangeStart, lineRangeEnd + 2)
|
||
assert.match(lineRangeSource, /isManualPreviewItem\(item\)[\s\S]*?手动新增,无源文件定位/, '来源标签仍会把缺少行偏移的正常记录误判为手动新增')
|
||
assert.match(lineRangeSource, /props\.processType === 'unstructured'[\s\S]*?来源:源文件记录/, '结构化来源记录缺少无行偏移时的准确标签')
|
||
assert.doesNotMatch(lineRangeSource, /sourceStartLine == null[^\n]*手动新增/, '来源标签仍直接以缺少行号判定手动新增')
|
||
assert.match(lineRangeSource, /sheet_name[\s\S]*?row_number[\s\S]*?来源:\$\{sheet\} · 第 \$\{locator\.row_number\} 行/, 'XLSX 来源标签没有展示工作表和物理行号')
|
||
assert.match(lineRangeSource, /json_pointer[\s\S]*?JSON 路径/, 'JSON 来源标签没有展示 JSON 路径')
|
||
assert.match(lineRangeSource, /locator\?\.kind === 'json'[\s\S]*?JSON 根对象/, 'JSON 根对象来源标签被空 JSON Pointer 错误降级')
|
||
assert.match(lineRangeSource, /locatedLines[\s\S]*?第 \$\{locatedLines\.start\}[\s\S]*?locatedLines\.end/, 'JSONL/CSV 来源标签没有展示行范围')
|
||
assert.match(lineRangeSource, /headingPath[\s\S]*?章节:/, '非结构化来源标签没有合并标题路径')
|
||
assert.match(previewSource, /sourceLocator\?\.start_line[\s\S]*?sourceLocator\?\.end_line/, '文本预览没有优先使用后端行号定位')
|
||
assert.match(previewSource, /sourceLocator\?\.source_start\s*\?\?\s*item\.sourceStart/, '文本预览没有优先使用 locator 字符起点')
|
||
assert.match(previewSource, /sourceLocator\?\.source_end\s*\?\?\s*item\.sourceEnd/, '文本预览没有优先使用 locator 字符终点')
|
||
assert.match(previewSource, /data-line-number="line\.number"/, '文本预览行缺少稳定行号定位标识')
|
||
assert.match(previewSource, /isLineHighlighted\(line\.number, line\.start, line\.end\)/, '文本预览没有按物理行号高亮')
|
||
assert.match(previewSource, /querySelector<HTMLElement>\(`\[data-line-number=/, '选中记录后没有按物理行号滚动定位')
|
||
assert.match(previewSource, /const SOURCE_LINE_RENDER_LIMIT = 240/, '源文件查看器缺少安全渲染上限')
|
||
assert.match(previewSource, /const SOURCE_LINE_CHARACTER_LIMIT = 4_000/, '源文件查看器缺少单行字符渲染上限')
|
||
assert.match(previewSource, /sourceLineWindow\([\s\S]*?SOURCE_LINE_RENDER_LIMIT/, '源文件查看器没有使用有界行窗口')
|
||
assert.match(previewSource, /SOURCE_LINE_RENDER_LIMIT,[\s\S]*?SOURCE_LINE_CHARACTER_LIMIT,[\s\S]*?selectedSourceLine\.value,[\s\S]*?selectedSourceOffset\.value/, '单行超大 JSON 没有围绕选中来源构建字符窗口')
|
||
assert.match(previewSource, /sourceWindowStartLine/, '源文件查看器缺少窗口起始行状态')
|
||
assert.match(previewSource, /showPreviousSourceWindow[\s\S]*?showNextSourceWindow/, '源文件查看器缺少前后窗口导航')
|
||
assert.match(previewSource, /sourceLineNumberAtOffset\(props\.sourceText/, '仅有字符偏移时没有解析目标物理行')
|
||
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, /<OfficeSourceViewer[\s\S]*v-else-if="isOfficeSource"/, 'Word/XLSX 没有切换到专用原文件查看组件')
|
||
assert.match(previewSource, /const isOfficeSource = computed[\s\S]*?'docx', 'xlsx'/, 'Word/XLSX 文件类型分流不完整')
|
||
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 页码映射接口地址不正确')
|
||
assert.match(apiSource, /getDataProcessOfficePreview/, '前端 API 缺少 Word/XLSX 预览接口')
|
||
assert.match(apiSource, /source-files\/\$\{encodeURIComponent\(fileId\)\}\/office-preview/, 'Word/XLSX 预览接口地址不正确')
|
||
for (const marker of [
|
||
'docx-page',
|
||
'docx-table',
|
||
'xlsx-grid',
|
||
'sheet-selector',
|
||
'xlsx-pagination',
|
||
'getDataProcessOfficePreview',
|
||
'is-highlighted',
|
||
'打开原文件',
|
||
'重试',
|
||
]) {
|
||
assert.ok(officeViewerSource.includes(marker), `Word/XLSX 预览缺少结构或行为:${marker}`)
|
||
}
|
||
assert.match(officeViewerSource, /const selectedXlsxLocator = computed/, 'XLSX 查看器没有读取精确来源定位')
|
||
assert.match(officeViewerSource, /row\.row_number === locator\.row_number/, 'XLSX 查看器没有按物理行号精确高亮')
|
||
assert.match(officeViewerSource, /row\.record_index === locator\.sheet_record_index/, 'XLSX 查看器没有按工作表记录序号精确高亮')
|
||
assert.match(officeViewerSource, /Math\.floor\(locator\.sheet_record_index \/ XLSX_PAGE_SIZE\) \* XLSX_PAGE_SIZE/, 'XLSX 查看器没有按记录序号自动计算分页')
|
||
assert.match(officeViewerSource, /activeSheetIndex\.value = targetSheet[\s\S]*?pageOffset\.value = targetOffset[\s\S]*?loadPreview\(\)/, '切换记录时 XLSX 查看器没有自动切工作表和分页')
|
||
const xlsxHighlightStart = officeViewerSource.indexOf('function xlsxRowHighlighted(')
|
||
const xlsxHighlightEnd = officeViewerSource.indexOf('\n}', xlsxHighlightStart)
|
||
const xlsxHighlightSource = officeViewerSource.slice(xlsxHighlightStart, xlsxHighlightEnd + 2)
|
||
assert.ok(
|
||
xlsxHighlightSource.indexOf('locator.row_number') < xlsxHighlightSource.indexOf('selectedRecordKey.value'),
|
||
'XLSX 查看器没有把精确定位放在原内容比对 fallback 之前',
|
||
)
|
||
|
||
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, /saveTaskConfiguration\(\)[\s\S]*?persistWorkflowStep\('model'\)/, '进入第二步前没有创建任务并持久化步骤')
|
||
assert.match(nextFromCreateSource, /goToStep\('model'\)/, '创建步骤校验通过后没有进入大模型选择')
|
||
assert.doesNotMatch(nextFromCreateSource, /uploadedFiles|buildDataProcessPreview/, '创建步骤仍在校验文件或提前生成预览')
|
||
assert.match(nextFromModelSource, /modelSelectionRef\.value\?\.validate\(\)/, '大模型选择步骤继续前没有校验模型配置')
|
||
assert.match(nextFromModelSource, /saveTaskConfiguration\(\)[\s\S]*?persistWorkflowStep\('upload'\)/, '大模型选择完成后没有保存配置并持久化步骤')
|
||
assert.match(nextFromModelSource, /goToStep\('upload'\)/, '大模型选择完成后没有进入上传文件')
|
||
assert.match(nextFromModelSource, /await modelsStore\.load\(true\)/, '提交任务前没有等待最新模型列表')
|
||
assert.ok(nextFromModelSource.indexOf('await modelsStore.load(true)') < nextFromModelSource.indexOf('prepareRegeneration(taskPayload())'), '重新生成可能在模型列表校验前修改服务端')
|
||
assert.ok(nextFromModelSource.indexOf('await modelsStore.load(true)') < nextFromModelSource.indexOf('await saveTaskConfiguration()'), '普通任务可能在模型列表校验前修改服务端')
|
||
assert.ok(nextFromModelSource.includes('模型列表加载失败,未提交任何修改'), '模型列表加载失败没有明确阻断提交')
|
||
assert.ok(nextFromModelSource.includes('原任务使用的模型已删除'), '原任务模型已删除没有要求重新选择')
|
||
assert.match(modelsStoreSource, /catch \{[\s\S]*?list\.value = \[\][\s\S]*?loaded\.value = false/, '模型列表请求失败后 loaded 状态仍可能错误保留')
|
||
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 monitorPreviewBuild\(pendingFileIds\)/,
|
||
'上传步骤没有通过独立 composable 启动后台切分',
|
||
)
|
||
assert.match(
|
||
previewBuildSource,
|
||
/startDataProcessPreview\(taskId,[\s\S]*?replace_existing:\s*true[\s\S]*?source_file_ids:\s*sourceFileIds/,
|
||
'切分 composable 没有调用后台启动 API 并传递待处理文件',
|
||
)
|
||
assert.match(
|
||
previewBuildSource,
|
||
/function isActive\([\s\S]*?preview_status === 'queued'[\s\S]*?preview_status === 'running'/,
|
||
'切分 composable 没有将 queued\/running 识别为活动状态',
|
||
)
|
||
assert.match(
|
||
previewBuildSource,
|
||
/while \(isActive\(progress\)[\s\S]*?getDataProcessPreviewProgress\(taskId\)/,
|
||
'切分 composable 没有持续轮询 queued\/running 的服务端状态',
|
||
)
|
||
assert.match(
|
||
previewBuildSource,
|
||
/async function resumePreviewBuild\([\s\S]*?getDataProcessPreviewProgress\(taskId\)[\s\S]*?pollUntilSettled/,
|
||
'重新进入上传步骤时没有接管已在后台运行的切分',
|
||
)
|
||
if (previewBuildSource.includes('function buildPreviewsByFile')) {
|
||
assert.match(
|
||
previewBuildSource,
|
||
/function buildPreviewsByFile[\s\S]*?startPreviewBuild\(taskId, sourceFileIds/,
|
||
'逐文件展示兼容层底层仍必须只启动一个后台切分任务',
|
||
)
|
||
}
|
||
assert.match(
|
||
previewBuildSource,
|
||
/onBeforeUnmount\(stopPreviewPolling\)/,
|
||
'离开页面时应只停止前端轮询,不得终止后台切分',
|
||
)
|
||
assert.match(
|
||
previewBuildSource,
|
||
/const pollRun = activePollRun[\s\S]*?await startDataProcessPreview[\s\S]*?pollUntilSettled\(taskId, progress, pollRun/,
|
||
'后台切分启动请求返回后必须复用原轮询令牌,避免页面卸载后重新启动轮询',
|
||
)
|
||
assert.match(
|
||
previewBuildSource,
|
||
/if \(pollRun !== activePollRun\) return progress/,
|
||
'已离开页面的后台切分请求不得重新接管页面轮询',
|
||
)
|
||
assert.doesNotMatch(previewBuildSource, /buildDataProcessPreview\(/, '新向导不应再依赖同步 preview\/build 请求')
|
||
assert.match(
|
||
viewSource,
|
||
/async function monitorPreviewBuild\([\s\S]*?startPreviewBuild\(taskId\.value, sourceFileIds[\s\S]*?finalProgress\.preview_status === 'completed'[\s\S]*?completePreviewWorkspace\(\)/,
|
||
'上传步骤没有启动后台切分或仅在完成后加载工作区',
|
||
)
|
||
assert.match(
|
||
viewSource,
|
||
/async function completePreviewWorkspace\(\)[\s\S]*?loadAllPreviewItems\(\)[\s\S]*?failedCount[\s\S]*?return false[\s\S]*?persistWorkflowStep\('preview'\)[\s\S]*?goToStep\('preview'\)/,
|
||
'后台切分未完成时向导不得越过第三步',
|
||
)
|
||
assert.match(viewSource, /function goToStep\(stepId: StepId\)[\s\S]*?WIZARD_STEPS\.findIndex/, '向导跳转没有使用稳定步骤标识')
|
||
assert.match(contractTypesSource, /export type DataProcessWorkflowStep = 'create' \| 'model' \| 'upload' \| 'preview' \| 'generate' \| 'results'/, '服务端向导步骤契约不完整')
|
||
assert.match(contractTypesSource, /workflow_step\?: DataProcessWorkflowStep/, '任务快照缺少可恢复的 workflow_step')
|
||
assert.match(
|
||
apiSource,
|
||
/export const updateDataProcessWorkflowStep[\s\S]*?\/workflow-step`[\s\S]*?\{ workflow_step: workflowStep \}/,
|
||
'前端 API 没有使用独立资源持久化向导步骤',
|
||
)
|
||
assert.match(
|
||
viewSource,
|
||
/async function persistWorkflowStep\([\s\S]*?updateDataProcessWorkflowStep\(taskId\.value, step as DataProcessWorkflowStep\)/,
|
||
'向导步骤切换没有持久化 workflow_step',
|
||
)
|
||
for (const workflowStep of ['model', 'upload', 'preview', 'generate', 'results']) {
|
||
assert.match(
|
||
viewSource,
|
||
new RegExp(`persistWorkflowStep\\(['"]${workflowStep}['"]\\)`),
|
||
`第 2-6 步推进缺少服务端持久化:${workflowStep}`,
|
||
)
|
||
}
|
||
assert.match(routerSource, /path:\s*'data-process\/:id\/workflow'[\s\S]*?name:\s*'data-process-workflow'[\s\S]*?DataProcessCreateView\.vue/, '运行中任务缺少向导恢复路由')
|
||
assert.match(regenerationSource, /route\.name === 'data-process-workflow'/, '创建向导没有识别任务恢复模式')
|
||
assert.match(generationSource, /async function resumeGeneration\(\)[\s\S]*?getDataProcessProgress\(taskId\)[\s\S]*?pollGeneration/, '恢复第五步时没有接管后台任务进度')
|
||
const workflowInitializationStart = viewSource.indexOf('async function initializeExistingWorkflow()')
|
||
const workflowInitializationEnd = viewSource.indexOf('onBeforeUnmount(', workflowInitializationStart)
|
||
assert.ok(workflowInitializationStart >= 0 && workflowInitializationEnd > workflowInitializationStart, '缺少现有任务向导初始化流程')
|
||
const workflowInitializationSource = viewSource.slice(workflowInitializationStart, workflowInitializationEnd)
|
||
assert.match(
|
||
workflowInitializationSource,
|
||
/sourceTask\.workflow_step[\s\S]*?goToStep/,
|
||
'恢复向导时没有优先使用服务端 workflow_step',
|
||
)
|
||
assert.doesNotMatch(
|
||
workflowInitializationSource,
|
||
/shouldReturnToUpload|preview_count[\s\S]*?\?\s*'upload'\s*:\s*'generate'/,
|
||
'向导恢复不得继续依赖 preview_count 猜测第三或第五步',
|
||
)
|
||
assert.match(
|
||
workflowInitializationSource,
|
||
/preview_status[\s\S]*?(?:'queued'[\s\S]*?'running'|'running'[\s\S]*?'queued')[\s\S]*?goToStep\('upload'\)[\s\S]*?monitorPreviewBuild\(sourceFileIds, true\)/,
|
||
'切分运行中时没有强制回到第三步并接管后台进度',
|
||
)
|
||
assert.match(
|
||
workflowInitializationSource,
|
||
/sourceTask\.status === 'running'[\s\S]*?resumeStep = 'generate'[\s\S]*?resumeGeneration\(\)[\s\S]*?goToStep\(resumeStep\)/,
|
||
'生成运行中时没有强制回到第五步并接管后台进度',
|
||
)
|
||
const startGenerationHandler = viewSource.slice(
|
||
viewSource.indexOf('async function handleStartGeneration()'),
|
||
viewSource.indexOf('async function persistWorkspaceForStep(', viewSource.indexOf('async function handleStartGeneration()')),
|
||
)
|
||
assert.match(startGenerationHandler, /await persistWorkflowStep\('generate'\)[\s\S]*?await startGeneration\(\)[\s\S]*?dirty\.value = false/, '开始生成没有持久化第五步或启动真实后台任务')
|
||
assert.doesNotMatch(startGenerationHandler, /router\.(?:push|replace)|allowLeave\s*=\s*true/, '开始生成后应停留在第五步,不得自动跳回列表')
|
||
assert.match(
|
||
generationSource,
|
||
/const canReturnFromGeneration = computed\(\(\) => \([\s\S]*?generation\.status === 'idle'[\s\S]*?!generationStarting\.value[\s\S]*?!generationRestoring\.value/,
|
||
'第五步返回权限没有区分未启动、启动中和恢复中状态',
|
||
)
|
||
assert.match(
|
||
viewSource,
|
||
/:disabled="\(currentStepId === 'generate' && !canReturnFromGeneration\) \|\| previewBuilding \|\| sourceUploading"/,
|
||
'第五步尚未启动生成时返回按钮仍被禁用',
|
||
)
|
||
const handleBackStart = viewSource.indexOf('async function handleBack()')
|
||
const handleBackEnd = viewSource.indexOf('\n}', handleBackStart)
|
||
const handleBackSource = viewSource.slice(handleBackStart, handleBackEnd + 2)
|
||
assert.match(
|
||
handleBackSource,
|
||
/currentStepId\.value === 'generate' && !canReturnFromGeneration\.value/,
|
||
'第五步处理函数仍无条件拦截返回',
|
||
)
|
||
assert.match(
|
||
generationSource,
|
||
/async function resumeGeneration\(\)[\s\S]*?generationRestoring\.value = true[\s\S]*?await getDataProcessProgress\(taskId\)[\s\S]*?generationRestoring\.value = false/,
|
||
'恢复已启动任务时存在短暂可返回的 idle 窗口',
|
||
)
|
||
assert.match(viewSource, /const resume = resumeGeneration\(\)[\s\S]*?goToStep\(resumeStep\)[\s\S]*?await resume/, '第五步展示时未先启动恢复锁')
|
||
assert.match(
|
||
generationSource,
|
||
/const generationStarting = ref\(false\)[\s\S]*?generationStarting\.value = true[\s\S]*?generationStarting\.value = false/,
|
||
'点击开始生成后到请求启动前没有锁定返回状态',
|
||
)
|
||
assert.match(
|
||
viewSource,
|
||
/generation\.status === 'success'[\s\S]*?persistWorkflowStep\('results'\)/,
|
||
'只有生成完成后才能持久化进入第六步',
|
||
)
|
||
assert.match(viewSource, /confirmDataProcessResults\(taskId\.value\)[\s\S]*?name: 'data-process-detail'/, '第六步没有在确认结果后进入正式详情')
|
||
assert.doesNotMatch(generationStepSource, /停止生成|emit\(['"]stop['"]\)|stop:\s*\[\]/, '第五步不应保留停止生成入口')
|
||
assert.doesNotMatch(viewSource, /@stop=|\bstopGeneration\b/, '创建向导不应绑定删除以外的生成终止动作')
|
||
assert.doesNotMatch(generationSource, /\bstopDataProcess\b|async function stopGeneration\b|\bstopGeneration,/, '生成 composable 不应暴露任务终止能力')
|
||
assert.match(viewSource, /<ResultEditorStep\s+[\s\S]*?v-else-if="currentStepId === 'results'"/, '结果编辑器必须只在结果步骤渲染')
|
||
assert.match(viewSource, /<ResultEditorStep[\s\S]*?:preview-items="previewItems"/, '结果编辑器没有接收已有预览原文')
|
||
assert.match(typesSource, /previewItemId: string \| null/, '结果项缺少预览原文关联 ID')
|
||
assert.match(generationSource, /previewItemId:\s*item\.preview_item_id == null \? null : String\(item\.preview_item_id\)/, '结果映射丢失 preview_item_id')
|
||
assert.match(resultEditorSource, /previewItems: PreviewItem\[\]/, '结果编辑器缺少原文列表契约')
|
||
assert.match(resultEditorSource, /props\.previewItems\.find\(\(item\) => item\.id === previewItemId\)/, '结果编辑器没有按 preview_item_id 关联原文')
|
||
assert.match(resultEditorSource, /editedContent\.trim\(\)[\s\S]*?originalContent\.trim\(\)/, '原文参照没有优先展示实际用于生成的预处理内容')
|
||
assert.match(resultEditorSource, /原文参照/, '结果编辑器缺少原文参照区域')
|
||
assert.match(resultEditorSource, /实际送入模型的预处理后原文/, '结果编辑器没有说明原文参照口径')
|
||
assert.match(resultEditorSource, /selectedItem\.savedStatus === 'invalid'[\s\S]*?重新生成/, '失败结果右上角缺少单条重新生成按钮')
|
||
assert.match(resultEditorSource, /regeneratingResultId: string \| null/, '结果编辑器缺少单条重新生成 loading 契约')
|
||
assert.match(resultEditorSource, /emit\('regenerate:item', selectedItem\.id\)/, '失败结果按钮没有触发单条重新生成事件')
|
||
assert.match(viewSource, /:regenerating-result-id="regeneratingResultId"[\s\S]*?@regenerate:item="regenerateResult"/, '创建向导没有接入单条重新生成状态与事件')
|
||
assert.match(apiSource, /regenerateDataProcessResult[\s\S]*?\/results\/\$\{encodeURIComponent\(resultId\)\}\/regenerate/, '前端 API 缺少安全编码的单条重新生成接口')
|
||
assert.match(generationSource, /async function regenerateResult\(id: string\)[\s\S]*?regenerateDataProcessResult\(taskId, id,[\s\S]*?results\.value\[index\] = mapResult\(regenerated\)[\s\S]*?finally[\s\S]*?regeneratingResultId\.value = null/, '单条重新生成没有原位替换结果或可靠释放 loading')
|
||
assert.match(resultEditorSource, /invalidCount[\s\S]*?全部重新生成/, '结果编辑器缺少失败项一键全部重新生成入口')
|
||
assert.match(resultEditorSource, /emit\('regenerate:all'\)/, '全部重新生成按钮没有触发批量事件')
|
||
assert.match(resultEditorSource, /已处理[\s\S]*?成功[\s\S]*?失败/, '批量重新生成缺少总体进度与成功失败统计')
|
||
assert.doesNotMatch(resultEditorSource, /selectedItem\.qualityFlags|class="quality-flags"|v-for="flag in selectedItem\.qualityFlags"/, '结果编辑器不应直接渲染后端原始质量标签')
|
||
assert.doesNotMatch(typesSource, /qualityFlags/, '结果编辑器数据类型不应继续保留已移除的原始质量标签')
|
||
assert.doesNotMatch(generationSource, /qualityFlags:/, '结果映射不应继续创建已移除的原始质量标签')
|
||
assert.match(resultEditorSource, /selectedItem\.error[\s\S]*?validation-error/, '移除原始质量标签后仍应展示可读的失败原因')
|
||
assert.match(viewSource, /:bulk-regeneration="bulkRegeneration"[\s\S]*?@regenerate:all="regenerateAllResults"/, '创建向导没有接入全部重新生成状态与事件')
|
||
assert.match(apiSource, /regenerateDataProcessResults[\s\S]*?\/results\/regenerate-batch/, '前端 API 缺少批量重新生成接口')
|
||
assert.match(generationSource, /const BULK_REGENERATION_CHUNK_SIZE = 12/, '批量重新生成没有在接口超时预算内持续填满并发槽位')
|
||
assert.match(generationSource, /for \(let offset = 0; offset < candidates\.length; offset \+= BULK_REGENERATION_CHUNK_SIZE\)[\s\S]*?candidates\.slice\(offset, offset \+ BULK_REGENERATION_CHUNK_SIZE\)/, '批量重新生成循环没有实际使用受控批次大小')
|
||
assert.match(generationSource, /async function regenerateAllResults\(\)[\s\S]*?savedStatus === 'invalid'[\s\S]*?regenerateDataProcessResults[\s\S]*?bulkRegeneration\.completed/, '一键全部重新生成没有按服务端失败状态推进总体进度')
|
||
assert.match(generationSource, /bulkRegeneration\.status = 'partial'[\s\S]*?bulkRegeneration\.status = 'failed'/, '批量重新生成缺少部分成功与全部失败状态')
|
||
assert.match(typesSource, /savedStatus: 'valid' \| 'modified' \| 'invalid'/, '结果项缺少服务端保存状态,可能把未保存的本地错误误判为可重生成')
|
||
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 expectedStructuredGroups = [
|
||
[
|
||
"values: ['clean_invalid', 'deduplicate']",
|
||
'数据清洗',
|
||
'清理全空列和空记录,并删除内容完全相同的记录;不会猜测可空字段是否必填',
|
||
],
|
||
[
|
||
"values: ['detect_structure', 'normalize_format']",
|
||
'结构标准化',
|
||
'展平嵌套对象和可解析的 JSON 字段,并统一编码、空白、字段名和 JSON 序列化格式',
|
||
],
|
||
["values: ['desensitize']", '敏感信息脱敏', '识别并脱敏姓名、手机号、邮箱和身份证号'],
|
||
]
|
||
for (const [values, label, description] of expectedStructuredGroups) {
|
||
assert.ok(structuredOptionsSource.includes(values), `结构化预处理组合值不准确:${label}`)
|
||
assert.ok(structuredOptionsSource.includes(`label: '${label}'`), `结构化预处理缺少标签:${label}`)
|
||
assert.ok(structuredOptionsSource.includes(`description: '${description}'`), `结构化预处理语义不准确:${label}`)
|
||
}
|
||
assert.equal(expectedStructuredGroups.length, 3, '结构化预处理应收敛为 3 项')
|
||
const preprocessGroupsSource = structuredOptionsSource.slice(
|
||
structuredOptionsSource.indexOf('const PREPROCESS_GROUPS'),
|
||
structuredOptionsSource.indexOf('const legacyAnomalyFilterEnabled'),
|
||
)
|
||
assert.doesNotMatch(preprocessGroupsSource, /异常数据过滤|filter_anomaly|IQR/, '结构化新任务仍暴露异常数据过滤')
|
||
assert.match(structuredOptionsSource, /:indeterminate="groupIndeterminate\(group\.values\)"/, '历史部分选中的组合项没有半选回显')
|
||
assert.match(structuredOptionsSource, /function updatePreprocessGroup\([\s\S]*?new Set\(props\.options\.preprocessOptions\)[\s\S]*?next\.add\(value\)[\s\S]*?next\.delete\(value\)[\s\S]*?\[\.\.\.next\]/, '结构化预处理组合开关没有原子化更新或去重内部选项')
|
||
assert.match(typesSource, /仅用于恢复历史任务[\s\S]*?\| 'filter_anomaly'/, '异常数据过滤缺少历史兼容类型')
|
||
assert.match(structuredOptionsSource, /legacyAnomalyFilterEnabled[\s\S]*?历史任务[\s\S]*?结果可复现/, '历史异常过滤配置没有透明提示')
|
||
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(typesSource, /QA_PAIRS_GENERATION_LIMITS\s*=\s*\{ min: 1, max: 50 \}/, '问答生成数量统一范围必须为 1 到 50')
|
||
assert.match(typesSource, /function normalizeQaPairsGenerationCount[\s\S]*?Math\.trunc\(parsed\)[\s\S]*?QA_PAIRS_GENERATION_LIMITS\.max[\s\S]*?QA_PAIRS_GENERATION_LIMITS\.min/, '问答生成数量缺少统一整数归一化和边界裁剪')
|
||
assert.match(structuredOptionsSource, /options\.qaPairsPerRow[\s\S]*?:min="QA_PAIRS_GENERATION_LIMITS\.min"[\s\S]*?:max="QA_PAIRS_GENERATION_LIMITS\.max"/, '每行生成数量必须使用统一的 1 到 50 限制')
|
||
assert.match(structuredOptionsSource, /支持 1~50 条;数量越大,处理耗时和 Token 消耗越高/, '结构化生成数量缺少耗时与 Token 消耗说明')
|
||
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.doesNotMatch(generationSource, /createResults\(/, '生成 composable 仍在本地伪造处理结果')
|
||
|
||
for (const apiName of [
|
||
'createDataProcessTask',
|
||
'updateDataProcessWorkflowStep',
|
||
'regenerateDataProcessTask',
|
||
'uploadDataProcessSourceFiles',
|
||
'buildDataProcessPreview',
|
||
'startDataProcessPreview',
|
||
'getDataProcessPreviewProgress',
|
||
'getDataProcessPreview',
|
||
'generateDataProcess',
|
||
'getDataProcessProgress',
|
||
'getDataProcessResults',
|
||
'updateDataProcessResult',
|
||
'confirmDataProcessResults',
|
||
'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 文件缺少转换提示')
|
||
const sourceValidationStart = sourceUploadWorkerSource.indexOf('export function validateSourceFileSelection(')
|
||
const sourceValidationEnd = sourceUploadWorkerSource.indexOf('\n}\n\nfunction unicodeCodePointLength', sourceValidationStart)
|
||
assert.ok(sourceValidationStart >= 0 && sourceValidationEnd > sourceValidationStart, '无法定位源文件选择校验函数')
|
||
const sourceValidationSource = sourceUploadWorkerSource.slice(sourceValidationStart, sourceValidationEnd + 2)
|
||
assert.doesNotMatch(sourceValidationSource, /file\.name === raw\.name[\s\S]{0,160}file\.size === raw\.size|同名且同大小/, '不同内容但同名同大小的文件仍会被前端误拒绝')
|
||
assert.match(sourceValidationSource, /selectedFiles\.length >= MAX_SOURCE_FILE_COUNT/, '移除伪重复校验时误删了文件数量限制')
|
||
assert.match(sourceValidationSource, /selectedBytes \+ raw\.size > MAX_SOURCE_BATCH_BYTES/, '移除伪重复校验时误删了批次大小限制')
|
||
assert.doesNotMatch(sourceUploadWorkerSource, /job\.file\.arrayBuffer\(|new TextDecoder/, '上传前仍把整个文本文件读入浏览器内存')
|
||
assert.match(sourceUploadWorkerSource, /export async function loadCanonicalSourceContent[\s\S]*?offset,[\s\S]*?limit: SOURCE_CONTENT_PAGE_CHARS/, '服务端 canonical content 没有按有界字符窗口读取')
|
||
assert.match(sourceUploadWorkerSource, /pending\.content = await loadCanonicalSourceContent\(currentTaskId, source\.id\)/, '上传成功后没有统一使用服务端 canonical content')
|
||
assert.doesNotMatch(sourceUploadWorkerSource, /\brawFile:\s*job\.file\b/, '上传成功状态仍长期保留原始 File')
|
||
assert.doesNotMatch(typesSource, /\brawFile\??:\s*File\b/, '上传状态类型仍长期持有原始 File')
|
||
assert.doesNotMatch(viewSource, /\brawFile:\s*raw\b/, '待上传列表仍复制保存原始 File')
|
||
assert.match(apiSource, /params:\s*\{[\s\S]*?offset\?: number[\s\S]*?limit\?: number[\s\S]*?\}/, '正文 API 前端契约缺少字符窗口参数')
|
||
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, /\/preview\/start`/, 'API 模块缺少后台切分启动路径')
|
||
assert.match(apiSource, /\/preview\/progress`/, 'API 模块缺少后台切分进度路径')
|
||
assert.match(apiSource, /\/workflow-step`/, '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,
|
||
/export type DataProcessPreviewFileStatus = 'waiting' \| 'processing' \| 'success' \| 'failed'/,
|
||
'文件预览状态契约不完整',
|
||
)
|
||
assert.match(
|
||
contractTypesSource,
|
||
/export interface DataProcessPreviewProgress[\s\S]*?workflow_step: DataProcessWorkflowStep[\s\S]*?preview_status: DataProcessPreviewStatus[\s\S]*?preview_progress: number[\s\S]*?preview_run_id/,
|
||
'后台切分进度契约缺少步骤、状态、进度或任务代次',
|
||
)
|
||
for (const field of ['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(nextFromUploadSource, /failedUploads[\s\S]*?hasUnfinishedUploads[\s\S]*?monitorPreviewBuild\(pendingFileIds\)/, '上传失败或未完成时没有阻断后台切分')
|
||
assert.doesNotMatch(viewSource, /file\.sourceFileId \|\| file\.uid/, '切分或删除仍可能把本地临时 UID 当成后端文件 ID')
|
||
assert.match(contractTypesSource, /expected_updated_at\?: string/, '编辑契约缺少乐观并发版本字段')
|
||
assert.match(contractTypesSource, /export interface DataProcessRegeneratePayload extends DataProcessTaskCreatePayload[\s\S]*?expected_updated_at: string/, '重新生成 payload 的原任务并发版本必须为必填')
|
||
assert.match(contractTypesSource, /export interface DataProcessRegenerateResult[\s\S]*?preview_invalidated: boolean[\s\S]*?published_outputs_preserved: boolean/, '重新生成响应缺少预览失效或已发布数据保留状态')
|
||
assert.match(apiSource, /regenerateDataProcessTask[\s\S]*?\/regenerate`/, '重新生成 API 路径未接入')
|
||
|
||
for (const field of [
|
||
'generationModelId',
|
||
'generationPrompt',
|
||
'outputType',
|
||
'reasoningDetail',
|
||
'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="2000"/, '默认提示语缺少合理的长度限制')
|
||
assert.match(generationControlSource, /aria-label="输出类型"/, '输出类型选项缺少可访问名称')
|
||
assert.match(generationControlSource, /v-if="options\.outputType === 'reasoning'" class="output-type-row"/, '推理详细程度没有按思维链模式渐进显示')
|
||
assert.match(generationControlSource, /aria-label="推理详细程度"/, '推理详细程度缺少可访问名称')
|
||
assert.match(generationControlSource, /<think>推理过程<\/think>/, '思维链选项没有说明最终保存格式')
|
||
assert.match(generationControlSource, /<el-select[\s\S]*?class="output-type-select"[\s\S]*?aria-label="输出类型"/, '输出类型必须使用右侧下拉选择')
|
||
assert.doesNotMatch(generationControlSource, /class="output-type-options"/, '输出类型不应继续使用横向按钮组')
|
||
assert.match(generationControlSource, /\.output-type-row\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) 150px/, '输出类型没有与生成数量控件保持一致宽度')
|
||
assert.match(generationControlSource, /\.output-type-select\s*\{[\s\S]*?width:\s*150px/, '输出类型下拉框宽度没有与生成数量控件对齐')
|
||
assert.match(
|
||
generationControlSource,
|
||
/<div v-else class="generation-config-group quality-config-group">[\s\S]*?<strong>输出类型<\/strong>[\s\S]*?<div class="quality-switch-row">/,
|
||
'输出类型必须显示在第一步生成选项中,并位于质量筛选之前',
|
||
)
|
||
const modelGenerationSection = generationControlSource.slice(
|
||
generationControlSource.indexOf('<div v-if="section === \'model\'"'),
|
||
generationControlSource.indexOf('<div v-else class="generation-config-group quality-config-group">'),
|
||
)
|
||
assert.doesNotMatch(modelGenerationSection, /<strong>输出类型<\/strong>/, '第二步大模型选择不应重复显示输出类型')
|
||
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/,
|
||
'大模型高级参数没有改为与第一步一致的纵向布局',
|
||
)
|
||
function defaultPromptValue(name) {
|
||
const match = stateSource.match(new RegExp(`export const ${name} = ` + '`([\\s\\S]*?)`'))
|
||
assert.ok(match, `${name} 缺少可编辑的内置默认提示语`)
|
||
return match[1]
|
||
}
|
||
const standardPrompt = defaultPromptValue('DEFAULT_STANDARD_GENERATION_PROMPT')
|
||
const reasoningPrompt = defaultPromptValue('DEFAULT_REASONING_GENERATION_PROMPT')
|
||
assert.ok(standardPrompt.includes('{{ content }}'), '标准回答默认提示语缺少 {{ content }} 占位符')
|
||
assert.ok(reasoningPrompt.includes('{{ content }}'), '思维链默认提示语缺少 {{ content }} 占位符')
|
||
assert.ok(standardPrompt.length <= 2000, '标准回答默认提示语超过输入框长度限制')
|
||
assert.ok(reasoningPrompt.length <= 2000, '思维链默认提示语超过输入框长度限制')
|
||
assert.ok(reasoningPrompt.includes('逐步展开思考过程'), '思维链默认提示语没有明确要求逐步生成思考过程')
|
||
assert.ok(reasoningPrompt.includes('不得跳过关键步骤只给结论'), '思维链默认提示语没有禁止只生成最终答案')
|
||
assert.notEqual(standardPrompt, reasoningPrompt, '标准回答与思维链回答不得共用默认提示语')
|
||
assert.match(generationControlSource, /默认提示语已包含[\s\S]*?\{\{ content \}\}[\s\S]*?可移动该占位符/, '默认提示语没有说明 {{ content }} 的内置位置和修改方式')
|
||
assert.equal((stateSource.match(/generationPrompt:\s*DEFAULT_STANDARD_GENERATION_PROMPT/g) || []).length, 2, '结构化与非结构化任务应默认使用标准回答提示语')
|
||
assert.match(generationControlSource, /isBuiltInGenerationPrompt\(props\.options\.generationPrompt\)[\s\S]*?defaultGenerationPrompt\(outputType\)[\s\S]*?: props\.options\.generationPrompt/, '切换输出类型时没有在保留自定义提示语的前提下切换内置提示语')
|
||
assert.equal((stateSource.match(/^\s{4}outputType:\s*'standard',/gm) || []).length, 2, '结构化与非结构化任务应默认生成标准回答')
|
||
assert.equal((stateSource.match(/reasoningDetail:\s*'normal'/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 = 'layout_hybrid' \| 'semantic' \| 'fixed'/,
|
||
'非结构化切分方式类型必须只保留 layout_hybrid、semantic 和 fixed',
|
||
)
|
||
for (const field of [
|
||
'preprocessOptions',
|
||
'chunkMethod',
|
||
'chunkSize',
|
||
'chunkOverlap',
|
||
'minChunkSize',
|
||
'semanticBreakpointPercentile',
|
||
'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.match(taskSetupSource, /processTypeLocked\?: boolean/, '任务配置缺少处理类型锁定状态')
|
||
assert.match(taskSetupSource, /:disabled="processTypeLocked"/, '重新生成时处理类型选项没有禁用')
|
||
assert.ok(taskSetupSource.includes('重新生成沿用原任务处理类型,不可修改'), '处理类型锁定缺少明确说明')
|
||
assert.match(viewSource, /:process-type-locked="isRegeneration"/, '创建向导没有向第一步传递重新生成锁定状态')
|
||
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 = [
|
||
['layout_hybrid', '版面结构混合切分'],
|
||
['semantic', '语义切分'],
|
||
['fixed', '固定 Token 切分'],
|
||
]
|
||
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 ['structure', 'custom', 'heading']) {
|
||
assert.ok(!chunkMethodsSource.includes(`value: '${removedMethod}'`), `切分方式仍保留已移除值:${removedMethod}`)
|
||
}
|
||
assert.ok(unstructuredOptionsSource.includes('默认推荐,按标题、段落、列表和表格结构切分'), '版面结构混合切分缺少推荐说明')
|
||
assert.ok(unstructuredOptionsSource.includes('根据相邻内容的语义变化寻找主题边界'), '语义切分缺少适用场景说明')
|
||
assert.ok(unstructuredOptionsSource.includes('按句子边界控制固定 Token 长度'), '固定 Token 切分缺少稳定性说明')
|
||
assert.doesNotMatch(unstructuredOptionsSource, /自定义分隔符/, '切分选项仍保留自定义分隔符')
|
||
assert.doesNotMatch(taskSetupSource, /customDelimiter|自定义分隔符/, '任务配置仍校验已移除的自定义分隔符')
|
||
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')
|
||
assert.match(unstructuredOptionsSource, /options\.semanticBreakpointPercentile[\s\S]*?:min="1"[\s\S]*?:max="99"/, '语义断点百分位必须限制在 1 到 99')
|
||
assert.match(unstructuredOptionsSource, /v-if="options\.chunkMethod === 'semantic'"[\s\S]*?options\.semanticBreakpointPercentile/, '语义断点百分位必须仅在语义切分时显示')
|
||
|
||
for (const label of ['每个切片生成数量', '数据集划分']) {
|
||
assert.ok(taskSetupFeatureSource.includes(label), `非结构化生成选项缺少:${label}`)
|
||
}
|
||
for (const removedLabel of ['上下文范围', '问题类型', '跳过无法回答的内容']) {
|
||
assert.ok(!taskSetupFeatureSource.includes(removedLabel), `简化后仍显示低频选项:${removedLabel}`)
|
||
}
|
||
assert.match(unstructuredOptionsSource, /qaPairsPerChunk:\s*QA_PAIRS_GENERATION_LIMITS/, '每切片生成数量裁剪必须使用统一的 1 到 50 限制')
|
||
assert.match(unstructuredOptionsSource, /options\.qaPairsPerChunk[\s\S]*?:min="QA_PAIRS_GENERATION_LIMITS\.min"[\s\S]*?:max="QA_PAIRS_GENERATION_LIMITS\.max"/, '每个切片生成数量必须使用统一的 1 到 50 限制')
|
||
assert.match(unstructuredOptionsSource, /支持 1~50 条;数量越大,处理耗时和 Token 消耗越高/, '非结构化生成数量缺少耗时与 Token 消耗说明')
|
||
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*'layout_hybrid'/, '非结构化默认切分方式必须为版面结构混合切分')
|
||
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, /semanticBreakpointPercentile:\s*95/, '默认语义断点百分位必须为 95')
|
||
assert.match(stateSource, /qaPairsPerChunk:\s*1/, '默认每个切片必须生成 1 个问答对')
|
||
assert.match(stateSource, /qaPairsPerRow:\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,
|
||
[],
|
||
'结构化新任务不应默认勾选预处理',
|
||
)
|
||
assert.equal(new Set(defaultStructuredPreprocess).size, defaultStructuredPreprocess.length, '结构化默认预处理值重复')
|
||
const defaultUnstructuredPreprocess = defaultPreprocessValues('createDefaultUnstructuredOptions')
|
||
assert.deepEqual(defaultUnstructuredPreprocess, [], '非结构化新任务不应默认勾选预处理')
|
||
assert.equal(new Set(defaultUnstructuredPreprocess).size, defaultUnstructuredPreprocess.length, '非结构化默认预处理值重复')
|
||
for (const field of ['preserveTables', 'preserveCodeBlocks', 'preserveLists']) {
|
||
assert.match(
|
||
stateSource,
|
||
new RegExp(`${field}:\\s*false`),
|
||
`非结构化预处理选项 ${field} 不应默认开启`,
|
||
)
|
||
}
|
||
assert.match(structuredOptionsSource, /默认不执行预处理,请按数据情况自行选择/, '结构化预处理缺少默认不勾选说明')
|
||
assert.match(unstructuredOptionsSource, /默认不执行预处理,请按文档情况自行选择/, '非结构化预处理缺少默认不勾选说明')
|
||
assert.doesNotMatch(unstructuredOptionsSource, /默认启用结构感知/, '非结构化预处理仍保留默认启用的误导文案')
|
||
|
||
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'],
|
||
['output_type', 'outputType'],
|
||
['reasoning_detail', 'reasoningDetail'],
|
||
['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'],
|
||
['semantic_breakpoint_percentile', 'semanticBreakpointPercentile'],
|
||
['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: originalProcessType.value || processType.value', 'config: toBackendConfig()']) {
|
||
assert.ok(taskPayloadSource.includes(marker), `任务创建 payload 缺少:${marker}`)
|
||
}
|
||
assert.match(stateSource, /Object\.prototype\.hasOwnProperty\.call\(config, key\)/, '配置反向映射没有区分缺失值与 false/0')
|
||
assert.match(stateSource, /Number\.isFinite\(value\) \? value : fallback/, '配置反向映射没有保留合法数字 0')
|
||
assert.match(stateSource, /qaPairsPerRow:\s*normalizeQaPairsGenerationCount\([\s\S]*?qa_pairs_per_row[\s\S]*?defaults\.qaPairsPerRow/, '结构化生成数量回填没有按 1 到 50 归一化')
|
||
assert.match(stateSource, /qaPairsPerChunk:\s*normalizeQaPairsGenerationCount\([\s\S]*?qa_pairs_per_chunk[\s\S]*?defaults\.qaPairsPerChunk/, '非结构化生成数量回填没有按 1 到 50 归一化')
|
||
assert.match(stateSource, /configuredOutputType === 'reasoning' \|\| configuredOutputType === 'dpo'[\s\S]*?\? configuredOutputType[\s\S]*?: 'standard'/, '输出类型没有从任务配置安全回填')
|
||
assert.match(stateSource, /reasoningDetail:\s*configValue\(config, 'reasoning_detail', defaults\.reasoningDetail\) === 'detailed'[\s\S]*?\? 'detailed'[\s\S]*?: 'normal'/, '推理详细程度没有从任务配置安全回填')
|
||
assert.match(stateSource, /isBuiltInGenerationPrompt\(configuredPrompt\)[\s\S]*?defaultGenerationPrompt\(outputType\)/, '旧版内置提示语没有按输出类型迁移')
|
||
assert.match(stateSource, /createStructuredOptionsFromConfig/, '结构化配置缺少后端到表单的反向映射')
|
||
assert.match(stateSource, /createUnstructuredOptionsFromConfig/, '非结构化配置缺少后端到表单的反向映射')
|
||
assert.match(stateSource, /configValue<unknown>\(config, 'preprocess_options', \[\]\)/, '历史任务缺少预处理配置时必须按后端空列表语义回填')
|
||
for (const [field, fallback] of [
|
||
['preserve_tables', 'preserveTables'],
|
||
['preserve_code_blocks', 'preserveCodeBlocks'],
|
||
['preserve_lists', 'preserveLists'],
|
||
]) {
|
||
assert.match(
|
||
stateSource,
|
||
new RegExp(`configValue\\(\\s*config,\\s*'${field}',[\\s\\S]*?defaults\\.${fallback}`),
|
||
`历史任务缺少 ${field} 时必须沿用表单默认值,同时保留显式 false`,
|
||
)
|
||
}
|
||
assert.match(regenerationSource, /getDataProcessTask\(sourceTaskId\.value\)/, '重新生成没有加载原任务')
|
||
assert.match(regenerationSource, /loadCanonicalSourceContent\(taskId, file\.id\)/, '重新生成没有复用分页 canonical 正文加载器')
|
||
assert.match(regenerationSource, /getDataProcessPreview\(taskId, \{ page: 1, page_size: 500 \}\)[\s\S]*?for \(let page = 2; page <= pages;/, '重新生成没有分页加载全部现有切片')
|
||
assert.match(viewSource, /if \(hydrating\.value\) return/, '任务水合期间仍可能触发重置副作用')
|
||
assert.match(regenerationSource, /currentSignature !== originalPreviewConfigSignature\.value[\s\S]*?currentSignature === confirmedPreviewConfigSignature\.value/, '切分变更确认没有按原签名和已确认签名去重')
|
||
assert.match(regenerationSource, /if \(!bindings\.previewItems\.value\.length\) return true/, '没有现有切片时仍会弹出删除切片警告')
|
||
assert.ok(regenerationSource.includes('点击“开始生成”前,原生成结果和已发布数据会继续保留'), '切分变更警告没有说明开始生成前原结果与发布数据仍保留')
|
||
assert.doesNotMatch(regenerationSource, /原切片、原生成结果/, '切分变更警告不应承诺实际重切后仍保留原切片')
|
||
assert.doesNotMatch(regenerationSource, /修改预处理或切分配置将删除现有切片和生成结果/, '准备配置阶段仍误称会立即删除原数据')
|
||
assert.match(regenerationSource, /regenerateDataProcessTask\(sourceTaskId\.value,[\s\S]*?expected_updated_at: originalTaskUpdatedAt\.value/, '第二步没有携带最新乐观并发版本重新生成任务')
|
||
assert.match(regenerationSource, /originalTaskUpdatedAt\.value = regenerated\.task\.updated_at/, '重新生成成功后没有更新下一次提交的并发版本')
|
||
assert.match(regenerationSource, /if \(regenerationPrepared\.value\) \{[\s\S]*?getDataProcessTask\(sourceTaskId\.value\)[\s\S]*?originalTaskUpdatedAt\.value = latestTask\.updated_at/, '服务端中间操作后再次提交没有刷新任务并发版本')
|
||
assert.match(regenerationSource, /regenerationPrepared\.value = true/, '重新生成提交成功后没有记录服务端已变更状态')
|
||
assert.match(regenerationSource, /hydrateWorkspace\(regeneratedTask, !regenerated\.preview_invalidated\)/, '重新生成没有按 preview_invalidated 决定保留或清空切片')
|
||
assert.match(regenerationSource, /重新生成配置已保存,但工作区恢复失败/, '重新生成配置已保存但水合失败时缺少可恢复错误状态')
|
||
assert.match(sourceUploadWorkerSource, /return chunks\.join\(''\)/, '分页恢复源正文时不应额外插入换行')
|
||
assert.doesNotMatch(regenerationSource, /binaryDocument[\s\S]*?mapDataProcessSourceFile\(file, ''\)/, '二进制源正文加载失败时不能静默降级为空内容')
|
||
assert.match(nextFromModelSource, /if \(isRegeneration\.value\) \{[\s\S]*?prepareRegeneration\(taskPayload\(\)\)/, '重新生成每次从模型步骤继续时没有调用专用接口')
|
||
assert.doesNotMatch(nextFromModelSource, /isRegeneration\.value && !taskId\.value/, '重新生成提交一次后可能错误转为普通任务更新')
|
||
assert.doesNotMatch(regenerationSource, /leaveWarning|returnToDetail/, '返回列表已自动保存,不应保留旧版离开警告或返回详情分支')
|
||
assert.match(viewSource, /async function handleCancel\(\)[\s\S]*?returnToPreviousPage\(\)/, '第一步退出没有复用统一返回逻辑')
|
||
assert.match(viewSource, /async function returnToPreviousPage\(\)[\s\S]*?router\.push\(\{ name: 'data-process' \}\)/, '向导退出没有通过命名路由返回任务列表')
|
||
assert.match(regenerationSource, /async function confirmStartGeneration\([\s\S]*?开始重新生成?[\s\S]*?当前生成结果将被替换[\s\S]*?confirmText: '开始生成'[\s\S]*?syncPreviewChanges\(\)/, '开始生成前没有明确破坏性边界或同步预览修改')
|
||
assert.match(viewSource, /beforeGenerate: beforeStartGeneration[\s\S]*?async function beforeStartGeneration\(\)[\s\S]*?confirmStartGeneration\([\s\S]*?syncPreviewChanges/, '生成流程没有在真实开始前调用确认边界')
|
||
assert.match(generationSource, /const canStart = await bindings\.beforeGenerate\?\.\(\)[\s\S]*?if \(canStart === false\) return[\s\S]*?generateDataProcess\(taskId\)/, '用户取消开始生成时仍可能调用真实生成 API')
|
||
assert.match(viewSource, /v-if="initializationError"[\s\S]*?@click="loadRegenerationSource"[\s\S]*?重试加载原任务/, '原任务初始化失败后缺少安全重试入口')
|
||
assert.match(regenerationSource, /previewStatus = preservePreviews && count > 0 \? 'success' : 'waiting'/, '保留切片时没有跳过重切,或切片失效后未回到等待状态')
|
||
|
||
const previewOptionsStart = stateSource.indexOf('export function previewAffectingOptionsFor(')
|
||
const previewOptionsEnd = stateSource.indexOf('export function generationAffectingOptionsFor(', previewOptionsStart)
|
||
assert.ok(previewOptionsStart >= 0 && previewOptionsEnd > previewOptionsStart, '缺少预览影响配置签名函数')
|
||
const previewOptionsSource = stateSource.slice(previewOptionsStart, previewOptionsEnd)
|
||
for (const field of [
|
||
'preprocessOptions',
|
||
'chunkMethod',
|
||
'chunkSize',
|
||
'chunkOverlap',
|
||
'minChunkSize',
|
||
'semanticBreakpointPercentile',
|
||
'preserveTables',
|
||
'preserveCodeBlocks',
|
||
'preserveLists',
|
||
]) {
|
||
assert.ok(previewOptionsSource.includes(field), `预览签名缺少切分影响字段:${field}`)
|
||
}
|
||
for (const field of ['semanticEnrichment', 'qaPairsPerChunk', 'datasetSplit']) {
|
||
assert.ok(!previewOptionsSource.includes(field), `生成字段 ${field} 不应导致预览重建并丢失编辑`)
|
||
}
|
||
|
||
const generationOptionsStart = stateSource.indexOf('export function generationAffectingOptionsFor(')
|
||
const generationOptionsEnd = stateSource.length
|
||
assert.ok(generationOptionsStart >= 0 && generationOptionsEnd > generationOptionsStart, '缺少生成影响配置签名函数')
|
||
const generationOptionsSource = stateSource.slice(generationOptionsStart, generationOptionsEnd)
|
||
for (const field of [
|
||
'semanticEnrichment',
|
||
'qaPairsPerChunk',
|
||
'datasetSplit',
|
||
'generationModelId',
|
||
'generationPrompt',
|
||
'outputType',
|
||
'reasoningDetail',
|
||
'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} 后没有失效旧生成结果`)
|
||
}
|
||
const updatePreviewContentStart = viewSource.indexOf('function updatePreviewContent(')
|
||
const updatePreviewContentEnd = viewSource.indexOf('\n}', updatePreviewContentStart)
|
||
const updatePreviewContentSource = viewSource.slice(updatePreviewContentStart, updatePreviewContentEnd + 2)
|
||
assert.match(updatePreviewContentSource, /isManualPreviewItem\(item\)/, '编辑预览内容仍未按稳定来源信息区分手动项')
|
||
assert.doesNotMatch(updatePreviewContentSource, /sourceStart == null/, '结构化来源记录编辑后仍会被误标为手动项')
|
||
const restorePreviewItemStart = viewSource.indexOf('function restorePreviewItem(')
|
||
const restorePreviewItemEnd = viewSource.indexOf('\n}', restorePreviewItemStart)
|
||
const restorePreviewItemSource = viewSource.slice(restorePreviewItemStart, restorePreviewItemEnd + 2)
|
||
assert.match(restorePreviewItemSource, /isManualPreviewItem\(item\)/, '恢复预览内容没有使用统一的手动项判定')
|
||
assert.doesNotMatch(restorePreviewItemSource, /sourceStart == null/, '结构化来源记录仍因缺少字符偏移而无法恢复')
|
||
assert.match(previewSource, /v-if="!isManualPreviewItem\(editingItem\)"/, '结构化来源记录的恢复原文按钮仍被错误隐藏')
|
||
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 \|\| externalPulling"\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 \|\| externalPulling"\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 \|\| externalPulling">继续上传<\/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\)/, '对照预览高度不足以展示切片正文')
|
||
|
||
assert.doesNotMatch(taskSetupSource, /外来数据源拉取/, '外部数据源仍错误地放在第一步处理类型中')
|
||
assert.match(sourceUploadSource, /<h3>数据来源<\/h3>[\s\S]*?本地上传[\s\S]*?外部数据源/, '第三步缺少本地与外部数据来源选择')
|
||
for (const field of ['地址 / URL', '鉴权方式', 'SSL 模式', '连接超时', '查询超时', '只读查询语句', '拉取条数', '落地文件名']) {
|
||
assert.ok(sourceUploadSource.includes(field), `外部数据源标准配置缺少:${field}`)
|
||
}
|
||
assert.match(generationControlSource, /label="DPO 偏好对" value="dpo"/, '输出类型缺少 DPO 偏好对')
|
||
assert.match(resultEditorSource, /Chosen[\s\S]*?Rejected/, '结果编辑器缺少 DPO 成对字段')
|
||
assert.match(viewSource, /sourceConfigForBackend\(sourceMode\.value, externalSource\)/, '任务配置没有保存第三步数据来源模式')
|
||
|
||
console.log('数据处理六步向导回归检查通过')
|