需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
521 lines
27 KiB
TypeScript
521 lines
27 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
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';
|
|
|
|
const hostApiFetchMock = vi.fn();
|
|
const invokeIpcMock = vi.fn();
|
|
const navigateMock = vi.fn();
|
|
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('react-router-dom', async (importOriginal) => ({ ...(await importOriginal<typeof import('react-router-dom')>()), useNavigate: () => navigateMock }));
|
|
|
|
describe('Sidebar project initialization flow', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
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();
|
|
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('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', 'bg-surface-sidebar');
|
|
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-expanded', 'false');
|
|
expect(moduleTrigger).toHaveClass('bg-transparent');
|
|
expect(moduleTrigger).toHaveClass('w-fit', 'justify-start', 'gap-1');
|
|
expect(moduleTrigger).not.toHaveClass('bg-brand-soft');
|
|
expect(moduleTrigger).toHaveTextContent('编程 Code');
|
|
expect(screen.queryByTestId('sidebar-module-programming')).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger'));
|
|
const programmingModule = screen.getByTestId('sidebar-module-programming');
|
|
expect(programmingModule).toHaveTextContent('编程 Code');
|
|
expect(programmingModule).toHaveClass('bg-brand-soft');
|
|
expect(programmingModule.querySelector('svg')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('sidebar-module-painting')).toHaveTextContent('绘画 Canvas');
|
|
expect(screen.getByTestId('sidebar-module-painting')).not.toHaveClass('bg-brand-soft');
|
|
expect(screen.getByTestId('sidebar-module-programming')).not.toHaveTextContent('AI');
|
|
expect(screen.getByTestId('sidebar-module-painting')).not.toHaveTextContent('AI');
|
|
expect(screen.getByTestId('sidebar-module-programming')).not.toHaveTextContent('Makelore');
|
|
expect(screen.getByTestId('sidebar-module-painting')).not.toHaveTextContent('Makelore');
|
|
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).toHaveClass('absolute', 'left-0', 'w-full', 'overflow-y-auto');
|
|
expect(menu).toHaveClass('max-h-[calc(100vh-7rem)]');
|
|
expect(menu).not.toHaveClass('fixed');
|
|
|
|
const usageItem = screen.getByTestId('sidebar-account-usage-menuitem');
|
|
expect(usageItem).toHaveClass('min-h-8');
|
|
expect(screen.queryByTestId('sidebar-account-usage-drawer')).not.toBeInTheDocument();
|
|
|
|
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');
|
|
|
|
rerender(
|
|
<MemoryRouter>
|
|
<Sidebar sidebarPeekOpen onSidebarPeekChange={onSidebarPeekChange} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(sidebar).toHaveClass('w-64', 'absolute', 'shadow-float');
|
|
expect(screen.getByTestId('sidebar-create-project')).toHaveTextContent('新建项目');
|
|
fireEvent.pointerEnter(sidebar);
|
|
expect(onSidebarPeekChange).toHaveBeenCalledWith(true);
|
|
fireEvent.pointerLeave(sidebar);
|
|
expect(onSidebarPeekChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
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('creates a path-backed project without selecting a template 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();
|
|
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.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/星星收集器' }) }));
|
|
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();
|
|
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.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: '星星收集器',
|
|
}),
|
|
}));
|
|
});
|
|
|
|
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}` }));
|
|
expect(await screen.findByTestId('project-config-error')).toBeInTheDocument();
|
|
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('shows project identity with an explicit 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}` })).toHaveTextContent('进入项目');
|
|
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: '云端概念设计',
|
|
turnRevision: 0,
|
|
viewRevision: 0,
|
|
phase: 'shaping',
|
|
brief: {
|
|
version: 0,
|
|
status: 'draft',
|
|
medium: null,
|
|
summary: '正在建立作品的视觉方向',
|
|
ready: false,
|
|
missingDecision: '作品形式',
|
|
},
|
|
updatedAt: '2026-07-31T10:00:00Z',
|
|
};
|
|
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, messages: [] } };
|
|
}
|
|
if (path === '/api/works/image-workspace/workspaces/cloud-project/tasks') {
|
|
return { success: true, data: [] };
|
|
}
|
|
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
|
|
return {
|
|
success: true,
|
|
data: { ...summary, workspaceId: 'cloud-project-two', title: '角色设定', messages: [] },
|
|
};
|
|
}
|
|
throw new Error(`Unexpected path ${path}`);
|
|
});
|
|
|
|
render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
|
|
|
|
expect(await screen.findByTestId('sidebar-image-project-cloud-project')).toHaveTextContent('云端概念设计');
|
|
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();
|
|
|
|
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: '概念设计',
|
|
turnRevision: 0,
|
|
viewRevision: 0,
|
|
phase: 'shaping',
|
|
brief: {
|
|
version: 0,
|
|
status: 'draft',
|
|
medium: null,
|
|
summary: '正在建立作品的视觉方向',
|
|
ready: false,
|
|
missingDecision: '作品形式',
|
|
},
|
|
updatedAt: '2026-07-31T10:00:00Z',
|
|
};
|
|
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, messages: [] } };
|
|
}
|
|
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: '角色设计',
|
|
messages: [],
|
|
};
|
|
bootstrap = { ...bootstrap, workspaces: [created, ...bootstrap.workspaces] };
|
|
return { success: true, data: created };
|
|
}
|
|
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();
|
|
});
|
|
|
|
});
|