216 lines
9.6 KiB
TypeScript
216 lines
9.6 KiB
TypeScript
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 <output aria-label="当前页面">{location.pathname}</output>;
|
|
}
|
|
|
|
function renderSidebar({ collapsed = false, hidden = false } = {}) {
|
|
return render(
|
|
<MemoryRouter initialEntries={['/module-select']}>
|
|
<aside hidden={hidden}>
|
|
<ImageWorkspaceSidebar sidebarCollapsed={collapsed} />
|
|
</aside>
|
|
<LocationProbe />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|