/* @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>; function createAuthValue(overrides: Partial = {}): 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(); 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(); 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( , ); 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( , ); 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(); 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(); 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(); 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(); 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'); }); });