feat(canvas): preview generated task images in app

This commit is contained in:
2026-09-15 14:58:21 +08:00
parent 80e52496d2
commit 41664b2ae3
6 changed files with 357 additions and 3 deletions

View File

@@ -0,0 +1,126 @@
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(<DesignHistoryRail workspace={workspace} />);
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(<DesignHistoryRail workspace={workspace} />);
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(<DesignHistoryRail workspace={completedWorkspace()} />);
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(<DesignHistoryRail workspace={workspace} />);
expandTask();
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
await screen.findByRole('img', { name: 'AI 作品大图' });
rerender(<DesignHistoryRail workspace={{ ...workspace, workspace: { ...workspace.workspace, workspaceId: 'workspace-2' } }} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});