/* @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 ? {alt} : null), })); const captureHostImageFileMock = vi.mocked(captureHostImageFile); const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability); const importHostImageFileMock = vi.mocked(importHostImageFile); const subscribeHostImageDropMock = vi.mocked(subscribeHostImageDrop); type HostImageDropListener = Parameters[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( } 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( {}} 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( {}} 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( } 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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'); });