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) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-18 15:18:33 +02:00
parent a6608bf8b3
commit e68bb737e3
20 changed files with 556 additions and 45 deletions

View File

@@ -8,6 +8,7 @@
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" /> <Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" /> <Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" /> <Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" /> <Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued);
}

View File

@@ -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<TaskEntity> all) => false;
}

View File

@@ -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<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running);
}

View File

@@ -0,0 +1,24 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
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<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,26 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// 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.
/// </summary>
public interface ITaskListFilter
{
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
string Id { get; }
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
bool Matches(TaskEntity t);
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
bool ShouldCount(TaskEntity t);
/// <summary>
/// True if <paramref name="t"/> is shown as a contextual row (not a primary citizen,
/// but appears to host children that match). Default: nothing extra.
/// </summary>
bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,27 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Shared predicates that capture planning-hierarchy semantics. Any new rule
/// involving parents, children, or planning phases belongs here.
/// </summary>
public static class PlanningRules
{
public static bool IsPlanningParent(TaskEntity t) =>
t.PlanningPhase != PlanningPhase.None;
public static bool HasMatchingChild(
TaskEntity parent,
IReadOnlyList<TaskEntity> all,
Func<TaskEntity, bool> childPredicate)
{
for (var i = 0; i < all.Count; i++)
{
var c = all[i];
if (c.ParentTaskId == parent.Id && childPredicate(c))
return true;
}
return false;
}
}

View File

@@ -0,0 +1,38 @@
using ClaudeDo.Data.Filtering.Filters;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// 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.
/// </summary>
public sealed class TaskListFilterRegistry
{
public const string UserListPrefix = "user:";
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
new Dictionary<string, ITaskListFilter>(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(),
};
/// <summary>
/// Resolve a filter for a list id, or null if the id is unknown.
/// </summary>
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;
}
}

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
@@ -19,6 +20,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services; private readonly IServiceProvider? _services;
private readonly WorkerClient? _worker; private readonly WorkerClient? _worker;
private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
public event EventHandler? FocusSearchRequested; public event EventHandler? FocusSearchRequested;
@@ -129,38 +131,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping. // Single snapshot; counters and the list loader share the same filter strategies.
var open = await ctx.Tasks.AsNoTracking() var all = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done) .Include(t => t.Worktree)
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
.ToListAsync(ct); .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) foreach (var item in SmartLists)
{ {
item.Count = item.Id switch var filter = _filters.Resolve(item.Id);
{ item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
"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,
};
} }
foreach (var item in UserLists) foreach (var item in UserLists)
{ {
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal) var filter = _filters.Resolve(item.Id);
? item.Id["user:".Length..] item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
: item.Id;
item.Count = open.Count(t => t.ListId == listId);
} }
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }

View File

@@ -3,6 +3,7 @@ using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
@@ -18,6 +19,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
private readonly Dictionary<string, bool> _expandedState = new(); private readonly Dictionary<string, bool> _expandedState = new();
private ListNavItemViewModel? _currentList; private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts; private CancellationTokenSource? _loadCts;
private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested; public event EventHandler? FocusAddTaskRequested;
@@ -192,25 +194,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
static bool IsPlanningParent(TaskEntity t) => t.PlanningPhase != PlanningPhase.None; var filter = _filters.Resolve(list.Id);
var filteredList = filter is null
IEnumerable<TaskEntity> filtered = list.Kind switch ? new List<TaskEntity>()
{ : all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
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<TaskEntity>(),
};
var filteredList = filtered.ToList();
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet(); var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
var existingIds = filteredList.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!))) 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. // Build hierarchy-aware flat list: top-level rows interleaved with visible children.
// Items is already ordered by SortOrder from the DB query. // 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<TaskRowViewModel>(); var flat = new List<TaskRowViewModel>();
var emitted = new HashSet<string>();
foreach (var parent in topLevel) foreach (var parent in topLevel)
{ {
if (!emitted.Add(parent.Id)) continue;
flat.Add(parent); flat.Add(parent);
// Also expand for Done parents so their (Done) children reach the classification // Also expand for Done parents so their (Done) children reach the classification
// loop and land in CompletedItems alongside the parent. // loop and land in CompletedItems alongside the parent.
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded) if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
{ {
var children = Items.Where(r => r.ParentTaskId == parent.Id); 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);
} }
} }

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClaudeDo.Data\ClaudeDo.Data.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<TaskEntity> { 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<TaskEntity> { 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<TaskEntity> { parent, otherParent, foreignKid };
Assert.False(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued));
}
}

View File

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

View File

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

View File

@@ -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<UserListFilter>(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:"));
}
}

View File

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

View File

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