diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index 2206362..aa959ef 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -91,4 +91,35 @@ export class Setting { export function normalizePath(path: string): string { // Simple normalization for testing return path.replace(/\\/g, '/'); +} + +// Minimal implementation of Obsidian's getAllTags utility for tests +// Accepts a metadata cache-like object and returns a flat list of tags with leading '#' +export function getAllTags(cache: any): string[] { + if (!cache) return []; + const out = new Set(); + + // From cache.tags: [{ tag: '#foo' }, { tag: '#foo/bar' }] + if (Array.isArray(cache.tags)) { + for (const t of cache.tags) { + const raw = typeof t === 'string' ? t : t?.tag; + if (!raw) continue; + const norm = raw.startsWith('#') ? raw : `#${raw}`; + out.add(norm); + } + } + + // From frontmatter.tags: 'foo', ['foo', 'bar'], or ['#foo/bar'] + const fmTags = cache.frontmatter?.tags; + if (fmTags) { + const list = Array.isArray(fmTags) ? fmTags : [fmTags]; + for (const rawItem of list) { + if (!rawItem) continue; + const raw = String(rawItem).trim(); + const norm = raw.startsWith('#') ? raw : `#${raw}`; + out.add(norm); + } + } + + return Array.from(out); } \ No newline at end of file diff --git a/tests/search-tag-operator.test.ts b/tests/search-tag-operator.test.ts new file mode 100644 index 0000000..9fd7b13 --- /dev/null +++ b/tests/search-tag-operator.test.ts @@ -0,0 +1,103 @@ +import { ObsidianAPI } from '../src/utils/obsidian-api'; +import { App, TFile } from 'obsidian'; + +// Helper to create a mock TFile with required fields +function makeFile(path: string): TFile { + const f = Object.create(TFile.prototype); + Object.assign(f, { + path, + name: path.split('/').pop()!, + basename: path.split('/').pop()!.replace(/\.[^/.]+$/, ''), + extension: path.split('.').pop() || 'md', + stat: { size: 123, mtime: Date.now() } + }); + return f as TFile; +} + +// Create a minimal mock app for tag-based search +function makeMockApp(filesWithCaches: Array<{ file: TFile; cache: any }>): App { + const files = filesWithCaches.map((x) => x.file); + const cacheMap = new Map(filesWithCaches.map(({ file, cache }) => [file.path, cache])); + + const mockApp: any = { + vault: { + getFiles: jest.fn(() => files), + }, + metadataCache: { + getFileCache: jest.fn((file: TFile) => cacheMap.get(file.path)), + }, + }; + + return mockApp as App; +} + +describe('search tag: operator', () => { + test('matches files with exact tag and hierarchical children (no leading #)', async () => { + const f1 = makeFile('notes/alpha.md'); + const f2 = makeFile('notes/beta.md'); + const f3 = makeFile('notes/gamma.md'); + + const app = makeMockApp([ + { + file: f1, + cache: { + // content tags form + tags: [{ tag: '#foo' }], + }, + }, + { + file: f2, + cache: { + // hierarchical child should match tag:foo + tags: [{ tag: '#foo/bar' }], + }, + }, + { + file: f3, + cache: { + // different tag + tags: [{ tag: '#bar' }], + }, + }, + ]); + + const api = new ObsidianAPI(app); + + const res = await api.searchPaginated('tag:foo'); + const paths = res.results.map((r) => r.path).sort(); + + expect(paths).toEqual(['notes/alpha.md', 'notes/beta.md']); + }); + + test('matches with leading # and frontmatter tags', async () => { + const f1 = makeFile('notes/frontmatter.md'); + const f2 = makeFile('notes/other.md'); + + const app = makeMockApp([ + { + file: f1, + cache: { + frontmatter: { + tags: ['foo', 'x/y'], + }, + }, + }, + { + file: f2, + cache: { + frontmatter: { + tags: ['bar'], + }, + }, + }, + ]); + + const api = new ObsidianAPI(app); + + const res1 = await api.searchPaginated('tag:#foo'); + expect(res1.results.map((r) => r.path)).toEqual(['notes/frontmatter.md']); + + const res2 = await api.searchPaginated('tag:x'); + expect(res2.results.map((r) => r.path)).toEqual(['notes/frontmatter.md']); + }); +});