diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 35cf21a6f0..075107154c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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') } diff --git a/src/main/data/__tests__/PreferenceService.simple.test.ts b/src/main/data/__tests__/PreferenceService.simple.test.ts deleted file mode 100644 index 9aa06ae587..0000000000 --- a/src/main/data/__tests__/PreferenceService.simple.test.ts +++ /dev/null @@ -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() - }) - }) -}) \ No newline at end of file diff --git a/src/main/data/__tests__/PreferenceService.test.ts b/src/main/data/__tests__/PreferenceService.test.ts deleted file mode 100644 index 2a817a0560..0000000000 --- a/src/main/data/__tests__/PreferenceService.test.ts +++ /dev/null @@ -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 - - // 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 - - 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() - }) - }) -}) \ No newline at end of file diff --git a/src/main/data/__tests__/README.md b/src/main/data/__tests__/README.md deleted file mode 100644 index f96641d6b4..0000000000 --- a/src/main/data/__tests__/README.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/renderer/src/data/__tests__/PreferenceService.test.ts b/src/renderer/src/data/__tests__/PreferenceService.test.ts deleted file mode 100644 index b74def2e56..0000000000 --- a/src/renderer/src/data/__tests__/PreferenceService.test.ts +++ /dev/null @@ -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 - 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 - 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() - }) - }) -}) \ No newline at end of file diff --git a/src/renderer/src/data/__tests__/README.md b/src/renderer/src/data/__tests__/README.md deleted file mode 100644 index 8e4ff906d0..0000000000 --- a/src/renderer/src/data/__tests__/README.md +++ /dev/null @@ -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` -- 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 \ No newline at end of file diff --git a/src/renderer/src/data/hooks/__tests__/usePreference.simple.test.tsx b/src/renderer/src/data/hooks/__tests__/usePreference.simple.test.tsx deleted file mode 100644 index 11b209a288..0000000000 --- a/src/renderer/src/data/hooks/__tests__/usePreference.simple.test.tsx +++ /dev/null @@ -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') - }) - }) -}) \ No newline at end of file diff --git a/src/renderer/src/data/hooks/__tests__/usePreference.test.tsx b/src/renderer/src/data/hooks/__tests__/usePreference.test.tsx deleted file mode 100644 index b6f5a5ae81..0000000000 --- a/src/renderer/src/data/hooks/__tests__/usePreference.test.tsx +++ /dev/null @@ -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) - }) - }) -}) \ No newline at end of file diff --git a/src/renderer/src/data/hooks/usePreference.ts b/src/renderer/src/data/hooks/usePreference.ts index f4e919e60e..0e2ecb851b 100644 --- a/src/renderer/src/data/hooks/usePreference.ts +++ b/src/renderer/src/data/hooks/usePreference.ts @@ -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>( (updates: Partial<{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] }>) => Promise ] { // 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( diff --git a/tsconfig.web.json b/tsconfig.web.json index 269f119f3b..544599b258 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -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"],