feat: 完善图像工作区与创作工具体验
This commit is contained in:
@@ -12,12 +12,20 @@ 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, DesignWorkspace } from '../../shared/image-workspace';
|
||||
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) => ({
|
||||
@@ -26,6 +34,69 @@ vi.mock('@/lib/user-avatar', async (importOriginal) => ({
|
||||
}));
|
||||
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();
|
||||
@@ -35,6 +106,7 @@ describe('Sidebar project initialization flow', () => {
|
||||
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',
|
||||
@@ -930,4 +1002,159 @@ describe('Sidebar project initialization flow', () => {
|
||||
.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', '新建设计项目');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user