diff --git a/docs/SYSTEM_ANALYSIS.md b/docs/SYSTEM_ANALYSIS.md index c2b0d9c..52ddf59 100644 --- a/docs/SYSTEM_ANALYSIS.md +++ b/docs/SYSTEM_ANALYSIS.md @@ -449,6 +449,11 @@ ZCLAW 是基于 **OpenFang** (Rust Agent OS) 的 AI Agent 桌面客户端,核 * API 客户端: * ✅ Team API 客户端 (`lib/team-client.ts`) * ✅ WebSocket 事件订阅 (`lib/useTeamEvents.ts`) + * 代码质量: + * ✅ TypeScript 类型检查通过 (2026-03-15) + * ✅ 移除未使用的导入和变量 + * ✅ 修复类型不兼容问题 * 待完成: * 与 OpenFang 后端 API 对接测试 -*下一步: 后端 API 对接测试与集成验证* + * 单元测试覆盖 +*下一步: 后端 API 对接测试与单元测试* diff --git a/tests/desktop/teamStore.test.ts b/tests/desktop/teamStore.test.ts new file mode 100644 index 0000000..7559163 --- /dev/null +++ b/tests/desktop/teamStore.test.ts @@ -0,0 +1,517 @@ +/** + * Team Store Tests + * + * Unit tests for multi-agent team collaboration state management. + * + * @module tests/desktop/teamStore.test + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + }; +})(); + +Object.defineProperty(global, 'localStorage', { + value: localStorageMock, +}); + +// Import store after mocking +import { useTeamStore } from '../../desktop/src/store/teamStore'; +import type { Team, TeamMember, TeamTask, DevQALoop } from '../../desktop/src/types/team'; + +describe('TeamStore', () => { + beforeEach(() => { + // Reset store state and localStorage + localStorageMock.clear(); + useTeamStore.setState({ + teams: [], + activeTeam: null, + metrics: null, + isLoading: false, + error: null, + selectedTaskId: null, + selectedMemberId: null, + recentEvents: [], + }); + }); + + describe('loadTeams', () => { + it('should load teams from localStorage', async () => { + const mockTeams: Team[] = [ + { + id: 'team-1', + name: 'Test Team', + description: 'A test team', + status: 'active', + pattern: 'sequential', + members: [], + tasks: [], + loops: [], + createdAt: '2026-03-15T00:00:00Z', + }, + ]; + + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(mockTeams)); + + const { loadTeams } = useTeamStore.getState(); + await loadTeams(); + + const state = useTeamStore.getState(); + expect(state.teams).toHaveLength(1); + expect(state.teams[0].name).toBe('Test Team'); + }); + + it('should handle empty localStorage', async () => { + localStorageMock.getItem.mockReturnValueOnce(null); + + const { loadTeams } = useTeamStore.getState(); + await loadTeams(); + + const state = useTeamStore.getState(); + expect(state.teams).toHaveLength(0); + }); + }); + + describe('createTeam', () => { + it('should create a new team with members', async () => { + const request = { + name: 'New Team', + description: 'A new team for testing', + pattern: 'parallel' as const, + memberAgents: [ + { agentId: 'agent-1', role: 'orchestrator' as const }, + { agentId: 'agent-2', role: 'developer' as const }, + ], + }; + + const { createTeam } = useTeamStore.getState(); + const team = await createTeam(request); + + expect(team).not.toBeNull(); + expect(team?.name).toBe('New Team'); + expect(team?.pattern).toBe('parallel'); + expect(team?.members).toHaveLength(2); + expect(team?.members[0].role).toBe('orchestrator'); + + const state = useTeamStore.getState(); + expect(state.teams).toHaveLength(1); + }); + }); + + describe('setActiveTeam', () => { + it('should set active team and calculate metrics', () => { + const mockTeam: Team = { + id: 'team-1', + name: 'Test Team', + description: 'A test team', + status: 'active', + pattern: 'sequential', + members: [ + { + id: 'member-1', + agentId: 'agent-1', + name: 'Developer 1', + role: 'developer', + status: 'idle', + skills: [], + workload: 0, + currentTasks: [], + maxConcurrentTasks: 2, + }, + ], + tasks: [ + { + id: 'task-1', + title: 'Completed Task', + description: 'A completed task', + status: 'completed', + priority: 'medium', + type: 'feature', + assigneeId: 'member-1', + createdAt: '2026-03-15T00:00:00Z', + updatedAt: '2026-03-15T01:00:00Z', + }, + ], + activeLoops: [], + createdAt: '2026-03-15T00:00:00Z', + }; + + const { setActiveTeam } = useTeamStore.getState(); + setActiveTeam(mockTeam); + + const state = useTeamStore.getState(); + expect(state.activeTeam).toEqual(mockTeam); + expect(state.metrics).not.toBeNull(); + expect(state.metrics?.tasksCompleted).toBe(1); + }); + + it('should clear active team when passed null', () => { + const { setActiveTeam } = useTeamStore.getState(); + + // First set a team + setActiveTeam({ + id: 'team-1', + name: 'Test', + description: '', + status: 'active', + pattern: 'sequential', + members: [], + tasks: [], + activeLoops: [], + createdAt: '2026-03-15T00:00:00Z', + }); + + // Then clear it + setActiveTeam(null); + + const state = useTeamStore.getState(); + expect(state.activeTeam).toBeNull(); + expect(state.metrics).toBeNull(); + }); + }); + + describe('addMember', () => { + it('should add a member to a team', async () => { + // Setup: create a team first + const { createTeam, addMember } = useTeamStore.getState(); + + await createTeam({ + name: 'Test Team', + description: '', + pattern: 'sequential', + memberAgents: [{ agentId: 'agent-1', role: 'orchestrator' }], + }); + + const teamId = useTeamStore.getState().teams[0].id; + const member = await addMember(teamId, 'agent-2', 'developer'); + + expect(member).not.toBeNull(); + expect(member?.role).toBe('developer'); + expect(member?.agentId).toBe('agent-2'); + + const state = useTeamStore.getState(); + expect(state.teams[0].members).toHaveLength(2); + }); + }); + + describe('addTask', () => { + it('should add a task to a team', async () => { + const { createTeam, addTask } = useTeamStore.getState(); + + await createTeam({ + name: 'Test Team', + description: '', + pattern: 'sequential', + memberAgents: [{ agentId: 'agent-1', role: 'orchestrator' }], + }); + + const teamId = useTeamStore.getState().teams[0].id; + + const task = await addTask({ + teamId, + title: 'New Task', + description: 'A new task for testing', + priority: 'high', + type: 'feature', + }); + + expect(task).not.toBeNull(); + expect(task?.title).toBe('New Task'); + expect(task?.status).toBe('pending'); + + const state = useTeamStore.getState(); + expect(state.teams[0].tasks).toHaveLength(1); + }); + }); + + describe('updateTaskStatus', () => { + it('should update task status', async () => { + const { createTeam, addTask, updateTaskStatus } = useTeamStore.getState(); + + await createTeam({ + name: 'Test Team', + description: '', + pattern: 'sequential', + memberAgents: [{ agentId: 'agent-1', role: 'orchestrator' }], + }); + + const teamId = useTeamStore.getState().teams[0].id; + + await addTask({ + teamId, + title: 'Test Task', + description: '', + priority: 'medium', + type: 'feature', + }); + + const taskId = useTeamStore.getState().teams[0].tasks[0].id; + + await updateTaskStatus(teamId, taskId, 'in_progress'); + + const state = useTeamStore.getState(); + expect(state.teams[0].tasks[0].status).toBe('in_progress'); + }); + }); + + describe('assignTask', () => { + it('should assign task to a member', async () => { + const { createTeam, addTask, assignTask } = useTeamStore.getState(); + + await createTeam({ + name: 'Test Team', + description: '', + pattern: 'sequential', + memberAgents: [ + { agentId: 'agent-1', role: 'orchestrator' }, + { agentId: 'agent-2', role: 'developer' }, + ], + }); + + const state1 = useTeamStore.getState(); + const teamId = state1.teams[0].id; + const memberId = state1.teams[0].members[1].id; + + await addTask({ + teamId, + title: 'Test Task', + description: '', + priority: 'medium', + type: 'feature', + }); + + const taskId = useTeamStore.getState().teams[0].tasks[0].id; + + await assignTask(teamId, taskId, memberId); + + const state2 = useTeamStore.getState(); + expect(state2.teams[0].tasks[0].assigneeId).toBe(memberId); + expect(state2.teams[0].tasks[0].status).toBe('assigned'); + }); + }); + + describe('startDevQALoop', () => { + it('should start a Dev↔QA loop', async () => { + const { createTeam, addTask, startDevQALoop } = useTeamStore.getState(); + + await createTeam({ + name: 'Test Team', + description: '', + pattern: 'review_loop', + memberAgents: [ + { agentId: 'agent-1', role: 'orchestrator' }, + { agentId: 'agent-2', role: 'developer' }, + { agentId: 'agent-3', role: 'reviewer' }, + ], + }); + + const state1 = useTeamStore.getState(); + const teamId = state1.teams[0].id; + const developerId = state1.teams[0].members[1].id; + const reviewerId = state1.teams[0].members[2].id; + + await addTask({ + teamId, + title: 'Code Task', + description: '', + priority: 'high', + type: 'feature', + }); + + const taskId = useTeamStore.getState().teams[0].tasks[0].id; + + const loop = await startDevQALoop(teamId, taskId, developerId, reviewerId); + + expect(loop).not.toBeNull(); + expect(loop?.state).toBe('developing'); + expect(loop?.developerId).toBe(developerId); + expect(loop?.reviewerId).toBe(reviewerId); + + const state2 = useTeamStore.getState(); + expect(state2.teams[0].activeLoops).toHaveLength(1); + }); + }); + + describe('submitReview', () => { + it('should submit review feedback', async () => { + const { createTeam, addTask, startDevQALoop, submitReview } = useTeamStore.getState(); + + await createTeam({ + name: 'Test Team', + description: '', + pattern: 'review_loop', + memberAgents: [ + { agentId: 'agent-1', role: 'orchestrator' }, + { agentId: 'agent-2', role: 'developer' }, + { agentId: 'agent-3', role: 'reviewer' }, + ], + }); + + const state1 = useTeamStore.getState(); + const teamId = state1.teams[0].id; + const developerId = state1.teams[0].members[1].id; + const reviewerId = state1.teams[0].members[2].id; + + await addTask({ + teamId, + title: 'Code Task', + description: '', + priority: 'high', + type: 'feature', + }); + + const taskId = useTeamStore.getState().teams[0].tasks[0].id; + await startDevQALoop(teamId, taskId, developerId, reviewerId); + + const loopId = useTeamStore.getState().teams[0].activeLoops[0].id; + + await submitReview(teamId, loopId, { + verdict: 'needs_work', + comments: ['Please fix the bug'], + issues: [ + { severity: 'major', description: 'Null pointer exception', file: 'main.ts', line: 42 }, + ], + }); + + const state2 = useTeamStore.getState(); + expect(state2.teams[0].activeLoops[0].state).toBe('revising'); + expect(state2.teams[0].activeLoops[0].feedbackHistory).toHaveLength(1); + }); + + it('should approve task when verdict is approved', async () => { + const { createTeam, addTask, startDevQALoop, submitReview } = useTeamStore.getState(); + + await createTeam({ + name: 'Test Team', + description: '', + pattern: 'review_loop', + memberAgents: [ + { agentId: 'agent-1', role: 'orchestrator' }, + { agentId: 'agent-2', role: 'developer' }, + { agentId: 'agent-3', role: 'reviewer' }, + ], + }); + + const state1 = useTeamStore.getState(); + const teamId = state1.teams[0].id; + const developerId = state1.teams[0].members[1].id; + const reviewerId = state1.teams[0].members[2].id; + + await addTask({ + teamId, + title: 'Code Task', + description: '', + priority: 'high', + type: 'feature', + }); + + const taskId = useTeamStore.getState().teams[0].tasks[0].id; + await startDevQALoop(teamId, taskId, developerId, reviewerId); + + const loopId = useTeamStore.getState().teams[0].activeLoops[0].id; + + await submitReview(teamId, loopId, { + verdict: 'approved', + comments: ['Great work!'], + issues: [], + }); + + const state2 = useTeamStore.getState(); + expect(state2.teams[0].activeLoops[0].state).toBe('approved'); + }); + }); + + describe('addEvent', () => { + it('should add collaboration event', () => { + const { addEvent } = useTeamStore.getState(); + + addEvent({ + type: 'task_assigned', + teamId: 'team-1', + sourceAgentId: 'agent-1', + payload: { taskId: 'task-1' }, + timestamp: new Date().toISOString(), + }); + + const state = useTeamStore.getState(); + expect(state.recentEvents).toHaveLength(1); + expect(state.recentEvents[0].type).toBe('task_assigned'); + }); + + it('should limit events to max 100', () => { + const { addEvent } = useTeamStore.getState(); + + // Add 105 events + for (let i = 0; i < 105; i++) { + addEvent({ + type: 'task_assigned', + teamId: 'team-1', + sourceAgentId: 'agent-1', + payload: { index: i }, + timestamp: new Date().toISOString(), + }); + } + + const state = useTeamStore.getState(); + expect(state.recentEvents).toHaveLength(100); + // Most recent event should be at index 0 + expect(state.recentEvents[0].payload.index).toBe(104); + // Oldest kept event should be at index 99 (events 0-4 are discarded) + expect(state.recentEvents[99].payload.index).toBe(5); + }); + }); + + describe('deleteTeam', () => { + it('should delete a team', async () => { + const { createTeam, deleteTeam } = useTeamStore.getState(); + + await createTeam({ + name: 'Team to Delete', + description: '', + pattern: 'sequential', + memberAgents: [{ agentId: 'agent-1', role: 'orchestrator' }], + }); + + const teamId = useTeamStore.getState().teams[0].id; + + await deleteTeam(teamId); + + const state = useTeamStore.getState(); + expect(state.teams).toHaveLength(0); + }); + + it('should clear active team if deleted', async () => { + const { createTeam, setActiveTeam, deleteTeam } = useTeamStore.getState(); + + await createTeam({ + name: 'Team to Delete', + description: '', + pattern: 'sequential', + memberAgents: [{ agentId: 'agent-1', role: 'orchestrator' }], + }); + + const teamId = useTeamStore.getState().teams[0].id; + const team = useTeamStore.getState().teams[0]; + + setActiveTeam(team); + await deleteTeam(teamId); + + const state = useTeamStore.getState(); + expect(state.activeTeam).toBeNull(); + }); + }); +});