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(); 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(); 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 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: '背景项目新名称' }), ]), ); }); });