mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-01 17:59:09 +08:00
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
This commit is contained in:
parent
a567666c79
commit
a1304054ce
90
src/renderer/src/utils/__tests__/asyncInitializer.test.ts
Normal file
90
src/renderer/src/utils/__tests__/asyncInitializer.test.ts
Normal file
@ -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<string>((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)
|
||||
})
|
||||
})
|
||||
196
src/renderer/src/utils/__tests__/copy.test.ts
Normal file
196
src/renderer/src/utils/__tests__/copy.test.ts
Normal file
@ -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> = {}): 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> = {}): 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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" & <tags> 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" & <tags> and &entities;')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('Text with "quotes" & <tags> 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" & <tags> 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" & <tags> and &entities;')
|
||||
expect(result).toContain('Content with "quotes" & <tags> 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(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user