Files
makelore/tests/unit/sidebar-opencode-projects.test.tsx
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

1162 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceSidebar } from '@/components/layout/ImageWorkspaceSidebar';
import { Sidebar } from '@/components/layout/Sidebar';
import { useOpencodeStore } from '@/stores/opencode';
import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { useUpdateStore } from '@/stores/update';
import { useAuthStore } from '@/stores/auth';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { useUserProfileStore } from '@/stores/user-profile';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { createProjectConfig } from '../../shared/project-config';
import type {
DesignConversationSummary,
DesignDeleteWorkspaceResult,
DesignGenerationTask,
DesignTaskStatus,
DesignWorkspace,
DesignWorkspaceSummary,
} from '../../shared/image-workspace';
const hostApiFetchMock = vi.fn();
const invokeIpcMock = vi.fn();
const navigateMock = vi.fn();
const prepareUserAvatarMock = vi.hoisted(() => vi.fn());
const originalDeleteImageProject = useImageWorkspaceStore.getState().deleteProject;
vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), ensureHostApiToken: vi.fn().mockResolvedValue('test-token') }));
vi.mock('@/lib/api-client', () => ({ invokeIpc: (...args: unknown[]) => invokeIpcMock(...args) }));
vi.mock('@/lib/user-avatar', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/user-avatar')>(),
prepareUserAvatar: (...args: unknown[]) => prepareUserAvatarMock(...args),
}));
vi.mock('react-router-dom', async (importOriginal) => ({ ...(await importOriginal<typeof import('react-router-dom')>()), useNavigate: () => navigateMock }));
const DELETE_PROJECT_SUMMARY: DesignWorkspaceSummary = {
workspaceId: 'delete-project',
title: '待删除的概念设计',
viewRevision: 3,
conversationCount: 1,
phase: 'shaping',
updatedAt: '2026-08-15T10:00:00Z',
};
function seedDeleteProjectState(
deleteProject: (workspaceId: string) => Promise<DesignDeleteWorkspaceResult>,
activeTaskStatus?: Extract<DesignTaskStatus, 'queued' | 'running'>,
) {
const workspace: DesignWorkspace = {
...DELETE_PROJECT_SUMMARY,
conversations: [],
};
const tasks: DesignGenerationTask[] = activeTaskStatus ? [{
taskId: 'active-delete-task',
workspaceId: DELETE_PROJECT_SUMMARY.workspaceId,
conversationId: null,
medium: 'image',
status: activeTaskStatus,
briefVersion: 1,
briefSummary: '仍在生成的概念图',
quoteId: null,
quotedDesignPoints: null,
failureCode: null,
resultAssets: [],
createdAt: '2026-08-15T10:00:00Z',
updatedAt: '2026-08-15T10:00:00Z',
}] : [];
useImageWorkspaceStore.setState({
status: 'ready',
bootstrap: {
capabilities: { conversation: true, generation: true, image: true, video: true },
workspaces: [DELETE_PROJECT_SUMMARY],
},
activeWorkspaceId: DELETE_PROJECT_SUMMARY.workspaceId,
activeConversationId: null,
workspace,
conversation: null,
deletingWorkspaceId: null,
tasks,
error: null,
deleteProject,
});
}
function renderDeleteProjectSidebar() {
render(
<MemoryRouter initialEntries={['/image-canvas']}>
<ImageWorkspaceSidebar sidebarCollapsed={false} />
</MemoryRouter>,
);
}
function openDeleteProjectDialog() {
fireEvent.click(screen.getByRole('button', { name: `删除 ${DELETE_PROJECT_SUMMARY.title}` }));
return screen.getByRole('dialog', { name: '删除项目' });
}
describe('Sidebar project initialization flow', () => {
beforeEach(() => {
vi.clearAllMocks();
prepareUserAvatarMock.mockReset();
window.electron.imageWorkspaceLocalDevelopment = false;
useSettingsStore.setState({ sidebarCollapsed: false });
useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: null, refreshToken: null, tokenType: null, expiresAt: null, user: null });
useUserProfileStore.setState({ profilesByUserId: {} });
useImageWorkspaceStore.getState().reset();
useImageWorkspaceStore.setState({ deleteProject: originalDeleteImageProject });
useUpdateStore.setState({
status: 'idle',
currentVersion: '0.9.1',
updateInfo: null,
progress: null,
error: null,
isInitialized: true,
isInitializing: false,
autoInstallCountdown: null,
init: vi.fn().mockResolvedValue(undefined),
downloadUpdate: vi.fn().mockResolvedValue(undefined),
installUpdate: vi.fn(),
});
useProjectConfigStore.setState({ configsByProjectId: {}, knowledgeByProjectId: {}, errorsByProjectId: {}, loadingProjectId: null });
useOpencodeStore.setState({ projects: [], activeProject: null, status: { state: 'stopped', port: 4096 }, sessions: [], sessionsByProjectId: {}, selectedSessionId: null, loading: false, error: null });
invokeIpcMock.mockResolvedValue({ canceled: true, filePaths: [] });
});
it('requires a personal profile name after login and saves optional details', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-1', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
return {
success: true,
profile: {
display_name: '小明',
age: 16,
gender: 'undisclosed',
share_age_with_agents: true,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-07-12T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: null,
age: null,
gender: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: false,
version: 0,
updated_at: null,
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
expect(await screen.findByText('首次使用前请先填写名字。个人资料与登录账号信息相互独立。')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '关闭个人资料' })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/名字/), { target: { value: '小明' } });
fireEvent.change(screen.getByLabelText('年龄(选填)'), { target: { value: '16' } });
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(useUserProfileStore.getState().profilesByUserId['user-1']).toMatchObject({ displayName: '小明', age: 16, version: 1 }));
});
it('renders the synced cloud avatar in the account surfaces and profile dialog', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-avatar', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/avatar.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const accountAvatar = await screen.findByTestId('sidebar-account-avatar');
expect(within(screen.getByTestId('sidebar-member-menu-trigger')).getByText('头像用户')).toBeInTheDocument();
await waitFor(() => expect(accountAvatar.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/avatar.webp'));
fireEvent.error(accountAvatar.querySelector('img')!);
expect(accountAvatar).toHaveTextContent('头');
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
expect(screen.getByTestId('sidebar-account-menu-avatar').querySelector('img')).toHaveAttribute('src', 'https://cdn.example/avatar.webp');
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/works/user/agent-profile')).toHaveLength(2));
expect(await screen.findByTestId('profile-avatar-preview')).toContainElement(
screen.getByRole('img', { name: '个人头像预览' }),
);
expect(screen.getByRole('img', { name: '个人头像预览' })).toHaveAttribute('src', 'https://cdn.example/avatar.webp');
});
it('stages a new avatar and uploads it together with the profile save', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-upload', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
prepareUserAvatarMock.mockResolvedValue({
fileName: 'avatar.webp',
mimeType: 'image/webp',
dataBase64: 'QUJD',
previewUrl: 'data:image/webp;base64,QUJD',
width: 512,
height: 512,
bytes: 3,
});
let profilePutAttempts = 0;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
profilePutAttempts += 1;
if (profilePutAttempts === 1) {
return {
success: false,
status: 503,
code: 'profile_storage_unavailable',
error: 'Profile service unavailable',
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/uploaded.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 2,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
if (path === '/api/works/user/avatar' && init?.method === 'POST') {
return { success: true, avatar_url: 'https://cdn.example/uploaded.webp' };
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
const input = screen.getByTestId('profile-avatar-upload');
fireEvent.change(input, { target: { files: [new File(['source'], 'portrait.png', { type: 'image/png' })] } });
await waitFor(() => expect(screen.getByRole('img', { name: '个人头像预览' })).toHaveAttribute('src', 'data:image/webp;base64,QUJD'));
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/user/avatar')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/user/avatar', {
method: 'POST',
body: JSON.stringify({ fileName: 'avatar.webp', mimeType: 'image/webp', dataBase64: 'QUJD' }),
headers: { 'X-NianCode-Access-Token': 'token' },
}));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('个人资料服务暂时不可用'));
expect(screen.getByRole('dialog', { name: '个人资料' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
expect(profilePutAttempts).toBe(2);
expect(hostApiFetchMock.mock.calls.filter(([path, init]) => path === '/api/works/user/avatar' && init?.method === 'POST')).toHaveLength(1);
expect(useUserProfileStore.getState().profilesByUserId['user-upload']?.avatarUrl).toBe('https://cdn.example/uploaded.webp');
});
it('stages avatar removal until the profile is saved', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-remove', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/avatar' && init?.method === 'DELETE') return { success: true, avatar_url: null };
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 2,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/avatar.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
fireEvent.click(await screen.findByTestId('profile-avatar-remove'));
expect(screen.getByTestId('profile-avatar-preview')).not.toContainElement(screen.queryByRole('img', { name: '个人头像预览' }));
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/user/avatar')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/user/avatar', {
method: 'DELETE',
headers: { 'X-NianCode-Access-Token': 'token' },
}));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
expect(useUserProfileStore.getState().profilesByUserId['user-remove']?.avatarUrl).toBeNull();
});
it('shows create project first and removes redundant project navigation actions', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const switcher = screen.getByTestId('sidebar-module-switcher');
expect(switcher).toHaveTextContent('编程 Code');
expect(screen.queryByRole('img', { name: 'Makelore logo' })).not.toBeInTheDocument();
expect(screen.queryByText('一念成光,万物可创。')).not.toBeInTheDocument();
const createProject = screen.getByTestId('sidebar-create-project');
expect(createProject).toHaveTextContent('新建项目');
expect(createProject).toHaveClass('bg-transparent', 'border-0', 'shadow-none');
expect(screen.getByTestId('sidebar')).toHaveClass('border-r', 'sidebar-glass-surface');
expect(screen.queryByTestId('sidebar-nav-asset-square')).not.toBeInTheDocument();
expect(screen.queryByTestId('sidebar-nav-project-config')).not.toBeInTheDocument();
expect(screen.queryByTestId('sidebar-nav-agent-chat')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '添加已有项目' })).not.toBeInTheDocument();
expect(screen.queryByText('课程路径')).not.toBeInTheDocument();
expect(screen.queryByText('伙伴广场')).not.toBeInTheDocument();
const moduleTrigger = screen.getByTestId('sidebar-module-switcher-trigger');
expect(moduleTrigger).toHaveAttribute('aria-label', '返回首页');
expect(moduleTrigger).toHaveClass('bg-transparent');
expect(moduleTrigger).toHaveClass('w-fit', 'justify-start', 'p-2');
expect(moduleTrigger).not.toHaveClass('bg-brand-soft');
expect(moduleTrigger).toHaveTextContent('编程 Code');
expect(screen.queryByTestId('sidebar-module-switcher-menu')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger'));
expect(navigateMock).toHaveBeenCalledWith('/module-select');
expect(screen.queryByTestId('sidebar-nav-image-canvas')).not.toBeInTheDocument();
expect(await screen.findByText(/暂无真实项目/)).toBeInTheDocument();
});
it('keeps new project and personal profile as dismissible modal dialogs', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(screen.getByTestId('sidebar-create-project'));
const createDialog = screen.getByRole('dialog', { name: '新建项目' });
expect(createDialog).toHaveAttribute('aria-modal', 'true');
act(() => fireEvent.keyDown(document, { key: 'Escape' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '新建项目' })).not.toBeInTheDocument());
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
const profileDialog = screen.getByRole('dialog', { name: '个人资料' });
expect(profileDialog).toHaveAttribute('aria-modal', 'true');
act(() => fireEvent.keyDown(document, { key: 'Escape' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
});
it('keeps the expanded account menu aligned and scrollable within the sidebar', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const account = screen.getByTestId('sidebar-member-menu-trigger');
expect(account).toHaveClass('min-h-9', 'px-2', 'py-1');
fireEvent.click(account);
const menu = screen.getByTestId('sidebar-account-menu');
expect(menu).toHaveAttribute('data-state', 'open');
expect(menu).toHaveClass('absolute', 'left-0', 'w-full');
expect(menu.querySelector('.codex-disclosure-inner')).toHaveClass('max-h-[calc(100vh-7rem)]', 'overflow-y-auto');
expect(menu).not.toHaveClass('fixed');
const usageItem = screen.getByTestId('sidebar-account-usage-menuitem');
expect(usageItem).toHaveClass('min-h-8');
expect(screen.getByTestId('sidebar-account-usage-drawer')).toHaveAttribute('data-state', 'closed');
fireEvent.click(usageItem);
const usageDrawer = screen.getByTestId('sidebar-account-usage-drawer');
expect(usageDrawer).toHaveTextContent('订阅等级');
expect(usageDrawer).toHaveTextContent('5 小时');
expect(usageDrawer).toHaveTextContent('1 周');
});
it('places the module switcher in the sidebar header above project actions', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const switcher = screen.getByTestId('sidebar-module-switcher');
const createProject = screen.getByTestId('sidebar-create-project');
expect(Boolean(switcher.compareDocumentPosition(createProject) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
});
it('fully hides the collapsed sidebar and exposes a full hover preview', () => {
useSettingsStore.setState({ sidebarCollapsed: true });
const onSidebarPeekChange = vi.fn();
const { rerender } = render(
<MemoryRouter>
<Sidebar sidebarPeekOpen={false} onSidebarPeekChange={onSidebarPeekChange} />
</MemoryRouter>,
);
const sidebar = screen.getByTestId('sidebar');
expect(sidebar).toHaveClass('w-0', 'pointer-events-none', 'opacity-0', 'fixed', 'inset-y-0', 'left-0', 'z-[350]');
expect(sidebar).not.toHaveClass('sidebar-peek-surface', 'pt-10');
rerender(
<MemoryRouter>
<Sidebar sidebarPeekOpen onSidebarPeekChange={onSidebarPeekChange} />
</MemoryRouter>,
);
expect(sidebar).toHaveClass('w-64', 'fixed', 'inset-y-0', 'z-[350]', 'sidebar-peek-surface', 'pt-10', 'shadow-float');
expect(screen.getByTestId('sidebar-create-project')).toHaveTextContent('新建项目');
fireEvent.pointerEnter(sidebar);
expect(onSidebarPeekChange).toHaveBeenCalledWith(true, 'sidebar');
fireEvent.pointerLeave(sidebar);
expect(onSidebarPeekChange).toHaveBeenCalledWith(false, 'sidebar');
});
it('keeps a clicked-open sidebar on the translucent blurred material', () => {
useSettingsStore.setState({ sidebarCollapsed: false });
render(
<MemoryRouter>
<Sidebar />
</MemoryRouter>,
);
const sidebar = screen.getByTestId('sidebar');
expect(sidebar).toHaveClass('w-64', 'sidebar-glass-surface');
expect(sidebar).not.toHaveClass('fixed', 'sidebar-peek-surface', 'pt-10');
});
it('shows an available update beside the lower-left account control', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const account = screen.getByTestId('sidebar-member-menu-trigger');
expect(screen.queryByTestId('sidebar-update-button')).not.toBeInTheDocument();
act(() => {
useUpdateStore.setState({
status: 'available',
updateInfo: { version: '0.9.2' },
});
});
const update = await screen.findByTestId('sidebar-update-button');
expect(account.parentElement).toBe(update.parentElement);
expect(Boolean(account.compareDocumentPosition(update) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
expect(update).toHaveAccessibleName('下载新版本 0.9.2');
});
it('defaults to a publishable mini-game project and opens configuration', async () => {
const project = { id: 'prj_game', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '2026-07-11T00:00:00.000Z', updatedAt: '2026-07-11T00:00:00.000Z', lastOpenedAt: '2026-07-11T00:00:00.000Z' };
const config = createProjectConfig(undefined, 'mini_game');
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo/星星收集器'] });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/opencode/projects/create') return { success: true, project, projects: [project], activeProject: null, config };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active' && init?.method === 'POST') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
expect(screen.getByRole('radio', { name: '小游戏' })).toBeChecked();
expect(screen.getByRole('radio', { name: '小程序' })).not.toBeChecked();
expect(screen.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
expect(screen.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
expect(screen.queryByLabelText('项目名称')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
await waitFor(() => expect(screen.getByLabelText('项目路径')).toHaveValue('D:/repo/星星收集器'));
expect(screen.getByText('将直接接入此文件夹,项目名称使用“星星收集器”,不会重命名目录。')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认创建' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ projectPath: 'D:/repo/星星收集器', projectType: 'mini_game' }) }));
await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/project-config'));
});
it('keeps creating a named child folder when that directory mode is selected', async () => {
const project = { id: 'prj_child', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const config = createProjectConfig(undefined, 'mini_program');
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo'] });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/opencode/projects/create') return { success: true, project, projects: [project], activeProject: null, config };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active' && init?.method === 'POST') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
fireEvent.click(screen.getByRole('radio', { name: '小程序' }));
fireEvent.click(screen.getByRole('radio', { name: '在所选位置新建下级文件夹' }));
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '星星收集器' } });
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
await waitFor(() => expect(screen.getByLabelText('项目路径')).toHaveValue('D:/repo'));
fireEvent.click(screen.getByRole('button', { name: '确认创建' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', {
method: 'POST',
body: JSON.stringify({
parentPath: 'D:/repo',
projectName: '星星收集器',
projectType: 'mini_program',
}),
}));
});
it('opens initialized projects directly in Agent collaboration', async () => {
const project = { id: 'prj_ready', path: 'D:/repo/ready', name: 'ready', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const config = { ...createProjectConfig(), initialized: true };
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: null };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: `进入项目 ${project.name}` }));
await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/opencode-chat'));
});
it('removes a project card when its local project configuration is missing', async () => {
const project = { id: 'prj_missing', path: 'D:/repo/missing', name: 'missing', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const staleConfig = createProjectConfig();
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: staleConfig }, knowledgeByProjectId: {}, errorsByProjectId: {}, loadingProjectId: null });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: null };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'missing', knowledgeFiles: [] };
if (path === '/api/opencode/projects/remove' && init?.method === 'POST') return { success: true, removedProjectId: project.id, projects: [], activeProject: null };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: `进入项目 ${project.name}` }));
const projectConfigError = await screen.findByTestId('project-config-error');
expect(projectConfigError).toBeInTheDocument();
expect(screen.getByTestId('project-config-error-overlay')).toHaveClass('fixed', 'inset-0', 'backdrop-blur-md');
expect(projectConfigError.closest('[data-testid="sidebar"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '移除项目卡片' }));
const removeDialog = screen.getByRole('dialog', { name: `移除项目“${project.name}”?` });
expect(removeDialog).toBeInTheDocument();
expect(removeDialog).toHaveAttribute('aria-modal', 'true');
expect(removeDialog.closest('[data-testid="sidebar"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认移除' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/remove', {
method: 'POST',
body: JSON.stringify({ projectId: project.id }),
}));
await waitFor(() => expect(screen.queryByTestId(`sidebar-course-project-${project.id}`)).not.toBeInTheDocument());
expect(useProjectConfigStore.getState().configsByProjectId[project.id]).toBeUndefined();
});
it('uses the whole project card as the enter action', async () => {
const project = { id: 'prj_config', path: 'D:/repo/config', name: 'config', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const config = {
...createProjectConfig(),
initialized: true,
agents: ['小策', '小设', '小码', '小发'].map((name, index) => ({
id: `manual-agent-${index}`,
name,
builtIn: false,
model: null,
skills: [],
responsibility: '',
avatarId: `avatar-${16 - index}`,
})),
};
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: null };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const card = await screen.findByTestId(`sidebar-course-project-${project.id}`);
expect(card).toHaveTextContent(project.name);
expect(card).not.toHaveTextContent('普通开发项目');
expect(card).not.toHaveTextContent(project.path);
expect(card).not.toHaveTextContent('已初始化');
expect(card).not.toHaveTextContent('最后活跃');
expect(card).not.toHaveTextContent('移除项目');
expect(screen.getByLabelText('项目 Agent').children).toHaveLength(4);
expect(screen.getByTitle('小策').querySelector('img')).toHaveAttribute('src', getAgentAvatarSrc('avatar-16'));
expect(screen.getByTitle('小设').querySelector('img')).toHaveAttribute('src', getAgentAvatarSrc('avatar-15'));
expect(screen.getByRole('button', { name: `进入项目 ${project.name}` })).toBe(card);
expect(card.querySelectorAll('button')).toHaveLength(0);
expect(screen.queryByRole('button', { name: `配置项目 ${project.name}` })).not.toBeInTheDocument();
});
it('switches the sidebar to titled Design Workspaces inside the AI painting module', async () => {
useAuthStore.setState({
accessToken: 'cloud-token',
expiresAt: Date.now() + 60_000,
});
const summary = {
workspaceId: 'cloud-project',
title: '云端概念设计',
viewRevision: 0,
conversationCount: 1,
phase: 'shaping',
updatedAt: '2026-07-31T10:00:00Z',
};
const conversation = {
conversationId: 'cloud-conversation',
workspaceId: 'cloud-project',
title: '主会话',
latestMessagePreview: '第一条历史消息',
turnRevision: 0,
phase: 'shaping',
brief: {
version: 0,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
createdAt: '2026-07-31T10:00:00Z',
updatedAt: '2026-07-31T10:00:00Z',
messages: [],
};
const { messages: _messages, ...conversationSummary } = conversation;
const secondConversation = {
...conversation,
conversationId: 'cloud-conversation-two',
title: '第二会话',
latestMessagePreview: '第二条历史消息',
messages: [],
createdAt: '2026-07-31T10:05:00Z',
updatedAt: '2026-07-31T10:05:00Z',
};
const { messages: _secondMessages, ...secondConversationSummary } = secondConversation;
let hasSecondConversation = false;
const bootstrap = {
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
},
workspaces: [summary],
};
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/works/image-workspace') {
return { success: true, data: bootstrap };
}
if (path === '/api/works/image-workspace/workspaces/cloud-project' && !init?.method) {
return {
success: true,
data: {
...summary,
conversationCount: hasSecondConversation ? 2 : 1,
conversations: hasSecondConversation
? [secondConversationSummary, conversationSummary]
: [conversationSummary],
},
};
}
if (path === '/api/works/image-workspace/workspaces/cloud-project/conversations/cloud-conversation') {
return { success: true, data: conversation };
}
if (path === '/api/works/image-workspace/workspaces/cloud-project-two/conversations/cloud-conversation-two') {
return {
success: true,
data: {
...conversation,
workspaceId: 'cloud-project-two',
conversationId: 'cloud-conversation-two',
},
};
}
if (path === '/api/works/image-workspace/workspaces/cloud-project/tasks') {
return { success: true, data: [] };
}
if (path === '/api/works/image-workspace/workspaces/cloud-project/conversations'
&& init?.method === 'POST') {
hasSecondConversation = true;
return { success: true, data: secondConversation };
}
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
return {
success: true,
data: {
...summary,
workspaceId: 'cloud-project-two',
title: '角色设定',
conversations: [{
...conversationSummary,
workspaceId: 'cloud-project-two',
conversationId: 'cloud-conversation-two',
}],
},
};
}
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
const projectCard = await screen.findByTestId('sidebar-image-project-cloud-project');
expect(projectCard).toHaveTextContent('云端概念设计');
expect(projectCard).not.toHaveTextContent('1 个会话');
expect(projectCard).not.toHaveTextContent('设计会话');
expect(screen.getByRole('button', { name: '收起 云端概念设计 的会话' })).toBeVisible();
expect(screen.getByRole('button', { name: '重命名 云端概念设计' })).toBeVisible();
const projectHeader = screen.getByTestId('sidebar-image-project-header-cloud-project');
const projectActions = [
screen.getByRole('button', { name: '收起 云端概念设计 的会话' }),
screen.getByRole('button', { name: '重命名 云端概念设计' }),
within(projectHeader).getByRole('button', { name: '新建设计会话' }),
];
projectActions.forEach((action) => {
expect(action).toHaveClass('opacity-0', 'pointer-events-none', 'group-hover/project:opacity-100', 'focus-visible:opacity-100');
});
expect(projectHeader).toHaveClass('items-center');
expect(projectHeader).not.toHaveClass('justify-center');
expect(projectHeader).toHaveClass('min-h-9');
expect(within(screen.getByTestId('sidebar-image-conversations-cloud-project')).queryByRole('button', { name: '新建设计会话' }))
.not.toBeInTheDocument();
expect(screen.getByTestId('sidebar-image-project-toggle-icon-cloud-project')).toHaveClass('rotate-180');
expect(screen.getByTestId('sidebar-image-conversations-cloud-project'))
.not.toHaveClass('border-t');
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toBeVisible();
const createImageProject = screen.getByTestId('sidebar-create-image-project');
expect(createImageProject).toBeEnabled();
expect(createImageProject).toHaveClass('bg-transparent', 'border-0', 'shadow-none');
expect(screen.queryByTestId('sidebar-create-project')).not.toBeInTheDocument();
expect(screen.queryByText('项目路径')).not.toBeInTheDocument();
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/projects')).toBe(false);
expect(screen.queryByText(/添加 Agent/)).not.toBeInTheDocument();
expect(await screen.findByTestId('sidebar-image-conversation-cloud-conversation'))
.toHaveAttribute('aria-current', 'page');
const firstConversation = screen.getByTestId('sidebar-image-conversation-cloud-conversation');
expect(firstConversation).toHaveTextContent('第一条历史消息');
expect(firstConversation).not.toHaveTextContent('主会话');
expect(screen.getByRole('button', { name: '新建设计会话' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '新建设计会话' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/image-workspace/workspaces/cloud-project/conversations',
expect.objectContaining({ method: 'POST' }),
));
expect(await screen.findByTestId('sidebar-image-conversation-cloud-conversation-two'))
.toHaveAttribute('aria-current', 'page');
const secondConversationButton = screen.getByTestId('sidebar-image-conversation-cloud-conversation-two');
expect(secondConversationButton).toHaveTextContent('第二条历史消息');
expect(secondConversationButton).not.toHaveTextContent('第二会话');
fireEvent.click(screen.getByTestId('sidebar-image-conversation-cloud-conversation'));
await waitFor(() => expect(screen.getByTestId('sidebar-image-conversation-cloud-conversation'))
.toHaveAttribute('aria-current', 'page'));
fireEvent.click(screen.getByTestId('sidebar-create-image-project'));
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
expect(screen.getByLabelText('项目名称')).toBeInTheDocument();
expect(screen.queryByLabelText('项目路径')).not.toBeInTheDocument();
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '角色设定' } });
fireEvent.click(screen.getByRole('button', { name: '创建项目' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/image-workspace/workspaces',
expect.objectContaining({ method: 'POST' }),
));
const createCall = hostApiFetchMock.mock.calls.find(
([path, init]) => path === '/api/works/image-workspace/workspaces'
&& (init as RequestInit | undefined)?.method === 'POST',
);
expect(JSON.parse(String((createCall?.[1] as RequestInit).body))).toMatchObject({
title: '角色设定',
clientWorkspaceId: expect.stringMatching(/^workspace-/),
});
});
it('keeps identical project copy when Main injects the local development adapter', async () => {
window.electron.imageWorkspaceLocalDevelopment = true;
const summary = {
workspaceId: 'local-project',
title: '概念设计',
viewRevision: 0,
conversationCount: 1,
phase: 'shaping',
updatedAt: '2026-07-31T10:00:00Z',
};
const conversation = {
conversationId: 'local-conversation',
workspaceId: 'local-project',
title: '主会话',
turnRevision: 0,
phase: 'shaping',
brief: {
version: 0,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
createdAt: '2026-07-31T10:00:00Z',
updatedAt: '2026-07-31T10:00:00Z',
messages: [],
};
const { messages: _messages, ...conversationSummary } = conversation;
let bootstrap = {
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
},
workspaces: [summary],
};
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/works/image-workspace' && !init?.method) {
return { success: true, data: bootstrap };
}
if (path === '/api/works/image-workspace/workspaces/local-project' && !init?.method) {
return { success: true, data: { ...summary, conversations: [conversationSummary] } };
}
if (path === '/api/works/image-workspace/workspaces/local-project/conversations/local-conversation') {
return { success: true, data: conversation };
}
if (path === '/api/works/image-workspace/workspaces/local-project/tasks') {
return { success: true, data: [] };
}
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
const created = {
...summary,
workspaceId: 'local-project-two',
title: '角色设计',
conversations: [{
...conversationSummary,
workspaceId: 'local-project-two',
conversationId: 'local-conversation-two',
}],
};
bootstrap = { ...bootstrap, workspaces: [created, ...bootstrap.workspaces] };
return { success: true, data: created };
}
if (path === '/api/works/image-workspace/workspaces/local-project-two/conversations/local-conversation-two') {
return {
success: true,
data: {
...conversation,
workspaceId: 'local-project-two',
conversationId: 'local-conversation-two',
},
};
}
throw new Error(`Unexpected path ${path}`);
});
const { container } = render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
expect(await screen.findByTestId('sidebar-image-project-local-project')).toHaveTextContent('概念设计');
expect(screen.getByTestId('sidebar-create-image-project')).toHaveTextContent('新建设计项目');
expect(screen.getByTestId('sidebar-image-projects')).toBeInTheDocument();
expect(container).not.toHaveTextContent('本地开发');
expect(container).not.toHaveTextContent('本地项目');
expect(screen.queryByTestId('sidebar-reset-local-image-workspace')).not.toBeInTheDocument();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
fireEvent.click(screen.getByTestId('sidebar-create-image-project'));
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '角色设计' } });
fireEvent.click(screen.getByRole('button', { name: '创建项目' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/image-workspace/workspaces',
expect.objectContaining({ method: 'POST' }),
));
expect(await screen.findByText('角色设计')).toBeInTheDocument();
});
it('keeps older design conversations behind a more-conversations disclosure', () => {
const conversations: DesignConversationSummary[] = Array.from({ length: 7 }, (_, index) => ({
conversationId: `history-conversation-${index + 1}`,
workspaceId: 'history-project',
title: `历史会话 ${index + 1}`,
latestMessagePreview: `历史消息 ${index + 1}`,
turnRevision: index,
phase: 'shaping',
brief: {
version: index,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
createdAt: `2026-07-31T10:${String(6 - index).padStart(2, '0')}:00Z`,
updatedAt: `2026-07-31T10:${String(6 - index).padStart(2, '0')}:00Z`,
}));
const workspace: DesignWorkspace = {
workspaceId: 'history-project',
title: '历史设计项目',
viewRevision: 7,
conversationCount: conversations.length,
phase: 'shaping',
updatedAt: '2026-07-31T10:06:00Z',
conversations,
};
useImageWorkspaceStore.setState({
status: 'ready',
bootstrap: {
capabilities: { conversation: true, generation: true, image: true, video: true },
workspaces: [{
workspaceId: workspace.workspaceId,
title: workspace.title,
viewRevision: workspace.viewRevision,
conversationCount: workspace.conversationCount,
phase: workspace.phase,
updatedAt: workspace.updatedAt,
}],
},
activeWorkspaceId: workspace.workspaceId,
activeConversationId: conversations[0].conversationId,
workspace,
});
render(<MemoryRouter><ImageWorkspaceSidebar sidebarCollapsed={false} /></MemoryRouter>);
expect(screen.getByTestId('sidebar-image-workspace')).toHaveClass('font-sans');
expect(screen.getByTestId('sidebar-image-conversation-history-conversation-1'))
.toHaveTextContent('历史消息 1');
expect(screen.getByTestId('sidebar-image-more-conversations-history-project'))
.toHaveAttribute('data-state', 'closed');
fireEvent.click(screen.getByRole('button', { name: '更多会话2' }));
expect(screen.getByTestId('sidebar-image-more-conversations-history-project'))
.toHaveAttribute('data-state', 'open');
expect(screen.getByTestId('sidebar-image-conversation-history-conversation-7'))
.toHaveTextContent('历史消息 7');
});
it('opens a delete confirmation without deleting immediately', () => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
expect(deleteProject).not.toHaveBeenCalled();
expect(within(dialog).getByText(/删除后,项目/))
.toBeInTheDocument();
expect(within(dialog).getByText(/未提交的任务会被取消并释放预留积分/)).toBeInTheDocument();
expect(within(dialog).getByText(/已提交或运行中的任务会继续后台结算/)).toBeInTheDocument();
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeDisabled();
});
it('requires the trimmed project name to match exactly before deletion', () => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
const input = within(dialog).getByLabelText(`输入项目名称“${DELETE_PROJECT_SUMMARY.title}”以确认删除`);
fireEvent.change(input, { target: { value: '待删除的概念' } });
expect(within(dialog).getByText('项目名称必须完全匹配。')).toBeInTheDocument();
const confirm = within(dialog).getByTestId('confirm-delete-image-project');
expect(confirm).toBeDisabled();
fireEvent.click(confirm);
expect(deleteProject).not.toHaveBeenCalled();
});
it('deletes only after an exact trimmed-name confirmation', async () => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
fireEvent.change(
within(dialog).getByTestId('delete-image-project-name-input'),
{ target: { value: ` ${DELETE_PROJECT_SUMMARY.title} ` } },
);
const confirm = within(dialog).getByTestId('confirm-delete-image-project');
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
await waitFor(() => expect(deleteProject).toHaveBeenCalledTimes(1));
expect(deleteProject).toHaveBeenCalledWith(DELETE_PROJECT_SUMMARY.workspaceId);
await waitFor(() => expect(screen.queryByTestId('delete-image-project-dialog')).not.toBeInTheDocument());
});
it.each(['queued', 'running'] as const)(
'allows deletion while the active project has a %s task',
async (taskStatus) => {
const deleteProject = vi.fn(async (workspaceId: string) => ({
workspaceId,
deleted: true as const,
}));
seedDeleteProjectState(deleteProject, taskStatus);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
expect(within(dialog).getByText(/未提交的任务会被取消并释放预留积分/)).toBeInTheDocument();
const input = within(dialog).getByTestId('delete-image-project-name-input');
expect(input).toBeEnabled();
fireEvent.change(input, { target: { value: DELETE_PROJECT_SUMMARY.title } });
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeEnabled();
fireEvent.click(within(dialog).getByTestId('confirm-delete-image-project'));
await waitFor(() => expect(deleteProject).toHaveBeenCalledWith(
DELETE_PROJECT_SUMMARY.workspaceId,
));
},
);
it('keeps the confirmation open and surfaces a deletion error', async () => {
const deleteProject = vi.fn(async () => {
throw new Error('云端暂时无法删除该项目');
});
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
fireEvent.change(
within(dialog).getByTestId('delete-image-project-name-input'),
{ target: { value: DELETE_PROJECT_SUMMARY.title } },
);
fireEvent.click(within(dialog).getByTestId('confirm-delete-image-project'));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('云端暂时无法删除该项目');
expect(screen.getByTestId('delete-image-project-dialog')).toBeInTheDocument();
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeEnabled();
});
it('locks every dismissal and editing control while deletion is pending', async () => {
let resolveDelete!: (result: DesignDeleteWorkspaceResult) => void;
const pendingDelete = new Promise<DesignDeleteWorkspaceResult>((resolve) => {
resolveDelete = resolve;
});
const deleteProject = vi.fn((workspaceId: string) => {
useImageWorkspaceStore.setState({ deletingWorkspaceId: workspaceId });
return pendingDelete.finally(() => {
useImageWorkspaceStore.setState({ deletingWorkspaceId: null });
});
});
seedDeleteProjectState(deleteProject);
renderDeleteProjectSidebar();
const dialog = openDeleteProjectDialog();
const input = within(dialog).getByTestId('delete-image-project-name-input');
fireEvent.change(input, { target: { value: DELETE_PROJECT_SUMMARY.title } });
fireEvent.click(within(dialog).getByTestId('confirm-delete-image-project'));
await waitFor(() => expect(within(dialog).getByTestId('confirm-delete-image-project'))
.toHaveTextContent('正在删除'));
expect(input).toBeDisabled();
expect(within(dialog).getByRole('button', { name: '取消' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: '关闭删除项目对话框' })).toBeDisabled();
expect(within(dialog).getByTestId('confirm-delete-image-project')).toBeDisabled();
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(screen.getByTestId('delete-image-project-dialog')).toBeInTheDocument();
await act(async () => {
resolveDelete({ workspaceId: DELETE_PROJECT_SUMMARY.workspaceId, deleted: true });
await pendingDelete;
});
await waitFor(() => expect(screen.queryByTestId('delete-image-project-dialog')).not.toBeInTheDocument());
});
it('keeps Canvas entry actions left-aligned and gives the active inspiration page feedback', () => {
useImageWorkspaceStore.setState({ status: 'ready' });
render(
<MemoryRouter initialEntries={['/image-prompts']}>
<ImageWorkspaceSidebar sidebarCollapsed={false} />
</MemoryRouter>,
);
const inspiration = screen.getByTestId('sidebar-open-image-prompt-museum');
const createProject = screen.getByTestId('sidebar-create-image-project');
expect(inspiration).toHaveClass('justify-start', 'focus-visible:ring-brand/35');
expect(createProject).toHaveClass('justify-start', 'focus-visible:ring-brand/35');
expect(inspiration).toHaveAttribute('aria-current', 'page');
expect(inspiration).toHaveAttribute('title', '获取灵感');
expect(createProject).toHaveAttribute('title', '新建设计项目');
});
});