feat(canvas): preview generated task images in app

This commit is contained in:
2026-09-15 14:58:21 +08:00
parent 80e52496d2
commit 41664b2ae3
6 changed files with 357 additions and 3 deletions

View File

@@ -0,0 +1,51 @@
# Task: Add enlarged image preview to AI Design task results
## Identity
- Task ID: 20260915-design-image-preview-b24997a6
- Mode: Feature
- Branch: codex/20260915-design-image-preview-b24997a6-design-image-preview
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-design-image-preview-b24997a6
- Base commit: 80e52496d2696c3b579c911ee7c07f9588b368a6
- Owner: codex
- Status: Ready for Integration
## Scope
- Add an in-app enlarged image viewer to generated images in AI Design task history, on desktop and compact layouts. Preserve the existing download action.
- Own DesignPlanHistory, its new image-preview component, focused unit/Electron coverage, README, and this record.
## Intent And Constraints
- Concurrent Gate Passed: bundled check/start succeeded and status confirmed the exact task, feature mode, branch, worktree and base above. Main contains three pre-existing untracked task records, so work is isolated from main.
- Planning Gate Passed: read agent entry, own task, memory index, positioning, integrated state, decision index, architecture, domain/success criteria, ADR-007, evidence/reflection/commitment/stale indexes and peer task scopes. Most registered peers are completed packaging/integration or historical reviews; active known work is unrelated, except an old Enter-key E2E test in a retired file. Placeholder peer scopes remain unknown, with no concrete semantic dependency on this feature.
- Positioning contains placeholders; use README, executable code and accepted V2 rules. Asset URL resolution remains through the existing Main-owned Host API helper. No contract, generation, billing, provider, persistence or global theme change.
- User prohibits subagents without consent; implementation and verification are performed directly.
- Implement a persistent enlarge icon beside download, a light modal that fits the complete image, optional native-size scrolling, load/error state, and accessible close/Escape behavior. Reset preview across Workspace switches.
- Verify selected-asset correctness, absence of download side effects, failures and dismissal with focused tests; verify rendered dimensions and desktop/compact interaction in Electron, then typecheck/lint/build for renderer integration.
## Outcome
- Generated image thumbnails now expose a persistent enlarge button beside the existing download button in task history. Desktop rail and compact conversation history share the same implementation.
- The light in-app dialog uses the existing canonical Asset content URL, initially fits the complete image, and supports native-size scrolling, dimensions, loading/error feedback, close button, Escape, and focus restoration. Viewing never calls the download endpoint. Video actions remain unchanged.
- Workspace changes remount history content to dismiss the old preview; reopening resets the viewer and permits retry after a load error. README reflects the new behavior.
- Feature complete in the isolated task branch; no main merge, push, package or installed-client update performed.
## Verification
- Exact pinned pnpm 10.33.4; frozen-lockfile install succeeded without lockfile changes.
- `pnpm run typecheck`: passed.
- `pnpm exec vitest run tests/unit/design-image-preview.test.tsx tests/unit/image-canvas-page.test.tsx tests/unit/image-workspace-api.test.ts --maxWorkers=4`: 31 passed, including five new preview cases. Covers selected result, image-only action, fit/native mode, reopening, close/Escape/focus, independent download, URL/image failures, and Workspace changes.
- Scoped ESLint over both changed product files and both test files: passed with no warnings or errors.
- `pnpm run build:vite`: all Renderer/Main/Preload/utility targets passed. Existing Browserslist age, chunk-size and mixed-import warnings remain; none prevented compilation.
- `pnpm exec playwright test tests/e2e/image-workspace-v2.spec.ts -g 'keeps conversation'`: 1 passed on Windows Electron. Existing V2 edit/confirmation flow remains covered; completed task preview verifies a 1600×2400 fixture, fit behavior, exact native dimensions and scrolling, Escape/focus restoration, 375px compact bounds and close action, with zero download requests.
- Electron uses deterministic local Host API/media fixtures; no paid generation or production asset access is claimed. The full unrelated unit suite was not run.
- Task Documentation Gate: reviewed changes against recorded base; `git diff --check` and the bundled task-aware documentation drift check passed.
## Follow-ups
- Integrate this feature branch and rebuild the desktop client to deliver the button to installed users.
## Promotion Candidates
- None. This is a local presentation improvement within the accepted V2 Asset and Main-network boundaries; README documents the user-visible behavior without changing canonical architecture or domain rules.

View File

