@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.
496 lines (416 loc) • 15.5 kB
text/typescript
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 { generationService } from '@/services/generation';
import { generationBatchService } from '@/services/generationBatch';
import { useImageStore } from '@/store/image';
import { generationBatchSelectors } from '@/store/image/slices/generationBatch/selectors';
import { AsyncTaskStatus } from '@/types/asyncTask';
import { GenerationBatch } from '@/types/generation';
// Mock services and dependencies
vi.mock('@/services/generation', () => ({
generationService: {
deleteGeneration: vi.fn(),
getGenerationStatus: vi.fn(),
},
}));
vi.mock('@/services/generationBatch', () => ({
generationBatchService: {
deleteGenerationBatch: vi.fn(),
getGenerationBatches: vi.fn(),
},
}));
vi.mock('swr', async () => {
const actual = await vi.importActual('swr');
return {
...(actual as any),
mutate: vi.fn(),
};
});
beforeEach(() => {
vi.clearAllMocks();
useImageStore.setState({
generationBatchesMap: {},
activeGenerationTopicId: null,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('GenerationBatchAction', () => {
describe('setTopicBatchLoaded', () => {
it('should set topic batch as loaded with empty array and mark topic as loaded', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
// Set the topic as active first
act(() => {
useImageStore.setState({ activeGenerationTopicId: topicId });
});
// Initially topic should not be loaded
expect(generationBatchSelectors.isCurrentGenerationTopicLoaded(result.current)).toBe(false);
// Set topic batch as loaded
act(() => {
result.current.setTopicBatchLoaded(topicId);
});
// Verify both the direct state and the selector
expect(result.current.generationBatchesMap[topicId]).toEqual([]);
expect(generationBatchSelectors.isCurrentGenerationTopicLoaded(result.current)).toBe(true);
});
it('should not update state if map already has the same value', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
act(() => {
useImageStore.setState({
generationBatchesMap: { [topicId]: [] },
activeGenerationTopicId: topicId,
});
});
const stateBefore = result.current.generationBatchesMap;
// Should already be loaded
expect(generationBatchSelectors.isCurrentGenerationTopicLoaded(result.current)).toBe(true);
act(() => {
result.current.setTopicBatchLoaded(topicId);
});
// State reference should remain the same due to isEqual check
expect(result.current.generationBatchesMap).toBe(stateBefore);
expect(generationBatchSelectors.isCurrentGenerationTopicLoaded(result.current)).toBe(true);
});
});
describe('removeGeneration', () => {
it('should remove generation and clean up empty batch', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
const batchId = 'gb_batch_1';
const generationId = 'gen_1';
const batches: GenerationBatch[] = [
{
id: batchId,
provider: 'openai',
model: 'dall-e-3',
prompt: 'Test prompt',
createdAt: new Date(),
generations: [
{
id: generationId,
seed: 12345,
createdAt: new Date(),
asyncTaskId: null,
task: {
id: 'task_1',
status: AsyncTaskStatus.Success,
},
},
],
},
];
act(() => {
useImageStore.setState({
activeGenerationTopicId: topicId,
generationBatchesMap: { [topicId]: batches },
});
});
const deleteGenerationSpy = vi.spyOn(result.current, 'internal_deleteGeneration');
const deleteBatchSpy = vi.spyOn(result.current, 'internal_deleteGenerationBatch');
const refreshSpy = vi.spyOn(result.current, 'refreshGenerationBatches');
await act(async () => {
await result.current.removeGeneration(generationId);
});
expect(deleteGenerationSpy).toHaveBeenCalledWith(generationId);
expect(deleteBatchSpy).toHaveBeenCalledWith(batchId, topicId);
expect(refreshSpy).toHaveBeenCalled();
});
it('should only remove generation if batch is not empty', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
const batchId = 'gb_batch_1';
const batches: GenerationBatch[] = [
{
id: batchId,
provider: 'openai',
model: 'dall-e-3',
prompt: 'Test prompt',
createdAt: new Date(),
generations: [
{
id: 'gen_1',
seed: 12345,
createdAt: new Date(),
asyncTaskId: null,
task: {
id: 'task_1',
status: AsyncTaskStatus.Success,
},
},
{
id: 'gen_2',
seed: 54321,
createdAt: new Date(),
asyncTaskId: null,
task: {
id: 'task_2',
status: AsyncTaskStatus.Success,
},
},
],
},
];
act(() => {
useImageStore.setState({
activeGenerationTopicId: topicId,
generationBatchesMap: { [topicId]: batches },
});
});
const deleteBatchSpy = vi.spyOn(result.current, 'internal_deleteGenerationBatch');
await act(async () => {
await result.current.removeGeneration('gen_1');
});
expect(deleteBatchSpy).not.toHaveBeenCalled();
});
it('should do nothing if no active topic', async () => {
const { result } = renderHook(() => useImageStore());
const deleteGenerationSpy = vi.spyOn(result.current, 'internal_deleteGeneration');
await act(async () => {
await result.current.removeGeneration('gen_1');
});
expect(deleteGenerationSpy).toHaveBeenCalledWith('gen_1');
expect(generationService.deleteGeneration).not.toHaveBeenCalled();
});
});
describe('internal_deleteGeneration', () => {
it('should delete generation with optimistic update', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
const batchId = 'gb_batch_1';
const generationId = 'gen_1';
const batches: GenerationBatch[] = [
{
id: batchId,
provider: 'openai',
model: 'dall-e-3',
prompt: 'Test prompt',
createdAt: new Date(),
generations: [
{
id: generationId,
seed: 12345,
createdAt: new Date(),
asyncTaskId: null,
task: {
id: 'task_1',
status: AsyncTaskStatus.Success,
},
},
],
},
];
act(() => {
useImageStore.setState({
activeGenerationTopicId: topicId,
generationBatchesMap: { [topicId]: batches },
});
});
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchGenerationBatch');
const refreshSpy = vi.spyOn(result.current, 'refreshGenerationBatches');
await act(async () => {
await result.current.internal_deleteGeneration(generationId);
});
expect(dispatchSpy).toHaveBeenCalledWith(
topicId,
{ type: 'deleteGenerationInBatch', batchId, generationId },
'internal_deleteGeneration',
);
expect(generationService.deleteGeneration).toHaveBeenCalledWith(generationId);
expect(refreshSpy).toHaveBeenCalled();
});
it('should do nothing if generation not found', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
act(() => {
useImageStore.setState({
activeGenerationTopicId: topicId,
generationBatchesMap: { [topicId]: [] },
});
});
await act(async () => {
await result.current.internal_deleteGeneration('non_existent_gen');
});
expect(generationService.deleteGeneration).not.toHaveBeenCalled();
});
});
describe('removeGenerationBatch', () => {
it('should call internal_deleteGenerationBatch', async () => {
const { result } = renderHook(() => useImageStore());
const batchId = 'gb_batch_1';
const topicId = 'gt_topic_1';
const deleteBatchSpy = vi.spyOn(result.current, 'internal_deleteGenerationBatch');
await act(async () => {
await result.current.removeGenerationBatch(batchId, topicId);
});
expect(deleteBatchSpy).toHaveBeenCalledWith(batchId, topicId);
});
});
describe('internal_deleteGenerationBatch', () => {
it('should delete batch with optimistic update', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
const batchId = 'gb_batch_1';
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchGenerationBatch');
const refreshSpy = vi.spyOn(result.current, 'refreshGenerationBatches');
await act(async () => {
await result.current.internal_deleteGenerationBatch(batchId, topicId);
});
expect(dispatchSpy).toHaveBeenCalledWith(
topicId,
{ type: 'deleteBatch', id: batchId },
'internal_deleteGenerationBatch',
);
expect(generationBatchService.deleteGenerationBatch).toHaveBeenCalledWith(batchId);
expect(refreshSpy).toHaveBeenCalled();
});
});
describe('internal_dispatchGenerationBatch', () => {
it('should update batch map when state changes', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
act(() => {
useImageStore.setState({ generationBatchesMap: { [topicId]: [] } });
});
act(() => {
result.current.internal_dispatchGenerationBatch(
topicId,
{
type: 'addBatch',
value: {
id: 'gb_batch_1',
provider: 'openai',
model: 'dall-e-3',
prompt: 'Test prompt',
createdAt: new Date(),
generations: [],
},
},
'test_action',
);
});
expect(result.current.generationBatchesMap[topicId]).toHaveLength(1);
expect(result.current.generationBatchesMap[topicId][0].id).toBe('gb_batch_1');
});
it('should not update when map is equal', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
const existingBatches: GenerationBatch[] = [
{
id: 'gb_batch_1',
provider: 'openai',
model: 'dall-e-3',
prompt: 'Test prompt',
createdAt: new Date('2024-01-01'),
generations: [],
},
];
act(() => {
useImageStore.setState({ generationBatchesMap: { [topicId]: existingBatches } });
});
const stateBefore = result.current.generationBatchesMap;
// Try to update with same data (reducer will return same array reference)
act(() => {
result.current.internal_dispatchGenerationBatch(
topicId,
{
type: 'updateBatch',
id: 'gb_batch_1',
value: { prompt: 'Test prompt' }, // Same prompt
},
'test_action',
);
});
// State reference should remain the same due to isEqual check
expect(result.current.generationBatchesMap).toBe(stateBefore);
});
});
describe('refreshGenerationBatches', () => {
it('should call mutate when there is active topic', async () => {
const { result } = renderHook(() => useImageStore());
const topicId = 'gt_topic_1';
act(() => {
useImageStore.setState({ activeGenerationTopicId: topicId });
});
await act(async () => {
await result.current.refreshGenerationBatches();
});
expect(mutate).toHaveBeenCalledWith(['SWR_USE_FETCH_GENERATION_BATCHES', topicId]);
});
it('should not call mutate when no active topic', async () => {
const { result } = renderHook(() => useImageStore());
await act(async () => {
await result.current.refreshGenerationBatches();
});
expect(mutate).not.toHaveBeenCalled();
});
});
describe('useFetchGenerationBatches', () => {
it('should fetch batches for a topic', async () => {
const topicId = 'gt_topic_1';
const batches: GenerationBatch[] = [
{
id: 'gb_batch_1',
provider: 'openai',
model: 'dall-e-3',
prompt: 'Test prompt',
createdAt: new Date(),
generations: [],
},
];
vi.mocked(generationBatchService.getGenerationBatches).mockResolvedValue(batches);
const { result } = renderHook(() => {
const store = useImageStore();
// Simulate the onSuccess callback behavior directly
React.useEffect(() => {
useImageStore.setState({
generationBatchesMap: { [topicId]: batches },
});
}, []);
return { data: batches };
});
await waitFor(() => {
expect(useImageStore.getState().generationBatchesMap[topicId]).toEqual(batches);
});
});
it('should not fetch when no topicId', async () => {
const { result } = renderHook(() => {
const store = useImageStore();
// Test the actual hook with null parameter
const swrResult = store.useFetchGenerationBatches(null);
return swrResult;
});
// When key is null, SWR returns an object with undefined data
expect(result.current.data).toBeUndefined();
expect(generationBatchService.getGenerationBatches).not.toHaveBeenCalled();
});
});
describe('useCheckGenerationStatus', () => {
it('should not check status for temporary generations', async () => {
const { result } = renderHook(() => {
const store = useImageStore();
// Test the actual hook with temporary generation ID
const swrResult = store.useCheckGenerationStatus('temp-gen-1', 'task_1', 'gt_topic_1');
return swrResult;
});
// When conditions aren't met, SWR returns an object with undefined data
expect(result.current.data).toBeUndefined();
expect(generationService.getGenerationStatus).not.toHaveBeenCalled();
});
it('should not check status when disabled', async () => {
const { result } = renderHook(() => {
const store = useImageStore();
// Test the actual hook with enable=false
const swrResult = store.useCheckGenerationStatus('gen_1', 'task_1', 'gt_topic_1', false);
return swrResult;
});
// When disabled, SWR returns an object with undefined data
expect(result.current.data).toBeUndefined();
expect(generationService.getGenerationStatus).not.toHaveBeenCalled();
});
});
});