145 lines
6.4 KiB
TypeScript
145 lines
6.4 KiB
TypeScript
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<unknown>>(),
|
|
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 }) => (
|
|
<div data-testid="conversation-list-mode">{archived ? 'archived' : 'recent'}</div>
|
|
),
|
|
}));
|
|
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 <div data-testid="location">{useLocation().pathname}</div>;
|
|
}
|
|
|
|
async function renderSidebar() {
|
|
render(<MemoryRouter initialEntries={['/chat']}><Sidebar /><LocationProbe /></MemoryRouter>);
|
|
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');
|
|
});
|
|
});
|