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 ['', '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, //, '路由离开守卫没有根据目标路由异步保存或确认') 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\(`\[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, /\(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, /