Files
makelore/tests/unit/canvas-workspace-layout.test.tsx
T

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);
});
});