diff --git a/apps/web/src/stores/plugin.test.ts b/apps/web/src/stores/plugin.test.ts new file mode 100644 index 0000000..a38eec9 --- /dev/null +++ b/apps/web/src/stores/plugin.test.ts @@ -0,0 +1,603 @@ +/** + * 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' }, + ], + }, + }); + + 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); + }); + }); +}); diff --git a/apps/web/src/stores/workbenchStore.test.ts b/apps/web/src/stores/workbenchStore.test.ts new file mode 100644 index 0000000..3f3e4ad --- /dev/null +++ b/apps/web/src/stores/workbenchStore.test.ts @@ -0,0 +1,406 @@ +/** + * workbenchStore 单元测试 + * + * 覆盖 useWorkbenchStore 的状态管理逻辑: + * - 初始状态值 + * - selectTask / setTab:任务选择与标签切换 + * - refreshTasks:加载任务列表(pending / completed) + * - refreshStats:加载统计信息 + * - completeTask:完成任务并自动选中下一个 + * - 错误处理(API 失败) + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock 外部依赖 +// --------------------------------------------------------------------------- + +const mockList = vi.fn(); +const mockStats = vi.fn(); + +vi.mock('../api/health/actionInbox', () => ({ + actionInboxApi: { + list: (...args: unknown[]) => mockList(...args), + stats: (...args: unknown[]) => mockStats(...args), + }, +})); + +import { useWorkbenchStore } from './workbenchStore'; +import type { ActionItem, WorkbenchStats } from '../api/health/actionInbox'; + +// --------------------------------------------------------------------------- +// 测试辅助函数 +// --------------------------------------------------------------------------- + +function createFakeAction(overrides: Partial = {}): ActionItem { + return { + id: 'action-001', + action_type: 'alert', + priority: 'high', + status: 'pending', + title: '高血压告警', + summary: '患者血压异常升高', + patient_id: 'patient-001', + patient_name: '张三', + source_ref: 'ref-001', + created_at: '2026-05-01T08:00:00Z', + updated_at: '2026-05-01T08:00:00Z', + ...overrides, + }; +} + +function createFakeStats(overrides: Partial = {}): WorkbenchStats { + return { + total_pending: 5, + ai_suggestion_pending: 2, + urgent_alerts: 1, + followup_due: 3, + completion_rate: 0.75, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// 测试套件 +// --------------------------------------------------------------------------- + +describe('useWorkbenchStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useWorkbenchStore.setState({ + tasks: [], + selectedTaskId: null, + tab: 'pending', + loading: false, + stats: null, + }); + }); + + // ========================================================================= + // 初始状态 + // ========================================================================= + describe('初始状态', () => { + it('应有正确的默认状态字段', () => { + const state = useWorkbenchStore.getState(); + expect(state).toHaveProperty('tasks'); + expect(state).toHaveProperty('selectedTaskId'); + expect(state).toHaveProperty('tab'); + expect(state).toHaveProperty('loading'); + expect(state).toHaveProperty('stats'); + expect(state).toHaveProperty('selectTask'); + expect(state).toHaveProperty('setTab'); + expect(state).toHaveProperty('refreshTasks'); + expect(state).toHaveProperty('refreshStats'); + expect(state).toHaveProperty('completeTask'); + }); + + it('默认值应为空和未选中', () => { + const state = useWorkbenchStore.getState(); + expect(state.tasks).toEqual([]); + expect(state.selectedTaskId).toBeNull(); + expect(state.tab).toBe('pending'); + expect(state.loading).toBe(false); + expect(state.stats).toBeNull(); + }); + }); + + // ========================================================================= + // selectTask + // ========================================================================= + describe('selectTask', () => { + it('应更新 selectedTaskId', () => { + useWorkbenchStore.getState().selectTask('action-001'); + expect(useWorkbenchStore.getState().selectedTaskId).toBe('action-001'); + }); + + it('应允许设为 null 以取消选择', () => { + useWorkbenchStore.setState({ selectedTaskId: 'action-001' }); + useWorkbenchStore.getState().selectTask(null); + expect(useWorkbenchStore.getState().selectedTaskId).toBeNull(); + }); + + it('切换选中不应影响其他状态', () => { + const fakeTask = createFakeAction(); + useWorkbenchStore.setState({ tasks: [fakeTask], tab: 'completed' }); + useWorkbenchStore.getState().selectTask('action-001'); + expect(useWorkbenchStore.getState().tasks).toEqual([fakeTask]); + expect(useWorkbenchStore.getState().tab).toBe('completed'); + }); + }); + + // ========================================================================= + // setTab + // ========================================================================= + describe('setTab', () => { + it('应更新 tab 并清空 selectedTaskId', async () => { + useWorkbenchStore.setState({ selectedTaskId: 'action-001', tab: 'pending' }); + mockList.mockResolvedValueOnce({ data: [] }); + + useWorkbenchStore.getState().setTab('completed'); + + expect(useWorkbenchStore.getState().tab).toBe('completed'); + expect(useWorkbenchStore.getState().selectedTaskId).toBeNull(); + }); + + it('切换 tab 时应自动触发 refreshTasks', async () => { + const fakeTasks = [createFakeAction({ status: 'completed' })]; + mockList.mockResolvedValueOnce({ data: fakeTasks }); + + useWorkbenchStore.getState().setTab('completed'); + + // 等待异步操作完成 + await vi.waitFor(() => { + expect(useWorkbenchStore.getState().loading).toBe(false); + }); + + expect(mockList).toHaveBeenCalledWith({ + status: 'completed', + page: 1, + page_size: 50, + }); + expect(useWorkbenchStore.getState().tasks).toEqual(fakeTasks); + }); + + it('切换到 pending tab 应传递 pending status', async () => { + mockList.mockResolvedValueOnce({ data: [] }); + + useWorkbenchStore.getState().setTab('pending'); + + await vi.waitFor(() => { + expect(useWorkbenchStore.getState().loading).toBe(false); + }); + + expect(mockList).toHaveBeenCalledWith({ + status: 'pending', + page: 1, + page_size: 50, + }); + }); + }); + + // ========================================================================= + // refreshTasks + // ========================================================================= + describe('refreshTasks', () => { + it('成功时应更新 tasks', async () => { + const fakeTasks = [ + createFakeAction({ id: 'a1' }), + createFakeAction({ id: 'a2', title: 'AI 建议' }), + ]; + mockList.mockResolvedValueOnce({ data: fakeTasks }); + + await useWorkbenchStore.getState().refreshTasks(); + + expect(useWorkbenchStore.getState().tasks).toEqual(fakeTasks); + }); + + it('加载中 loading 应为 true,完成后恢复为 false', async () => { + let resolveList: (value: unknown) => void; + const listPromise = new Promise((resolve) => { + resolveList = resolve; + }); + mockList.mockReturnValue(listPromise); + + const refreshPromise = useWorkbenchStore.getState().refreshTasks(); + + expect(useWorkbenchStore.getState().loading).toBe(true); + + resolveList!({ data: [] }); + await refreshPromise; + + expect(useWorkbenchStore.getState().loading).toBe(false); + }); + + it('应传递当前 tab 的 status 参数', async () => { + mockList.mockResolvedValueOnce({ data: [] }); + + useWorkbenchStore.setState({ tab: 'completed' }); + await useWorkbenchStore.getState().refreshTasks(); + + expect(mockList).toHaveBeenCalledWith({ + status: 'completed', + page: 1, + page_size: 50, + }); + }); + + it('API 返回非数组数据时 tasks 应为空数组', async () => { + mockList.mockResolvedValueOnce({ data: null }); + + await useWorkbenchStore.getState().refreshTasks(); + + expect(useWorkbenchStore.getState().tasks).toEqual([]); + }); + + it('API 返回 undefined data 时 tasks 应为空数组', async () => { + mockList.mockResolvedValueOnce(undefined); + + await useWorkbenchStore.getState().refreshTasks(); + + expect(useWorkbenchStore.getState().tasks).toEqual([]); + }); + + it('API 失败时应恢复 loading 为 false', async () => { + mockList.mockRejectedValueOnce(new Error('network error')); + + await useWorkbenchStore.getState().refreshTasks(); + + expect(useWorkbenchStore.getState().loading).toBe(false); + }); + + it('API 失败时不应清空已有 tasks', async () => { + const existingTasks = [createFakeAction()]; + useWorkbenchStore.setState({ tasks: existingTasks }); + mockList.mockRejectedValueOnce(new Error('network error')); + + await useWorkbenchStore.getState().refreshTasks(); + + // 失败时 set 只重置了 loading,未更新 tasks + // 但注意:代码中 catch 块只 set({ loading: false }),不修改 tasks + // 不过 loading: true 已经在前面 set 了,这里 tasks 在成功路径才会被替换 + // 由于代码是 try/catch/finally 模式,catch 中 tasks 不会被覆盖 + expect(useWorkbenchStore.getState().loading).toBe(false); + }); + }); + + // ========================================================================= + // refreshStats + // ========================================================================= + describe('refreshStats', () => { + it('成功时应更新 stats', async () => { + const fakeStats = createFakeStats(); + mockStats.mockResolvedValueOnce(fakeStats); + + await useWorkbenchStore.getState().refreshStats(); + + expect(useWorkbenchStore.getState().stats).toEqual(fakeStats); + }); + + it('API 返回 null 时 stats 应为 null', async () => { + mockStats.mockResolvedValueOnce(null); + + await useWorkbenchStore.getState().refreshStats(); + + expect(useWorkbenchStore.getState().stats).toBeNull(); + }); + + it('API 失败时 stats 应保持不变', async () => { + const existingStats = createFakeStats(); + useWorkbenchStore.setState({ stats: existingStats }); + mockStats.mockRejectedValueOnce(new Error('server error')); + + await useWorkbenchStore.getState().refreshStats(); + + expect(useWorkbenchStore.getState().stats).toEqual(existingStats); + }); + + it('API 失败时不应抛出异常', async () => { + mockStats.mockRejectedValueOnce(new Error('timeout')); + + await expect( + useWorkbenchStore.getState().refreshStats(), + ).resolves.toBeUndefined(); + }); + }); + + // ========================================================================= + // completeTask + // ========================================================================= + describe('completeTask', () => { + it('应从 tasks 中移除指定 id', () => { + const task1 = createFakeAction({ id: 'a1' }); + const task2 = createFakeAction({ id: 'a2' }); + useWorkbenchStore.setState({ tasks: [task1, task2] }); + + useWorkbenchStore.getState().completeTask('a1'); + + expect(useWorkbenchStore.getState().tasks).toEqual([task2]); + }); + + it('应自动选中下一个任务', () => { + const task1 = createFakeAction({ id: 'a1' }); + const task2 = createFakeAction({ id: 'a2' }); + const task3 = createFakeAction({ id: 'a3' }); + useWorkbenchStore.setState({ tasks: [task1, task2, task3], selectedTaskId: 'a2' }); + + useWorkbenchStore.getState().completeTask('a2'); + + // 移除 a2 后剩余 [a1, a3],下一个选中 a1 + expect(useWorkbenchStore.getState().selectedTaskId).toBe('a1'); + }); + + it('无剩余任务时 selectedTaskId 应为 null', () => { + const task1 = createFakeAction({ id: 'a1' }); + useWorkbenchStore.setState({ tasks: [task1], selectedTaskId: 'a1' }); + + useWorkbenchStore.getState().completeTask('a1'); + + expect(useWorkbenchStore.getState().tasks).toEqual([]); + expect(useWorkbenchStore.getState().selectedTaskId).toBeNull(); + }); + + it('完成后应自动刷新 stats', async () => { + const fakeStats = createFakeStats({ total_pending: 4 }); + mockStats.mockResolvedValueOnce(fakeStats); + + const task1 = createFakeAction({ id: 'a1' }); + useWorkbenchStore.setState({ tasks: [task1] }); + useWorkbenchStore.getState().completeTask('a1'); + + // 等待异步 refreshStats 完成 + await vi.waitFor(() => { + expect(mockStats).toHaveBeenCalledTimes(1); + }); + + expect(useWorkbenchStore.getState().stats).toEqual(fakeStats); + }); + + it('移除不存在的 id 不应影响 tasks', () => { + const task1 = createFakeAction({ id: 'a1' }); + useWorkbenchStore.setState({ tasks: [task1] }); + + useWorkbenchStore.getState().completeTask('nonexistent'); + + expect(useWorkbenchStore.getState().tasks).toEqual([task1]); + }); + }); + + // ========================================================================= + // 边界情况 + // ========================================================================= + describe('边界情况', () => { + it('快速连续 selectTask 应反映最后设置的值', () => { + useWorkbenchStore.getState().selectTask('a1'); + useWorkbenchStore.getState().selectTask('a2'); + useWorkbenchStore.getState().selectTask('a3'); + + expect(useWorkbenchStore.getState().selectedTaskId).toBe('a3'); + }); + + it('stats 含 null completion_rate 时应正确存储', async () => { + const statsWithNull = createFakeStats({ completion_rate: null }); + mockStats.mockResolvedValueOnce(statsWithNull); + + await useWorkbenchStore.getState().refreshStats(); + + expect(useWorkbenchStore.getState().stats!.completion_rate).toBeNull(); + }); + + it('completeTask 后再 refreshTasks 应正常加载新列表', async () => { + const task1 = createFakeAction({ id: 'a1' }); + const task2 = createFakeAction({ id: 'a2' }); + useWorkbenchStore.setState({ tasks: [task1, task2] }); + + useWorkbenchStore.getState().completeTask('a1'); + expect(useWorkbenchStore.getState().tasks).toEqual([task2]); + + const newTasks = [createFakeAction({ id: 'a3' }), createFakeAction({ id: 'a4' })]; + mockList.mockResolvedValueOnce({ data: newTasks }); + mockStats.mockResolvedValueOnce(createFakeStats()); + + await useWorkbenchStore.getState().refreshTasks(); + + expect(useWorkbenchStore.getState().tasks).toEqual(newTasks); + }); + }); +});