837 lines
34 KiB
TypeScript
837 lines
34 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
|
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
|
import type {
|
|
DesignAssistantDeltaEvent,
|
|
DesignConversation,
|
|
DesignConversationSnapshotEvent,
|
|
DesignGenerationTask,
|
|
DesignGenerationTaskUpdatedEvent,
|
|
DesignWorkspace,
|
|
DesignWorkspaceBootstrap,
|
|
} from '../../shared/image-workspace';
|
|
|
|
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
|
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
|
const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
|
const createImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
|
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
|
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
|
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
|
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
|
return {
|
|
...actual,
|
|
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
|
|
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
|
fetchImageWorkspaceConversation: (...args: unknown[]) => (
|
|
fetchImageWorkspaceConversationMock(...args)
|
|
),
|
|
createImageWorkspaceConversation: (...args: unknown[]) => (
|
|
createImageWorkspaceConversationMock(...args)
|
|
),
|
|
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
|
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
|
|
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
|
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
|
confirmImageWorkspaceGenerationMock(...args)
|
|
),
|
|
};
|
|
});
|
|
|
|
type EventListener = (event: MessageEvent<string>) => void;
|
|
|
|
class MockEventSource {
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
readonly close = vi.fn();
|
|
private readonly listeners = new Map<string, Set<EventListener>>();
|
|
|
|
addEventListener(type: string, listener: EventListener): void {
|
|
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
|
listeners.add(listener);
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
|
|
emit(type: string, payload: unknown): void {
|
|
const event = { data: JSON.stringify(payload) } as MessageEvent<string>;
|
|
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
}
|
|
}
|
|
|
|
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((accept) => {
|
|
resolve = accept;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
const task: DesignGenerationTask = {
|
|
taskId: 'task-one',
|
|
workspaceId: 'workspace-one',
|
|
conversationId: 'conversation-one',
|
|
medium: 'image',
|
|
status: 'queued',
|
|
briefVersion: 1,
|
|
briefSummary: '海洋公益海报',
|
|
quoteId: 'quote-one',
|
|
quotedDesignPoints: 1,
|
|
failureCode: null,
|
|
resultAssets: [],
|
|
createdAt: '2026-08-02T10:00:00Z',
|
|
updatedAt: '2026-08-02T10:00:00Z',
|
|
};
|
|
|
|
function conversation(
|
|
workspaceId = 'workspace-one',
|
|
turnRevision = 1,
|
|
conversationId = workspaceId.replace('workspace', 'conversation'),
|
|
): DesignConversation {
|
|
return {
|
|
conversationId,
|
|
workspaceId,
|
|
title: '主会话',
|
|
turnRevision,
|
|
phase: 'shaping',
|
|
brief: {
|
|
version: 1,
|
|
status: 'ready',
|
|
medium: 'image',
|
|
summary: '海洋公益海报',
|
|
ready: true,
|
|
missingDecision: null,
|
|
},
|
|
updatedAt: '2026-08-02T10:00:00Z',
|
|
messages: [],
|
|
createdAt: '2026-08-02T10:00:00Z',
|
|
};
|
|
}
|
|
|
|
function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace {
|
|
const { messages: _messages, ...conversationSummary } = conversation(workspaceId);
|
|
return {
|
|
workspaceId,
|
|
title: workspaceId,
|
|
viewRevision,
|
|
conversationCount: 1,
|
|
phase: conversationSummary.phase,
|
|
updatedAt: '2026-08-02T10:00:00Z',
|
|
conversations: [conversationSummary],
|
|
};
|
|
}
|
|
|
|
function bootstrap(workspaceIds = ['workspace-one']): DesignWorkspaceBootstrap {
|
|
return {
|
|
capabilities: { conversation: true, generation: true, image: true, video: true },
|
|
workspaces: workspaceIds.map((workspaceId) => {
|
|
const { conversations: _conversations, ...summary } = workspace(workspaceId);
|
|
return summary;
|
|
}),
|
|
};
|
|
}
|
|
|
|
function taskEvent(
|
|
workspaceViewRevision: number,
|
|
status: DesignGenerationTask['status'],
|
|
): DesignGenerationTaskUpdatedEvent {
|
|
return {
|
|
id: `session-one:${workspaceViewRevision}`,
|
|
type: 'design.generation_task.updated',
|
|
workspaceId: 'workspace-one',
|
|
workspaceViewRevision,
|
|
generationTask: {
|
|
...task,
|
|
status,
|
|
updatedAt: `2026-08-02T10:0${workspaceViewRevision}:00Z`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function taskSnapshotEvent(
|
|
workspaceViewRevision: number,
|
|
status: DesignGenerationTask['status'],
|
|
): DesignConversationSnapshotEvent {
|
|
return {
|
|
id: `session-one:${workspaceViewRevision}`,
|
|
type: 'design.conversation.snapshot',
|
|
workspaceId: 'workspace-one',
|
|
conversationId: 'conversation-one',
|
|
workspaceViewRevision,
|
|
conversation: conversation('workspace-one', workspaceViewRevision),
|
|
generationTasks: [{
|
|
...task,
|
|
status,
|
|
updatedAt: `2026-08-02T10:0${workspaceViewRevision}:00Z`,
|
|
}],
|
|
};
|
|
}
|
|
|
|
describe('AI design task event store', () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
useImageWorkspaceStore.getState().reset();
|
|
fetchImageWorkspaceMock.mockResolvedValue(bootstrap());
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace());
|
|
fetchImageWorkspaceConversationMock.mockImplementation((workspaceId: string, conversationId: string) => (
|
|
Promise.resolve(conversation(workspaceId, 1, conversationId))
|
|
));
|
|
createImageWorkspaceConversationMock.mockResolvedValue(
|
|
conversation('workspace-one', 0, 'conversation-new'),
|
|
);
|
|
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
|
|
sendImageWorkspaceMessageMock.mockResolvedValue(conversation());
|
|
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
|
...conversation('workspace-one', 2),
|
|
turnRevision: 2,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
useImageWorkspaceStore.getState().reset();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('uses task event revisions instead of the project view revision as the task watermark', async () => {
|
|
const source = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 50));
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
|
.toHaveBeenCalledWith('workspace-one', 'conversation-one'));
|
|
source.onopen?.(new Event('open'));
|
|
source.emit('design.generation_task.updated', taskEvent(3, 'failed'));
|
|
expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('failed');
|
|
source.emit('design.conversation.snapshot', taskSnapshotEvent(6, 'running'));
|
|
source.emit('design.generation_task.updated', taskEvent(5, 'failed'));
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
taskStreamState: 'connected',
|
|
workspace: { viewRevision: 50 },
|
|
tasks: [{ taskId: 'task-one', status: 'running' }],
|
|
});
|
|
});
|
|
|
|
it('assembles assistant deltas once and replaces them with the canonical snapshot', async () => {
|
|
const source = new MockEventSource();
|
|
const response = deferred<DesignConversation>();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
|
const sending = useImageWorkspaceStore.getState().sendMessage('把主视觉改成鲸鱼');
|
|
const pending = useImageWorkspaceStore.getState().pendingTurn;
|
|
expect(pending).toMatchObject({
|
|
userText: '把主视觉改成鲸鱼',
|
|
assistantText: '',
|
|
turnRevision: 2,
|
|
});
|
|
|
|
const firstDelta = {
|
|
id: 'session-one:2',
|
|
type: 'design.assistant.delta',
|
|
workspaceId: 'workspace-one',
|
|
conversationId: 'conversation-one',
|
|
clientTurnId: pending!.clientTurnId,
|
|
turnRevision: 2,
|
|
chunkIndex: 0,
|
|
delta: '可以,先强化',
|
|
} satisfies DesignAssistantDeltaEvent;
|
|
source.emit('design.assistant.delta', firstDelta);
|
|
source.emit('design.assistant.delta', firstDelta);
|
|
source.emit('design.assistant.delta', {
|
|
...firstDelta,
|
|
id: 'session-one:3',
|
|
chunkIndex: 1,
|
|
delta: '鲸鱼的轮廓。',
|
|
} satisfies DesignAssistantDeltaEvent);
|
|
|
|
expect(useImageWorkspaceStore.getState().pendingTurn?.assistantText)
|
|
.toBe('可以,先强化鲸鱼的轮廓。');
|
|
|
|
const canonical = {
|
|
...conversation('workspace-one', 2),
|
|
turnRevision: 2,
|
|
messages: [{
|
|
id: 'workspace-one:2:assistant:0',
|
|
role: 'assistant' as const,
|
|
kind: 'reply' as const,
|
|
text: '可以,先强化鲸鱼的轮廓。',
|
|
quickReplies: [],
|
|
generationQuote: null,
|
|
turnRevision: 2,
|
|
createdAt: '2026-08-02T10:01:00Z',
|
|
}],
|
|
};
|
|
source.emit('design.conversation.snapshot', {
|
|
...taskSnapshotEvent(2, 'queued'),
|
|
id: 'session-one:4',
|
|
conversation: canonical,
|
|
} satisfies DesignConversationSnapshotEvent);
|
|
|
|
expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull();
|
|
expect(useImageWorkspaceStore.getState().conversation?.messages[0].text)
|
|
.toBe('可以,先强化鲸鱼的轮廓。');
|
|
response.resolve(canonical);
|
|
await sending;
|
|
});
|
|
|
|
it('reconciles the confirmed Quote until its generation task is visible', async () => {
|
|
const source = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([task]);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await useImageWorkspaceStore.getState().confirmGeneration('quote-one');
|
|
|
|
expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledWith(
|
|
'workspace-one',
|
|
'conversation-one',
|
|
1,
|
|
'quote-one',
|
|
expect.stringMatching(/^turn-/),
|
|
);
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3);
|
|
expect(useImageWorkspaceStore.getState().tasks).toEqual([task]);
|
|
});
|
|
|
|
it('recovers a committed generation task when the Agent Run fails afterward', async () => {
|
|
const source = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([task]);
|
|
fetchImageWorkspaceConversationMock
|
|
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
|
|
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-one'));
|
|
confirmImageWorkspaceGenerationMock.mockRejectedValueOnce(
|
|
new ImageWorkspaceApiError(
|
|
503,
|
|
'agent_runtime_unavailable',
|
|
'AI 设计服务暂时不可用,请稍后重试',
|
|
),
|
|
);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await expect(useImageWorkspaceStore.getState().confirmGeneration('quote-one'))
|
|
.resolves.toMatchObject({ turnRevision: 2 });
|
|
|
|
expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce();
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
error: null,
|
|
pendingTurn: null,
|
|
conversation: { turnRevision: 2 },
|
|
tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }],
|
|
});
|
|
});
|
|
|
|
it('keeps reconciling Workspace tasks after switching Conversations during confirmation', async () => {
|
|
const first = new MockEventSource();
|
|
const second = new MockEventSource();
|
|
const confirmation = deferred<DesignConversation>();
|
|
const project = workspace();
|
|
const { messages: _messages, ...secondSummary } = conversation(
|
|
'workspace-one',
|
|
1,
|
|
'conversation-two',
|
|
);
|
|
openImageWorkspaceTaskEventsMock
|
|
.mockResolvedValueOnce(first as unknown as EventSource)
|
|
.mockResolvedValueOnce(second as unknown as EventSource);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
|
...project,
|
|
conversationCount: 2,
|
|
conversations: [...project.conversations, secondSummary],
|
|
});
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([task]);
|
|
fetchImageWorkspaceConversationMock
|
|
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
|
|
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-two'));
|
|
confirmImageWorkspaceGenerationMock.mockReturnValueOnce(confirmation.promise);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
const confirming = useImageWorkspaceStore.getState().confirmGeneration('quote-one');
|
|
await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce());
|
|
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
|
confirmation.resolve(conversation('workspace-one', 2, 'conversation-one'));
|
|
await confirming;
|
|
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-one',
|
|
activeConversationId: 'conversation-two',
|
|
conversation: { conversationId: 'conversation-two', turnRevision: 1 },
|
|
pendingTurn: null,
|
|
error: null,
|
|
tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }],
|
|
});
|
|
});
|
|
|
|
it('keeps the newer Conversation error when stale confirmation task refresh fails', async () => {
|
|
const first = new MockEventSource();
|
|
const second = new MockEventSource();
|
|
const confirmation = deferred<DesignConversation>();
|
|
const project = workspace();
|
|
const { messages: _messages, ...secondSummary } = conversation(
|
|
'workspace-one',
|
|
1,
|
|
'conversation-two',
|
|
);
|
|
const refreshError = new Error('任务刷新失败');
|
|
openImageWorkspaceTaskEventsMock
|
|
.mockResolvedValueOnce(first as unknown as EventSource)
|
|
.mockResolvedValueOnce(second as unknown as EventSource);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
|
...project,
|
|
conversationCount: 2,
|
|
conversations: [...project.conversations, secondSummary],
|
|
});
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockRejectedValueOnce(refreshError)
|
|
.mockResolvedValueOnce([task]);
|
|
fetchImageWorkspaceConversationMock
|
|
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
|
|
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-two'));
|
|
confirmImageWorkspaceGenerationMock.mockReturnValueOnce(confirmation.promise);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
const confirming = useImageWorkspaceStore.getState().confirmGeneration('quote-one');
|
|
await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce());
|
|
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
|
useImageWorkspaceStore.setState({ error: '新会话错误' });
|
|
confirmation.resolve(conversation('workspace-one', 2, 'conversation-one'));
|
|
await confirming;
|
|
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3);
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeConversationId: 'conversation-two',
|
|
conversation: { conversationId: 'conversation-two', turnRevision: 1 },
|
|
error: '新会话错误',
|
|
tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }],
|
|
});
|
|
});
|
|
|
|
it('still reports errors from an explicit task refresh', async () => {
|
|
const refreshError = new Error('主动刷新任务失败');
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockRejectedValueOnce(refreshError);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await expect(useImageWorkspaceStore.getState().refreshTasks()).rejects.toBe(refreshError);
|
|
|
|
expect(useImageWorkspaceStore.getState().error).toBe('主动刷新任务失败');
|
|
});
|
|
|
|
it('does not let a failed ABA confirmation overwrite a newer turn in the same Workspace', async () => {
|
|
const source = new MockEventSource();
|
|
const latestResponse = deferred<DesignConversation>();
|
|
const runtimeError = new ImageWorkspaceApiError(
|
|
503,
|
|
'agent_runtime_unavailable',
|
|
'AI 设计服务暂时不可用,请稍后重试',
|
|
);
|
|
let rejectConfirmation!: (error: unknown) => void;
|
|
const staleConfirmationResponse = new Promise<DesignConversation>((_resolve, reject) => {
|
|
rejectConfirmation = reject;
|
|
});
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
|
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
|
Promise.resolve(workspace(workspaceId))
|
|
));
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([task]);
|
|
fetchImageWorkspaceConversationMock
|
|
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
|
|
.mockResolvedValueOnce(conversation('workspace-two', 1, 'conversation-two'))
|
|
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
|
|
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-one'));
|
|
confirmImageWorkspaceGenerationMock.mockReturnValueOnce(staleConfirmationResponse);
|
|
sendImageWorkspaceMessageMock.mockReturnValueOnce(latestResponse.promise);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
const staleOutcome = useImageWorkspaceStore.getState().confirmGeneration('quote-one').then(
|
|
(value) => ({ value, error: null }),
|
|
(error: unknown) => ({ value: null, error }),
|
|
);
|
|
await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce());
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-one');
|
|
const latestTurn = useImageWorkspaceStore.getState().sendMessage('继续调整最新方案');
|
|
|
|
rejectConfirmation(runtimeError);
|
|
|
|
await expect(staleOutcome).resolves.toMatchObject({
|
|
value: { conversationId: 'conversation-one', turnRevision: 1 },
|
|
error: null,
|
|
});
|
|
expect(fetchImageWorkspaceConversationMock).toHaveBeenCalledTimes(3);
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-one',
|
|
activeConversationId: 'conversation-one',
|
|
conversation: { turnRevision: 1 },
|
|
pendingTurn: { userText: '继续调整最新方案' },
|
|
error: null,
|
|
});
|
|
|
|
latestResponse.resolve(conversation('workspace-one', 2, 'conversation-one'));
|
|
await latestTurn;
|
|
});
|
|
|
|
it('does not silently complete confirmation when the quoted task stays missing', async () => {
|
|
const source = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
vi.useFakeTimers();
|
|
const outcome = useImageWorkspaceStore.getState().confirmGeneration('quote-one').then(
|
|
() => null,
|
|
(error: unknown) => error,
|
|
);
|
|
await vi.runAllTimersAsync();
|
|
|
|
await expect(outcome).resolves.toMatchObject({ code: 'generation_task_not_visible' });
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(6);
|
|
expect(useImageWorkspaceStore.getState().error)
|
|
.toContain('任务列表同步超时');
|
|
});
|
|
|
|
it('closes the previous stream when switching workspaces and on reset', async () => {
|
|
const first = new MockEventSource();
|
|
const second = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock
|
|
.mockResolvedValueOnce(first as unknown as EventSource)
|
|
.mockResolvedValueOnce(second as unknown as EventSource);
|
|
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
|
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
|
Promise.resolve(workspace(workspaceId))
|
|
));
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(1));
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
|
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(2));
|
|
|
|
expect(first.close).toHaveBeenCalledOnce();
|
|
useImageWorkspaceStore.getState().reset();
|
|
expect(second.close).toHaveBeenCalledOnce();
|
|
expect(useImageWorkspaceStore.getState().taskStreamState).toBe('idle');
|
|
});
|
|
|
|
it('does not switch back when an Agent turn finishes after selecting another Workspace', async () => {
|
|
const first = new MockEventSource();
|
|
const second = new MockEventSource();
|
|
const response = deferred<DesignConversation>();
|
|
openImageWorkspaceTaskEventsMock
|
|
.mockResolvedValueOnce(first as unknown as EventSource)
|
|
.mockResolvedValueOnce(second as unknown as EventSource);
|
|
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
|
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
|
Promise.resolve(workspace(workspaceId))
|
|
));
|
|
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
const sending = useImageWorkspaceStore.getState().sendMessage('继续优化海报');
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
|
response.resolve({
|
|
...conversation('workspace-one', 2),
|
|
turnRevision: 2,
|
|
});
|
|
await sending;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-two',
|
|
workspace: { workspaceId: 'workspace-two' },
|
|
pendingTurn: null,
|
|
});
|
|
});
|
|
|
|
it('does not let a slow previous Workspace selection overwrite the latest one', async () => {
|
|
const rootSource = new MockEventSource();
|
|
const latestSource = new MockEventSource();
|
|
const slowWorkspace = deferred<DesignWorkspace>();
|
|
openImageWorkspaceTaskEventsMock
|
|
.mockResolvedValueOnce(rootSource as unknown as EventSource)
|
|
.mockResolvedValueOnce(latestSource as unknown as EventSource);
|
|
fetchImageWorkspaceMock.mockResolvedValue(bootstrap([
|
|
'workspace-root',
|
|
'workspace-one',
|
|
'workspace-two',
|
|
]));
|
|
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
|
workspaceId === 'workspace-one'
|
|
? slowWorkspace.promise
|
|
: Promise.resolve(workspace(workspaceId))
|
|
));
|
|
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
|
.toHaveBeenCalledWith('workspace-root', 'conversation-root'));
|
|
const staleSelection = useImageWorkspaceStore.getState().selectProject('workspace-one');
|
|
await vi.waitFor(() => expect(fetchImageWorkspaceProjectMock)
|
|
.toHaveBeenCalledWith('workspace-one'));
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
|
slowWorkspace.resolve(workspace('workspace-one'));
|
|
await staleSelection;
|
|
|
|
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe('workspace-two');
|
|
expect(useImageWorkspaceStore.getState().workspace?.workspaceId).toBe('workspace-two');
|
|
expect(openImageWorkspaceTaskEventsMock)
|
|
.not.toHaveBeenCalledWith('workspace-one', 'conversation-one');
|
|
});
|
|
|
|
it('uses updatedAt to keep a newer degraded REST task over an older replay', async () => {
|
|
const source = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 8));
|
|
fetchImageWorkspaceTasksMock.mockResolvedValue([{
|
|
...task,
|
|
status: 'succeeded',
|
|
updatedAt: '2026-08-02T11:00:00Z',
|
|
}]);
|
|
await useImageWorkspaceStore.getState().refreshWorkspace();
|
|
await useImageWorkspaceStore.getState().refreshTasks();
|
|
source.emit('design.generation_task.updated', taskEvent(7, 'failed'));
|
|
|
|
expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('succeeded');
|
|
});
|
|
|
|
it('polls slowly only while the event stream is degraded', async () => {
|
|
vi.useFakeTimers();
|
|
const source = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await vi.waitFor(() => expect(source.onerror).not.toBeNull());
|
|
source.onerror?.(new Event('error'));
|
|
await vi.advanceTimersByTimeAsync(14_999);
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
|
|
|
|
source.onopen?.(new Event('open'));
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('switches the conversation stream without clearing project generation tasks', async () => {
|
|
const first = new MockEventSource();
|
|
const second = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock
|
|
.mockResolvedValueOnce(first as unknown as EventSource)
|
|
.mockResolvedValueOnce(second as unknown as EventSource);
|
|
const project = workspace();
|
|
const { messages: _messages, ...secondSummary } = conversation(
|
|
'workspace-one',
|
|
0,
|
|
'conversation-two',
|
|
);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
|
...project,
|
|
conversationCount: 2,
|
|
conversations: [...project.conversations, secondSummary],
|
|
});
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
|
|
|
expect(first.close).toHaveBeenCalledOnce();
|
|
expect(openImageWorkspaceTaskEventsMock)
|
|
.toHaveBeenLastCalledWith('workspace-one', 'conversation-two');
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-one',
|
|
activeConversationId: 'conversation-two',
|
|
conversation: { conversationId: 'conversation-two' },
|
|
tasks: [{ taskId: 'task-one' }],
|
|
});
|
|
});
|
|
|
|
it('accepts a lower-revision fresh task event after switching conversations', async () => {
|
|
const first = new MockEventSource();
|
|
const second = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock
|
|
.mockResolvedValueOnce(first as unknown as EventSource)
|
|
.mockResolvedValueOnce(second as unknown as EventSource);
|
|
const project = workspace('workspace-one', 50);
|
|
const { messages: _messages, ...secondSummary } = conversation(
|
|
'workspace-one',
|
|
0,
|
|
'conversation-two',
|
|
);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
|
...project,
|
|
conversationCount: 2,
|
|
conversations: [...project.conversations, secondSummary],
|
|
});
|
|
|
|
await useImageWorkspaceStore.getState().load();
|
|
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
|
second.emit('design.generation_task.updated', taskEvent(3, 'running'));
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeConversationId: 'conversation-two',
|
|
workspace: { viewRevision: 50 },
|
|
tasks: [{ taskId: 'task-one', status: 'running' }],
|
|
});
|
|
});
|
|
|
|
it('does not let an older ABA conversation response replace the latest turn', async () => {
|
|
const source = new MockEventSource();
|
|
const project = workspace();
|
|
const { messages: _messages, ...secondSummary } = conversation(
|
|
'workspace-one',
|
|
0,
|
|
'conversation-two',
|
|
);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
|
...project,
|
|
conversationCount: 2,
|
|
conversations: [...project.conversations, secondSummary],
|
|
});
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
await useImageWorkspaceStore.getState().load();
|
|
|
|
const staleA = deferred<DesignConversation>();
|
|
const freshA = deferred<DesignConversation>();
|
|
fetchImageWorkspaceConversationMock
|
|
.mockReset()
|
|
.mockReturnValueOnce(staleA.promise)
|
|
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-two'))
|
|
.mockReturnValueOnce(freshA.promise);
|
|
const firstA = useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
|
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
|
const secondA = useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
|
freshA.resolve({
|
|
...conversation('workspace-one', 5, 'conversation-one'),
|
|
updatedAt: '2026-08-02T10:05:00Z',
|
|
});
|
|
await secondA;
|
|
staleA.resolve({
|
|
...conversation('workspace-one', 4, 'conversation-one'),
|
|
updatedAt: '2026-08-02T10:04:00Z',
|
|
});
|
|
await firstA;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeConversationId: 'conversation-one',
|
|
conversation: { conversationId: 'conversation-one', turnRevision: 5 },
|
|
});
|
|
});
|
|
|
|
it('does not surface a late ABA conversation error on the latest selection', async () => {
|
|
const source = new MockEventSource();
|
|
const project = workspace();
|
|
const { messages: _messages, ...secondSummary } = conversation(
|
|
'workspace-one',
|
|
0,
|
|
'conversation-two',
|
|
);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
|
...project,
|
|
conversationCount: 2,
|
|
conversations: [...project.conversations, secondSummary],
|
|
});
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
await useImageWorkspaceStore.getState().load();
|
|
|
|
let rejectStale!: (error: unknown) => void;
|
|
const staleA = new Promise<DesignConversation>((_resolve, reject) => {
|
|
rejectStale = reject;
|
|
});
|
|
fetchImageWorkspaceConversationMock
|
|
.mockReset()
|
|
.mockReturnValueOnce(staleA)
|
|
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-two'))
|
|
.mockResolvedValueOnce(conversation('workspace-one', 5, 'conversation-one'));
|
|
const firstA = useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
|
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
|
await useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
|
rejectStale(new Error('stale request failed'));
|
|
await firstA;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeConversationId: 'conversation-one',
|
|
conversation: { turnRevision: 5 },
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it('does not let stale ABA workspace tasks replace the latest project load', async () => {
|
|
const source = new MockEventSource();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
|
await useImageWorkspaceStore.getState().load();
|
|
|
|
const staleTasks = deferred<DesignGenerationTask[]>();
|
|
fetchImageWorkspaceProjectMock
|
|
.mockReset()
|
|
.mockResolvedValueOnce(workspace('workspace-one', 4))
|
|
.mockResolvedValueOnce(workspace('workspace-two', 1))
|
|
.mockResolvedValueOnce(workspace('workspace-one', 5));
|
|
fetchImageWorkspaceTasksMock
|
|
.mockReset()
|
|
.mockReturnValueOnce(staleTasks.promise)
|
|
.mockResolvedValueOnce([{ ...task, taskId: 'task-two', workspaceId: 'workspace-two' }])
|
|
.mockResolvedValueOnce([{
|
|
...task,
|
|
status: 'succeeded',
|
|
updatedAt: '2026-08-02T11:00:00Z',
|
|
}]);
|
|
|
|
const firstA = useImageWorkspaceStore.getState().selectProject('workspace-one');
|
|
await vi.waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce());
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-one');
|
|
staleTasks.resolve([{
|
|
...task,
|
|
status: 'failed',
|
|
updatedAt: '2026-08-02T10:04:00Z',
|
|
}]);
|
|
await firstA;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-one',
|
|
workspace: { workspaceId: 'workspace-one', viewRevision: 5 },
|
|
tasks: [{ taskId: 'task-one', status: 'succeeded' }],
|
|
});
|
|
});
|
|
|
|
it('prevents duplicate conversations while one create request is in flight', async () => {
|
|
const source = new MockEventSource();
|
|
const pending = deferred<DesignConversation>();
|
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
|
createImageWorkspaceConversationMock.mockReturnValueOnce(pending.promise);
|
|
await useImageWorkspaceStore.getState().load();
|
|
|
|
const firstCreate = useImageWorkspaceStore.getState().createConversation();
|
|
await expect(useImageWorkspaceStore.getState().createConversation())
|
|
.rejects.toThrow('正在创建新会话');
|
|
expect(createImageWorkspaceConversationMock).toHaveBeenCalledOnce();
|
|
|
|
pending.resolve(conversation('workspace-one', 0, 'conversation-new'));
|
|
await firstCreate;
|
|
expect(useImageWorkspaceStore.getState().creatingConversation).toBe(false);
|
|
});
|
|
});
|