Files
makelore/tests/unit/image-workspace-store.test.ts

403 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createImageWorkspaceOperationId,
createImageWorkspaceProject,
deleteImageWorkspaceProject,
fetchImageWorkspace,
fetchImageWorkspaceProject,
ImageWorkspaceApiError,
openImageWorkspaceEvents,
renameImageWorkspaceProject,
submitImageWorkspaceCommand,
} from '@/lib/image-workspace';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import {
designBootstrapFixture,
designFormFixture,
designSummaryFixture,
designWorkspaceFixture,
} from '../fixtures/design-workspace-v2';
vi.mock('@/lib/image-workspace', async (importOriginal) => {
const original = await importOriginal<typeof import('@/lib/image-workspace')>();
return {
...original,
createImageWorkspaceOperationId: vi.fn(),
createImageWorkspaceProject: vi.fn(),
deleteImageWorkspaceProject: vi.fn(),
fetchImageWorkspace: vi.fn(),
fetchImageWorkspaceProject: vi.fn(),
openImageWorkspaceEvents: vi.fn(),
renameImageWorkspaceProject: vi.fn(),
submitImageWorkspaceCommand: vi.fn(),
};
});
vi.mock('@/lib/host-api', async (importOriginal) => {
const original = await importOriginal<typeof import('@/lib/host-api')>();
return { ...original, setDesktopBackgroundLease: vi.fn() };
});
vi.mock('@/stores/auth', () => ({
useAuthStore: {
getState: () => ({ isAuthenticated: () => true }),
},
}));
const operationIdMock = vi.mocked(createImageWorkspaceOperationId);
const createProjectMock = vi.mocked(createImageWorkspaceProject);
const deleteProjectMock = vi.mocked(deleteImageWorkspaceProject);
const fetchBootstrapMock = vi.mocked(fetchImageWorkspace);
const fetchProjectMock = vi.mocked(fetchImageWorkspaceProject);
const openEventsMock = vi.mocked(openImageWorkspaceEvents);
const renameProjectMock = vi.mocked(renameImageWorkspaceProject);
const submitCommandMock = vi.mocked(submitImageWorkspaceCommand);
class FakeEventSource {
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readonly listeners = new Map<string, Set<(event: Event) => void>>();
close = vi.fn();
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
const callback = typeof listener === 'function'
? listener
: (event: Event) => listener.handleEvent(event);
const listeners = this.listeners.get(type) ?? new Set<(event: Event) => void>();
listeners.add(callback);
this.listeners.set(type, listeners);
}
emit(type: string, payload: unknown): void {
const event = new MessageEvent(type, { data: JSON.stringify(payload) });
this.listeners.get(type)?.forEach((listener) => listener(event));
}
}
async function loadedStore(eventSource = new FakeEventSource()) {
openEventsMock.mockResolvedValue(eventSource as unknown as EventSource);
await useImageWorkspaceStore.getState().load();
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalled());
return eventSource;
}
describe('V2 Living Form store', () => {
beforeEach(() => {
vi.clearAllMocks();
useImageWorkspaceStore.getState().reset();
const workspace = designWorkspaceFixture();
fetchBootstrapMock.mockResolvedValue(designBootstrapFixture());
fetchProjectMock.mockResolvedValue(workspace);
createProjectMock.mockResolvedValue(workspace);
renameProjectMock.mockResolvedValue(workspace);
deleteProjectMock.mockResolvedValue({ workspaceId: 'workspace-1', deleted: true });
operationIdMock.mockReturnValue('operation-1');
submitCommandMock.mockResolvedValue({
clientOperationId: 'operation-1',
runId: 'run-1',
workspace,
});
});
it('loads one canonical Workspace and opens its resumable event stream', async () => {
await loadedStore();
expect(useImageWorkspaceStore.getState()).toMatchObject({
status: 'ready',
activeWorkspaceId: 'workspace-1',
workspace: { form: { specificationRevision: 3 } },
});
expect(openEventsMock).toHaveBeenCalledWith('workspace-1', 'session-1', undefined);
});
it('keeps a field draft separate until the direct edit succeeds', async () => {
await loadedStore();
useImageWorkspaceStore.getState().setFieldDraft('output.aspect_ratio', '16:9');
await useImageWorkspaceStore.getState().applyFieldOperations(
[{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
['output.aspect_ratio'],
);
expect(submitCommandMock).toHaveBeenCalledWith({
kind: 'apply_input',
workspaceId: 'workspace-1',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-1',
input: {
kind: 'direct_edit',
operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
},
});
expect(useImageWorkspaceStore.getState().fieldDrafts).not.toHaveProperty('output.aspect_ratio');
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
});
it('expires quote blockers only after the canonical Specification changes', async () => {
await loadedStore();
const blocker = {
code: 'aspect_ratio_required',
message: 'Choose an aspect ratio supported by the frozen route.',
severity: 'blocker' as const,
path: 'output.aspect_ratio',
};
useImageWorkspaceStore.setState({ quoteBlockers: [blocker] });
await useImageWorkspaceStore.getState().refreshWorkspace();
expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([blocker]);
const revisedWorkspace = designWorkspaceFixture({
form: designFormFixture({
directionRevision: 5,
specificationRevision: 4,
specificationRevisionId: 'specification-revision-4',
}),
});
submitCommandMock.mockResolvedValueOnce({
clientOperationId: 'operation-1',
runId: 'run-2',
workspace: revisedWorkspace,
});
await useImageWorkspaceStore.getState().applyFieldOperations([
{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' },
]);
expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([]);
expect(useImageWorkspaceStore.getState().workspace?.form.specificationRevision).toBe(4);
});
it('treats a terminal quote-blocked response as definitive instead of unknown', async () => {
await loadedStore();
submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
422,
'design_quote_blocked',
'The frozen compiler needs more information.',
));
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toMatchObject({
status: 422,
code: 'design_quote_blocked',
});
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
expect(useImageWorkspaceStore.getState().error).toBeNull();
});
it('settles an unknown quote from its blocked event, then allows a revised Quote', async () => {
const source = await loadedStore();
submitCommandMock.mockRejectedValueOnce(new Error('network disconnected'));
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toThrow(
'network disconnected',
);
expect(useImageWorkspaceStore.getState().pendingOperations['operation-1']?.status).toBe('unknown');
const blocker = {
code: 'aspect_ratio_required',
message: 'Choose an aspect ratio supported by the frozen route.',
severity: 'blocker' as const,
path: 'output.aspect_ratio',
};
source.emit('design.quote.blocked', {
id: 'session-1:quote-blocked',
type: 'design.quote.blocked',
clientOperationId: 'operation-1',
blockers: [blocker],
warnings: [],
form: designFormFixture(),
});
expect(useImageWorkspaceStore.getState()).toMatchObject({
pendingOperations: {},
quoteBlockers: [blocker],
error: null,
});
const revisedWorkspace = designWorkspaceFixture({
form: designFormFixture({
directionRevision: 5,
specificationRevision: 4,
specificationRevisionId: 'specification-revision-4',
}),
});
submitCommandMock.mockResolvedValueOnce({
clientOperationId: 'operation-1',
runId: 'run-edit',
workspace: revisedWorkspace,
});
await useImageWorkspaceStore.getState().applyFieldOperations([
{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' },
]);
expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([]);
submitCommandMock.mockResolvedValueOnce({
clientOperationId: 'operation-1',
runId: 'run-quote-revised',
workspace: revisedWorkspace,
});
await useImageWorkspaceStore.getState().requestQuote();
expect(submitCommandMock).toHaveBeenLastCalledWith(expect.objectContaining({
kind: 'request_quote',
expectedDirectionRevision: 5,
specificationRevision: 4,
}));
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
});
it('refreshes on a definitive revision conflict and preserves the unsent draft', async () => {
await loadedStore();
useImageWorkspaceStore.getState().setFieldDraft('intent.purpose', '新的用途');
submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
409,
'design_direction_revision_conflict',
'conflict',
));
await expect(useImageWorkspaceStore.getState().applyFieldOperations(
[{ kind: 'set', path: 'intent.purpose', value: '新的用途' }],
['intent.purpose'],
)).rejects.toMatchObject({ status: 409 });
expect(fetchProjectMock).toHaveBeenCalledWith('workspace-1');
expect(useImageWorkspaceStore.getState().fieldDrafts).toEqual({
'intent.purpose': '新的用途',
});
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
});
it('retains an uncertain accepted write and retries the exact same command identity', async () => {
await loadedStore();
submitCommandMock.mockRejectedValueOnce(new Error('network disconnected'));
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toThrow(
'network disconnected',
);
const pending = useImageWorkspaceStore.getState().pendingOperations['operation-1'];
expect(pending).toMatchObject({
id: 'operation-1',
status: 'unknown',
command: {
kind: 'request_quote',
expectedDirectionRevision: 4,
specificationRevision: 3,
},
});
submitCommandMock.mockResolvedValueOnce({
clientOperationId: 'operation-1',
runId: 'run-original',
workspace: designWorkspaceFixture(),
});
await useImageWorkspaceStore.getState().retryOperation('operation-1');
expect(submitCommandMock).toHaveBeenNthCalledWith(2, pending.command);
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
});
it('reconciles assistant deltas and canonical Form events without mutating drafts', async () => {
const source = await loadedStore();
useImageWorkspaceStore.getState().setFieldDraft('intent.purpose', '本地草稿');
useImageWorkspaceStore.setState({
quoteBlockers: [{
code: 'missing_choice',
message: '还需要选择作品形状',
severity: 'blocker',
path: 'output.aspect_ratio',
}],
});
source.emit('design.assistant.delta', {
id: 'session-1:7',
type: 'design.assistant.delta',
workspaceId: 'workspace-1',
directionId: 'direction-1',
clientOperationId: 'operation-chat-1',
directionRevision: 4,
chunkIndex: 0,
delta: '正在整理',
});
source.emit('design.direction.updated', {
id: 'session-1:8',
type: 'design.direction.updated',
replayed: false,
operation: {
operationKind: 'chat',
interactionId: 'interaction-2',
baseDirectionRevision: 4,
newDirectionRevision: 5,
rawTurnSequence: 2,
specificationRevision: 4,
specificationRevisionId: 'specification-revision-4',
workspaceViewRevision: 7,
specificationRevisionCreated: true,
meaningChanged: true,
changeSet: { interaction_id: 'interaction-2', changes: [] },
turnId: 'turn-2',
assistantMessage: '已更新',
createdDecisionPromptIds: [],
resolvedDecisionPromptId: null,
supersededDecisionPromptIds: [],
supersededQuoteCount: 0,
quoteId: null,
generationTaskId: null,
},
form: designFormFixture({
directionRevision: 5,
specificationRevision: 4,
specificationRevisionId: 'specification-revision-4',
workspaceViewRevision: 7,
}),
});
expect(useImageWorkspaceStore.getState()).toMatchObject({
lastEventId: 'session-1:8',
assistantStreams: { 'operation-chat-1': '正在整理' },
fieldDrafts: { 'intent.purpose': '本地草稿' },
quoteBlockers: [],
workspace: { form: { directionRevision: 5, specificationRevision: 4 } },
});
});
it('creates, renames and deletes only Workspace identities', async () => {
await useImageWorkspaceStore.getState().createProject('新项目');
await useImageWorkspaceStore.getState().renameProject('workspace-1', '新名称');
await useImageWorkspaceStore.getState().deleteProject('workspace-1');
expect(createProjectMock).toHaveBeenCalledWith('新项目');
expect(renameProjectMock).toHaveBeenCalledWith('workspace-1', '新名称');
expect(deleteProjectMock).toHaveBeenCalledWith('workspace-1');
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull();
});
it('renames a background Workspace without changing the active Workspace', async () => {
await loadedStore();
const backgroundSummary = designSummaryFixture({
workspaceId: 'workspace-2',
clientWorkspaceId: 'client-workspace-2',
title: '背景项目',
directionId: 'direction-2',
sessionId: 'session-2',
});
useImageWorkspaceStore.setState((state) => ({
bootstrap: state.bootstrap
? { ...state.bootstrap, workspaces: [...state.bootstrap.workspaces, backgroundSummary] }
: null,
}));
renameProjectMock.mockResolvedValueOnce(designWorkspaceFixture({
workspace: { ...backgroundSummary, title: '背景项目新名称' },
}));
await useImageWorkspaceStore.getState().renameProject('workspace-2', '背景项目新名称');
expect(useImageWorkspaceStore.getState()).toMatchObject({
activeWorkspaceId: 'workspace-1',
workspace: { workspace: { workspaceId: 'workspace-1' } },
});
expect(useImageWorkspaceStore.getState().bootstrap?.workspaces).toEqual(
expect.arrayContaining([
expect.objectContaining({ workspaceId: 'workspace-2', title: '背景项目新名称' }),
]),
);
});
});