mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 09:49:40 +02:00
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:
parent
c1ca93b2f1
commit
534b55b566
4 changed files with 371 additions and 20 deletions
114
apps/todo/apps/web/src/lib/stores/view.test.ts
Normal file
114
apps/todo/apps/web/src/lib/stores/view.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
210
apps/todo/apps/web/src/lib/utils/task-filters.test.ts
Normal file
210
apps/todo/apps/web/src/lib/utils/task-filters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
37
apps/todo/apps/web/src/lib/utils/task-filters.ts
Normal file
37
apps/todo/apps/web/src/lib/utils/task-filters.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue