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:
fullex 2025-08-12 11:35:09 +08:00
parent 72f32e4b8f
commit c02f93e6b9
10 changed files with 4 additions and 1397 deletions

View File

@ -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')
}

View File

@ -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()
})
})
})

View File

@ -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()
})
})
})

View File

@ -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

View File

@ -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()
})
})
})

View File

@ -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

View File

@ -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')
})
})
})

View File

@ -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)
})
})
})

View File

@ -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(

View File

@ -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"],