/* @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>; 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, })); 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> = {}, ) { return render(); } 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( , ); 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('读取项目失败')); 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, }); }); });