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:
Jason Young 2025-07-06 04:51:41 +08:00 committed by GitHub
parent a567666c79
commit a1304054ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 382 additions and 11 deletions

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

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

View File

@ -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(() => {