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)); + } +}