-
-
-
- {error && workspace && (
-
- {error}
- void refreshWorkspace()}
- >
- 刷新
-
-
- )}
-
-
-
- {workspace ? (
-
- ) : (
-
- )}
-
-
-
-
- {desktopLayout ?
: null}
-
- {!desktopLayout && (
-
-
-
- 设计项目
- 新建、切换、重命名或删除 AI 绘画项目。
-
- setWorksOpen(false)} />
-
-
- )}
+
+
+ {error && workspace &&
+ {error}
+ void refreshWorkspace()}>刷新
+
}
+
:
}
+ works={
} />} />
+ {!desktopLayout &&
+
+
+ 设计项目
+ 新建、切换、重命名或删除 AI 绘画项目。
+
+ setWorksOpen(false)} />
+
+ }
);
}
diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts
index f7c9bb43..dbffecc4 100644
--- a/tests/e2e/image-workspace-v2.spec.ts
+++ b/tests/e2e/image-workspace-v2.spec.ts
@@ -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 {
diff --git a/tests/unit/canvas-workspace-layout.test.tsx b/tests/unit/canvas-workspace-layout.test.tsx
new file mode 100644
index 00000000..8bb83d78
--- /dev/null
+++ b/tests/unit/canvas-workspace-layout.test.tsx
@@ -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
(),
+ 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( } works={ } />);
+ 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( {
+ if (event.key === 'Escape') event.preventDefault();
+ }} />} works={作品
} />);
+ 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( );
+ 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( }
+ works={ } />);
+ 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( } />);
+ 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( );
+ 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( );
+ 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);
+ });
+});
diff --git a/tests/unit/image-canvas-conversation.test.tsx b/tests/unit/image-canvas-conversation.test.tsx
new file mode 100644
index 00000000..7b5c2109
--- /dev/null
+++ b/tests/unit/image-canvas-conversation.test.tsx
@@ -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(),
+ openConversationExternalUrl: openExternalMock,
+}));
+vi.mock('@/pages/ImageCanvas/DesignProductionPanel', () => ({
+ DesignPendingOperationsPanel: () =>
,
+}));
+vi.mock('@/pages/ImageCanvas/DesignPlanHistory', () => ({
+ DesignPlanHistory: () =>
,
+}));
+vi.mock('@/pages/ImageCanvas/YouthCreationCard', () => ({
+ YouthCreationCard: () =>
,
+}));
+
+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 ;
+}
+
+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(alert(1)\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( );
+ const link = screen.getByRole('link', { name: '查看参考' });
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ fireEvent.click(link);
+ expect(openExternalMock).toHaveBeenCalledWith('https://example.com/reference');
+ });
+});
diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx
index 67d64977..a6719635 100644
--- a/tests/unit/image-canvas-page.test.tsx
+++ b/tests/unit/image-canvas-page.test.tsx
@@ -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( );
@@ -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( );
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', () => {
diff --git a/tests/unit/image-workspace-sidebar.test.tsx b/tests/unit/image-workspace-sidebar.test.tsx
new file mode 100644
index 00000000..a9d3a86c
--- /dev/null
+++ b/tests/unit/image-workspace-sidebar.test.tsx
@@ -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 {location.pathname} ;
+}
+
+function renderSidebar({ collapsed = false, hidden = false } = {}) {
+ return render(
+
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/tests/unit/youth-creation-card.test.tsx b/tests/unit/youth-creation-card.test.tsx
index 4c8a5b15..dc3e2fa5 100644
--- a/tests/unit/youth-creation-card.test.tsx
+++ b/tests/unit/youth-creation-card.test.tsx
@@ -397,7 +397,7 @@ describe('YouthCreationCard', () => {
})],
});
render( );
- fireEvent.click(screen.getByText('制作记录'));
+ fireEvent.click(screen.getByText('图片作品 · 1'));
expect(screen.getByText('作品没有通过内容检查,可以换一种安全的说法再试。')).toBeInTheDocument();
expect(screen.queryByText(/Provider policy/i)).not.toBeInTheDocument();