From 534b55b566ed10cf1a0748d3dd51fb5583f513d0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 23 Mar 2026 21:04:21 +0100 Subject: [PATCH] test(todo-web): add tests for unified TaskFilters and viewStore filter state - Extract applyTaskFilters as pure utility function for testability - Add 21 tests for task filtering (priority, project, labels, search, combined) - Add 13 tests for viewStore filter methods (setters, clearFilters, reset) - All 87 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../todo/apps/web/src/lib/stores/view.test.ts | 114 ++++++++++ .../web/src/lib/utils/task-filters.test.ts | 210 ++++++++++++++++++ .../apps/web/src/lib/utils/task-filters.ts | 37 +++ .../apps/web/src/routes/(app)/+page.svelte | 30 +-- 4 files changed, 371 insertions(+), 20 deletions(-) create mode 100644 apps/todo/apps/web/src/lib/stores/view.test.ts create mode 100644 apps/todo/apps/web/src/lib/utils/task-filters.test.ts create mode 100644 apps/todo/apps/web/src/lib/utils/task-filters.ts diff --git a/apps/todo/apps/web/src/lib/stores/view.test.ts b/apps/todo/apps/web/src/lib/stores/view.test.ts new file mode 100644 index 000000000..338274c9b --- /dev/null +++ b/apps/todo/apps/web/src/lib/stores/view.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { viewStore } from './view.svelte'; + +describe('viewStore filter methods', () => { + beforeEach(() => { + viewStore.reset(); + }); + + // Filter state defaults + describe('initial state', () => { + it('has empty filter priorities', () => { + expect(viewStore.filterPriorities).toEqual([]); + }); + + it('has null filter project', () => { + expect(viewStore.filterProjectId).toBeNull(); + }); + + it('has empty filter labels', () => { + expect(viewStore.filterLabelIds).toEqual([]); + }); + + it('has empty filter search query', () => { + expect(viewStore.filterSearchQuery).toBe(''); + }); + }); + + // Setters + describe('setFilterPriorities', () => { + it('sets priorities', () => { + viewStore.setFilterPriorities(['high', 'urgent']); + expect(viewStore.filterPriorities).toEqual(['high', 'urgent']); + }); + + it('can set to empty array', () => { + viewStore.setFilterPriorities(['high']); + viewStore.setFilterPriorities([]); + expect(viewStore.filterPriorities).toEqual([]); + }); + }); + + describe('setFilterProjectId', () => { + it('sets project ID', () => { + viewStore.setFilterProjectId('proj-1'); + expect(viewStore.filterProjectId).toBe('proj-1'); + }); + + it('can clear to null', () => { + viewStore.setFilterProjectId('proj-1'); + viewStore.setFilterProjectId(null); + expect(viewStore.filterProjectId).toBeNull(); + }); + }); + + describe('setFilterLabelIds', () => { + it('sets label IDs', () => { + viewStore.setFilterLabelIds(['label-1', 'label-2']); + expect(viewStore.filterLabelIds).toEqual(['label-1', 'label-2']); + }); + }); + + describe('setFilterSearchQuery', () => { + it('sets search query', () => { + viewStore.setFilterSearchQuery('hello'); + expect(viewStore.filterSearchQuery).toBe('hello'); + }); + }); + + // clearFilters + describe('clearFilters', () => { + it('resets all filter state', () => { + viewStore.setFilterPriorities(['urgent']); + viewStore.setFilterProjectId('proj-1'); + viewStore.setFilterLabelIds(['label-1']); + viewStore.setFilterSearchQuery('test'); + + viewStore.clearFilters(); + + expect(viewStore.filterPriorities).toEqual([]); + expect(viewStore.filterProjectId).toBeNull(); + expect(viewStore.filterLabelIds).toEqual([]); + expect(viewStore.filterSearchQuery).toBe(''); + }); + + it('does not affect non-filter state', () => { + viewStore.setSort('priority', 'desc'); + viewStore.toggleShowCompleted(); + viewStore.setFilterPriorities(['high']); + + viewStore.clearFilters(); + + expect(viewStore.sortBy).toBe('priority'); + expect(viewStore.sortOrder).toBe('desc'); + expect(viewStore.showCompleted).toBe(true); + }); + }); + + // reset + describe('reset', () => { + it('resets filter state along with everything else', () => { + viewStore.setFilterPriorities(['urgent']); + viewStore.setFilterProjectId('proj-1'); + viewStore.setSort('title', 'desc'); + + viewStore.reset(); + + expect(viewStore.filterPriorities).toEqual([]); + expect(viewStore.filterProjectId).toBeNull(); + expect(viewStore.sortBy).toBe('order'); + expect(viewStore.sortOrder).toBe('asc'); + expect(viewStore.currentView).toBe('inbox'); + }); + }); +}); diff --git a/apps/todo/apps/web/src/lib/utils/task-filters.test.ts b/apps/todo/apps/web/src/lib/utils/task-filters.test.ts new file mode 100644 index 000000000..72d9e256b --- /dev/null +++ b/apps/todo/apps/web/src/lib/utils/task-filters.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; +import type { Task } from '@todo/shared'; +import { applyTaskFilters, type TaskFilterCriteria } from './task-filters'; + +// Helper to create a minimal task for testing +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + userId: 'user-1', + title: 'Test Task', + priority: 'medium', + status: 'pending', + isCompleted: false, + order: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +const emptyFilters: TaskFilterCriteria = { + priorities: [], + projectId: null, + labelIds: [], + searchQuery: '', +}; + +describe('applyTaskFilters', () => { + const tasks: Task[] = [ + makeTask({ id: '1', title: 'Buy groceries', priority: 'low', projectId: 'proj-a' }), + makeTask({ + id: '2', + title: 'Urgent meeting', + priority: 'urgent', + projectId: 'proj-b', + labels: [{ id: 'label-1', userId: 'user-1', name: 'Work', color: '#f00' }], + }), + makeTask({ + id: '3', + title: 'Write report', + priority: 'high', + projectId: 'proj-a', + description: 'Quarterly financial report', + labels: [ + { id: 'label-1', userId: 'user-1', name: 'Work', color: '#f00' }, + { id: 'label-2', userId: 'user-1', name: 'Important', color: '#0f0' }, + ], + }), + makeTask({ id: '4', title: 'Relax', priority: 'low', projectId: null }), + ]; + + it('returns all tasks when no filters are active', () => { + const result = applyTaskFilters(tasks, emptyFilters); + expect(result).toHaveLength(4); + }); + + it('returns empty array for empty input', () => { + const result = applyTaskFilters([], emptyFilters); + expect(result).toEqual([]); + }); + + // Priority filtering + describe('priority filter', () => { + it('filters by single priority', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, priorities: ['urgent'] }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + + it('filters by multiple priorities', () => { + const result = applyTaskFilters(tasks, { + ...emptyFilters, + priorities: ['low', 'high'], + }); + expect(result).toHaveLength(3); + expect(result.map((t) => t.id).sort()).toEqual(['1', '3', '4']); + }); + + it('returns nothing when priority matches no tasks', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, priorities: ['medium'] }); + expect(result).toHaveLength(0); + }); + }); + + // Project filtering + describe('project filter', () => { + it('filters by project ID', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, projectId: 'proj-a' }); + expect(result).toHaveLength(2); + expect(result.map((t) => t.id).sort()).toEqual(['1', '3']); + }); + + it('does not match tasks with null projectId when filtering', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, projectId: 'proj-b' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + + it('skips project filter when null', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, projectId: null }); + expect(result).toHaveLength(4); + }); + }); + + // Label filtering + describe('label filter', () => { + it('filters by single label', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, labelIds: ['label-1'] }); + expect(result).toHaveLength(2); + expect(result.map((t) => t.id).sort()).toEqual(['2', '3']); + }); + + it('filters by label that only one task has', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, labelIds: ['label-2'] }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('3'); + }); + + it('matches tasks having any of the filter labels (OR logic)', () => { + const result = applyTaskFilters(tasks, { + ...emptyFilters, + labelIds: ['label-1', 'label-2'], + }); + expect(result).toHaveLength(2); + }); + + it('excludes tasks with no labels', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, labelIds: ['label-1'] }); + // Tasks 1 and 4 have no labels + expect(result.find((t) => t.id === '1')).toBeUndefined(); + expect(result.find((t) => t.id === '4')).toBeUndefined(); + }); + }); + + // Search query filtering + describe('search query filter', () => { + it('filters by title match (case insensitive)', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, searchQuery: 'urgent' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + + it('filters by description match', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, searchQuery: 'quarterly' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('3'); + }); + + it('is case insensitive', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, searchQuery: 'BUY' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('ignores whitespace-only query', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, searchQuery: ' ' }); + expect(result).toHaveLength(4); + }); + + it('matches partial strings', () => { + const result = applyTaskFilters(tasks, { ...emptyFilters, searchQuery: 'rep' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('3'); + }); + }); + + // Combined filters + describe('combined filters', () => { + it('applies priority + project filter together (AND)', () => { + const result = applyTaskFilters(tasks, { + ...emptyFilters, + priorities: ['low'], + projectId: 'proj-a', + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('applies priority + label filter together', () => { + const result = applyTaskFilters(tasks, { + ...emptyFilters, + priorities: ['high', 'urgent'], + labelIds: ['label-1'], + }); + expect(result).toHaveLength(2); + expect(result.map((t) => t.id).sort()).toEqual(['2', '3']); + }); + + it('applies all filters together', () => { + const result = applyTaskFilters(tasks, { + priorities: ['high'], + projectId: 'proj-a', + labelIds: ['label-1'], + searchQuery: 'report', + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('3'); + }); + + it('returns empty when combined filters contradict', () => { + const result = applyTaskFilters(tasks, { + priorities: ['urgent'], + projectId: 'proj-a', // task 2 is urgent but in proj-b + labelIds: [], + searchQuery: '', + }); + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/apps/todo/apps/web/src/lib/utils/task-filters.ts b/apps/todo/apps/web/src/lib/utils/task-filters.ts new file mode 100644 index 000000000..5e35bb232 --- /dev/null +++ b/apps/todo/apps/web/src/lib/utils/task-filters.ts @@ -0,0 +1,37 @@ +import type { Task, TaskPriority } from '@todo/shared'; + +export interface TaskFilterCriteria { + priorities: TaskPriority[]; + projectId: string | null; + labelIds: string[]; + searchQuery: string; +} + +/** + * Apply filter criteria to a list of tasks. + * Pure function — no store dependency — easy to test. + */ +export function applyTaskFilters(tasks: Task[], filters: TaskFilterCriteria): Task[] { + let filtered = tasks; + + if (filters.priorities.length > 0) { + filtered = filtered.filter((t) => filters.priorities.includes(t.priority)); + } + + if (filters.projectId) { + filtered = filtered.filter((t) => t.projectId === filters.projectId); + } + + if (filters.labelIds.length > 0) { + filtered = filtered.filter((t) => t.labels?.some((l) => filters.labelIds.includes(l.id))); + } + + if (filters.searchQuery.trim()) { + const q = filters.searchQuery.toLowerCase(); + filtered = filtered.filter( + (t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q) + ); + } + + return filtered; +} diff --git a/apps/todo/apps/web/src/routes/(app)/+page.svelte b/apps/todo/apps/web/src/routes/(app)/+page.svelte index f0e8dc919..44800c910 100644 --- a/apps/todo/apps/web/src/routes/(app)/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+page.svelte @@ -5,6 +5,7 @@ import { Sparkle, ArrowDown } from '@manacore/shared-icons'; import { tasksStore } from '$lib/stores/tasks.svelte'; import { viewStore } from '$lib/stores/view.svelte'; + import { applyTaskFilters } from '$lib/utils/task-filters'; import TaskList from '$lib/components/TaskList.svelte'; import CollapsibleSection from '$lib/components/CollapsibleSection.svelte'; import { TaskListSkeleton } from '$lib/components/skeletons'; @@ -12,27 +13,16 @@ let isLoading = $state(true); - // Apply viewStore filters to task lists + // Build filter criteria from viewStore (reactive) + let filterCriteria = $derived({ + priorities: viewStore.filterPriorities, + projectId: viewStore.filterProjectId, + labelIds: viewStore.filterLabelIds, + searchQuery: viewStore.filterSearchQuery, + }); + function applyFilters(tasks: Task[]): Task[] { - let filtered = tasks; - if (viewStore.filterPriorities.length > 0) { - filtered = filtered.filter((t) => viewStore.filterPriorities.includes(t.priority)); - } - if (viewStore.filterProjectId) { - filtered = filtered.filter((t) => t.projectId === viewStore.filterProjectId); - } - if (viewStore.filterLabelIds.length > 0) { - filtered = filtered.filter((t) => - t.labels?.some((l) => viewStore.filterLabelIds.includes(l.id)) - ); - } - if (viewStore.filterSearchQuery.trim()) { - const q = viewStore.filterSearchQuery.toLowerCase(); - filtered = filtered.filter( - (t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q) - ); - } - return filtered; + return applyTaskFilters(tasks, filterCriteria); } onMount(async () => {