Files
makelore/tests/unit/sidebar-project-actions.test.tsx
T

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');
});
});