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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 21:04:21 +01:00
parent c1ca93b2f1
commit 534b55b566
4 changed files with 371 additions and 20 deletions

View file

@ -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');
});
});
});

View file

@ -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> = {}): 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);
});
});
});

View file

@ -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;
}

View file

@ -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 () => {