新增画布素材导出能力并补充 JSZip 依赖 优化素材上传占位进度、拖拽添加和文件夹移动交互 接入未登录项目访问弹窗并完善项目卡片封面缩略图 补充图片画布与项目页回归测试
297 lines
9.8 KiB
TypeScript
297 lines
9.8 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import type { ContextType } from 'react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ApiClientError } from '../../services/apiClient';
|
|
import { AuthUiContext } from '../auth/AuthUiContext';
|
|
import { ProjectGalleryView } from './ProjectGalleryView';
|
|
|
|
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
|
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
|
|
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
|
|
|
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
|
return {
|
|
user: null,
|
|
canAccessProtectedData: false,
|
|
openLoginModal: vi.fn(),
|
|
requireAuth: vi.fn((action: () => void) => action()),
|
|
openSettingsModal: vi.fn(),
|
|
openAccountModal: vi.fn(),
|
|
setCurrentUser: vi.fn(),
|
|
logout: vi.fn(),
|
|
musicVolume: 0.5,
|
|
setMusicVolume: vi.fn(),
|
|
platformTheme: 'light' as const,
|
|
setPlatformTheme: vi.fn(),
|
|
isHydratingSettings: false,
|
|
isPersistingSettings: false,
|
|
settingsError: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
|
listEditorProjects: listEditorProjectsMock,
|
|
createEditorProject: createEditorProjectMock,
|
|
renameEditorProject: renameEditorProjectMock,
|
|
deleteEditorProject: deleteEditorProjectMock,
|
|
}));
|
|
|
|
const projectItems = [
|
|
{
|
|
projectId: 'editor-project-1',
|
|
title: '角色设定板',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [{ layerId: 'layer-1', resourceId: 'resource-1' }],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-1',
|
|
projectId: 'editor-project-1',
|
|
imageSrc: 'data:image/png;base64,one',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T08:00:00.000Z',
|
|
},
|
|
{
|
|
projectId: 'editor-project-2',
|
|
title: '场景草图',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T07:00:00.000Z',
|
|
},
|
|
];
|
|
|
|
describe('ProjectGalleryView', () => {
|
|
afterEach(() => {
|
|
listEditorProjectsMock.mockReset();
|
|
createEditorProjectMock.mockReset();
|
|
renameEditorProjectMock.mockReset();
|
|
deleteEditorProjectMock.mockReset();
|
|
});
|
|
|
|
it('opens a project from the gallery card', async () => {
|
|
const onOpenProject = vi.fn();
|
|
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectGalleryView onOpenProject={onOpenProject} />);
|
|
|
|
await screen.findByText('角色设定板');
|
|
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
|
|
|
|
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
|
|
});
|
|
|
|
it('uses canvas-center layer composition as the project cover', async () => {
|
|
listEditorProjectsMock.mockResolvedValueOnce([
|
|
{
|
|
projectId: 'editor-project-cover',
|
|
title: '封面项目',
|
|
viewport: { x: 120, y: -60, scale: 0.8 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-cover-back',
|
|
resourceId: 'resource-back',
|
|
title: '背景图',
|
|
x: 200,
|
|
y: 160,
|
|
width: 300,
|
|
height: 180,
|
|
zIndex: 1,
|
|
},
|
|
{
|
|
layerId: 'layer-cover-front',
|
|
resourceId: 'resource-front',
|
|
title: '前景图',
|
|
x: 420,
|
|
y: 260,
|
|
width: 160,
|
|
height: 120,
|
|
zIndex: 2,
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-front',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: 'data:image/png;base64,front',
|
|
width: 160,
|
|
height: 120,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
resourceId: 'resource-back',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: 'data:image/png;base64,back',
|
|
width: 300,
|
|
height: 180,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T08:00:00.000Z',
|
|
},
|
|
]);
|
|
|
|
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
|
|
|
await screen.findByText('封面项目');
|
|
const cover = document.querySelector('.project-gallery__canvas-cover');
|
|
const coverLayers = document.querySelectorAll(
|
|
'.project-gallery__canvas-cover-layer',
|
|
);
|
|
|
|
expect(cover).toBeTruthy();
|
|
expect(coverLayers).toHaveLength(2);
|
|
expect(
|
|
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
|
|
).toBe('data:image/png;base64,back');
|
|
expect((coverLayers[0] as HTMLElement).style.zIndex).toBe('1');
|
|
expect((coverLayers[1] as HTMLElement).style.zIndex).toBe('2');
|
|
});
|
|
|
|
it('opens the login modal when project list loading is unauthorized', async () => {
|
|
const openLoginModal = vi.fn();
|
|
listEditorProjectsMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '未授权访问',
|
|
status: 401,
|
|
code: 'UNAUTHORIZED',
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
|
|
<ProjectGalleryView onOpenProject={vi.fn()} />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
});
|
|
|
|
it('requires login before opening a project card while logged out', async () => {
|
|
const onOpenProject = vi.fn();
|
|
const requireAuth = vi.fn();
|
|
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
|
|
<ProjectGalleryView onOpenProject={onOpenProject} />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
await screen.findByText('角色设定板');
|
|
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
|
|
|
|
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
|
|
expect(onOpenProject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders project loading errors through the shared status message', async () => {
|
|
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));
|
|
|
|
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
|
|
|
const alert = await screen.findByRole('alert');
|
|
|
|
expect(alert.textContent).toContain('读取项目失败');
|
|
expect(alert.className).toContain('platform-status-message');
|
|
expect(alert.className).toContain('project-gallery__error');
|
|
});
|
|
|
|
it('renames and deletes a project from the hover menu', async () => {
|
|
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
|
renameEditorProjectMock.mockResolvedValueOnce({
|
|
...projectItems[0],
|
|
title: '新角色设定板',
|
|
});
|
|
deleteEditorProjectMock.mockResolvedValueOnce('editor-project-2');
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
|
|
|
await screen.findByText('角色设定板');
|
|
await user.click(screen.getByRole('button', { name: '打开项目角色设定板菜单' }));
|
|
await user.click(screen.getByRole('menuitem', { name: /重命名/u }));
|
|
await user.clear(screen.getByLabelText('项目名称'));
|
|
await user.type(screen.getByLabelText('项目名称'), '新角色设定板');
|
|
await user.click(screen.getByRole('button', { name: '保存' }));
|
|
|
|
expect(renameEditorProjectMock).toHaveBeenCalledWith(
|
|
'editor-project-1',
|
|
'新角色设定板',
|
|
);
|
|
await screen.findByText('新角色设定板');
|
|
|
|
await user.click(screen.getByRole('button', { name: '打开项目场景草图菜单' }));
|
|
await user.click(screen.getByRole('menuitem', { name: /删除/u }));
|
|
|
|
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('场景草图')).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('supports batch selection actions from the bottom toolbar', async () => {
|
|
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
|
deleteEditorProjectMock.mockResolvedValue('deleted');
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
|
|
|
await screen.findByText('角色设定板');
|
|
await user.click(screen.getByRole('button', { name: '选择' }));
|
|
const toolbar = screen.getByRole('toolbar', { name: '批量操作' });
|
|
|
|
await user.click(within(toolbar).getByRole('button', { name: '全选' }));
|
|
|
|
expect(
|
|
within(toolbar).getByRole('button', { name: '取消全选 · 已选 2' }),
|
|
).toBeTruthy();
|
|
|
|
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
|
|
|
|
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
|
|
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
|
|
});
|
|
|
|
it('renders the empty project action with shared empty state chrome', async () => {
|
|
const onOpenProject = vi.fn();
|
|
listEditorProjectsMock.mockResolvedValueOnce([]);
|
|
createEditorProjectMock.mockResolvedValueOnce({
|
|
...projectItems[0],
|
|
projectId: 'editor-project-new',
|
|
});
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectGalleryView onOpenProject={onOpenProject} />);
|
|
|
|
const newProjectButton = await screen.findByRole('button', {
|
|
name: '新建项目',
|
|
});
|
|
|
|
expect(newProjectButton.className).toContain('platform-empty-state');
|
|
expect(newProjectButton.className).toContain('project-gallery__new-card');
|
|
|
|
await user.click(newProjectButton);
|
|
|
|
expect(createEditorProjectMock).toHaveBeenCalledTimes(1);
|
|
expect(onOpenProject).toHaveBeenCalledWith('editor-project-new');
|
|
});
|
|
});
|