Files
Genarrative/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx

594 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { useEffect, useRef, useState } from 'react';
import { expect, test, vi } from 'vitest';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
type TestSession = {
sessionId: string;
messages: Array<{
id: string;
role: string;
kind?: string;
text: string;
createdAt?: string;
}>;
};
type TestMessagePayload = {
clientMessageId: string;
text: string;
};
function TestHarness({
streamMessage,
}: {
streamMessage: (
sessionId: string,
payload: TestMessagePayload,
options?: { onUpdate?: (text: string) => void },
) => Promise<TestSession>;
}) {
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
TestSession,
Record<string, never>,
{ session: TestSession },
TestMessagePayload,
{ action: string },
{ session: TestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
},
}),
getSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
},
}),
streamMessage,
executeAction: async () => ({
session: {
sessionId: 'session-1',
messages: [],
},
}),
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => false,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: () => {},
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, [flow]);
return (
<div>
<button
type="button"
onClick={() => {
void flow.submitMessage({
clientMessageId: 'client-message-1',
text: '做一个办公室文具方洞挑战',
});
}}
>
</button>
<div data-testid="messages">
{flow.session?.messages.map((message) => (
<div
key={message.id}
>{`${message.role}:${message.kind}:${message.text}`}</div>
))}
</div>
{flow.error ? <div>{flow.error}</div> : null}
</div>
);
}
type ActionTestSession = TestSession & {
draft?: { profileId: string } | null;
};
function ActionErrorHarness({
onActionError,
}: {
onActionError: (params: {
session: ActionTestSession;
setSession: (session: ActionTestSession) => void;
}) => void;
}) {
const [stage, setStage] = useState('platform');
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
ActionTestSession,
Record<string, never>,
{ session: ActionTestSession },
TestMessagePayload,
{ action: string },
{ session: ActionTestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
getSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
streamMessage: async () => ({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
}),
executeAction: async () => {
throw new Error('模型生成失败');
},
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => true,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: setStage,
onActionError: ({ session, setSession }) => {
onActionError({ session, setSession });
},
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, [flow]);
return (
<div>
<button
type="button"
onClick={() => {
void flow.executeAction({ action: 'match3d_compile_draft' });
}}
>
</button>
<div data-testid="stage">{stage}</div>
<div data-testid="profile">
{flow.session?.draft?.profileId ?? 'missing'}
</div>
{flow.error ? <div>{flow.error}</div> : null}
</div>
);
}
function ActionCompleteHarness({
onActionComplete,
}: {
onActionComplete: () => { openResult?: boolean } | void;
}) {
const [stage, setStage] = useState('platform');
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
ActionTestSession,
Record<string, never>,
{ session: ActionTestSession },
TestMessagePayload,
{ action: string },
{ session: ActionTestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
getSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
streamMessage: async () => ({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
}),
executeAction: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-ready-1' },
},
}),
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => true,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: setStage,
onActionComplete: ({ setSession, response }) => {
setSession(response.session);
return onActionComplete();
},
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, [flow]);
return (
<div>
<button
type="button"
onClick={() => {
void flow.executeAction({ action: 'match3d_compile_draft' });
}}
>
</button>
<div data-testid="stage">{stage}</div>
<div data-testid="profile">
{flow.session?.draft?.profileId ?? 'missing'}
</div>
</div>
);
}
function SessionChangeHarness({
onSessionChanged,
}: {
onSessionChanged: (session: TestSession | null) => void;
}) {
const flow = usePlatformCreationAgentFlowController<
TestSession,
Record<string, never>,
{ session: TestSession },
TestMessagePayload,
{ action: string },
{ session: TestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-open',
messages: [],
},
}),
getSession: async () => ({
session: {
sessionId: 'session-restore',
messages: [],
},
}),
streamMessage: async () => ({
sessionId: 'session-open',
messages: [],
}),
executeAction: async () => ({
session: {
sessionId: 'session-compile',
messages: [],
},
}),
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: (payload) => payload.action === 'match3d_compile_draft',
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: () => {},
onSessionChanged,
onActionComplete: ({ response, setSession }) => {
setSession(response.session);
},
});
return (
<div>
<button type="button" onClick={() => void flow.openWorkspace({})}>
</button>
<button
type="button"
onClick={() => void flow.restoreDraft('session-restore')}
>
</button>
<button
type="button"
onClick={() =>
void flow.executeAction({ action: 'match3d_compile_draft' })
}
>
</button>
</div>
);
}
function SessionSetterIdentityHarness({
onSetterIdentity,
}: {
onSetterIdentity: (setter: unknown) => void;
}) {
const [renderCount, setRenderCount] = useState(0);
const flow = usePlatformCreationAgentFlowController<
TestSession,
Record<string, never>,
{ session: TestSession },
TestMessagePayload,
{ action: string },
{ session: TestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-open',
messages: [],
},
}),
getSession: async () => ({
session: {
sessionId: 'session-restore',
messages: [],
},
}),
streamMessage: async () => ({
sessionId: 'session-open',
messages: [],
}),
executeAction: async () => ({
session: {
sessionId: 'session-compile',
messages: [],
},
}),
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => false,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: () => {},
onSessionChanged: () => {},
});
useEffect(() => {
onSetterIdentity(flow.setSession);
});
return (
<button
type="button"
onClick={() => setRenderCount((current) => current + 1)}
>
{renderCount}
</button>
);
}
test('creation agent flow preserves streamed assistant text when stream fails', async () => {
const streamMessage = vi.fn(async (_sessionId, _payload, options) => {
options?.onUpdate?.('先把方洞万能的反差定住。');
throw new Error('方洞挑战聊天生成失败LLM 请求超时');
});
render(<TestHarness streamMessage={streamMessage} />);
await waitFor(() => {
expect(screen.getByRole('button', { name: '发送' })).toBeTruthy();
});
await act(async () => {
screen.getByRole('button', { name: '发送' }).click();
});
await waitFor(() => {
expect(screen.getByText('方洞挑战聊天生成失败LLM 请求超时')).toBeTruthy();
});
expect(screen.getByTestId('messages').textContent).toContain(
'user:chat:做一个办公室文具方洞挑战',
);
expect(screen.getByTestId('messages').textContent).toContain(
'assistant:warning:先把方洞万能的反差定住。',
);
});
test('creation agent flow exposes session setter after compile action fails', async () => {
const onActionError = vi.fn(
({
setSession,
}: {
session: ActionTestSession;
setSession: (session: ActionTestSession) => void;
}) => {
setSession({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-after-failure' },
});
},
);
render(<ActionErrorHarness onActionError={onActionError} />);
await waitFor(() => {
expect(screen.getByRole('button', { name: '执行' })).toBeTruthy();
});
await act(async () => {
screen.getByRole('button', { name: '执行' }).click();
});
await waitFor(() => {
expect(screen.getByText('模型生成失败')).toBeTruthy();
});
expect(onActionError).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('profile').textContent).toBe(
'profile-after-failure',
);
expect(screen.getByTestId('stage').textContent).toBe(
'match3d-agent-workspace',
);
});
test('creation agent flow suppresses compile result stage for background completion', async () => {
const onActionComplete = vi.fn(() => ({ openResult: false }));
render(<ActionCompleteHarness onActionComplete={onActionComplete} />);
await waitFor(() => {
expect(screen.getByTestId('stage').textContent).toBe(
'match3d-agent-workspace',
);
});
await act(async () => {
screen.getByRole('button', { name: '执行' }).click();
});
await waitFor(() => {
expect(onActionComplete).toHaveBeenCalledTimes(1);
});
expect(screen.getByTestId('profile').textContent).toBe('profile-ready-1');
expect(screen.getByTestId('stage').textContent).toBe(
'match3d-agent-workspace',
);
});
test('creation agent flow notifies session changes after open restore and compile', async () => {
const onSessionChanged = vi.fn();
render(<SessionChangeHarness onSessionChanged={onSessionChanged} />);
await act(async () => {
screen.getByRole('button', { name: '打开' }).click();
});
await act(async () => {
screen.getByRole('button', { name: '恢复' }).click();
});
await act(async () => {
screen.getByRole('button', { name: '编译' }).click();
});
await waitFor(() => {
expect(onSessionChanged).toHaveBeenCalledTimes(3);
});
expect(
onSessionChanged.mock.calls.map(([session]) => session?.sessionId),
).toEqual(['session-open', 'session-restore', 'session-compile']);
});
test('creation agent flow keeps session setter stable across parent rerenders', async () => {
const onSetterIdentity = vi.fn();
render(<SessionSetterIdentityHarness onSetterIdentity={onSetterIdentity} />);
await waitFor(() => {
expect(onSetterIdentity).toHaveBeenCalledTimes(1);
});
const initialSetter = onSetterIdentity.mock.calls[0]?.[0];
await act(async () => {
screen.getByRole('button', { name: //u }).click();
});
await waitFor(() => {
expect(onSetterIdentity).toHaveBeenCalledTimes(2);
});
expect(onSetterIdentity.mock.calls[1]?.[0]).toBe(initialSetter);
});