feat: include approved Code sidebar actions in combined trial source

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 21:51:27 +08:00
1 parent 16b10c44f1
commit 18e4be66da
12 files changed
+596 -61

No files matched your search

+4 -2
View File
@@ -393,7 +393,8 @@ test.describe('Makelore module navigation without setup flow', () => {
await sidebarResizer.dblclick();
await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '171');
await sidebar.getByRole('button', { name: '项目设置', exact: true }).click();
await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click();
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
await expect(page).toHaveURL(/\/project-config$/);
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0);
@@ -404,7 +405,8 @@ test.describe('Makelore module navigation without setup flow', () => {
await expect(page).toHaveURL(/\/chat$/);
await expect(moduleReturn).toBeVisible();
await sidebar.getByRole('button', { name: '项目设置', exact: true }).click();
await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click();
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
await page.getByTestId('resource-card-plugins').click();
await expect(page).toHaveURL(/\/project-config\/plugins\?/);
const plugins = page.getByTestId('project-plugins-sheet');
+140 -5
View File
@@ -632,7 +632,8 @@ async function installCodingFirstChatHost(
return respond({ project: activeProject });
}
if (multipleProjects && path === `/api/coding/projects/config?projectId=${otherProject.id}`)
return respond({ snapshot: { project: otherProject, config, knowledgeFiles: [] } });
return respond({ snapshot: { project: otherProject,
config: { ...config, defaultAgentId: agent.id, agents: [{ ...agent, prompt: 'Settings for Another project' }] }, knowledgeFiles: [] } });
if (multipleProjects && path === `/api/coding/projects/conversations?projectId=${otherProject.id}`)
return respond({ conversations: [{ ...secondConversation, id: 'conversation-other-project', title: 'Other project conversation' }] });
if (multipleProjects && path === '/api/coding/conversations/conversation-other-project/snapshot')
@@ -995,7 +996,10 @@ test('conversation menus rename, archive and restore without selecting or stoppi
await page.evaluate(() => { window.location.hash = '/chat'; });
const sidebar = page.getByTestId('project-conversations');
const header = page.getByTestId('coding-conversation-header');
const projectMenu = page.getByRole('button', { name: '项目操作:PI first chat', exact: true });
await expect(header).toContainText('新对话');
await expect(sidebar.getByRole('button', { name: '查看归档对话' })).toHaveCount(0);
await expect(sidebar.getByRole('button', { name: '项目设置', exact: true })).toHaveCount(0);
await sidebar.getByRole('button', { name: '对话操作:Second Conversation' }).focus();
await page.keyboard.press('Enter');
await page.getByRole('menuitem', { name: '重命名', exact: true }).click();
@@ -1009,20 +1013,148 @@ test('conversation menus rename, archive and restore without selecting or stoppi
await page.keyboard.press('Enter');
await page.getByRole('menuitem', { name: '归档', exact: true }).click();
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toHaveCount(0);
await sidebar.getByRole('button', { name: '查看归档对话' }).click();
await projectMenu.focus();
await page.keyboard.press('Enter');
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
await expect(sidebar.getByText('已归档对话', { exact: true })).toBeVisible();
await expect(sidebar.getByRole('button', { name: '返回最近对话', exact: true })).toBeVisible();
await expect(header).toContainText('新对话');
await projectMenu.click();
await expect(page.getByRole('menuitem', { name: '最近对话', exact: true })).toBeVisible();
await page.keyboard.press('Escape');
await sidebar.getByRole('button', { name: '重命名后的会话', exact: true }).click();
await expect(page.getByRole('button', { name: '恢复对话', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '发送', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: '恢复对话', exact: true }).click();
await expect(header).toContainText('重命名后的会话');
await expect(sidebar.getByRole('button', { name: '查看归档对话' })).toBeVisible();
await expect(sidebar.getByRole('button', { name: '返回最近对话', exact: true })).toHaveCount(0);
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toBeVisible();
await projectMenu.click();
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
await sidebar.getByRole('button', { name: '返回最近对话', exact: true }).click();
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toBeVisible();
const requests = (await readState(electronApp)).captured;
expect(requests.some((request) => request.path.endsWith('/abort'))).toBe(false);
expect(requests.filter((request) => request.method === 'PATCH' && request.path.endsWith('/conversation-pi-second'))
.map((request) => request.body)).toEqual([
{ title: '重命名后的会话' }, { archived: true }, { archived: false },
]);
await page.screenshot({ path: 'test-results/coding-session-controls.png' });
await page.screenshot({ path: test.info().outputPath('coding-session-controls.png') });
} finally { await releaseSnapshot(electronApp); }
});
test('project menus target their project and keep new conversations aligned at every sidebar width', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
try {
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
const rail = page.getByTestId('sidebar');
const conversations = page.getByTestId('project-conversations');
const create = conversations.getByRole('button', { name: '新建对话', exact: true });
const primaryProject = page.getByTestId('sidebar-course-project-project-pi-first-chat');
const otherProject = page.getByTestId('sidebar-course-project-project-pi-other');
const primaryMenu = page.getByRole('button', { name: '项目操作:PI first chat', exact: true });
const otherMenu = page.getByRole('button', { name: '项目操作:Another project', exact: true });
const resizer = page.getByRole('separator', { name: '调整左侧功能栏宽度' });
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
await expect(create).toHaveText('新对话');
await expect(conversations.locator('.classroom-conversation-actions').getByRole('button')).toHaveCount(1);
expect(await otherMenu.evaluate(element => element.parentElement?.querySelector('button')?.contains(element))).toBe(false);
for (const width of [171, 128, 256]) {
if (width !== 171) await resizer.press(width === 128 ? 'Home' : 'End');
await expect(rail).toHaveCSS('width', `${width}px`);
await expect(create).toBeInViewport({ ratio: 1 });
const [iconBounds, cardBounds] = await Promise.all([create.locator('svg').boundingBox(), primaryProject.boundingBox()]);
expect(Math.abs(iconBounds!.x - cardBounds!.x)).toBeLessThanOrEqual(1);
expect(await create.evaluate(element => {
const text = Array.from(element.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent?.includes('新对话'));
if (!text) return false;
const range = document.createRange();
range.selectNodeContents(text);
const label = range.getBoundingClientRect();
const button = element.getBoundingClientRect();
return label.width > 0 && label.left >= button.left && label.right <= button.right && label.bottom <= button.bottom;
})).toBe(true);
for (const element of [rail, conversations, create, primaryProject]) {
expect(await element.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true);
}
await expect(primaryMenu).toBeInViewport({ ratio: 1 });
await page.screenshot({ path: test.info().outputPath(`sidebar-actions-${width}.png`) });
}
const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle');
await sidebarToggle.click();
await page.mouse.move(800, 500);
await expect(rail).toHaveAttribute('aria-hidden', 'true');
await sidebarToggle.hover();
await expect(rail).toHaveAttribute('aria-hidden', 'false');
await primaryMenu.click();
const archivedMenuItem = page.getByRole('menuitem', { name: '已归档对话', exact: true });
await expect(archivedMenuItem).toBeVisible();
await page.mouse.move(800, 500);
// Stay outside the rail longer than its 180ms peek-close delay. An open
// portal menu must keep the preview available and remain above it.
await page.waitForTimeout(350);
// Radix temporarily marks background content aria-hidden while its modal
// menu is open, so measure the rail's visual state here instead.
await expect(rail).toHaveCSS('width', '256px');
await expect(rail).toHaveCSS('opacity', '1');
await expect(archivedMenuItem).toBeVisible();
await archivedMenuItem.click({ trial: true });
await page.screenshot({ path: test.info().outputPath('sidebar-project-menu-peek.png') });
await archivedMenuItem.click();
await page.mouse.move(800, 500);
await expect(rail).toHaveAttribute('aria-hidden', 'true');
await sidebarToggle.hover();
await conversations.getByRole('button', { name: '返回最近对话', exact: true }).click();
await sidebarToggle.click();
await expect(rail).toHaveCSS('width', '256px');
await expect(resizer).toBeVisible();
const activationRequests = () => readState(electronApp).then(state => state.captured.filter(request =>
request.path === '/api/coding/projects/active' && request.method === 'POST'));
const activationsBeforeMenu = await activationRequests();
await otherMenu.focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('menuitem', { name: '已归档对话', exact: true })).toBeVisible();
await expect(page.getByRole('menuitem', { name: '项目设置', exact: true })).toBeVisible();
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
await expect(otherProject).not.toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('coding-conversation-header')).toContainText('新对话');
expect(await activationRequests()).toEqual(activationsBeforeMenu);
await page.keyboard.press('Escape');
await expect(otherMenu).toBeFocused();
await otherMenu.click();
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByRole('textbox', { name: '项目提示词', exact: true })).toHaveValue('Settings for Another project');
await expect(otherProject).toHaveAttribute('aria-current', 'page');
expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-other' });
// The archive action also validates and activates an inactive project.
await primaryMenu.click();
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
await expect(conversations.getByText('已归档对话', { exact: true })).toBeVisible();
expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-first-chat' });
await primaryMenu.click();
await page.getByRole('menuitem', { name: '最近对话', exact: true }).click();
await expect(conversations.getByRole('button', { name: '返回最近对话', exact: true })).toHaveCount(0);
await expect(conversations.getByRole('button', { name: '新对话', exact: true })).toBeVisible();
expect((await readState(electronApp)).captured.filter(request => request.method === 'POST'
&& /\/(prompt|messages|abort)$/.test(request.path))).toHaveLength(0);
} finally { await releaseSnapshot(electronApp); }
});
@@ -1579,7 +1711,10 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
)).toBe(false);
await page.getByRole('tab', { name: '操作对话' }).click();
await expect(page.getByTestId('agent-browser-panel')).toHaveCount(0);
await expect(page.getByRole('button', { name: '项目设置' })).toHaveCount(1);
await expect(page.getByRole('button', { name: '项目设置' })).toHaveCount(0);
await page.getByRole('button', { name: '项目操作:PI first chat', exact: true }).click();
await expect(page.getByRole('menuitem', { name: '项目设置', exact: true })).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('project-conversations')).toBeVisible();
const activeProcess = page.getByTestId('coding-process-group');
await expect(activeProcess).toHaveAttribute('open', '');
+104 -7
View File
@@ -523,7 +523,15 @@ describe('CodingChatPanel first Conversation', () => {
run: { status: 'running', runId: 'run-1' } });
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(false);
return <>
<button onClick={() => setArchived(true)}>打开归档列表</button>
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
const { codingConversationStore } = await import('@/stores/coding-conversations');
render(<CodingChatPanel />);
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
@@ -533,7 +541,8 @@ describe('CodingChatPanel first Conversation', () => {
await screen.findByRole('heading', { name: '今天,你想创造什么?' });
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
fireEvent.click(screen.getByRole('button', { name: '打开归档列表' }));
expect(screen.getByText('已归档对话')).toBeVisible();
fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true }));
await screen.findByText('此对话已归档,任务仍在运行。');
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
@@ -541,6 +550,92 @@ describe('CodingChatPanel first Conversation', () => {
await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument());
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false });
expect(screen.queryByText('已归档对话')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
expect(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true })).toHaveAttribute('aria-current', 'page');
expect(conversationApi.abort).not.toHaveBeenCalled();
});
it('returns to recent conversations when restoring an unselected archived conversation from its menu', async () => {
const archivedConversation = {
...conversation,
id: 'archived-conversation',
title: '归档中的对话',
archivedAt: '2026-09-20T00:00:00Z',
};
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation, archivedConversation]);
projectApi.patch.mockResolvedValue({ ...archivedConversation, archivedAt: null });
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(true);
return <>
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
render(<CodingChatPanel />);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
const list = within(screen.getByTestId('project-conversations'));
expect(list.getByText('已归档对话')).toBeVisible();
expect(list.queryByRole('button', { name: conversation.title, exact: true })).not.toBeInTheDocument();
fireEvent.keyDown(list.getByRole('button', { name: `对话操作:${archivedConversation.title}` }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '恢复到最近对话' }));
await waitFor(() => expect(list.queryByText('已归档对话')).not.toBeInTheDocument());
expect(projectApi.patch).toHaveBeenCalledWith(archivedConversation.id, { archived: false });
expect(list.getByRole('button', { name: archivedConversation.title, exact: true })).toBeVisible();
expect(list.getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(archivedConversation.id);
expect(projectApi.create).not.toHaveBeenCalled();
});
it('keeps the new-conversation label visible in compact archive view and returns to recent conversations after creation', async () => {
const archivedConversation = { ...conversation, archivedAt: '2026-09-20T00:00:00Z' };
const createdConversation = { ...conversation, id: 'created-conversation', title: '新建的对话' };
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([archivedConversation]);
projectApi.create.mockResolvedValue(createdConversation);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, createdConversation));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(true);
return <>
<CodingProjectConversations projectId={project.id} compact archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
render(<CodingChatPanel />);
const list = within(await screen.findByTestId('project-conversations'));
const create = list.getByRole('button', { name: '新建对话', exact: true });
expect(create).toHaveTextContent('新对话');
expect(list.getByText('已归档对话')).toBeVisible();
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(create);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(createdConversation.id));
expect(projectApi.create).toHaveBeenCalledOnce();
expect(list.queryByText('已归档对话')).not.toBeInTheDocument();
expect(list.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
expect(list.queryByRole('button', { name: archivedConversation.title, exact: true })).not.toBeInTheDocument();
expect(list.getByRole('button', { name: createdConversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('renames an unselected conversation from its menu without preparing that conversation', async () => {
@@ -959,7 +1054,7 @@ describe('CodingChatPanel first Conversation', () => {
expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument();
});
it('keeps project settings only in the Conversation list and removes legacy Header actions', async () => {
it('removes project toolbar actions and legacy Header actions while keeping conversation creation', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
@@ -986,10 +1081,12 @@ describe('CodingChatPanel first Conversation', () => {
expect(within(header).queryByRole('button', { name: /标为(已读|未读)/ })).not.toBeInTheDocument();
expect(within(header).queryByRole('button', { name: '归档' })).not.toBeInTheDocument();
expect(within(header).queryByRole('button', { name: '伙伴设置' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: '项目设置' })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '项目设置' }));
expect(navigate).toHaveBeenCalledWith('/project-config');
expect(screen.queryByRole('button', { name: '项目设置' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '查看归档对话' })).not.toBeInTheDocument();
const list = within(screen.getByTestId('project-conversations'));
expect(list.getByRole('button', { name: '新建对话', exact: true })).toHaveTextContent('新对话');
expect(list.getByRole('button', { name: `对话操作:${conversation.title}` })).toBeInTheDocument();
expect(onOpenProjectSettings).not.toHaveBeenCalled();
});
it('remounts pending interaction state when switching Conversations', async () => {
+19 -2
View File
@@ -7,7 +7,7 @@ import { useSettingsStore } from '@/stores/settings';
type PeekProps = {
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar' | 'menu') => void;
forceExpanded?: boolean;
};
@@ -42,7 +42,10 @@ vi.mock('@/components/layout/Sidebar', () => ({
data-force-expanded={forceExpanded ? 'true' : 'false'}
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
/>
>
<button onClick={() => onSidebarPeekChange?.(true, 'menu')}>打开项目菜单</button>
<button onClick={() => onSidebarPeekChange?.(false, 'menu')}>关闭项目菜单</button>
</aside>
),
}));
@@ -124,6 +127,20 @@ describe('MainLayout sidebar hover preview', () => {
expect(screen.getByTestId('peek-trigger')).toHaveAttribute('data-peek-open', 'false');
});
it('keeps the preview open for a project menu after the pointer leaves the sidebar', () => {
renderLayout();
const sidebar = screen.getByTestId('peek-sidebar');
fireEvent.pointerEnter(sidebar);
fireEvent.click(screen.getByRole('button', { name: '打开项目菜单' }));
fireEvent.pointerLeave(sidebar);
act(() => vi.advanceTimersByTime(300));
expect(sidebar).toHaveAttribute('data-peek-open', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭项目菜单' }));
act(() => vi.advanceTimersByTime(180));
expect(sidebar).toHaveAttribute('data-peek-open', 'false');
});
it('closes once after leaving the only hover zone', () => {
renderLayout();
+144
View File
@@ -0,0 +1,144 @@
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');
});
});