From e68bb737e33bee4b05ea2cab0db9a71a91134b74 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 18 May 2026 15:18:33 +0200 Subject: [PATCH] refactor(filtering): consolidate task list filters into single strategy registry Replace the three drifting filter implementations (counter, list loader, regroup) with one ITaskListFilter strategy per list kind. Counter and list loader now share the same predicates, so they cannot diverge again. Planning hierarchy rules (parent-as-context, orphan handling) live in PlanningRules and are unit-tested via 29 new tests in ClaudeDo.Data.Tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- ClaudeDo.slnx | 1 + .../Filtering/Filters/ImportantFilter.cs | 12 +++ .../Filtering/Filters/MyDayFilter.cs | 12 +++ .../Filtering/Filters/PlannedFilter.cs | 12 +++ .../Filtering/Filters/QueuedFilter.cs | 14 +++ .../Filtering/Filters/ReviewFilter.cs | 14 +++ .../Filtering/Filters/RunningFilter.cs | 14 +++ .../Filtering/Filters/UserListFilter.cs | 24 +++++ .../Filtering/ITaskListFilter.cs | 26 +++++ src/ClaudeDo.Data/Filtering/PlanningRules.cs | 27 ++++++ .../Filtering/TaskListFilterRegistry.cs | 38 ++++++++ .../Islands/ListsIslandViewModel.cs | 33 ++----- .../Islands/TasksIslandViewModel.cs | 39 ++++---- .../ClaudeDo.Data.Tests.csproj | 27 ++++++ .../Filtering/PlanningRulesTests.cs | 49 ++++++++++ .../Filtering/SmartFilterTests.cs | 46 +++++++++ .../Filtering/TaskFactory.cs | 46 +++++++++ .../Filtering/TaskListFilterRegistryTests.cs | 39 ++++++++ .../Filtering/UserListFilterTests.cs | 31 ++++++ .../Filtering/VirtualFilterTests.cs | 97 +++++++++++++++++++ 20 files changed, 556 insertions(+), 45 deletions(-) create mode 100644 src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/ITaskListFilter.cs create mode 100644 src/ClaudeDo.Data/Filtering/PlanningRules.cs create mode 100644 src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs create mode 100644 tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj create mode 100644 tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs create mode 100644 tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs create mode 100644 tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs create mode 100644 tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs create mode 100644 tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs create mode 100644 tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx index eb6dbf9..ad80295 100644 --- a/ClaudeDo.slnx +++ b/ClaudeDo.slnx @@ -8,6 +8,7 @@ + diff --git a/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs new file mode 100644 index 0000000..6ec9c62 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs @@ -0,0 +1,12 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +public sealed class ImportantFilter : ITaskListFilter +{ + public string Id => "smart:important"; + public bool Matches(TaskEntity t) => t.IsStarred; + public bool ShouldCount(TaskEntity t) => t.IsStarred && t.Status != TaskStatus.Done; + public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs new file mode 100644 index 0000000..63653fa --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs @@ -0,0 +1,12 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +public sealed class MyDayFilter : ITaskListFilter +{ + public string Id => "smart:my-day"; + public bool Matches(TaskEntity t) => t.IsMyDay; + public bool ShouldCount(TaskEntity t) => t.IsMyDay && t.Status != TaskStatus.Done; + public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs new file mode 100644 index 0000000..e8cd839 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs @@ -0,0 +1,12 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +public sealed class PlannedFilter : ITaskListFilter +{ + public string Id => "smart:planned"; + public bool Matches(TaskEntity t) => t.ScheduledFor != null; + public bool ShouldCount(TaskEntity t) => t.ScheduledFor != null && t.Status != TaskStatus.Done; + public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs new file mode 100644 index 0000000..1567601 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs @@ -0,0 +1,14 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +public sealed class QueuedFilter : ITaskListFilter +{ + public string Id => "virtual:queued"; + public bool Matches(TaskEntity t) => t.Status == TaskStatus.Queued; + public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Queued; + public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => + PlanningRules.IsPlanningParent(t) && + PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued); +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs new file mode 100644 index 0000000..58a8f74 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs @@ -0,0 +1,14 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +public sealed class ReviewFilter : ITaskListFilter +{ + public string Id => "virtual:review"; + public bool Matches(TaskEntity t) => + t.Status == TaskStatus.Done && + t.Worktree is { State: WorktreeState.Active }; + public bool ShouldCount(TaskEntity t) => Matches(t); + public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs new file mode 100644 index 0000000..56152ad --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs @@ -0,0 +1,14 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +public sealed class RunningFilter : ITaskListFilter +{ + public string Id => "virtual:running"; + public bool Matches(TaskEntity t) => t.Status == TaskStatus.Running; + public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Running; + public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => + PlanningRules.IsPlanningParent(t) && + PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running); +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs new file mode 100644 index 0000000..3373dbf --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs @@ -0,0 +1,24 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +/// +/// Filter for any user-defined list. Constructed on demand from the list id — +/// one instance per list. +/// +public sealed class UserListFilter : ITaskListFilter +{ + private readonly string _listId; + + public UserListFilter(string listId) + { + _listId = listId; + Id = $"user:{listId}"; + } + + public string Id { get; } + public bool Matches(TaskEntity t) => t.ListId == _listId; + public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done; + public bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/ITaskListFilter.cs b/src/ClaudeDo.Data/Filtering/ITaskListFilter.cs new file mode 100644 index 0000000..79b1343 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/ITaskListFilter.cs @@ -0,0 +1,26 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Data.Filtering; + +/// +/// Strategy that defines which tasks belong to a single list. One implementation +/// per list kind; consumers (counters, list loader) ask the registry for the +/// right strategy and never branch on the list id themselves. +/// +public interface ITaskListFilter +{ + /// The list id this filter applies to (e.g. "virtual:queued", "user:abc"). + string Id { get; } + + /// True if is a primary citizen of this list — appears as a row. + bool Matches(TaskEntity t); + + /// True if should be counted in this list's badge. + bool ShouldCount(TaskEntity t); + + /// + /// True if is shown as a contextual row (not a primary citizen, + /// but appears to host children that match). Default: nothing extra. + /// + bool MatchesAsContext(TaskEntity t, IReadOnlyList all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/PlanningRules.cs b/src/ClaudeDo.Data/Filtering/PlanningRules.cs new file mode 100644 index 0000000..fd90bf7 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/PlanningRules.cs @@ -0,0 +1,27 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Data.Filtering; + +/// +/// Shared predicates that capture planning-hierarchy semantics. Any new rule +/// involving parents, children, or planning phases belongs here. +/// +public static class PlanningRules +{ + public static bool IsPlanningParent(TaskEntity t) => + t.PlanningPhase != PlanningPhase.None; + + public static bool HasMatchingChild( + TaskEntity parent, + IReadOnlyList all, + Func childPredicate) + { + for (var i = 0; i < all.Count; i++) + { + var c = all[i]; + if (c.ParentTaskId == parent.Id && childPredicate(c)) + return true; + } + return false; + } +} diff --git a/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs b/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs new file mode 100644 index 0000000..a5cfeba --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs @@ -0,0 +1,38 @@ +using ClaudeDo.Data.Filtering.Filters; + +namespace ClaudeDo.Data.Filtering; + +/// +/// Resolves a list id (e.g. "virtual:queued", "user:abc") to the filter that +/// owns its semantics. Smart and virtual filters are singletons; user-list +/// filters are constructed on demand from the id. +/// +public sealed class TaskListFilterRegistry +{ + public const string UserListPrefix = "user:"; + + private static readonly IReadOnlyDictionary BuiltIn = + new Dictionary(StringComparer.Ordinal) + { + ["smart:my-day"] = new MyDayFilter(), + ["smart:important"] = new ImportantFilter(), + ["smart:planned"] = new PlannedFilter(), + ["virtual:queued"] = new QueuedFilter(), + ["virtual:running"] = new RunningFilter(), + ["virtual:review"] = new ReviewFilter(), + }; + + /// + /// Resolve a filter for a list id, or null if the id is unknown. + /// + public ITaskListFilter? Resolve(string listId) + { + if (BuiltIn.TryGetValue(listId, out var f)) return f; + if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal)) + { + var inner = listId[UserListPrefix.Length..]; + return string.IsNullOrEmpty(inner) ? null : new UserListFilter(inner); + } + return null; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index a47e1e3..55b22fc 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; +using ClaudeDo.Data.Filtering; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using ClaudeDo.Data.Repositories; @@ -19,6 +20,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase private readonly IDbContextFactory _dbFactory; private readonly IServiceProvider? _services; private readonly WorkerClient? _worker; + private static readonly TaskListFilterRegistry _filters = new(); public event EventHandler? SelectionChanged; public event EventHandler? FocusSearchRequested; @@ -129,38 +131,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); - // Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping. - var open = await ctx.Tasks.AsNoTracking() - .Where(t => t.Status != TaskStatus.Done) - .Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor }) + // Single snapshot; counters and the list loader share the same filter strategies. + var all = await ctx.Tasks.AsNoTracking() + .Include(t => t.Worktree) .ToListAsync(ct); - var running = open.Count(t => t.Status == TaskStatus.Running); - var queued = open.Count(t => t.Status == TaskStatus.Queued); - var review = await ctx.Tasks.AsNoTracking() - .Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active) - .CountAsync(ct); - foreach (var item in SmartLists) { - item.Count = item.Id switch - { - "smart:my-day" => open.Count(t => t.IsMyDay), - "smart:important" => open.Count(t => t.IsStarred), - "smart:planned" => open.Count(t => t.Scheduled != null), - "virtual:queued" => queued, - "virtual:running" => running, - "virtual:review" => review, - _ => 0, - }; + var filter = _filters.Resolve(item.Id); + item.Count = filter is null ? 0 : all.Count(filter.ShouldCount); } foreach (var item in UserLists) { - var listId = item.Id.StartsWith("user:", StringComparison.Ordinal) - ? item.Id["user:".Length..] - : item.Id; - item.Count = open.Count(t => t.ListId == listId); + var filter = _filters.Resolve(item.Id); + item.Count = filter is null ? 0 : all.Count(filter.ShouldCount); } } catch (OperationCanceledException) { throw; } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 8cf6c91..f0187d8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -3,6 +3,7 @@ using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; +using ClaudeDo.Data.Filtering; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals; @@ -18,6 +19,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase private readonly Dictionary _expandedState = new(); private ListNavItemViewModel? _currentList; private CancellationTokenSource? _loadCts; + private static readonly TaskListFilterRegistry _filters = new(); public event EventHandler? SelectionChanged; public event EventHandler? FocusAddTaskRequested; @@ -192,25 +194,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase ct.ThrowIfCancellationRequested(); - static bool IsPlanningParent(TaskEntity t) => t.PlanningPhase != PlanningPhase.None; - - IEnumerable filtered = list.Kind switch - { - ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay), - ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred), - ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null), - ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => - (t.Status == TaskStatus.Queued && t.ParentTaskId == null) || - (IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Queued))), - ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => - (t.Status == TaskStatus.Running && t.ParentTaskId == null) || - (IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))), - ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null), - ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id), - _ => Enumerable.Empty(), - }; - - var filteredList = filtered.ToList(); + var filter = _filters.Resolve(list.Id); + var filteredList = filter is null + ? new List() + : all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList(); var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet(); var existingIds = filteredList.Select(t => t.Id).ToHashSet(); foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!))) @@ -282,17 +269,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase // Build hierarchy-aware flat list: top-level rows interleaved with visible children. // Items is already ordered by SortOrder from the DB query. - var topLevel = Items.Where(r => !r.IsChild); + // Treat rows whose ParentTaskId is not in the current view as orphans -> top-level. + var visibleIds = Items.Select(r => r.Id).ToHashSet(); + bool IsTopLevel(TaskRowViewModel r) => + !r.IsChild + || string.IsNullOrEmpty(r.ParentTaskId) + || !visibleIds.Contains(r.ParentTaskId!); + var topLevel = Items.Where(IsTopLevel); var flat = new List(); + var emitted = new HashSet(); foreach (var parent in topLevel) { + if (!emitted.Add(parent.Id)) continue; flat.Add(parent); // Also expand for Done parents so their (Done) children reach the classification // loop and land in CompletedItems alongside the parent. if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded) { var children = Items.Where(r => r.ParentTaskId == parent.Id); - flat.AddRange(children); + foreach (var c in children) + if (emitted.Add(c.Id)) + flat.Add(c); } } diff --git a/tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj b/tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj new file mode 100644 index 0000000..39a77c0 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs new file mode 100644 index 0000000..a8ab776 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs @@ -0,0 +1,49 @@ +using ClaudeDo.Data.Filtering; +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests.Filtering; + +public sealed class PlanningRulesTests +{ + [Theory] + [InlineData(PlanningPhase.None, false)] + [InlineData(PlanningPhase.Active, true)] + [InlineData(PlanningPhase.Finalized, true)] + public void IsPlanningParent_reflects_phase(PlanningPhase phase, bool expected) + { + var t = TaskFactory.Make("p", phase: phase); + Assert.Equal(expected, PlanningRules.IsPlanningParent(t)); + } + + [Fact] + public void HasMatchingChild_true_when_a_child_matches() + { + var parent = TaskFactory.Make("p", phase: PlanningPhase.Active); + var child = TaskFactory.Make("c", parentId: "p", status: TaskStatus.Queued); + var all = new List { parent, child }; + + Assert.True(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued)); + } + + [Fact] + public void HasMatchingChild_false_when_no_child_matches() + { + var parent = TaskFactory.Make("p", phase: PlanningPhase.Active); + var child = TaskFactory.Make("c", parentId: "p", status: TaskStatus.Done); + var all = new List { parent, child }; + + Assert.False(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued)); + } + + [Fact] + public void HasMatchingChild_ignores_other_parents_children() + { + var parent = TaskFactory.Make("p", phase: PlanningPhase.Active); + var otherParent = TaskFactory.Make("p2", phase: PlanningPhase.Active); + var foreignKid = TaskFactory.Make("c", parentId: "p2", status: TaskStatus.Queued); + var all = new List { parent, otherParent, foreignKid }; + + Assert.False(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued)); + } +} diff --git a/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs new file mode 100644 index 0000000..a80a49e --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs @@ -0,0 +1,46 @@ +using ClaudeDo.Data.Filtering.Filters; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests.Filtering; + +public sealed class SmartFilterTests +{ + [Fact] + public void MyDay_matches_my_day_tasks_regardless_of_status() + { + var f = new MyDayFilter(); + Assert.True (f.Matches(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Idle))); + Assert.True (f.Matches(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done))); + Assert.False(f.Matches(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle))); + } + + [Fact] + public void MyDay_count_excludes_done() + { + var f = new MyDayFilter(); + Assert.True (f.ShouldCount(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Queued))); + Assert.False(f.ShouldCount(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done))); + Assert.False(f.ShouldCount(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle))); + } + + [Fact] + public void Important_uses_IsStarred_with_same_split() + { + var f = new ImportantFilter(); + Assert.True (f.Matches (TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done))); + Assert.False(f.ShouldCount(TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done))); + Assert.True (f.ShouldCount(TaskFactory.Make("b", isStarred: true, status: TaskStatus.Queued))); + Assert.False(f.Matches (TaskFactory.Make("c", isStarred: false))); + } + + [Fact] + public void Planned_uses_ScheduledFor_with_same_split() + { + var f = new PlannedFilter(); + var when = DateTime.Today; + Assert.True (f.Matches (TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done))); + Assert.False(f.ShouldCount(TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done))); + Assert.True (f.ShouldCount(TaskFactory.Make("b", scheduled: when, status: TaskStatus.Idle))); + Assert.False(f.Matches (TaskFactory.Make("c", scheduled: null))); + } +} diff --git a/tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs b/tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs new file mode 100644 index 0000000..1b89b17 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs @@ -0,0 +1,46 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests.Filtering; + +internal static class TaskFactory +{ + public static TaskEntity Make( + string id, + string listId = "L", + TaskStatus status = TaskStatus.Idle, + PlanningPhase phase = PlanningPhase.None, + string? parentId = null, + bool isMyDay = false, + bool isStarred = false, + DateTime? scheduled = null, + WorktreeState? worktreeState = null) + { + var t = new TaskEntity + { + Id = id, + ListId = listId, + Title = id, + CreatedAt = DateTime.UtcNow, + Status = status, + PlanningPhase = phase, + ParentTaskId = parentId, + IsMyDay = isMyDay, + IsStarred = isStarred, + ScheduledFor = scheduled, + }; + if (worktreeState is { } s) + { + t.Worktree = new WorktreeEntity + { + TaskId = id, + Path = "/tmp/" + id, + BranchName = "br/" + id, + BaseCommit = "deadbeef", + CreatedAt = DateTime.UtcNow, + State = s, + }; + } + return t; + } +} diff --git a/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs new file mode 100644 index 0000000..8cbe145 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs @@ -0,0 +1,39 @@ +using ClaudeDo.Data.Filtering; +using ClaudeDo.Data.Filtering.Filters; + +namespace ClaudeDo.Data.Tests.Filtering; + +public sealed class TaskListFilterRegistryTests +{ + private readonly TaskListFilterRegistry _registry = new(); + + [Theory] + [InlineData("smart:my-day", typeof(MyDayFilter))] + [InlineData("smart:important", typeof(ImportantFilter))] + [InlineData("smart:planned", typeof(PlannedFilter))] + [InlineData("virtual:queued", typeof(QueuedFilter))] + [InlineData("virtual:running", typeof(RunningFilter))] + [InlineData("virtual:review", typeof(ReviewFilter))] + public void Resolves_known_built_in_filters(string id, Type expected) + { + var f = _registry.Resolve(id); + Assert.NotNull(f); + Assert.IsType(expected, f); + Assert.Equal(id, f!.Id); + } + + [Fact] + public void Resolves_user_list_filter_from_prefixed_id() + { + var f = _registry.Resolve("user:abc123"); + var user = Assert.IsType(f); + Assert.Equal("user:abc123", user.Id); + } + + [Fact] + public void Returns_null_for_unknown_or_empty_user_id() + { + Assert.Null(_registry.Resolve("bogus")); + Assert.Null(_registry.Resolve("user:")); + } +} diff --git a/tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs new file mode 100644 index 0000000..3d72d71 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs @@ -0,0 +1,31 @@ +using ClaudeDo.Data.Filtering.Filters; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests.Filtering; + +public sealed class UserListFilterTests +{ + [Fact] + public void Id_uses_user_prefix() + { + var f = new UserListFilter("inbox"); + Assert.Equal("user:inbox", f.Id); + } + + [Fact] + public void Matches_only_tasks_in_the_owning_list() + { + var f = new UserListFilter("inbox"); + Assert.True (f.Matches(TaskFactory.Make("a", listId: "inbox"))); + Assert.False(f.Matches(TaskFactory.Make("b", listId: "other"))); + } + + [Fact] + public void Count_excludes_done_tasks() + { + var f = new UserListFilter("inbox"); + Assert.True (f.ShouldCount(TaskFactory.Make("a", listId: "inbox", status: TaskStatus.Idle))); + Assert.False(f.ShouldCount(TaskFactory.Make("b", listId: "inbox", status: TaskStatus.Done))); + Assert.False(f.ShouldCount(TaskFactory.Make("c", listId: "other", status: TaskStatus.Idle))); + } +} diff --git a/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs new file mode 100644 index 0000000..8494f97 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs @@ -0,0 +1,97 @@ +using ClaudeDo.Data.Filtering.Filters; +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests.Filtering; + +public sealed class VirtualFilterTests +{ + // --- Queued --- + + [Fact] + public void Queued_matches_every_queued_task_regardless_of_parent() + { + var f = new QueuedFilter(); + Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Queued))); + Assert.True (f.Matches(TaskFactory.Make("b", status: TaskStatus.Queued, parentId: "p"))); + Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Running))); + } + + [Fact] + public void Queued_count_equals_match_for_top_level_and_children_alike() + { + // The point of the consolidation: counter must agree with display set. + var f = new QueuedFilter(); + var orphan = TaskFactory.Make("o", status: TaskStatus.Queued, parentId: "missing"); + Assert.True(f.Matches(orphan)); + Assert.True(f.ShouldCount(orphan)); + } + + [Fact] + public void Queued_planning_parent_with_queued_kid_is_context_match() + { + var f = new QueuedFilter(); + var parent = TaskFactory.Make("p", phase: PlanningPhase.Active, status: TaskStatus.Done); + var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued); + var all = new List { parent, kid }; + + Assert.False(f.Matches(parent)); + Assert.True (f.MatchesAsContext(parent, all)); + } + + [Fact] + public void Queued_non_planning_parent_is_never_context_match() + { + var f = new QueuedFilter(); + var parent = TaskFactory.Make("p", phase: PlanningPhase.None, status: TaskStatus.Done); + var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued); + var all = new List { parent, kid }; + + Assert.False(f.MatchesAsContext(parent, all)); + } + + [Fact] + public void Queued_planning_parent_without_queued_kid_is_not_context_match() + { + var f = new QueuedFilter(); + var parent = TaskFactory.Make("p", phase: PlanningPhase.Active); + var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Done); + var all = new List { parent, kid }; + + Assert.False(f.MatchesAsContext(parent, all)); + } + + // --- Running mirrors Queued --- + + [Fact] + public void Running_matches_and_context_mirror_queued() + { + var f = new RunningFilter(); + var parent = TaskFactory.Make("p", phase: PlanningPhase.Active); + var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Running); + var all = new List { parent, kid }; + + Assert.True (f.Matches(kid)); + Assert.True (f.MatchesAsContext(parent, all)); + } + + // --- Review --- + + [Fact] + public void Review_matches_only_done_with_active_worktree() + { + var f = new ReviewFilter(); + Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active))); + Assert.False(f.Matches(TaskFactory.Make("b", status: TaskStatus.Done, worktreeState: WorktreeState.Merged))); + Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Done, worktreeState: null))); + Assert.False(f.Matches(TaskFactory.Make("d", status: TaskStatus.Failed, worktreeState: WorktreeState.Active))); + } + + [Fact] + public void Review_count_equals_match() + { + var f = new ReviewFilter(); + var t = TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active); + Assert.Equal(f.Matches(t), f.ShouldCount(t)); + } +}