test: 集成 Vitest 测试框架和开发规范

- 安装 vitest, @vue/test-utils, jsdom 等测试依赖
- 配置 vitest.config.js 测试环境
- 添加 schema.test.ts (7个数据结构验证测试)
- 添加 useStore.test.ts (7个状态管理测试)
- 创建测试指南文档 (docs/testing.md)
- 创建测试规范文档 (docs/testing-rules.md)
- 创建开发规范文档 (docs/development-rules.md)
- 创建开发工作流程文档 (docs/1management/workflow.md)
- 添加测试相关 npm scripts (test, test:watch, test:ui, test:coverage)
- 所有测试通过 (14/14)
This commit is contained in:
2026-02-12 23:25:13 +08:00
parent c4d701b443
commit 92aa4094f5
13 changed files with 4245 additions and 17 deletions

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest'
import {
CURRENT_SCHEMA_VERSION,
DefaultNodeStyle,
type GraphNode,
type GraphEdge,
type NodeProperties,
type RootDocument
} from '../ts/schema'
describe('Schema 数据结构验证', () => {
it('当前 schema 版本应该是 1.0.0', () => {
expect(CURRENT_SCHEMA_VERSION).toBe('1.0.0')
})
it('DefaultNodeStyle 应该包含正确的默认值', () => {
expect(DefaultNodeStyle).toMatchObject({
width: 180,
height: 120,
rotate: 0,
fill: '#ffffff',
stroke: '#dcdfe6'
})
})
it('创建 GraphNode 应该符合类型定义', () => {
const node: GraphNode = {
id: 'node-1',
type: 'rect',
x: 100,
y: 200,
properties: {
style: {
width: 200,
height: 150,
fill: '#ff0000'
}
}
}
expect(node.id).toBe('node-1')
expect(node.type).toBe('rect')
expect(node.properties.style.width).toBe(200)
})
it('创建 GraphEdge 应该包含必需字段', () => {
const edge: GraphEdge = {
id: 'edge-1',
sourceNodeId: 'node-1',
targetNodeId: 'node-2'
}
expect(edge.id).toBe('edge-1')
expect(edge.sourceNodeId).toBe('node-1')
expect(edge.targetNodeId).toBe('node-2')
})
it('NodeProperties 应该支持式神数据', () => {
const properties: NodeProperties = {
style: DefaultNodeStyle,
shikigami: {
name: '茨木童子',
avatar: '/assets/Shikigami/ibaraki.png',
rarity: 'SSR'
}
}
expect(properties.shikigami?.name).toBe('茨木童子')
expect(properties.shikigami?.rarity).toBe('SSR')
})
it('NodeProperties 应该支持御魂数据', () => {
const properties: NodeProperties = {
style: DefaultNodeStyle,
yuhun: {
name: '破势',
type: '攻击',
avatar: '/assets/Yuhun/poshi.png'
}
}
expect(properties.yuhun?.name).toBe('破势')
expect(properties.yuhun?.type).toBe('攻击')
})
it('RootDocument 应该包含文件列表和活动文件', () => {
const doc: RootDocument = {
schemaVersion: '1.0.0',
fileList: [],
activeFile: 'File 1',
activeFileId: 'f_123'
}
expect(doc.schemaVersion).toBe('1.0.0')
expect(doc.activeFile).toBe('File 1')
expect(doc.activeFileId).toBe('f_123')
})
})

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFilesStore } from '../ts/useStore'
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} }
}
})()
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
// Mock ElMessageBox 和 useGlobalMessage
vi.mock('element-plus', () => ({
ElMessageBox: {
confirm: vi.fn()
}
}))
vi.mock('../ts/useGlobalMessage', () => ({
useGlobalMessage: () => ({
showMessage: vi.fn()
})
}))
vi.mock('../ts/useLogicFlow', () => ({
getLogicFlowInstance: vi.fn(() => ({
getGraphRawData: vi.fn(() => ({ nodes: [], edges: [] })),
getTransform: vi.fn(() => ({
SCALE_X: 1,
SCALE_Y: 1,
TRANSLATE_X: 0,
TRANSLATE_Y: 0
}))
}))
}))
describe('useFilesStore 数据操作测试', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorageMock.clear()
})
it('应该初始化默认文件列表', () => {
const store = useFilesStore()
store.initializeWithPrompt()
expect(store.fileList.length).toBeGreaterThan(0)
expect(store.fileList[0].name).toBe('File 1')
expect(store.fileList[0].type).toBe('FLOW')
})
it('添加新文件应该增加文件列表长度', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
const initialLength = store.fileList.length
store.addTab()
// 等待 requestAnimationFrame 完成
await new Promise(resolve => setTimeout(resolve, 50))
expect(store.fileList.length).toBe(initialLength + 1)
expect(store.fileList[store.fileList.length - 1].name).toContain('File')
})
it('删除文件应该减少文件列表长度', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 50))
const initialLength = store.fileList.length
const fileToDelete = store.fileList[0]
store.removeTab(fileToDelete.id)
expect(store.fileList.length).toBe(initialLength - 1)
})
it('切换活动文件应该更新 activeFileId', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 50))
const secondFile = store.fileList[1]
store.activeFileId = secondFile.id
expect(store.activeFileId).toBe(secondFile.id)
})
it('visibleFiles 应该只返回可见文件', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 50))
// 隐藏第一个文件
store.fileList[0].visible = false
expect(store.visibleFiles.length).toBe(store.fileList.length - 1)
expect(store.visibleFiles.every(f => f.visible)).toBe(true)
})
it('导入数据应该正确恢复文件列表', () => {
const store = useFilesStore()
const mockData = {
schemaVersion: '1.0.0',
fileList: [
{
id: 'test-1',
name: 'Test File',
label: 'Test File',
visible: true,
type: 'FLOW',
graphRawData: { nodes: [], edges: [] }
}
],
activeFileId: 'test-1',
activeFile: 'Test File'
}
store.importData(mockData)
expect(store.fileList.length).toBe(1)
expect(store.fileList[0].name).toBe('Test File')
expect(store.activeFileId).toBe('test-1')
})
it('重置工作区应该恢复到默认状态', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 100))
store.resetWorkspace()
expect(store.fileList.length).toBe(1)
expect(store.fileList[0].name).toBe('File 1')
})
})

