import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { resolveImageWorkspaceAssetUrl, saveImageWorkspaceAsset } from '@/lib/image-workspace'; import { CanvasWorkspaceSplit } from '@/pages/ImageCanvas/CanvasWorkspaceSplit'; import { DesignCurrentWork } from '@/pages/ImageCanvas/DesignCurrentWork'; import { DesignHistoryRail } from '@/pages/ImageCanvas/DesignHistoryRail'; import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignAsset, DesignGenerationTask } from '../../shared/image-workspace'; import { designCapabilitiesFixture, designQuoteFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2'; vi.mock('@/lib/image-workspace', async (importOriginal) => ({ ...await importOriginal(), resolveImageWorkspaceAssetUrl: vi.fn(), saveImageWorkspaceAsset: vi.fn(), })); function assetFixture(mediaType: DesignAsset['mediaType'] = 'image'): DesignAsset { return { assetId: `asset-${mediaType}`, workspaceId: 'workspace-1', role: 'generated', mediaType, mimeType: mediaType === 'image' ? 'image/png' : 'video/mp4', width: 1024, height: 1024, durationMilliseconds: mediaType === 'video' ? 4000 : null, generationTaskId: 'task-1', createdAt: '2026-10-13T10:00:00.000Z', contentPath: `/api/works/image-workspace/workspaces/workspace-1/assets/asset-${mediaType}/content`, }; } function taskFixture(status: 'queued' | 'running'): DesignGenerationTask { const quote = designQuoteFixture(); return { taskId: 'task-1', quoteId: quote.quoteId, status, taskRevision: 1, specificationRevision: quote.specificationRevision, specificationRevisionId: quote.specificationRevisionId, specificationDigest: quote.specificationDigest, medium: 'image', compilerVersion: quote.compilerVersion, outputSummary: quote.outputSummary, maximumCustomerChargeAtoms: 1200, customerBilling: { quotedAtoms: 1200, heldAtoms: 1200, chargedAtoms: 0, refundedAtoms: 0, pendingResolutionAtoms: 0 }, customerHoldExpiresAt: quote.expiresAt, resolutionDeadlineAt: null, progress: { stage: status === 'queued' ? 'queued' : 'generating', completedRequiredSteps: 0, totalRequiredSteps: 1 }, executionHealth: 'normal', cancellation: { state: 'available', outcome: null }, issues: [], resultAssetIds: [], nextActions: ['cancel'], createdAt: '2026-10-13T10:00:00.000Z', }; } /** JSDOM has no layout engine; model offsetParent from the panel visibility rules. */ function playingVideo(video: HTMLVideoElement, visible: () => boolean) { Object.defineProperty(video, 'paused', { configurable: true, get: () => false }); Object.defineProperty(video, 'offsetParent', { configurable: true, get: () => visible() ? video.parentElement : null, }); return vi.spyOn(video, 'pause').mockImplementation(() => undefined); } describe('Canvas workspace layout regressions', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); useImageWorkspaceStore.getState().reset(); vi.mocked(resolveImageWorkspaceAssetUrl).mockImplementation(async (path) => `blob:canvas-${path}`); vi.mocked(saveImageWorkspaceAsset).mockResolvedValue({ status: 'saved' }); }); it('closes the real portal image dialog with Escape while keeping the works panel expanded', async () => { render(} works={} />); const split = screen.getByTestId('canvas-workspace-split'); fireEvent.click(screen.getByRole('button', { name: '展开作品工作区' })); fireEvent.click(screen.getByRole('button', { name: '放大查看图片' })); const dialog = await screen.findByRole('dialog', { name: '图片预览' }); expect(split.contains(dialog)).toBe(false); await within(dialog).findByRole('img', { name: 'AI 作品大图' }); fireEvent.keyDown(dialog, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(split).toHaveAttribute('data-focus', 'works'); expect(screen.getByRole('button', { name: '放大查看图片' })).toHaveFocus(); expect(saveImageWorkspaceAsset).not.toHaveBeenCalled(); fireEvent.keyDown(screen.getByRole('button', { name: '恢复分栏' }), { key: 'Escape' }); expect(split).toHaveAttribute('data-focus', ''); }); it('respects an Escape event already handled by a child', () => { render( { if (event.key === 'Escape') event.preventDefault(); }} />} works={

作品

} />); fireEvent.click(screen.getByRole('button', { name: '展开创作对话' })); const input = screen.getByRole('textbox', { name: '正在编辑的字段' }); fireEvent.keyDown(input, { key: 'Escape' }); expect(screen.getByTestId('canvas-workspace-split')).toHaveAttribute('data-focus', 'conversation'); fireEvent.keyDown(screen.getByRole('button', { name: '恢复分栏' }), { key: 'Escape' }); expect(screen.getByTestId('canvas-workspace-split')).toHaveAttribute('data-focus', ''); }); it('pauses the real inline video when the history tab hides it', async () => { render(); const video = await screen.findByLabelText('当前视频作品') as HTMLVideoElement; const pause = playingVideo(video, () => !video.closest('[hidden]')); fireEvent.click(screen.getByRole('button', { name: '历史作品' })); expect(video.closest('[hidden]')).not.toBeNull(); expect(pause).toHaveBeenCalledOnce(); fireEvent.click(screen.getByRole('button', { name: '当前作品' })); expect(screen.getByLabelText('当前视频作品')).toBe(video); expect(pause).toHaveBeenCalledOnce(); }); it('pauses the real inline video when the compact switch hides the works panel', async () => { render(} works={} />); fireEvent.click(screen.getByRole('button', { name: '作品与方案' })); const split = screen.getByTestId('canvas-workspace-split'); const video = await screen.findByLabelText('当前视频作品') as HTMLVideoElement; const pause = playingVideo(video, () => split.dataset.compactPanel === 'works'); fireEvent.click(screen.getByRole('button', { name: '创作对话' })); expect(split).toHaveAttribute('data-compact-panel', 'conversation'); expect(pause).toHaveBeenCalledOnce(); fireEvent.click(screen.getByRole('button', { name: '作品与方案' })); expect(screen.getByLabelText('当前视频作品')).toBe(video); expect(pause).toHaveBeenCalledOnce(); }); it('keeps the real creation card mounted and preserves its local prompt draft across tab switches', () => { const workspace = designWorkspaceFixture(); const applyFieldOperations = vi.fn().mockResolvedValue(workspace); useImageWorkspaceStore.setState({ workspace, pendingOperations: {}, applyFieldOperations }); render(} />); const card = screen.getByTestId('youth-creation-card'); const input = screen.getByRole('textbox', { name: '创作提示词' }); fireEvent.change(input, { target: { value: '尚未保存:粉色的云和会飞的小熊' } }); fireEvent.click(screen.getByRole('button', { name: '历史作品' })); expect(card).toBeInTheDocument(); expect(card).not.toBeVisible(); fireEvent.click(screen.getByRole('button', { name: '当前作品' })); expect(screen.getByTestId('youth-creation-card')).toBe(card); expect(screen.getByRole('textbox', { name: '创作提示词' })).toBe(input); expect(input).toHaveValue('尚未保存:粉色的云和会飞的小熊'); expect(applyFieldOperations).not.toHaveBeenCalled(); }); it.each([ ['queued', '已加入制作队列'], ['running', '正在制作,完成后会显示在这里'], ] as const)('shows %s production status instead of the first-creation guide', (status, message) => { render(); expect(screen.queryByRole('heading', { name: '从一个画面开始' })).not.toBeInTheDocument(); const current = screen.getByLabelText('当前作品与制作方案'); expect(within(current).getByRole('status')).toHaveTextContent('作品正在准备中'); expect(within(current).getByRole('status')).toHaveTextContent(message); expect(current).toBeVisible(); }); it.each(['url', 'image'] as const)('recovers from a %s preview failure only on explicit reload and saves only on explicit click', async (failure) => { const asset = assetFixture(); if (failure === 'url') vi.mocked(resolveImageWorkspaceAssetUrl).mockRejectedValueOnce(new Error('preview unavailable')); render(); if (failure === 'image') fireEvent.error(await screen.findByRole('img', { name: '当前图片作品' })); expect(await screen.findByRole('alert')).toHaveTextContent('作品暂时无法加载'); expect(resolveImageWorkspaceAssetUrl).toHaveBeenCalledExactlyOnceWith(asset.contentPath); expect(saveImageWorkspaceAsset).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '保存到电脑' })); expect(saveImageWorkspaceAsset).toHaveBeenCalledExactlyOnceWith(asset); await waitFor(() => expect(screen.getByRole('button', { name: '保存到电脑' })).toBeEnabled()); expect(resolveImageWorkspaceAssetUrl).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: '重新加载' })); const image = await screen.findByRole('img', { name: '当前图片作品' }); fireEvent.load(image); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.queryByText('正在加载作品…')).not.toBeInTheDocument(); expect(resolveImageWorkspaceAssetUrl).toHaveBeenCalledTimes(2); expect(saveImageWorkspaceAsset).toHaveBeenCalledTimes(1); }); });