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

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