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)); } } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); return { promise, resolve }; } 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('closes an obsolete event source when a replacement connection wins the race', async () => { const workspace = designWorkspaceFixture(); useImageWorkspaceStore.setState({ status: 'ready', activeWorkspaceId: workspace.workspace.workspaceId, workspace, }); const firstOpen = deferred(); const secondOpen = deferred(); const firstSource = new FakeEventSource(); const secondSource = new FakeEventSource(); openEventsMock .mockReturnValueOnce(firstOpen.promise) .mockReturnValueOnce(secondOpen.promise); useImageWorkspaceStore.getState().connectEvents(); useImageWorkspaceStore.getState().disconnectEvents(); useImageWorkspaceStore.getState().connectEvents(); firstOpen.resolve(firstSource as unknown as EventSource); await Promise.resolve(); secondOpen.resolve(secondSource as unknown as EventSource); await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2)); expect(firstSource.close).toHaveBeenCalledOnce(); expect(secondSource.close).not.toHaveBeenCalled(); }); it('allows a new event connection after opening the previous one failed', async () => { const workspace = designWorkspaceFixture(); const recoveredSource = new FakeEventSource(); useImageWorkspaceStore.setState({ status: 'ready', activeWorkspaceId: workspace.workspace.workspaceId, workspace, }); openEventsMock .mockRejectedValueOnce(new Error('temporary event connection failure')) .mockResolvedValueOnce(recoveredSource as unknown as EventSource); useImageWorkspaceStore.getState().connectEvents(); await vi.waitFor(() => expect(useImageWorkspaceStore.getState().eventState).toBe('degraded')); useImageWorkspaceStore.getState().connectEvents(); await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2)); expect(recoveredSource.close).not.toHaveBeenCalled(); }); it('uses assistant chunk indexes to ignore replayed deltas', async () => { const source = await loadedStore(); const baseEvent = { type: 'design.assistant.delta', workspaceId: 'workspace-1', directionId: 'direction-1', clientOperationId: 'operation-chat-1', directionRevision: 4, } as const; source.emit('design.assistant.delta', { ...baseEvent, id: 'session-1:7', chunkIndex: 0, delta: '收到', }); source.emit('design.assistant.delta', { ...baseEvent, id: 'session-1:7-replayed', chunkIndex: 0, delta: '收到', }); source.emit('design.assistant.delta', { ...baseEvent, id: 'session-1:8', chunkIndex: 1, delta: ',正在整理', }); expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-chat-1': '收到,正在整理', }); }); 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('publishes a pending chat operation before the server result settles', async () => { await loadedStore(); const message = '画一只在月球踢球的熊猫'; const submission = deferred>>(); submitCommandMock.mockReturnValueOnce(submission.promise); useImageWorkspaceStore.getState().setChatDraft(message); const sendPromise = useImageWorkspaceStore.getState().sendChat(); expect(useImageWorkspaceStore.getState()).toMatchObject({ chatDraft: message, pendingOperations: { 'operation-1': { id: 'operation-1', status: 'submitting', clearChatDraft: true, command: { kind: 'apply_input', workspaceId: 'workspace-1', clientOperationId: 'operation-1', input: { kind: 'chat', message }, }, }, }, }); const workspace = designWorkspaceFixture({ turns: [{ turnId: 'turn-2', rawTurnSequence: 2, userMessage: message, assistantMessage: '你希望画面更像漫画,还是更像电影?', }], }); submission.resolve({ clientOperationId: 'operation-1', runId: 'run-2', workspace }); await sendPromise; expect(useImageWorkspaceStore.getState()).toMatchObject({ workspace, chatDraft: '', pendingOperations: {}, }); }); it('settles a definitive reasoner failure without losing the chat draft', async () => { const source = await loadedStore(); useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); submitCommandMock.mockImplementationOnce(async () => { source.emit('design.assistant.delta', { id: 'session-1:7', type: 'design.assistant.delta', workspaceId: 'workspace-1', directionId: 'direction-1', clientOperationId: 'operation-1', directionRevision: 4, chunkIndex: 0, delta: '正在整理', }); throw new ImageWorkspaceApiError( 502, 'design_reasoner_invalid', 'AI 没有整理好这次想法,请再试一次', 'definitive_failure', ); }); await expect(useImageWorkspaceStore.getState().sendChat()).rejects.toMatchObject({ status: 502, code: 'design_reasoner_invalid', }); expect(useImageWorkspaceStore.getState()).toMatchObject({ chatDraft: '画一只会做饭的机器人', error: 'AI 没有整理好这次想法,请再试一次', }); expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({}); }); it('settles any Main-confirmed terminal run without an error-code allowlist', async () => { await loadedStore(); submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError( 422, 'policy_blocked', '当前内容不符合创作安全规则', 'definitive_failure', )); await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toMatchObject({ code: 'policy_blocked', commandOutcome: 'definitive_failure', }); expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); expect(useImageWorkspaceStore.getState().error).toBe('当前内容不符合创作安全规则'); }); it('keeps the accepted command identity when polling auth expires', async () => { const source = await loadedStore(); useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); submitCommandMock.mockImplementationOnce(async () => { source.emit('design.assistant.delta', { id: 'session-1:7', type: 'design.assistant.delta', workspaceId: 'workspace-1', directionId: 'direction-1', clientOperationId: 'operation-1', directionRevision: 4, chunkIndex: 0, delta: '正在整理', }); throw new ImageWorkspaceApiError( 401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录', 'unknown', ); }); await expect(useImageWorkspaceStore.getState().sendChat()).rejects.toMatchObject({ status: 401, code: 'AUTH_EXPIRED', commandOutcome: 'unknown', }); expect(useImageWorkspaceStore.getState().chatDraft).toBe('画一只会做饭的机器人'); expect(useImageWorkspaceStore.getState().pendingOperations['operation-1']).toMatchObject({ id: 'operation-1', status: 'unknown', }); expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-1': '正在整理', }); }); 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({ assistantStreams: { 'operation-chat-unknown': '这段回复还没确认' }, pendingOperations: { 'operation-chat-unknown': { id: 'operation-chat-unknown', label: '发送创作想法', command: { kind: 'apply_input', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-chat-unknown', input: { kind: 'chat', message: '保留这条未知结果' }, }, status: 'unknown', error: '结果尚未确认', clearDraftPaths: [], clearChatDraft: true, }, }, 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().assistantStreams).toEqual({ 'operation-chat-unknown': '这段回复还没确认', 'operation-chat-1': '正在整理', }); expect(useImageWorkspaceStore.getState()).toMatchObject({ lastEventId: 'session-1:8', 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: '背景项目新名称' }), ]), ); }); });