@@ -13,7 +13,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接可以在发布智能体前先扫码之后选择或更换目标智能体多个微信账号可以使用同一个已发布智能体切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开不提供联系人邀请或授权调用者管理。Agent 的「使用与分享」只展示已关联渠道并提供定位入口。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。自动任务可明确选择把结果发送到已经开始对话的微信账号,暂停保留目标;渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录,已移除的联系人权限操作记录只能丢弃。用户与应用分享不属于微信渠道账号管理范围。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore RobotAI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main再由 Main 调用 Works Square成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { ImageIcon, Loader2, Maximize2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { resolveImageWorkspaceAssetUrl } from '@/lib/image-workspace';
import type { DesignAsset } from '../../../shared/image-workspace';
function ImagePreviewContent({ asset }: { asset: DesignAsset }) {
const [source, setSource] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const [originalSize, setOriginalSize] = useState(false);
useEffect(() => {
let active = true;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((url) => { if (active) setSource(url); })
.catch(() => { if (active) setFailed(true); });
return () => { active = false; };
}, [asset.contentPath]);
return (
<>
<header className="flex items-center justify-between gap-3 border-b border-border/70 px-4 py-3">
<div className="min-w-0">
<DialogTitle className="text-base"></DialogTitle>
<DialogDescription className="mt-1 text-xs">
{asset.width} × {asset.height}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={!loaded || failed}
onClick={() => setOriginalSize((value) => !value)}
>
{originalSize ? '适应窗口' : '原始尺寸'}
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="icon" aria-label="关闭图片预览">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</DialogClose>
</div>
</header>
<div className="relative min-h-0 overflow-auto bg-surface-subtle/50" data-testid="design-image-preview-viewport">
{failed ? (
<div role="alert" className="flex h-full flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground">
<ImageIcon className="h-8 w-8" aria-hidden="true" />
</div>
) : (
<>
{!loaded && (
<div role="status" className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
</div>
)}
{source && (
<img
src={source}
alt="AI 作品大图"
className={originalSize ? 'block max-w-none' : 'h-full w-full object-contain'}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
)}
</>
)}
</div>
</>
);
}
export function DesignImagePreviewButton({ asset }: { asset: DesignAsset }) {
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
aria-label="放大查看图片"
title="放大查看图片"
className="absolute bottom-1 left-1 flex h-7 w-7 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</DialogTrigger>
<DialogContent className="h-[85vh] max-w-[min(94vw,100rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
<ImagePreviewContent key={asset.contentPath} asset={asset} />
</DialogContent>
</Dialog>
);
}

View File

