0cb75f14e5
创作 Agent 轻输入参考图在原生壳内优先走宿主图片导入 移动壳声明拍摄能力时展示拍摄参考图入口 同步 HostBridge 测试、协议文档和共享记忆
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
canUseNativeHostCapability,
|
|
captureHostImageFile,
|
|
importHostImageFile,
|
|
} from '../../services/host-bridge/hostBridge';
|
|
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
|
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
|
|
|
|
vi.mock('../../services/host-bridge/hostBridge', () => ({
|
|
canUseNativeHostCapability: vi.fn(() => false),
|
|
captureHostImageFile: vi.fn(),
|
|
importHostImageFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../services/puzzleReferenceImage', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../services/puzzleReferenceImage')
|
|
>('../../services/puzzleReferenceImage');
|
|
|
|
return {
|
|
...actual,
|
|
readPuzzleReferenceImageAsDataUrl: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
|
|
const captureHostImageFileMock = vi.mocked(captureHostImageFile);
|
|
const importHostImageFileMock = vi.mocked(importHostImageFile);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
canUseNativeHostCapabilityMock.mockReturnValue(false);
|
|
captureHostImageFileMock.mockResolvedValue(false);
|
|
importHostImageFileMock.mockResolvedValue(false);
|
|
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset();
|
|
});
|
|
|
|
test('submits typed text with uploaded reference image', async () => {
|
|
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
|
'data:image/png;base64,reference',
|
|
);
|
|
const user = userEvent.setup();
|
|
const onSubmit = vi.fn();
|
|
render(<CreativeAgentInputComposer isBusy={false} onSubmit={onSubmit} />);
|
|
|
|
const composer = screen.getByLabelText('智能创作输入').closest('section');
|
|
expect(composer?.className).toContain('platform-subpanel');
|
|
expect(composer?.className).toContain('creative-agent-composer');
|
|
expect(screen.getByLabelText('智能创作输入').className).toContain(
|
|
'bg-white/90',
|
|
);
|
|
expect(screen.getByLabelText('智能创作输入').className).toContain(
|
|
'focus:ring-[var(--platform-warm-border)]',
|
|
);
|
|
|
|
const file = new File(['reference'], '参考图.png', { type: 'image/png' });
|
|
const uploadInput = screen.getByLabelText('添加参考图', {
|
|
selector: 'input',
|
|
});
|
|
fireEvent.change(uploadInput, { target: { files: [file] } });
|
|
|
|
expect(await screen.findByText('参考图.png')).toBeTruthy();
|
|
expect(screen.getByAltText('创作参考图').closest('div')?.className).toContain(
|
|
'h-12 w-12',
|
|
);
|
|
expect(screen.getByText('参考图.png').parentElement?.className).toContain(
|
|
'bg-white/68',
|
|
);
|
|
await user.type(screen.getByLabelText('智能创作输入'), '做成森林拼图');
|
|
await user.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
expect(onSubmit).toHaveBeenCalledWith({
|
|
text: '做成森林拼图',
|
|
image: {
|
|
imageUrl: 'data:image/png;base64,reference',
|
|
thumbnailUrl: 'data:image/png;base64,reference',
|
|
label: '参考图.png',
|
|
},
|
|
});
|
|
expect(screen.queryByText('参考图.png')).toBeNull();
|
|
});
|
|
|
|
test('imports reference image through native HostBridge', async () => {
|
|
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
|
'data:image/png;base64,host-reference',
|
|
);
|
|
canUseNativeHostCapabilityMock.mockImplementation(
|
|
(capability) => capability === 'file.importImage',
|
|
);
|
|
importHostImageFileMock.mockResolvedValue({
|
|
action: 'selected',
|
|
fileName: '宿主参考图.png',
|
|
base64Data: 'aG9zdA==',
|
|
mimeType: 'image/png',
|
|
bytes: 4,
|
|
});
|
|
const inputClickSpy = vi
|
|
.spyOn(HTMLInputElement.prototype, 'click')
|
|
.mockImplementation(() => undefined);
|
|
|
|
try {
|
|
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
|
|
|
expect(await screen.findByText('宿主参考图.png')).toBeTruthy();
|
|
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
|
|
expect(readPuzzleReferenceImageAsDataUrl).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: '宿主参考图.png',
|
|
type: 'image/png',
|
|
size: 4,
|
|
}),
|
|
);
|
|
expect(inputClickSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
inputClickSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
test('keeps native image cancellation inside shell flow', async () => {
|
|
canUseNativeHostCapabilityMock.mockImplementation(
|
|
(capability) => capability === 'file.importImage',
|
|
);
|
|
importHostImageFileMock.mockResolvedValue(false);
|
|
const inputClickSpy = vi
|
|
.spyOn(HTMLInputElement.prototype, 'click')
|
|
.mockImplementation(() => undefined);
|
|
|
|
try {
|
|
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
|
|
|
await waitFor(() => expect(importHostImageFileMock).toHaveBeenCalledTimes(1));
|
|
expect(inputClickSpy).not.toHaveBeenCalled();
|
|
expect(readPuzzleReferenceImageAsDataUrl).not.toHaveBeenCalled();
|
|
} finally {
|
|
inputClickSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
test('captures reference image through native HostBridge', async () => {
|
|
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
|
'data:image/png;base64,captured-reference',
|
|
);
|
|
canUseNativeHostCapabilityMock.mockImplementation(
|
|
(capability) => capability === 'file.captureImage',
|
|
);
|
|
captureHostImageFileMock.mockResolvedValue({
|
|
action: 'captured',
|
|
fileName: '拍摄参考图.png',
|
|
base64Data: 'Y2FtZXJh',
|
|
mimeType: 'image/png',
|
|
bytes: 6,
|
|
});
|
|
|
|
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
|
|
|
|
expect(screen.getByRole('button', { name: '拍摄参考图' })).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: '拍摄参考图' }));
|
|
|
|
expect(await screen.findByText('拍摄参考图.png')).toBeTruthy();
|
|
expect(captureHostImageFileMock).toHaveBeenCalledTimes(1);
|
|
expect(readPuzzleReferenceImageAsDataUrl).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: '拍摄参考图.png',
|
|
type: 'image/png',
|
|
size: 6,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('removes selected reference image before submit', async () => {
|
|
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
|
'data:image/png;base64,reference',
|
|
);
|
|
const onSubmit = vi.fn();
|
|
render(<CreativeAgentInputComposer isBusy={false} onSubmit={onSubmit} />);
|
|
|
|
const file = new File(['reference'], '参考图.png', { type: 'image/png' });
|
|
fireEvent.change(screen.getByLabelText('添加参考图', { selector: 'input' }), {
|
|
target: { files: [file] },
|
|
});
|
|
expect(await screen.findByText('参考图.png')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '移除参考图' }));
|
|
|
|
await waitFor(() => expect(screen.queryByText('参考图.png')).toBeNull());
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('shows failed reference image reads with shared status chrome', async () => {
|
|
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockRejectedValue(
|
|
new Error('参考图太大'),
|
|
);
|
|
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
|
|
|
|
const file = new File(['reference'], '参考图.png', { type: 'image/png' });
|
|
fireEvent.change(screen.getByLabelText('添加参考图', { selector: 'input' }), {
|
|
target: { files: [file] },
|
|
});
|
|
|
|
const message = await screen.findByText('参考图太大');
|
|
|
|
expect(message.className).toContain(
|
|
'border-[var(--platform-button-danger-border)]',
|
|
);
|
|
expect(message.className).toContain(
|
|
'bg-[var(--platform-button-danger-fill)]',
|
|
);
|
|
expect(message.className).toContain('text-xs');
|
|
});
|