import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ImageWorkspaceSidebar } from '@/components/layout/ImageWorkspaceSidebar'; import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; import { useAuthStore } from '@/stores/auth'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import { designBootstrapFixture, designSummaryFixture, designWorkspaceFixture, } from '../fixtures/design-workspace-v2'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); function setViewport(desktop: boolean) { vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ matches: desktop, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })); } function prepareProjects() { const workspace = designWorkspaceFixture(); const target = designSummaryFixture({ workspaceId: 'workspace-other', clientWorkspaceId: 'client-workspace-other', directionId: 'direction-other', sessionId: 'session-other', title: '月球熊猫海报', updatedAt: '2026-09-01T10:00:00.000Z', }); const actions = { load: vi.fn().mockResolvedValue(undefined), createProject: vi.fn().mockResolvedValue(workspace), renameProject: vi.fn().mockResolvedValue(workspace), deleteProject: vi.fn().mockResolvedValue({ workspaceId: target.workspaceId, deleted: true }), selectProject: vi.fn().mockResolvedValue(undefined), }; useAuthStore.setState({ isAuthenticated: () => true }); useImageWorkspaceStore.setState({ status: 'ready', bootstrap: { ...designBootstrapFixture(workspace.workspace), workspaces: [workspace.workspace, target], }, activeWorkspaceId: workspace.workspace.workspaceId, workspace, deletingWorkspaceId: null, error: null, ...actions, }); return { workspace, target, actions }; } function LocationProbe() { const location = useLocation(); return {location.pathname}; } function renderSidebar({ collapsed = false, hidden = false } = {}) { return render( , ); } async function openProjectMenu(title: string) { const trigger = screen.getByRole('button', { name: `项目操作:${title}` }); fireEvent.keyDown(trigger, { key: 'Enter' }); return screen.findByRole('menu'); } describe('ImageWorkspaceSidebar', () => { beforeEach(() => { setViewport(true); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); it('opens one creation dialog from the collapsed sidebar and submits the trimmed title', async () => { const { actions } = prepareProjects(); renderSidebar({ collapsed: true }); fireEvent.click(screen.getByRole('button', { name: '新建设计项目' })); const dialog = screen.getByRole('dialog', { name: '新建设计项目' }); expect(screen.getAllByRole('dialog')).toHaveLength(1); fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), { target: { value: ' 小狗的快乐短片 ' }, }); fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' })); await waitFor(() => expect(actions.createProject).toHaveBeenCalledExactlyOnceWith('小狗的快乐短片')); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas'); expect(actions.selectProject).not.toHaveBeenCalled(); expect(actions.renameProject).not.toHaveBeenCalled(); expect(actions.deleteProject).not.toHaveBeenCalled(); }); it('ignores compact creation events while the desktop sidebar is hidden', () => { const { actions } = prepareProjects(); setViewport(false); renderSidebar({ hidden: true }); act(() => window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT))); expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(0); expect(actions.createProject).not.toHaveBeenCalled(); expect(actions.selectProject).not.toHaveBeenCalled(); expect(screen.getByLabelText('当前页面')).toHaveTextContent('/module-select'); }); it('handles desktop creation events with a single dialog', () => { prepareProjects(); renderSidebar(); act(() => window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT))); expect(screen.getAllByRole('dialog')).toHaveLength(1); expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument(); }); it('renames an unselected project through its keyboard menu without switching projects', async () => { const { workspace, target, actions } = prepareProjects(); renderSidebar(); const menu = await openProjectMenu(target.title); expect(within(menu).getByRole('menuitem', { name: '删除项目' })).toBeInTheDocument(); fireEvent.click(within(menu).getByRole('menuitem', { name: '重命名' })); const dialog = await screen.findByRole('dialog', { name: '重命名设计项目' }); const name = within(dialog).getByRole('textbox', { name: '项目名称' }); expect(name).toHaveValue(target.title); expect(actions.selectProject).not.toHaveBeenCalled(); expect(screen.getByLabelText('当前页面')).toHaveTextContent('/module-select'); fireEvent.change(name, { target: { value: ' ' } }); fireEvent.click(within(dialog).getByRole('button', { name: '保存名称' })); expect(within(dialog).getByText('请输入项目名称')).toBeInTheDocument(); expect(actions.renameProject).not.toHaveBeenCalled(); fireEvent.change(name, { target: { value: ' 熊猫足球比赛 ' } }); fireEvent.click(within(dialog).getByRole('button', { name: '保存名称' })); await waitFor(() => expect(actions.renameProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId, '熊猫足球比赛')); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe(workspace.workspace.workspaceId); expect(actions.selectProject).not.toHaveBeenCalled(); expect(actions.createProject).not.toHaveBeenCalled(); expect(actions.deleteProject).not.toHaveBeenCalled(); }); it('requires the full project title and deletes the menu target without switching projects', async () => { const { workspace, target, actions } = prepareProjects(); renderSidebar(); const menu = await openProjectMenu(target.title); fireEvent.click(within(menu).getByRole('menuitem', { name: '删除项目' })); const dialog = await screen.findByRole('dialog', { name: '删除设计项目' }); const confirmation = within(dialog).getByRole('textbox', { name: `输入“${target.title}”确认` }); fireEvent.change(confirmation, { target: { value: `${target.title} ` } }); fireEvent.click(within(dialog).getByRole('button', { name: '删除项目' })); expect(within(dialog).getByText('请输入完整项目名称')).toBeInTheDocument(); expect(actions.deleteProject).not.toHaveBeenCalled(); expect(actions.selectProject).not.toHaveBeenCalled(); fireEvent.change(confirmation, { target: { value: target.title } }); fireEvent.click(within(dialog).getByRole('button', { name: '删除项目' })); await waitFor(() => expect(actions.deleteProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId)); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe(workspace.workspace.workspaceId); expect(screen.getByLabelText('当前页面')).toHaveTextContent('/module-select'); expect(actions.selectProject).not.toHaveBeenCalled(); expect(actions.createProject).not.toHaveBeenCalled(); expect(actions.renameProject).not.toHaveBeenCalled(); }); it('shows only project titles under My Projects and keeps recent-first selection by workspace identity', () => { const { workspace, target, actions } = prepareProjects(); renderSidebar(); const sidebar = screen.getByTestId('sidebar-image-workspace'); expect(within(sidebar).getByText('我的项目', { exact: true })).toBeInTheDocument(); expect(within(sidebar).queryByText('设计项目', { exact: true })).not.toBeInTheDocument(); expect(sidebar).not.toHaveTextContent('设计版本'); const activeProject = within(sidebar).getByRole('button', { name: workspace.workspace.title, exact: true }); const newerProject = within(sidebar).getByRole('button', { name: target.title, exact: true }); expect(activeProject.textContent).toBe(workspace.workspace.title); expect(newerProject.textContent).toBe(target.title); expect(activeProject).toHaveAttribute('aria-current', 'page'); // The September project is newer than the active August project, despite // arriving second in bootstrap. Removing its date must not change order. expect(newerProject.compareDocumentPosition(activeProject)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); fireEvent.click(newerProject); expect(actions.selectProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId); expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas'); expect(actions.renameProject).not.toHaveBeenCalled(); expect(actions.deleteProject).not.toHaveBeenCalled(); }); });