From a1304054ce08ba3dce3cd1066b5ccbc441d32abe Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Sun, 6 Jul 2025 04:51:41 +0800 Subject: [PATCH] test: add comprehensive unit tests for asyncInitializer and copy utilities (#7858) * test: add unit tests for asyncInitializer and copy utilities - Add tests for asyncInitializer class functionality - Add tests for clipboard copy operations * refactor(test): improve copy.test.ts structure and maintainability - Remove complex shared testCopyFunction in favor of individual test cases - Simplify mock cleanup by removing redundant afterEach - Split test scenarios into focused, independent test cases - Improve test readability with clear Chinese comments - Maintain full test coverage while following TEST_UTILS.md guidelines - Fix minor formatting in asyncInitializer.test.ts * test: remove unnecessary test cases - Remove AsyncInitializer type support test - Remove maintain separate instances test - These tests verify language features rather than business logic * refactor(test): reorganize copy and export test structure Restructure test organization based on PR review feedback: - Move export functionality tests from copy.test.ts to export.test.ts - Remove unnecessary "clipboard API not available" test - Merge duplicate empty content tests for better coverage - Add boundary tests for special characters and Markdown formatting - Fix ESLint formatting issues Test responsibilities are now clearer: - copy.test.ts: Focus on clipboard operations (8 tests) - export.test.ts: Focus on content conversion and edge cases * fix(test): correct markdown formatting test for list items Fix the regex pattern to properly handle markdown list items. Replace with separate patterns to avoid removing the dash from list items incorrectly. * fix(test): format prettier style for markdown test --- .../utils/__tests__/asyncInitializer.test.ts | 90 ++++++++ src/renderer/src/utils/__tests__/copy.test.ts | 196 ++++++++++++++++++ .../src/utils/__tests__/export.test.ts | 107 +++++++++- 3 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/utils/__tests__/asyncInitializer.test.ts create mode 100644 src/renderer/src/utils/__tests__/copy.test.ts diff --git a/src/renderer/src/utils/__tests__/asyncInitializer.test.ts b/src/renderer/src/utils/__tests__/asyncInitializer.test.ts new file mode 100644 index 0000000000..8ff6d2ced6 --- /dev/null +++ b/src/renderer/src/utils/__tests__/asyncInitializer.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AsyncInitializer } from '../asyncInitializer' + +describe('AsyncInitializer', () => { + it('should initialize value lazily on first get', async () => { + const mockFactory = vi.fn().mockResolvedValue('test-value') + const initializer = new AsyncInitializer(mockFactory) + + // factory 不应该在构造时调用 + expect(mockFactory).not.toHaveBeenCalled() + + // 第一次调用 get + const result = await initializer.get() + + expect(mockFactory).toHaveBeenCalledTimes(1) + expect(result).toBe('test-value') + }) + + it('should cache value and return same instance on subsequent calls', async () => { + const mockFactory = vi.fn().mockResolvedValue('test-value') + const initializer = new AsyncInitializer(mockFactory) + + // 多次调用 get + const result1 = await initializer.get() + const result2 = await initializer.get() + const result3 = await initializer.get() + + // factory 只应该被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + + // 所有结果应该相同 + expect(result1).toBe('test-value') + expect(result2).toBe('test-value') + expect(result3).toBe('test-value') + }) + + it('should handle concurrent calls properly', async () => { + let resolveFactory: (value: string) => void + const factoryPromise = new Promise((resolve) => { + resolveFactory = resolve + }) + const mockFactory = vi.fn().mockReturnValue(factoryPromise) + + const initializer = new AsyncInitializer(mockFactory) + + // 同时调用多次 get + const promise1 = initializer.get() + const promise2 = initializer.get() + const promise3 = initializer.get() + + // factory 只应该被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + + // 解析 promise + resolveFactory!('concurrent-value') + + const results = await Promise.all([promise1, promise2, promise3]) + expect(results).toEqual(['concurrent-value', 'concurrent-value', 'concurrent-value']) + }) + + it('should handle and cache errors', async () => { + const error = new Error('Factory error') + const mockFactory = vi.fn().mockRejectedValue(error) + const initializer = new AsyncInitializer(mockFactory) + + // 多次调用都应该返回相同的错误 + await expect(initializer.get()).rejects.toThrow('Factory error') + await expect(initializer.get()).rejects.toThrow('Factory error') + + // factory 只应该被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + }) + + it('should not retry after failure', async () => { + // 确认错误被缓存,不会重试 + const error = new Error('Initialization failed') + const mockFactory = vi.fn().mockRejectedValue(error) + const initializer = new AsyncInitializer(mockFactory) + + // 第一次失败 + await expect(initializer.get()).rejects.toThrow('Initialization failed') + + // 第二次调用不应该重试 + await expect(initializer.get()).rejects.toThrow('Initialization failed') + + // factory 只被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/utils/__tests__/copy.test.ts b/src/renderer/src/utils/__tests__/copy.test.ts new file mode 100644 index 0000000000..85480a686e --- /dev/null +++ b/src/renderer/src/utils/__tests__/copy.test.ts @@ -0,0 +1,196 @@ +import { Message, Topic } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { copyMessageAsPlainText, copyTopicAsMarkdown, copyTopicAsPlainText } from '../copy' + +// Mock dependencies +vi.mock('@renderer/utils/export', () => ({ + topicToMarkdown: vi.fn(), + topicToPlainText: vi.fn(), + messageToPlainText: vi.fn() +})) + +vi.mock('i18next', () => ({ + default: { + t: vi.fn((key) => key) + } +})) + +// Mock navigator.clipboard +const mockClipboard = { + writeText: vi.fn() +} + +// Mock window.message +const mockMessage = { + success: vi.fn() +} + +// 创建测试数据辅助函数 +function createTestTopic(partial: Partial = {}): Topic { + return { + id: 'test-topic-id', + assistantId: 'test-assistant-id', + name: 'Test Topic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messages: [], + ...partial + } +} + +function createTestMessage(partial: Partial = {}): Message { + return { + id: 'test-message-id', + role: 'user', + assistantId: 'test-assistant-id', + topicId: 'test-topic-id', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'success', + blocks: [], + ...partial + } as Message +} + +describe('copy', () => { + beforeEach(() => { + // 设置全局 mocks + Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, + writable: true + }) + + Object.defineProperty(global.window, 'message', { + value: mockMessage, + writable: true + }) + + // 清理所有 mock 调用 + vi.clearAllMocks() + }) + + describe('copyTopicAsMarkdown', () => { + it('should copy topic as markdown successfully', async () => { + // 准备测试数据 + const topic = createTestTopic() + const markdownContent = '# Test Topic\n\nContent here...' + + const { topicToMarkdown } = await import('@renderer/utils/export') + vi.mocked(topicToMarkdown).mockResolvedValue(markdownContent) + mockClipboard.writeText.mockResolvedValue(undefined) + + // 执行测试 + await copyTopicAsMarkdown(topic) + + // 验证结果 + expect(topicToMarkdown).toHaveBeenCalledWith(topic) + expect(mockClipboard.writeText).toHaveBeenCalledWith(markdownContent) + expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success') + }) + + it('should handle export function errors', async () => { + // 测试导出函数错误 + const topic = createTestTopic() + const { topicToMarkdown } = await import('@renderer/utils/export') + vi.mocked(topicToMarkdown).mockRejectedValue(new Error('Export error')) + + await expect(copyTopicAsMarkdown(topic)).rejects.toThrow('Export error') + expect(mockClipboard.writeText).not.toHaveBeenCalled() + expect(mockMessage.success).not.toHaveBeenCalled() + }) + + it('should handle clipboard write errors', async () => { + // 测试剪贴板写入错误 + const topic = createTestTopic() + const markdownContent = '# Test Topic' + + const { topicToMarkdown } = await import('@renderer/utils/export') + vi.mocked(topicToMarkdown).mockResolvedValue(markdownContent) + mockClipboard.writeText.mockRejectedValue(new Error('Clipboard error')) + + await expect(copyTopicAsMarkdown(topic)).rejects.toThrow('Clipboard error') + expect(mockMessage.success).not.toHaveBeenCalled() + }) + }) + + describe('copyTopicAsPlainText', () => { + it('should copy topic as plain text successfully', async () => { + // 测试成功复制纯文本 + const topic = createTestTopic() + const plainTextContent = 'Test Topic\n\nPlain text content...' + + const { topicToPlainText } = await import('@renderer/utils/export') + vi.mocked(topicToPlainText).mockResolvedValue(plainTextContent) + mockClipboard.writeText.mockResolvedValue(undefined) + + await copyTopicAsPlainText(topic) + + expect(topicToPlainText).toHaveBeenCalledWith(topic) + expect(mockClipboard.writeText).toHaveBeenCalledWith(plainTextContent) + expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success') + }) + + it('should handle export function errors', async () => { + // 测试导出函数错误 + const topic = createTestTopic() + const { topicToPlainText } = await import('@renderer/utils/export') + vi.mocked(topicToPlainText).mockRejectedValue(new Error('Export error')) + + await expect(copyTopicAsPlainText(topic)).rejects.toThrow('Export error') + expect(mockClipboard.writeText).not.toHaveBeenCalled() + expect(mockMessage.success).not.toHaveBeenCalled() + }) + }) + + describe('copyMessageAsPlainText', () => { + it('should copy message as plain text successfully', async () => { + // 测试成功复制消息纯文本 + const message = createTestMessage() + const plainTextContent = 'This is the plain text content of the message' + + const { messageToPlainText } = await import('@renderer/utils/export') + vi.mocked(messageToPlainText).mockReturnValue(plainTextContent) + mockClipboard.writeText.mockResolvedValue(undefined) + + await copyMessageAsPlainText(message) + + expect(messageToPlainText).toHaveBeenCalledWith(message) + expect(mockClipboard.writeText).toHaveBeenCalledWith(plainTextContent) + expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success') + }) + + it('should handle messageToPlainText errors', async () => { + // 测试消息转换错误 + const message = createTestMessage() + const { messageToPlainText } = await import('@renderer/utils/export') + vi.mocked(messageToPlainText).mockImplementation(() => { + throw new Error('Message conversion error') + }) + + await expect(copyMessageAsPlainText(message)).rejects.toThrow('Message conversion error') + expect(mockClipboard.writeText).not.toHaveBeenCalled() + expect(mockMessage.success).not.toHaveBeenCalled() + }) + }) + + describe('edge cases', () => { + it('should handle null or undefined inputs gracefully', async () => { + // 测试null/undefined输入的错误处理 + const { topicToMarkdown, topicToPlainText, messageToPlainText } = await import('@renderer/utils/export') + + vi.mocked(topicToMarkdown).mockRejectedValue(new Error('Cannot read properties of null')) + vi.mocked(topicToPlainText).mockRejectedValue(new Error('Cannot read properties of undefined')) + vi.mocked(messageToPlainText).mockImplementation(() => { + throw new Error('Cannot read properties of null') + }) + + // @ts-expect-error 测试类型错误 + await expect(copyTopicAsMarkdown(null)).rejects.toThrow('Cannot read properties of null') + // @ts-expect-error 测试类型错误 + await expect(copyTopicAsPlainText(undefined)).rejects.toThrow('Cannot read properties of undefined') + // @ts-expect-error 测试类型错误 + await expect(copyMessageAsPlainText(null)).rejects.toThrow('Cannot read properties of null') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/export.test.ts b/src/renderer/src/utils/__tests__/export.test.ts index aa25fbb679..463313078b 100644 --- a/src/renderer/src/utils/__tests__/export.test.ts +++ b/src/renderer/src/utils/__tests__/export.test.ts @@ -258,6 +258,17 @@ describe('export', () => { mockedMessages = [userMsg, assistantMsg] }) + it('should handle empty content in message blocks', () => { + const msgWithEmptyContent = createMessage({ role: 'user', id: 'empty_block' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '' } + ]) + const markdown = messageToMarkdown(msgWithEmptyContent) + expect(markdown).toContain('### 🧑‍💻 User') + // Should handle empty content gracefully + expect(markdown).toBeDefined() + expect(markdown.split('\n\n').filter((s) => s.trim()).length).toBeGreaterThanOrEqual(1) + }) + it('should format user message using main text block', () => { const msg = mockedMessages.find((m) => m.id === 'u1') expect(msg).toBeDefined() @@ -421,7 +432,7 @@ describe('export', () => { } ;(db.topics.get as any).mockResolvedValue({ messages: [userMsg, assistantMsg] }) // Specific mock for this test to check formatting - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*]/g, '')) const plainText = await topicToPlainText(testTopic) @@ -438,20 +449,54 @@ describe('export', () => { const testMessage = createMessage({ role: 'user', id: 'single_msg_plain' }, [ { type: MessageBlockType.MAIN_TEXT, content: '### Single Message Content' } ]) - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) const result = messageToPlainText(testMessage) expect(result).toBe('Single Message Content') expect(markdownToPlainText).toHaveBeenCalledWith('### Single Message Content') }) - it('should return empty string for message with no main text', () => { - const testMessage = createMessage({ role: 'user', id: 'empty_msg_plain' }, []) - ;(markdownToPlainText as any).mockReturnValue('') // Mock to return empty for empty input + it('should return empty string for message with no main text or empty content', () => { + // Test case 1: No blocks at all + const testMessageNoBlocks = createMessage({ role: 'user', id: 'empty_msg_plain' }, []) + ;(markdownToPlainText as any).mockReturnValue('') + + const result1 = messageToPlainText(testMessageNoBlocks) + expect(result1).toBe('') + expect(markdownToPlainText).toHaveBeenCalledWith('') + + // Test case 2: Block exists but content is empty + const testMessageEmptyContent = createMessage({ role: 'user', id: 'empty_content_msg' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '' } + ]) + + const result2 = messageToPlainText(testMessageEmptyContent) + expect(result2).toBe('') + expect(markdownToPlainText).toHaveBeenCalledWith('') + }) + + it('should handle special characters in message content', () => { + const testMessage = createMessage({ role: 'user', id: 'special_chars_msg' }, [ + { type: MessageBlockType.MAIN_TEXT, content: 'Text with "quotes" & and &entities;' } + ]) + ;(markdownToPlainText as any).mockImplementation((str: string) => str) const result = messageToPlainText(testMessage) - expect(result).toBe('') - expect(markdownToPlainText).toHaveBeenCalledWith('') + expect(result).toBe('Text with "quotes" & and &entities;') + expect(markdownToPlainText).toHaveBeenCalledWith('Text with "quotes" & and &entities;') + }) + + it('should handle messages with markdown formatting', () => { + const testMessage = createMessage({ role: 'user', id: 'markdown_msg' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '# Header\n**Bold** and *italic* text\n- List item' } + ]) + ;(markdownToPlainText as any).mockImplementation((str: string) => + str.replace(/[#*_]/g, '').replace(/^- /gm, '').replace(/\n+/g, '\n').trim() + ) + + const result = messageToPlainText(testMessage) + expect(result).toBe('Header\nBold and italic text\nList item') + expect(markdownToPlainText).toHaveBeenCalledWith('# Header\n**Bold** and *italic* text\n- List item') }) }) @@ -472,7 +517,7 @@ describe('export', () => { updatedAt: '' } ;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] }) - ;(markdownToPlainText as any).mockImplementation((str) => str) // Pass-through + ;(markdownToPlainText as any).mockImplementation((str: string) => str) // Pass-through const plainText = await topicToPlainText(testTopic) expect(plainText).toBe('Multi Plain Formatted\n\nUser:\nMsg1 Formatted\n\nAssistant:\nMsg2 Formatted') @@ -492,6 +537,46 @@ describe('export', () => { })) }) + it('should handle empty content in topic messages', async () => { + const msgWithEmpty = createMessage({ role: 'user', id: 'empty_content' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '' } + ]) + const testTopic: Topic = { + id: 'topic_empty_content', + name: 'Topic with empty content', + assistantId: 'asst_test', + messages: [msgWithEmpty] as any, + createdAt: '', + updatedAt: '' + } + ;(db.topics.get as any).mockResolvedValue({ messages: [msgWithEmpty] }) + ;(markdownToPlainText as any).mockImplementation((str: string) => str) + + const result = await topicToPlainText(testTopic) + expect(result).toBe('Topic with empty content\n\nUser:\n') + }) + + it('should handle special characters in topic content', async () => { + const msgWithSpecial = createMessage({ role: 'user', id: 'special_chars' }, [ + { type: MessageBlockType.MAIN_TEXT, content: 'Content with "quotes" & and &entities;' } + ]) + const testTopic: Topic = { + id: 'topic_special_chars', + name: 'Topic with "quotes" & symbols', + assistantId: 'asst_test', + messages: [msgWithSpecial] as any, + createdAt: '', + updatedAt: '' + } + ;(db.topics.get as any).mockResolvedValue({ messages: [msgWithSpecial] }) + ;(markdownToPlainText as any).mockImplementation((str: string) => str) + + const result = await topicToPlainText(testTopic) + expect(markdownToPlainText).toHaveBeenCalledWith('Topic with "quotes" & symbols') + expect(markdownToPlainText).toHaveBeenCalledWith('Content with "quotes" & and &entities;') + expect(result).toContain('Content with "quotes" & and &entities;') + }) + it('should return plain text for a topic with messages', async () => { const msg1 = createMessage({ role: 'user', id: 'tp_u1' }, [ { type: MessageBlockType.MAIN_TEXT, content: '**Hello**' } @@ -508,7 +593,7 @@ describe('export', () => { updatedAt: '' } ;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] }) - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) const result = await topicToPlainText(testTopic) expect(db.topics.get).toHaveBeenCalledWith('topic1_plain') @@ -528,7 +613,7 @@ describe('export', () => { updatedAt: '' } ;(db.topics.get as any).mockResolvedValue({ messages: [] }) - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) const result = await topicToPlainText(testTopic) expect(result).toBe('Empty Topic') @@ -580,7 +665,7 @@ describe('export', () => { writeTextMock.mockReset() // Ensure markdownToPlainText mock is set - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) }) afterEach(() => {