jan/web-app/src/hooks/__tests__/useModelSources.test.ts
2025-08-14 11:33:03 +07:00

387 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useModelSources } from '../useModelSources'
import type { CatalogModel } from '@/services/models'
// Mock constants
vi.mock('@/constants/localStorage', () => ({
localStorageKey: {
modelSources: 'model-sources-settings',
},
}))
// Mock zustand persist
vi.mock('zustand/middleware', () => ({
persist: (fn: any) => fn,
createJSONStorage: () => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
}),
}))
// Mock the fetchModelCatalog service
vi.mock('@/services/models', () => ({
fetchModelCatalog: vi.fn(),
}))
// Mock the sanitizeModelId function
vi.mock('@/lib/utils', () => ({
sanitizeModelId: vi.fn((id: string) => id),
}))
describe('useModelSources', () => {
let mockFetchModelCatalog: any
beforeEach(async () => {
vi.clearAllMocks()
// Get the mocked function
const { fetchModelCatalog } = await import('@/services/models')
mockFetchModelCatalog = fetchModelCatalog as any
// Reset store state to defaults
useModelSources.setState({
sources: [],
error: null,
loading: false,
})
})
it('should initialize with default values', () => {
const { result } = renderHook(() => useModelSources())
expect(result.current.sources).toEqual([])
expect(result.current.error).toBe(null)
expect(result.current.loading).toBe(false)
expect(typeof result.current.fetchSources).toBe('function')
})
describe('fetchSources', () => {
it('should fetch sources successfully', async () => {
const mockSources: CatalogModel[] = [
{
model_name: 'model-1',
description: 'First model',
developer: 'provider-1',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'model-1-q4', path: '/path/1', file_size: '1GB' }],
},
{
model_name: 'model-2',
description: 'Second model',
developer: 'provider-2',
downloads: 200,
num_quants: 1,
quants: [{ model_id: 'model-2-q4', path: '/path/2', file_size: '2GB' }],
},
]
mockFetchModelCatalog.mockResolvedValueOnce(mockSources)
const { result } = renderHook(() => useModelSources())
await act(async () => {
await result.current.fetchSources()
})
expect(mockFetchModelCatalog).toHaveBeenCalledOnce()
expect(result.current.sources).toEqual(mockSources)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBe(null)
})
it('should handle fetch errors', async () => {
const mockError = new Error('Network error')
mockFetchModelCatalog.mockRejectedValueOnce(mockError)
const { result } = renderHook(() => useModelSources())
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.loading).toBe(false)
expect(result.current.error).toBe(mockError)
expect(result.current.sources).toEqual([])
})
it('should not merge new sources with existing ones', async () => {
const existingSources: CatalogModel[] = [
{
model_name: 'existing-model',
description: 'Existing model',
developer: 'existing-provider',
downloads: 50,
num_quants: 1,
quants: [{ model_id: 'existing-model-q4', path: '/path/existing', file_size: '1GB' }],
},
]
const newSources: CatalogModel[] = [
{
model_name: 'new-model',
description: 'New model',
developer: 'new-provider',
downloads: 150,
num_quants: 1,
quants: [{ model_id: 'new-model-q4', path: '/path/new', file_size: '2GB' }],
},
]
// Set initial state with existing sources
useModelSources.setState({
sources: existingSources,
error: null,
loading: false,
})
mockFetchModelCatalog.mockResolvedValueOnce(newSources)
const { result } = renderHook(() => useModelSources())
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.sources).toEqual(newSources)
})
it('should not duplicate models with same model_name', async () => {
const existingSources: CatalogModel[] = [
{
model_name: 'duplicate-model',
description: 'Old version',
developer: 'old-provider',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'duplicate-model-q4', path: '/path/old', file_size: '1GB' }],
},
{
model_name: 'unique-model',
description: 'Unique model',
developer: 'provider',
downloads: 75,
num_quants: 1,
quants: [{ model_id: 'unique-model-q4', path: '/path/unique', file_size: '1GB' }],
},
]
const newSources: CatalogModel[] = [
{
model_name: 'duplicate-model',
description: 'New version',
developer: 'new-provider',
downloads: 200,
num_quants: 1,
quants: [{ model_id: 'duplicate-model-q4-new', path: '/path/new', file_size: '2GB' }],
},
]
// Set initial state with existing sources
useModelSources.setState({
sources: existingSources,
error: null,
loading: false,
})
mockFetchModelCatalog.mockResolvedValueOnce(newSources)
const { result } = renderHook(() => useModelSources())
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.sources).toEqual(newSources)
})
it('should handle empty sources response', async () => {
mockFetchModelCatalog.mockResolvedValueOnce([])
const { result } = renderHook(() => useModelSources())
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.sources).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.error).toBe(null)
})
it('should clear previous error on successful fetch', async () => {
const { result } = renderHook(() => useModelSources())
// First request fails
mockFetchModelCatalog.mockRejectedValueOnce(new Error('First error'))
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.error).toBeInstanceOf(Error)
// Second request succeeds
const mockSources: CatalogModel[] = [
{
model_name: 'model-1',
description: 'Model 1',
developer: 'provider-1',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'model-1-q4', path: '/path/1', file_size: '1GB' }],
},
]
mockFetchModelCatalog.mockResolvedValueOnce(mockSources)
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.error).toBe(null)
expect(result.current.sources).toEqual(mockSources)
})
})
describe('state management', () => {
it('should maintain state across multiple hook instances', () => {
const { result: result1 } = renderHook(() => useModelSources())
const { result: result2 } = renderHook(() => useModelSources())
expect(result1.current.sources).toBe(result2.current.sources)
expect(result1.current.loading).toBe(result2.current.loading)
expect(result1.current.error).toBe(result2.current.error)
})
it('should update state across multiple hook instances', async () => {
const mockSources: CatalogModel[] = [
{
model_name: 'shared-model',
description: 'Shared model',
developer: 'shared-provider',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'shared-model-q4', path: '/path/shared', file_size: '1GB' }],
},
]
mockFetchModelCatalog.mockResolvedValueOnce(mockSources)
const { result: result1 } = renderHook(() => useModelSources())
const { result: result2 } = renderHook(() => useModelSources())
await act(async () => {
await result1.current.fetchSources()
})
expect(result2.current.sources).toEqual(mockSources)
})
})
describe('error handling', () => {
it('should handle different error types', async () => {
const errors = [
new Error('Network error'),
new TypeError('Type error'),
new ReferenceError('Reference error'),
]
const { result } = renderHook(() => useModelSources())
for (const error of errors) {
mockFetchModelCatalog.mockRejectedValueOnce(error)
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.error).toBe(error)
expect(result.current.loading).toBe(false)
expect(result.current.sources).toEqual([])
}
})
})
describe('complex scenarios', () => {
it('should handle multiple fetch operations', async () => {
const { result } = renderHook(() => useModelSources())
const sources1: CatalogModel[] = [
{
model_name: 'model-1',
description: 'First batch',
developer: 'provider-1',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'model-1-q4', path: '/path/1', file_size: '1GB' }],
},
]
const sources2: CatalogModel[] = [
{
model_name: 'model-2',
description: 'Second batch',
developer: 'provider-2',
downloads: 200,
num_quants: 1,
quants: [{ model_id: 'model-2-q4', path: '/path/2', file_size: '2GB' }],
},
]
// First fetch
mockFetchModelCatalog.mockResolvedValueOnce(sources1)
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.sources).toEqual(sources1)
// Second fetch
mockFetchModelCatalog.mockResolvedValueOnce(sources2)
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.sources).toEqual(sources2)
})
it('should handle fetch after error', async () => {
const { result } = renderHook(() => useModelSources())
// First request fails
mockFetchModelCatalog.mockRejectedValueOnce(new Error('Network error'))
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.error).toBeInstanceOf(Error)
// Second request succeeds
const mockSources: CatalogModel[] = [
{
model_name: 'recovery-model',
description: 'Recovery model',
developer: 'recovery-provider',
downloads: 100,
num_quants: 1,
quants: [{ model_id: 'recovery-model-q4', path: '/path/recovery', file_size: '1GB' }],
},
]
mockFetchModelCatalog.mockResolvedValueOnce(mockSources)
await act(async () => {
await result.current.fetchSources()
})
expect(result.current.error).toBe(null)
expect(result.current.sources).toEqual(mockSources)
})
})
})