diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts index 1ff74a9..127abb8 100644 --- a/__mocks__/obsidian.ts +++ b/__mocks__/obsidian.ts @@ -1,8 +1,80 @@ export const requestUrl = jest.fn(); +// Obsidian API functions +export const getFrontMatterInfo = jest.fn((content: string) => { + const match = content.match(/^---\n[\s\S]*?\n---\n/); + if (match) { + return { + exists: true, + frontmatter: match[0], + contentStart: match[0].length, + }; + } + return { + exists: false, + frontmatter: '', + contentStart: 0, + }; +}); + +export const parseFrontMatterStringArray = jest.fn((frontmatter: any, key: string) => { + if (!frontmatter || !frontmatter[key]) { + return null; + } + const value = frontmatter[key]; + if (Array.isArray(value)) { + return value; + } + return [value]; +}); + +export const getAllTags = jest.fn((cache: any) => { + if (!cache) return null; + const tags = new Set(); + + // Add frontmatter tags + if (cache.frontmatter && cache.frontmatter.tags) { + const frontmatterTags = Array.isArray(cache.frontmatter.tags) + ? cache.frontmatter.tags + : [cache.frontmatter.tags]; + frontmatterTags.forEach((tag: string) => tags.add(tag)); + } + + // Add inline tags (simulated) + if (cache.tags) { + cache.tags.forEach((tag: any) => tags.add(tag.tag)); + } + + return tags.size > 0 ? Array.from(tags) : null; +}); + +// MetadataCache mock +export class MetadataCache { + private fileCaches = new Map(); + + getFileCache = jest.fn((file: any) => { + return this.fileCaches.get(file) || null; + }); + + // Helper for testing + setFileCache(file: any, cache: any) { + this.fileCaches.set(file, cache); + } + + clearCache() { + this.fileCaches.clear(); + } +} + +// TFile mock +export class TFile { + constructor(public path: string, public basename: string) {} +} + // Basic App mock for components relying on Obsidian's App export class App { vault = { getMarkdownFiles: jest.fn().mockReturnValue([]) } as any; + metadataCache = new MetadataCache(); } export class FuzzySuggestModal { diff --git a/__tests__/api/UnifiedProvider.test.ts b/__tests__/api/UnifiedProvider.test.ts new file mode 100644 index 0000000..535b3de --- /dev/null +++ b/__tests__/api/UnifiedProvider.test.ts @@ -0,0 +1,203 @@ +import { UnifiedProvider } from 'api'; +import { ProviderConfig } from 'api/types'; +import { requestUrl } from 'obsidian'; +import { PROVIDER_NAMES } from 'utils'; + +jest.mock('obsidian'); + +describe('UnifiedProvider Tests', () => { + const unifiedProvider = new UnifiedProvider(); + + const mockConfig: ProviderConfig = { + name: '', + apiKey: 'test-key', + baseUrl: 'https://api.test.com', + models: [{ id: 'test-model', name: 'Test Model' }], + temperature: 0.7 + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Response parsing', () => { + it('should parse OpenAI format with nested JSON content', () => { + const openAIResponse = { + choices: [{ + message: { + content: '{"output":["tag1"],"reliability":0.9}' + } + }] + }; + expect(unifiedProvider.processApiResponse(openAIResponse)).toEqual({ + output: ['tag1'], + reliability: 0.9 + }); + }); + + it('should parse Anthropic tool_use format', () => { + const anthropicResponse = { + content: [{ + type: 'tool_use', + input: { output: ['tag2'], reliability: 0.8 } + }] + }; + expect(unifiedProvider.processApiResponse(anthropicResponse, PROVIDER_NAMES.ANTHROPIC)).toEqual({ + output: ['tag2'], + reliability: 0.8 + }); + }); + + it('should parse Gemini nested content format', () => { + const geminiResponse = { + candidates: [{ + content: { + parts: [{ + text: '{"output":["tag3"],"reliability":0.7}' + }] + } + }] + }; + expect(unifiedProvider.processApiResponse(geminiResponse, PROVIDER_NAMES.GEMINI)).toEqual({ + output: ['tag3'], + reliability: 0.7 + }); + }); + + it('should parse Ollama message format', () => { + const ollamaResponse = { + message: { + content: '{"output":["tag4"],"reliability":0.6}' + } + }; + expect(unifiedProvider.processApiResponse(ollamaResponse, PROVIDER_NAMES.OLLAMA)).toEqual({ + output: ['tag4'], + reliability: 0.6 + }); + }); + + it('should throw error for missing content in Gemini response', () => { + const invalidResponse = { candidates: [] }; + expect(() => unifiedProvider.processApiResponse(invalidResponse, PROVIDER_NAMES.GEMINI)) + .toThrow('Gemini response missing content'); + }); + + it('should throw error for missing content in Ollama response', () => { + const invalidResponse = { message: {} }; + expect(() => unifiedProvider.processApiResponse(invalidResponse, PROVIDER_NAMES.OLLAMA)) + .toThrow('Ollama response missing content'); + }); + + it('should throw error for invalid JSON structure in response', () => { + const invalidJsonResponse = { + choices: [{ + message: { + content: '{"invalid":"structure"}' + } + }] + }; + expect(() => unifiedProvider.processApiResponse(invalidJsonResponse)) + .toThrow('Invalid response structure: missing output array or reliability number'); + }); + + it('should throw error for malformed JSON in response', () => { + const malformedJsonResponse = { + choices: [{ + message: { + content: 'not valid json' + } + }] + }; + expect(() => unifiedProvider.processApiResponse(malformedJsonResponse)) + .toThrow(/Failed to parse API response/); + }); + }); + + describe('API routing integration', () => { + it('should handle Anthropic provider end-to-end', async () => { + const config: ProviderConfig = { + ...mockConfig, + name: PROVIDER_NAMES.ANTHROPIC + }; + + const mockResponse = { + status: 200, + json: { + content: [{ + type: 'tool_use', + input: { output: ['test'], reliability: 1.0 } + }] + } + }; + + (requestUrl as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await unifiedProvider.callAPI('system', 'user', config, 'model'); + + expect(requestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-api-key': 'test-key', + 'anthropic-version': '2023-06-01' + }) + }) + ); + expect(result).toEqual({ output: ['test'], reliability: 1.0 }); + }); + + it('should handle Gemini provider with API key in URL', async () => { + const config: ProviderConfig = { + ...mockConfig, + name: PROVIDER_NAMES.GEMINI + }; + + const mockResponse = { + status: 200, + json: { + candidates: [{ + content: { + parts: [{ + text: '{"output":["test"],"reliability":0.95}' + }] + } + }] + } + }; + + (requestUrl as jest.Mock).mockResolvedValueOnce(mockResponse); + + await unifiedProvider.callAPI('system', 'user', config, 'gemini-pro'); + + expect(requestUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining('models/gemini-pro:generateContent?key=test-key') + }) + ); + }); + + it('should handle temperature override in provider config', async () => { + const config: ProviderConfig = { + ...mockConfig, + name: PROVIDER_NAMES.OPENAI, + temperature: 0.3 + }; + + const mockResponse = { + status: 200, + json: { + choices: [{ + message: { content: '{"output":["test"],"reliability":1.0}' } + }] + } + }; + + (requestUrl as jest.Mock).mockResolvedValueOnce(mockResponse); + + await unifiedProvider.callAPI('system', 'user', config, 'model', 0.9); + + const callArgs = (requestUrl as jest.Mock).mock.calls[0][0]; + const bodyData = JSON.parse(callArgs.body); + expect(bodyData.temperature).toBe(0.3); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/index.test.ts b/__tests__/api/index.test.ts index a3ea1f8..9ccf1a4 100644 --- a/__tests__/api/index.test.ts +++ b/__tests__/api/index.test.ts @@ -1,23 +1,6 @@ -import { getProvider, sendRequest, UnifiedProvider } from 'api'; -import { COMMON_CONSTANTS } from 'api/constants'; -import { ProviderConfig, StructuredOutput } from 'api/types'; +import { sendRequest } from 'api'; import { requestUrl } from 'obsidian'; -import { PROVIDER_NAMES } from 'utils'; -// -------------------- getProvider Tests -------------------- -describe('getProvider', () => { - test('returns UnifiedProvider instance', () => { - expect(getProvider()).toBeInstanceOf(UnifiedProvider); - }); - - test('returns the same instance every time', () => { - const provider1 = getProvider(); - const provider2 = getProvider(); - expect(provider1).toBe(provider2); - }); -}); - -// -------------------- sendRequest Tests -------------------- describe('sendRequest', () => { const url = 'https://api.test.com'; const headers = { Authorization: 'token' }; @@ -27,11 +10,12 @@ describe('sendRequest', () => { jest.clearAllMocks(); }); - test('returns JSON for successful request', async () => { + it('should return JSON for successful request', async () => { const mockResponse = { status: 200, json: { ok: true } }; (requestUrl as jest.Mock).mockResolvedValueOnce(mockResponse); const result = await sendRequest(url, headers, body); + expect(requestUrl).toHaveBeenCalledWith({ url, method: 'POST', @@ -41,75 +25,64 @@ describe('sendRequest', () => { expect(result).toEqual(mockResponse.json); }); - test('throws server error when status >= 500', async () => { - (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 500, text: 'oops' }); + it('should throw server error when status is 500', async () => { + (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 500, text: 'Internal Server Error' }); + await expect(sendRequest(url, headers, body)).rejects.toThrow( - `Server error (HTTP 500) from ${url}: oops` + `Server error (HTTP 500) from ${url}: Internal Server Error` ); }); - test('throws client error when status >= 400', async () => { - (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 404, text: 'missing' }); + it('should throw server error when status is 503', async () => { + (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 503, text: 'Service Unavailable' }); + await expect(sendRequest(url, headers, body)).rejects.toThrow( - `Client error (HTTP 404) from ${url}: missing` + `Server error (HTTP 503) from ${url}: Service Unavailable` ); }); -}); -// ---------------- processAPIRequest & testModel Tests ---------------- - -describe('processAPIRequest and testModel', () => { - const providerConfig: ProviderConfig = { - name: PROVIDER_NAMES.OPENAI, - apiKey: 'k', - baseUrl: 'url', - models: [], - temperature: 0.7, - }; - - const mockCallAPI = jest.fn(); - let processAPIRequest: (typeof import('api/index'))['processAPIRequest']; - let testModel: (typeof import('api/index'))['testModel']; - - beforeAll(() => { - jest.resetModules(); - jest.doMock('api/UnifiedProvider', () => { - return { - UnifiedProvider: jest.fn().mockImplementation(() => ({ - callAPI: mockCallAPI, - buildHeaders: jest.fn(), - processApiResponse: jest.fn(), - })), - }; - }); - const apiIndex = require('api/index'); - processAPIRequest = apiIndex.processAPIRequest; - testModel = apiIndex.testModel; + it('should throw client error when status is 400', async () => { + (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 400, text: 'Bad Request' }); + + await expect(sendRequest(url, headers, body)).rejects.toThrow( + `Client error (HTTP 400) from ${url}: Bad Request` + ); }); - beforeEach(() => { - mockCallAPI.mockClear(); + it('should throw client error when status is 401', async () => { + (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 401, text: 'Unauthorized' }); + + await expect(sendRequest(url, headers, body)).rejects.toThrow( + `Client error (HTTP 401) from ${url}: Unauthorized` + ); }); - test('processAPIRequest delegates to provider', async () => { - const expected: StructuredOutput = { output: ['tag'], reliability: 1 }; - mockCallAPI.mockResolvedValueOnce(expected); + it('should throw client error when status is 404', async () => { + (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 404, text: 'Not Found' }); - const result = await processAPIRequest('sys', 'prompt', providerConfig, 'model'); - expect(mockCallAPI).toHaveBeenCalledWith('sys', 'prompt', providerConfig, 'model'); - expect(result).toEqual(expected); + await expect(sendRequest(url, headers, body)).rejects.toThrow( + `Client error (HTTP 404) from ${url}: Not Found` + ); }); - test('testModel uses verification prompts', async () => { - mockCallAPI.mockResolvedValueOnce({}); - const success = await testModel(providerConfig, 'model'); + it('should throw client error when status is 429', async () => { + (requestUrl as jest.Mock).mockResolvedValueOnce({ status: 429, text: 'Rate Limit Exceeded' }); - expect(success).toBe(true); - expect(mockCallAPI).toHaveBeenCalledWith( - COMMON_CONSTANTS.VERIFY_CONNECTION_SYSTEM_PROMPT, - COMMON_CONSTANTS.VERIFY_CONNECTION_USER_PROMPT, - providerConfig, - 'model' + await expect(sendRequest(url, headers, body)).rejects.toThrow( + `Client error (HTTP 429) from ${url}: Rate Limit Exceeded` ); }); + + it('should rethrow native errors from requestUrl', async () => { + const networkError = new Error('Network connection failed'); + (requestUrl as jest.Mock).mockRejectedValueOnce(networkError); + + await expect(sendRequest(url, headers, body)).rejects.toThrow('Network connection failed'); + }); + + it('should convert non-Error exceptions to Error', async () => { + (requestUrl as jest.Mock).mockRejectedValueOnce('string error'); + + await expect(sendRequest(url, headers, body)).rejects.toThrow('string error'); + }); }); diff --git a/__tests__/api/prompt.test.ts b/__tests__/api/prompt.test.ts new file mode 100644 index 0000000..a2158bd --- /dev/null +++ b/__tests__/api/prompt.test.ts @@ -0,0 +1,49 @@ +import { getPromptTemplate } from '../../src/api/prompt'; + +describe('api/prompt', () => { + describe('getPromptTemplate', () => { + const defaultCount = { min: 1, max: 5 }; + const defaultInput = 'Test content to classify'; + const defaultReference = ['category1', 'category2', 'category3']; + const defaultCustomQuery = 'Custom classification context'; + + it('should replace all placeholders with provided values', () => { + const count = { min: 2, max: 10 }; + const result = getPromptTemplate(count, defaultInput, defaultReference, defaultCustomQuery); + + expect(result).toContain('2'); + expect(result).toContain('10'); + expect(result).toContain('category1, category2, category3'); + expect(result).toContain(defaultInput); + expect(result).toContain(defaultCustomQuery); + expect(result).not.toContain('{minCount}'); + expect(result).not.toContain('{maxCount}'); + expect(result).not.toContain('{reference}'); + expect(result).not.toContain('{input}'); + expect(result).not.toContain('{customQuery}'); + }); + + it('should use custom template when provided', () => { + const customTemplate = 'My custom instructions'; + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery, customTemplate); + + expect(result).toContain('My custom instructions'); + expect(result).toContain(''); + expect(result).not.toContain(''); + }); + + it('should handle empty reference array', () => { + const result = getPromptTemplate(defaultCount, defaultInput, [], defaultCustomQuery); + + expect(result).toContain(''); + expect(result).toContain(defaultInput); + }); + + it('should handle special characters in input', () => { + const specialInput = 'Content with and {braces} and "quotes"'; + const result = getPromptTemplate(defaultCount, specialInput, defaultReference, defaultCustomQuery); + + expect(result).toContain(specialInput); + }); + }); +}); diff --git a/__tests__/api/providers/unified.test.ts b/__tests__/api/providers/unified.test.ts deleted file mode 100644 index 9607c6a..0000000 --- a/__tests__/api/providers/unified.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { UnifiedProvider } from 'api'; -import { ProviderConfig } from 'api/types'; -import { requestUrl } from 'obsidian'; -import { PROVIDER_NAMES } from 'utils'; - -jest.mock('obsidian'); - -describe('UnifiedProvider Tests', () => { - const unifiedProvider = new UnifiedProvider(); - - const mockConfig: ProviderConfig = { - name: '', - apiKey: 'test-key', - baseUrl: 'https://api.test.com', - models: [{ id: 'test-model', name: 'Test Model' }], - temperature: 0.7 - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Provider-specific behavior', () => { - test('uses correct headers for each provider', () => { - // OpenAI/Default - const defaultHeaders = unifiedProvider.buildHeaders('test-key'); - expect(defaultHeaders).toHaveProperty('Authorization', 'Bearer test-key'); - - // Anthropic - const anthropicHeaders = unifiedProvider.buildHeaders('test-key', PROVIDER_NAMES.ANTHROPIC); - expect(anthropicHeaders).toHaveProperty('x-api-key', 'test-key'); - expect(anthropicHeaders).toHaveProperty('anthropic-version'); - - // Gemini (no API key in headers) - const geminiHeaders = unifiedProvider.buildHeaders('test-key', PROVIDER_NAMES.GEMINI); - expect(geminiHeaders).not.toHaveProperty('Authorization'); - }); - - test('correctly parses responses for each provider', () => { - // OpenAI/Default format - const openAIResponse = { - choices: [{ - message: { - content: '{"output":["tag1"],"reliability":0.9}' - } - }] - }; - expect(unifiedProvider.processApiResponse(openAIResponse)).toEqual({ - output: ['tag1'], - reliability: 0.9 - }); - - // Anthropic format - const anthropicResponse = { - content: [{ - type: 'tool_use', - input: { output: ['tag2'], reliability: 0.8 } - }] - }; - expect(unifiedProvider.processApiResponse(anthropicResponse, PROVIDER_NAMES.ANTHROPIC)).toEqual({ - output: ['tag2'], - reliability: 0.8 - }); - - // Gemini format - const geminiResponse = { - candidates: [{ - content: { - parts: [{ - text: '{"output":["tag3"],"reliability":0.7}' - }] - } - }] - }; - expect(unifiedProvider.processApiResponse(geminiResponse, PROVIDER_NAMES.GEMINI)).toEqual({ - output: ['tag3'], - reliability: 0.7 - }); - }); - }); - - describe('API call routing', () => { - test.each([ - PROVIDER_NAMES.OPENAI, - PROVIDER_NAMES.ANTHROPIC, - PROVIDER_NAMES.GEMINI, - PROVIDER_NAMES.OLLAMA, - PROVIDER_NAMES.OPENROUTER, - PROVIDER_NAMES.DEEPSEEK, - PROVIDER_NAMES.LMSTUDIO, - 'Custom' - ])('correctly routes %s provider', async (providerName) => { - const config: ProviderConfig = { - ...mockConfig, - name: providerName - }; - - const mockResponse = { - status: 200, - json: providerName === PROVIDER_NAMES.ANTHROPIC ? { - content: [{ - type: 'tool_use', - input: { output: ['test'], reliability: 1.0 } - }] - } : providerName === PROVIDER_NAMES.GEMINI ? { - candidates: [{ - content: { - parts: [{ - text: '{"output":["test"],"reliability":1.0}' - }] - } - }] - } : providerName === PROVIDER_NAMES.OLLAMA ? { - message: { - content: '{"output":["test"],"reliability":1.0}' - } - } : { - choices: [{ - message: { - content: '{"output":["test"],"reliability":1.0}' - } - }] - } - }; - - (requestUrl as jest.Mock).mockResolvedValueOnce(mockResponse); - - const result = await unifiedProvider.callAPI( - 'system', - 'user', - config, - 'model' - ); - - expect(result).toEqual({ - output: ['test'], - reliability: 1.0 - }); - }); - }); -}); \ No newline at end of file diff --git a/__tests__/frontmatter/index.test.ts b/__tests__/frontmatter/index.test.ts new file mode 100644 index 0000000..6988ba6 --- /dev/null +++ b/__tests__/frontmatter/index.test.ts @@ -0,0 +1,283 @@ +import { + getContentWithoutFrontmatter, + getFieldValues, + getFrontmatterSetting, + insertToFrontMatter, +} from 'frontmatter'; +import type { FrontmatterField, InsertFrontMatterParams } from 'frontmatter/types'; +import { getAllTags, getFrontMatterInfo, MetadataCache, parseFrontMatterStringArray, TFile } from 'obsidian'; + +// -------------------- getContentWithoutFrontmatter Tests -------------------- +describe('getContentWithoutFrontmatter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('removes frontmatter from content with frontmatter', () => { + const content = `--- +title: Test Note +tags: [test, example] +--- +This is the actual content.`; + + const result = getContentWithoutFrontmatter(content); + expect(result).toBe('This is the actual content.'); + expect(getFrontMatterInfo).toHaveBeenCalledWith(content); + }); +}); + +// -------------------- getFrontmatterSetting Tests -------------------- +describe('getFrontmatterSetting', () => { + const mockSettings: FrontmatterField[] = [ + { + id: 1, + name: 'tags', + count: { min: 1, max: 5 }, + refs: ['#tag1', '#tag2'], + overwrite: false, + linkType: 'Normal', + customQuery: '', + }, + { + id: 2, + name: 'category', + count: { min: 1, max: 1 }, + refs: ['cat1', 'cat2'], + overwrite: true, + linkType: 'WikiLink', + customQuery: 'custom query text', + }, + ]; + + test('returns correct setting when ID exists', () => { + expect(getFrontmatterSetting(1, mockSettings)).toEqual(mockSettings[0]); + }); + + test('throws error when setting not found', () => { + expect(() => getFrontmatterSetting(999, mockSettings)).toThrow('Setting not found'); + }); +}); + +// -------------------- getFieldValues Tests -------------------- +describe('getFieldValues', () => { + let metadataCache: MetadataCache; + let file1: TFile; + let file2: TFile; + let file3: TFile; + + beforeEach(() => { + jest.clearAllMocks(); + metadataCache = new MetadataCache(); + file1 = new TFile('note1.md', 'note1'); + file2 = new TFile('note2.md', 'note2'); + file3 = new TFile('note3.md', 'note3'); + }); + + test('collects unique values for regular frontmatter field', () => { + metadataCache.setFileCache(file1, { + frontmatter: { category: ['tech', 'programming'] }, + }); + metadataCache.setFileCache(file2, { + frontmatter: { category: ['tech', 'design'] }, + }); + metadataCache.setFileCache(file3, { + frontmatter: { category: 'programming' }, + }); + + const result = getFieldValues('category', [file1, file2, file3], metadataCache); + + expect(parseFrontMatterStringArray).toHaveBeenCalledTimes(3); + expect(result).toEqual(expect.arrayContaining(['tech', 'programming', 'design'])); + expect(result).toHaveLength(3); + }); + + test('handles tags field using getAllTags', () => { + metadataCache.setFileCache(file1, { + frontmatter: { tags: ['#tag1', '#tag2'] }, + tags: [{ tag: '#tag3' }], + }); + metadataCache.setFileCache(file2, { + frontmatter: { tags: '#tag2' }, + tags: [{ tag: '#tag4' }], + }); + + const result = getFieldValues('tags', [file1, file2], metadataCache); + + expect(getAllTags).toHaveBeenCalledTimes(2); + expect(parseFrontMatterStringArray).not.toHaveBeenCalled(); + expect(result.length).toBeGreaterThan(0); + }); + + test('skips files with no cache', () => { + metadataCache.setFileCache(file1, { + frontmatter: { category: 'tech' }, + }); + // file2 has no cache + + const result = getFieldValues('category', [file1, file2], metadataCache); + + expect(metadataCache.getFileCache).toHaveBeenCalledTimes(2); + expect(result).toEqual(['tech']); + }); + + test('skips files without the specified field', () => { + metadataCache.setFileCache(file1, { + frontmatter: { category: 'tech' }, + }); + metadataCache.setFileCache(file2, { + frontmatter: { other: 'value' }, + }); + + const result = getFieldValues('category', [file1, file2], metadataCache); + + expect(result).toEqual(['tech']); + }); +}); + +// -------------------- insertToFrontMatter Tests -------------------- +describe('insertToFrontMatter', () => { + let mockProcessFrontMatter: jest.Mock; + let mockFile: TFile; + + beforeEach(() => { + jest.clearAllMocks(); + mockProcessFrontMatter = jest.fn((file, callback) => { + const frontmatter = {}; + callback(frontmatter); + return Promise.resolve(); + }); + mockFile = new TFile('test.md', 'test'); + }); + + test('inserts values with Normal link type', async () => { + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'tags', + value: ['tag1', 'tag2'], + overwrite: false, + linkType: 'Normal', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + expect(mockProcessFrontMatter).toHaveBeenCalledWith(mockFile, expect.any(Function)); + + // Verify the callback modifies frontmatter correctly + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = {}; + callbackFn(testFrontmatter); + + expect(testFrontmatter.tags).toEqual(['tag1', 'tag2']); + }); + + test('inserts values with WikiLink format', async () => { + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'references', + value: ['Page1', 'Page2'], + overwrite: false, + linkType: 'WikiLink', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = {}; + callbackFn(testFrontmatter); + + expect(testFrontmatter.references).toEqual(['[[Page1]]', '[[Page2]]']); + }); + + test('overwrites existing values when overwrite is true', async () => { + mockProcessFrontMatter = jest.fn((file, callback) => { + const frontmatter = { tags: ['old1', 'old2'] }; + callback(frontmatter); + return Promise.resolve(); + }); + + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'tags', + value: ['new1', 'new2'], + overwrite: true, + linkType: 'Normal', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = { tags: ['old1', 'old2'] }; + callbackFn(testFrontmatter); + + expect(testFrontmatter.tags).toEqual(['new1', 'new2']); + expect(testFrontmatter.tags).not.toContain('old1'); + }); + + test('appends values when overwrite is false', async () => { + mockProcessFrontMatter = jest.fn((file, callback) => { + const frontmatter = { tags: ['existing'] }; + callback(frontmatter); + return Promise.resolve(); + }); + + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'tags', + value: ['new'], + overwrite: false, + linkType: 'Normal', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = { tags: ['existing'] }; + callbackFn(testFrontmatter); + + expect(testFrontmatter.tags).toEqual(['existing', 'new']); + }); + + test('removes duplicate values', async () => { + mockProcessFrontMatter = jest.fn((file, callback) => { + const frontmatter = { tags: ['tag1', 'tag2'] }; + callback(frontmatter); + return Promise.resolve(); + }); + + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'tags', + value: ['tag2', 'tag3'], + overwrite: false, + linkType: 'Normal', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = { tags: ['tag1', 'tag2'] }; + callbackFn(testFrontmatter); + + expect(testFrontmatter.tags).toEqual(['tag1', 'tag2', 'tag3']); + expect(testFrontmatter.tags).toHaveLength(3); + }); + + test('filters out empty strings', async () => { + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'tags', + value: ['tag1', '', 'tag2', ''], + overwrite: false, + linkType: 'Normal', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = {}; + callbackFn(testFrontmatter); + + expect(testFrontmatter.tags).toEqual(['tag1', 'tag2']); + expect(testFrontmatter.tags).not.toContain(''); + }); +}); diff --git a/__tests__/ui/components/commonButton.test.ts b/__tests__/ui/components/common/CommonButton.test.ts similarity index 100% rename from __tests__/ui/components/commonButton.test.ts rename to __tests__/ui/components/common/CommonButton.test.ts diff --git a/__tests__/ui/components/commonNotice.test.ts b/__tests__/ui/components/common/CommonNotice.test.ts similarity index 100% rename from __tests__/ui/components/commonNotice.test.ts rename to __tests__/ui/components/common/CommonNotice.test.ts diff --git a/__tests__/ui/containers/apiContainer.test.ts b/__tests__/ui/containers/Api.test.ts similarity index 100% rename from __tests__/ui/containers/apiContainer.test.ts rename to __tests__/ui/containers/Api.test.ts diff --git a/__tests__/ui/containers/frontmatterContainer.test.ts b/__tests__/ui/containers/Frontmatter.test.ts similarity index 100% rename from __tests__/ui/containers/frontmatterContainer.test.ts rename to __tests__/ui/containers/Frontmatter.test.ts diff --git a/__tests__/ui/containers/tagContainer.test.ts b/__tests__/ui/containers/Tag.test.ts similarity index 100% rename from __tests__/ui/containers/tagContainer.test.ts rename to __tests__/ui/containers/Tag.test.ts diff --git a/__tests__/utils/index.test.ts b/__tests__/utils/index.test.ts new file mode 100644 index 0000000..8d77104 --- /dev/null +++ b/__tests__/utils/index.test.ts @@ -0,0 +1,77 @@ +import { + generateId, + getProviderPreset, + findMatchingPreset, +} from 'utils'; + +// -------------------- generateId Tests -------------------- +describe('generateId', () => { + test('generates unique IDs based on timestamp', async () => { + const id1 = generateId(); + await new Promise((resolve) => setTimeout(resolve, 10)); + const id2 = generateId(); + + expect(id2).toBeGreaterThan(id1); + }); +}); + +// -------------------- getProviderPreset Tests -------------------- +describe('getProviderPreset', () => { + test('returns correct preset for valid provider name', () => { + const preset = getProviderPreset('OpenAI'); + + expect(preset.name).toBe('OpenAI'); + expect(preset.baseUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(preset.apiKeyRequired).toBe(true); + }); + + test('throws error when provider name does not exist', () => { + expect(() => getProviderPreset('NonExistentProvider')).toThrow( + 'Provider preset not found: NonExistentProvider' + ); + }); + + test('throws error for case-sensitive provider names', () => { + expect(() => getProviderPreset('openai')).toThrow(/Provider preset not found/); + }); +}); + +// -------------------- findMatchingPreset Tests -------------------- +describe('findMatchingPreset', () => { + test('matches provider by exact baseUrl', () => { + const config = { + baseUrl: 'https://api.openai.com/v1/chat/completions', + name: 'SomeOtherName', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('OpenAI'); + }); + + test('matches provider by exact name', () => { + const config = { + baseUrl: 'https://some-other-url.com', + name: 'Anthropic', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Anthropic'); + }); + + test('prioritizes baseUrl match over name match', () => { + const config = { + baseUrl: 'https://api.openai.com/v1/chat/completions', + name: 'Anthropic', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('OpenAI'); + }); + + test('returns "Custom Provider" when no match found', () => { + expect(findMatchingPreset({ baseUrl: '', name: '' })).toBe('Custom Provider'); + expect(findMatchingPreset({ baseUrl: 'https://custom.com', name: 'UnknownProvider' })).toBe('Custom Provider'); + expect(findMatchingPreset({ baseUrl: 'https://custom.com', name: 'openai' })).toBe('Custom Provider'); + expect(findMatchingPreset({ baseUrl: 'https://api.openai.com/v1/chat', name: 'Test' })).toBe('Custom Provider'); + }); +});