@@ -4,6 +4,7 @@ import { saveImageWorkspaceAsset } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import type { DesignGenerationTask, DesignWorkspace } from '../../../shared/image-workspace';
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
import { DesignImagePreviewButton } from './DesignImagePreview';
import { youthTaskIssueMessage } from './youth-issue-copy';
const STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
@@ -41,7 +42,7 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
if (tasks.length === 0) return null;
return (
<div className="space-y-2" data-testid="design-plan-history">
<div key={workspace.workspace.workspaceId} className="space-y-2" data-testid="design-plan-history">
{tasks.map((task) => {
const resultAssets = task.resultAssetIds
.map((assetId) => workspace.assets.find((asset) => asset.assetId === assetId) ?? null)
@@ -111,6 +112,7 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
{resultAssets.map((asset) => (
<div key={asset.assetId} className="group/result relative">
<DesignAssetThumbnail asset={asset} className="h-20 w-20 rounded-xl" />
{asset.mediaType === 'image' && <DesignImagePreviewButton asset={asset} />}
<button
type="button"
aria-label="保存作品到电脑"

View File

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

View File

@@ -0,0 +1,126 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveImageWorkspaceAssetUrl, saveImageWorkspaceAsset } from '@/lib/image-workspace';
import { DesignHistoryRail } from '@/pages/ImageCanvas/DesignHistoryRail';
import type { DesignAsset } from '../../shared/image-workspace';
import { designQuoteFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2';
vi.mock('@/lib/image-workspace', () => ({
resolveImageWorkspaceAssetUrl: vi.fn(),
saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }),
}));
function assetFixture(assetId: string, mediaType: DesignAsset['mediaType'] = 'image'): DesignAsset {
return {
assetId, workspaceId: 'workspace-1', role: 'generated', mediaType,
mimeType: mediaType === 'image' ? 'image/png' : 'video/mp4',
width: 1600, height: 2400, durationMilliseconds: null,
generationTaskId: 'task-1', createdAt: '2026-09-15T06:00:00.000Z',
contentPath: `/api/works/image-workspace/workspaces/workspace-1/assets/${assetId}/content`,
};
}
function completedWorkspace(assets = [assetFixture('image-1')]) {
const quote = designQuoteFixture();
return designWorkspaceFixture({
assets,
tasks: [{
taskId: 'task-1', quoteId: quote.quoteId, status: 'succeeded', taskRevision: 2,
specificationRevision: quote.specificationRevision,
specificationRevisionId: quote.specificationRevisionId,
specificationDigest: quote.specificationDigest,
compilerVersion: quote.compilerVersion, medium: 'image',
outputSummary: { ...quote.outputSummary, outputCount: assets.length },
maximumCustomerChargeAtoms: 1200,
customerBilling: { quotedAtoms: 1200, heldAtoms: 0, chargedAtoms: 1200, refundedAtoms: 0, pendingResolutionAtoms: 0 },
customerHoldExpiresAt: quote.expiresAt, resolutionDeadlineAt: null,
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
executionHealth: 'normal', cancellation: { state: 'unavailable', outcome: null },
issues: [], resultAssetIds: assets.map((asset) => asset.assetId), nextActions: ['view_result'],
createdAt: '2026-09-15T06:00:00.000Z',
}],
});
}
function expandTask() {
screen.getByTestId('design-plan-history-task-1').setAttribute('open', '');
}
describe('AI Design task image preview', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveImageWorkspaceAssetUrl).mockImplementation(async (path) => `http://localhost${path}`);
});
it('opens the selected result without downloading and restores fit mode when reopened', async () => {
const workspace = completedWorkspace([assetFixture('image-1'), assetFixture('image-2'), assetFixture('video-1', 'video')]);
render(<DesignHistoryRail workspace={workspace} />);
expandTask();
const buttons = screen.getAllByRole('button', { name: '放大查看图片' });
expect(buttons).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '保存作品到电脑' })).toHaveLength(3);
fireEvent.click(buttons[1]);
const dialog = screen.getByRole('dialog', { name: '图片预览' });
const image = await within(dialog).findByRole('img', { name: 'AI 作品大图' });
expect(image).toHaveAttribute('src', `http://localhost${workspace.assets[1].contentPath}`);
expect(image).toHaveClass('object-contain');
expect(within(dialog).getByRole('status')).toHaveTextContent('正在加载图片');
fireEvent.load(image);
fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' }));
expect(image).toHaveClass('max-w-none');
fireEvent.click(within(dialog).getByRole('button', { name: '适应窗口' }));
expect(image).toHaveClass('object-contain');
fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' }));
fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片预览' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(buttons[0]);
const reopenedImage = await screen.findByRole('img', { name: 'AI 作品大图' });
expect(reopenedImage).toHaveAttribute('src', `http://localhost${workspace.assets[0].contentPath}`);
expect(reopenedImage).toHaveClass('object-contain');
expect(saveImageWorkspaceAsset).not.toHaveBeenCalled();
});
it('closes with Escape and keeps the existing download action independent', async () => {
const workspace = completedWorkspace();
render(<DesignHistoryRail workspace={workspace} />);
expandTask();
const trigger = screen.getByRole('button', { name: '放大查看图片' });
fireEvent.click(trigger);
await screen.findByRole('img', { name: 'AI 作品大图' });
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: '保存作品到电脑' }));
expect(saveImageWorkspaceAsset).toHaveBeenCalledExactlyOnceWith(workspace.assets[0]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it.each(['url', 'image'] as const)('shows a recoverable message for %s loading failures', async (failure) => {
render(<DesignHistoryRail workspace={completedWorkspace()} />);
expandTask();
if (failure === 'url') {
vi.mocked(resolveImageWorkspaceAssetUrl).mockRejectedValueOnce(new Error('unavailable'));
}
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
if (failure === 'image') fireEvent.error(await screen.findByRole('img', { name: 'AI 作品大图' }));
expect(await screen.findByRole('alert')).toHaveTextContent('图片暂时无法加载,请关闭后重试');
expect(screen.getByRole('button', { name: '原始尺寸' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '关闭图片预览' }));
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
fireEvent.load(await screen.findByRole('img', { name: 'AI 作品大图' }));
expect(screen.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('removes the open preview when switching design projects', async () => {
const workspace = completedWorkspace();
const { rerender } = render(<DesignHistoryRail workspace={workspace} />);
expandTask();
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
await screen.findByRole('img', { name: 'AI 作品大图' });
rerender(<DesignHistoryRail workspace={{ ...workspace, workspace: { ...workspace.workspace, workspaceId: 'workspace-2' } }} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});