UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

688 lines (556 loc) • 23.5 kB
import { act, renderHook, waitFor } from '@testing-library/react'; import React from 'react'; import { mutate } from 'swr'; import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LOADING_FLAT } from '@/const/message'; import { chatService } from '@/services/chat'; import { generationTopicService } from '@/services/generationTopic'; import { useImageStore } from '@/store/image'; import { useUserStore } from '@/store/user'; import { ImageGenerationTopic } from '@/types/generation'; // Mock services and dependencies vi.mock('@/services/generationTopic', () => ({ generationTopicService: { createTopic: vi.fn(), updateTopic: vi.fn(), deleteTopic: vi.fn(), getAllGenerationTopics: vi.fn(), updateTopicCover: vi.fn(), }, })); vi.mock('@/services/chat', () => ({ chatService: { fetchPresetTaskResult: vi.fn(), }, })); vi.mock('@/store/user', () => ({ useUserStore: { getState: vi.fn(), }, })); vi.mock('@/store/user/selectors', () => ({ systemAgentSelectors: { generationTopic: vi.fn().mockReturnValue({ model: 'gpt-4', provider: 'openai', }), }, })); beforeEach(() => { vi.clearAllMocks(); useImageStore.setState({ generationTopics: [], activeGenerationTopicId: null, loadingGenerationTopicIds: [], }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('GenerationTopicAction', () => { describe('createGenerationTopic', () => { it('should create a new topic and auto-generate title from prompts', async () => { const { result } = renderHook(() => useImageStore()); const newTopicId = 'gt_new_topic'; const prompts = ['A beautiful sunset over mountains']; vi.mocked(generationTopicService.createTopic).mockResolvedValue(newTopicId); vi.mocked(generationTopicService.getAllGenerationTopics).mockResolvedValue([ { id: newTopicId, title: 'Beautiful Sunset', createdAt: new Date(), updatedAt: new Date(), }, ] as ImageGenerationTopic[]); const summaryTopicTitleSpy = vi.spyOn(result.current, 'summaryGenerationTopicTitle'); let createdTopicId; await act(async () => { createdTopicId = await result.current.createGenerationTopic(prompts); }); expect(createdTopicId).toBe(newTopicId); expect(generationTopicService.createTopic).toHaveBeenCalled(); expect(summaryTopicTitleSpy).toHaveBeenCalledWith(newTopicId, prompts); }); it('should throw error when prompts are empty', async () => { const { result } = renderHook(() => useImageStore()); await act(async () => { await expect(result.current.createGenerationTopic([])).rejects.toThrow( 'Prompts cannot be empty when creating a generation topic', ); }); expect(generationTopicService.createTopic).not.toHaveBeenCalled(); }); it('should throw error when prompts are null or undefined', async () => { const { result } = renderHook(() => useImageStore()); await act(async () => { await expect(result.current.createGenerationTopic(null as any)).rejects.toThrow( 'Prompts cannot be empty when creating a generation topic', ); }); await act(async () => { await expect(result.current.createGenerationTopic(undefined as any)).rejects.toThrow( 'Prompts cannot be empty when creating a generation topic', ); }); expect(generationTopicService.createTopic).not.toHaveBeenCalled(); }); }); describe('switchGenerationTopic', () => { it('should switch to the specified topic', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const topics = [ { id: 'gt_topic_1', title: 'Topic 1' }, { id: 'gt_topic_2', title: 'Topic 2' }, ] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics }); }); act(() => { result.current.switchGenerationTopic(topicId); }); expect(result.current.activeGenerationTopicId).toBe(topicId); }); it('should not update if already active topic', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const topics = [{ id: 'gt_topic_1', title: 'Topic 1' }] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics, activeGenerationTopicId: topicId, }); }); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); act(() => { result.current.switchGenerationTopic(topicId); }); expect(result.current.activeGenerationTopicId).toBe(topicId); consoleSpy.mockRestore(); }); it('should warn when topic does not exist', async () => { const { result } = renderHook(() => useImageStore()); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); act(() => { useImageStore.setState({ generationTopics: [] }); }); act(() => { result.current.switchGenerationTopic('gt_non_existent_topic'); }); expect(consoleSpy).toHaveBeenCalledWith( 'Generation topic with id gt_non_existent_topic not found', ); consoleSpy.mockRestore(); }); }); describe('openNewGenerationTopic', () => { it('should set activeGenerationTopicId to null', async () => { const { result } = renderHook(() => useImageStore()); act(() => { useImageStore.setState({ activeGenerationTopicId: 'existing-topic' }); }); act(() => { result.current.openNewGenerationTopic(); }); expect(result.current.activeGenerationTopicId).toBeNull(); }); }); describe('summaryGenerationTopicTitle', () => { it('should generate title using AI and update topic', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const prompts = ['A beautiful sunset over mountains']; const topics = [{ id: topicId, title: 'Original Title' }] as ImageGenerationTopic[]; const generatedTitle = 'Mountain Sunset Landscape'; act(() => { useImageStore.setState({ generationTopics: topics }); }); // Mock successful AI response vi.mocked(chatService.fetchPresetTaskResult).mockImplementation((params) => { if (params.onFinish) { params.onFinish(generatedTitle, { type: 'done' }); } return Promise.resolve(undefined); }); await act(async () => { await result.current.summaryGenerationTopicTitle(topicId, prompts); }); expect(chatService.fetchPresetTaskResult).toHaveBeenCalled(); expect(generationTopicService.updateTopic).toHaveBeenCalledWith(topicId, { title: generatedTitle, }); }); it('should use fallback title when AI fails', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const prompts = ['A beautiful sunset over mountains with clear sky']; const topics = [{ id: topicId, title: 'Original Title' }] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics }); }); // Mock AI error vi.mocked(chatService.fetchPresetTaskResult).mockImplementation((params) => { if (params.onError) { params.onError(new Error('AI service failed')); } return Promise.resolve(undefined); }); await act(async () => { await result.current.summaryGenerationTopicTitle(topicId, prompts); }); expect(chatService.fetchPresetTaskResult).toHaveBeenCalled(); // Should call with fallback title (first 3 words, max 10 chars) expect(generationTopicService.updateTopic).toHaveBeenCalledWith(topicId, { title: 'A beautifu', }); }); it('should throw error when topic not found', async () => { const { result } = renderHook(() => useImageStore()); act(() => { useImageStore.setState({ generationTopics: [] }); }); await act(async () => { await expect( result.current.summaryGenerationTopicTitle('gt_non_existent', ['prompt']), ).rejects.toThrow('Topic gt_non_existent not found'); }); }); it('should handle streaming text updates', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const prompts = ['Test prompt']; const topics = [{ id: topicId, title: 'Original Title' }] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics }); }); const updateTitleSpy = vi.spyOn( result.current, 'internal_updateGenerationTopicTitleInSummary', ); // Mock streaming response vi.mocked(chatService.fetchPresetTaskResult).mockImplementation((params) => { if (params.onMessageHandle) { params.onMessageHandle({ type: 'text', text: 'Streaming' }); params.onMessageHandle({ type: 'text', text: ' Title' }); } if (params.onFinish) { params.onFinish('Streaming Title', { type: 'done' }); } return Promise.resolve(undefined); }); await act(async () => { await result.current.summaryGenerationTopicTitle(topicId, prompts); }); expect(updateTitleSpy).toHaveBeenCalledWith(topicId, 'Streaming'); expect(updateTitleSpy).toHaveBeenCalledWith(topicId, 'Streaming Title'); }); }); describe('removeGenerationTopic', () => { it('should remove topic and switch to next topic when removing active topic', async () => { const { result } = renderHook(() => useImageStore()); const topics = [ { id: 'gt_topic_1', title: 'Topic 1' }, { id: 'gt_topic_2', title: 'Topic 2' }, { id: 'gt_topic_3', title: 'Topic 3' }, ] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics, activeGenerationTopicId: 'gt_topic_2', }); }); vi.mocked(generationTopicService.getAllGenerationTopics).mockResolvedValue([ { id: 'gt_topic_1', title: 'Topic 1' }, { id: 'gt_topic_3', title: 'Topic 3' }, ] as ImageGenerationTopic[]); const switchTopicSpy = vi.spyOn(result.current, 'switchGenerationTopic'); await act(async () => { await result.current.removeGenerationTopic('gt_topic_2'); }); expect(generationTopicService.deleteTopic).toHaveBeenCalledWith('gt_topic_2'); expect(switchTopicSpy).toHaveBeenCalled(); }); it('should open new topic when removing the last topic', async () => { const { result } = renderHook(() => useImageStore()); const topics = [{ id: 'gt_topic_1', title: 'Topic 1' }] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics, activeGenerationTopicId: 'gt_topic_1', }); }); // Mock getAllGenerationTopics to return empty array after deletion vi.mocked(generationTopicService.getAllGenerationTopics).mockResolvedValue([]); const openNewTopicSpy = vi.spyOn(result.current, 'openNewGenerationTopic'); const refreshSpy = vi .spyOn(result.current, 'refreshGenerationTopics') .mockImplementation(async () => { // Simulate state update after refresh - empty topics array useImageStore.setState({ generationTopics: [] }); }); await act(async () => { await result.current.removeGenerationTopic('gt_topic_1'); }); expect(generationTopicService.deleteTopic).toHaveBeenCalledWith('gt_topic_1'); expect(refreshSpy).toHaveBeenCalled(); expect(openNewTopicSpy).toHaveBeenCalled(); }); it('should not switch topic when removing non-active topic', async () => { const { result } = renderHook(() => useImageStore()); const topics = [ { id: 'gt_topic_1', title: 'Topic 1' }, { id: 'gt_topic_2', title: 'Topic 2' }, ] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics, activeGenerationTopicId: 'gt_topic_1', }); }); const switchTopicSpy = vi.spyOn(result.current, 'switchGenerationTopic'); const openNewTopicSpy = vi.spyOn(result.current, 'openNewGenerationTopic'); await act(async () => { await result.current.removeGenerationTopic('gt_topic_2'); }); expect(generationTopicService.deleteTopic).toHaveBeenCalledWith('gt_topic_2'); expect(switchTopicSpy).not.toHaveBeenCalled(); expect(openNewTopicSpy).not.toHaveBeenCalled(); }); }); describe('useFetchGenerationTopics', () => { it('should fetch generation topics when enabled', async () => { const topics = [ { id: 'gt_topic_1', title: 'Topic 1', createdAt: new Date(), updatedAt: new Date() }, { id: 'gt_topic_2', title: 'Topic 2', createdAt: new Date(), updatedAt: new Date() }, ] as ImageGenerationTopic[]; vi.mocked(generationTopicService.getAllGenerationTopics).mockResolvedValue(topics); let hookResult: any; await act(async () => { const { result } = renderHook(() => { const store = useImageStore(); // Actually call the SWR hook to trigger the service call const swrResult = store.useFetchGenerationTopics(true, true); // Simulate the SWR onSuccess callback behavior React.useEffect(() => { useImageStore.setState({ generationTopics: topics }); }, []); return swrResult; }); hookResult = result; }); // Wait for service to be called and state to be updated await waitFor(() => { expect(generationTopicService.getAllGenerationTopics).toHaveBeenCalled(); expect(useImageStore.getState().generationTopics).toEqual(topics); }); }); it('should not fetch when disabled', async () => { const { result } = renderHook(() => useImageStore().useFetchGenerationTopics(false, true)); expect(result.current.data).toBeUndefined(); expect(generationTopicService.getAllGenerationTopics).not.toHaveBeenCalled(); }); }); describe('refreshGenerationTopics', () => { beforeEach(() => { vi.mock('swr', async () => { const actual = await vi.importActual('swr'); return { ...(actual as any), mutate: vi.fn(), }; }); }); afterEach(() => { vi.resetAllMocks(); }); it('should call mutate to refresh topics', async () => { const { result } = renderHook(() => useImageStore()); await act(async () => { await result.current.refreshGenerationTopics(); }); expect(mutate).toHaveBeenCalledWith(['fetchGenerationTopics', true]); }); }); describe('updateGenerationTopicCover', () => { it('should update topic cover with optimistic update', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const coverUrl = 'https://example.com/cover.jpg'; const topics = [{ id: topicId, title: 'Topic 1', coverUrl: '' }] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics }); }); const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchGenerationTopic'); await act(async () => { await result.current.updateGenerationTopicCover(topicId, coverUrl); }); expect(dispatchSpy).toHaveBeenCalledWith( { type: 'updateTopic', id: topicId, value: { coverUrl } }, 'internal_updateGenerationTopicCover/optimistic', ); expect(generationTopicService.updateTopicCover).toHaveBeenCalledWith(topicId, coverUrl); }); }); describe('internal_updateGenerationTopicLoading', () => { it('should add topic id to loading array when loading is true', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; act(() => { useImageStore.setState({ loadingGenerationTopicIds: [] }); }); act(() => { result.current.internal_updateGenerationTopicLoading(topicId, true); }); expect(result.current.loadingGenerationTopicIds).toContain(topicId); }); it('should remove topic id from loading array when loading is false', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; act(() => { useImageStore.setState({ loadingGenerationTopicIds: [topicId] }); }); act(() => { result.current.internal_updateGenerationTopicLoading(topicId, false); }); expect(result.current.loadingGenerationTopicIds).not.toContain(topicId); }); }); describe('internal_dispatchGenerationTopic', () => { it('should update topics when state changes', async () => { const { result } = renderHook(() => useImageStore()); const initialTopics = [{ id: 'gt_topic_1', title: 'Topic 1' }] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: initialTopics }); }); act(() => { result.current.internal_dispatchGenerationTopic({ type: 'addTopic', value: { id: 'gt_topic_2', title: 'Topic 2' }, }); }); expect(result.current.generationTopics).toHaveLength(2); expect(result.current.generationTopics.find((t) => t.id === 'gt_topic_2')).toBeDefined(); }); it('should not update when topics are equal', async () => { const { result } = renderHook(() => useImageStore()); const existingDate = new Date('2024-01-01T00:00:00.000Z'); const topics = [ { id: 'gt_topic_1', title: 'Topic 1', createdAt: existingDate, updatedAt: existingDate, }, ] as ImageGenerationTopic[]; act(() => { useImageStore.setState({ generationTopics: topics }); }); const stateBefore = result.current.generationTopics; act(() => { result.current.internal_dispatchGenerationTopic({ type: 'updateTopic', id: 'gt_topic_1', value: { title: 'Topic 1' }, // Same title, but updatedAt will still change }); }); // The state object reference should change due to updatedAt being updated expect(result.current.generationTopics).not.toBe(stateBefore); // But the topic should still exist with updated timestamp expect(result.current.generationTopics[0].id).toBe('gt_topic_1'); expect(result.current.generationTopics[0].title).toBe('Topic 1'); expect(result.current.generationTopics[0].updatedAt.getTime()).toBeGreaterThan( existingDate.getTime(), ); }); }); describe('internal_createGenerationTopic', () => { it('should create topic with optimistic update pattern', async () => { const { result } = renderHook(() => useImageStore()); const newTopicId = 'gt_new_topic'; vi.mocked(generationTopicService.createTopic).mockResolvedValue(newTopicId); vi.mocked(generationTopicService.getAllGenerationTopics).mockResolvedValue([ { id: newTopicId, title: '', createdAt: new Date(), updatedAt: new Date(), }, ] as ImageGenerationTopic[]); const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchGenerationTopic'); const loadingSpy = vi.spyOn(result.current, 'internal_updateGenerationTopicLoading'); await act(async () => { const topicId = await result.current.internal_createGenerationTopic(); expect(topicId).toBe(newTopicId); }); expect(dispatchSpy).toHaveBeenCalled(); expect(loadingSpy).toHaveBeenCalledWith(expect.any(String), true); expect(loadingSpy).toHaveBeenCalledWith(newTopicId, false); expect(generationTopicService.createTopic).toHaveBeenCalled(); }); }); describe('internal_updateGenerationTopic', () => { it('should update topic with optimistic update and refresh', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const updateData = { title: 'Updated Title' }; const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchGenerationTopic'); const loadingSpy = vi.spyOn(result.current, 'internal_updateGenerationTopicLoading'); const refreshSpy = vi.spyOn(result.current, 'refreshGenerationTopics'); await act(async () => { await result.current.internal_updateGenerationTopic(topicId, updateData); }); expect(dispatchSpy).toHaveBeenCalledWith({ type: 'updateTopic', id: topicId, value: updateData, }); expect(loadingSpy).toHaveBeenCalledWith(topicId, true); expect(generationTopicService.updateTopic).toHaveBeenCalledWith(topicId, updateData); expect(refreshSpy).toHaveBeenCalled(); expect(loadingSpy).toHaveBeenCalledWith(topicId, false); }); }); describe('internal_updateGenerationTopicTitleInSummary', () => { it('should dispatch title update action', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const title = 'Summary Title'; const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchGenerationTopic'); act(() => { result.current.internal_updateGenerationTopicTitleInSummary(topicId, title); }); expect(dispatchSpy).toHaveBeenCalledWith( { type: 'updateTopic', id: topicId, value: { title } }, 'updateGenerationTopicTitleInSummary', ); }); }); describe('internal_removeGenerationTopic', () => { it('should handle removal with loading states', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; const loadingSpy = vi.spyOn(result.current, 'internal_updateGenerationTopicLoading'); const refreshSpy = vi.spyOn(result.current, 'refreshGenerationTopics'); await act(async () => { await result.current.internal_removeGenerationTopic(topicId); }); expect(loadingSpy).toHaveBeenCalledWith(topicId, true); expect(generationTopicService.deleteTopic).toHaveBeenCalledWith(topicId); expect(refreshSpy).toHaveBeenCalled(); expect(loadingSpy).toHaveBeenCalledWith(topicId, false); }); it('should clear loading state even if deletion fails', async () => { const { result } = renderHook(() => useImageStore()); const topicId = 'gt_topic_1'; vi.mocked(generationTopicService.deleteTopic).mockRejectedValue(new Error('Delete failed')); const loadingSpy = vi.spyOn(result.current, 'internal_updateGenerationTopicLoading'); await act(async () => { await expect(result.current.internal_removeGenerationTopic(topicId)).rejects.toThrow( 'Delete failed', ); }); expect(loadingSpy).toHaveBeenCalledWith(topicId, true); expect(loadingSpy).toHaveBeenCalledWith(topicId, false); }); }); });