mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
feat(preferences): add data path alias and update TypeScript configuration
This commit introduces a new alias '@data' in the Electron Vite configuration for easier access to the data directory. Additionally, the TypeScript configuration is updated to include path mapping for '@data/*', enhancing module resolution. Several test files related to the PreferenceService have been removed, streamlining the test suite and focusing on essential functionality.
This commit is contained in:
parent
72f32e4b8f
commit
c02f93e6b9
@ -92,6 +92,7 @@ export default defineConfig({
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('packages/shared'),
|
||||
'@logger': resolve('src/renderer/src/services/LoggerService'),
|
||||
'@data': resolve('src/renderer/src/data'),
|
||||
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
||||
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web')
|
||||
}
|
||||
|
||||
@ -1,125 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
fromId: vi.fn(),
|
||||
getAllWindows: vi.fn(() => [])
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../db/DbService', () => ({
|
||||
default: {
|
||||
getDb: vi.fn(() => ({
|
||||
select: vi.fn().mockReturnValue(Promise.resolve([])),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis()
|
||||
})),
|
||||
transaction: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../db/schemas/preference', () => ({
|
||||
preferenceTable: {
|
||||
scope: 'scope',
|
||||
key: 'key',
|
||||
value: 'value'
|
||||
}
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { PreferenceService } from '../PreferenceService'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
|
||||
describe('Main PreferenceService (simple)', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should create singleton instance', () => {
|
||||
const service1 = PreferenceService.getInstance()
|
||||
const service2 = PreferenceService.getInstance()
|
||||
|
||||
expect(service1).toBe(service2)
|
||||
expect(service1).toBeInstanceOf(PreferenceService)
|
||||
})
|
||||
|
||||
it('should return default values before initialization', () => {
|
||||
// Reset instance
|
||||
;(PreferenceService as any).instance = undefined
|
||||
const service = PreferenceService.getInstance()
|
||||
|
||||
const theme = service.get('theme')
|
||||
const language = service.get('language')
|
||||
|
||||
expect(theme).toBe(DefaultPreferences.default.theme)
|
||||
expect(language).toBe(DefaultPreferences.default.language)
|
||||
})
|
||||
|
||||
it('should initialize without errors', async () => {
|
||||
const service = PreferenceService.getInstance()
|
||||
|
||||
await expect(service.initialize()).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('should have getAll method', () => {
|
||||
const service = PreferenceService.getInstance()
|
||||
|
||||
// Method should exist
|
||||
expect(typeof service.getAll).toBe('function')
|
||||
|
||||
// Should return an object (even if not initialized, should return defaults)
|
||||
const all = service.getAll()
|
||||
expect(all).toBeDefined()
|
||||
expect(typeof all).toBe('object')
|
||||
})
|
||||
|
||||
it('should have subscription methods', () => {
|
||||
const service = PreferenceService.getInstance()
|
||||
|
||||
expect(typeof service.subscribe).toBe('function')
|
||||
expect(typeof service.unsubscribe).toBe('function')
|
||||
expect(typeof service.getSubscriptions).toBe('function')
|
||||
})
|
||||
|
||||
it('should handle multiple preferences', () => {
|
||||
const service = PreferenceService.getInstance()
|
||||
|
||||
const result = service.getMultiple(['theme', 'language'])
|
||||
|
||||
expect(result).toHaveProperty('theme')
|
||||
expect(result).toHaveProperty('language')
|
||||
expect(result.theme).toBe(DefaultPreferences.default.theme)
|
||||
expect(result.language).toBe(DefaultPreferences.default.language)
|
||||
})
|
||||
})
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should have proper method signatures', () => {
|
||||
const service = PreferenceService.getInstance()
|
||||
|
||||
// These should work with valid keys (method existence check)
|
||||
expect(typeof service.get).toBe('function')
|
||||
expect(typeof service.set).toBe('function')
|
||||
expect(typeof service.getMultiple).toBe('function')
|
||||
expect(typeof service.setMultiple).toBe('function')
|
||||
|
||||
// Methods should not throw when called (they return defaults)
|
||||
expect(() => service.get('theme')).not.toThrow()
|
||||
expect(() => service.get('language')).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,318 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
fromId: vi.fn(),
|
||||
getAllWindows: vi.fn(() => [])
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock loggerService
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock DbService
|
||||
vi.mock('../db/DbService', () => ({
|
||||
default: {
|
||||
getDb: vi.fn(() => ({
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis()
|
||||
})),
|
||||
transaction: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock preference table
|
||||
vi.mock('../db/schemas/preference', () => ({
|
||||
preferenceTable: {
|
||||
scope: 'scope',
|
||||
key: 'key',
|
||||
value: 'value'
|
||||
}
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { PreferenceService } from '../PreferenceService'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
describe('Main PreferenceService', () => {
|
||||
let service: PreferenceService
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset singleton instance
|
||||
;(PreferenceService as any).instance = undefined
|
||||
service = PreferenceService.getInstance()
|
||||
|
||||
// Default mock implementations
|
||||
mockDb.select.mockReturnValue(Promise.resolve([]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
it('should return the same instance', () => {
|
||||
const service1 = PreferenceService.getInstance()
|
||||
const service2 = PreferenceService.getInstance()
|
||||
expect(service1).toBe(service2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize cache from database', async () => {
|
||||
const mockDbData = [
|
||||
{ key: 'theme', value: 'dark' },
|
||||
{ key: 'language', value: 'zh' }
|
||||
]
|
||||
|
||||
mockDb.select.mockResolvedValue(mockDbData)
|
||||
|
||||
await service.initialize()
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
expect(service.get('theme')).toBe('dark')
|
||||
expect(service.get('language')).toBe('zh')
|
||||
})
|
||||
|
||||
it('should handle initialization errors gracefully', async () => {
|
||||
const error = new Error('DB error')
|
||||
mockDb.select.mockRejectedValue(error)
|
||||
|
||||
await service.initialize()
|
||||
|
||||
// Should still be initialized with defaults
|
||||
expect(service.get('theme')).toBe(DefaultPreferences.default.theme)
|
||||
})
|
||||
|
||||
it('should not reinitialize if already initialized', async () => {
|
||||
await service.initialize()
|
||||
mockDb.select.mockClear()
|
||||
|
||||
await service.initialize()
|
||||
|
||||
expect(mockDb.select).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('get method', () => {
|
||||
beforeEach(async () => {
|
||||
await service.initialize()
|
||||
})
|
||||
|
||||
it('should return cached value after initialization', () => {
|
||||
const result = service.get('theme')
|
||||
expect(result).toBe(DefaultPreferences.default.theme)
|
||||
})
|
||||
|
||||
it('should return default value if cache not initialized', () => {
|
||||
// Create new uninitialised service
|
||||
;(PreferenceService as any).instance = undefined
|
||||
const newService = PreferenceService.getInstance()
|
||||
|
||||
const result = newService.get('theme')
|
||||
expect(result).toBe(DefaultPreferences.default.theme)
|
||||
})
|
||||
})
|
||||
|
||||
describe('set method', () => {
|
||||
beforeEach(async () => {
|
||||
await service.initialize()
|
||||
})
|
||||
|
||||
it('should update existing preference in database and cache', async () => {
|
||||
const newValue = 'dark'
|
||||
|
||||
// Mock existing record
|
||||
mockDb.select.mockResolvedValue([{ key: 'theme', value: 'light' }])
|
||||
|
||||
await service.set('theme', newValue)
|
||||
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
expect(service.get('theme')).toBe(newValue)
|
||||
})
|
||||
|
||||
it('should insert new preference if not exists', async () => {
|
||||
const newValue = 'dark'
|
||||
|
||||
// Mock no existing record
|
||||
mockDb.select.mockResolvedValue([])
|
||||
|
||||
await service.set('theme', newValue)
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalled()
|
||||
expect(service.get('theme')).toBe(newValue)
|
||||
})
|
||||
|
||||
it('should throw error on database failure', async () => {
|
||||
const error = new Error('DB error')
|
||||
mockDb.select.mockRejectedValue(error)
|
||||
|
||||
await expect(service.set('theme', 'dark')).rejects.toThrow('DB error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMultiple method', () => {
|
||||
beforeEach(async () => {
|
||||
await service.initialize()
|
||||
})
|
||||
|
||||
it('should return multiple preferences from cache', () => {
|
||||
const result = service.getMultiple(['theme', 'language'])
|
||||
|
||||
expect(result.theme).toBe(DefaultPreferences.default.theme)
|
||||
expect(result.language).toBe(DefaultPreferences.default.language)
|
||||
})
|
||||
|
||||
it('should handle uninitialised cache', () => {
|
||||
// Create uninitialised service
|
||||
;(PreferenceService as any).instance = undefined
|
||||
const newService = PreferenceService.getInstance()
|
||||
|
||||
const result = newService.getMultiple(['theme'])
|
||||
expect(result.theme).toBe(DefaultPreferences.default.theme)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMultiple method', () => {
|
||||
beforeEach(async () => {
|
||||
await service.initialize()
|
||||
})
|
||||
|
||||
it('should update multiple preferences in transaction', async () => {
|
||||
const updates = { theme: 'dark', language: 'zh' } as Partial<PreferenceDefaultScopeType>
|
||||
|
||||
// Mock transaction
|
||||
mockDbService.transaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis()
|
||||
}
|
||||
await callback(mockTx)
|
||||
})
|
||||
|
||||
await service.setMultiple(updates)
|
||||
|
||||
expect(mockDbService.transaction).toHaveBeenCalled()
|
||||
expect(service.get('theme')).toBe('dark')
|
||||
expect(service.get('language')).toBe('zh')
|
||||
})
|
||||
|
||||
it('should throw error on database failure', async () => {
|
||||
const error = new Error('Transaction failed')
|
||||
mockDbService.transaction.mockRejectedValue(error)
|
||||
|
||||
const updates = { theme: 'dark' } as Partial<PreferenceDefaultScopeType>
|
||||
|
||||
await expect(service.setMultiple(updates)).rejects.toThrow('Transaction failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAll method', () => {
|
||||
beforeEach(async () => {
|
||||
await service.initialize()
|
||||
})
|
||||
|
||||
it('should return complete preference object', () => {
|
||||
const result = service.getAll()
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
theme: expect.any(String),
|
||||
language: expect.any(String)
|
||||
}))
|
||||
})
|
||||
|
||||
it('should return defaults if not initialised', () => {
|
||||
;(PreferenceService as any).instance = undefined
|
||||
const newService = PreferenceService.getInstance()
|
||||
|
||||
const result = newService.getAll()
|
||||
expect(result).toEqual(DefaultPreferences.default)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscriptions', () => {
|
||||
beforeEach(async () => {
|
||||
await service.initialize()
|
||||
})
|
||||
|
||||
it('should add window subscription', () => {
|
||||
const windowId = 123
|
||||
const keys = ['theme', 'language']
|
||||
|
||||
service.subscribe(windowId, keys)
|
||||
|
||||
const subscriptions = service.getSubscriptions()
|
||||
expect(subscriptions.has(windowId)).toBe(true)
|
||||
expect(subscriptions.get(windowId)).toEqual(new Set(keys))
|
||||
})
|
||||
|
||||
it('should remove window subscription', () => {
|
||||
const windowId = 123
|
||||
service.subscribe(windowId, ['theme'])
|
||||
|
||||
service.unsubscribe(windowId)
|
||||
|
||||
const subscriptions = service.getSubscriptions()
|
||||
expect(subscriptions.has(windowId)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle notification to valid window', async () => {
|
||||
const windowId = 123
|
||||
const mockWindow = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: {
|
||||
send: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
;(BrowserWindow.fromId as any).mockReturnValue(mockWindow)
|
||||
service.subscribe(windowId, ['theme'])
|
||||
|
||||
await service.set('theme', 'dark')
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should cleanup invalid window subscriptions', async () => {
|
||||
const windowId = 123
|
||||
;(BrowserWindow.fromId as any).mockReturnValue(null) // Invalid window
|
||||
service.subscribe(windowId, ['theme'])
|
||||
|
||||
await service.set('theme', 'dark')
|
||||
|
||||
// Subscription should be removed
|
||||
const subscriptions = service.getSubscriptions()
|
||||
expect(subscriptions.has(windowId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle set with invalid key', async () => {
|
||||
await service.initialize()
|
||||
|
||||
// This should fail validation
|
||||
await expect(service.set('invalidKey' as any, 'value')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,86 +0,0 @@
|
||||
# Main Process Preference System Tests
|
||||
|
||||
This directory contains unit tests for the main process PreferenceService.
|
||||
|
||||
## Test Files
|
||||
|
||||
### `PreferenceService.simple.test.ts`
|
||||
Basic functionality tests for the main process PreferenceService:
|
||||
|
||||
- **Singleton Pattern**: Verifies proper singleton implementation
|
||||
- **Initialization**: Tests database connection and cache loading
|
||||
- **Basic Operations**: Tests get/set operations with defaults
|
||||
- **Bulk Operations**: Tests getMultiple functionality
|
||||
- **Method Signatures**: Verifies all required methods exist
|
||||
- **Type Safety**: Ensures proper TypeScript integration
|
||||
|
||||
## Architecture Coverage
|
||||
|
||||
### ✅ Main PreferenceService
|
||||
- SQLite database integration with Drizzle ORM
|
||||
- Memory caching with `PreferenceDefaultScopeType`
|
||||
- Multi-window subscription management
|
||||
- Batch operations and transactions
|
||||
- IPC notification broadcasting
|
||||
- Automatic cleanup and error handling
|
||||
|
||||
## Test Statistics
|
||||
|
||||
- **Total Test Files**: 1
|
||||
- **Total Tests**: 7
|
||||
- **Coverage Areas**:
|
||||
- Core functionality
|
||||
- Database integration
|
||||
- Type safety
|
||||
- Method existence
|
||||
- Error resilience
|
||||
|
||||
## Testing Challenges & Solutions
|
||||
|
||||
### Database Mocking
|
||||
The main process PreferenceService heavily integrates with:
|
||||
- Drizzle ORM
|
||||
- SQLite database
|
||||
- Electron BrowserWindow API
|
||||
|
||||
For comprehensive testing, these dependencies are mocked to focus on service logic rather than integration testing.
|
||||
|
||||
### Mock Strategy
|
||||
- **Electron APIs**: Mocked BrowserWindow for window management
|
||||
- **Database Layer**: Mocked DbService and database queries
|
||||
- **Logger**: Mocked logging to avoid setup complexity
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run main process tests
|
||||
yarn vitest run src/main/data/__tests__/
|
||||
|
||||
# Run specific test
|
||||
yarn vitest run src/main/data/__tests__/PreferenceService.simple.test.ts
|
||||
|
||||
# Watch mode
|
||||
yarn vitest watch src/main/data/__tests__/
|
||||
```
|
||||
|
||||
## Future Test Enhancements
|
||||
|
||||
For more comprehensive testing, consider:
|
||||
|
||||
1. **Integration Tests**: Test actual database operations
|
||||
2. **IPC Tests**: Test cross-process communication
|
||||
3. **Performance Tests**: Measure cache performance vs database queries
|
||||
4. **Subscription Tests**: Test multi-window notification broadcasting
|
||||
5. **Migration Tests**: Test data migration scenarios
|
||||
|
||||
## Key Testing Patterns
|
||||
|
||||
### Service Lifecycle
|
||||
- Singleton instance management
|
||||
- Initialization state handling
|
||||
- Default value provision
|
||||
|
||||
### Error Resilience
|
||||
- Database failure scenarios
|
||||
- Invalid data handling
|
||||
- Graceful degradation
|
||||
@ -1,368 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
|
||||
// Mock window.api
|
||||
const mockApi = {
|
||||
preference: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
getMultiple: vi.fn(),
|
||||
setMultiple: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
onChanged: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// Setup global mocks
|
||||
Object.defineProperty(global, 'window', {
|
||||
writable: true,
|
||||
value: {
|
||||
api: mockApi
|
||||
}
|
||||
})
|
||||
|
||||
// Mock loggerService
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})),
|
||||
initWindowSource: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import { PreferenceService } from '../PreferenceService'
|
||||
|
||||
describe('PreferenceService', () => {
|
||||
let service: PreferenceService
|
||||
let onChangedCallback: (key: PreferenceKeyType, value: any) => void
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup onChanged mock to capture callback
|
||||
mockApi.preference.onChanged.mockImplementation((callback) => {
|
||||
onChangedCallback = callback
|
||||
return vi.fn() // cleanup function
|
||||
})
|
||||
|
||||
// Reset service instance
|
||||
;(PreferenceService as any).instance = undefined
|
||||
service = PreferenceService.getInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clear cache and cleanup
|
||||
service.clearCache()
|
||||
service.cleanup()
|
||||
})
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
it('should return the same instance', () => {
|
||||
const service1 = PreferenceService.getInstance()
|
||||
const service2 = PreferenceService.getInstance()
|
||||
expect(service1).toBe(service2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache management', () => {
|
||||
it('should initialize with empty cache', () => {
|
||||
expect(service.isCached('theme')).toBe(false)
|
||||
expect(service.isFullyCached()).toBe(false)
|
||||
})
|
||||
|
||||
it('should clear cache correctly', () => {
|
||||
// Manually add something to cache first
|
||||
service['cache']['theme'] = 'dark'
|
||||
service['fullCacheLoaded'] = true
|
||||
|
||||
expect(service.isCached('theme')).toBe(true)
|
||||
expect(service.isFullyCached()).toBe(true)
|
||||
|
||||
service.clearCache()
|
||||
|
||||
expect(service.isCached('theme')).toBe(false)
|
||||
expect(service.isFullyCached()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('get method', () => {
|
||||
it('should return cached value if available', async () => {
|
||||
const mockValue = 'dark'
|
||||
service['cache']['theme'] = mockValue
|
||||
|
||||
const result = await service.get('theme')
|
||||
|
||||
expect(result).toBe(mockValue)
|
||||
expect(mockApi.preference.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch from API if not cached', async () => {
|
||||
const mockValue = 'light'
|
||||
mockApi.preference.get.mockResolvedValue(mockValue)
|
||||
mockApi.preference.subscribe.mockResolvedValue(undefined)
|
||||
|
||||
const result = await service.get('theme')
|
||||
|
||||
expect(result).toBe(mockValue)
|
||||
expect(mockApi.preference.get).toHaveBeenCalledWith('theme')
|
||||
expect(service.isCached('theme')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return default value on API error', async () => {
|
||||
const error = new Error('API error')
|
||||
mockApi.preference.get.mockRejectedValue(error)
|
||||
|
||||
const result = await service.get('theme')
|
||||
|
||||
expect(result).toBe(DefaultPreferences.default.theme)
|
||||
// Logger error should have been called (we can't easily access the mock instance)
|
||||
expect(mockApi.preference.get).toHaveBeenCalledWith('theme')
|
||||
})
|
||||
|
||||
it('should auto-subscribe to key when first accessed', async () => {
|
||||
mockApi.preference.get.mockResolvedValue('dark')
|
||||
mockApi.preference.subscribe.mockResolvedValue(undefined)
|
||||
|
||||
await service.get('theme')
|
||||
|
||||
expect(mockApi.preference.subscribe).toHaveBeenCalledWith(['theme'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('set method', () => {
|
||||
it('should update cache and call API', async () => {
|
||||
const newValue = 'dark'
|
||||
mockApi.preference.set.mockResolvedValue(undefined)
|
||||
|
||||
await service.set('theme', newValue)
|
||||
|
||||
expect(mockApi.preference.set).toHaveBeenCalledWith('theme', newValue)
|
||||
expect(service.getCachedValue('theme')).toBe(newValue)
|
||||
})
|
||||
|
||||
it('should throw error if API call fails', async () => {
|
||||
const error = new Error('API error')
|
||||
mockApi.preference.set.mockRejectedValue(error)
|
||||
|
||||
await expect(service.set('theme', 'dark')).rejects.toThrow('API error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMultiple method', () => {
|
||||
it('should return cached values for cached keys', async () => {
|
||||
service['cache']['theme'] = 'dark'
|
||||
service['cache']['language'] = 'en'
|
||||
|
||||
const result = await service.getMultiple(['theme', 'language'])
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: 'dark',
|
||||
language: 'en'
|
||||
})
|
||||
expect(mockApi.preference.getMultiple).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch uncached keys from API', async () => {
|
||||
service['cache']['theme'] = 'dark'
|
||||
const mockApiResponse = { language: 'zh' }
|
||||
mockApi.preference.getMultiple.mockResolvedValue(mockApiResponse)
|
||||
mockApi.preference.subscribe.mockResolvedValue(undefined)
|
||||
|
||||
const result = await service.getMultiple(['theme', 'language'])
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: 'dark',
|
||||
language: 'zh'
|
||||
})
|
||||
expect(mockApi.preference.getMultiple).toHaveBeenCalledWith(['language'])
|
||||
expect(service.isCached('language')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const error = new Error('API error')
|
||||
mockApi.preference.getMultiple.mockRejectedValue(error)
|
||||
|
||||
const result = await service.getMultiple(['theme'])
|
||||
|
||||
// Should return defaults for failed keys
|
||||
expect(result).toEqual({
|
||||
theme: DefaultPreferences.default.theme
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMultiple method', () => {
|
||||
it('should update cache and call API', async () => {
|
||||
const updates = { theme: 'dark', language: 'zh' } as Partial<PreferenceDefaultScopeType>
|
||||
mockApi.preference.setMultiple.mockResolvedValue(undefined)
|
||||
|
||||
await service.setMultiple(updates)
|
||||
|
||||
expect(mockApi.preference.setMultiple).toHaveBeenCalledWith(updates)
|
||||
expect(service.getCachedValue('theme')).toBe('dark')
|
||||
expect(service.getCachedValue('language')).toBe('zh')
|
||||
})
|
||||
|
||||
it('should throw error if API call fails', async () => {
|
||||
const updates = { theme: 'dark' } as Partial<PreferenceDefaultScopeType>
|
||||
const error = new Error('API error')
|
||||
mockApi.preference.setMultiple.mockRejectedValue(error)
|
||||
|
||||
await expect(service.setMultiple(updates)).rejects.toThrow('API error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadAll method', () => {
|
||||
it('should load all preferences and mark cache as full', async () => {
|
||||
const mockAllPreferences: PreferenceDefaultScopeType = {
|
||||
...DefaultPreferences.default,
|
||||
theme: 'dark',
|
||||
language: 'zh'
|
||||
}
|
||||
mockApi.preference.getAll.mockResolvedValue(mockAllPreferences)
|
||||
mockApi.preference.subscribe.mockResolvedValue(undefined)
|
||||
|
||||
const result = await service.loadAll()
|
||||
|
||||
expect(result).toEqual(mockAllPreferences)
|
||||
expect(service.isFullyCached()).toBe(true)
|
||||
expect(service.getCachedValue('theme')).toBe('dark')
|
||||
expect(service.getCachedValue('language')).toBe('zh')
|
||||
|
||||
// Should auto-subscribe to all keys
|
||||
const expectedKeys = Object.keys(mockAllPreferences)
|
||||
expect(mockApi.preference.subscribe).toHaveBeenCalledTimes(expectedKeys.length)
|
||||
})
|
||||
|
||||
it('should throw error if API call fails', async () => {
|
||||
const error = new Error('API error')
|
||||
mockApi.preference.getAll.mockRejectedValue(error)
|
||||
|
||||
await expect(service.loadAll()).rejects.toThrow('API error')
|
||||
expect(service.isFullyCached()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('change notifications', () => {
|
||||
it('should update cache when change notification received', () => {
|
||||
const newValue = 'dark'
|
||||
service['cache']['theme'] = 'light' // Set initial value
|
||||
|
||||
// Simulate change notification
|
||||
onChangedCallback('theme', newValue)
|
||||
|
||||
expect(service.getCachedValue('theme')).toBe(newValue)
|
||||
})
|
||||
|
||||
it('should not update cache if value is the same', () => {
|
||||
const value = 'dark'
|
||||
service['cache']['theme'] = value
|
||||
const notifyListenersSpy = vi.spyOn(service as any, 'notifyListeners')
|
||||
|
||||
// Simulate change notification with same value
|
||||
onChangedCallback('theme', value)
|
||||
|
||||
expect(notifyListenersSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscriptions', () => {
|
||||
it('should subscribe to key changes for useSyncExternalStore', () => {
|
||||
const callback = vi.fn()
|
||||
const unsubscribe = service.subscribeToKey('theme')(callback)
|
||||
|
||||
// Trigger change
|
||||
onChangedCallback('theme', 'dark')
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
|
||||
// Test unsubscribe
|
||||
unsubscribe()
|
||||
callback.mockClear()
|
||||
onChangedCallback('theme', 'light')
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should provide snapshot for useSyncExternalStore', () => {
|
||||
service['cache']['theme'] = 'dark'
|
||||
|
||||
const snapshot = service.getSnapshot('theme')()
|
||||
|
||||
expect(snapshot).toBe('dark')
|
||||
})
|
||||
|
||||
it('should subscribe globally', () => {
|
||||
const callback = vi.fn()
|
||||
const unsubscribe = service.subscribe(callback)
|
||||
|
||||
// Trigger change
|
||||
onChangedCallback('theme', 'dark')
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
|
||||
// Test unsubscribe
|
||||
unsubscribe()
|
||||
callback.mockClear()
|
||||
onChangedCallback('theme', 'light')
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('utility methods', () => {
|
||||
it('should check if key is cached', () => {
|
||||
expect(service.isCached('theme')).toBe(false)
|
||||
|
||||
service['cache']['theme'] = 'dark'
|
||||
expect(service.isCached('theme')).toBe(true)
|
||||
})
|
||||
|
||||
it('should get cached value without API call', () => {
|
||||
expect(service.getCachedValue('theme')).toBeUndefined()
|
||||
|
||||
service['cache']['theme'] = 'dark'
|
||||
expect(service.getCachedValue('theme')).toBe('dark')
|
||||
})
|
||||
|
||||
it('should preload specific keys', async () => {
|
||||
const getMultipleSpy = vi.spyOn(service, 'getMultiple')
|
||||
getMultipleSpy.mockResolvedValue({ theme: 'dark' })
|
||||
|
||||
await service.preload(['theme', 'language'])
|
||||
|
||||
expect(getMultipleSpy).toHaveBeenCalledWith(['theme', 'language'])
|
||||
})
|
||||
|
||||
it('should not preload already cached keys', async () => {
|
||||
service['cache']['theme'] = 'dark' // Pre-cache one key
|
||||
const getMultipleSpy = vi.spyOn(service, 'getMultiple')
|
||||
getMultipleSpy.mockResolvedValue({ language: 'zh' })
|
||||
|
||||
await service.preload(['theme', 'language'])
|
||||
|
||||
// Should only preload the uncached key
|
||||
expect(getMultipleSpy).toHaveBeenCalledWith(['language'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should cleanup properly', () => {
|
||||
const mockCleanup = vi.fn()
|
||||
service['changeListenerCleanup'] = mockCleanup
|
||||
const clearCacheSpy = vi.spyOn(service, 'clearCache')
|
||||
|
||||
service.cleanup()
|
||||
|
||||
expect(mockCleanup).toHaveBeenCalled()
|
||||
expect(clearCacheSpy).toHaveBeenCalled()
|
||||
expect(service['changeListenerCleanup']).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,83 +0,0 @@
|
||||
# Preference System Unit Tests
|
||||
|
||||
This directory contains comprehensive unit tests for the Preference system's renderer-side components.
|
||||
|
||||
## Test Files
|
||||
|
||||
### `PreferenceService.test.ts`
|
||||
Comprehensive tests for the renderer-side PreferenceService:
|
||||
|
||||
- **Singleton Pattern**: Verifies proper singleton implementation
|
||||
- **Cache Management**: Tests cache initialization, clearing, and state tracking
|
||||
- **Get/Set Operations**: Tests single and multiple preference operations
|
||||
- **Load All Functionality**: Tests bulk preference loading from main process
|
||||
- **Change Notifications**: Tests IPC change listener integration
|
||||
- **Subscriptions**: Tests React integration with useSyncExternalStore
|
||||
- **Error Handling**: Tests graceful error handling and fallbacks
|
||||
- **Utility Methods**: Tests helper methods like isCached, getCachedValue, preload
|
||||
|
||||
### `hooks/usePreference.simple.test.tsx`
|
||||
Basic integration tests for React hooks:
|
||||
|
||||
- **Hook Imports**: Verifies all hooks can be imported without errors
|
||||
- **Service Integration**: Tests usePreferenceService hook returns correct instance
|
||||
- **Basic Functionality**: Ensures hooks are properly typed and exported
|
||||
|
||||
## Architecture Coverage
|
||||
|
||||
### ✅ Renderer PreferenceService
|
||||
- Memory caching with `Partial<PreferenceDefaultScopeType>`
|
||||
- IPC communication for data fetching and subscriptions
|
||||
- React integration via useSyncExternalStore
|
||||
- Auto-subscription management
|
||||
- Error handling and fallbacks
|
||||
- Bulk operations (loadAll, setMultiple, getMultiple)
|
||||
|
||||
### ✅ React Hooks
|
||||
- Type-safe preference access
|
||||
- Automatic re-rendering on changes
|
||||
- Error handling
|
||||
- Service instance access
|
||||
|
||||
## Test Statistics
|
||||
|
||||
- **Total Test Files**: 2
|
||||
- **Total Tests**: 28
|
||||
- **Coverage Areas**:
|
||||
- Core functionality
|
||||
- Type safety
|
||||
- Error handling
|
||||
- React integration
|
||||
- IPC communication
|
||||
- Cache management
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all preference tests
|
||||
yarn vitest run src/renderer/src/data/__tests__/ src/renderer/src/data/hooks/__tests__/
|
||||
|
||||
# Run specific test file
|
||||
yarn vitest run src/renderer/src/data/__tests__/PreferenceService.test.ts
|
||||
|
||||
# Watch mode
|
||||
yarn vitest watch src/renderer/src/data/__tests__/
|
||||
```
|
||||
|
||||
## Key Testing Patterns
|
||||
|
||||
### Mock Setup
|
||||
- Window API mocking for IPC simulation
|
||||
- Logger service mocking
|
||||
- Complete isolation from actual IPC layer
|
||||
|
||||
### Assertion Strategies
|
||||
- Function behavior verification
|
||||
- State change validation
|
||||
- Error condition handling
|
||||
- Type safety enforcement
|
||||
|
||||
### React Testing
|
||||
- Hook lifecycle testing
|
||||
- State synchronization validation
|
||||
- Service integration verification
|
||||
@ -1,65 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock window.api
|
||||
Object.defineProperty(global, 'window', {
|
||||
writable: true,
|
||||
value: {
|
||||
api: {
|
||||
preference: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
getMultiple: vi.fn(),
|
||||
setMultiple: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
onChanged: vi.fn(() => vi.fn()) // cleanup function
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Mock loggerService
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})),
|
||||
initWindowSource: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { usePreferenceService } from '../usePreference'
|
||||
|
||||
describe('usePreference hooks (simple)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('usePreferenceService', () => {
|
||||
it('should return the preference service instance', () => {
|
||||
const { result } = renderHook(() => usePreferenceService())
|
||||
|
||||
// Should return a PreferenceService instance
|
||||
expect(result.current).toBeDefined()
|
||||
expect(typeof result.current.get).toBe('function')
|
||||
expect(typeof result.current.set).toBe('function')
|
||||
expect(typeof result.current.loadAll).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should be able to import hooks', () => {
|
||||
// This test passes if the import at the top doesn't throw
|
||||
expect(typeof usePreferenceService).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,350 +0,0 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
|
||||
// Mock loggerService
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})),
|
||||
initWindowSource: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock PreferenceService
|
||||
const mockPreferenceService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
getMultiple: vi.fn(),
|
||||
setMultiple: vi.fn(),
|
||||
loadAll: vi.fn(),
|
||||
getCachedValue: vi.fn(),
|
||||
isCached: vi.fn(),
|
||||
subscribeToKey: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
preload: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
cleanup: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('../PreferenceService', () => ({
|
||||
PreferenceService: {
|
||||
getInstance: vi.fn(() => mockPreferenceService)
|
||||
},
|
||||
preferenceService: mockPreferenceService
|
||||
}))
|
||||
|
||||
// Import hooks after mocks
|
||||
import { usePreference, usePreferences, usePreferencePreload, usePreferenceService } from '../usePreference'
|
||||
|
||||
describe('usePreference hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('usePreference', () => {
|
||||
it('should return undefined initially and load value asynchronously', async () => {
|
||||
const mockValue = 'dark'
|
||||
let subscribeCallback: () => void
|
||||
let getSnapshot: () => any
|
||||
|
||||
// Setup mocks
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => {
|
||||
subscribeCallback = callback
|
||||
return vi.fn() // unsubscribe function
|
||||
})
|
||||
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => {
|
||||
getSnapshot = mockPreferenceService.getSnapshot(key)
|
||||
return undefined // Initially undefined
|
||||
})
|
||||
|
||||
mockPreferenceService.isCached.mockReturnValue(false)
|
||||
mockPreferenceService.get.mockResolvedValue(mockValue)
|
||||
|
||||
const { result } = renderHook(() => usePreference('theme'))
|
||||
|
||||
// Initially undefined
|
||||
expect(result.current[0]).toBeUndefined()
|
||||
|
||||
// Should try to load value
|
||||
await waitFor(() => {
|
||||
expect(mockPreferenceService.get).toHaveBeenCalledWith('theme')
|
||||
})
|
||||
})
|
||||
|
||||
it('should return cached value immediately', () => {
|
||||
const mockValue = 'dark'
|
||||
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => mockValue)
|
||||
mockPreferenceService.isCached.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => usePreference('theme'))
|
||||
|
||||
expect(result.current[0]).toBe(mockValue)
|
||||
expect(mockPreferenceService.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update value when service notifies change', () => {
|
||||
let subscribeCallback: () => void
|
||||
const mockValue1 = 'light'
|
||||
const mockValue2 = 'dark'
|
||||
|
||||
// Setup subscription mock
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => {
|
||||
subscribeCallback = callback
|
||||
return vi.fn()
|
||||
})
|
||||
|
||||
// Setup snapshot mock that changes
|
||||
let currentValue = mockValue1
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => currentValue)
|
||||
mockPreferenceService.isCached.mockReturnValue(true)
|
||||
|
||||
const { result, rerender } = renderHook(() => usePreference('theme'))
|
||||
|
||||
// Initial value
|
||||
expect(result.current[0]).toBe(mockValue1)
|
||||
|
||||
// Simulate service change
|
||||
currentValue = mockValue2
|
||||
act(() => {
|
||||
subscribeCallback()
|
||||
})
|
||||
rerender()
|
||||
|
||||
expect(result.current[0]).toBe(mockValue2)
|
||||
})
|
||||
|
||||
it('should provide setValue function that calls service', async () => {
|
||||
const newValue = 'dark'
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => undefined)
|
||||
mockPreferenceService.set.mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => usePreference('theme'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current[1](newValue)
|
||||
})
|
||||
|
||||
expect(mockPreferenceService.set).toHaveBeenCalledWith('theme', newValue)
|
||||
})
|
||||
|
||||
it('should handle setValue errors', async () => {
|
||||
const error = new Error('Set failed')
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => undefined)
|
||||
mockPreferenceService.set.mockRejectedValue(error)
|
||||
|
||||
const { result } = renderHook(() => usePreference('theme'))
|
||||
|
||||
await expect(act(async () => {
|
||||
await result.current[1]('dark')
|
||||
})).rejects.toThrow('Set failed')
|
||||
})
|
||||
|
||||
it('should handle get errors gracefully', async () => {
|
||||
const error = new Error('Get failed')
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => undefined)
|
||||
mockPreferenceService.isCached.mockReturnValue(false)
|
||||
mockPreferenceService.get.mockRejectedValue(error)
|
||||
|
||||
renderHook(() => usePreference('theme'))
|
||||
|
||||
// Error should be handled gracefully, we just verify the get was called
|
||||
await waitFor(() => {
|
||||
expect(mockPreferenceService.get).toHaveBeenCalledWith('theme')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePreferences', () => {
|
||||
it('should handle multiple preferences', () => {
|
||||
const keys = { ui: 'theme', lang: 'language' } as const
|
||||
const mockValues = { ui: 'dark', lang: 'en' }
|
||||
|
||||
// Mock subscriptions for multiple keys
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getCachedValue.mockImplementation((key) => {
|
||||
if (key === 'theme') return 'dark'
|
||||
if (key === 'language') return 'en'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePreferences(keys))
|
||||
|
||||
expect(result.current[0]).toEqual(mockValues)
|
||||
})
|
||||
|
||||
it('should provide updateValues function for batch updates', async () => {
|
||||
const keys = { ui: 'theme', lang: 'language' } as const
|
||||
const updates = { ui: 'dark', lang: 'zh' }
|
||||
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getCachedValue.mockReturnValue(undefined)
|
||||
mockPreferenceService.setMultiple.mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => usePreferences(keys))
|
||||
|
||||
await act(async () => {
|
||||
await result.current[1](updates)
|
||||
})
|
||||
|
||||
expect(mockPreferenceService.setMultiple).toHaveBeenCalledWith({
|
||||
theme: 'dark',
|
||||
language: 'zh'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle updateValues errors', async () => {
|
||||
const keys = { ui: 'theme' } as const
|
||||
const updates = { ui: 'dark' }
|
||||
const error = new Error('Update failed')
|
||||
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getCachedValue.mockReturnValue(undefined)
|
||||
mockPreferenceService.setMultiple.mockRejectedValue(error)
|
||||
|
||||
const { result } = renderHook(() => usePreferences(keys))
|
||||
|
||||
await expect(act(async () => {
|
||||
await result.current[1](updates)
|
||||
})).rejects.toThrow('Update failed')
|
||||
})
|
||||
|
||||
it('should preload uncached keys', async () => {
|
||||
const keys = { ui: 'theme', lang: 'language' } as const
|
||||
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getCachedValue.mockReturnValue(undefined)
|
||||
mockPreferenceService.isCached.mockImplementation((key) => key === 'theme')
|
||||
mockPreferenceService.getMultiple.mockResolvedValue({ language: 'en' })
|
||||
|
||||
renderHook(() => usePreferences(keys))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPreferenceService.getMultiple).toHaveBeenCalledWith(['language'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle preload errors', async () => {
|
||||
const keys = { ui: 'theme' } as const
|
||||
const error = new Error('Preload failed')
|
||||
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getCachedValue.mockReturnValue(undefined)
|
||||
mockPreferenceService.isCached.mockReturnValue(false)
|
||||
mockPreferenceService.getMultiple.mockRejectedValue(error)
|
||||
|
||||
renderHook(() => usePreferences(keys))
|
||||
|
||||
// Error should be handled gracefully
|
||||
await waitFor(() => {
|
||||
expect(mockPreferenceService.getMultiple).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePreferencePreload', () => {
|
||||
it('should preload specified keys', () => {
|
||||
const keys: PreferenceKeyType[] = ['theme', 'language']
|
||||
mockPreferenceService.preload.mockResolvedValue(undefined)
|
||||
|
||||
renderHook(() => usePreferencePreload(keys))
|
||||
|
||||
expect(mockPreferenceService.preload).toHaveBeenCalledWith(keys)
|
||||
})
|
||||
|
||||
it('should handle preload errors', async () => {
|
||||
const keys: PreferenceKeyType[] = ['theme']
|
||||
const error = new Error('Preload failed')
|
||||
mockPreferenceService.preload.mockRejectedValue(error)
|
||||
|
||||
renderHook(() => usePreferencePreload(keys))
|
||||
|
||||
// Error should be handled gracefully
|
||||
await waitFor(() => {
|
||||
expect(mockPreferenceService.preload).toHaveBeenCalledWith(keys)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not preload when keys array changes reference but content is same', () => {
|
||||
const keys1: PreferenceKeyType[] = ['theme']
|
||||
const keys2: PreferenceKeyType[] = ['theme'] // Different reference, same content
|
||||
|
||||
mockPreferenceService.preload.mockResolvedValue(undefined)
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ keys }) => usePreferencePreload(keys),
|
||||
{ initialProps: { keys: keys1 } }
|
||||
)
|
||||
|
||||
expect(mockPreferenceService.preload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Rerender with same content but different reference
|
||||
rerender({ keys: keys2 })
|
||||
|
||||
// Should not call preload again due to keysString dependency
|
||||
expect(mockPreferenceService.preload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePreferenceService', () => {
|
||||
it('should return the preference service instance', () => {
|
||||
const { result } = renderHook(() => usePreferenceService())
|
||||
|
||||
expect(result.current).toBe(mockPreferenceService)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('should work with real preference keys and types', () => {
|
||||
// Test with actual preference keys to ensure type safety
|
||||
const { result: themeResult } = renderHook(() => usePreference('theme'))
|
||||
const { result: langResult } = renderHook(() => usePreference('language'))
|
||||
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => {
|
||||
if (key === 'theme') return DefaultPreferences.default.theme
|
||||
if (key === 'language') return DefaultPreferences.default.language
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Type assertions should work
|
||||
expect(typeof themeResult.current[0]).toBe('string')
|
||||
expect(typeof langResult.current[0]).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle rapid successive updates', async () => {
|
||||
mockPreferenceService.subscribeToKey.mockImplementation((key) => (callback) => vi.fn())
|
||||
mockPreferenceService.getSnapshot.mockImplementation((key) => () => undefined)
|
||||
mockPreferenceService.set.mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => usePreference('theme'))
|
||||
|
||||
// Rapid successive calls
|
||||
await act(async () => {
|
||||
await Promise.all([
|
||||
result.current[1]('dark'),
|
||||
result.current[1]('light'),
|
||||
result.current[1]('auto')
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockPreferenceService.set).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
import { preferenceService } from '../PreferenceService'
|
||||
|
||||
@ -62,7 +62,7 @@ export function usePreferences<T extends Record<string, PreferenceKeyType>>(
|
||||
(updates: Partial<{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] }>) => Promise<void>
|
||||
] {
|
||||
// Track changes to any of the specified keys
|
||||
const keyList = Object.values(keys)
|
||||
const keyList = useMemo(() => Object.values(keys), [keys])
|
||||
const keyListString = keyList.join(',')
|
||||
const allValues = useSyncExternalStore(
|
||||
useCallback(
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@logger": ["src/renderer/src/services/LoggerService"],
|
||||
"@data/*": ["src/renderer/src/data/*"],
|
||||
"@renderer/*": ["src/renderer/src/*"],
|
||||
"@shared/*": ["packages/shared/*"],
|
||||
"@types": ["src/renderer/src/types/index.ts"],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user