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 { DesignHistoryRail } from '@/pages/ImageCanvas/DesignHistoryRail'; import type { DesignAsset } from '../../shared/image-workspace'; import { designQuoteFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2'; vi.mock('@/lib/image-workspace', () => ({ resolveImageWorkspaceAssetUrl: vi.fn(), saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }), })); function assetFixture(assetId: string, mediaType: DesignAsset['mediaType'] = 'image'): DesignAsset { return { assetId, workspaceId: 'workspace-1', role: 'generated', mediaType, mimeType: mediaType === 'image' ? 'image/png' : 'video/mp4', width: 1600, height: 2400, durationMilliseconds: null, generationTaskId: 'task-1', createdAt: '2026-09-15T06:00:00.000Z', contentPath: `/api/works/image-workspace/workspaces/workspace-1/assets/${assetId}/content`, }; } function completedWorkspace(assets = [assetFixture('image-1')]) { const quote = designQuoteFixture(); return designWorkspaceFixture({ assets, tasks: [{ taskId: 'task-1', quoteId: quote.quoteId, status: 'succeeded', taskRevision: 2, specificationRevision: quote.specificationRevision, specificationRevisionId: quote.specificationRevisionId, specificationDigest: quote.specificationDigest, compilerVersion: quote.compilerVersion, medium: 'image', outputSummary: { ...quote.outputSummary, outputCount: assets.length }, maximumCustomerChargeAtoms: 1200, customerBilling: { quotedAtoms: 1200, heldAtoms: 0, chargedAtoms: 1200, refundedAtoms: 0, pendingResolutionAtoms: 0 }, customerHoldExpiresAt: quote.expiresAt, resolutionDeadlineAt: null, progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 }, executionHealth: 'normal', cancellation: { state: 'unavailable', outcome: null }, issues: [], resultAssetIds: assets.map((asset) => asset.assetId), nextActions: ['view_result'], createdAt: '2026-09-15T06:00:00.000Z', }], }); } function expandTask() { screen.getByTestId('design-plan-history-task-1').setAttribute('open', ''); } describe('AI Design task image preview', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(resolveImageWorkspaceAssetUrl).mockImplementation(async (path) => `http://localhost${path}`); }); it('opens the selected result without downloading and restores fit mode when reopened', async () => { const workspace = completedWorkspace([assetFixture('image-1'), assetFixture('image-2'), assetFixture('video-1', 'video')]); render(); expandTask(); const buttons = screen.getAllByRole('button', { name: '放大查看图片' }); expect(buttons).toHaveLength(2); expect(screen.getAllByRole('button', { name: '保存作品到电脑' })).toHaveLength(3); fireEvent.click(buttons[1]); const dialog = screen.getByRole('dialog', { name: '图片预览' }); const image = await within(dialog).findByRole('img', { name: 'AI 作品大图' }); expect(image).toHaveAttribute('src', `http://localhost${workspace.assets[1].contentPath}`); expect(image).toHaveClass('object-contain'); expect(within(dialog).getByRole('status')).toHaveTextContent('正在加载图片'); fireEvent.load(image); fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' })); expect(image).toHaveClass('max-w-none'); fireEvent.click(within(dialog).getByRole('button', { name: '适应窗口' })); expect(image).toHaveClass('object-contain'); fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' })); fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片预览' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); fireEvent.click(buttons[0]); const reopenedImage = await screen.findByRole('img', { name: 'AI 作品大图' }); expect(reopenedImage).toHaveAttribute('src', `http://localhost${workspace.assets[0].contentPath}`); expect(reopenedImage).toHaveClass('object-contain'); expect(saveImageWorkspaceAsset).not.toHaveBeenCalled(); }); it('closes with Escape and keeps the existing download action independent', async () => { const workspace = completedWorkspace(); render(); expandTask(); const trigger = screen.getByRole('button', { name: '放大查看图片' }); fireEvent.click(trigger); await screen.findByRole('img', { name: 'AI 作品大图' }); fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(trigger).toHaveFocus(); fireEvent.click(screen.getByRole('button', { name: '保存作品到电脑' })); expect(saveImageWorkspaceAsset).toHaveBeenCalledExactlyOnceWith(workspace.assets[0]); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it.each(['url', 'image'] as const)('shows a recoverable message for %s loading failures', async (failure) => { render(); expandTask(); if (failure === 'url') { vi.mocked(resolveImageWorkspaceAssetUrl).mockRejectedValueOnce(new Error('unavailable')); } fireEvent.click(screen.getByRole('button', { name: '放大查看图片' })); if (failure === 'image') fireEvent.error(await screen.findByRole('img', { name: 'AI 作品大图' })); expect(await screen.findByRole('alert')).toHaveTextContent('图片暂时无法加载,请关闭后重试'); expect(screen.getByRole('button', { name: '原始尺寸' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '关闭图片预览' })); fireEvent.click(screen.getByRole('button', { name: '放大查看图片' })); fireEvent.load(await screen.findByRole('img', { name: 'AI 作品大图' })); expect(screen.getByRole('button', { name: '原始尺寸' })).toBeEnabled(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('removes the open preview when switching design projects', async () => { const workspace = completedWorkspace(); const { rerender } = render(); expandTask(); fireEvent.click(screen.getByRole('button', { name: '放大查看图片' })); await screen.findByRole('img', { name: 'AI 作品大图' }); rerender(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); });