import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Sidebar } from '@/components/layout/Sidebar'; const projects = vi.hoisted(() => ['A', 'B'].map((name) => ({ id: `project-${name.toLowerCase()}`, name, createdAt: '2026-10-13T00:00:00.000Z', updatedAt: '2026-10-13T00:00:00.000Z', lastOpenedAt: '2026-10-13T00:00:00.000Z', }))); const codingState = vi.hoisted(() => ({ projects, activeProjectId: projects[0].id, activeProject: projects[0], load: vi.fn().mockResolvedValue(undefined), setActiveProject: vi.fn(), removeProject: vi.fn(), createProject: vi.fn(), })); const projectConfigState = vi.hoisted(() => ({ load: vi.fn<(projectId: string) => Promise>(), remove: vi.fn(), })); const providerState = vi.hoisted(() => ({ refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }), })); vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() })); vi.mock('@/hooks/use-current-user-profile', () => ({ useCurrentUserProfile: () => ({ userProfile: { displayName: '测试用户', avatarUrl: null }, profileRequired: false, profileSyncError: null, syncProfileNow: vi.fn().mockResolvedValue(undefined), }), })); vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: null, logout: vi.fn() }), })); vi.mock('@/stores/settings', () => ({ useSettingsStore: (selector: (state: unknown) => unknown) => selector({ sidebarCollapsed: false }), })); vi.mock('@/stores/coding-workspace', () => ({ codingWorkspaceStore: { getState: () => codingState }, useCodingWorkspaceStore: (selector: (state: typeof codingState) => unknown) => selector(codingState), })); vi.mock('@/stores/project-config', () => ({ useProjectConfigStore: (selector: (state: typeof projectConfigState) => unknown) => selector(projectConfigState), })); vi.mock('@/stores/providers', () => ({ useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState), })); vi.mock('@/pages/Chat/CodingProjectConversations', () => ({ CodingProjectConversations: ({ archived }: { archived: boolean }) => (
{archived ? 'archived' : 'recent'}
), })); vi.mock('@/components/account/PointWallet', () => ({ PointWallet: () => null })); vi.mock('@/components/community/CommunityLink', () => ({ CommunityLink: () => null })); vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null })); vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null })); vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null })); vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null })); function LocationProbe() { return
{useLocation().pathname}
; } async function renderSidebar() { render(); await act(async () => undefined); projectConfigState.load.mockClear(); } async function selectProjectAction(projectName: string, action: string) { fireEvent.keyDown(screen.getByRole('button', { name: `项目操作:${projectName}` }), { key: 'Enter' }); fireEvent.click(await screen.findByRole('menuitem', { name: action })); } describe('Sidebar project actions', () => { beforeEach(() => { codingState.activeProjectId = projects[0].id; codingState.activeProject = projects[0]; codingState.setActiveProject.mockReset(); codingState.setActiveProject.mockImplementation(async (projectId: string) => { const project = projects.find((item) => item.id === projectId)!; codingState.activeProjectId = projectId; codingState.activeProject = project; return project; }); projectConfigState.load.mockReset(); projectConfigState.load.mockResolvedValue({ status: 'valid', config: {} }); }); it.each(['项目设置', '已归档对话'])('%s for the current project supersedes an earlier pending project open', async (action) => { await renderSidebar(); let resolveProjectB!: (value: unknown) => void; const pendingProjectB = new Promise((resolve) => { resolveProjectB = resolve; }); projectConfigState.load.mockImplementation((projectId) => projectId === projects[1].id ? pendingProjectB : Promise.resolve({ status: 'valid', config: {} })); fireEvent.click(screen.getByRole('button', { name: '进入项目 B' })); expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id); expect(codingState.setActiveProject).not.toHaveBeenCalled(); await selectProjectAction('A', action); await waitFor(() => { if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config'); else expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived'); }); await act(async () => { resolveProjectB({ status: 'valid', config: {} }); await pendingProjectB; }); expect(codingState.activeProjectId).toBe(projects[0].id); expect(codingState.setActiveProject).not.toHaveBeenCalledWith(projects[1].id); if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config'); else { expect(screen.getByTestId('location')).toHaveTextContent('/chat'); expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived'); } }); it.each(['项目设置', '已归档对话'])('rejects %s for an inactive project with invalid configuration', async (action) => { await renderSidebar(); projectConfigState.load.mockResolvedValue({ status: 'invalid', config: null, error: '配置文件无效' }); await selectProjectAction('B', action); expect(await screen.findByRole('alertdialog')).toHaveTextContent('配置文件无效'); expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id); expect(codingState.setActiveProject).not.toHaveBeenCalled(); expect(codingState.activeProjectId).toBe(projects[0].id); expect(screen.getByTestId('location')).toHaveTextContent('/chat'); expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('recent'); }); });