任务设置步骤新增 external 数据类型,支持 MySQL/PostgreSQL/MongoDB/REST API 配置、测试连接与拉取数据,生成步骤与草稿持久化同步适配,回归脚本放宽分页器数量断言。
413 lines
18 KiB
JavaScript
413 lines
18 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 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: 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(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)]
|
||
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: 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('数据处理四步向导回归检查通过')
|