View File

@@ -3,6 +3,7 @@
<div>
<el-button icon="Upload" type="primary" @click="handleImport">{{ t('import') }}</el-button>
<el-button icon="Download" type="primary" @click="handleExport">{{ t('export') }}</el-button>
<el-button icon="View" type="success" @click="handlePreviewData">数据预览</el-button>
<el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button>
<el-button icon="Setting" type="primary" @click="state.showWatermarkDialog = true">{{ t('setWatermark') }}</el-button>
<el-button type="info" @click="loadExample">{{ t('loadExample') }}</el-button>
@@ -97,6 +98,19 @@
</template>
</el-dialog>
<!-- 数据预览对话框 -->
<el-dialog v-model="state.showDataPreviewDialog" title="数据预览" width="70%">
<div style="max-height: 600px; overflow-y: auto;">
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; font-size: 12px; line-height: 1.5;">{{ state.previewDataContent }}</pre>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="state.showDataPreviewDialog = false">关闭</el-button>
<el-button type="primary" @click="copyDataToClipboard">复制到剪贴板</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
@@ -124,6 +138,8 @@ const state = reactive({
showWatermarkDialog: false, // 控制水印设置弹窗的显示状态,
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
previewDataContent: '', // 存储预览的数据内容
});
// 重新渲染 LogicFlow 画布的通用方法
@@ -216,6 +232,39 @@ const handleExport = () => {
}, 2000);
};
const handlePreviewData = () => {
// 预览前先更新当前数据
filesStore.updateTab();
// 延迟一点确保更新完成后再预览
setTimeout(() => {
try {
const activeName = filesStore.fileList.find(f => f.id === filesStore.activeFileId)?.name || '';
const dataObj = {
schemaVersion: 1,
fileList: filesStore.fileList,
activeFileId: filesStore.activeFileId,
activeFile: activeName,
};
state.previewDataContent = JSON.stringify(dataObj, null, 2);
state.showDataPreviewDialog = true;
} catch (error) {
console.error('生成预览数据失败:', error);
showMessage('error', '数据预览失败');
}
}, 100);
};
const copyDataToClipboard = async () => {
try {
await navigator.clipboard.writeText(state.previewDataContent);
showMessage('success', '已复制到剪贴板');
} catch (error) {
console.error('复制失败:', error);
showMessage('error', '复制失败');
}
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';

View File

@@ -197,6 +197,24 @@ function normalizeAllNodes() {
const lfInstance = lf.value;
if (!lfInstance) return;
lfInstance.graphModel?.nodes.forEach((model: BaseNodeModel) => normalizeNodeModel(model));
// 检查是否所有节点的 zIndex 都相同且为默认值(通常是从历史恢复的情况)
const allNodes = lfInstance.graphModel?.nodes || [];
if (allNodes.length > 1) {
const firstZIndex = allNodes[0]?.zIndex;
const allSameZIndex = allNodes.every(n => n.zIndex === firstZIndex);
// 只有当所有节点的 zIndex 都是默认值 1 时才重新分配
if (allSameZIndex && firstZIndex === 1) {
console.log('[初始化] 检测到所有节点 zIndex 都为默认值 1开始重新分配 zIndex');
// 为所有节点分配递增的 zIndex避免层级操作异常
allNodes.forEach((node, index) => {
const newZIndex = index + 1;
node.setZIndex(newZIndex);
});
console.log('[初始化] zIndex 重新分配完成:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
}
}
function updateNodeMeta(model: BaseNodeModel, updater: (meta: Record<string, any>) => Record<string, any>) {
@@ -262,7 +280,16 @@ function bringToFront(nodeId?: string) {
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
// 诊断日志:查看所有节点的 zIndex
const allNodes = lfInstance.graphModel.nodes;
console.log('[置于顶层] 目标节点ID:', targetId);
console.log('[置于顶层] 所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
lfInstance.setElementZIndex(targetId, 'top');
// 操作后再次查看
console.log('[置于顶层] 操作后所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
function sendToBack(nodeId?: string) {
@@ -270,7 +297,16 @@ function sendToBack(nodeId?: string) {
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
// 诊断日志:查看所有节点的 zIndex
const allNodes = lfInstance.graphModel.nodes;
console.log('[置于底层] 目标节点ID:', targetId);
console.log('[置于底层] 所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
lfInstance.setElementZIndex(targetId, 'bottom');
// 操作后再次查看
console.log('[置于底层] 操作后所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
function bringForward(nodeId?: string) {
@@ -663,7 +699,7 @@ onMounted(() => {
grid: { type: 'dot', size: 10 },
allowResize: true,
allowRotate: true,
overlapMode: -1,
overlapMode: 0,
snapline: snaplineEnabled.value,
keyboard: {
enabled: true
@@ -902,8 +938,20 @@ onMounted(() => {
lfInstance.on(EventType.NODE_DRAG, (args) => handleNodeDrag(args as any));
lfInstance.on(EventType.NODE_ADD, ({ data }) => {
console.log('[NODE_ADD 事件触发] 节点ID:', data.id);
const model = lfInstance.getNodeModelById(data.id);
if (model) normalizeNodeModel(model);
if (model) {
console.log('[NODE_ADD] 获取到节点模型,当前 zIndex:', model.zIndex);
normalizeNodeModel(model);
// 设置新节点的 zIndex 为 1000
const newZIndex = 1000;
console.log(`[NODE_ADD] 准备设置 zIndex: ${newZIndex}`);
model.setZIndex(newZIndex);
console.log(`[NODE_ADD] 设置后的 zIndex:`, model.zIndex);
} else {
console.log('[NODE_ADD] 未能获取到节点模型');
}
});
lfInstance.on(EventType.GRAPH_RENDERED, () => normalizeAllNodes());

View File

@@ -321,12 +321,24 @@ export const useFilesStore = defineStore('files', () => {
const transform = logicFlowInstance.getTransform();
if (graphData) {
// 手动添加 zIndex 信息到每个节点
const enrichedGraphData = {
...graphData,
nodes: (graphData.nodes || []).map((node: any) => {
const model = logicFlowInstance.getNodeModelById(node.id);
return {
...node,
zIndex: model?.zIndex ?? node.zIndex ?? 1
};
})
};
// 直接保存原始数据到 GraphRawData
const file = findById(targetId);
if (file) {
file.graphRawData = graphData;
file.graphRawData = enrichedGraphData;
file.transform = transform;
console.log(`已同步画布数据到文件 "${file.name}"(${targetId})`);
console.log(`已同步画布数据到文件 "${file.name}"(${targetId}),包含 zIndex 信息`);
}
}
} catch (error) {