Files
Genarrative/src/components/project/ProjectGalleryView.test.tsx
T
kdletters dfe6bafb5e 接入创作主页画布直达引导
创作工具卡片接入画布生成器直达意图

新建空白画布增加工具栏引导并在生成面板打开后关闭

暂未开放工具复用移动端欢迎弹窗提示

补充主页、项目入口和画布启动意图测试

同步创作主页入口改版文档
2026-06-30 18:13:23 +08:00

439 lines
14 KiB
TypeScript

/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps, ContextType } from 'react';
import { afterEach, beforeEach, 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());
const readEditorProjectCoverCacheMock = 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,
}));
vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({
readEditorProjectCoverCache: readEditorProjectCoverCacheMock,
}));
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',
},
];
function renderProjectGalleryView(
props: Partial<ComponentProps<typeof ProjectGalleryView>> = {},
) {
return render(<ProjectGalleryView onOpenProject={vi.fn()} {...props} />);
}
describe('ProjectGalleryView', () => {
beforeEach(() => {
readEditorProjectCoverCacheMock.mockResolvedValue(new Map());
URL.createObjectURL = vi.fn(() => 'blob:project-cover-cache');
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
listEditorProjectsMock.mockReset();
createEditorProjectMock.mockReset();
renameEditorProjectMock.mockReset();
deleteEditorProjectMock.mockReset();
readEditorProjectCoverCacheMock.mockReset();
});
it('opens a project from the gallery card', async () => {
const onOpenProject = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
renderProjectGalleryView({ onOpenProject });
await screen.findByText('角色设定板');
await user.click(
screen.getByRole('button', { name: '打开项目角色设定板' }),
);
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
it('uses the saved project cover snapshot resource 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',
},
{
resourceId: 'resource-cover',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,cover',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: 'project-cover-snapshot',
updatedAt: '2026-06-12T09:00:00.000Z',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
renderProjectGalleryView();
await screen.findByText('封面项目');
expect(document.querySelector('.project-gallery__canvas-cover')).toBeNull();
expect(
document
.querySelector('.project-gallery__preview img')
?.getAttribute('src'),
).toBe('data:image/png;base64,cover');
});
it('falls back to a visible canvas layer image when no cover snapshot exists', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-layer-cover',
title: '图层封面项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-back',
resourceId: 'resource-back',
title: '背景图',
zIndex: 1,
},
{
layerId: 'layer-front',
resourceId: 'resource-front',
title: '前景图',
zIndex: 2,
},
],
resources: [
{
resourceId: 'resource-front',
projectId: 'editor-project-layer-cover',
imageSrc: 'data:image/png;base64,front',
width: 160,
height: 120,
sourceType: 'uploaded',
},
{
resourceId: 'resource-back',
projectId: 'editor-project-layer-cover',
imageSrc: 'data:image/png;base64,back',
width: 300,
height: 180,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
renderProjectGalleryView();
await screen.findByText('图层封面项目');
expect(
document
.querySelector('.project-gallery__preview img')
?.getAttribute('src'),
).toBe('data:image/png;base64,front');
});
it('uses locally cached cover snapshots before falling back to canvas layers', async () => {
readEditorProjectCoverCacheMock.mockResolvedValueOnce(
new Map([
[
'editor-project-local-cover',
{
projectId: 'editor-project-local-cover',
blob: new Blob(['cover'], { type: 'image/png' }),
updatedAt: '2026-06-12T09:30:00.000Z',
cachedAt: Date.now(),
},
],
]),
);
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-local-cover',
title: '本地封面项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-front',
resourceId: 'resource-front',
title: '前景图',
zIndex: 2,
},
],
resources: [
{
resourceId: 'resource-front',
projectId: 'editor-project-local-cover',
imageSrc: 'data:image/png;base64,front',
width: 160,
height: 120,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
renderProjectGalleryView();
await screen.findByText('本地封面项目');
await waitFor(() => {
expect(
document
.querySelector('.project-gallery__preview img')
?.getAttribute('src'),
).toBe('blob:project-cover-cache');
});
expect(readEditorProjectCoverCacheMock).toHaveBeenCalledWith([
'editor-project-local-cover',
]);
});
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('读取项目失败'));
renderProjectGalleryView();
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();
renderProjectGalleryView();
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();
renderProjectGalleryView();
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();
renderProjectGalleryView({ 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', {
guide: true,
});
});
});