From 8353f331f1daf43bea29f3c4fa5f513e66c442c6 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Tue, 16 Sep 2025 14:07:54 +0800 Subject: [PATCH] test: update tests to use usePreference hook and improve snapshot consistency - Refactored tests in MainTextBlock and ThinkingBlock to utilize the usePreference hook for managing user settings. - Updated snapshots in DraggableVirtualList test to reflect changes in class names. - Enhanced export tests to ensure proper handling of markdown formatting and citation footnotes. - Mocked additional dependencies globally for improved test reliability. --- .../DraggableVirtualList.test.tsx.snap | 3 +- src/renderer/src/data/README.md | 305 +++++++++ .../Blocks/__tests__/MainTextBlock.test.tsx | 18 +- .../Blocks/__tests__/ThinkingBlock.test.tsx | 70 +- .../src/services/__tests__/ApiService.test.ts | 3 +- .../src/utils/__tests__/export.test.ts | 37 +- .../src/utils/__tests__/match.test.ts | 14 +- .../src/utils/__tests__/naming.test.ts | 14 +- src/renderer/src/utils/export.ts | 2 +- tests/__mocks__/README.md | 609 ++++++++++++++++++ tests/__mocks__/main/CacheService.ts | 237 +++++++ tests/__mocks__/main/DataApiService.ts | 169 +++++ tests/__mocks__/main/PreferenceService.ts | 286 ++++++++ tests/__mocks__/renderer/CacheService.ts | 389 +++++++++++ tests/__mocks__/renderer/DataApiService.ts | 326 ++++++++++ tests/__mocks__/renderer/PreferenceService.ts | 98 +++ tests/__mocks__/renderer/useCache.ts | 428 ++++++++++++ tests/__mocks__/renderer/useDataApi.ts | 369 +++++++++++ tests/__mocks__/renderer/usePreference.ts | 296 +++++++++ tests/main.setup.ts | 34 + tests/renderer.setup.ts | 36 ++ 21 files changed, 3706 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/data/README.md create mode 100644 tests/__mocks__/README.md create mode 100644 tests/__mocks__/main/CacheService.ts create mode 100644 tests/__mocks__/main/DataApiService.ts create mode 100644 tests/__mocks__/main/PreferenceService.ts create mode 100644 tests/__mocks__/renderer/CacheService.ts create mode 100644 tests/__mocks__/renderer/DataApiService.ts create mode 100644 tests/__mocks__/renderer/PreferenceService.ts create mode 100644 tests/__mocks__/renderer/useCache.ts create mode 100644 tests/__mocks__/renderer/useDataApi.ts create mode 100644 tests/__mocks__/renderer/usePreference.ts diff --git a/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap b/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap index 17a207ef30..2bfce9a29c 100644 --- a/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap +++ b/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap @@ -20,8 +20,7 @@ exports[`DraggableVirtualList > snapshot > should match snapshot with custom sty
{ + try { + await setTheme(newTheme) // Auto-rollback on failure + } catch (error) { + console.error('Theme update failed:', error) + } +} +``` + +### useCache Hooks + +Component-friendly cache management with automatic lifecycle handling. + +```typescript +import { useCache, useSharedCache, usePersistCache } from '@data/hooks/useCache' + +// Memory cache (useState-like, but shared between components) +const [counter, setCounter] = useCache('ui.counter', 0) + +// Shared cache (cross-window) +const [layout, setLayout] = useSharedCache('window.layout') + +// Persistent cache (survives restarts) +const [recentFiles, setRecentFiles] = usePersistCache('app.recent_files') +``` + +## Best Practices + +### When to Use Which Service + +- **DataApiService**: For database operations, API calls, and any data that needs to persist +- **PreferenceService**: For user settings, app configuration, and preferences +- **CacheService**: For temporary data, computed results, and performance optimization + +### Performance Guidelines + +1. **Prefer React Hooks**: Use `useQuery`, `usePreference`, `useCache` for component integration +2. **Batch Operations**: Use `setMultiple()` for updating multiple preferences +3. **Cache Strategically**: Use appropriate cache tiers based on data lifetime needs +4. **Optimize Re-renders**: SWR and useSyncExternalStore minimize unnecessary re-renders + +### Common Patterns + +```typescript +// Loading states with error handling +const { data, loading, error } = useQuery('/topics') +if (loading) return +if (error) return + +// Form handling with preferences +const [fontSize, setFontSize] = usePreference('chat.message.font_size') +const handleChange = (e) => setFontSize(Number(e.target.value)) + +// Temporary state with caching +const [searchQuery, setSearchQuery] = useCache('search.current_query', '') +const [searchResults, setSearchResults] = useCache('search.results', []) +``` + +## Type Safety + +All services provide full TypeScript support with auto-completion and type checking: + +- **API Types**: Defined in `@shared/data/api/` +- **Preference Types**: Defined in `@shared/data/preference/` +- **Cache Types**: Defined in `@shared/data/cache/` + +Type definitions are automatically inferred, providing: +- Request/response type safety +- Preference key validation +- Cache schema enforcement +- Auto-completion in IDEs + +## Migration from Legacy Systems + +This new data layer replaces multiple legacy systems: +- Redux-persist slices → PreferenceService +- localStorage direct access → CacheService +- Direct IPC calls → DataApiService +- Dexie database operations → DataApiService + +For migration guidelines, see the project's `.claude/` directory documentation. + +## File Structure + +``` +src/renderer/src/data/ +├── DataApiService.ts # User Data API querying service +├── PreferenceService.ts # Preferences management +├── CacheService.ts # Three-tier caching system +└── hooks/ + ├── useDataApi.ts # React hooks for user data operations + ├── usePreference.ts # React hooks for preferences + └── useCache.ts # React hooks for caching +``` + +## Related Documentation + +- **API Schemas**: `packages/shared/data/` - Type definitions and API contracts +- **Architecture Design**: `.claude/data-architecture.md` - Detailed system design +- **Migration Guide**: `.claude/migration-planning.md` - Legacy system migration +- **Project Overview**: `CLAUDE.local.md` - Complete refactoring context \ No newline at end of file diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx index fd5c27dc3d..1193cd8169 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx @@ -12,12 +12,17 @@ import MainTextBlock from '../MainTextBlock' // Mock dependencies const mockUseSettings = vi.fn() const mockUseSelector = vi.fn() +let mockUsePreference: any // Mock hooks vi.mock('@renderer/hooks/useSettings', () => ({ useSettings: () => mockUseSettings() })) +vi.mock('@data/hooks/usePreference', () => ({ + usePreference: vi.fn() +})) + vi.mock('react-redux', async () => { const actual = await import('react-redux') return { @@ -107,12 +112,15 @@ describe('MainTextBlock', () => { // Get the mocked functions const { getModelUniqId } = await import('@renderer/services/ModelService') const { withCitationTags, determineCitationSource } = await import('@renderer/utils/citation') + const { usePreference } = await import('@data/hooks/usePreference') mockGetModelUniqId = getModelUniqId as any mockWithCitationTags = withCitationTags as any mockDetermineCitationSource = determineCitationSource as any + mockUsePreference = usePreference as any // Default mock implementations mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + mockUsePreference.mockReturnValue([false, vi.fn()]) // usePreference returns [value, setter] mockUseSelector.mockReturnValue([]) // Empty citations by default mockGetModelUniqId.mockImplementation((model: Model) => `${model.id}-${model.name}`) }) @@ -167,7 +175,7 @@ describe('MainTextBlock', () => { }) it('should render in plain text mode for user messages when setting disabled', () => { - mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + mockUsePreference.mockReturnValue([false, vi.fn()]) const block = createMainTextBlock({ content: 'User message\nWith line breaks' }) renderMainTextBlock({ block, role: 'user' }) @@ -182,7 +190,7 @@ describe('MainTextBlock', () => { }) it('should render user messages as markdown when setting enabled', () => { - mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true }) + mockUsePreference.mockReturnValue([true, vi.fn()]) const block = createMainTextBlock({ content: 'User **bold** content' }) renderMainTextBlock({ block, role: 'user' }) @@ -191,7 +199,7 @@ describe('MainTextBlock', () => { }) it('should preserve complex formatting in plain text mode', () => { - mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + mockUsePreference.mockReturnValue([false, vi.fn()]) const complexContent = `Line 1 Indented line **Bold not parsed** @@ -417,13 +425,13 @@ describe('MainTextBlock', () => { const block = createMainTextBlock({ content: 'Settings test content' }) // Test with markdown enabled - mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true }) + mockUsePreference.mockReturnValue([true, vi.fn()]) const { unmount } = renderMainTextBlock({ block, role: 'user' }) expect(getRenderedMarkdown()).toBeInTheDocument() unmount() // Test with markdown disabled - mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + mockUsePreference.mockReturnValue([false, vi.fn()]) renderMainTextBlock({ block, role: 'user' }) expect(getRenderedPlainText()).toBeInTheDocument() expect(getRenderedMarkdown()).not.toBeInTheDocument() diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx index 8db122d948..921eb63afa 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx @@ -8,12 +8,17 @@ import ThinkingBlock from '../ThinkingBlock' // Mock dependencies const mockUseSettings = vi.fn() const mockUseTranslation = vi.fn() +let mockUsePreference: any // Mock hooks vi.mock('@renderer/hooks/useSettings', () => ({ useSettings: () => mockUseSettings() })) +vi.mock('@data/hooks/usePreference', () => ({ + usePreference: vi.fn() +})) + vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation() })) @@ -120,6 +125,10 @@ describe('ThinkingBlock', () => { beforeEach(async () => { vi.useFakeTimers() + // Get the mocked functions + const { usePreference } = await import('@data/hooks/usePreference') + mockUsePreference = usePreference as any + // Default mock implementations mockUseSettings.mockReturnValue({ messageFont: 'sans-serif', @@ -127,6 +136,23 @@ describe('ThinkingBlock', () => { thoughtAutoCollapse: false }) + // Mock usePreference calls - component uses these hooks: + // - usePreference('chat.message.font') + // - usePreference('chat.message.font_size') + // - usePreference('chat.message.thought.auto_collapse') + mockUsePreference.mockImplementation((key: string) => { + switch (key) { + case 'chat.message.font': + return ['sans-serif', vi.fn()] + case 'chat.message.font_size': + return [14, vi.fn()] + case 'chat.message.thought.auto_collapse': + return [false, vi.fn()] + default: + return [undefined, vi.fn()] + } + }) + mockUseTranslation.mockReturnValue({ t: (key: string, params?: any) => { if (key === 'chat.thinking' && params?.seconds) { @@ -275,10 +301,17 @@ describe('ThinkingBlock', () => { unmount() // Test collapsed by default (auto-collapse enabled) - mockUseSettings.mockReturnValue({ - messageFont: 'sans-serif', - fontSize: 14, - thoughtAutoCollapse: true + mockUsePreference.mockImplementation((key: string) => { + switch (key) { + case 'chat.message.font': + return ['sans-serif', vi.fn()] + case 'chat.message.font_size': + return [14, vi.fn()] + case 'chat.message.thought.auto_collapse': + return [true, vi.fn()] // Enable auto-collapse + default: + return [undefined, vi.fn()] + } }) renderThinkingBlock(block) @@ -288,10 +321,17 @@ describe('ThinkingBlock', () => { }) it('should auto-collapse when thinking completes if setting enabled', () => { - mockUseSettings.mockReturnValue({ - messageFont: 'sans-serif', - fontSize: 14, - thoughtAutoCollapse: true + mockUsePreference.mockImplementation((key: string) => { + switch (key) { + case 'chat.message.font': + return ['sans-serif', vi.fn()] + case 'chat.message.font_size': + return [14, vi.fn()] + case 'chat.message.thought.auto_collapse': + return [true, vi.fn()] // Enable auto-collapse + default: + return [undefined, vi.fn()] + } }) const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING }) @@ -325,9 +365,17 @@ describe('ThinkingBlock', () => { ] testCases.forEach(({ settings, expectedFont, expectedSize }) => { - mockUseSettings.mockReturnValue({ - ...settings, - thoughtAutoCollapse: false + mockUsePreference.mockImplementation((key: string) => { + switch (key) { + case 'chat.message.font': + return [settings.messageFont, vi.fn()] + case 'chat.message.font_size': + return [settings.fontSize, vi.fn()] + case 'chat.message.thought.auto_collapse': + return [false, vi.fn()] // Keep expanded to test styling + default: + return [undefined, vi.fn()] + } }) const block = createThinkingBlock() diff --git a/src/renderer/src/services/__tests__/ApiService.test.ts b/src/renderer/src/services/__tests__/ApiService.test.ts index a46f724b1c..70cff69d2b 100644 --- a/src/renderer/src/services/__tests__/ApiService.test.ts +++ b/src/renderer/src/services/__tests__/ApiService.test.ts @@ -99,7 +99,8 @@ vi.mock('@renderer/utils', () => ({ })) vi.mock('@shared/config/prompts', () => ({ - WEB_SEARCH_PROMPT_FOR_OPENROUTER: 'mock-prompt' + WEB_SEARCH_PROMPT_FOR_OPENROUTER: 'mock-prompt', + TRANSLATE_PROMPT: 'You are a translation expert. Your only task is to translate text enclosed with from input language to {{target_language}}, provide the translation result directly without any explanation, without `TRANSLATE` and keep original format.' })) vi.mock('@renderer/config/systemModels', () => ({ diff --git a/src/renderer/src/utils/__tests__/export.test.ts b/src/renderer/src/utils/__tests__/export.test.ts index 267dfb4247..7b51cceff6 100644 --- a/src/renderer/src/utils/__tests__/export.test.ts +++ b/src/renderer/src/utils/__tests__/export.test.ts @@ -35,6 +35,11 @@ vi.mock('@renderer/i18n', () => ({ } })) +// Mock getProviderLabel +vi.mock('@renderer/i18n/label', () => ({ + getProviderLabel: vi.fn((providerId: string) => providerId || 'Unknown Provider') +})) + // Mock the find utility functions - crucial for the test vi.mock('@renderer/utils/messageUtils/find', () => ({ // Provide type safety for mocked message @@ -66,11 +71,13 @@ vi.mock('@renderer/hooks/useTopic', () => ({ } })) +// PreferenceService is now mocked globally in tests/renderer.setup.ts + vi.mock('@renderer/utils/markdown', async (importOriginal) => { const actual = await importOriginal() return { ...(actual as any), - markdownToPlainText: vi.fn((str) => str) // Simple pass-through for testing export logic + markdownToPlainText: vi.fn((str: string) => str) // Simple pass-through for testing export logic } }) @@ -311,7 +318,7 @@ describe('export', () => { const markdown = await messageToMarkdown(msgWithCitation) expect(markdown).toContain('## 🤖 Assistant') expect(markdown).toContain('Main content') - expect(markdown).toContain('[1] [https://example1.com](Example Citation 1)') + expect(markdown).toContain('[^1]: [https://example1.com](Example Citation 1)') }) }) @@ -352,34 +359,34 @@ describe('export', () => { expect(sections.length).toBeGreaterThanOrEqual(2) }) - it('should handle tag and replace newlines with
in reasoning', () => { + it('should handle tag and replace newlines with
in reasoning', async () => { const msg = mockedMessages.find((m) => m.id === 'a3') expect(msg).toBeDefined() - const markdown = messageToMarkdownWithReasoning(msg!) + const markdown = await messageToMarkdownWithReasoning(msg!) expect(markdown).toContain('Answer B') expect(markdown).toContain('Line2') expect(markdown).not.toContain('') }) - it('should not include details section if no thinking block exists', () => { + it('should not include details section if no thinking block exists', async () => { const msg = mockedMessages.find((m) => m.id === 'a4') expect(msg).toBeDefined() - const markdown = messageToMarkdownWithReasoning(msg!) + const markdown = await messageToMarkdownWithReasoning(msg!) expect(markdown).toContain('## 🤖 Assistant') expect(markdown).toContain('Simple Answer') expect(markdown).not.toContain(' { + it('should include both reasoning and citation content', async () => { const msg = mockedMessages.find((m) => m.id === 'a5') expect(msg).toBeDefined() - const markdown = messageToMarkdownWithReasoning(msg!) + const markdown = await messageToMarkdownWithReasoning(msg!) expect(markdown).toContain('## 🤖 Assistant') expect(markdown).toContain('Answer with citation') expect(markdown).toContain(' { @@ -415,7 +422,7 @@ describe('export', () => { }) it('should handle an empty array of messages', async () => { - expect(messagesToMarkdown([])).toBe('') + expect(await messagesToMarkdown([])).toBe('') }) it('should handle a single message without separator', async () => { @@ -458,7 +465,7 @@ describe('export', () => { const { TopicManager } = await import('@renderer/hooks/useTopic') ;(TopicManager.getTopicMessages as any).mockResolvedValue([userMsg, assistantMsg]) // Specific mock for this test to check formatting - ;(markdownToPlainText as any).mockImplementation(async (str: string) => str.replace(/[#*]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*]/g, '')) const plainText = await topicToPlainText(testTopic) @@ -475,7 +482,7 @@ describe('export', () => { const testMessage = createMessage({ role: 'user', id: 'single_msg_plain' }, [ { type: MessageBlockType.MAIN_TEXT, content: '### Single Message Content' } ]) - ;(markdownToPlainText as any).mockImplementation(async (str: string) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) const result = await messageToPlainText(testMessage) expect(result).toBe('Single Message Content') @@ -1002,7 +1009,7 @@ describe('Citation formatting in Markdown export', () => { expect(processedContent).not.toContain(' { + test('should properly test formatCitationsAsFootnotes through messageToMarkdown', async () => { const msgWithCitations = createMessage({ role: 'assistant', id: 'test_footnotes' }, [ { type: MessageBlockType.MAIN_TEXT, @@ -1012,13 +1019,13 @@ describe('Citation formatting in Markdown export', () => { ]) // This tests the complete flow including formatCitationsAsFootnotes - const markdown = messageToMarkdown(msgWithCitations) + const markdown = await messageToMarkdown(msgWithCitations) // Should contain the title and content expect(markdown).toContain('## 🤖 Assistant') expect(markdown).toContain('Content with citations') // Should include citation content (mocked by getCitationContent) - expect(markdown).toContain('[1] [https://example1.com](Example Citation 1)') + expect(markdown).toContain('[^1]: [https://example1.com](Example Citation 1)') }) }) diff --git a/src/renderer/src/utils/__tests__/match.test.ts b/src/renderer/src/utils/__tests__/match.test.ts index 223382ea01..0325d11abe 100644 --- a/src/renderer/src/utils/__tests__/match.test.ts +++ b/src/renderer/src/utils/__tests__/match.test.ts @@ -1,8 +1,20 @@ import type { Model, Provider, SystemProvider } from '@renderer/types' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { includeKeywords, matchKeywordsInModel, matchKeywordsInProvider, matchKeywordsInString } from '../match' +// Mock i18n to return English provider labels +vi.mock('@renderer/i18n/label', () => ({ + getProviderLabel: vi.fn((id: string) => { + const labelMap: Record = { + 'dashscope': 'Alibaba Cloud', + 'openai': 'OpenAI', + 'anthropic': 'Anthropic' + } + return labelMap[id] || id + }) +})) + describe('match', () => { const provider = { id: '12345', diff --git a/src/renderer/src/utils/__tests__/naming.test.ts b/src/renderer/src/utils/__tests__/naming.test.ts index 0dfabedda1..a07de2802e 100644 --- a/src/renderer/src/utils/__tests__/naming.test.ts +++ b/src/renderer/src/utils/__tests__/naming.test.ts @@ -1,5 +1,17 @@ import { Provider, SystemProvider } from '@renderer/types' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +// Mock i18n to return English provider labels +vi.mock('@renderer/i18n/label', () => ({ + getProviderLabel: vi.fn((id: string) => { + const labelMap: Record = { + 'dashscope': 'Alibaba Cloud', + 'openai': 'OpenAI', + 'anthropic': 'Anthropic' + } + return labelMap[id] || id + }) +})) import { firstLetter, diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index 0df2f67602..80e58586e1 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -270,7 +270,7 @@ const createBaseMarkdown = async ( normalizeCitations: boolean = true ): Promise<{ titleSection: string; reasoningSection: string; contentSection: string; citation: string }> => { const forceDollarMathInMarkdown = await preferenceService.get('data.export.markdown.force_dollar_math') - const roleText = getRoleText(message.role, message.model?.name, message.model?.provider) + const roleText = await getRoleText(message.role, message.model?.name, message.model?.provider) const titleSection = `## ${roleText}` let reasoningSection = '' diff --git a/tests/__mocks__/README.md b/tests/__mocks__/README.md new file mode 100644 index 0000000000..2ef64b7230 --- /dev/null +++ b/tests/__mocks__/README.md @@ -0,0 +1,609 @@ +# Test Mocks + +这个目录包含了项目中使用的统一测试模拟(mocks)。这些模拟按照进程类型组织,避免重名冲突,并在相应的测试设置文件中全局配置。 + +## 🎯 统一模拟概述 + +### 已实现的统一模拟 + +#### Renderer Process Mocks +- ✅ **PreferenceService** - 渲染进程偏好设置服务模拟 +- ✅ **DataApiService** - 渲染进程数据API服务模拟 +- ✅ **CacheService** - 渲染进程三层缓存服务模拟 +- ✅ **useDataApi hooks** - 数据API钩子模拟 (useQuery, useMutation, usePaginatedQuery, etc.) +- ✅ **usePreference hooks** - 偏好设置钩子模拟 (usePreference, useMultiplePreferences) +- ✅ **useCache hooks** - 缓存钩子模拟 (useCache, useSharedCache, usePersistCache) + +#### Main Process Mocks +- ✅ **PreferenceService** - 主进程偏好设置服务模拟 +- ✅ **DataApiService** - 主进程数据API服务模拟 +- ✅ **CacheService** - 主进程缓存服务模拟 + +### 🌟 核心优势 + +- **进程分离**: 按照renderer/main分开组织,避免重名冲突 +- **自动应用**: 无需在每个测试文件中单独模拟 +- **完整API覆盖**: 实现了所有服务和钩子的完整API +- **类型安全**: 完全支持 TypeScript,保持与真实服务的类型兼容性 +- **现实行为**: 模拟提供现实的默认值和行为模式 +- **高度可定制**: 支持为特定测试定制行为 +- **测试工具**: 内置丰富的测试工具函数 + +### 📁 文件结构 + +``` +tests/__mocks__/ +├── README.md # 本文档 +├── renderer/ # 渲染进程模拟 +│ ├── PreferenceService.ts # 渲染进程偏好设置服务模拟 +│ ├── DataApiService.ts # 渲染进程数据API服务模拟 +│ ├── CacheService.ts # 渲染进程缓存服务模拟 +│ ├── useDataApi.ts # 数据API钩子模拟 +│ ├── usePreference.ts # 偏好设置钩子模拟 +│ └── useCache.ts # 缓存钩子模拟 +├── main/ # 主进程模拟 +│ ├── PreferenceService.ts # 主进程偏好设置服务模拟 +│ ├── DataApiService.ts # 主进程数据API服务模拟 +│ └── CacheService.ts # 主进程缓存服务模拟 +├── RendererLoggerService.ts # 渲染进程日志服务模拟 +└── MainLoggerService.ts # 主进程日志服务模拟 +``` + +### 🔧 测试设置 + +#### Renderer Process Tests +在 `tests/renderer.setup.ts` 中配置了所有渲染进程模拟: + +```typescript +// 自动加载 renderer/ 目录下的模拟 +vi.mock('@data/PreferenceService', async () => { + const { MockPreferenceService } = await import('./__mocks__/renderer/PreferenceService') + return MockPreferenceService +}) +// ... 其他渲染进程模拟 +``` + +#### Main Process Tests +在 `tests/main.setup.ts` 中配置了所有主进程模拟: + +```typescript +// 自动加载 main/ 目录下的模拟 +vi.mock('@main/data/PreferenceService', async () => { + const { MockMainPreferenceServiceExport } = await import('./__mocks__/main/PreferenceService') + return MockMainPreferenceServiceExport +}) +// ... 其他主进程模拟 +``` + +## PreferenceService Mock + +### 简介 + +`PreferenceService.ts` 提供了 PreferenceService 的统一模拟实现,用于所有渲染进程测试。这个模拟: + +- ✅ **自动应用**:在 `renderer.setup.ts` 中全局配置,无需在每个测试文件中单独模拟 +- ✅ **完整API**:实现了 PreferenceService 的所有方法(get, getMultiple, set, etc.) +- ✅ **合理默认值**:提供了常用偏好设置的默认值 +- ✅ **可定制**:支持为特定测试定制默认值 +- ✅ **类型安全**:完全支持 TypeScript 类型检查 + +### 默认值 + +模拟提供了以下默认偏好设置: + +```typescript +// 导出偏好设置 +'data.export.markdown.force_dollar_math': false +'data.export.markdown.exclude_citations': false +'data.export.markdown.standardize_citations': true +'data.export.markdown.show_model_name': false +'data.export.markdown.show_model_provider': false + +// UI偏好设置 +'ui.language': 'en' +'ui.theme': 'light' +'ui.font_size': 14 + +// AI偏好设置 +'ai.default_model': 'gpt-4' +'ai.temperature': 0.7 +'ai.max_tokens': 2000 + +// 功能开关 +'feature.web_search': true +'feature.reasoning': false +'feature.tool_calling': true +``` + +### 基本使用 + +由于模拟已经全局配置,大多数测试可以直接使用 PreferenceService,无需额外设置: + +```typescript +import { preferenceService } from '@data/PreferenceService' + +describe('MyComponent', () => { + it('should use preference values', async () => { + // PreferenceService 已经被自动模拟 + const value = await preferenceService.get('ui.theme') + expect(value).toBe('light') // 使用默认值 + }) +}) +``` + +### 高级使用 + +#### 1. 修改单个测试的偏好值 + +```typescript +import { preferenceService } from '@data/PreferenceService' +import { vi } from 'vitest' + +describe('Custom preferences', () => { + it('should work with custom preference values', async () => { + // 为这个测试修改特定值 + ;(preferenceService.get as any).mockImplementation((key: string) => { + if (key === 'ui.theme') return Promise.resolve('dark') + // 其他键使用默认模拟行为 + return vi.fn().mockResolvedValue(null)() + }) + + const theme = await preferenceService.get('ui.theme') + expect(theme).toBe('dark') + }) +}) +``` + +#### 2. 重置模拟状态 + +```typescript +import { preferenceService } from '@data/PreferenceService' + +describe('Mock state management', () => { + beforeEach(() => { + // 重置模拟到初始状态 + if ('_resetMockState' in preferenceService) { + ;(preferenceService as any)._resetMockState() + } + }) +}) +``` + +#### 3. 检查模拟内部状态 + +```typescript +import { preferenceService } from '@data/PreferenceService' + +describe('Mock inspection', () => { + it('should allow inspecting mock state', () => { + // 查看当前模拟状态 + if ('_getMockState' in preferenceService) { + const state = (preferenceService as any)._getMockState() + console.log('Current mock state:', state) + } + }) +}) +``` + +#### 4. 为整个测试套件定制默认值 + +如果需要为特定的测试文件定制默认值,可以在该文件中重新模拟: + +```typescript +import { vi } from 'vitest' + +// 重写全局模拟,添加自定义默认值 +vi.mock('@data/PreferenceService', async () => { + const { createMockPreferenceService } = await import('tests/__mocks__/PreferenceService') + + // 定制默认值 + const customDefaults = { + 'my.custom.setting': 'custom_value', + 'ui.theme': 'dark' // 覆盖默认值 + } + + return { + preferenceService: createMockPreferenceService(customDefaults) + } +}) +``` + +### 测试验证 + +可以验证 PreferenceService 方法是否被正确调用: + +```typescript +import { preferenceService } from '@data/PreferenceService' +import { vi } from 'vitest' + +describe('Preference service calls', () => { + it('should call preference service methods', async () => { + await preferenceService.get('ui.theme') + + // 验证方法调用 + expect(preferenceService.get).toHaveBeenCalledWith('ui.theme') + expect(preferenceService.get).toHaveBeenCalledTimes(1) + }) +}) +``` + +### 添加新的默认值 + +当项目中添加新的偏好设置时,请在 `PreferenceService.ts` 的 `mockPreferenceDefaults` 中添加相应的默认值: + +```typescript +export const mockPreferenceDefaults: Record = { + // 现有默认值... + + // 新增默认值 + 'new.feature.enabled': true, + 'new.feature.config': { option: 'value' } +} +``` + +这样可以确保所有测试都能使用合理的默认值,减少测试失败的可能性。 + +## DataApiService Mock + +### 简介 + +`DataApiService.ts` 提供了数据API服务的统一模拟,支持所有HTTP方法和高级功能。 + +### 功能特性 + +- **完整HTTP支持**: GET, POST, PUT, PATCH, DELETE +- **批量操作**: batch() 和 transaction() 支持 +- **订阅系统**: subscribe/unsubscribe 模拟 +- **连接管理**: connect/disconnect/ping 方法 +- **智能模拟数据**: 基于路径自动生成合理的响应 + +### 基本使用 + +```typescript +import { dataApiService } from '@data/DataApiService' + +describe('API Integration', () => { + it('should fetch topics', async () => { + // 自动模拟,返回预设的主题列表 + const response = await dataApiService.get('/api/topics') + expect(response.success).toBe(true) + expect(response.data.topics).toHaveLength(2) + }) +}) +``` + +### 高级使用 + +```typescript +import { MockDataApiUtils } from 'tests/__mocks__/DataApiService' + +describe('Custom API behavior', () => { + beforeEach(() => { + MockDataApiUtils.resetMocks() + }) + + it('should handle custom responses', async () => { + // 设置特定路径的自定义响应 + MockDataApiUtils.setCustomResponse('/api/topics', 'GET', { + topics: [{ id: 'custom', name: 'Custom Topic' }] + }) + + const response = await dataApiService.get('/api/topics') + expect(response.data.topics[0].name).toBe('Custom Topic') + }) + + it('should simulate errors', async () => { + // 模拟错误响应 + MockDataApiUtils.setErrorResponse('/api/topics', 'GET', 'Network error') + + const response = await dataApiService.get('/api/topics') + expect(response.success).toBe(false) + expect(response.error?.message).toBe('Network error') + }) +}) +``` + +## CacheService Mock + +### 简介 + +`CacheService.ts` 提供了三层缓存系统的完整模拟:内存缓存、共享缓存和持久化缓存。 + +### 功能特性 + +- **三层架构**: 内存、共享、持久化缓存 +- **订阅系统**: 支持缓存变更订阅 +- **TTL支持**: 模拟缓存过期(简化版) +- **Hook引用跟踪**: 模拟生产环境的引用管理 +- **默认值**: 基于缓存schema的智能默认值 + +### 基本使用 + +```typescript +import { cacheService } from '@data/CacheService' + +describe('Cache Operations', () => { + it('should store and retrieve cache values', () => { + // 设置缓存值 + cacheService.set('user.preferences', { theme: 'dark' }) + + // 获取缓存值 + const preferences = cacheService.get('user.preferences') + expect(preferences.theme).toBe('dark') + }) + + it('should work with persist cache', () => { + // 持久化缓存操作 + cacheService.setPersist('app.last_opened_topic', 'topic123') + const lastTopic = cacheService.getPersist('app.last_opened_topic') + expect(lastTopic).toBe('topic123') + }) +}) +``` + +### 高级测试工具 + +```typescript +import { MockCacheUtils } from 'tests/__mocks__/CacheService' + +describe('Advanced cache testing', () => { + beforeEach(() => { + MockCacheUtils.resetMocks() + }) + + it('should set initial cache state', () => { + // 设置初始缓存状态 + MockCacheUtils.setInitialState({ + memory: [['theme', 'dark'], ['language', 'en']], + persist: [['app.version', '1.0.0']] + }) + + expect(cacheService.get('theme')).toBe('dark') + expect(cacheService.getPersist('app.version')).toBe('1.0.0') + }) + + it('should simulate cache changes', () => { + let changeCount = 0 + cacheService.subscribe('theme', () => changeCount++) + + MockCacheUtils.triggerCacheChange('theme', 'light') + expect(changeCount).toBe(1) + }) +}) +``` + +## useDataApi Hooks Mock + +### 简介 + +`useDataApi.ts` 提供了所有数据API钩子的统一模拟,包括查询、变更和分页功能。 + +### 支持的钩子 + +- `useQuery` - 数据查询钩子 +- `useMutation` - 数据变更钩子 +- `usePaginatedQuery` - 分页查询钩子 +- `useInvalidateCache` - 缓存失效钩子 +- `prefetch` - 预取函数 + +### 基本使用 + +```typescript +import { useQuery, useMutation } from '@data/hooks/useDataApi' + +describe('Data API Hooks', () => { + it('should work with useQuery', () => { + const { data, isLoading, error } = useQuery('/api/topics') + + // 默认返回模拟数据 + expect(data).toBeDefined() + expect(data.topics).toHaveLength(2) + expect(isLoading).toBe(false) + expect(error).toBeUndefined() + }) + + it('should work with useMutation', async () => { + const { trigger, isMutating } = useMutation('/api/topics', 'POST') + + const result = await trigger({ name: 'New Topic' }) + expect(result.created).toBe(true) + expect(result.name).toBe('New Topic') + }) +}) +``` + +### 自定义测试行为 + +```typescript +import { MockUseDataApiUtils } from 'tests/__mocks__/useDataApi' + +describe('Custom hook behavior', () => { + beforeEach(() => { + MockUseDataApiUtils.resetMocks() + }) + + it('should mock loading state', () => { + MockUseDataApiUtils.mockQueryLoading('/api/topics') + + const { data, isLoading } = useQuery('/api/topics') + expect(isLoading).toBe(true) + expect(data).toBeUndefined() + }) + + it('should mock error state', () => { + const error = new Error('API Error') + MockUseDataApiUtils.mockQueryError('/api/topics', error) + + const { data, error: queryError } = useQuery('/api/topics') + expect(queryError).toBe(error) + expect(data).toBeUndefined() + }) +}) +``` + +## usePreference Hooks Mock + +### 简介 + +`usePreference.ts` 提供了偏好设置钩子的统一模拟,支持单个和批量偏好管理。 + +### 支持的钩子 + +- `usePreference` - 单个偏好设置钩子 +- `useMultiplePreferences` - 多个偏好设置钩子 + +### 基本使用 + +```typescript +import { usePreference, useMultiplePreferences } from '@data/hooks/usePreference' + +describe('Preference Hooks', () => { + it('should work with usePreference', async () => { + const [theme, setTheme] = usePreference('ui.theme') + + expect(theme).toBe('light') // 默认值 + + await setTheme('dark') + // 在测试中,可以通过工具函数验证值是否更新 + }) + + it('should work with multiple preferences', async () => { + const [prefs, setPrefs] = useMultiplePreferences({ + theme: 'ui.theme', + lang: 'ui.language' + }) + + expect(prefs.theme).toBe('light') + expect(prefs.lang).toBe('en') + + await setPrefs({ theme: 'dark' }) + }) +}) +``` + +### 高级测试 + +```typescript +import { MockUsePreferenceUtils } from 'tests/__mocks__/usePreference' + +describe('Advanced preference testing', () => { + beforeEach(() => { + MockUsePreferenceUtils.resetMocks() + }) + + it('should simulate preference changes', () => { + MockUsePreferenceUtils.setPreferenceValue('ui.theme', 'dark') + + const [theme] = usePreference('ui.theme') + expect(theme).toBe('dark') + }) + + it('should simulate external changes', () => { + let callCount = 0 + MockUsePreferenceUtils.addSubscriber('ui.theme', () => callCount++) + + MockUsePreferenceUtils.simulateExternalPreferenceChange('ui.theme', 'dark') + expect(callCount).toBe(1) + }) +}) +``` + +## useCache Hooks Mock + +### 简介 + +`useCache.ts` 提供了缓存钩子的统一模拟,支持三种缓存层级。 + +### 支持的钩子 + +- `useCache` - 内存缓存钩子 +- `useSharedCache` - 共享缓存钩子 +- `usePersistCache` - 持久化缓存钩子 + +### 基本使用 + +```typescript +import { useCache, useSharedCache, usePersistCache } from '@data/hooks/useCache' + +describe('Cache Hooks', () => { + it('should work with useCache', () => { + const [theme, setTheme] = useCache('ui.theme', 'light') + + expect(theme).toBe('light') + setTheme('dark') + // 值立即更新 + }) + + it('should work with different cache types', () => { + const [shared, setShared] = useSharedCache('app.window_count', 1) + const [persist, setPersist] = usePersistCache('app.last_version', '1.0.0') + + expect(shared).toBe(1) + expect(persist).toBe('1.0.0') + }) +}) +``` + +### 测试工具 + +```typescript +import { MockUseCacheUtils } from 'tests/__mocks__/useCache' + +describe('Cache hook testing', () => { + beforeEach(() => { + MockUseCacheUtils.resetMocks() + }) + + it('should set initial cache state', () => { + MockUseCacheUtils.setMultipleCacheValues({ + memory: [['ui.theme', 'dark']], + shared: [['app.mode', 'development']], + persist: [['user.id', 'user123']] + }) + + const [theme] = useCache('ui.theme') + const [mode] = useSharedCache('app.mode') + const [userId] = usePersistCache('user.id') + + expect(theme).toBe('dark') + expect(mode).toBe('development') + expect(userId).toBe('user123') + }) +}) +``` + +## LoggerService Mock + +### 简介 + +项目还包含了 LoggerService 的模拟: +- `RendererLoggerService.ts` - 渲染进程日志服务模拟 +- `MainLoggerService.ts` - 主进程日志服务模拟 + +这些模拟同样在相应的测试设置文件中全局配置。 + +## 最佳实践 + +1. **优先使用全局模拟**:大多数情况下应该直接使用全局配置的模拟,而不是在每个测试中单独模拟 +2. **合理的默认值**:确保模拟的默认值反映实际应用的常见配置 +3. **文档更新**:当添加新的模拟或修改现有模拟时,请更新相关文档 +4. **类型安全**:保持模拟与实际服务的类型兼容性 +5. **测试隔离**:如果需要修改模拟行为,确保在测试后恢复或在 beforeEach 中重置 + +## 故障排除 + +### 模拟未生效 + +如果发现 PreferenceService 模拟未生效: + +1. 确认测试运行在渲染进程环境中(`vitest.config.ts` 中的 `renderer` 项目) +2. 检查 `tests/renderer.setup.ts` 是否正确配置 +3. 确认导入路径使用的是 `@data/PreferenceService` 而非相对路径 + +### 类型错误 + +如果遇到 TypeScript 类型错误: + +1. 确认模拟实现与实际 PreferenceService 接口匹配 +2. 在测试中使用类型断言:`(preferenceService as any)._getMockState()` +3. 检查是否需要更新模拟的类型定义 \ No newline at end of file diff --git a/tests/__mocks__/main/CacheService.ts b/tests/__mocks__/main/CacheService.ts new file mode 100644 index 0000000000..9592785747 --- /dev/null +++ b/tests/__mocks__/main/CacheService.ts @@ -0,0 +1,237 @@ +import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes' +import { vi } from 'vitest' + +/** + * Mock CacheService for main process testing + * Simulates the complete main process CacheService functionality + */ + +// Mock cache storage +const mockMainCache = new Map() + +// Mock broadcast tracking +const mockBroadcastCalls: Array<{ message: CacheSyncMessage; senderWindowId?: number }> = [] + +/** + * Mock CacheService class + */ +export class MockMainCacheService { + private static instance: MockMainCacheService + private initialized = false + + private constructor() {} + + public static getInstance(): MockMainCacheService { + if (!MockMainCacheService.instance) { + MockMainCacheService.instance = new MockMainCacheService() + } + return MockMainCacheService.instance + } + + // Mock initialization + public initialize = vi.fn(async (): Promise => { + this.initialized = true + }) + + // Mock main process cache methods + public get = vi.fn((key: string): T | undefined => { + const entry = mockMainCache.get(key) + if (!entry) return undefined + + // Check TTL (lazy cleanup) + if (entry.expireAt && Date.now() > entry.expireAt) { + mockMainCache.delete(key) + return undefined + } + + return entry.value as T + }) + + public set = vi.fn((key: string, value: T, ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + mockMainCache.set(key, entry) + }) + + public has = vi.fn((key: string): boolean => { + const entry = mockMainCache.get(key) + if (!entry) return false + + // Check TTL + if (entry.expireAt && Date.now() > entry.expireAt) { + mockMainCache.delete(key) + return false + } + + return true + }) + + public delete = vi.fn((key: string): boolean => { + return mockMainCache.delete(key) + }) + + // Mock cleanup + public cleanup = vi.fn((): void => { + mockMainCache.clear() + mockBroadcastCalls.length = 0 + }) + + // Private methods exposed for testing + private broadcastSync = vi.fn((message: CacheSyncMessage, senderWindowId?: number): void => { + mockBroadcastCalls.push({ message, senderWindowId }) + }) + + private setupIpcHandlers = vi.fn((): void => { + // Mock IPC handler setup + }) +} + +// Mock singleton instance +const mockInstance = MockMainCacheService.getInstance() + +/** + * Export mock service + */ +export const MockMainCacheServiceExport = { + CacheService: MockMainCacheService, + cacheService: mockInstance +} + +/** + * Utility functions for testing + */ +export const MockMainCacheServiceUtils = { + /** + * Reset all mock call counts and state + */ + resetMocks: () => { + // Reset all method mocks + Object.values(mockInstance).forEach(method => { + if (vi.isMockFunction(method)) { + method.mockClear() + } + }) + + // Reset cache state + mockMainCache.clear() + mockBroadcastCalls.length = 0 + + // Reset initialized state + mockInstance['initialized'] = false + }, + + /** + * Set cache value for testing + */ + setCacheValue: (key: string, value: T, ttl?: number) => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + mockMainCache.set(key, entry) + }, + + /** + * Get cache value for testing + */ + getCacheValue: (key: string): T | undefined => { + const entry = mockMainCache.get(key) + if (!entry) return undefined + + // Check TTL + if (entry.expireAt && Date.now() > entry.expireAt) { + mockMainCache.delete(key) + return undefined + } + + return entry.value as T + }, + + /** + * Set initialization state for testing + */ + setInitialized: (initialized: boolean) => { + mockInstance['initialized'] = initialized + }, + + /** + * Get current initialization state + */ + isInitialized: (): boolean => { + return mockInstance['initialized'] + }, + + /** + * Get all cache entries for testing + */ + getAllCacheEntries: (): Map => { + return new Map(mockMainCache) + }, + + /** + * Get broadcast call history for testing + */ + getBroadcastHistory: (): Array<{ message: CacheSyncMessage; senderWindowId?: number }> => { + return [...mockBroadcastCalls] + }, + + /** + * Simulate cache sync broadcast + */ + simulateCacheSync: (message: CacheSyncMessage, senderWindowId?: number) => { + mockBroadcastCalls.push({ message, senderWindowId }) + }, + + /** + * Set multiple cache values at once + */ + setMultipleCacheValues: (values: Array<[string, any, number?]>) => { + values.forEach(([key, value, ttl]) => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + mockMainCache.set(key, entry) + }) + }, + + /** + * Simulate cache expiration for testing + */ + simulateCacheExpiration: (key: string) => { + const entry = mockMainCache.get(key) + if (entry) { + entry.expireAt = Date.now() - 1000 // Set to expired + } + }, + + /** + * Get cache statistics + */ + getCacheStats: () => ({ + totalEntries: mockMainCache.size, + broadcastCalls: mockBroadcastCalls.length, + keys: Array.from(mockMainCache.keys()) + }), + + /** + * Mock initialization error + */ + simulateInitializationError: (error: Error) => { + mockInstance.initialize.mockRejectedValue(error) + }, + + /** + * Get mock call counts for debugging + */ + getMockCallCounts: () => ({ + initialize: mockInstance.initialize.mock.calls.length, + get: mockInstance.get.mock.calls.length, + set: mockInstance.set.mock.calls.length, + has: mockInstance.has.mock.calls.length, + delete: mockInstance.delete.mock.calls.length, + cleanup: mockInstance.cleanup.mock.calls.length + }) +} \ No newline at end of file diff --git a/tests/__mocks__/main/DataApiService.ts b/tests/__mocks__/main/DataApiService.ts new file mode 100644 index 0000000000..04177d6f28 --- /dev/null +++ b/tests/__mocks__/main/DataApiService.ts @@ -0,0 +1,169 @@ +import { vi } from 'vitest' + +/** + * Mock DataApiService for main process testing + * Simulates the complete main process DataApiService functionality + */ + +/** + * Mock ApiServer class + */ +class MockApiServer { + public initialize = vi.fn(() => new MockApiServer()) + + public getSystemInfo = vi.fn(() => ({ + server: 'MockApiServer', + version: '1.0.0', + handlers: ['test-handler'], + middlewares: ['test-middleware'] + })) +} + +/** + * Mock IpcAdapter class + */ +class MockIpcAdapter { + public setupHandlers = vi.fn() + public removeHandlers = vi.fn() + public isInitialized = vi.fn(() => true) +} + +/** + * Mock DataApiService class + */ +export class MockMainDataApiService { + private static instance: MockMainDataApiService + private initialized = false + private apiServer: MockApiServer + private ipcAdapter: MockIpcAdapter + + private constructor() { + this.apiServer = new MockApiServer() + this.ipcAdapter = new MockIpcAdapter() + } + + public static getInstance(): MockMainDataApiService { + if (!MockMainDataApiService.instance) { + MockMainDataApiService.instance = new MockMainDataApiService() + } + return MockMainDataApiService.instance + } + + // Mock initialization + public initialize = vi.fn(async (): Promise => { + this.initialized = true + }) + + // Mock system status + public getSystemStatus = vi.fn(() => { + if (!this.initialized) { + return { + initialized: false, + error: 'DataApiService not initialized' + } + } + + return { + initialized: true, + ipcInitialized: true, + server: 'MockApiServer', + version: '1.0.0', + handlers: ['test-handler'], + middlewares: ['test-middleware'] + } + }) + + // Mock API server access + public getApiServer = vi.fn((): MockApiServer => { + return this.apiServer + }) + + // Mock shutdown + public shutdown = vi.fn(async (): Promise => { + this.initialized = false + }) +} + +// Mock singleton instance +const mockInstance = MockMainDataApiService.getInstance() + +/** + * Export mock service + */ +export const MockMainDataApiServiceExport = { + DataApiService: MockMainDataApiService, + dataApiService: mockInstance +} + +/** + * Mock API components for advanced testing + */ +export const MockApiComponents = { + ApiServer: MockApiServer, + IpcAdapter: MockIpcAdapter +} + +/** + * Utility functions for testing + */ +export const MockMainDataApiServiceUtils = { + /** + * Reset all mock call counts and state + */ + resetMocks: () => { + // Reset all method mocks + Object.values(mockInstance).forEach(method => { + if (vi.isMockFunction(method)) { + method.mockClear() + } + }) + + // Reset initialized state + mockInstance['initialized'] = false + }, + + /** + * Set initialization state for testing + */ + setInitialized: (initialized: boolean) => { + mockInstance['initialized'] = initialized + }, + + /** + * Get current initialization state + */ + isInitialized: (): boolean => { + return mockInstance['initialized'] + }, + + /** + * Mock system info for testing + */ + mockSystemInfo: (info: Record) => { + mockInstance.getApiServer().getSystemInfo.mockReturnValue(info) + }, + + /** + * Simulate initialization error + */ + simulateInitializationError: (error: Error) => { + mockInstance.initialize.mockRejectedValue(error) + }, + + /** + * Simulate shutdown error + */ + simulateShutdownError: (error: Error) => { + mockInstance.shutdown.mockRejectedValue(error) + }, + + /** + * Get mock call counts for debugging + */ + getMockCallCounts: () => ({ + initialize: mockInstance.initialize.mock.calls.length, + shutdown: mockInstance.shutdown.mock.calls.length, + getSystemStatus: mockInstance.getSystemStatus.mock.calls.length, + getApiServer: mockInstance.getApiServer.mock.calls.length + }) +} \ No newline at end of file diff --git a/tests/__mocks__/main/PreferenceService.ts b/tests/__mocks__/main/PreferenceService.ts new file mode 100644 index 0000000000..77ab1c4173 --- /dev/null +++ b/tests/__mocks__/main/PreferenceService.ts @@ -0,0 +1,286 @@ +import type { + PreferenceDefaultScopeType, + PreferenceKeyType +} from '@shared/data/preference/preferenceTypes' +import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' +import { vi } from 'vitest' + +/** + * Mock PreferenceService for main process testing + * Simulates the complete main process PreferenceService functionality + */ + +// Mock preference state storage +const mockPreferenceState = new Map() + +// Initialize with defaults +Object.entries(DefaultPreferences.default).forEach(([key, value]) => { + mockPreferenceState.set(key as PreferenceKeyType, value) +}) + +// Mock subscription tracking +const mockSubscriptions = new Map>() // windowId -> Set +const mockMainSubscribers = new Map void>>() + +// Helper function to notify main process subscribers +const notifyMainSubscribers = (key: string, newValue: any, oldValue?: any) => { + const subscribers = mockMainSubscribers.get(key) + if (subscribers) { + subscribers.forEach(callback => { + try { + callback(newValue, oldValue) + } catch (error) { + console.warn('Mock PreferenceService: Main subscriber callback error:', error) + } + }) + } +} + +/** + * Mock PreferenceService class + */ +export class MockMainPreferenceService { + private static instance: MockMainPreferenceService + private initialized = false + + private constructor() {} + + public static getInstance(): MockMainPreferenceService { + if (!MockMainPreferenceService.instance) { + MockMainPreferenceService.instance = new MockMainPreferenceService() + } + return MockMainPreferenceService.instance + } + + // Mock initialization + public initialize = vi.fn(async (): Promise => { + this.initialized = true + }) + + // Mock get method + public get = vi.fn((key: K): PreferenceDefaultScopeType[K] => { + return mockPreferenceState.get(key) ?? DefaultPreferences.default[key] + }) + + // Mock set method + public set = vi.fn(async ( + key: K, + value: PreferenceDefaultScopeType[K] + ): Promise => { + const oldValue = mockPreferenceState.get(key) + mockPreferenceState.set(key, value) + notifyMainSubscribers(key, value, oldValue) + }) + + // Mock getMultiple method + public getMultiple = vi.fn((keys: K[]) => { + const result: any = {} + keys.forEach(key => { + result[key] = mockPreferenceState.get(key) ?? DefaultPreferences.default[key] + }) + return result + }) + + // Mock setMultiple method + public setMultiple = vi.fn(async (updates: Partial): Promise => { + Object.entries(updates).forEach(([key, value]) => { + if (value !== undefined) { + const oldValue = mockPreferenceState.get(key as PreferenceKeyType) + mockPreferenceState.set(key as PreferenceKeyType, value) + notifyMainSubscribers(key, value, oldValue) + } + }) + }) + + // Mock subscription methods + public subscribeForWindow = vi.fn((windowId: number, keys: string[]): void => { + if (!mockSubscriptions.has(windowId)) { + mockSubscriptions.set(windowId, new Set()) + } + const windowKeys = mockSubscriptions.get(windowId)! + keys.forEach(key => windowKeys.add(key)) + }) + + public unsubscribeForWindow = vi.fn((windowId: number): void => { + mockSubscriptions.delete(windowId) + }) + + // Mock main process subscription methods + public subscribeChange = vi.fn(( + key: K, + callback: (newValue: PreferenceDefaultScopeType[K], oldValue?: PreferenceDefaultScopeType[K]) => void + ): (() => void) => { + if (!mockMainSubscribers.has(key)) { + mockMainSubscribers.set(key, new Set()) + } + mockMainSubscribers.get(key)!.add(callback) + + // Return unsubscribe function + return () => { + const subscribers = mockMainSubscribers.get(key) + if (subscribers) { + subscribers.delete(callback) + if (subscribers.size === 0) { + mockMainSubscribers.delete(key) + } + } + } + }) + + public subscribeMultipleChanges = vi.fn(( + keys: PreferenceKeyType[], + callback: (key: PreferenceKeyType, newValue: any, oldValue: any) => void + ): (() => void) => { + const unsubscribeFunctions = keys.map(key => + this.subscribeChange(key, (newValue, oldValue) => callback(key, newValue, oldValue)) + ) + + return () => { + unsubscribeFunctions.forEach(unsubscribe => unsubscribe()) + } + }) + + // Mock utility methods + public getAll = vi.fn((): PreferenceDefaultScopeType => { + const result: any = {} + Object.keys(DefaultPreferences.default).forEach(key => { + result[key] = mockPreferenceState.get(key as PreferenceKeyType) ?? DefaultPreferences.default[key as PreferenceKeyType] + }) + return result + }) + + public getSubscriptions = vi.fn(() => new Map(mockSubscriptions)) + + public removeAllChangeListeners = vi.fn((): void => { + mockMainSubscribers.clear() + }) + + public getChangeListenerCount = vi.fn((): number => { + let total = 0 + mockMainSubscribers.forEach(subscribers => { + total += subscribers.size + }) + return total + }) + + public getKeyListenerCount = vi.fn((key: PreferenceKeyType): number => { + return mockMainSubscribers.get(key)?.size ?? 0 + }) + + public getSubscribedKeys = vi.fn((): string[] => { + return Array.from(mockMainSubscribers.keys()) + }) + + public getSubscriptionStats = vi.fn((): Record => { + const stats: Record = {} + mockMainSubscribers.forEach((subscribers, key) => { + stats[key] = subscribers.size + }) + return stats + }) + + // Static methods + public static registerIpcHandler = vi.fn((): void => { + // Mock IPC handler registration + }) +} + +// Mock singleton instance +const mockInstance = MockMainPreferenceService.getInstance() + +/** + * Export mock service + */ +export const MockMainPreferenceServiceExport = { + PreferenceService: MockMainPreferenceService, + preferenceService: mockInstance +} + +/** + * Utility functions for testing + */ +export const MockMainPreferenceServiceUtils = { + /** + * Reset all mock call counts and state + */ + resetMocks: () => { + // Reset all method mocks + Object.values(mockInstance).forEach(method => { + if (vi.isMockFunction(method)) { + method.mockClear() + } + }) + + // Reset state to defaults + mockPreferenceState.clear() + Object.entries(DefaultPreferences.default).forEach(([key, value]) => { + mockPreferenceState.set(key as PreferenceKeyType, value) + }) + + // Clear subscriptions + mockSubscriptions.clear() + mockMainSubscribers.clear() + }, + + /** + * Set a preference value for testing + */ + setPreferenceValue: (key: K, value: PreferenceDefaultScopeType[K]) => { + const oldValue = mockPreferenceState.get(key) + mockPreferenceState.set(key, value) + notifyMainSubscribers(key, value, oldValue) + }, + + /** + * Get current preference value + */ + getPreferenceValue: (key: K): PreferenceDefaultScopeType[K] => { + return mockPreferenceState.get(key) ?? DefaultPreferences.default[key] + }, + + /** + * Set multiple preference values for testing + */ + setMultiplePreferenceValues: (values: Record) => { + Object.entries(values).forEach(([key, value]) => { + const oldValue = mockPreferenceState.get(key as PreferenceKeyType) + mockPreferenceState.set(key as PreferenceKeyType, value) + notifyMainSubscribers(key, value, oldValue) + }) + }, + + /** + * Get all current preference values + */ + getAllPreferenceValues: (): Record => { + const result: Record = {} + mockPreferenceState.forEach((value, key) => { + result[key] = value + }) + return result + }, + + /** + * Simulate window subscription + */ + simulateWindowSubscription: (windowId: number, keys: string[]) => { + mockInstance.subscribeForWindow(windowId, keys) + }, + + /** + * Simulate external preference change + */ + simulateExternalPreferenceChange: (key: K, value: PreferenceDefaultScopeType[K]) => { + const oldValue = mockPreferenceState.get(key) + mockPreferenceState.set(key, value) + notifyMainSubscribers(key, value, oldValue) + }, + + /** + * Get subscription counts for debugging + */ + getSubscriptionCounts: () => ({ + windows: Array.from(mockSubscriptions.entries()).map(([windowId, keys]) => [windowId, keys.size]), + mainSubscribers: Array.from(mockMainSubscribers.entries()).map(([key, subs]) => [key, subs.size]) + }) +} \ No newline at end of file diff --git a/tests/__mocks__/renderer/CacheService.ts b/tests/__mocks__/renderer/CacheService.ts new file mode 100644 index 0000000000..67f0e1cdc6 --- /dev/null +++ b/tests/__mocks__/renderer/CacheService.ts @@ -0,0 +1,389 @@ +import type { + RendererPersistCacheKey, + RendererPersistCacheSchema, + UseCacheKey, + UseCacheSchema, + UseSharedCacheKey, + UseSharedCacheSchema +} from '@shared/data/cache/cacheSchemas' +import { DefaultRendererPersistCache, DefaultUseCache, DefaultUseSharedCache } from '@shared/data/cache/cacheSchemas' +import type { CacheSubscriber } from '@shared/data/cache/cacheTypes' +import { vi } from 'vitest' + +/** + * Mock CacheService for testing + * Provides a comprehensive mock of the three-layer cache system + */ + +/** + * Create a mock CacheService with realistic behavior + */ +export const createMockCacheService = (options: { + initialMemoryCache?: Map + initialSharedCache?: Map + initialPersistCache?: Map +} = {}) => { + // Mock cache storage + const memoryCache = new Map(options.initialMemoryCache || []) + const sharedCache = new Map(options.initialSharedCache || []) + const persistCache = new Map(options.initialPersistCache || []) + + // Mock subscribers + const subscribers = new Map>() + + // Helper function to notify subscribers + const notifySubscribers = (key: string, value: any) => { + const keySubscribers = subscribers.get(key) + if (keySubscribers) { + keySubscribers.forEach(callback => { + try { + callback() + } catch (error) { + console.warn('Mock CacheService: Subscriber callback error:', error) + } + }) + } + } + + const mockCacheService = { + // Memory cache methods + get: vi.fn((key: string): T | null => { + if (memoryCache.has(key)) { + return memoryCache.get(key) as T + } + // Return default values for known cache keys + const defaultValue = getDefaultValueForKey(key) + return defaultValue !== undefined ? defaultValue : null + }), + + set: vi.fn((key: string, value: T, ttl?: number): void => { + const oldValue = memoryCache.get(key) + memoryCache.set(key, value) + if (oldValue !== value) { + notifySubscribers(key, value) + } + }), + + delete: vi.fn((key: string): boolean => { + const existed = memoryCache.has(key) + memoryCache.delete(key) + if (existed) { + notifySubscribers(key, null) + } + return existed + }), + + clear: vi.fn((): void => { + const keys = Array.from(memoryCache.keys()) + memoryCache.clear() + keys.forEach(key => notifySubscribers(key, null)) + }), + + has: vi.fn((key: string): boolean => { + return memoryCache.has(key) + }), + + size: vi.fn((): number => { + return memoryCache.size + }), + + // Shared cache methods + getShared: vi.fn((key: string): T | null => { + if (sharedCache.has(key)) { + return sharedCache.get(key) as T + } + const defaultValue = getDefaultSharedValueForKey(key) + return defaultValue !== undefined ? defaultValue : null + }), + + setShared: vi.fn((key: string, value: T, ttl?: number): void => { + const oldValue = sharedCache.get(key) + sharedCache.set(key, value) + if (oldValue !== value) { + notifySubscribers(`shared:${key}`, value) + } + }), + + deleteShared: vi.fn((key: string): boolean => { + const existed = sharedCache.has(key) + sharedCache.delete(key) + if (existed) { + notifySubscribers(`shared:${key}`, null) + } + return existed + }), + + clearShared: vi.fn((): void => { + const keys = Array.from(sharedCache.keys()) + sharedCache.clear() + keys.forEach(key => notifySubscribers(`shared:${key}`, null)) + }), + + // Persist cache methods + getPersist: vi.fn((key: K): RendererPersistCacheSchema[K] => { + if (persistCache.has(key)) { + return persistCache.get(key) as RendererPersistCacheSchema[K] + } + return DefaultRendererPersistCache[key] + }), + + setPersist: vi.fn((key: K, value: RendererPersistCacheSchema[K]): void => { + const oldValue = persistCache.get(key) + persistCache.set(key, value) + if (oldValue !== value) { + notifySubscribers(`persist:${key}`, value) + } + }), + + deletePersist: vi.fn((key: K): boolean => { + const existed = persistCache.has(key) + persistCache.delete(key) + if (existed) { + notifySubscribers(`persist:${key}`, DefaultRendererPersistCache[key]) + } + return existed + }), + + clearPersist: vi.fn((): void => { + const keys = Array.from(persistCache.keys()) as RendererPersistCacheKey[] + persistCache.clear() + keys.forEach(key => notifySubscribers(`persist:${key}`, DefaultRendererPersistCache[key])) + }), + + // Subscription methods + subscribe: vi.fn((key: string, callback: CacheSubscriber): (() => void) => { + if (!subscribers.has(key)) { + subscribers.set(key, new Set()) + } + subscribers.get(key)!.add(callback) + + // Return unsubscribe function + return () => { + const keySubscribers = subscribers.get(key) + if (keySubscribers) { + keySubscribers.delete(callback) + if (keySubscribers.size === 0) { + subscribers.delete(key) + } + } + } + }), + + unsubscribe: vi.fn((key: string, callback?: CacheSubscriber): void => { + if (callback) { + const keySubscribers = subscribers.get(key) + if (keySubscribers) { + keySubscribers.delete(callback) + if (keySubscribers.size === 0) { + subscribers.delete(key) + } + } + } else { + subscribers.delete(key) + } + }), + + // Hook reference tracking (for advanced cache management) + addHookReference: vi.fn((key: string): void => { + // Mock implementation - in real service this prevents cache cleanup + }), + + removeHookReference: vi.fn((key: string): void => { + // Mock implementation + }), + + // Utility methods + getAllKeys: vi.fn((): string[] => { + return Array.from(memoryCache.keys()) + }), + + getStats: vi.fn(() => ({ + memorySize: memoryCache.size, + sharedSize: sharedCache.size, + persistSize: persistCache.size, + subscriberCount: subscribers.size + })), + + // Internal state access for testing + _getMockState: () => ({ + memoryCache: new Map(memoryCache), + sharedCache: new Map(sharedCache), + persistCache: new Map(persistCache), + subscribers: new Map(subscribers) + }), + + _resetMockState: () => { + memoryCache.clear() + sharedCache.clear() + persistCache.clear() + subscribers.clear() + } + } + + return mockCacheService +} + +/** + * Get default value for cache keys based on schema + */ +function getDefaultValueForKey(key: string): any { + // Try to match against known cache schemas + if (key in DefaultUseCache) { + return DefaultUseCache[key as UseCacheKey] + } + return undefined +} + +function getDefaultSharedValueForKey(key: string): any { + if (key in DefaultUseSharedCache) { + return DefaultUseSharedCache[key as UseSharedCacheKey] + } + return undefined +} + +// Default mock instance +export const mockCacheService = createMockCacheService() + +// Singleton instance mock +export const MockCacheService = { + CacheService: class MockCacheService { + static getInstance() { + return mockCacheService + } + + // Delegate all methods to the mock + get(key: string): T | null { + return mockCacheService.get(key) + } + + set(key: string, value: T, ttl?: number): void { + return mockCacheService.set(key, value, ttl) + } + + delete(key: string): boolean { + return mockCacheService.delete(key) + } + + clear(): void { + return mockCacheService.clear() + } + + has(key: string): boolean { + return mockCacheService.has(key) + } + + size(): number { + return mockCacheService.size() + } + + getShared(key: string): T | null { + return mockCacheService.getShared(key) + } + + setShared(key: string, value: T, ttl?: number): void { + return mockCacheService.setShared(key, value, ttl) + } + + deleteShared(key: string): boolean { + return mockCacheService.deleteShared(key) + } + + clearShared(): void { + return mockCacheService.clearShared() + } + + getPersist(key: K): RendererPersistCacheSchema[K] { + return mockCacheService.getPersist(key) + } + + setPersist(key: K, value: RendererPersistCacheSchema[K]): void { + return mockCacheService.setPersist(key, value) + } + + deletePersist(key: K): boolean { + return mockCacheService.deletePersist(key) + } + + clearPersist(): void { + return mockCacheService.clearPersist() + } + + subscribe(key: string, callback: CacheSubscriber): () => void { + return mockCacheService.subscribe(key, callback) + } + + unsubscribe(key: string, callback?: CacheSubscriber): void { + return mockCacheService.unsubscribe(key, callback) + } + + addHookReference(key: string): void { + return mockCacheService.addHookReference(key) + } + + removeHookReference(key: string): void { + return mockCacheService.removeHookReference(key) + } + + getAllKeys(): string[] { + return mockCacheService.getAllKeys() + } + + getStats() { + return mockCacheService.getStats() + } + }, + cacheService: mockCacheService +} + +/** + * Utility functions for testing + */ +export const MockCacheUtils = { + /** + * Reset all mock function call counts and state + */ + resetMocks: () => { + Object.values(mockCacheService).forEach(method => { + if (vi.isMockFunction(method)) { + method.mockClear() + } + }) + if ('_resetMockState' in mockCacheService) { + ;(mockCacheService as any)._resetMockState() + } + }, + + /** + * Set initial cache state for testing + */ + setInitialState: (state: { + memory?: Array<[string, any]> + shared?: Array<[string, any]> + persist?: Array<[RendererPersistCacheKey, any]> + }) => { + if ('_resetMockState' in mockCacheService) { + ;(mockCacheService as any)._resetMockState() + } + + state.memory?.forEach(([key, value]) => mockCacheService.set(key, value)) + state.shared?.forEach(([key, value]) => mockCacheService.setShared(key, value)) + state.persist?.forEach(([key, value]) => mockCacheService.setPersist(key, value)) + }, + + /** + * Get current mock state for inspection + */ + getCurrentState: () => { + if ('_getMockState' in mockCacheService) { + return (mockCacheService as any)._getMockState() + } + return null + }, + + /** + * Simulate cache events for testing subscribers + */ + triggerCacheChange: (key: string, value: any) => { + mockCacheService.set(key, value) + } +} \ No newline at end of file diff --git a/tests/__mocks__/renderer/DataApiService.ts b/tests/__mocks__/renderer/DataApiService.ts new file mode 100644 index 0000000000..65c600f8ce --- /dev/null +++ b/tests/__mocks__/renderer/DataApiService.ts @@ -0,0 +1,326 @@ +import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas' +import type { + ApiClient, + BatchRequest, + BatchResponse, + DataRequest, + DataResponse, + SubscriptionCallback, + SubscriptionOptions, + TransactionRequest +} from '@shared/data/api/apiTypes' +import { vi } from 'vitest' + +/** + * Mock DataApiService for testing + * Provides a comprehensive mock of the DataApiService with realistic behavior + */ + +// Mock response utilities +const createMockResponse = (data: T, success = true): DataResponse => ({ + success, + data, + timestamp: new Date().toISOString(), + ...(success ? {} : { error: { code: 'MOCK_ERROR', message: 'Mock error', details: {} } }) +}) + +const createMockError = (message: string): DataResponse => ({ + success: false, + error: { + code: 'MOCK_ERROR', + message, + details: {} + }, + timestamp: new Date().toISOString() +}) + +/** + * Mock implementation of DataApiService + */ +export const createMockDataApiService = (customBehavior: Partial = {}): ApiClient => { + const mockService: ApiClient = { + // HTTP Methods + get: vi.fn(async (path: ConcreteApiPaths, options?: any) => { + // Default mock behavior - return empty data based on path + const mockData = getMockDataForPath(path, 'GET') + return createMockResponse(mockData) + }), + + post: vi.fn(async (path: ConcreteApiPaths, options?: any) => { + const mockData = getMockDataForPath(path, 'POST') + return createMockResponse(mockData) + }), + + put: vi.fn(async (path: ConcreteApiPaths, options?: any) => { + const mockData = getMockDataForPath(path, 'PUT') + return createMockResponse(mockData) + }), + + patch: vi.fn(async (path: ConcreteApiPaths, options?: any) => { + const mockData = getMockDataForPath(path, 'PATCH') + return createMockResponse(mockData) + }), + + delete: vi.fn(async (path: ConcreteApiPaths, options?: any) => { + return createMockResponse({ deleted: true }) + }), + + // Batch operations + batch: vi.fn(async (requests: BatchRequest[]): Promise => { + const responses = requests.map((request, index) => ({ + id: request.id || `batch_${index}`, + success: true, + data: getMockDataForPath(request.path as ConcreteApiPaths, request.method), + timestamp: new Date().toISOString() + })) + + return { + success: true, + responses, + timestamp: new Date().toISOString() + } + }), + + // Transaction support + transaction: vi.fn(async (operations: TransactionRequest[]): Promise> => { + const results = operations.map((op, index) => ({ + operation: op.operation, + result: getMockDataForPath(op.path as ConcreteApiPaths, 'POST'), + success: true + })) + + return createMockResponse(results) + }), + + // Subscription methods + subscribe: vi.fn((path: ConcreteApiPaths, callback: SubscriptionCallback, options?: SubscriptionOptions) => { + // Return a mock unsubscribe function + return vi.fn() + }), + + unsubscribe: vi.fn((path: ConcreteApiPaths) => { + // Mock unsubscribe + }), + + // Connection management + connect: vi.fn(async () => { + return createMockResponse({ connected: true }) + }), + + disconnect: vi.fn(async () => { + return createMockResponse({ disconnected: true }) + }), + + // Health check + ping: vi.fn(async () => { + return createMockResponse({ pong: true, timestamp: new Date().toISOString() }) + }), + + // Apply custom behavior overrides + ...customBehavior + } + + return mockService +} + +/** + * Get mock data based on API path and method + * Provides realistic mock responses for common API endpoints + */ +function getMockDataForPath(path: ConcreteApiPaths, method: string): any { + // Parse path to determine data type + if (path.includes('/topics')) { + if (method === 'GET' && path.endsWith('/topics')) { + return { + topics: [ + { id: 'topic1', name: 'Mock Topic 1', createdAt: '2024-01-01T00:00:00Z' }, + { id: 'topic2', name: 'Mock Topic 2', createdAt: '2024-01-02T00:00:00Z' } + ], + total: 2 + } + } + if (method === 'GET' && path.match(/\/topics\/[^/]+$/)) { + return { + id: 'topic1', + name: 'Mock Topic', + messages: [], + createdAt: '2024-01-01T00:00:00Z' + } + } + if (method === 'POST' && path.endsWith('/topics')) { + return { + id: 'new_topic', + name: 'New Mock Topic', + createdAt: new Date().toISOString() + } + } + } + + if (path.includes('/messages')) { + if (method === 'GET') { + return { + messages: [ + { id: 'msg1', content: 'Mock message 1', role: 'user', timestamp: '2024-01-01T00:00:00Z' }, + { id: 'msg2', content: 'Mock message 2', role: 'assistant', timestamp: '2024-01-01T00:01:00Z' } + ], + total: 2 + } + } + if (method === 'POST') { + return { + id: 'new_message', + content: 'New mock message', + role: 'user', + timestamp: new Date().toISOString() + } + } + } + + if (path.includes('/preferences')) { + if (method === 'GET') { + return { + preferences: { + 'ui.theme': 'light', + 'ui.language': 'en', + 'data.export.format': 'markdown' + } + } + } + if (method === 'POST' || method === 'PUT') { + return { updated: true, timestamp: new Date().toISOString() } + } + } + + // Default mock data + return { + id: 'mock_id', + data: 'mock_data', + timestamp: new Date().toISOString() + } +} + +// Default mock instance +export const mockDataApiService = createMockDataApiService() + +// Singleton instance mock +export const MockDataApiService = { + DataApiService: class MockDataApiService { + static getInstance() { + return mockDataApiService + } + + // Instance methods delegate to the mock + async get(path: ConcreteApiPaths, options?: any) { + return mockDataApiService.get(path, options) + } + + async post(path: ConcreteApiPaths, options?: any) { + return mockDataApiService.post(path, options) + } + + async put(path: ConcreteApiPaths, options?: any) { + return mockDataApiService.put(path, options) + } + + async patch(path: ConcreteApiPaths, options?: any) { + return mockDataApiService.patch(path, options) + } + + async delete(path: ConcreteApiPaths, options?: any) { + return mockDataApiService.delete(path, options) + } + + async batch(requests: BatchRequest[]) { + return mockDataApiService.batch(requests) + } + + async transaction(operations: TransactionRequest[]) { + return mockDataApiService.transaction(operations) + } + + subscribe(path: ConcreteApiPaths, callback: SubscriptionCallback, options?: SubscriptionOptions) { + return mockDataApiService.subscribe(path, callback, options) + } + + unsubscribe(path: ConcreteApiPaths) { + return mockDataApiService.unsubscribe(path) + } + + async connect() { + return mockDataApiService.connect() + } + + async disconnect() { + return mockDataApiService.disconnect() + } + + async ping() { + return mockDataApiService.ping() + } + }, + dataApiService: mockDataApiService +} + +/** + * Utility functions for testing + */ +export const MockDataApiUtils = { + /** + * Reset all mock function call counts and implementations + */ + resetMocks: () => { + Object.values(mockDataApiService).forEach(method => { + if (vi.isMockFunction(method)) { + method.mockClear() + } + }) + }, + + /** + * Set custom response for a specific path and method + */ + setCustomResponse: (path: ConcreteApiPaths, method: string, response: any) => { + const methodFn = mockDataApiService[method.toLowerCase() as keyof ApiClient] as any + if (vi.isMockFunction(methodFn)) { + methodFn.mockImplementation(async (requestPath: string, options?: any) => { + if (requestPath === path) { + return createMockResponse(response) + } + // Fall back to default behavior + return createMockResponse(getMockDataForPath(requestPath as ConcreteApiPaths, method)) + }) + } + }, + + /** + * Set error response for a specific path and method + */ + setErrorResponse: (path: ConcreteApiPaths, method: string, errorMessage: string) => { + const methodFn = mockDataApiService[method.toLowerCase() as keyof ApiClient] as any + if (vi.isMockFunction(methodFn)) { + methodFn.mockImplementation(async (requestPath: string, options?: any) => { + if (requestPath === path) { + return createMockError(errorMessage) + } + // Fall back to default behavior + return createMockResponse(getMockDataForPath(requestPath as ConcreteApiPaths, method)) + }) + } + }, + + /** + * Get call count for a specific method + */ + getCallCount: (method: keyof ApiClient): number => { + const methodFn = mockDataApiService[method] as any + return vi.isMockFunction(methodFn) ? methodFn.mock.calls.length : 0 + }, + + /** + * Get calls for a specific method + */ + getCalls: (method: keyof ApiClient): any[] => { + const methodFn = mockDataApiService[method] as any + return vi.isMockFunction(methodFn) ? methodFn.mock.calls : [] + } +} \ No newline at end of file diff --git a/tests/__mocks__/renderer/PreferenceService.ts b/tests/__mocks__/renderer/PreferenceService.ts new file mode 100644 index 0000000000..f4bdad9234 --- /dev/null +++ b/tests/__mocks__/renderer/PreferenceService.ts @@ -0,0 +1,98 @@ +import { vi } from 'vitest' + +/** + * Mock PreferenceService for testing + * Provides common preference defaults used across the application + */ + +// Default preference values used in tests +export const mockPreferenceDefaults: Record = { + // Export preferences + 'data.export.markdown.force_dollar_math': false, + 'data.export.markdown.exclude_citations': false, + 'data.export.markdown.standardize_citations': true, + 'data.export.markdown.show_model_name': false, + 'data.export.markdown.show_model_provider': false, + + // UI preferences + 'ui.language': 'zh-CN', + 'ui.theme': 'light', + 'ui.font_size': 14, + + // AI preferences + 'ai.default_model': 'gpt-4', + 'ai.temperature': 0.7, + 'ai.max_tokens': 2000, + + // Feature flags + 'feature.web_search': true, + 'feature.reasoning': false, + 'feature.tool_calling': true, + + // User preferences + 'user.name': 'MockUser', + + // App preferences + 'app.user.name': 'MockUser', + 'app.language': 'zh-CN', + + // Add more defaults as needed +} + +/** + * Mock implementation of PreferenceService + */ +export const createMockPreferenceService = (customDefaults: Record = {}) => { + const mergedDefaults = { ...mockPreferenceDefaults, ...customDefaults } + + return { + get: vi.fn((key: string) => { + const value = mergedDefaults[key] + return Promise.resolve(value !== undefined ? value : null) + }), + + getMultiple: vi.fn((keys: Record) => { + const result: Record = {} + Object.entries(keys).forEach(([alias, key]) => { + const value = mergedDefaults[key] + result[alias] = value !== undefined ? value : null + }) + return Promise.resolve(result) + }), + + set: vi.fn((key: string, value: any) => { + mergedDefaults[key] = value + return Promise.resolve() + }), + + setMultiple: vi.fn((values: Record) => { + Object.assign(mergedDefaults, values) + return Promise.resolve() + }), + + delete: vi.fn((key: string) => { + delete mergedDefaults[key] + return Promise.resolve() + }), + + clear: vi.fn(() => { + Object.keys(mergedDefaults).forEach(key => delete mergedDefaults[key]) + return Promise.resolve() + }), + + // Internal state access for testing + _getMockState: () => ({ ...mergedDefaults }), + _resetMockState: () => { + Object.keys(mergedDefaults).forEach(key => delete mergedDefaults[key]) + Object.assign(mergedDefaults, mockPreferenceDefaults, customDefaults) + } + } +} + +// Default mock instance +export const mockPreferenceService = createMockPreferenceService() + +// Export for easy mocking in individual tests +export const MockPreferenceService = { + preferenceService: mockPreferenceService +} \ No newline at end of file diff --git a/tests/__mocks__/renderer/useCache.ts b/tests/__mocks__/renderer/useCache.ts new file mode 100644 index 0000000000..e19c8be076 --- /dev/null +++ b/tests/__mocks__/renderer/useCache.ts @@ -0,0 +1,428 @@ +import type { + RendererPersistCacheKey, + RendererPersistCacheSchema, + UseCacheKey, + UseCacheSchema, + UseSharedCacheKey, + UseSharedCacheSchema +} from '@shared/data/cache/cacheSchemas' +import { DefaultRendererPersistCache, DefaultUseCache, DefaultUseSharedCache } from '@shared/data/cache/cacheSchemas' +import { vi } from 'vitest' + +/** + * Mock useCache hooks for testing + * Provides comprehensive mocks for all cache management hooks + */ + +// Mock cache state storage +const mockMemoryCache = new Map() +const mockSharedCache = new Map() +const mockPersistCache = new Map() + +// Initialize caches with defaults +Object.entries(DefaultUseCache).forEach(([key, value]) => { + mockMemoryCache.set(key as UseCacheKey, value) +}) + +Object.entries(DefaultUseSharedCache).forEach(([key, value]) => { + mockSharedCache.set(key as UseSharedCacheKey, value) +}) + +Object.entries(DefaultRendererPersistCache).forEach(([key, value]) => { + mockPersistCache.set(key as RendererPersistCacheKey, value) +}) + +// Mock subscribers for cache changes +const mockMemorySubscribers = new Map void>>() +const mockSharedSubscribers = new Map void>>() +const mockPersistSubscribers = new Map void>>() + +// Helper functions to notify subscribers +const notifyMemorySubscribers = (key: UseCacheKey) => { + const subscribers = mockMemorySubscribers.get(key) + if (subscribers) { + subscribers.forEach(callback => { + try { + callback() + } catch (error) { + console.warn('Mock useCache: Memory subscriber callback error:', error) + } + }) + } +} + +const notifySharedSubscribers = (key: UseSharedCacheKey) => { + const subscribers = mockSharedSubscribers.get(key) + if (subscribers) { + subscribers.forEach(callback => { + try { + callback() + } catch (error) { + console.warn('Mock useCache: Shared subscriber callback error:', error) + } + }) + } +} + +const notifyPersistSubscribers = (key: RendererPersistCacheKey) => { + const subscribers = mockPersistSubscribers.get(key) + if (subscribers) { + subscribers.forEach(callback => { + try { + callback() + } catch (error) { + console.warn('Mock useCache: Persist subscriber callback error:', error) + } + }) + } +} + +/** + * Mock useCache hook (memory cache) + */ +export const mockUseCache = vi.fn(( + key: K, + initValue?: UseCacheSchema[K] +): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] => { + // Get current value + let currentValue = mockMemoryCache.get(key) + if (currentValue === undefined) { + currentValue = initValue ?? DefaultUseCache[key] + if (currentValue !== undefined) { + mockMemoryCache.set(key, currentValue) + } + } + + // Mock setValue function + const setValue = vi.fn((value: UseCacheSchema[K]) => { + mockMemoryCache.set(key, value) + notifyMemorySubscribers(key) + }) + + return [currentValue, setValue] +}) + +/** + * Mock useSharedCache hook (shared cache) + */ +export const mockUseSharedCache = vi.fn(( + key: K, + initValue?: UseSharedCacheSchema[K] +): [UseSharedCacheSchema[K], (value: UseSharedCacheSchema[K]) => void] => { + // Get current value + let currentValue = mockSharedCache.get(key) + if (currentValue === undefined) { + currentValue = initValue ?? DefaultUseSharedCache[key] + if (currentValue !== undefined) { + mockSharedCache.set(key, currentValue) + } + } + + // Mock setValue function + const setValue = vi.fn((value: UseSharedCacheSchema[K]) => { + mockSharedCache.set(key, value) + notifySharedSubscribers(key) + }) + + return [currentValue, setValue] +}) + +/** + * Mock usePersistCache hook (persistent cache) + */ +export const mockUsePersistCache = vi.fn(( + key: K, + initValue?: RendererPersistCacheSchema[K] +): [RendererPersistCacheSchema[K], (value: RendererPersistCacheSchema[K]) => void] => { + // Get current value + let currentValue = mockPersistCache.get(key) + if (currentValue === undefined) { + currentValue = initValue ?? DefaultRendererPersistCache[key] + if (currentValue !== undefined) { + mockPersistCache.set(key, currentValue) + } + } + + // Mock setValue function + const setValue = vi.fn((value: RendererPersistCacheSchema[K]) => { + mockPersistCache.set(key, value) + notifyPersistSubscribers(key) + }) + + return [currentValue, setValue] +}) + +/** + * Export all mocks as a unified module + */ +export const MockUseCache = { + useCache: mockUseCache, + useSharedCache: mockUseSharedCache, + usePersistCache: mockUsePersistCache +} + +/** + * Utility functions for testing + */ +export const MockUseCacheUtils = { + /** + * Reset all hook mock call counts and state + */ + resetMocks: () => { + mockUseCache.mockClear() + mockUseSharedCache.mockClear() + mockUsePersistCache.mockClear() + + // Reset caches to defaults + mockMemoryCache.clear() + mockSharedCache.clear() + mockPersistCache.clear() + + Object.entries(DefaultUseCache).forEach(([key, value]) => { + mockMemoryCache.set(key as UseCacheKey, value) + }) + + Object.entries(DefaultUseSharedCache).forEach(([key, value]) => { + mockSharedCache.set(key as UseSharedCacheKey, value) + }) + + Object.entries(DefaultRendererPersistCache).forEach(([key, value]) => { + mockPersistCache.set(key as RendererPersistCacheKey, value) + }) + + // Clear subscribers + mockMemorySubscribers.clear() + mockSharedSubscribers.clear() + mockPersistSubscribers.clear() + }, + + /** + * Set cache value for testing (memory cache) + */ + setCacheValue: (key: K, value: UseCacheSchema[K]) => { + mockMemoryCache.set(key, value) + notifyMemorySubscribers(key) + }, + + /** + * Get cache value (memory cache) + */ + getCacheValue: (key: K): UseCacheSchema[K] => { + return mockMemoryCache.get(key) ?? DefaultUseCache[key] + }, + + /** + * Set shared cache value for testing + */ + setSharedCacheValue: (key: K, value: UseSharedCacheSchema[K]) => { + mockSharedCache.set(key, value) + notifySharedSubscribers(key) + }, + + /** + * Get shared cache value + */ + getSharedCacheValue: (key: K): UseSharedCacheSchema[K] => { + return mockSharedCache.get(key) ?? DefaultUseSharedCache[key] + }, + + /** + * Set persist cache value for testing + */ + setPersistCacheValue: (key: K, value: RendererPersistCacheSchema[K]) => { + mockPersistCache.set(key, value) + notifyPersistSubscribers(key) + }, + + /** + * Get persist cache value + */ + getPersistCacheValue: (key: K): RendererPersistCacheSchema[K] => { + return mockPersistCache.get(key) ?? DefaultRendererPersistCache[key] + }, + + /** + * Set multiple cache values at once + */ + setMultipleCacheValues: (values: { + memory?: Array<[UseCacheKey, any]> + shared?: Array<[UseSharedCacheKey, any]> + persist?: Array<[RendererPersistCacheKey, any]> + }) => { + values.memory?.forEach(([key, value]) => { + mockMemoryCache.set(key, value) + notifyMemorySubscribers(key) + }) + + values.shared?.forEach(([key, value]) => { + mockSharedCache.set(key, value) + notifySharedSubscribers(key) + }) + + values.persist?.forEach(([key, value]) => { + mockPersistCache.set(key, value) + notifyPersistSubscribers(key) + }) + }, + + /** + * Get all cache values + */ + getAllCacheValues: () => ({ + memory: Object.fromEntries(mockMemoryCache.entries()), + shared: Object.fromEntries(mockSharedCache.entries()), + persist: Object.fromEntries(mockPersistCache.entries()) + }), + + /** + * Simulate cache change from external source + */ + simulateExternalCacheChange: (key: K, value: UseCacheSchema[K]) => { + mockMemoryCache.set(key, value) + notifyMemorySubscribers(key) + }, + + /** + * Mock cache hook to return specific value for a key + */ + mockCacheReturn: ( + key: K, + value: UseCacheSchema[K], + setValue?: (value: UseCacheSchema[K]) => void + ) => { + mockUseCache.mockImplementation((cacheKey, initValue) => { + if (cacheKey === key) { + return [ + value, + setValue || vi.fn() + ] + } + + // Default behavior for other keys + const defaultValue = mockMemoryCache.get(cacheKey) ?? initValue ?? DefaultUseCache[cacheKey] + return [ + defaultValue, + vi.fn() + ] + }) + }, + + /** + * Mock shared cache hook to return specific value for a key + */ + mockSharedCacheReturn: ( + key: K, + value: UseSharedCacheSchema[K], + setValue?: (value: UseSharedCacheSchema[K]) => void + ) => { + mockUseSharedCache.mockImplementation((cacheKey, initValue) => { + if (cacheKey === key) { + return [ + value, + setValue || vi.fn() + ] + } + + // Default behavior for other keys + const defaultValue = mockSharedCache.get(cacheKey) ?? initValue ?? DefaultUseSharedCache[cacheKey] + return [ + defaultValue, + vi.fn() + ] + }) + }, + + /** + * Mock persist cache hook to return specific value for a key + */ + mockPersistCacheReturn: ( + key: K, + value: RendererPersistCacheSchema[K], + setValue?: (value: RendererPersistCacheSchema[K]) => void + ) => { + mockUsePersistCache.mockImplementation((cacheKey, initValue) => { + if (cacheKey === key) { + return [ + value, + setValue || vi.fn() + ] + } + + // Default behavior for other keys + const defaultValue = mockPersistCache.get(cacheKey) ?? initValue ?? DefaultRendererPersistCache[cacheKey] + return [ + defaultValue, + vi.fn() + ] + }) + }, + + /** + * Add subscriber for cache changes (for testing subscription behavior) + */ + addMemorySubscriber: (key: UseCacheKey, callback: () => void): (() => void) => { + if (!mockMemorySubscribers.has(key)) { + mockMemorySubscribers.set(key, new Set()) + } + mockMemorySubscribers.get(key)!.add(callback) + + return () => { + const subscribers = mockMemorySubscribers.get(key) + if (subscribers) { + subscribers.delete(callback) + if (subscribers.size === 0) { + mockMemorySubscribers.delete(key) + } + } + } + }, + + /** + * Add subscriber for shared cache changes + */ + addSharedSubscriber: (key: UseSharedCacheKey, callback: () => void): (() => void) => { + if (!mockSharedSubscribers.has(key)) { + mockSharedSubscribers.set(key, new Set()) + } + mockSharedSubscribers.get(key)!.add(callback) + + return () => { + const subscribers = mockSharedSubscribers.get(key) + if (subscribers) { + subscribers.delete(callback) + if (subscribers.size === 0) { + mockSharedSubscribers.delete(key) + } + } + } + }, + + /** + * Add subscriber for persist cache changes + */ + addPersistSubscriber: (key: RendererPersistCacheKey, callback: () => void): (() => void) => { + if (!mockPersistSubscribers.has(key)) { + mockPersistSubscribers.set(key, new Set()) + } + mockPersistSubscribers.get(key)!.add(callback) + + return () => { + const subscribers = mockPersistSubscribers.get(key) + if (subscribers) { + subscribers.delete(callback) + if (subscribers.size === 0) { + mockPersistSubscribers.delete(key) + } + } + } + }, + + /** + * Get subscriber counts for debugging + */ + getSubscriberCounts: () => ({ + memory: Array.from(mockMemorySubscribers.entries()).map(([key, subs]) => [key, subs.size]), + shared: Array.from(mockSharedSubscribers.entries()).map(([key, subs]) => [key, subs.size]), + persist: Array.from(mockPersistSubscribers.entries()).map(([key, subs]) => [key, subs.size]) + }) +} \ No newline at end of file diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts new file mode 100644 index 0000000000..5b748dfea9 --- /dev/null +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -0,0 +1,369 @@ +import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas' +import type { PaginatedResponse } from '@shared/data/api/apiTypes' +import { vi } from 'vitest' + +/** + * Mock useDataApi hooks for testing + * Provides comprehensive mocks for all data API hooks with realistic SWR-like behavior + */ + +// Mock SWR response interface +interface MockSWRResponse { + data?: T + error?: Error + isLoading: boolean + isValidating: boolean + mutate: (data?: T | Promise | ((data: T) => T)) => Promise +} + +// Mock mutation response interface +interface MockMutationResponse { + data?: T + error?: Error + isMutating: boolean + trigger: (...args: any[]) => Promise + reset: () => void +} + +// Mock paginated response interface +interface MockPaginatedResponse extends MockSWRResponse> { + loadMore: () => void + isLoadingMore: boolean + hasMore: boolean + items: T[] +} + +/** + * Create mock data based on API path + */ +function createMockDataForPath(path: ConcreteApiPaths): any { + if (path.includes('/topics')) { + if (path.endsWith('/topics')) { + return { + topics: [ + { id: 'topic1', name: 'Mock Topic 1', createdAt: '2024-01-01T00:00:00Z' }, + { id: 'topic2', name: 'Mock Topic 2', createdAt: '2024-01-02T00:00:00Z' } + ], + total: 2 + } + } + return { + id: 'topic1', + name: 'Mock Topic', + messages: [], + createdAt: '2024-01-01T00:00:00Z' + } + } + + if (path.includes('/messages')) { + return { + messages: [ + { id: 'msg1', content: 'Mock message 1', role: 'user' }, + { id: 'msg2', content: 'Mock message 2', role: 'assistant' } + ], + total: 2 + } + } + + return { id: 'mock_id', data: 'mock_data' } +} + +/** + * Mock useQuery hook + */ +export const mockUseQuery = vi.fn(( + path: TPath | null, + query?: any, + options?: any +): MockSWRResponse => { + const isLoading = options?.initialLoading ?? false + const hasError = options?.shouldError ?? false + + if (hasError) { + return { + data: undefined, + error: new Error(`Mock error for ${path}`), + isLoading: false, + isValidating: false, + mutate: vi.fn().mockResolvedValue(undefined) + } + } + + const mockData = path ? createMockDataForPath(path) : undefined + + return { + data: mockData, + error: undefined, + isLoading, + isValidating: false, + mutate: vi.fn().mockResolvedValue(mockData) + } +}) + +/** + * Mock useMutation hook + */ +export const mockUseMutation = vi.fn(( + path: TPath, + method: TMethod, + options?: any +): MockMutationResponse => { + const isMutating = options?.initialMutating ?? false + const hasError = options?.shouldError ?? false + + const mockTrigger = vi.fn(async (...args: any[]) => { + if (hasError) { + throw new Error(`Mock mutation error for ${method} ${path}`) + } + + // Simulate different responses based on method + switch (method) { + case 'POST': + return { id: 'new_item', created: true, ...args[0] } + case 'PUT': + case 'PATCH': + return { id: 'updated_item', updated: true, ...args[0] } + case 'DELETE': + return { deleted: true } + default: + return { success: true } + } + }) + + return { + data: undefined, + error: undefined, + isMutating, + trigger: mockTrigger, + reset: vi.fn() + } +}) + +/** + * Mock usePaginatedQuery hook + */ +export const mockUsePaginatedQuery = vi.fn(( + path: TPath | null, + query?: any, + options?: any +): MockPaginatedResponse => { + const isLoading = options?.initialLoading ?? false + const isLoadingMore = options?.initialLoadingMore ?? false + const hasError = options?.shouldError ?? false + + if (hasError) { + return { + data: undefined, + error: new Error(`Mock paginated error for ${path}`), + isLoading: false, + isValidating: false, + mutate: vi.fn().mockResolvedValue(undefined), + loadMore: vi.fn(), + isLoadingMore: false, + hasMore: false, + items: [] + } + } + + const mockItems = path ? [ + { id: 'item1', name: 'Mock Item 1' }, + { id: 'item2', name: 'Mock Item 2' }, + { id: 'item3', name: 'Mock Item 3' } + ] : [] + + const mockData: PaginatedResponse = { + items: mockItems, + total: mockItems.length, + page: 1, + pageSize: 10, + hasMore: false + } + + return { + data: mockData, + error: undefined, + isLoading, + isValidating: false, + mutate: vi.fn().mockResolvedValue(mockData), + loadMore: vi.fn(), + isLoadingMore, + hasMore: mockData.hasMore, + items: mockItems + } +}) + +/** + * Mock useInvalidateCache hook + */ +export const mockUseInvalidateCache = vi.fn(() => { + return { + invalidate: vi.fn(async (path?: ConcreteApiPaths) => { + // Mock cache invalidation + return Promise.resolve() + }), + invalidateAll: vi.fn(async () => { + // Mock invalidate all caches + return Promise.resolve() + }) + } +}) + +/** + * Mock prefetch function + */ +export const mockPrefetch = vi.fn(async ( + path: TPath, + query?: any, + options?: any +): Promise => { + // Mock prefetch - return mock data + return createMockDataForPath(path) +}) + +/** + * Export all mocks as a unified module + */ +export const MockUseDataApi = { + useQuery: mockUseQuery, + useMutation: mockUseMutation, + usePaginatedQuery: mockUsePaginatedQuery, + useInvalidateCache: mockUseInvalidateCache, + prefetch: mockPrefetch +} + +/** + * Utility functions for testing + */ +export const MockUseDataApiUtils = { + /** + * Reset all hook mock call counts and implementations + */ + resetMocks: () => { + mockUseQuery.mockClear() + mockUseMutation.mockClear() + mockUsePaginatedQuery.mockClear() + mockUseInvalidateCache.mockClear() + mockPrefetch.mockClear() + }, + + /** + * Set up useQuery to return specific data + */ + mockQueryData: (path: ConcreteApiPaths, data: T) => { + mockUseQuery.mockImplementation((queryPath, query, options) => { + if (queryPath === path) { + return { + data, + error: undefined, + isLoading: false, + isValidating: false, + mutate: vi.fn().mockResolvedValue(data) + } + } + // Default behavior for other paths + return mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { + data: undefined, + error: undefined, + isLoading: false, + isValidating: false, + mutate: vi.fn().mockResolvedValue(undefined) + } + }) + }, + + /** + * Set up useQuery to return loading state + */ + mockQueryLoading: (path: ConcreteApiPaths) => { + mockUseQuery.mockImplementation((queryPath, query, options) => { + if (queryPath === path) { + return { + data: undefined, + error: undefined, + isLoading: true, + isValidating: true, + mutate: vi.fn().mockResolvedValue(undefined) + } + } + return mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { + data: undefined, + error: undefined, + isLoading: false, + isValidating: false, + mutate: vi.fn().mockResolvedValue(undefined) + } + }) + }, + + /** + * Set up useQuery to return error state + */ + mockQueryError: (path: ConcreteApiPaths, error: Error) => { + mockUseQuery.mockImplementation((queryPath, query, options) => { + if (queryPath === path) { + return { + data: undefined, + error, + isLoading: false, + isValidating: false, + mutate: vi.fn().mockResolvedValue(undefined) + } + } + return mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { + data: undefined, + error: undefined, + isLoading: false, + isValidating: false, + mutate: vi.fn().mockResolvedValue(undefined) + } + }) + }, + + /** + * Set up useMutation to simulate success + */ + mockMutationSuccess: (path: ConcreteApiPaths, method: string, result: T) => { + mockUseMutation.mockImplementation((mutationPath, mutationMethod, options) => { + if (mutationPath === path && mutationMethod === method) { + return { + data: undefined, + error: undefined, + isMutating: false, + trigger: vi.fn().mockResolvedValue(result), + reset: vi.fn() + } + } + return mockUseMutation.getMockImplementation()?.(mutationPath, mutationMethod, options) || { + data: undefined, + error: undefined, + isMutating: false, + trigger: vi.fn().mockResolvedValue({}), + reset: vi.fn() + } + }) + }, + + /** + * Set up useMutation to simulate error + */ + mockMutationError: (path: ConcreteApiPaths, method: string, error: Error) => { + mockUseMutation.mockImplementation((mutationPath, mutationMethod, options) => { + if (mutationPath === path && mutationMethod === method) { + return { + data: undefined, + error: undefined, + isMutating: false, + trigger: vi.fn().mockRejectedValue(error), + reset: vi.fn() + } + } + return mockUseMutation.getMockImplementation()?.(mutationPath, mutationMethod, options) || { + data: undefined, + error: undefined, + isMutating: false, + trigger: vi.fn().mockResolvedValue({}), + reset: vi.fn() + } + }) + } +} \ No newline at end of file diff --git a/tests/__mocks__/renderer/usePreference.ts b/tests/__mocks__/renderer/usePreference.ts new file mode 100644 index 0000000000..343e1b8a81 --- /dev/null +++ b/tests/__mocks__/renderer/usePreference.ts @@ -0,0 +1,296 @@ +import type { + PreferenceKeyType, + PreferenceUpdateOptions, + PreferenceValueType +} from '@shared/data/preference/preferenceTypes' +import { vi } from 'vitest' + +import { mockPreferenceDefaults } from './PreferenceService' + +/** + * Mock usePreference hooks for testing + * Provides comprehensive mocks for preference management hooks + */ + +// Mock preference state storage +const mockPreferenceState = new Map() + +// Initialize with defaults +Object.entries(mockPreferenceDefaults).forEach(([key, value]) => { + mockPreferenceState.set(key as PreferenceKeyType, value) +}) + +// Mock subscribers for preference changes +const mockPreferenceSubscribers = new Map void>>() + +// Helper function to notify subscribers +const notifyPreferenceSubscribers = (key: PreferenceKeyType) => { + const subscribers = mockPreferenceSubscribers.get(key) + if (subscribers) { + subscribers.forEach(callback => { + try { + callback() + } catch (error) { + console.warn('Mock usePreference: Subscriber callback error:', error) + } + }) + } +} + +/** + * Mock usePreference hook + */ +export const mockUsePreference = vi.fn(( + key: K, + options?: PreferenceUpdateOptions +): [PreferenceValueType, (value: PreferenceValueType) => Promise] => { + // Get current value + const currentValue = mockPreferenceState.get(key) ?? mockPreferenceDefaults[key] ?? null + + // Mock setValue function + const setValue = vi.fn(async (value: PreferenceValueType) => { + const oldValue = mockPreferenceState.get(key) + + // Simulate optimistic updates (default behavior) + if (options?.optimistic !== false) { + mockPreferenceState.set(key, value) + notifyPreferenceSubscribers(key) + } + + // Simulate async update delay + await new Promise(resolve => setTimeout(resolve, 10)) + + // For pessimistic updates, update after delay + if (options?.optimistic === false) { + mockPreferenceState.set(key, value) + notifyPreferenceSubscribers(key) + } + + // Simulate error scenarios if configured + if (options?.shouldError) { + // Rollback optimistic update on error + if (options.optimistic !== false) { + mockPreferenceState.set(key, oldValue) + notifyPreferenceSubscribers(key) + } + throw new Error(`Mock preference error for key: ${key}`) + } + }) + + return [currentValue, setValue] +}) + +/** + * Mock useMultiplePreferences hook + */ +export const mockUseMultiplePreferences = vi.fn(>( + keys: T, + options?: PreferenceUpdateOptions +): [ + { [K in keyof T]: PreferenceValueType }, + (values: Partial<{ [K in keyof T]: PreferenceValueType }>) => Promise +] => { + // Get current values for all keys + const currentValues = {} as { [K in keyof T]: PreferenceValueType } + Object.entries(keys).forEach(([alias, key]) => { + currentValues[alias as keyof T] = mockPreferenceState.get(key as PreferenceKeyType) ?? + mockPreferenceDefaults[key as string] ?? null + }) + + // Mock setValues function + const setValues = vi.fn(async (values: Partial<{ [K in keyof T]: PreferenceValueType }>) => { + const oldValues = { ...currentValues } + + // Simulate optimistic updates + if (options?.optimistic !== false) { + Object.entries(values).forEach(([alias, value]) => { + const key = keys[alias as keyof T] as PreferenceKeyType + if (value !== undefined) { + mockPreferenceState.set(key, value) + currentValues[alias as keyof T] = value as any + notifyPreferenceSubscribers(key) + } + }) + } + + // Simulate async update delay + await new Promise(resolve => setTimeout(resolve, 10)) + + // For pessimistic updates, update after delay + if (options?.optimistic === false) { + Object.entries(values).forEach(([alias, value]) => { + const key = keys[alias as keyof T] as PreferenceKeyType + if (value !== undefined) { + mockPreferenceState.set(key, value) + currentValues[alias as keyof T] = value as any + notifyPreferenceSubscribers(key) + } + }) + } + + // Simulate error scenarios + if (options?.shouldError) { + // Rollback optimistic updates on error + if (options.optimistic !== false) { + Object.entries(oldValues).forEach(([alias, value]) => { + const key = keys[alias as keyof T] as PreferenceKeyType + mockPreferenceState.set(key, value) + currentValues[alias as keyof T] = value + notifyPreferenceSubscribers(key) + }) + } + throw new Error('Mock multiple preferences error') + } + }) + + return [currentValues, setValues] +}) + +/** + * Export all mocks as a unified module + */ +export const MockUsePreference = { + usePreference: mockUsePreference, + useMultiplePreferences: mockUseMultiplePreferences +} + +/** + * Utility functions for testing + */ +export const MockUsePreferenceUtils = { + /** + * Reset all hook mock call counts and state + */ + resetMocks: () => { + mockUsePreference.mockClear() + mockUseMultiplePreferences.mockClear() + + // Reset state to defaults + mockPreferenceState.clear() + Object.entries(mockPreferenceDefaults).forEach(([key, value]) => { + mockPreferenceState.set(key as PreferenceKeyType, value) + }) + + // Clear subscribers + mockPreferenceSubscribers.clear() + }, + + /** + * Set a preference value for testing + */ + setPreferenceValue: (key: K, value: PreferenceValueType) => { + mockPreferenceState.set(key, value) + notifyPreferenceSubscribers(key) + }, + + /** + * Get current preference value + */ + getPreferenceValue: (key: K): PreferenceValueType => { + return mockPreferenceState.get(key) ?? mockPreferenceDefaults[key] ?? null + }, + + /** + * Set multiple preference values for testing + */ + setMultiplePreferenceValues: (values: Record) => { + Object.entries(values).forEach(([key, value]) => { + mockPreferenceState.set(key as PreferenceKeyType, value) + notifyPreferenceSubscribers(key as PreferenceKeyType) + }) + }, + + /** + * Get all current preference values + */ + getAllPreferenceValues: (): Record => { + const result: Record = {} + mockPreferenceState.forEach((value, key) => { + result[key] = value + }) + return result + }, + + /** + * Simulate preference change from external source + */ + simulateExternalPreferenceChange: (key: K, value: PreferenceValueType) => { + mockPreferenceState.set(key, value) + notifyPreferenceSubscribers(key) + }, + + /** + * Mock preference hook to return specific value for a key + */ + mockPreferenceReturn: ( + key: K, + value: PreferenceValueType, + setValue?: (value: PreferenceValueType) => Promise + ) => { + mockUsePreference.mockImplementation((preferenceKey, options) => { + if (preferenceKey === key) { + return [ + value, + setValue || vi.fn().mockResolvedValue(undefined) + ] + } + + // Default behavior for other keys + const defaultValue = mockPreferenceState.get(preferenceKey) ?? + mockPreferenceDefaults[preferenceKey] ?? null + return [ + defaultValue, + vi.fn().mockResolvedValue(undefined) + ] + }) + }, + + /** + * Mock preference hook to simulate error for a key + */ + mockPreferenceError: (key: K, error: Error) => { + mockUsePreference.mockImplementation((preferenceKey, options) => { + if (preferenceKey === key) { + const setValue = vi.fn().mockRejectedValue(error) + const currentValue = mockPreferenceState.get(key) ?? mockPreferenceDefaults[key] ?? null + return [currentValue, setValue] + } + + // Default behavior for other keys + const defaultValue = mockPreferenceState.get(preferenceKey) ?? + mockPreferenceDefaults[preferenceKey] ?? null + return [ + defaultValue, + vi.fn().mockResolvedValue(undefined) + ] + }) + }, + + /** + * Add subscriber for preference changes (for testing subscription behavior) + */ + addSubscriber: (key: PreferenceKeyType, callback: () => void): (() => void) => { + if (!mockPreferenceSubscribers.has(key)) { + mockPreferenceSubscribers.set(key, new Set()) + } + mockPreferenceSubscribers.get(key)!.add(callback) + + // Return unsubscribe function + return () => { + const subscribers = mockPreferenceSubscribers.get(key) + if (subscribers) { + subscribers.delete(callback) + if (subscribers.size === 0) { + mockPreferenceSubscribers.delete(key) + } + } + } + }, + + /** + * Get subscriber count for a preference key + */ + getSubscriberCount: (key: PreferenceKeyType): number => { + return mockPreferenceSubscribers.get(key)?.size ?? 0 + } +} \ No newline at end of file diff --git a/tests/main.setup.ts b/tests/main.setup.ts index 5cadb89d02..0f70bccd7d 100644 --- a/tests/main.setup.ts +++ b/tests/main.setup.ts @@ -9,6 +9,40 @@ vi.mock('@logger', async () => { } }) +// Mock PreferenceService globally for main tests +vi.mock('@main/data/PreferenceService', async () => { + const { MockMainPreferenceServiceExport } = await import('./__mocks__/main/PreferenceService') + return MockMainPreferenceServiceExport +}) + +// Mock DataApiService globally for main tests +vi.mock('@main/data/DataApiService', async () => { + const { MockMainDataApiServiceExport } = await import('./__mocks__/main/DataApiService') + return MockMainDataApiServiceExport +}) + +// Mock CacheService globally for main tests +vi.mock('@main/data/CacheService', async () => { + const { MockMainCacheServiceExport } = await import('./__mocks__/main/CacheService') + return MockMainCacheServiceExport +}) + +// Mock DbService globally for main tests (if exists) +vi.mock('@main/data/db/DbService', async () => { + try { + const { MockDbService } = await import('./__mocks__/DbService') + return MockDbService + } catch { + // Return basic mock if DbService mock doesn't exist yet + return { + dbService: { + initialize: vi.fn(), + getDb: vi.fn() + } + } + } +}) + // Mock electron modules that are commonly used in main process vi.mock('electron', () => ({ app: { diff --git a/tests/renderer.setup.ts b/tests/renderer.setup.ts index ea4057eca8..7320474f08 100644 --- a/tests/renderer.setup.ts +++ b/tests/renderer.setup.ts @@ -14,6 +14,42 @@ vi.mock('@logger', async () => { } }) +// Mock PreferenceService globally for renderer tests +vi.mock('@data/PreferenceService', async () => { + const { MockPreferenceService } = await import('./__mocks__/renderer/PreferenceService') + return MockPreferenceService +}) + +// Mock DataApiService globally for renderer tests +vi.mock('@data/DataApiService', async () => { + const { MockDataApiService } = await import('./__mocks__/renderer/DataApiService') + return MockDataApiService +}) + +// Mock CacheService globally for renderer tests +vi.mock('@data/CacheService', async () => { + const { MockCacheService } = await import('./__mocks__/renderer/CacheService') + return MockCacheService +}) + +// Mock useDataApi hooks globally for renderer tests +vi.mock('@data/hooks/useDataApi', async () => { + const { MockUseDataApi } = await import('./__mocks__/renderer/useDataApi') + return MockUseDataApi +}) + +// Mock usePreference hooks globally for renderer tests +vi.mock('@data/hooks/usePreference', async () => { + const { MockUsePreference } = await import('./__mocks__/renderer/usePreference') + return MockUsePreference +}) + +// Mock useCache hooks globally for renderer tests +vi.mock('@data/hooks/useCache', async () => { + const { MockUseCache } = await import('./__mocks__/renderer/useCache') + return MockUseCache +}) + vi.mock('axios', () => ({ default: { get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request