Files
Genarrative/src/components/common/CreativeImageInputPanel.test.tsx
T
kdletters f7126f9556 接入移动端壳相机图片导入能力
新增 HostBridge file.captureImage 契约与 H5 facade

Expo 移动壳通过系统相机拍摄图片并复用图片导入校验

通用图片输入面板按宿主能力展示拍摄入口并转换为现有 File 回调

补充移动壳、HostBridge、图片面板测试和原生壳文档
2026-06-18 06:03:45 +08:00

1276 lines
45 KiB
TypeScript

/* @vitest-environment jsdom */
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import {
canUseNativeHostCapability,
captureHostImageFile,
importHostImageFile,
subscribeHostImageDrop,
} from '../../services/host-bridge/hostBridge';
import { CreativeImageInputPanel } from './CreativeImageInputPanel';
vi.mock('../../services/host-bridge/hostBridge', () => ({
captureHostImageFile: vi.fn(),
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
subscribeHostImageDrop: vi.fn(() => () => undefined),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
const captureHostImageFileMock = vi.mocked(captureHostImageFile);
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
const importHostImageFileMock = vi.mocked(importHostImageFile);
const subscribeHostImageDropMock = vi.mocked(subscribeHostImageDrop);
type HostImageDropListener = Parameters<typeof subscribeHostImageDrop>[0];
afterEach(() => {
vi.clearAllMocks();
canUseNativeHostCapabilityMock.mockReturnValue(false);
subscribeHostImageDropMock.mockReturnValue(() => undefined);
});
function requireHostImageDropListener(
listener: HostImageDropListener | null,
) {
expect(listener).not.toBeNull();
return listener as HostImageDropListener;
}
test('creative image input panel handles reference uploads and preview', () => {
const onPromptReferenceFilesSelect = vi.fn();
const onPromptReferenceRemove = vi.fn();
const onSubmit = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'ref-1',
label: '参考图 1',
imageSrc: 'data:image/png;base64,ref-1',
},
]}
imageModelPicker={<div />}
submitLabel="生成"
submitCostLabel="2泥点"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onPromptReferenceRemove={onPromptReferenceRemove}
onSubmit={onSubmit}
/>,
);
const promptReferenceInput = screen.getByLabelText('上传参考图', {
selector: 'input',
});
const promptTextarea = screen.getByLabelText('画面描述');
const imageFieldLabel = screen.getByText('拼图画面');
const promptFieldLabel = screen.getByText('画面描述');
const emptyMainImageIconBadge = document.querySelector(
'[aria-hidden="true"]',
);
expect(imageFieldLabel.className).toContain('shrink-0');
expect(imageFieldLabel.className).toContain('text-sm font-black');
expect(promptFieldLabel.className).toContain('mb-0');
expect(promptFieldLabel.className).toContain('text-sm font-black');
expect(promptTextarea.className).toContain(
'border border-[var(--platform-subpanel-border)]',
);
expect(promptTextarea.className).toContain('rounded-[1.15rem]');
expect(promptTextarea.className).toContain('pb-14');
expect(emptyMainImageIconBadge?.className).toContain('h-14');
expect(emptyMainImageIconBadge?.className).toContain('rounded-full');
expect(emptyMainImageIconBadge?.className).toContain('bg-white/92');
expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true);
fireEvent.change(promptReferenceInput, {
target: {
files: [
new File(['a'], 'ref-1.png', { type: 'image/png' }),
new File(['b'], 'ref-2.png', { type: 'image/png' }),
],
},
});
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith(
expect.arrayContaining([expect.any(File), expect.any(File)]),
);
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
expect(screen.getByRole('dialog', { name: '参考图 1' })).toBeTruthy();
expect(screen.getByAltText('参考图预览')).toHaveProperty(
'src',
expect.stringContaining('ref-1'),
);
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '移除参考图 参考图 1' }));
expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1');
const costBadge = screen.getByText('2泥点');
expect(costBadge.className).toContain('rounded-full');
expect(costBadge.className).toContain('bg-white/24');
expect(costBadge.className).toContain('text-current');
fireEvent.click(screen.getByRole('button', { name: /生成/u }));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel can opt out of filling the parent height', () => {
const { container } = render(
<CreativeImageInputPanel
fillHeight={false}
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector(
'.creative-image-input-panel__section',
);
expect(panel?.className).toContain('flex-none');
expect(panel?.className).not.toContain('flex-1');
expect(body?.className).toContain('flex-none');
expect(body?.className).not.toContain('overflow-y-auto');
expect(section?.className).toContain('flex-none');
expect(section?.className).not.toContain('overflow-hidden');
});
test('creative image input panel fills the parent height by default', () => {
const { container } = render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector(
'.creative-image-input-panel__section',
);
expect(panel?.className).toContain('flex-1');
expect(panel?.className).not.toContain('flex-none');
expect(body?.className).toContain('flex-1');
expect(body?.className).toContain('overflow-y-auto');
expect(section?.className).toContain('flex-1');
expect(section?.className).toContain('overflow-hidden');
});
test('creative image input panel confirms before removing uploaded image', () => {
const onMainImageRemove = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="data:image/png;base64,main"
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面AI重绘要求(提示词)"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={<div />}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={onMainImageRemove}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' });
expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
});
test('creative image input panel closes reference preview with close button', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'ref-1',
label: '参考图 1',
imageSrc: 'data:image/png;base64,ref-1',
},
]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={() => {}}
onPromptReferenceRemove={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
expect(screen.getByRole('dialog', { name: '参考图 1' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
});
test('creative image input panel supports a preview-only main image mode', () => {
const onSubmit = vi.fn();
render(
<CreativeImageInputPanel
mainImageMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/ui/background.png"
uploadedImageAlt="UI背景预览"
mainImageInputId="background-image-upload-input"
promptTextareaId="background-prompt-input"
prompt="雨夜猫街竖屏拼图UI背景"
promptLabel="UI背景提示词"
aiRedraw={false}
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成UI背景"
submitDisabled={false}
labels={{
imageField: 'UI背景预览',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={onSubmit}
/>,
);
expect(screen.getByAltText('UI背景预览').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background.png',
);
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.getByLabelText('UI背景提示词')).toHaveProperty(
'value',
'雨夜猫街竖屏拼图UI背景',
);
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel can preview the main image and keep upload on a corner button', () => {
const onMainImageFileSelect = vi.fn();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
try {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
const previewDialog = screen.getByRole('dialog', {
name: '查看关卡图片',
});
expect(previewDialog.className).toContain('platform-image-preview-modal');
expect(previewDialog.className).toContain('bg-black');
expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy();
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(
2,
);
fireEvent.click(screen.getByRole('button', { name: '关闭关卡图片预览' }));
expect(
screen.queryByRole('dialog', { name: '查看关卡图片' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1);
fireEvent.change(
screen.getByLabelText('上传参考图', { selector: 'input' }),
{
target: {
files: [
new File(['a'], 'level-reference.png', { type: 'image/png' }),
],
},
},
);
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel closes main image preview with close button', () => {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭关卡图片预览' }));
expect(screen.queryByRole('dialog', { name: '查看关卡图片' })).toBeNull();
});
test('creative image input panel can hide upload and history controls independently', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canUploadMainImage={false}
canUseImageHistory={false}
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onHistoryClick={() => {}}
onSubmit={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '更换参考图' })).toBeNull();
expect(
screen.queryByLabelText('上传参考图', { selector: 'input' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
});
test('creative image input panel uses floating icon button for history action', () => {
const onHistoryClick = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传描述参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
history: '选择历史图片',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onHistoryClick={onHistoryClick}
onSubmit={() => {}}
/>,
);
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
expect(within(historyButton).getByText('历史')).toBeTruthy();
expect(historyButton.className).toContain('bg-white/94');
expect(historyButton.className).toContain('backdrop-blur');
expect(historyButton.className).toContain('gap-1.5');
fireEvent.click(historyButton);
expect(onHistoryClick).toHaveBeenCalledTimes(1);
});
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canRemoveMainImage={false}
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面AI重绘要求(提示词)"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
expect(screen.getByAltText('拼图关卡图')).toBeTruthy();
expect(screen.queryByText('上传图片/填写画面描述')).toBeNull();
expect(screen.queryByRole('button', { name: '移除参考图' })).toBeNull();
});
test('creative image input panel can show an image without exposing AI redraw controls', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canToggleAiRedraw={false}
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
expect(screen.getByAltText('拼图关卡图')).toBeTruthy();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.getByLabelText('画面描述')).toBeTruthy();
});
test('creative image input panel can upload prompt references while showing a main image', () => {
const onPromptReferenceFilesSelect = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canUploadPromptReferences
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'prompt-ref-1',
label: '描述参考图 1',
imageSrc: 'data:image/png;base64,prompt-ref-1',
},
]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传主图',
replaceImage: '更换主图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除主图',
removeImageConfirmTitle: '移除主图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onSubmit={() => {}}
/>,
);
const promptReferenceInput = screen.getByLabelText('上传参考图', {
selector: 'input',
});
fireEvent.change(promptReferenceInput, {
target: {
files: [new File(['a'], 'prompt-reference.png', { type: 'image/png' })],
},
});
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([expect.any(File)]);
expect(
screen.getByRole('button', { name: '预览参考图 描述参考图 1' }),
).toBeTruthy();
});
test('creative image input panel imports the main image from native host capability', async () => {
const onMainImageFileSelect = vi.fn();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
canUseNativeHostCapabilityMock.mockReturnValue(true);
importHostImageFileMock.mockResolvedValue({
action: 'selected',
fileName: '参考图.png',
mimeType: 'image/png',
base64Data: 'aW1hZ2U=',
bytes: 5,
});
try {
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="native-main-image-upload-input"
promptTextareaId="native-image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传拼图图片' }));
await waitFor(() => {
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
});
const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File;
expect(selectedFile.name).toBe('参考图.png');
expect(selectedFile.type).toBe('image/png');
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel imports prompt reference image from native host capability', async () => {
const onPromptReferenceFilesSelect = vi.fn();
canUseNativeHostCapabilityMock.mockReturnValue(true);
importHostImageFileMock.mockResolvedValue({
action: 'selected',
fileName: '描述参考.webp',
mimeType: 'image/webp',
base64Data: 'cmVm',
bytes: 3,
});
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="native-reference-main-image-upload-input"
promptTextareaId="native-reference-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
await waitFor(() => {
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([
expect.any(File),
]);
});
const selectedFile = onPromptReferenceFilesSelect.mock.calls[0]?.[0]?.[0] as
| File
| undefined;
expect(selectedFile?.name).toBe('描述参考.webp');
expect(selectedFile?.type).toBe('image/webp');
});
test('creative image input panel captures the main image from native host capability', async () => {
const onMainImageFileSelect = vi.fn();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.captureImage',
);
captureHostImageFileMock.mockResolvedValue({
action: 'captured',
fileName: '拍摄图.jpg',
mimeType: 'image/jpeg',
base64Data: 'Y2FtZXJh',
bytes: 6,
});
try {
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="native-capture-main-image-input"
promptTextareaId="native-capture-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拍摄图片' }));
await waitFor(() => {
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
});
const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File;
expect(selectedFile.name).toBe('拍摄图.jpg');
expect(selectedFile.type).toBe('image/jpeg');
expect(inputClickSpy).not.toHaveBeenCalled();
expect(importHostImageFileMock).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel captures prompt reference image from native host capability', async () => {
const onPromptReferenceFilesSelect = vi.fn();
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.captureImage',
);
captureHostImageFileMock.mockResolvedValue({
action: 'captured',
fileName: '参考拍摄.png',
mimeType: 'image/png',
base64Data: 'cmVm',
bytes: 3,
});
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="native-capture-reference-main-image-input"
promptTextareaId="native-capture-reference-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onSubmit={() => {}}
/>,
);
const captureButtons = screen.getAllByRole('button', { name: '拍摄图片' });
expect(captureButtons).toHaveLength(2);
fireEvent.click(captureButtons[1] as HTMLElement);
await waitFor(() => {
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([
expect.any(File),
]);
});
const selectedFile = onPromptReferenceFilesSelect.mock.calls[0]?.[0]?.[0] as
| File
| undefined;
expect(selectedFile?.name).toBe('参考拍摄.png');
expect(selectedFile?.type).toBe('image/png');
expect(importHostImageFileMock).not.toHaveBeenCalled();
});
test('creative image input panel keeps callbacks quiet when native image capture is cancelled', async () => {
const onMainImageFileSelect = vi.fn();
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.captureImage',
);
captureHostImageFileMock.mockResolvedValue(false);
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="cancelled-native-capture-image-input"
promptTextareaId="cancelled-native-capture-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拍摄图片' }));
await waitFor(() => {
expect(captureHostImageFileMock).toHaveBeenCalledTimes(1);
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
});
test('creative image input panel keeps callbacks quiet when native image import is cancelled', async () => {
const onMainImageFileSelect = vi.fn();
canUseNativeHostCapabilityMock.mockReturnValue(true);
importHostImageFileMock.mockResolvedValue(false);
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="cancelled-native-image-upload-input"
promptTextareaId="cancelled-native-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传拼图图片' }));
await waitFor(() => {
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
});
test('creative image input panel accepts desktop host image drops inside the main image card', async () => {
const onMainImageFileSelect = vi.fn();
let dropListener: HostImageDropListener | null = null;
canUseNativeHostCapabilityMock.mockImplementation(
(capability) =>
capability === 'file.importImage' || capability === 'file.imageDropped',
);
subscribeHostImageDropMock.mockImplementation((listener) => {
dropListener = listener;
return () => undefined;
});
const { container } = render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="dropped-main-image-upload-input"
promptTextareaId="dropped-native-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const card = container.querySelector('.creative-image-input-panel__image-card');
expect(card).not.toBeNull();
vi.spyOn(card as Element, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: 20,
right: 210,
bottom: 220,
width: 200,
height: 200,
x: 10,
y: 20,
toJSON: () => ({}),
});
const elementFromPointMock = vi.fn(() => card);
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: elementFromPointMock,
});
requireHostImageDropListener(dropListener)({
action: 'dropped',
fileName: '拖入图.webp',
mimeType: 'image/webp',
base64Data: 'ZHJvcA==',
bytes: 4,
position: {
x: 32,
y: 48,
},
});
await waitFor(() => {
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
});
const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File;
expect(selectedFile.name).toBe('拖入图.webp');
expect(selectedFile.type).toBe('image/webp');
Reflect.deleteProperty(document, 'elementFromPoint');
});
test('creative image input panel ignores desktop host image drops outside or behind another element', () => {
const onMainImageFileSelect = vi.fn();
let dropListener: HostImageDropListener | null = null;
canUseNativeHostCapabilityMock.mockImplementation(
(capability) =>
capability === 'file.importImage' || capability === 'file.imageDropped',
);
subscribeHostImageDropMock.mockImplementation((listener) => {
dropListener = listener;
return () => undefined;
});
const { container } = render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="ignored-drop-main-image-upload-input"
promptTextareaId="ignored-drop-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const card = container.querySelector('.creative-image-input-panel__image-card');
expect(card).not.toBeNull();
vi.spyOn(card as Element, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: 20,
right: 210,
bottom: 220,
width: 200,
height: 200,
x: 10,
y: 20,
toJSON: () => ({}),
});
const emitDrop = requireHostImageDropListener(dropListener);
emitDrop({
action: 'dropped',
fileName: '卡片外.png',
mimeType: 'image/png',
base64Data: 'b3V0',
bytes: 3,
position: {
x: 4,
y: 48,
},
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
const overlay = document.createElement('div');
const elementFromPointMock = vi.fn(() => overlay);
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: elementFromPointMock,
});
emitDrop({
action: 'dropped',
fileName: '遮挡图.png',
mimeType: 'image/png',
base64Data: 'b3Zlcg==',
bytes: 4,
position: {
x: 32,
y: 48,
},
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
Reflect.deleteProperty(document, 'elementFromPoint');
});