diff --git a/src/lib/utils/filterEntries.test.ts b/src/lib/utils/filterEntries.test.ts new file mode 100644 index 0000000..f8513a1 --- /dev/null +++ b/src/lib/utils/filterEntries.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { filterEntries } from './filterEntries'; +import type { SearchFilters } from '$lib/types/search'; + +// Test data factory +function createEntry( + overrides: Partial<{ + id: string; + type: 'task' | 'thought'; + title: string | null; + content: string; + createdAt: string; + tags: Array<{ id: string; name: string; entryId: string }>; + }> = {} +) { + return { + id: overrides.id ?? 'entry-1', + type: overrides.type ?? 'task', + title: overrides.title ?? null, + content: overrides.content ?? 'Default content', + createdAt: overrides.createdAt ?? '2026-01-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + tags: overrides.tags ?? [] + }; +} + +function createFilters(overrides: Partial = {}): SearchFilters { + return { + query: overrides.query ?? '', + tags: overrides.tags ?? [], + type: overrides.type ?? 'all', + dateRange: overrides.dateRange ?? { start: null, end: null } + }; +} + +describe('filterEntries', () => { + describe('empty input', () => { + it('returns empty array when given empty entries', () => { + const result = filterEntries([], createFilters()); + expect(result).toEqual([]); + }); + }); + + describe('query filter', () => { + it('ignores query shorter than 2 characters', () => { + const entries = [createEntry({ content: 'Hello world' })]; + const result = filterEntries(entries, createFilters({ query: 'H' })); + expect(result).toHaveLength(1); + }); + + it('filters by content match (case insensitive)', () => { + const entries = [ + createEntry({ id: '1', content: 'Buy groceries' }), + createEntry({ id: '2', content: 'Write code' }), + createEntry({ id: '3', content: 'Buy books' }) + ]; + const result = filterEntries(entries, createFilters({ query: 'buy' })); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toEqual(['1', '3']); + }); + + it('filters by title match (case insensitive)', () => { + const entries = [ + createEntry({ id: '1', title: 'Shopping List', content: 'items' }), + createEntry({ id: '2', title: 'Work Notes', content: 'stuff' }), + createEntry({ id: '3', title: null, content: 'shopping reminder' }) + ]; + const result = filterEntries(entries, createFilters({ query: 'shopping' })); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toEqual(['1', '3']); + }); + + it('matches title OR content', () => { + const entries = [ + createEntry({ id: '1', title: 'Meeting', content: 'discuss project' }), + createEntry({ id: '2', title: 'Note', content: 'meeting notes' }) + ]; + const result = filterEntries(entries, createFilters({ query: 'meeting' })); + expect(result).toHaveLength(2); + }); + }); + + describe('tag filter', () => { + it('filters entries with matching tag', () => { + const entries = [ + createEntry({ + id: '1', + tags: [{ id: 't1', name: 'work', entryId: '1' }] + }), + createEntry({ + id: '2', + tags: [{ id: 't2', name: 'personal', entryId: '2' }] + }), + createEntry({ + id: '3', + tags: [ + { id: 't3', name: 'work', entryId: '3' }, + { id: 't4', name: 'urgent', entryId: '3' } + ] + }) + ]; + const result = filterEntries(entries, createFilters({ tags: ['work'] })); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toEqual(['1', '3']); + }); + + it('requires ALL tags (AND logic)', () => { + const entries = [ + createEntry({ + id: '1', + tags: [{ id: 't1', name: 'work', entryId: '1' }] + }), + createEntry({ + id: '2', + tags: [ + { id: 't2', name: 'work', entryId: '2' }, + { id: 't3', name: 'urgent', entryId: '2' } + ] + }), + createEntry({ + id: '3', + tags: [ + { id: 't4', name: 'work', entryId: '3' }, + { id: 't5', name: 'urgent', entryId: '3' }, + { id: 't6', name: 'meeting', entryId: '3' } + ] + }) + ]; + const result = filterEntries(entries, createFilters({ tags: ['work', 'urgent'] })); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toEqual(['2', '3']); + }); + + it('matches tags case-insensitively', () => { + const entries = [ + createEntry({ + id: '1', + tags: [{ id: 't1', name: 'Work', entryId: '1' }] + }) + ]; + const result = filterEntries(entries, createFilters({ tags: ['work'] })); + expect(result).toHaveLength(1); + }); + + it('returns empty for entries without any tags when tag filter active', () => { + const entries = [createEntry({ id: '1', tags: [] })]; + const result = filterEntries(entries, createFilters({ tags: ['work'] })); + expect(result).toHaveLength(0); + }); + }); + + describe('type filter', () => { + it('returns all types when filter is "all"', () => { + const entries = [ + createEntry({ id: '1', type: 'task' }), + createEntry({ id: '2', type: 'thought' }) + ]; + const result = filterEntries(entries, createFilters({ type: 'all' })); + expect(result).toHaveLength(2); + }); + + it('filters by task type', () => { + const entries = [ + createEntry({ id: '1', type: 'task' }), + createEntry({ id: '2', type: 'thought' }), + createEntry({ id: '3', type: 'task' }) + ]; + const result = filterEntries(entries, createFilters({ type: 'task' })); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toEqual(['1', '3']); + }); + + it('filters by thought type', () => { + const entries = [ + createEntry({ id: '1', type: 'task' }), + createEntry({ id: '2', type: 'thought' }) + ]; + const result = filterEntries(entries, createFilters({ type: 'thought' })); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + }); + + describe('date range filter', () => { + const entries = [ + createEntry({ id: '1', createdAt: '2026-01-10T10:00:00Z' }), + createEntry({ id: '2', createdAt: '2026-01-15T10:00:00Z' }), + createEntry({ id: '3', createdAt: '2026-01-20T10:00:00Z' }) + ]; + + it('filters by start date', () => { + const result = filterEntries( + entries, + createFilters({ dateRange: { start: '2026-01-15', end: null } }) + ); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toEqual(['2', '3']); + }); + + it('filters by end date (inclusive)', () => { + const result = filterEntries( + entries, + createFilters({ dateRange: { start: null, end: '2026-01-15' } }) + ); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id)).toEqual(['1', '2']); + }); + + it('filters by both start and end date', () => { + const result = filterEntries( + entries, + createFilters({ dateRange: { start: '2026-01-12', end: '2026-01-18' } }) + ); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + }); + + describe('combined filters', () => { + it('applies all filters together', () => { + const entries = [ + createEntry({ + id: '1', + type: 'task', + content: 'Buy groceries', + tags: [{ id: 't1', name: 'shopping', entryId: '1' }], + createdAt: '2026-01-15T10:00:00Z' + }), + createEntry({ + id: '2', + type: 'task', + content: 'Buy office supplies', + tags: [{ id: 't2', name: 'work', entryId: '2' }], + createdAt: '2026-01-15T10:00:00Z' + }), + createEntry({ + id: '3', + type: 'thought', + content: 'Buy a car someday', + tags: [{ id: 't3', name: 'shopping', entryId: '3' }], + createdAt: '2026-01-15T10:00:00Z' + }), + createEntry({ + id: '4', + type: 'task', + content: 'Buy groceries', + tags: [{ id: 't4', name: 'shopping', entryId: '4' }], + createdAt: '2026-01-01T10:00:00Z' // Too early + }) + ]; + + const result = filterEntries( + entries, + createFilters({ + query: 'buy', + tags: ['shopping'], + type: 'task', + dateRange: { start: '2026-01-10', end: null } + }) + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + }); + + describe('preserves entry type', () => { + it('preserves additional properties on entries', () => { + interface ExtendedEntry { + id: string; + type: 'task' | 'thought'; + title: string | null; + content: string; + createdAt: string; + updatedAt: string; + tags: Array<{ id: string; name: string; entryId: string }>; + images: Array<{ id: string; path: string }>; + } + + const entries: ExtendedEntry[] = [ + { + ...createEntry({ id: '1', content: 'Has image' }), + images: [{ id: 'img1', path: '/uploads/photo.jpg' }] + } + ]; + + const result = filterEntries(entries, createFilters({ query: 'image' })); + + expect(result).toHaveLength(1); + expect(result[0].images).toEqual([{ id: 'img1', path: '/uploads/photo.jpg' }]); + }); + }); +});