Align Canvas with Agents and give current works a resizable workspace
This commit is contained in:
1 parent
70166d22b3
commit
2ca6e3199d
19 files changed
+1548
-511
No files matched your search
@@ -68,8 +68,8 @@ test.describe('AI Design V2 workspace', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps conversation and youth-friendly direct edits on one canonical design through production confirmation', async ({ launchElectronApp }) => {
|
||||
test.setTimeout(120_000);
|
||||
test('keeps split-panel drafts, direct edits and production results on one canonical design', async ({ launchElectronApp }, testInfo) => {
|
||||
test.setTimeout(150_000);
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
@@ -440,12 +440,21 @@ test.describe('AI Design V2 workspace', () => {
|
||||
(globalThis as typeof globalThis & { __designE2eAuthReady?: boolean }).__designE2eAuthReady,
|
||||
))).toBe(true);
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByTestId('ai-module-option-painting').click();
|
||||
await expect(page.getByTestId('image-workspace')).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-conversation')).toHaveClass(/bg-background/);
|
||||
await expect(page.getByTestId('design-message-composer')).toHaveClass(/max-w-\[50rem\]/);
|
||||
await expect(page.getByTestId('design-message-composer-surface')).toHaveClass(/chat-composer-surface/);
|
||||
const conversation = page.getByTestId('image-workspace-conversation');
|
||||
const split = page.getByTestId('canvas-workspace-split');
|
||||
const conversationPane = split.getByRole('region', { name: '创作对话', exact: true });
|
||||
const worksPane = split.getByRole('region', { name: '作品工作区', exact: true });
|
||||
const separator = page.getByRole('separator', { name: '调整对话与作品宽度' });
|
||||
const creationCard = page.getByTestId('youth-creation-card');
|
||||
const composer = page.getByLabel('告诉 AI 你想创作什么');
|
||||
const currentTab = worksPane.getByRole('button', { name: '当前作品', exact: true });
|
||||
const historyTab = worksPane.getByRole('button', { name: /^历史作品/u });
|
||||
|
||||
await expect(conversation).toBeVisible();
|
||||
await expect(page.getByTestId('design-message-composer')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-history-rail')).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0);
|
||||
@@ -454,8 +463,9 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await expect(page.getByText('当前设计项目的制作记录')).toHaveCount(0);
|
||||
await expect(page.getByText('实时同步')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '刷新作品' })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: '制作方案' })).toBeVisible();
|
||||
await expect(page.getByTestId('youth-creation-card')).not.toHaveClass(/border|shadow-sm/);
|
||||
await expect(worksPane.getByRole('heading', { name: '制作方案' })).toBeVisible();
|
||||
await expect(worksPane.getByTestId('youth-creation-card')).toHaveCount(1);
|
||||
await expect(conversation.getByTestId('youth-creation-card')).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
'把便携咖啡机呈现为城市通勤中的精密工具',
|
||||
);
|
||||
@@ -463,74 +473,144 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await expect(page.getByText('目标与用途')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="sidebar-image-conversation-"]')).toHaveCount(0);
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const conversation = document.querySelector('[data-testid="image-workspace-conversation"]')
|
||||
?.getBoundingClientRect();
|
||||
const projectSidebar = document.querySelector('[data-testid="sidebar"]')
|
||||
?.getBoundingClientRect();
|
||||
const workspace = document.querySelector('[data-testid="image-workspace"]')
|
||||
?.getBoundingClientRect();
|
||||
const historyRail = document.querySelector('[data-testid="image-workspace-history-rail"]')
|
||||
?.getBoundingClientRect();
|
||||
const workspaceHeader = document.querySelector('[data-testid="image-workspace-header"]')
|
||||
?.getBoundingClientRect();
|
||||
const historyHeader = document.querySelector('[data-testid="image-workspace-history-header"]')
|
||||
?.getBoundingClientRect();
|
||||
return conversation && projectSidebar && workspace && historyRail && workspaceHeader && historyHeader
|
||||
? {
|
||||
conversationHeight: conversation.height,
|
||||
conversationWidth: conversation.width,
|
||||
sidebarWidth: projectSidebar.width,
|
||||
horizontalGap: Math.abs(projectSidebar.right - conversation.left),
|
||||
historyRailWidth: historyRail.width,
|
||||
historyRailHeight: historyRail.height,
|
||||
historyTopGap: Math.abs(workspace.top - historyRail.top),
|
||||
historyBottomGap: Math.abs(workspace.bottom - historyRail.bottom),
|
||||
historyHorizontalGap: Math.abs(conversation.right - historyRail.left),
|
||||
workspaceHeaderHeight: workspaceHeader.height,
|
||||
historyHeaderHeight: historyHeader.height,
|
||||
headerTopGap: Math.abs(workspaceHeader.top - historyHeader.top),
|
||||
headerBottomGap: Math.abs(workspaceHeader.bottom - historyHeader.bottom),
|
||||
}
|
||||
: null;
|
||||
});
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout!.conversationHeight).toBeGreaterThan(300);
|
||||
expect(layout!.sidebarWidth).toBe(256);
|
||||
expect(layout!.horizontalGap).toBeLessThanOrEqual(1);
|
||||
expect(layout!.historyRailWidth).toBe(256);
|
||||
expect(layout!.historyRailWidth).toBe(layout!.sidebarWidth);
|
||||
expect(layout!.historyRailHeight).toBeGreaterThan(500);
|
||||
expect(layout!.historyTopGap).toBeLessThanOrEqual(1);
|
||||
expect(layout!.historyBottomGap).toBeLessThanOrEqual(1);
|
||||
expect(layout!.historyHorizontalGap).toBeLessThanOrEqual(1);
|
||||
expect(layout!.workspaceHeaderHeight).toBe(40);
|
||||
expect(layout!.historyHeaderHeight).toBe(40);
|
||||
expect(layout!.headerTopGap).toBeLessThanOrEqual(1);
|
||||
expect(layout!.headerBottomGap).toBeLessThanOrEqual(1);
|
||||
expect(layout!.conversationWidth).toBeGreaterThan(600);
|
||||
const splitBounds = (await split.boundingBox())!;
|
||||
const conversationBounds = (await conversationPane.boundingBox())!;
|
||||
const worksBounds = (await worksPane.boundingBox())!;
|
||||
const separatorBounds = (await separator.boundingBox())!;
|
||||
const headerBounds = (await page.getByTestId('image-workspace-header').boundingBox())!;
|
||||
expect(headerBounds.height).toBe(64);
|
||||
expect(conversationBounds.height).toBeGreaterThan(500);
|
||||
expect(Math.abs(headerBounds.y + headerBounds.height - splitBounds.y)).toBeLessThanOrEqual(1);
|
||||
expect(separatorBounds.width).toBe(8);
|
||||
expect(Math.abs(conversationBounds.x + conversationBounds.width - separatorBounds.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(separatorBounds.x + separatorBounds.width - worksBounds.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(conversationBounds.y - worksBounds.y)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(conversationBounds.height - worksBounds.height)).toBeLessThanOrEqual(1);
|
||||
expect(conversationBounds.width / (splitBounds.width - separatorBounds.width)).toBeCloseTo(0.42, 2);
|
||||
expect(worksBounds.width).toBeGreaterThan(conversationBounds.width);
|
||||
await expect(separator).toHaveAttribute('aria-valuenow', '42');
|
||||
await page.screenshot({ path: testInfo.outputPath('canvas-desktop-split.png'), animations: 'disabled' });
|
||||
|
||||
// Resize changes usable panel widths, and both keyboard and pointer reset work.
|
||||
await separator.focus();
|
||||
await separator.press('ArrowRight');
|
||||
await expect(separator).toHaveAttribute('aria-valuenow', '44');
|
||||
expect((await conversationPane.boundingBox())!.width).toBeGreaterThan(conversationBounds.width + 15);
|
||||
await separator.press('ArrowLeft');
|
||||
await expect(separator).toHaveAttribute('aria-valuenow', '42');
|
||||
await page.mouse.move(separatorBounds.x + separatorBounds.width / 2, separatorBounds.y + 100);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(splitBounds.x + (splitBounds.width - 8) * 0.55, separatorBounds.y + 100, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await expect(separator).toHaveAttribute('aria-valuenow', '55');
|
||||
expect((await conversationPane.boundingBox())!.width).toBeGreaterThan((await worksPane.boundingBox())!.width);
|
||||
await separator.dblclick();
|
||||
await expect(separator).toHaveAttribute('aria-valuenow', '42');
|
||||
await separator.press('ArrowRight');
|
||||
await separator.press('Home');
|
||||
await expect(separator).toHaveAttribute('aria-valuenow', '42');
|
||||
|
||||
const unsentDraft = '还没发送的想法:让咖啡机更有未来感';
|
||||
await composer.fill(unsentDraft);
|
||||
await composer.press('F6');
|
||||
await expect(worksPane.getByRole('button', { name: '展开作品工作区' })).toBeFocused();
|
||||
await page.keyboard.press('F6');
|
||||
await expect(conversationPane.getByRole('button', { name: '展开创作对话' })).toBeFocused();
|
||||
await conversationPane.getByRole('button', { name: '展开创作对话' }).click();
|
||||
await expect(worksPane).toBeHidden();
|
||||
await expect(creationCard).toHaveCount(1);
|
||||
await expect(separator).toBeHidden();
|
||||
expect((await conversationPane.boundingBox())!.width).toBeCloseTo(splitBounds.width, 0);
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(worksPane).toBeVisible();
|
||||
await expect(separator).toBeVisible();
|
||||
await worksPane.getByRole('button', { name: '展开作品工作区' }).click();
|
||||
await expect(conversationPane).toBeHidden();
|
||||
await expect(conversation).toHaveCount(1);
|
||||
await expect(creationCard).toBeVisible();
|
||||
await worksPane.getByRole('button', { name: '恢复分栏' }).click();
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
await expect(separator).toHaveAttribute('aria-valuenow', '42');
|
||||
|
||||
await historyTab.click();
|
||||
await expect(historyTab).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(worksPane.getByText('还没有历史作品', { exact: true })).toBeVisible();
|
||||
await expect(creationCard).toBeHidden();
|
||||
await expect(creationCard).toHaveCount(1);
|
||||
await currentTab.click();
|
||||
await expect(currentTab).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(creationCard).toBeVisible();
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
|
||||
const desktopViewport = page.viewportSize();
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
await expect(page.getByTestId('sidebar')).toBeHidden();
|
||||
await expect(page.getByTestId('image-workspace-history-rail')).toHaveCount(0);
|
||||
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
|
||||
await expect(page.getByTestId('youth-creation-card')).toBeVisible();
|
||||
// The last explicitly expanded panel remains selected when the layout becomes compact.
|
||||
await expect(worksPane).toBeVisible();
|
||||
await page.getByRole('button', { name: '创作对话', exact: true }).click();
|
||||
await expect(conversation).toBeVisible();
|
||||
await expect(worksPane).toBeHidden();
|
||||
await expect(separator).toBeHidden();
|
||||
await expect(page.getByTestId('image-workspace-history-rail')).toHaveCount(1);
|
||||
await expect(creationCard).toBeHidden();
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
await page.getByRole('button', { name: '作品与方案', exact: true }).click();
|
||||
await expect(conversation).toBeHidden();
|
||||
await expect(conversation).toHaveCount(1);
|
||||
await expect(creationCard).toBeVisible();
|
||||
await page.getByRole('combobox', { name: '画幅' }).selectOption('16:9');
|
||||
await expect(page.getByRole('combobox', { name: '画幅' })).toHaveValue('16:9');
|
||||
await historyTab.click();
|
||||
await expect(worksPane.getByText('还没有历史作品', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '创作对话', exact: true }).click();
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
await page.getByRole('button', { name: '项目', exact: true }).click();
|
||||
await expect(page.getByTestId('image-workspace-works-rail')).toBeVisible();
|
||||
await expect(page.getByRole('dialog')).toHaveClass(/left-0/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0);
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
|
||||
await page.setViewportSize({ width: 768, height: 800 });
|
||||
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
|
||||
await expect(conversation).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '作品与方案', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '项目', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '作品与方案', exact: true }).click();
|
||||
await expect(historyTab).toHaveAttribute('aria-pressed', 'true');
|
||||
await currentTab.click();
|
||||
await expect(creationCard).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: '画幅' })).toHaveValue('16:9');
|
||||
await page.getByRole('button', { name: '创作对话', exact: true }).click();
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
|
||||
await page.setViewportSize(desktopViewport ?? { width: 1280, height: 800 });
|
||||
// The usable Canvas container selects one panel through 899 px and splits from 900 px.
|
||||
await page.setViewportSize({ width: 899, height: 800 });
|
||||
await expect(conversation).toBeVisible();
|
||||
await expect(worksPane).toBeHidden();
|
||||
await expect(separator).toBeHidden();
|
||||
await page.getByRole('button', { name: '作品与方案', exact: true }).click();
|
||||
await expect(creationCard).toBeVisible();
|
||||
await expect(conversation).toBeHidden();
|
||||
await page.getByRole('button', { name: '创作对话', exact: true }).click();
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
await page.setViewportSize({ width: 900, height: 800 });
|
||||
await expect(conversation).toBeVisible();
|
||||
await expect(worksPane).toBeVisible();
|
||||
await expect(separator).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '作品与方案', exact: true })).toHaveCount(0);
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
const boundarySplit = (await split.boundingBox())!;
|
||||
const boundaryWorks = (await worksPane.boundingBox())!;
|
||||
expect(boundarySplit.width).toBe(900);
|
||||
expect(boundaryWorks.x + boundaryWorks.width).toBeLessThanOrEqual(900);
|
||||
|
||||
await page.setViewportSize(desktopViewport ?? { width: 1440, height: 900 });
|
||||
await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-history-rail')).toBeVisible();
|
||||
await expect(conversation).toBeVisible();
|
||||
await expect(worksPane).toBeVisible();
|
||||
await expect(separator).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0);
|
||||
|
||||
await page.getByRole('combobox', { name: '画幅' }).selectOption('16:9');
|
||||
await expect(composer).toHaveValue(unsentDraft);
|
||||
await expect(page.getByRole('combobox', { name: '画幅' })).toHaveValue('16:9');
|
||||
|
||||
const followUp = '主体更克制,留出品牌标题空间';
|
||||
@@ -547,9 +627,11 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await expect(page.getByText(/规格版本|编译器|生成方式|不可变/)).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: '确认并开始制作' }).click();
|
||||
await expect(page.getByTestId('image-workspace-history-rail').getByTestId('design-plan-history-task-1')).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-conversation').getByTestId('design-plan-history-task-1')).toHaveCount(0);
|
||||
await expect(page.getByText('等待制作')).toBeVisible();
|
||||
await historyTab.click();
|
||||
await expect(worksPane.getByTestId('design-plan-history-task-1')).toBeVisible();
|
||||
await expect(conversation.getByTestId('design-plan-history-task-1')).toHaveCount(0);
|
||||
await expect(worksPane.getByTestId('design-plan-history-task-1')).toContainText('等待制作');
|
||||
await expect(page.getByTestId('image-workspace-header').getByRole('status')).toHaveText('等待制作');
|
||||
await expect(page.getByTestId('youth-creation-card')).toHaveCount(0);
|
||||
await expect(page.getByText('1200', { exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -562,7 +644,13 @@ test.describe('AI Design V2 workspace', () => {
|
||||
(globalThis as typeof globalThis & { __designE2eCompleteTask?: () => void }).__designE2eCompleteTask?.();
|
||||
});
|
||||
await page.reload();
|
||||
const historyTask = page.getByTestId('design-plan-history-task-1');
|
||||
await expect(page.getByTestId('canvas-current-work')).toBeVisible();
|
||||
await expect(page.getByTestId('canvas-current-work').getByRole('img', { name: '当前图片作品' })).toBeVisible();
|
||||
await expect(page.getByTestId('canvas-current-work').getByRole('button', { name: '保存到电脑' })).toBeEnabled();
|
||||
await expect(currentTab).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.screenshot({ path: testInfo.outputPath('canvas-desktop-current-work.png'), animations: 'disabled' });
|
||||
await historyTab.click();
|
||||
const historyTask = worksPane.getByTestId('design-plan-history-task-1');
|
||||
await expect(historyTask).toContainText('已完成');
|
||||
await historyTask.locator('summary').click();
|
||||
const previewButton = historyTask.getByRole('button', { name: '放大查看图片' });
|
||||
@@ -587,9 +675,27 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await expect(previewDialog).toHaveCount(0);
|
||||
await expect(previewButton).toBeFocused();
|
||||
|
||||
// A historical result becomes the current large workspace preview without a download.
|
||||
await historyTask.getByRole('button', { name: '在工作区查看' }).click();
|
||||
await expect(currentTab).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(historyTask).toBeHidden();
|
||||
await expect(page.getByTestId('canvas-current-work')).toBeVisible();
|
||||
const currentPreview = page.getByTestId('canvas-current-work').getByRole('button', { name: '放大查看图片' });
|
||||
await currentPreview.click();
|
||||
await expect(previewDialog).toBeVisible();
|
||||
await previewDialog.getByRole('button', { name: '关闭图片预览' }).click();
|
||||
await expect(currentPreview).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 expect(conversation).toBeVisible();
|
||||
await expect(worksPane).toBeHidden();
|
||||
await page.getByRole('button', { name: '作品与方案', exact: true }).click();
|
||||
await expect(page.getByTestId('canvas-current-work')).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('canvas-compact-current-work.png'), animations: 'disabled' });
|
||||
await historyTab.click();
|
||||
const compactTask = worksPane.getByTestId('design-plan-history-task-1');
|
||||
await expect(compactTask).toBeVisible();
|
||||
await expect(conversation.getByTestId('design-plan-history-task-1')).toHaveCount(0);
|
||||
await compactTask.getByRole('button', { name: '放大查看图片' }).click();
|
||||
await expect(previewDialog.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
|
||||
const compactBounds = await previewDialog.boundingBox();
|
||||
@@ -605,6 +711,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
.__designE2eOpenIncompleteVideo?.();
|
||||
});
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: '作品与方案', exact: true }).click();
|
||||
const duration = page.getByRole('combobox', { name: '时长' });
|
||||
await expect(duration).toHaveValue('');
|
||||
await expect(page.getByRole('button', { name: '增加时长' })).toHaveCount(0);
|
||||
@@ -612,6 +719,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await duration.selectOption('6');
|
||||
await expect(page.getByRole('button', { name: '增加时长' })).toBeEnabled();
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: '作品与方案', exact: true }).click();
|
||||
await expect(page.getByRole('combobox', { name: '时长' })).toHaveCount(0);
|
||||
await expect(page.getByText('6 秒', { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DesignConversationPane } from '@/pages/ImageCanvas/DesignConversationPane';
|
||||
import { DesignMessageMarkdown } from '@/pages/ImageCanvas/DesignMessageMarkdown';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import { designWorkspaceFixture } from '../fixtures/design-workspace-v2';
|
||||
|
||||
const { openExternalMock, toastErrorMock } = vi.hoisted(() => ({
|
||||
openExternalMock: vi.fn().mockResolvedValue(undefined),
|
||||
toastErrorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }));
|
||||
vi.mock('@/lib/conversation-links', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/conversation-links')>(),
|
||||
openConversationExternalUrl: openExternalMock,
|
||||
}));
|
||||
vi.mock('@/pages/ImageCanvas/DesignProductionPanel', () => ({
|
||||
DesignPendingOperationsPanel: () => <div data-testid="pending-operations-panel" />,
|
||||
}));
|
||||
vi.mock('@/pages/ImageCanvas/DesignPlanHistory', () => ({
|
||||
DesignPlanHistory: () => <div data-testid="plan-history" />,
|
||||
}));
|
||||
vi.mock('@/pages/ImageCanvas/YouthCreationCard', () => ({
|
||||
YouthCreationCard: () => <div data-testid="youth-creation-card" />,
|
||||
}));
|
||||
|
||||
function prepare(workspace = designWorkspaceFixture()) {
|
||||
const sendChat = vi.fn().mockResolvedValue(workspace);
|
||||
useImageWorkspaceStore.setState({
|
||||
workspace,
|
||||
activeWorkspaceId: workspace.workspace.workspaceId,
|
||||
chatDraft: '',
|
||||
assistantStreams: {},
|
||||
assistantActivities: {},
|
||||
pendingOperations: {},
|
||||
sendChat,
|
||||
});
|
||||
return { workspace, sendChat };
|
||||
}
|
||||
|
||||
function pane(workspace = designWorkspaceFixture(), showCreationCard = true) {
|
||||
return <DesignConversationPane workspace={workspace} quoteBlockers={[]} generationAvailable showCreationCard={showCreationCard} />;
|
||||
}
|
||||
|
||||
function pendingChat(workspaceId = 'workspace-1') {
|
||||
return {
|
||||
id: 'chat-1', label: '发送消息', status: 'submitting' as const,
|
||||
command: {
|
||||
kind: 'apply_input' as const, workspaceId, sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4, clientOperationId: 'chat-1',
|
||||
input: { kind: 'chat' as const, message: '画一只小熊' },
|
||||
},
|
||||
error: null, clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Canvas conversation presentation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
});
|
||||
|
||||
it('formats assistant Markdown identically during streaming and after confirmation', () => {
|
||||
const text = '**画面方向**\n\n- 黄色的小熊\n- 蓝色的夜空\n\n| 颜色 | 用途 |\n| --- | --- |\n| 黄色 | 小熊 |';
|
||||
const { workspace } = prepare(designWorkspaceFixture({ turns: [] }));
|
||||
useImageWorkspaceStore.setState({
|
||||
pendingOperations: { 'chat-1': pendingChat() },
|
||||
assistantStreams: { 'chat-1': text },
|
||||
});
|
||||
const { rerender } = render(pane(workspace));
|
||||
const streaming = screen.getByTestId('streaming-design-assistant-chat-1');
|
||||
const streamedBody = streaming.querySelector('.canvas-message-markdown')?.innerHTML;
|
||||
expect(within(streaming).getByText('画面方向').tagName).toBe('STRONG');
|
||||
expect(within(streaming).getAllByRole('listitem')).toHaveLength(2);
|
||||
expect(within(streaming).getByRole('table')).toBeInTheDocument();
|
||||
expect(streaming).not.toHaveTextContent('**');
|
||||
|
||||
const finalWorkspace = {
|
||||
...workspace,
|
||||
turns: [{ turnId: 'turn-2', rawTurnSequence: 2, userMessage: '画一只小熊', assistantMessage: text }],
|
||||
};
|
||||
act(() => { useImageWorkspaceStore.setState({ pendingOperations: {}, workspace: finalWorkspace }); });
|
||||
rerender(pane(finalWorkspace));
|
||||
expect(screen.queryByTestId('streaming-design-assistant-chat-1')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('麦洛的回复').querySelector('.canvas-message-markdown')?.innerHTML).toBe(streamedBody);
|
||||
});
|
||||
|
||||
it('leaves user messages literal and can move only the creation card out of the conversation', () => {
|
||||
const { workspace } = prepare(designWorkspaceFixture({ turns: [{
|
||||
turnId: 'turn-1', rawTurnSequence: 1, userMessage: '**我的原话**', assistantMessage: '收到',
|
||||
}] }));
|
||||
const { rerender } = render(pane(workspace));
|
||||
expect(screen.getByText('**我的原话**').querySelector('strong')).toBeNull();
|
||||
expect(screen.getByTestId('youth-creation-card')).toBeInTheDocument();
|
||||
rerender(pane(workspace, false));
|
||||
expect(screen.queryByTestId('youth-creation-card')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('pending-operations-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('plan-history')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fills the starter draft without sending it', () => {
|
||||
const { workspace, sendChat } = prepare(designWorkspaceFixture({ turns: [] }));
|
||||
render(pane(workspace));
|
||||
fireEvent.click(screen.getByRole('button', { name: '我脑中有一只在月球踢球的熊猫' }));
|
||||
expect(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue('我脑中有一只在月球踢球的熊猫');
|
||||
expect(sendChat).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: '发送' })).toHaveClass('h-10', 'w-10');
|
||||
});
|
||||
|
||||
it('preserves IME and Shift+Enter, blocks repeat submit, and keeps the draft on failure', async () => {
|
||||
const { workspace, sendChat } = prepare();
|
||||
let rejectSend!: (reason: Error) => void;
|
||||
sendChat.mockImplementation(() => {
|
||||
useImageWorkspaceStore.setState({ pendingOperations: { 'chat-1': pendingChat() } });
|
||||
return new Promise((_, reject) => { rejectSend = reject; });
|
||||
});
|
||||
useImageWorkspaceStore.setState({ chatDraft: '第一行\n第二行' });
|
||||
render(pane(workspace));
|
||||
const input = screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' });
|
||||
expect(input).toHaveAttribute('rows', '2');
|
||||
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
|
||||
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
|
||||
expect(sendChat).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
fireEvent.submit(screen.getByTestId('design-message-composer'));
|
||||
expect(sendChat).toHaveBeenCalledOnce();
|
||||
expect(input).toBeDisabled();
|
||||
expect(screen.getByText('麦洛正在整理你的想法…')).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
useImageWorkspaceStore.setState({ pendingOperations: {} });
|
||||
rejectSend(new Error('offline'));
|
||||
});
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
expect(input).toHaveValue('第一行\n第二行');
|
||||
expect(toastErrorMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps another Workspace pending command outside this composer and reply', () => {
|
||||
const { workspace } = prepare();
|
||||
useImageWorkspaceStore.setState({
|
||||
chatDraft: '本项目的想法',
|
||||
pendingOperations: { 'chat-1': pendingChat('workspace-other') },
|
||||
assistantStreams: { 'chat-1': '另一个项目的回复' },
|
||||
});
|
||||
render(pane(workspace));
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||
expect(screen.queryByText('另一个项目的回复')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets canonical completion restore the composer even before the HTTP response arrives', () => {
|
||||
const { workspace, sendChat } = prepare();
|
||||
sendChat.mockImplementation(() => {
|
||||
useImageWorkspaceStore.setState({ pendingOperations: { 'chat-1': pendingChat() } });
|
||||
return new Promise(() => undefined);
|
||||
});
|
||||
useImageWorkspaceStore.setState({ chatDraft: '画一只小熊' });
|
||||
render(pane(workspace));
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
const input = screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' });
|
||||
expect(input).toBeDisabled();
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState({ pendingOperations: {}, chatDraft: '' });
|
||||
});
|
||||
expect(input).toBeEnabled();
|
||||
fireEvent.change(input, { target: { value: '让天空更蓝一点' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
expect(sendChat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canvas reply safety', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('blocks executable HTML, unsafe links, local file access and unverified image requests', () => {
|
||||
const { container } = render(<DesignMessageMarkdown text={'<script>alert(1)</script>\n\n[危险](javascript:alert(1)) [本地](file:///private/art.png) [相对](../art.png)\n\n'} />);
|
||||
expect(container.querySelector('script, iframe, img, a')).toBeNull();
|
||||
expect(screen.getByText('[图片:预览]')).toBeInTheDocument();
|
||||
expect(openExternalMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a safe explicit HTTP(S) link through the existing host boundary', () => {
|
||||
render(<DesignMessageMarkdown text="[查看参考](https://example.com/reference)" />);
|
||||
const link = screen.getByRole('link', { name: '查看参考' });
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
fireEvent.click(link);
|
||||
expect(openExternalMock).toHaveBeenCalledWith('https://example.com/reference');
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe('AI Design Canvas page', () => {
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps conversation and the active editable plan together in the center', () => {
|
||||
it('keeps one active editable plan in the works pane beside the conversation', () => {
|
||||
prepareWorkspace();
|
||||
render(<ImageCanvas />);
|
||||
|
||||
@@ -121,11 +121,11 @@ describe('AI Design Canvas page', () => {
|
||||
expect(within(conversation).getByTestId('design-message-composer')).toHaveClass('max-w-[50rem]');
|
||||
expect(within(conversation).getByTestId('design-message-composer-surface')).toHaveClass('chat-composer-surface');
|
||||
expect(within(conversation).queryByRole('heading', { name: '和 AI 一起完善创作' })).not.toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
expect(within(screen.getByTestId('image-workspace-history-rail')).getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('image-workspace-history-rail')).getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
'把便携咖啡机呈现为城市通勤中的精密工具',
|
||||
);
|
||||
expect(within(conversation).getByRole('combobox', { name: '画幅' })).toHaveValue('3:4');
|
||||
expect(within(screen.getByTestId('image-workspace-history-rail')).getByRole('combobox', { name: '画幅' })).toHaveValue('3:4');
|
||||
expect(screen.queryByText('Living Form')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -161,18 +161,23 @@ describe('AI Design Canvas page', () => {
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('places desktop production history in a full-height right rail', () => {
|
||||
it('switches current and historical works without remounting the editable plan', () => {
|
||||
setViewport(true);
|
||||
prepareWorkspace();
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const historyRail = screen.getByTestId('image-workspace-history-rail');
|
||||
expect(historyRail).toHaveClass('h-full', 'w-64', 'border-l');
|
||||
expect(screen.getByTestId('image-workspace-header')).toHaveClass('h-10');
|
||||
expect(screen.getByTestId('image-workspace-history-header')).toHaveClass('h-10');
|
||||
expect(screen.getAllByTestId('youth-creation-card')).toHaveLength(1);
|
||||
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
|
||||
fireEvent.change(prompt, { target: { value: '保留正在编辑的提示词' } });
|
||||
fireEvent.click(within(historyRail).getByRole('button', { name: '历史作品', exact: true }));
|
||||
expect(within(historyRail).getByRole('heading', { name: '历史作品' })).toBeInTheDocument();
|
||||
expect(within(historyRail).queryByText('当前设计项目的制作记录')).not.toBeInTheDocument();
|
||||
expect(within(historyRail).getByText('还没有历史作品')).toBeInTheDocument();
|
||||
expect(prompt).not.toBeVisible();
|
||||
fireEvent.click(within(historyRail).getByRole('button', { name: '当前作品', exact: true }));
|
||||
expect(prompt).toBeVisible();
|
||||
expect(prompt).toHaveValue('保留正在编辑的提示词');
|
||||
expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '项目' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('实时同步')).not.toBeInTheDocument();
|
||||
@@ -295,7 +300,7 @@ describe('AI Design Canvas page', () => {
|
||||
expect(within(conversation).queryByTestId('streaming-design-assistant-operation-chat-1')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(within(conversation).getAllByText(unfinishedDraft)).toHaveLength(1);
|
||||
expect(within(conversation).getByTestId('youth-creation-card')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('image-workspace-history-rail')).getByTestId('youth-creation-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps each pending reply beside the user message that started it', () => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ImageWorkspaceSidebar } from '@/components/layout/ImageWorkspaceSidebar';
|
||||
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import {
|
||||
designBootstrapFixture,
|
||||
designSummaryFixture,
|
||||
designWorkspaceFixture,
|
||||
} from '../fixtures/design-workspace-v2';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
function setViewport(desktop: boolean) {
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
matches: desktop,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function prepareProjects() {
|
||||
const workspace = designWorkspaceFixture();
|
||||
const target = designSummaryFixture({
|
||||
workspaceId: 'workspace-other',
|
||||
clientWorkspaceId: 'client-workspace-other',
|
||||
directionId: 'direction-other',
|
||||
sessionId: 'session-other',
|
||||
title: '月球熊猫海报',
|
||||
updatedAt: '2026-09-01T10:00:00.000Z',
|
||||
});
|
||||
const actions = {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
createProject: vi.fn().mockResolvedValue(workspace),
|
||||
renameProject: vi.fn().mockResolvedValue(workspace),
|
||||
deleteProject: vi.fn().mockResolvedValue({ workspaceId: target.workspaceId, deleted: true }),
|
||||
selectProject: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
useAuthStore.setState({ isAuthenticated: () => true });
|
||||
useImageWorkspaceStore.setState({
|
||||
status: 'ready',
|
||||
bootstrap: {
|
||||
...designBootstrapFixture(workspace.workspace),
|
||||
workspaces: [workspace.workspace, target],
|
||||
},
|
||||
activeWorkspaceId: workspace.workspace.workspaceId,
|
||||
workspace,
|
||||
deletingWorkspaceId: null,
|
||||
error: null,
|
||||
...actions,
|
||||
});
|
||||
return { workspace, target, actions };
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output aria-label="当前页面">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
function renderSidebar({ collapsed = false, hidden = false } = {}) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/module-select']}>
|
||||
<aside hidden={hidden}>
|
||||
<ImageWorkspaceSidebar sidebarCollapsed={collapsed} />
|
||||
</aside>
|
||||
<LocationProbe />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
async function openProjectMenu(title: string) {
|
||||
const trigger = screen.getByRole('button', { name: `项目操作:${title}` });
|
||||
fireEvent.keyDown(trigger, { key: 'Enter' });
|
||||
return screen.findByRole('menu');
|
||||
}
|
||||
|
||||
describe('ImageWorkspaceSidebar', () => {
|
||||
beforeEach(() => {
|
||||
setViewport(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens one creation dialog from the collapsed sidebar and submits the trimmed title', async () => {
|
||||
const { actions } = prepareProjects();
|
||||
renderSidebar({ collapsed: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建设计项目' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建设计项目' });
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), {
|
||||
target: { value: ' 小狗的快乐短片 ' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' }));
|
||||
|
||||
await waitFor(() => expect(actions.createProject).toHaveBeenCalledExactlyOnceWith('小狗的快乐短片'));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas');
|
||||
expect(actions.selectProject).not.toHaveBeenCalled();
|
||||
expect(actions.renameProject).not.toHaveBeenCalled();
|
||||
expect(actions.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores compact creation events while the desktop sidebar is hidden', () => {
|
||||
const { actions } = prepareProjects();
|
||||
setViewport(false);
|
||||
renderSidebar({ hidden: true });
|
||||
|
||||
act(() => window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT)));
|
||||
|
||||
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(0);
|
||||
expect(actions.createProject).not.toHaveBeenCalled();
|
||||
expect(actions.selectProject).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('当前页面')).toHaveTextContent('/module-select');
|
||||
});
|
||||
|
||||
it('handles desktop creation events with a single dialog', () => {
|
||||
prepareProjects();
|
||||
renderSidebar();
|
||||
|
||||
act(() => window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT)));
|
||||
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
||||
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renames an unselected project through its keyboard menu without switching projects', async () => {
|
||||
const { workspace, target, actions } = prepareProjects();
|
||||
renderSidebar();
|
||||
|
||||
const menu = await openProjectMenu(target.title);
|
||||
expect(within(menu).getByRole('menuitem', { name: '删除项目' })).toBeInTheDocument();
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: '重命名' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '重命名设计项目' });
|
||||
const name = within(dialog).getByRole('textbox', { name: '项目名称' });
|
||||
expect(name).toHaveValue(target.title);
|
||||
expect(actions.selectProject).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('当前页面')).toHaveTextContent('/module-select');
|
||||
|
||||
fireEvent.change(name, { target: { value: ' ' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存名称' }));
|
||||
expect(within(dialog).getByText('请输入项目名称')).toBeInTheDocument();
|
||||
expect(actions.renameProject).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(name, { target: { value: ' 熊猫足球比赛 ' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存名称' }));
|
||||
|
||||
await waitFor(() => expect(actions.renameProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId, '熊猫足球比赛'));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe(workspace.workspace.workspaceId);
|
||||
expect(actions.selectProject).not.toHaveBeenCalled();
|
||||
expect(actions.createProject).not.toHaveBeenCalled();
|
||||
expect(actions.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires the full project title and deletes the menu target without switching projects', async () => {
|
||||
const { workspace, target, actions } = prepareProjects();
|
||||
renderSidebar();
|
||||
|
||||
const menu = await openProjectMenu(target.title);
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: '删除项目' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '删除设计项目' });
|
||||
const confirmation = within(dialog).getByRole('textbox', { name: `输入“${target.title}”确认` });
|
||||
fireEvent.change(confirmation, { target: { value: `${target.title} ` } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '删除项目' }));
|
||||
expect(within(dialog).getByText('请输入完整项目名称')).toBeInTheDocument();
|
||||
expect(actions.deleteProject).not.toHaveBeenCalled();
|
||||
expect(actions.selectProject).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(confirmation, { target: { value: target.title } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '删除项目' }));
|
||||
|
||||
await waitFor(() => expect(actions.deleteProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe(workspace.workspace.workspaceId);
|
||||
expect(screen.getByLabelText('当前页面')).toHaveTextContent('/module-select');
|
||||
expect(actions.selectProject).not.toHaveBeenCalled();
|
||||
expect(actions.createProject).not.toHaveBeenCalled();
|
||||
expect(actions.renameProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects a project by its workspace identity from the project row', () => {
|
||||
const { target, actions } = prepareProjects();
|
||||
renderSidebar();
|
||||
|
||||
const sidebar = screen.getByTestId('sidebar-image-workspace');
|
||||
fireEvent.click(within(sidebar).getByRole('button', { name: new RegExp(`^${target.title}`) }));
|
||||
|
||||
expect(actions.selectProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId);
|
||||
expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas');
|
||||
expect(actions.renameProject).not.toHaveBeenCalled();
|
||||
expect(actions.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -397,7 +397,7 @@ describe('YouthCreationCard', () => {
|
||||
})],
|
||||
});
|
||||
render(<DesignPlanHistory workspace={workspace} />);
|
||||
fireEvent.click(screen.getByText('制作记录'));
|
||||
fireEvent.click(screen.getByText('图片作品 · 1'));
|
||||
|
||||
expect(screen.getByText('作品没有通过内容检查,可以换一种安全的说法再试。')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Provider policy/i)).not.toBeInTheDocument();
|
||||
|
||||
Reference in new issue
Block a user