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()), 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(); 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(); expect(screen.getByRole('img', { name: 'Makelore logo' })).toBeInTheDocument(); expect(screen.getByText('Makelore 工作台')).toBeInTheDocument(); expect(screen.queryByText(/NITU/)).not.toBeInTheDocument(); expect(screen.getByTestId('sidebar-create-project')).toHaveTextContent('新建项目'); 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(); expect(screen.getByTestId('sidebar-module-switcher')).toHaveTextContent('切换模块'); expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByTestId('sidebar-module-programming')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger')); expect(screen.getByTestId('sidebar-module-programming')).toHaveTextContent('Makelore Code'); expect(screen.getByTestId('sidebar-module-painting')).toHaveTextContent('Makelore Canvas'); expect(screen.queryByTestId('sidebar-nav-image-canvas')).not.toBeInTheDocument(); expect(await screen.findByText(/暂无真实项目/)).toBeInTheDocument(); }); it('places the module switcher above personal profile in the lower-left area', async () => { hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null }); render(); fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger')); const switcher = screen.getByTestId('sidebar-module-switcher'); const profileMenuItem = screen.getByRole('menuitem', { name: '个人资料' }); expect(Boolean(switcher.compareDocumentPosition(profileMenuItem) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); }); it('shows an available update beside the lower-left account control', async () => { hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null }); render(); 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 game project with the selected replacement 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('game-development'); 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(); 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(); expect(screen.queryByTestId('project-template-option-youth-ai-course')).not.toBeInTheDocument(); expect(screen.getByTestId('project-template-option-standard-development')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('project-template-option-game-development')); fireEvent.click(screen.getByRole('button', { name: '确认创建' })); await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ projectPath: 'D:/repo/星星收集器', templateId: 'game-development' }) })); 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('standard-development'); 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(); 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.getByTestId('project-template-option-standard-development')); fireEvent.click(screen.getByRole('button', { name: '确认创建' })); await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ parentPath: 'D:/repo', projectName: '星星收集器', templateId: 'standard-development', }), })); }); 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('standard-development'), 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(); 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('standard-development'); 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(); fireEvent.click(await screen.findByRole('button', { name: `配置项目 ${project.name}` })); expect(await screen.findByTestId('project-template-error')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '移除项目卡片' })); expect(screen.getByRole('dialog', { name: `移除项目“${project.name}”?` })).toBeInTheDocument(); 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 explicit enter and configuration actions', async () => { const project = { id: 'prj_config', path: 'D:/repo/config', name: 'config', createdAt: '', updatedAt: '', lastOpenedAt: '' }; const config = { ...createProjectConfig('standard-development'), initialized: true, agents: createProjectConfig('standard-development').agents.map((agent, index) => ({ ...agent, name: ['小策', '小设', '小码', '小发'][index], avatarId: ['avatar-16', 'avatar-15', 'avatar-14', 'avatar-13'][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(); const card = await screen.findByTestId(`sidebar-course-project-${project.id}`); await waitFor(() => expect(card).toHaveTextContent('普通开发项目')); expect(card).toHaveTextContent(project.name); 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('进入项目'); fireEvent.click(screen.getByRole('button', { name: `配置项目 ${project.name}` })); await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/project-config')); }); it('switches the sidebar to cloud projects and Agents inside the AI painting module', async () => { useAuthStore.setState({ accessToken: 'cloud-token', expiresAt: Date.now() + 60_000, }); const workspace = { activeProjectId: 'cloud-project', capabilities: { modes: [], models: [], aspectRatios: [], resolutions: [], outputCounts: [], maxReferenceImages: 0, referenceUpload: { enabled: false, acceptedMimeTypes: [] }, }, projects: [{ id: 'cloud-project', name: '云端概念设计', activeAgentId: 'cloud-agent', agents: [{ id: 'cloud-agent', name: '概念设计 Agent' }], messages: [], }], }; hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/works/image-workspace') return { success: true, workspace }; if (path === '/api/works/image-workspace/projects' && init?.method === 'POST') { return { success: true, workspace }; } if (path === '/api/works/image-workspace/projects/cloud-project/agents' && init?.method === 'POST') { return { success: true, workspace: { ...workspace, projects: [{ ...workspace.projects[0], agents: [ ...workspace.projects[0].agents, { id: 'cloud-agent-two', name: '新 Agent' }, ], }], }, }; } throw new Error(`Unexpected path ${path}`); }); render(); expect(await screen.findByTestId('sidebar-image-project-cloud-project')).toHaveTextContent('云端概念设计'); expect(screen.getByText('概念设计 Agent')).toBeInTheDocument(); expect(screen.getByTestId('sidebar-create-image-project')).toBeEnabled(); 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); fireEvent.click(screen.getByRole('button', { name: '为 云端概念设计 添加 Agent' })); expect(await screen.findByText('新 Agent')).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/projects', { method: 'POST', headers: { 'X-NianCode-Access-Token': 'cloud-token' }, body: JSON.stringify({ name: '角色设定' }), }, )); }); it('keeps production project copy while the development adapter bypasses cloud login', async () => { window.electron.imageWorkspaceLocalDevelopment = true; const adapterProject = { id: 'local-project', name: '概念设计', activeAgentId: 'local-agent', agents: [{ id: 'local-agent', name: '视觉创作 Agent' }], messages: [], }; let workspace = { activeProjectId: adapterProject.id as string | null, capabilities: { modes: [], models: [], aspectRatios: [], resolutions: [], outputCounts: [], maxReferenceImages: 0, referenceUpload: { enabled: false, acceptedMimeTypes: [] }, }, projects: [adapterProject], }; hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/works/image-workspace' && !init?.method) { return { success: true, workspace }; } if (path === '/api/works/image-workspace/projects' && init?.method === 'POST') { const createdProject = { id: 'local-project-two', name: '角色设计', activeAgentId: 'local-agent-two', agents: [{ id: 'local-agent-two', name: '视觉创作 Agent' }], messages: [], }; workspace = { ...workspace, activeProjectId: createdProject.id, projects: [...workspace.projects, createdProject], }; return { success: true, workspace }; } throw new Error(`Unexpected path ${path}`); }); const { container } = render(); 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/projects', { method: 'POST', body: JSON.stringify({ name: '角色设计' }), }, )); expect(await screen.findByText('角色设计')).toBeInTheDocument(); }); });