feat(canvas): preview generated task images in app
This commit is contained in:
@@ -145,7 +145,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
assistantMessage: '明白了:你想做一张适合小红书的便携咖啡机图片。',
|
||||
}],
|
||||
tasks: [] as Array<Record<string, unknown>>,
|
||||
assets: [],
|
||||
assets: [] as Array<Record<string, unknown>>,
|
||||
};
|
||||
const respond = (json: unknown) => ({
|
||||
ok: true,
|
||||
@@ -162,9 +162,29 @@ test.describe('AI Design V2 workspace', () => {
|
||||
const e2eGlobal = globalThis as typeof globalThis & {
|
||||
__designE2eAuthReady?: boolean;
|
||||
__designE2eProfileReady?: boolean;
|
||||
__designE2eCompleteTask?: () => void;
|
||||
__designE2eDownloadCount?: number;
|
||||
};
|
||||
e2eGlobal.__designE2eAuthReady = false;
|
||||
e2eGlobal.__designE2eProfileReady = false;
|
||||
e2eGlobal.__designE2eDownloadCount = 0;
|
||||
e2eGlobal.__designE2eCompleteTask = () => {
|
||||
workspace.tasks[0] = {
|
||||
...workspace.tasks[0],
|
||||
status: 'succeeded',
|
||||
taskRevision: 2,
|
||||
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
|
||||
resultAssetIds: ['result-1'],
|
||||
nextActions: ['view_result'],
|
||||
};
|
||||
workspace.assets = [{
|
||||
assetId: 'result-1', workspaceId: 'workspace-1', role: 'generated',
|
||||
mediaType: 'image', mimeType: 'image/svg+xml', width: 1600, height: 2400,
|
||||
durationMilliseconds: null, generationTaskId: 'task-1',
|
||||
createdAt: '2026-08-30T10:02:00.000Z',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace-1/assets/result-1/content',
|
||||
}];
|
||||
};
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: {
|
||||
@@ -173,6 +193,10 @@ test.describe('AI Design V2 workspace', () => {
|
||||
body?: string | null;
|
||||
}) => {
|
||||
const path = request.path ?? '';
|
||||
if (path.endsWith('/assets/result-1/download')) {
|
||||
e2eGlobal.__designE2eDownloadCount! += 1;
|
||||
return envelope({ status: 'saved' });
|
||||
}
|
||||
if (path === '/api/auth/session/sync') {
|
||||
return respond({ success: true, session: e2eSession });
|
||||
}
|
||||
@@ -465,6 +489,54 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await expect(page.getByText('等待制作')).toBeVisible();
|
||||
await expect(page.getByTestId('youth-creation-card')).toHaveCount(0);
|
||||
await expect(page.getByText('1200', { exact: true })).toHaveCount(0);
|
||||
|
||||
// Complete the existing task without a paid provider or native download.
|
||||
await page.route('**/assets/result-1/content?*', (route) => route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="2400"><rect width="1600" height="2400" fill="#f4efe5"/><rect x="80" y="80" width="1440" height="2240" rx="80" fill="#d6e5dc"/><circle cx="800" cy="1050" r="450" fill="#47665b"/><text x="800" y="1850" text-anchor="middle" font-size="120" fill="#203b30">DESIGN PREVIEW</text></svg>',
|
||||
}));
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & { __designE2eCompleteTask?: () => void }).__designE2eCompleteTask?.();
|
||||
});
|
||||
await page.reload();
|
||||
const historyTask = page.getByTestId('design-plan-history-task-1');
|
||||
await expect(historyTask).toContainText('已完成');
|
||||
await historyTask.locator('summary').click();
|
||||
const previewButton = historyTask.getByRole('button', { name: '放大查看图片' });
|
||||
await previewButton.click();
|
||||
const previewDialog = page.getByRole('dialog', { name: '图片预览' });
|
||||
const previewImage = previewDialog.getByRole('img', { name: 'AI 作品大图' });
|
||||
await expect(previewDialog).toBeVisible();
|
||||
await expect(previewDialog.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
|
||||
expect(await previewImage.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1600);
|
||||
await expect(previewImage).toHaveCSS('object-fit', 'contain');
|
||||
const previewBounds = await previewDialog.boundingBox();
|
||||
expect(previewBounds!.height).toBeGreaterThan(400);
|
||||
expect(previewBounds!.width).toBeGreaterThan(800);
|
||||
|
||||
await previewDialog.getByRole('button', { name: '原始尺寸' }).click();
|
||||
expect((await previewImage.boundingBox())!.width).toBe(1600);
|
||||
expect((await previewImage.boundingBox())!.height).toBe(2400);
|
||||
const scrollArea = previewDialog.getByTestId('design-image-preview-viewport');
|
||||
expect(await scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
await previewDialog.getByRole('button', { name: '适应窗口' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(previewDialog).toHaveCount(0);
|
||||
await expect(previewButton).toBeFocused();
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
const compactTask = page.getByTestId('image-workspace-conversation').getByTestId('design-plan-history-task-1');
|
||||
await compactTask.locator('summary').click();
|
||||
await compactTask.getByRole('button', { name: '放大查看图片' }).click();
|
||||
await expect(previewDialog.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
|
||||
const compactBounds = await previewDialog.boundingBox();
|
||||
expect(compactBounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(compactBounds!.x + compactBounds!.width).toBeLessThanOrEqual(375);
|
||||
await previewDialog.getByRole('button', { name: '关闭图片预览' }).click();
|
||||
await expect(previewDialog).toHaveCount(0);
|
||||
expect(await app.evaluate(() => (
|
||||
globalThis as typeof globalThis & { __designE2eDownloadCount?: number }
|
||||
).__designE2eDownloadCount)).toBe(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
126
tests/unit/design-image-preview.test.tsx
Normal file
126
tests/unit/design-image-preview.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user