386534a415
- 为避免prop drilling把原有的刷新画布集中到一个useContext中, 在此基础上实现画布聚焦, 并在画布agent中使用  Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/123 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
266 lines
7.8 KiB
TypeScript
266 lines
7.8 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from '@testing-library/react';
|
|
import type { ReactElement, ReactNode } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { EditorAgentToolCall } from '@/packages/shared/src/contracts';
|
|
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
|
|
|
|
import ToolCallView from './ToolCallView.tsx';
|
|
|
|
const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn());
|
|
const focusResourceMock = vi.fn();
|
|
const refreshCanvasMock = vi.fn();
|
|
|
|
vi.mock('@/src/services/external-generation', () => ({
|
|
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
|
|
}));
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((nextResolve) => {
|
|
resolve = nextResolve;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function createToolCall(
|
|
overrides: Partial<EditorAgentToolCall> = {},
|
|
): EditorAgentToolCall {
|
|
return {
|
|
toolName: 'generate-image',
|
|
status: 'not_completed',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
externalJobId: 'job-a',
|
|
images: [],
|
|
error: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createCompletedJobResponse(jobId: string) {
|
|
return {
|
|
job: {
|
|
operationId: jobId,
|
|
status: 'completed' as const,
|
|
phaseLabel: '已完成',
|
|
phaseDetail: '生成完成',
|
|
progress: 100,
|
|
error: null,
|
|
updatedAtMicros: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderWithCanvasActions(ui: ReactElement) {
|
|
return render(ui, {
|
|
wrapper: ({ children }: { children: ReactNode }) => (
|
|
<ImageCanvasActionsProvider
|
|
focusResource={focusResourceMock}
|
|
refreshCanvas={refreshCanvasMock}
|
|
>
|
|
{children}
|
|
</ImageCanvasActionsProvider>
|
|
),
|
|
});
|
|
}
|
|
|
|
describe('ToolCallView', () => {
|
|
beforeEach(() => {
|
|
getExternalGenerationJobStatusMock.mockReset();
|
|
focusResourceMock.mockReset();
|
|
refreshCanvasMock.mockReset();
|
|
});
|
|
|
|
it.each([
|
|
['failed', '失败', '生成失败'],
|
|
['cancelled', '已取消', null],
|
|
] as const)(
|
|
'keeps the server %s state when the pending response arrives late',
|
|
async (status, statusLabel, error) => {
|
|
const pendingResponse =
|
|
createDeferred<ReturnType<typeof createCompletedJobResponse>>();
|
|
getExternalGenerationJobStatusMock.mockReturnValueOnce(
|
|
pendingResponse.promise,
|
|
);
|
|
const onJobCompleted = vi.fn();
|
|
const { rerender } = renderWithCanvasActions(
|
|
<ToolCallView
|
|
toolCall={createToolCall()}
|
|
onJobCompleted={onJobCompleted}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith(
|
|
'job-a',
|
|
);
|
|
});
|
|
|
|
rerender(
|
|
<ToolCallView
|
|
toolCall={createToolCall({ status, error })}
|
|
onJobCompleted={onJobCompleted}
|
|
/>,
|
|
);
|
|
expect(await screen.findByText(statusLabel)).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
pendingResponse.resolve(createCompletedJobResponse('job-a'));
|
|
await pendingResponse.promise;
|
|
});
|
|
|
|
expect(screen.getByText(statusLabel)).toBeTruthy();
|
|
expect(screen.queryByText('已完成')).toBeNull();
|
|
expect(onJobCompleted).not.toHaveBeenCalled();
|
|
expect(refreshCanvasMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('ignores job A after switching to job B and completes job B once', async () => {
|
|
const jobAResponse =
|
|
createDeferred<ReturnType<typeof createCompletedJobResponse>>();
|
|
const jobBResponse =
|
|
createDeferred<ReturnType<typeof createCompletedJobResponse>>();
|
|
getExternalGenerationJobStatusMock.mockImplementation((jobId: string) =>
|
|
jobId === 'job-a' ? jobAResponse.promise : jobBResponse.promise,
|
|
);
|
|
const onJobCompleted = vi.fn();
|
|
const { rerender } = renderWithCanvasActions(
|
|
<ToolCallView
|
|
toolCall={createToolCall()}
|
|
onJobCompleted={onJobCompleted}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith('job-a');
|
|
});
|
|
|
|
rerender(
|
|
<ToolCallView
|
|
toolCall={createToolCall({ externalJobId: 'job-b' })}
|
|
onJobCompleted={onJobCompleted}
|
|
/>,
|
|
);
|
|
await waitFor(() => {
|
|
expect(getExternalGenerationJobStatusMock).toHaveBeenCalledWith('job-b');
|
|
});
|
|
|
|
await act(async () => {
|
|
jobAResponse.resolve(createCompletedJobResponse('job-a'));
|
|
await jobAResponse.promise;
|
|
});
|
|
expect(screen.getByText('执行中')).toBeTruthy();
|
|
expect(onJobCompleted).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
jobBResponse.resolve(createCompletedJobResponse('job-b'));
|
|
await jobBResponse.promise;
|
|
});
|
|
|
|
expect(await screen.findByText('已完成')).toBeTruthy();
|
|
expect(onJobCompleted).toHaveBeenCalledTimes(1);
|
|
expect(refreshCanvasMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('focuses image clicks only and exposes canvas resources to the right-click menu', () => {
|
|
const onRightClickMenu = vi.fn();
|
|
const { container } = renderWithCanvasActions(
|
|
<ToolCallView
|
|
toolCall={createToolCall({
|
|
status: 'completed',
|
|
externalJobId: null,
|
|
images: [
|
|
{
|
|
resourceId: ' resource-image ',
|
|
imageSrc: 'data:image/png;base64,aW1hZ2U=',
|
|
},
|
|
{
|
|
imageSrc: 'data:image/png;base64,bGVnYWN5',
|
|
},
|
|
],
|
|
videos: [
|
|
{
|
|
resourceId: 'resource-video',
|
|
videoSrc: 'data:video/mp4;base64,dmlkZW8=',
|
|
},
|
|
],
|
|
audios: [
|
|
{
|
|
resourceId: 'resource-audio',
|
|
audioSrc: 'data:audio/wav;base64,YXVkaW8=',
|
|
},
|
|
],
|
|
})}
|
|
onRightClickMenu={onRightClickMenu}
|
|
/>,
|
|
);
|
|
|
|
const imageCards = container.querySelectorAll('.grid.grid-cols-3 > div');
|
|
const video = container.querySelector('video');
|
|
const audio = container.querySelector('audio');
|
|
expect(imageCards).toHaveLength(2);
|
|
expect(video).toBeTruthy();
|
|
expect(audio).toBeTruthy();
|
|
expect(imageCards[0]!.getAttribute('role')).toBeNull();
|
|
expect(imageCards[0]!.getAttribute('tabindex')).toBeNull();
|
|
|
|
fireEvent.click(imageCards[0]!);
|
|
fireEvent.keyDown(imageCards[0]!, { key: 'Enter' });
|
|
fireEvent.keyDown(imageCards[0]!, { key: ' ', code: 'Space' });
|
|
fireEvent.click(imageCards[1]!);
|
|
fireEvent.click(video!);
|
|
fireEvent.click(audio!);
|
|
expect(focusResourceMock).toHaveBeenCalledTimes(1);
|
|
expect(focusResourceMock).toHaveBeenCalledWith('resource-image');
|
|
expect(
|
|
screen.queryByRole('button', {
|
|
name: '在画布中定位Agent生成图片-1',
|
|
}),
|
|
).toBeNull();
|
|
expect(video!.getAttribute('aria-label')).toBe('Agent生成视频-1');
|
|
expect(audio!.getAttribute('aria-label')).toBe('Agent生成音频-1');
|
|
|
|
fireEvent.contextMenu(imageCards[0]!);
|
|
fireEvent.contextMenu(video!.parentElement!);
|
|
fireEvent.contextMenu(audio!.parentElement!);
|
|
fireEvent.contextMenu(imageCards[1]!);
|
|
|
|
expect(onRightClickMenu.mock.calls.map((call) => call[1])).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'generated_media',
|
|
mediaType: 'image',
|
|
resourceId: 'resource-image',
|
|
}),
|
|
expect.objectContaining({
|
|
kind: 'generated_media',
|
|
mediaType: 'video',
|
|
resourceId: 'resource-video',
|
|
}),
|
|
expect.objectContaining({
|
|
kind: 'generated_media',
|
|
mediaType: 'audio',
|
|
resourceId: 'resource-audio',
|
|
}),
|
|
expect.objectContaining({
|
|
kind: 'generated_media',
|
|
mediaType: 'image',
|
|
resourceId: null,
|
|
}),
|
|
]);
|
|
});
|
|
});
|