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/providers/unified.test.ts b/__tests__/api/UnifiedProvider.test.ts similarity index 100% rename from __tests__/api/providers/unified.test.ts rename to __tests__/api/UnifiedProvider.test.ts diff --git a/__tests__/api/prompt.test.ts b/__tests__/api/prompt.test.ts new file mode 100644 index 0000000..168c71f --- /dev/null +++ b/__tests__/api/prompt.test.ts @@ -0,0 +1,165 @@ +import { DEFAULT_SYSTEM_ROLE, DEFAULT_TASK_TEMPLATE, getPromptTemplate } from '../../src/api/prompt'; + +describe('api/prompt', () => { + describe('DEFAULT_SYSTEM_ROLE', () => { + it('should be a non-empty string', () => { + expect(typeof DEFAULT_SYSTEM_ROLE).toBe('string'); + expect(DEFAULT_SYSTEM_ROLE.length).toBeGreaterThan(0); + }); + + it('should contain JSON classification instructions', () => { + expect(DEFAULT_SYSTEM_ROLE).toContain('JSON'); + expect(DEFAULT_SYSTEM_ROLE).toContain('classification'); + }); + }); + + describe('DEFAULT_TASK_TEMPLATE', () => { + it('should be a non-empty string', () => { + expect(typeof DEFAULT_TASK_TEMPLATE).toBe('string'); + expect(DEFAULT_TASK_TEMPLATE.length).toBeGreaterThan(0); + }); + + it('should contain required sections', () => { + expect(DEFAULT_TASK_TEMPLATE).toContain(''); + expect(DEFAULT_TASK_TEMPLATE).toContain(''); + expect(DEFAULT_TASK_TEMPLATE).toContain(''); + expect(DEFAULT_TASK_TEMPLATE).toContain(''); + }); + + it('should contain example JSON structures', () => { + expect(DEFAULT_TASK_TEMPLATE).toContain('"output"'); + expect(DEFAULT_TASK_TEMPLATE).toContain('"reliability"'); + }); + }); + + 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 return a non-empty string', () => { + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should include the default task template', () => { + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should replace minCount placeholder', () => { + const count = { min: 2, max: 8 }; + const result = getPromptTemplate(count, defaultInput, defaultReference, defaultCustomQuery); + expect(result).toContain('2'); + expect(result).not.toContain('{minCount}'); + }); + + it('should replace maxCount placeholder', () => { + const count = { min: 1, max: 10 }; + const result = getPromptTemplate(count, defaultInput, defaultReference, defaultCustomQuery); + expect(result).toContain('10'); + expect(result).not.toContain('{maxCount}'); + }); + + it('should include reference categories', () => { + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery); + expect(result).toContain('category1'); + expect(result).toContain('category2'); + expect(result).toContain('category3'); + }); + + it('should include the input content', () => { + const input = 'Unique test content for classification'; + const result = getPromptTemplate(defaultCount, input, defaultReference, defaultCustomQuery); + expect(result).toContain(input); + }); + + it('should include the custom query', () => { + const customQuery = 'Specific classification rules for this context'; + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, customQuery); + expect(result).toContain(customQuery); + }); + + it('should use custom template when provided', () => { + const customTemplate = 'My custom template'; + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery, customTemplate); + expect(result).toContain(''); + expect(result).toContain('My custom template'); + }); + + it('should include output format section', () => { + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery); + expect(result).toContain(''); + expect(result).toContain('"output": string[]'); + expect(result).toContain('"reliability": number'); + }); + + it('should include reference categories section', () => { + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery); + expect(result).toContain(''); + }); + + it('should include content section', () => { + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, defaultCustomQuery); + expect(result).toContain(''); + }); + + it('should handle empty reference array', () => { + const result = getPromptTemplate(defaultCount, defaultInput, [], defaultCustomQuery); + expect(result).toContain(''); + expect(result).not.toContain('{reference}'); + }); + + it('should handle empty input string', () => { + const result = getPromptTemplate(defaultCount, '', defaultReference, defaultCustomQuery); + expect(result).toContain(''); + expect(result).not.toContain('{input}'); + }); + + it('should handle empty custom query', () => { + const result = getPromptTemplate(defaultCount, defaultInput, defaultReference, ''); + expect(result).toContain(''); + expect(result).not.toContain('{customQuery}'); + }); + + it('should handle special characters in input', () => { + const specialInput = 'Content with & "characters" and {braces}'; + const result = getPromptTemplate(defaultCount, specialInput, defaultReference, defaultCustomQuery); + expect(result).toContain(specialInput); + }); + + it('should handle multiline input', () => { + const multilineInput = 'Line 1\nLine 2\nLine 3'; + const result = getPromptTemplate(defaultCount, multilineInput, defaultReference, defaultCustomQuery); + expect(result).toContain('Line 1'); + expect(result).toContain('Line 2'); + expect(result).toContain('Line 3'); + }); + + it('should join reference categories with comma and space', () => { + const refs = ['cat1', 'cat2', 'cat3']; + const result = getPromptTemplate(defaultCount, defaultInput, refs, defaultCustomQuery); + expect(result).toContain('cat1, cat2, cat3'); + }); + + it('should handle single reference category', () => { + const result = getPromptTemplate(defaultCount, defaultInput, ['onlyOne'], defaultCustomQuery); + expect(result).toContain('onlyOne'); + }); + + it('should not modify input parameters', () => { + const count = { min: 1, max: 5 }; + const reference = ['a', 'b']; + const originalCount = { ...count }; + const originalReference = [...reference]; + + getPromptTemplate(count, defaultInput, reference, defaultCustomQuery); + + expect(count).toEqual(originalCount); + expect(reference).toEqual(originalReference); + }); + }); +}); diff --git a/__tests__/frontmatter/index.test.ts b/__tests__/frontmatter/index.test.ts new file mode 100644 index 0000000..7112afd --- /dev/null +++ b/__tests__/frontmatter/index.test.ts @@ -0,0 +1,498 @@ +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); + }); + + test('returns full content when no frontmatter exists', () => { + const content = 'This is content without frontmatter.'; + const result = getContentWithoutFrontmatter(content); + expect(result).toBe('This is content without frontmatter.'); + expect(getFrontMatterInfo).toHaveBeenCalledWith(content); + }); + + test('handles empty content', () => { + const content = ''; + const result = getContentWithoutFrontmatter(content); + expect(result).toBe(''); + expect(getFrontMatterInfo).toHaveBeenCalledWith(content); + }); + + test('handles content with only frontmatter', () => { + const content = `--- +title: Only Frontmatter +--- +`; + const result = getContentWithoutFrontmatter(content); + expect(result).toBe(''); + expect(getFrontMatterInfo).toHaveBeenCalledWith(content); + }); + + test('handles multiline content after frontmatter', () => { + const content = `--- +title: Test +--- +Line 1 +Line 2 +Line 3`; + const result = getContentWithoutFrontmatter(content); + expect(result).toBe('Line 1\nLine 2\nLine 3'); + }); +}); + +// -------------------- 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', + }, + { + id: 3, + name: 'author', + count: { min: 1, max: 3 }, + refs: [], + overwrite: false, + linkType: 'Normal', + customQuery: '', + }, + ]; + + test('returns correct setting when ID exists', () => { + const result = getFrontmatterSetting(2, mockSettings); + expect(result).toEqual(mockSettings[1]); + expect(result.name).toBe('category'); + expect(result.overwrite).toBe(true); + }); + + test('returns first setting for ID 1', () => { + const result = getFrontmatterSetting(1, mockSettings); + expect(result).toEqual(mockSettings[0]); + expect(result.name).toBe('tags'); + }); + + test('returns last setting for ID 3', () => { + const result = getFrontmatterSetting(3, mockSettings); + expect(result).toEqual(mockSettings[2]); + expect(result.name).toBe('author'); + }); + + test('throws error when ID does not exist', () => { + expect(() => getFrontmatterSetting(999, mockSettings)).toThrow('Setting not found'); + }); + + test('throws error when settings array is empty', () => { + expect(() => getFrontmatterSetting(1, [])).toThrow('Setting not found'); + }); + + test('throws error when settings is undefined', () => { + expect(() => getFrontmatterSetting(1, undefined as any)).toThrow('Setting not found'); + }); + + test('throws error when settings is null', () => { + expect(() => getFrontmatterSetting(1, null as any)).toThrow('Setting not found'); + }); + + test('returns setting with all field properties intact', () => { + const result = getFrontmatterSetting(2, mockSettings); + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('name'); + expect(result).toHaveProperty('count'); + expect(result).toHaveProperty('refs'); + expect(result).toHaveProperty('overwrite'); + expect(result).toHaveProperty('linkType'); + expect(result).toHaveProperty('customQuery'); + }); +}); + +// -------------------- 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('returns empty array when no files provided', () => { + const result = getFieldValues('category', [], metadataCache); + expect(result).toEqual([]); + }); + + 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']); + }); + + test('removes duplicate values across files', () => { + metadataCache.setFileCache(file1, { + frontmatter: { status: 'active' }, + }); + metadataCache.setFileCache(file2, { + frontmatter: { status: 'active' }, + }); + metadataCache.setFileCache(file3, { + frontmatter: { status: 'active' }, + }); + + const result = getFieldValues('status', [file1, file2, file3], metadataCache); + + expect(result).toEqual(['active']); + expect(result).toHaveLength(1); + }); + + test('handles single value and array values correctly', () => { + metadataCache.setFileCache(file1, { + frontmatter: { author: 'John' }, + }); + metadataCache.setFileCache(file2, { + frontmatter: { author: ['Jane', 'Bob'] }, + }); + + const result = getFieldValues('author', [file1, file2], metadataCache); + + expect(result).toEqual(expect.arrayContaining(['John', 'Jane', 'Bob'])); + expect(result).toHaveLength(3); + }); + + test('handles empty frontmatter', () => { + metadataCache.setFileCache(file1, { + frontmatter: {}, + }); + + const result = getFieldValues('category', [file1], metadataCache); + + expect(result).toEqual([]); + }); + + test('maintains unique values with Set behavior', () => { + metadataCache.setFileCache(file1, { + frontmatter: { labels: ['important', 'review', 'important'] }, + }); + + const result = getFieldValues('labels', [file1], metadataCache); + + // Set should deduplicate values + expect(result).toEqual(expect.arrayContaining(['important', 'review'])); + expect(result).toHaveLength(2); + }); +}); + +// -------------------- 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(''); + }); + + test('creates new field if it does not exist', async () => { + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'newfield', + value: ['value1', 'value2'], + overwrite: false, + linkType: 'Normal', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = {}; + callbackFn(testFrontmatter); + + expect(testFrontmatter.newfield).toEqual(['value1', 'value2']); + }); + + test('handles WikiLink with overwrite and deduplication', async () => { + mockProcessFrontMatter = jest.fn((file, callback) => { + const frontmatter = { refs: ['[[Page1]]', '[[Page2]]'] }; + callback(frontmatter); + return Promise.resolve(); + }); + + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'refs', + value: ['Page2', 'Page3'], + overwrite: false, + linkType: 'WikiLink', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = { refs: ['[[Page1]]', '[[Page2]]'] }; + callbackFn(testFrontmatter); + + expect(testFrontmatter.refs).toEqual(['[[Page1]]', '[[Page2]]', '[[Page3]]']); + }); + + test('returns Promise that resolves', async () => { + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'tags', + value: ['tag1'], + overwrite: false, + linkType: 'Normal', + }; + + const result = insertToFrontMatter(mockProcessFrontMatter, params); + + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toBeUndefined(); + }); + + test('handles empty value array', async () => { + const params: InsertFrontMatterParams = { + file: mockFile, + name: 'tags', + value: [], + overwrite: false, + linkType: 'Normal', + }; + + await insertToFrontMatter(mockProcessFrontMatter, params); + + const callbackFn = mockProcessFrontMatter.mock.calls[0][1]; + const testFrontmatter: any = {}; + callbackFn(testFrontmatter); + + expect(testFrontmatter.tags).toEqual([]); + }); +}); 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/components/commonButton.test.ts b/__tests__/ui/components/commonButton.test.ts deleted file mode 100644 index 06a6f0b..0000000 --- a/__tests__/ui/components/commonButton.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - CommonButton, - createButtonConfig, - createExtraButtonConfig, -} from 'ui/components/common/CommonButton'; -import { ButtonComponent, ExtraButtonComponent } from 'obsidian'; - -describe('CommonButton utilities', () => { - test('CommonButton configures ButtonComponent', () => { - const container = {} as HTMLElement; - const onClick = jest.fn(); - const props = { - text: 'Click', - icon: 'icon', - tooltip: 'tip', - onClick, - cta: true, - warning: true, - disabled: true, - className: 'cls', - }; - const button = CommonButton(container, props); - expect(button.setButtonText).toHaveBeenCalledWith('Click'); - expect(button.setIcon).toHaveBeenCalledWith('icon'); - expect(button.setTooltip).toHaveBeenCalledWith('tip'); - expect(button.setCta).toHaveBeenCalled(); - expect(button.setWarning).toHaveBeenCalled(); - expect(button.setDisabled).toHaveBeenCalledWith(true); - expect(button.buttonEl.addClass).toHaveBeenCalledWith('cls'); - expect(button.onClick).toHaveBeenCalledWith(onClick); - }); - - test('createButtonConfig sets button properties', () => { - const button = new ButtonComponent({} as HTMLElement); - createButtonConfig({ text: 'A', onClick: jest.fn() })(button); - expect(button.setButtonText).toHaveBeenCalledWith('A'); - }); - - test('createExtraButtonConfig sets extra button properties', () => { - const extra = new ExtraButtonComponent({} as HTMLElement); - createExtraButtonConfig({ - icon: 'x', - tooltip: 't', - onClick: jest.fn(), - disabled: true, - className: 'c', - })(extra); - expect(extra.setIcon).toHaveBeenCalledWith('x'); - expect(extra.setTooltip).toHaveBeenCalledWith('t'); - expect(extra.setDisabled).toHaveBeenCalledWith(true); - expect(extra.extraSettingsEl.addClass).toHaveBeenCalledWith('c'); - }); -}); diff --git a/__tests__/ui/containers/frontmatterContainer.test.ts b/__tests__/ui/containers/Frontmatter.test.ts similarity index 79% rename from __tests__/ui/containers/frontmatterContainer.test.ts rename to __tests__/ui/containers/Frontmatter.test.ts index e7ffb7e..9bad44c 100644 --- a/__tests__/ui/containers/frontmatterContainer.test.ts +++ b/__tests__/ui/containers/Frontmatter.test.ts @@ -45,37 +45,6 @@ describe('Frontmatter Container - Regression Tests', () => { jest.clearAllMocks(); }); - describe('Basic Rendering', () => { - test('renders frontmatter templates correctly', () => { - // Given: 3 frontmatter templates (id: 1,2,3) - expect(mockPlugin.settings.frontmatter).toHaveLength(3); - const templates = mockPlugin.settings.frontmatter; - - // When: display is called - frontmatter.display(); - - // Then: container should be emptied - expect(mockContainer.empty).toHaveBeenCalled(); - - // Verify each template's data is valid for rendering - templates.forEach((template: any) => { - expect(template.name).toBeDefined(); - expect(template.id).not.toBe(0); // Should not include id=0 templates - }); - }); - - test('handles empty template array without errors', () => { - // Given: empty frontmatter array - mockPlugin.settings.frontmatter = []; - - // When: display is called - expect(() => frontmatter.display()).not.toThrow(); - - // Then: should only empty container without errors - expect(mockContainer.empty).toHaveBeenCalled(); - }); - }); - describe('Template Management', () => { test('filters templates with id=0 in display (Critical Regression)', () => { // Given: templates including id=0 diff --git a/__tests__/ui/containers/tagContainer.test.ts b/__tests__/ui/containers/Tag.test.ts similarity index 81% rename from __tests__/ui/containers/tagContainer.test.ts rename to __tests__/ui/containers/Tag.test.ts index 5c06c99..e1aeaba 100644 --- a/__tests__/ui/containers/tagContainer.test.ts +++ b/__tests__/ui/containers/Tag.test.ts @@ -46,22 +46,6 @@ describe('Tag Container - Regression Tests', () => { jest.clearAllMocks(); }); - describe('Basic Rendering', () => { - test('renders DEFAULT_TAG_SETTING without delete button', () => { - // When: display is called (should not throw) - expect(() => tag.display()).not.toThrow(); - - // Verify the DEFAULT_TAG_SETTING properties are valid for display - expect(DEFAULT_TAG_SETTING.name).toBeDefined(); - expect(DEFAULT_TAG_SETTING.id).toBe(0); // Reserved id for Tag - - // Verify the tag setting structure is correct - expect(DEFAULT_TAG_SETTING.linkType).toBeDefined(); - expect(DEFAULT_TAG_SETTING.count).toBeDefined(); - expect(DEFAULT_TAG_SETTING.refs).toBeDefined(); - }); - }); - describe('Tag Setting Management', () => { test('updates id=0 template in handleEdit (Critical Regression)', async () => { // Given: DEFAULT_TAG_SETTING (id=0) diff --git a/__tests__/ui/containers/apiContainer.test.ts b/__tests__/ui/containers/apiContainer.test.ts deleted file mode 100644 index 3bde263..0000000 --- a/__tests__/ui/containers/apiContainer.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -jest.mock('api', () => ({ - testModel: jest.fn(), -})); - -jest.mock('ui/modals/ModelModal', () => ({ - ModelModal: class { - constructor( - public app: any, - public props: any - ) {} - open = jest.fn(); - }, -})); - -jest.mock('ui/modals/ProviderModal', () => ({ - ProviderModal: class { - constructor( - public app: any, - public onSave: any, - public provider?: any - ) {} - open = jest.fn(); - }, -})); - -import { testModel } from 'api'; -import { Api } from 'ui/containers/Api'; - -const mockTestModel = testModel as jest.MockedFunction; - -const createMockPlugin = () => ({ - app: { vault: { getMarkdownFiles: jest.fn() } }, - settings: { - providers: [ - { - name: 'openai', - models: [ - { name: 'gpt-4', displayName: 'GPT-4' }, - { name: 'gpt-3.5', displayName: 'GPT-3.5' }, - ], - }, - { - name: 'anthropic', - models: [{ name: 'claude-3', displayName: 'Claude 3' }], - }, - ], - selectedProvider: 'openai', - selectedModel: 'gpt-4', - classificationRule: 'default rule', - }, - saveSettings: jest.fn().mockResolvedValue(undefined), -}); - -const createMockContainer = () => ({ - empty: jest.fn(), - createEl: jest.fn().mockReturnValue({ - createEl: jest.fn().mockReturnValue({}), - }), - createDiv: jest.fn().mockReturnValue({ - style: {}, - }), -}); - -describe('Api Container - Regression Tests', () => { - let mockPlugin: any; - let mockContainer: any; - let api: Api; - - beforeEach(() => { - mockPlugin = createMockPlugin(); - mockContainer = createMockContainer(); - api = new Api(mockPlugin, mockContainer); - jest.clearAllMocks(); - }); - - describe('Basic Rendering', () => { - test('renders default settings correctly', () => { - // When: display is called - api.display(); - - // Then: All basic UI components should be created - expect(mockContainer.empty).toHaveBeenCalled(); - expect(mockContainer.createEl).toHaveBeenCalledWith('h2', { text: 'API Configuration' }); - expect(mockContainer.createEl).toHaveBeenCalledWith('div', { cls: 'provider-section' }); - expect(mockContainer.createEl).toHaveBeenCalledWith('div', { cls: 'model-section' }); - expect(mockContainer.createDiv).toHaveBeenCalledWith({ cls: 'custom-prompt-container' }); - }); - }); - - describe('Provider Management', () => { - test('resets selected settings when provider is deleted (Critical Regression)', async () => { - // When: openai provider deletion is simulated - mockPlugin.settings.providers = mockPlugin.settings.providers.filter( - (p: any) => p.name !== 'openai' - ); - mockPlugin.settings.selectedProvider = ''; - mockPlugin.settings.selectedModel = ''; - - // Then: selected settings should be reset - expect(mockPlugin.settings.selectedProvider).toBe(''); - expect(mockPlugin.settings.selectedModel).toBe(''); - expect(mockPlugin.settings.providers).toHaveLength(1); - }); - - test('preserves selected settings when non-selected provider is deleted', async () => { - // When: anthropic provider is deleted - mockPlugin.settings.providers = mockPlugin.settings.providers.filter( - (p: any) => p.name !== 'anthropic' - ); - - // Then: selected settings should be preserved - expect(mockPlugin.settings.selectedProvider).toBe('openai'); - expect(mockPlugin.settings.selectedModel).toBe('gpt-4'); - }); - }); - - describe('Model Management', () => { - test('updates provider when model is selected (Critical Regression)', async () => { - // When: Claude 3 model selection is simulated - mockPlugin.settings.selectedProvider = 'anthropic'; - mockPlugin.settings.selectedModel = 'claude-3'; - - // Then: provider should also be updated - expect(mockPlugin.settings.selectedProvider).toBe('anthropic'); - expect(mockPlugin.settings.selectedModel).toBe('claude-3'); - }); - - test('resets selected settings when model is deleted', async () => { - // When: gpt-4 model is deleted and settings reset - mockPlugin.settings.selectedProvider = ''; - mockPlugin.settings.selectedModel = ''; - - // Then: selected settings should be reset - expect(mockPlugin.settings.selectedProvider).toBe(''); - expect(mockPlugin.settings.selectedModel).toBe(''); - }); - }); - - describe('Connection Test', () => { - test('returns correct result from testModel function', async () => { - // Success case - mockTestModel.mockResolvedValue(true); - const successResult = await testModel(mockPlugin.settings.providers[0], 'gpt-4'); - expect(successResult).toBe(true); - - // Failure case - mockTestModel.mockResolvedValue(false); - const failureResult = await testModel(mockPlugin.settings.providers[0], 'gpt-4'); - expect(failureResult).toBe(false); - }); - }); - - describe('Classification Rule', () => { - test('updates classification rule correctly', () => { - // Test updating rule - mockPlugin.settings.classificationRule = 'new rule'; - expect(mockPlugin.settings.classificationRule).toBe('new rule'); - - // Test resetting rule - mockPlugin.settings.classificationRule = 'default template'; - expect(mockPlugin.settings.classificationRule).toBe('default template'); - }); - }); -}); diff --git a/__tests__/utils/index.test.ts b/__tests__/utils/index.test.ts new file mode 100644 index 0000000..d5ac94a --- /dev/null +++ b/__tests__/utils/index.test.ts @@ -0,0 +1,365 @@ +import { + PROVIDER_NAMES, + generateId, + getProviderPresets, + getProviderPreset, + findMatchingPreset, +} from 'utils'; +import type { ProviderPreset } from 'api/types'; +import providerPresetsData from '../../src/api/providerPreset.json'; + +// -------------------- PROVIDER_NAMES Tests -------------------- +describe('PROVIDER_NAMES', () => { + test('contains all expected provider names', () => { + expect(PROVIDER_NAMES.OPENAI).toBe(providerPresetsData.openai.name); + expect(PROVIDER_NAMES.ANTHROPIC).toBe(providerPresetsData.anthropic.name); + expect(PROVIDER_NAMES.OPENROUTER).toBe(providerPresetsData.openrouter.name); + expect(PROVIDER_NAMES.GEMINI).toBe(providerPresetsData.gemini.name); + expect(PROVIDER_NAMES.DEEPSEEK).toBe(providerPresetsData.deepseek.name); + expect(PROVIDER_NAMES.LMSTUDIO).toBe(providerPresetsData.lmstudio.name); + expect(PROVIDER_NAMES.OLLAMA).toBe(providerPresetsData.ollama.name); + expect(PROVIDER_NAMES.CUSTOM).toBe(providerPresetsData.custom.name); + }); + + test('has correct provider name values', () => { + expect(PROVIDER_NAMES.OPENAI).toBe('OpenAI'); + expect(PROVIDER_NAMES.ANTHROPIC).toBe('Anthropic'); + expect(PROVIDER_NAMES.OPENROUTER).toBe('OpenRouter'); + expect(PROVIDER_NAMES.GEMINI).toBe('Gemini'); + expect(PROVIDER_NAMES.DEEPSEEK).toBe('DeepSeek'); + expect(PROVIDER_NAMES.LMSTUDIO).toBe('LM Studio'); + expect(PROVIDER_NAMES.OLLAMA).toBe('Ollama'); + expect(PROVIDER_NAMES.CUSTOM).toBe('Custom Provider'); + }); + + test('is a readonly constant', () => { + expect(Object.isFrozen(PROVIDER_NAMES)).toBe(false); + const constantCheck = () => { + // TypeScript should prevent this at compile time + // @ts-expect-error - Testing immutability + PROVIDER_NAMES.OPENAI = 'Modified'; + }; + expect(constantCheck).toBeDefined(); + }); +}); + +// -------------------- generateId Tests -------------------- +describe('generateId', () => { + test('returns a number', () => { + const id = generateId(); + expect(typeof id).toBe('number'); + }); + + test('returns current timestamp', () => { + const beforeTime = Date.now(); + const id = generateId(); + const afterTime = Date.now(); + + expect(id).toBeGreaterThanOrEqual(beforeTime); + expect(id).toBeLessThanOrEqual(afterTime); + }); + + test('generates unique IDs when called multiple times', () => { + const id1 = generateId(); + const id2 = generateId(); + + // IDs should be different or equal if called at exact same millisecond + expect(id2).toBeGreaterThanOrEqual(id1); + }); + + test('generates increasing IDs over time', async () => { + const id1 = generateId(); + // Wait 10ms to ensure different timestamp + await new Promise((resolve) => setTimeout(resolve, 10)); + const id2 = generateId(); + + expect(id2).toBeGreaterThan(id1); + }); +}); + +// -------------------- getProviderPresets Tests -------------------- +describe('getProviderPresets', () => { + test('returns an array', () => { + const presets = getProviderPresets(); + expect(Array.isArray(presets)).toBe(true); + }); + + test('returns all provider presets', () => { + const presets = getProviderPresets(); + expect(presets.length).toBe(8); + }); + + test('contains expected provider names', () => { + const presets = getProviderPresets(); + const names = presets.map((p) => p.name); + + expect(names).toContain('OpenAI'); + expect(names).toContain('Anthropic'); + expect(names).toContain('OpenRouter'); + expect(names).toContain('Gemini'); + expect(names).toContain('DeepSeek'); + expect(names).toContain('LM Studio'); + expect(names).toContain('Ollama'); + expect(names).toContain('Custom Provider'); + }); + + test('each preset has required ProviderPreset properties', () => { + const presets = getProviderPresets(); + + presets.forEach((preset: ProviderPreset) => { + expect(preset).toHaveProperty('name'); + expect(preset).toHaveProperty('baseUrl'); + expect(preset).toHaveProperty('apiKeyUrl'); + expect(preset).toHaveProperty('apiKeyRequired'); + expect(preset).toHaveProperty('modelsList'); + expect(preset).toHaveProperty('temperature'); + expect(preset).toHaveProperty('popularModels'); + + expect(typeof preset.name).toBe('string'); + expect(typeof preset.baseUrl).toBe('string'); + expect(typeof preset.apiKeyUrl).toBe('string'); + expect(typeof preset.apiKeyRequired).toBe('boolean'); + expect(typeof preset.modelsList).toBe('string'); + expect(typeof preset.temperature).toBe('number'); + expect(Array.isArray(preset.popularModels)).toBe(true); + }); + }); + + test('does not include apiKey property', () => { + const presets = getProviderPresets(); + + presets.forEach((preset) => { + expect(preset).not.toHaveProperty('apiKey'); + }); + }); +}); + +// -------------------- getProviderPreset Tests -------------------- +describe('getProviderPreset', () => { + test('returns OpenAI preset when given "OpenAI"', () => { + 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('returns Anthropic preset when given "Anthropic"', () => { + const preset = getProviderPreset('Anthropic'); + + expect(preset.name).toBe('Anthropic'); + expect(preset.baseUrl).toBe('https://api.anthropic.com/v1/messages'); + expect(preset.apiKeyRequired).toBe(true); + }); + + test('returns Gemini preset when given "Gemini"', () => { + const preset = getProviderPreset('Gemini'); + + expect(preset.name).toBe('Gemini'); + expect(preset.baseUrl).toBe('https://generativelanguage.googleapis.com/v1beta'); + expect(preset.apiKeyRequired).toBe(true); + }); + + test('returns OpenRouter preset when given "OpenRouter"', () => { + const preset = getProviderPreset('OpenRouter'); + + expect(preset.name).toBe('OpenRouter'); + expect(preset.baseUrl).toBe('https://openrouter.ai/api/v1/chat/completions'); + expect(preset.apiKeyRequired).toBe(true); + }); + + test('returns DeepSeek preset when given "DeepSeek"', () => { + const preset = getProviderPreset('DeepSeek'); + + expect(preset.name).toBe('DeepSeek'); + expect(preset.baseUrl).toBe('https://api.deepseek.com/v1/chat/completions'); + expect(preset.apiKeyRequired).toBe(true); + }); + + test('returns LM Studio preset when given "LM Studio"', () => { + const preset = getProviderPreset('LM Studio'); + + expect(preset.name).toBe('LM Studio'); + expect(preset.baseUrl).toBe('http://localhost:1234/v1/chat/completions'); + expect(preset.apiKeyRequired).toBe(false); + }); + + test('returns Ollama preset when given "Ollama"', () => { + const preset = getProviderPreset('Ollama'); + + expect(preset.name).toBe('Ollama'); + expect(preset.baseUrl).toBe('http://localhost:11434/api/chat'); + expect(preset.apiKeyRequired).toBe(false); + }); + + test('returns Custom Provider preset when given "Custom Provider"', () => { + const preset = getProviderPreset('Custom Provider'); + + expect(preset.name).toBe('Custom Provider'); + expect(preset.baseUrl).toBe(''); + expect(preset.apiKeyRequired).toBe(false); + }); + + test('throws error when provider name does not exist', () => { + expect(() => getProviderPreset('NonExistentProvider')).toThrow( + 'Provider preset not found: NonExistentProvider' + ); + }); + + test('throws error with correct message for invalid provider', () => { + expect(() => getProviderPreset('InvalidProvider')).toThrow(/Provider preset not found/); + }); + + test('throws error for empty string provider name', () => { + expect(() => getProviderPreset('')).toThrow('Provider preset not found: '); + }); + + test('is case-sensitive for provider names', () => { + expect(() => getProviderPreset('openai')).toThrow(); + expect(() => getProviderPreset('OPENAI')).toThrow(); + }); + + test('returned preset includes popularModels array', () => { + const preset = getProviderPreset('OpenAI'); + + expect(Array.isArray(preset.popularModels)).toBe(true); + expect(preset.popularModels.length).toBeGreaterThan(0); + preset.popularModels.forEach((model) => { + expect(model).toHaveProperty('id'); + expect(model).toHaveProperty('name'); + }); + }); +}); + +// -------------------- 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', () => { + const config = { + baseUrl: 'https://unknown-provider.com', + name: 'UnknownProvider', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Custom Provider'); + }); + + test('returns "Custom Provider" for empty baseUrl and name', () => { + const config = { + baseUrl: '', + name: '', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Custom Provider'); + }); + + test('matches Gemini by baseUrl', () => { + const config = { + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + name: 'CustomName', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Gemini'); + }); + + test('matches DeepSeek by name', () => { + const config = { + baseUrl: 'https://custom-url.com', + name: 'DeepSeek', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('DeepSeek'); + }); + + test('matches Ollama by baseUrl', () => { + const config = { + baseUrl: 'http://localhost:11434/api/chat', + name: 'LocalAI', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Ollama'); + }); + + test('matches LM Studio by baseUrl', () => { + const config = { + baseUrl: 'http://localhost:1234/v1/chat/completions', + name: 'LocalModel', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('LM Studio'); + }); + + test('matches OpenRouter by name', () => { + const config = { + baseUrl: 'https://different-url.com', + name: 'OpenRouter', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('OpenRouter'); + }); + + test('is case-sensitive for matching', () => { + const config = { + baseUrl: 'https://custom.com', + name: 'openai', // lowercase + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Custom Provider'); + }); + + test('does not partially match baseUrl', () => { + const config = { + baseUrl: 'https://api.openai.com/v1/chat', // Missing '/completions' + name: 'Test', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Custom Provider'); + }); + + test('handles Custom Provider name explicitly', () => { + const config = { + baseUrl: 'https://custom.com', + name: 'Custom Provider', + }; + + const result = findMatchingPreset(config); + expect(result).toBe('Custom Provider'); + }); +});