174 lines
10 KiB
TypeScript
174 lines
10 KiB
TypeScript
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<typeof import('@/lib/image-workspace')>(),
|
|
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(<CanvasWorkspaceSplit conversation={<input aria-label="创作消息" />} works={<DesignCurrentWork asset={assetFixture()} />} />);
|
|
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(<CanvasWorkspaceSplit conversation={<input aria-label="正在编辑的字段" onKeyDown={(event) => {
|
|
if (event.key === 'Escape') event.preventDefault();
|
|
}} />} works={<p>作品</p>} />);
|
|
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(<DesignHistoryRail workspace={designWorkspaceFixture({ assets: [assetFixture('video')] })} />);
|
|
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(<CanvasWorkspaceSplit conversation={<input aria-label="创作消息" />}
|
|
works={<DesignHistoryRail workspace={designWorkspaceFixture({ assets: [assetFixture('video')] })} />} />);
|
|
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(<DesignHistoryRail workspace={workspace} creation={<YouthCreationCard workspace={workspace}
|
|
quoteBlockers={[]} generationAvailable generationOptions={designCapabilitiesFixture.generationOptions} />} />);
|
|
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(<DesignHistoryRail workspace={designWorkspaceFixture({ tasks: [taskFixture(status)], assets: [] })} />);
|
|
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(<DesignCurrentWork asset={asset} />);
|
|
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);
|
|
});
|
|
});
|