/** * plugin store 单元测试 * * 覆盖 usePluginStore 的状态管理逻辑: * - 初始状态值 * - fetchPlugins:加载列表、并行 schema、去重 * - refreshMenuItems:菜单生成、分组、各页面类型路由 * - schema 缓存 * - 错误处理(schema 加载失败、API 失败) */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // --------------------------------------------------------------------------- // Mock 外部依赖 // --------------------------------------------------------------------------- const mockListPlugins = vi.fn(); const mockGetPluginSchema = vi.fn(); vi.mock('../api/plugins', () => ({ listPlugins: (...args: unknown[]) => mockListPlugins(...args), getPluginSchema: (...args: unknown[]) => mockGetPluginSchema(...args), })); import { usePluginStore } from './plugin'; import type { PluginInfo, PluginSchemaResponse } from '../api/plugins'; // --------------------------------------------------------------------------- // 测试辅助函数 // --------------------------------------------------------------------------- function createFakePlugin(overrides: Partial = {}): PluginInfo { return { id: 'plugin-001', name: '测试插件', version: '1.0.0', description: '用于单元测试', status: 'running', config: {}, entities: [ { name: 'customer', display_name: '客户', table_name: 'plugin_001_customer' }, ], record_version: 1, ...overrides, }; } function createFakeSchema( overrides: Partial = {}, ): PluginSchemaResponse { return { entities: [ { name: 'customer', display_name: '客户', fields: [ { name: 'id', field_type: 'uuid', required: true }, { name: 'name', field_type: 'text', required: true, display_name: '名称' }, ], }, ], ...overrides, }; } // --------------------------------------------------------------------------- // 重置 fetchPluginsPromise(模块级变量) // 因为 fetchPluginsPromise 是模块作用域变量,无法直接重置, // 但在 fetchPlugins finally 块中会被设为 null。 // 只要每次 fetchPlugins 正常结束,就不会残留。 // --------------------------------------------------------------------------- describe('usePluginStore', () => { beforeEach(() => { vi.clearAllMocks(); usePluginStore.setState({ plugins: [], loading: false, pluginMenuItems: [], pluginMenuGroups: [], schemaCache: {}, }); }); // ========================================================================= // 初始状态 // ========================================================================= describe('初始状态', () => { it('应有正确的默认状态字段', () => { const state = usePluginStore.getState(); expect(state).toHaveProperty('plugins'); expect(state).toHaveProperty('loading'); expect(state).toHaveProperty('pluginMenuItems'); expect(state).toHaveProperty('pluginMenuGroups'); expect(state).toHaveProperty('schemaCache'); expect(state).toHaveProperty('fetchPlugins'); expect(state).toHaveProperty('refreshMenuItems'); }); it('默认值应为空', () => { const state = usePluginStore.getState(); expect(state.plugins).toEqual([]); expect(state.loading).toBe(false); expect(state.pluginMenuItems).toEqual([]); expect(state.pluginMenuGroups).toEqual([]); expect(state.schemaCache).toEqual({}); }); }); // ========================================================================= // fetchPlugins // ========================================================================= describe('fetchPlugins', () => { it('成功时应更新 plugins 和 loading 状态', async () => { const fakePlugin = createFakePlugin(); mockListPlugins.mockResolvedValueOnce({ data: [fakePlugin] }); mockGetPluginSchema.mockResolvedValueOnce(createFakeSchema()); await usePluginStore.getState().fetchPlugins(); const state = usePluginStore.getState(); expect(state.plugins).toEqual([fakePlugin]); expect(state.loading).toBe(false); expect(mockListPlugins).toHaveBeenCalledWith(1, 100, undefined); }); it('加载中 loading 应为 true,完成后恢复为 false', async () => { let resolveList: (value: unknown) => void; const listPromise = new Promise((resolve) => { resolveList = resolve; }); mockListPlugins.mockReturnValue(listPromise); const fetchPromise = usePluginStore.getState().fetchPlugins(); expect(usePluginStore.getState().loading).toBe(true); resolveList!({ data: [] }); await fetchPromise; expect(usePluginStore.getState().loading).toBe(false); }); it('应并行加载所有运行中/启用插件的 schema', async () => { const plugin1 = createFakePlugin({ id: 'p1', status: 'running' }); const plugin2 = createFakePlugin({ id: 'p2', status: 'enabled' }); const plugin3 = createFakePlugin({ id: 'p3', status: 'disabled' }); mockListPlugins.mockResolvedValueOnce({ data: [plugin1, plugin2, plugin3] }); mockGetPluginSchema .mockResolvedValueOnce(createFakeSchema()) .mockResolvedValueOnce(createFakeSchema()); await usePluginStore.getState().fetchPlugins(); // 只有 running 和 enabled 的插件加载了 schema expect(mockGetPluginSchema).toHaveBeenCalledWith('p1'); expect(mockGetPluginSchema).toHaveBeenCalledWith('p2'); expect(mockGetPluginSchema).not.toHaveBeenCalledWith('p3'); }); it('schema 加载应写入 schemaCache', async () => { const fakePlugin = createFakePlugin({ id: 'p1', status: 'running' }); const fakeSchema = createFakeSchema(); mockListPlugins.mockResolvedValueOnce({ data: [fakePlugin] }); mockGetPluginSchema.mockResolvedValueOnce(fakeSchema); await usePluginStore.getState().fetchPlugins(); expect(usePluginStore.getState().schemaCache['p1']).toEqual(fakeSchema); }); it('单个 schema 加载失败不应阻止其他 schema', async () => { const plugin1 = createFakePlugin({ id: 'p1', status: 'running' }); const plugin2 = createFakePlugin({ id: 'p2', status: 'running' }); mockListPlugins.mockResolvedValueOnce({ data: [plugin1, plugin2] }); mockGetPluginSchema .mockRejectedValueOnce(new Error('schema fail')) .mockResolvedValueOnce(createFakeSchema()); await usePluginStore.getState().fetchPlugins(); // p1 失败不应出现在 cache 中 expect(usePluginStore.getState().schemaCache['p1']).toBeUndefined(); // p2 应该成功 expect(usePluginStore.getState().schemaCache['p2']).toBeDefined(); }); it('无运行中插件时不应调用 getPluginSchema', async () => { mockListPlugins.mockResolvedValueOnce({ data: [] }); await usePluginStore.getState().fetchPlugins(); expect(mockGetPluginSchema).not.toHaveBeenCalled(); }); it('API 失败时应恢复 loading 为 false', async () => { mockListPlugins.mockRejectedValueOnce(new Error('network error')); // fetchPlugins 使用 try/finally,不捕获异常,错误会向上传播 await expect(usePluginStore.getState().fetchPlugins()).rejects.toThrow('network error'); expect(usePluginStore.getState().loading).toBe(false); }); it('应传递 page 和 status 参数', async () => { mockListPlugins.mockResolvedValueOnce({ data: [] }); await usePluginStore.getState().fetchPlugins(2, 'enabled' as any); expect(mockListPlugins).toHaveBeenCalledWith(2, 100, 'enabled'); }); }); // ========================================================================= // refreshMenuItems — 无 schema 回退模式 // ========================================================================= describe('refreshMenuItems(无 schema 回退)', () => { it('无运行中插件时菜单应为空', () => { usePluginStore.setState({ plugins: [createFakePlugin({ status: 'disabled' })], }); usePluginStore.getState().refreshMenuItems(); expect(usePluginStore.getState().pluginMenuItems).toEqual([]); }); it('无 schema 时应从 entities 生成回退菜单', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running', entities: [ { name: 'customer', display_name: '客户', table_name: 't1' }, { name: 'order', display_name: '订单', table_name: 't2' }, ], }); usePluginStore.setState({ plugins: [plugin], schemaCache: {}, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items).toHaveLength(2); expect(items[0]).toEqual({ key: '/plugins/p1/customer', icon: 'AppstoreOutlined', label: '客户', pluginId: 'p1', entity: 'customer', pageType: 'crud', }); expect(items[1]).toEqual({ key: '/plugins/p1/order', icon: 'AppstoreOutlined', label: '订单', pluginId: 'p1', entity: 'order', pageType: 'crud', }); }); it('entity 无 display_name 时应使用 name 作为 label', () => { const plugin = createFakePlugin({ id: 'p2', status: 'enabled', entities: [ { name: 'product', display_name: '', table_name: 't3' }, ], }); usePluginStore.setState({ plugins: [plugin], schemaCache: {}, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items[0].label).toBe('product'); }); }); // ========================================================================= // refreshMenuItems — 有 schema 的各页面类型 // ========================================================================= describe('refreshMenuItems(有 schema 页面类型)', () => { it('crud 类型应生成正确的菜单项', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); const schema = createFakeSchema({ ui: { pages: [ { type: 'crud' as const, entity: 'customer', label: '客户管理', icon: 'UserOutlined' }, ], }, }); usePluginStore.setState({ plugins: [plugin], schemaCache: { p1: schema }, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items).toHaveLength(1); expect(items[0]).toEqual({ key: '/plugins/p1/customer', icon: 'UserOutlined', label: '客户管理', pluginId: 'p1', entity: 'customer', pageType: 'crud', }); }); it('tree 类型应生成正确的菜单项', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); const schema = createFakeSchema({ ui: { pages: [ { type: 'tree' as const, entity: 'department', label: '部门树', id_field: 'id', parent_field: 'parent_id', label_field: 'name', }, ], }, }); usePluginStore.setState({ plugins: [plugin], schemaCache: { p1: schema }, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items).toHaveLength(1); expect(items[0]).toEqual({ key: '/plugins/p1/tree/department', icon: 'ApartmentOutlined', label: '部门树', pluginId: 'p1', entity: 'department', pageType: 'tree', }); }); it('tabs 类型应生成正确的菜单项', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); const schema = createFakeSchema({ ui: { pages: [ { type: 'tabs' as const, label: '综合视图', icon: 'LayoutOutlined', tabs: [] }, ], }, }); usePluginStore.setState({ plugins: [plugin], schemaCache: { p1: schema }, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items).toHaveLength(1); expect(items[0].pageType).toBe('tabs'); expect(items[0].key).toContain('/plugins/p1/tabs/'); expect(items[0].key).toContain(encodeURIComponent('综合视图')); }); it('graph 类型应生成正确的菜单项', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); const schema = createFakeSchema({ ui: { pages: [ { type: 'graph' as const, entity: 'relation', label: '关系图', relationship_entity: 'rel', source_field: 'src', target_field: 'tgt', edge_label_field: 'label', node_label_field: 'name', }, ], }, }); usePluginStore.setState({ plugins: [plugin], schemaCache: { p1: schema }, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items).toHaveLength(1); expect(items[0]).toEqual({ key: '/plugins/p1/graph/relation', icon: 'ApartmentOutlined', label: '关系图', pluginId: 'p1', entity: 'relation', pageType: 'graph', }); }); it('dashboard 类型应生成正确的菜单项', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); const schema = createFakeSchema({ ui: { pages: [ { type: 'dashboard' as const, label: '仪表盘', widgets: [] }, ], }, }); usePluginStore.setState({ plugins: [plugin], schemaCache: { p1: schema }, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items).toHaveLength(1); expect(items[0]).toEqual({ key: '/plugins/p1/dashboard', icon: 'DashboardOutlined', label: '仪表盘', pluginId: 'p1', pageType: 'dashboard', }); }); it('kanban 类型应生成正确的菜单项', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); const schema = createFakeSchema({ ui: { pages: [ { type: 'kanban' as const, entity: 'task', label: '看板', lane_field: 'status', card_title_field: 'title', }, ], }, }); usePluginStore.setState({ plugins: [plugin], schemaCache: { p1: schema }, }); usePluginStore.getState().refreshMenuItems(); const items = usePluginStore.getState().pluginMenuItems; expect(items).toHaveLength(1); expect(items[0]).toEqual({ key: '/plugins/p1/kanban/task', icon: 'UnorderedListOutlined', label: '看板', pluginId: 'p1', entity: 'task', pageType: 'kanban', }); }); it('detail 类型不应生成菜单项', () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); const schema = createFakeSchema({ ui: { pages: [ { type: 'detail' as const, entity: 'customer', label: '客户详情', sections: [{ type: 'fields', label: '基本信息', fields: ['name'] }], }, ], }, }); usePluginStore.setState({ plugins: [plugin], schemaCache: { p1: schema }, }); usePluginStore.getState().refreshMenuItems(); expect(usePluginStore.getState().pluginMenuItems).toEqual([]); }); }); // ========================================================================= // pluginMenuGroups 分组 // ========================================================================= describe('pluginMenuGroups 分组', () => { it('应按 pluginId 正确分组', () => { const plugin1 = createFakePlugin({ id: 'p1', name: '插件一', status: 'running', entities: [ { name: 'a', display_name: '实体A', table_name: 't1' }, { name: 'b', display_name: '实体B', table_name: 't2' }, ], }); const plugin2 = createFakePlugin({ id: 'p2', name: '插件二', status: 'enabled', entities: [ { name: 'c', display_name: '实体C', table_name: 't3' }, ], }); usePluginStore.setState({ plugins: [plugin1, plugin2], schemaCache: {}, }); usePluginStore.getState().refreshMenuItems(); const groups = usePluginStore.getState().pluginMenuGroups; expect(groups).toHaveLength(2); const g1 = groups.find((g) => g.pluginId === 'p1'); expect(g1).toBeDefined(); expect(g1!.pluginName).toBe('插件一'); expect(g1!.items).toHaveLength(2); const g2 = groups.find((g) => g.pluginId === 'p2'); expect(g2).toBeDefined(); expect(g2!.pluginName).toBe('插件二'); expect(g2!.items).toHaveLength(1); }); it('无运行中插件时 groups 应为空', () => { usePluginStore.setState({ plugins: [createFakePlugin({ status: 'disabled' })], }); usePluginStore.getState().refreshMenuItems(); expect(usePluginStore.getState().pluginMenuGroups).toEqual([]); }); it('pluginName 应从 plugins 列表查找', () => { const plugin = createFakePlugin({ id: 'px', name: '名称查找插件', status: 'running', entities: [ { name: 'x', display_name: 'X', table_name: 't1' }, ], }); usePluginStore.setState({ plugins: [plugin], schemaCache: {}, }); usePluginStore.getState().refreshMenuItems(); const groups = usePluginStore.getState().pluginMenuGroups; expect(groups[0].pluginName).toBe('名称查找插件'); }); }); // ========================================================================= // 集成:fetchPlugins 后菜单自动生成 // ========================================================================= describe('fetchPlugins 集成', () => { it('fetchPlugins 完成后应自动生成菜单', async () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); mockListPlugins.mockResolvedValueOnce({ data: [plugin] }); mockGetPluginSchema.mockResolvedValueOnce(createFakeSchema()); await usePluginStore.getState().fetchPlugins(); // 应该有菜单项(来自回退或 schema) const items = usePluginStore.getState().pluginMenuItems; expect(items.length).toBeGreaterThan(0); }); it('多次 fetchPlugins 不应导致菜单重复', async () => { const plugin = createFakePlugin({ id: 'p1', status: 'running' }); mockListPlugins.mockResolvedValue({ data: [plugin] }); mockGetPluginSchema.mockResolvedValue(createFakeSchema()); await usePluginStore.getState().fetchPlugins(); await usePluginStore.getState().fetchPlugins(); // 每次都是完全替换,不会累积 const items = usePluginStore.getState().pluginMenuItems; const keys = items.map((i) => i.key); const uniqueKeys = [...new Set(keys)]; expect(keys.length).toBe(uniqueKeys.length); }); }); });