2026-07-10 16:46:23 +08:00
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 layoutPath = path . resolve ( scriptDir , '../src/layouts/MainLayout.vue' )
const viewSource = await readFile ( viewPath , 'utf8' )
const layoutSource = await readFile ( layoutPath , 'utf8' )
assert . match ( viewSource , /const WIZARD_STEPS = \[/ , '向导步骤尚未改为固定常量' )
for ( const title of [ '创建任务' , '数据预览' , '开始生成' , '结果编辑与保存' ] ) {
assert . ok ( viewSource . includes ( ` title: ' ${ title } ' ` ) , ` 缺少固定步骤: ${ title } ` )
}
assert . doesNotMatch ( viewSource , /steps\s*=\s*computed|all\.filter/ , '步骤仍根据处理类型动态增减' )
assert . match ( viewSource , /localStorage\.setItem\(DRAFT_STORAGE_KEY/ , '草稿没有持久化' )
assert . match ( viewSource , /localStorage\.getItem\(DRAFT_STORAGE_KEY\)/ , '草稿没有恢复读取' )
assert . match ( viewSource , /restoreDraft\(\)/ , '页面没有恢复草稿' )
const expectedComponents = [
'TaskSetupStep.vue' ,
'PreviewCompareStep.vue' ,
'GenerationStep.vue' ,
'ResultEditorStep.vue' ,
]
for ( const component of expectedComponents ) {
assert . ok ( existsSync ( path . join ( createDir , component ) ) , ` 缺少步骤组件: ${ component } ` )
assert . ok ( viewSource . includes ( component . replace ( '.vue' , '' ) ) , ` 父页面未使用: ${ component } ` )
}
const typesPath = path . join ( createDir , 'types.ts' )
const modelPath = path . join ( createDir , 'previewModel.ts' )
assert . ok ( existsSync ( typesPath ) , '缺少向导类型定义' )
assert . ok ( existsSync ( modelPath ) , '缺少来源映射模型' )
const [ typesSource , modelSource , previewSource ] = await Promise . all ( [
readFile ( typesPath , 'utf8' ) ,
readFile ( modelPath , 'utf8' ) ,
readFile ( path . join ( createDir , 'PreviewCompareStep.vue' ) , 'utf8' ) ,
] )
for ( const field of [ 'sourceStart' , 'sourceEnd' , 'originalContent' , 'editedContent' ] ) {
assert . ok ( typesSource . includes ( field ) , ` PreviewItem 缺少字段: ${ field } ` )
}
assert . match ( typesSource , /sourceFileId/ , 'PreviewItem 缺少来源文件标识' )
assert . match ( modelSource , /export function buildPreviewItems/ , '缺少切片来源映射生成函数' )
assert . match ( modelSource , /export function sourceLines/ , '缺少源文件行偏移生成函数' )
assert . match ( modelSource , /sourceFileId/ , '切片生成没有写入来源文件标识' )
assert . match ( viewSource , /selectedPreviewFileId/ , '父页面缺少当前预览文件状态' )
assert . match (
viewSource ,
/buildPreviewItems\(file\.content, processType\.value, String\(file\.uid\)\)/ ,
'预览没有按文件分别生成' ,
)
for ( const marker of [
'preview-workspace' ,
'source-viewer' ,
'source-line' ,
'is-highlighted' ,
'preview-item' ,
'preview-editor' ,
'scrollIntoView' ,
] ) {
assert . ok ( previewSource . includes ( marker ) , ` 第二步缺少结构或行为: ${ marker } ` )
}
assert . match ( previewSource , /sourceStart/ , '第二步未使用来源起始偏移' )
assert . match ( previewSource , /sourceEnd/ , '第二步未使用来源结束偏移' )
assert . match ( previewSource , /filterable/ , '文件选择器必须可搜索' )
assert . match ( previewSource , /当前文件/ , '预览缺少当前文件切换器' )
assert . doesNotMatch ( previewSource , /located-badge|sync-label|已定位到/ , '源文件栏不应显示冗余定位提示' )
assert . match ( previewSource , /const PREVIEW_PAGE_SIZE = 6/ , '切片列表必须限制每页展示数量' )
assert . match ( previewSource , /const pagedItems = computed/ , '切片列表缺少分页数据' )
assert . match ( previewSource , /v-for="item in pagedItems"/ , '切片列表没有使用分页数据' )
assert . match ( previewSource , /<el-pagination[\s\S]*:page-size="PREVIEW_PAGE_SIZE"/ , '切片列表缺少分页控件' )
assert . match ( previewSource , /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/ , '预览工作区高度不足以展示切片正文' )
assert . match ( previewSource , /const editingItemId = ref<string \| null>\(null\)/ , '缺少切片编辑模式状态' )
assert . match ( previewSource , /const editorDraft = ref\(''\)/ , '缺少编辑临时草稿' )
assert . match ( previewSource , /function openEditor\(item: PreviewItem\)/ , '列表缺少打开切片编辑器的动作' )
assert . match ( previewSource , /function closeEditor\(\)/ , '编辑器缺少返回列表的动作' )
assert . match ( previewSource , /function saveEditor\(\)/ , '编辑器缺少保存动作' )
assert . match ( previewSource , /<template v-if="!editingItem">[\s\S]*?<template v-else>/ , '切片列表与编辑器必须互斥展示' )
assert . match ( previewSource , /fa-pencil/ , '切片列表缺少铅笔编辑按钮' )
assert . match ( previewSource , /fa-trash-o/ , '切片列表缺少垃圾桶删除按钮' )
assert . match ( previewSource , /class="preview-item"[\s\S]*?@click="selectItem\(item\.id\)"/ , '点击切片行必须更新当前选中切片' )
assert . match ( previewSource , /v-model="editorDraft"/ , '编辑器必须绑定临时草稿' )
assert . match ( previewSource , />取消<\/el-button>/ , '编辑器缺少取消按钮' )
assert . doesNotMatch ( previewSource , /返回列表/ , '编辑器不应同时显示返回列表和取消两个相同作用的按钮' )
assert . match ( previewSource , />保存修改<\/el-button>/ , '编辑器缺少保存修改按钮' )
assert . match ( previewSource , /\.editor-actions\s*\{[\s\S]*?justify-content:\s*flex-end/ , '取消和保存按钮必须在编辑器右侧对齐' )
assert . doesNotMatch ( previewSource , /item-token|item-status|modifiedOnly|仅看已修改/ , '切片列表不应再显示 Token 或修改状态' )
assert . match ( previewSource , /\.preview-editor\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?overflow-y:\s*auto/ , '编辑模式必须占据右侧剩余区域并可滚动' )
assert . match ( previewSource , /@media \(max-width: 900px\)/ , '第二步缺少窄屏上下布局' )
const taskSetupPath = path . join ( createDir , 'TaskSetupStep.vue' )
const taskSetupSource = await readFile ( taskSetupPath , 'utf8' )
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 : 20 rem ;
}
@ media ( min - width : 1 px ) {
& - 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 ( taskSetupSource . includes ( marker ) , ` 源数据文件列表缺少: ${ marker } ` )
}
assert . match (
taskSetupSource ,
/^const[ \t]+FILE_PAGE_SIZE[ \t]*=[ \t]*10[ \t]*;?[ \t]*$/m ,
'文件分页大小必须固定为整数 10' ,
)
assert . match (
taskSetupSource ,
/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 (
taskSetupSource ,
/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 = [ ... taskSetupSource . matchAll ( /<el-pagination\b[\s\S]*?\/>/g ) ]
2026-07-11 11:00:15 +08:00
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 } ` )
}
2026-07-10 16:46:23 +08:00
}
const { descriptor : taskSetupDescriptor } = parseSfc ( taskSetupSource , { filename : taskSetupPath } )
const uploadedFileItemsStyles = taskSetupDescriptor . 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 (
taskSetupSource ,
/<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 (
taskSetupSource ,
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>/ ,
'文件列表标题结构或文件数量文案缺失' ,
)
assert . match (
taskSetupSource ,
/<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">/ ,
'文件列表缺少分页后的文件行容器' ,
)
assert . match (
taskSetupSource ,
/<el-upload\s+v-if="uploadedFiles\.length === 0"\s+drag\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"\s*>/ ,
'无文件时未保留原有大拖拽上传区或上传配置' ,
)
assert . match (
taskSetupSource ,
/<el-upload\s+v-if="uploadedFiles\.length === 0"\s+drag\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"\s*>[\s\S]*?<template\s+#tip>\s*<div\s+class="el-upload__tip">\s*\{\{\s*processType === 'unstructured'\s*\? '支持 TXT、Markdown、PDF、Word、JSON、JSONL, 单文件不超过 200MB'\s*:\s*'支持 JSON、JSONL、CSV、Excel, 单文件不超过 200MB'\s*\}\}/ ,
'无文件时大拖拽上传区缺少格式提示槽、处理类型分支或完整格式提示' ,
)
assert . match (
taskSetupSource ,
/<div\s+class="uploaded-file-list-header">\s*<span>已添加 \{\{ uploadedFiles\.length \}\} 个文件<\/span>\s*<div\s+class="continue-upload">\s*<el-upload\s+multiple\s+:accept="uploadAccept"\s+:auto-upload="false"\s+:show-file-list="false"\s+:on-change="\(file: UploadFile\) => emit\('file-change', file\)"\s*>\s*<el-button\s+size="small"\s+type="primary">继续上传<\/el-button>\s*<\/el-upload>\s*<\/div>\s*<\/div>/ ,
'有文件时缺少标题右侧的继续上传触发器或上传配置' ,
)
assert . match (
taskSetupSource ,
/\.uploaded-file-list-header\s*\{[^}]*display:\s*flex[^}]*justify-content:\s*space-between/ ,
'文件列表标题未布局为右侧继续上传按钮' ,
)
assert . match (
taskSetupSource ,
/\.continue-upload\s+:deep\(\.el-upload\)\s*\{[^}]*width:\s*auto;?[^}]*margin-top:\s*0;?/ ,
'继续上传未覆盖内层上传节点的宽度和顶部间距' ,
)
assert . match ( taskSetupSource , /\.uploaded-file\s*\{[^}]*min-height:\s*48px/ , '文件行没有保持 48px 最小高度' )
assert . match (
taskSetupSource ,
/<section\s+v-else\s+class="uploaded-file-list"\s+aria-label="已上传文件列表">[\s\S]*?<div\s+class="uploaded-file-items">\s*<div\s+v-for="file in pagedUploadedFiles"[^>]*class="uploaded-file">[\s\S]*?<span\s+class="file-status"><i\s+class="fa fa-check-circle"\s*\/>\s*校验通过<\/span>\s*<el-button\s+link\s+type="danger"\s+@click="emit\('remove-file', file\.uid\)">删除<\/el-button>\s*<\/div>\s*<\/div>/ ,
'文件行没有将成功状态与对应 remove-file 删除按钮关联' ,
)
const { descriptor } = parseSfc ( viewSource , { filename : viewPath } )
const template = descriptor . template ? . content || ''
assert . equal ( ( template . match ( /class="wizard-primary-action"/g ) || [ ] ) . length , 1 , '页面必须只有一个主操作入口' )
assert . match ( viewSource , /onBeforeUnmount\(stopGenerationTimer\)/ , '生成计时器没有在卸载时清理' )
assert . match ( viewSource , /function scrollToStepTop/ , '步骤切换后没有恢复页面顶部上下文' )
assert . match ( viewSource , /nextTick\(scrollToStepTop\)/ , '步骤切换没有触发页面滚动复位' )
assert . match ( viewSource , /\.wizard-content\s*\{[\s\S]*min-height:\s*400px/ , '第一步内容区必须保留足够高度以显示底部操作栏' )
assert . match (
layoutSource ,
/&:has\(\.create-wizard-layout\)\s*\{[\s\S]*?overflow-y:\s*hidden[\s\S]*?\.page-canvas\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]*?min-height:\s*0/ ,
'创建任务页必须约束画布高度,避免底部操作栏被裁掉' ,
)
assert . match ( previewSource , /height:\s*clamp\(560px,\s*calc\(100vh - 370px\),\s*720px\)/ , '对照预览高度不足以展示切片正文' )
console . log ( '数据处理四步向导回归检查通过' )