using System.Linq; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using Xunit; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; public class MergeHelperSelectionModalViewModelTests : IDisposable { private readonly string _dbPath; public MergeHelperSelectionModalViewModelTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mh_test_{Guid.NewGuid():N}.db"); using var ctx = NewContext(); ctx.Database.EnsureCreated(); } public void Dispose() { try { File.Delete(_dbPath); } catch { } try { File.Delete(_dbPath + "-wal"); } catch { } try { File.Delete(_dbPath + "-shm"); } catch { } } private ClaudeDoDbContext NewContext() { var opts = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); } private MergeHelperSelectionModalViewModel BuildVm() => new(new TestDbFactory(NewContext)); private async Task SeedAllStatusesAsync() { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Lists.Add(new ListEntity { Id = "L2", Name = "Home", CreatedAt = DateTime.UtcNow }); var order = 0; void Add(string id, string listId, TaskStatus status) => db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = id, ListId = listId, Title = $"task {id}", Status = status, CreatedAt = DateTime.UtcNow, SortOrder = order++, }); Add("t-idle", "L1", TaskStatus.Idle); Add("t-queued", "L1", TaskStatus.Queued); Add("t-running", "L1", TaskStatus.Running); Add("t-review", "L1", TaskStatus.WaitingForReview); Add("t-children", "L1", TaskStatus.WaitingForChildren); Add("t-failed", "L1", TaskStatus.Failed); Add("t-done", "L1", TaskStatus.Done); Add("t-cancelled", "L1", TaskStatus.Cancelled); Add("t-other-list", "L2", TaskStatus.Idle); await db.SaveChangesAsync(); } [Fact] public async Task Load_ExcludesTerminalStatuses_AndTicksActionableByDefault() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled"); Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list"); Assert.Equal(6, vm.Tasks.Count); Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-review").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-failed").IsSelected); Assert.False(vm.Tasks.Single(t => t.Id == "t-running").IsSelected); Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected); } [Fact] public async Task ReconcileTick_KeepsBothTicksAndUnticks_AndDefaultsOnlyNewRows() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); // The user's own choices, both directions, against the defaults. vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = false; // default true -> off vm.Tasks.Single(t => t.Id == "t-running").IsSelected = true; // default false -> on await using (var db = NewContext()) { db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "t-new", ListId = "L1", Title = "arrived later", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 99, }); await db.SaveChangesAsync(); } await vm.ReconcileTickAsync(); Assert.False(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-running").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected); // untouched, keeps default Assert.True(vm.Tasks.Single(t => t.Id == "t-new").IsSelected); // new row gets the default } [Fact] public async Task ReconcileTick_DoesNotCascadeParentStateOverAnUntickedChild() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "parent", ListId = "L1", Title = "parent", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0, }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "child1", ListId = "L1", Title = "child1", ParentTaskId = "parent", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "child2", ListId = "L1", Title = "child2", ParentTaskId = "parent", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2, }); await db.SaveChangesAsync(); } var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); vm.Tasks.Single(t => t.Id == "child1").IsSelected = false; // parent stays ticked await vm.ReconcileTickAsync(); Assert.True(vm.Tasks.Single(t => t.Id == "parent").IsSelected); Assert.False(vm.Tasks.Single(t => t.Id == "child1").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "child2").IsSelected); } [Fact] public async Task Load_PerListScope_FiltersToThatList() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L2", "Home"); await vm.LoadAsync(); Assert.Single(vm.Tasks); Assert.Equal("t-other-list", vm.Tasks[0].Id); Assert.Contains("Home", vm.ScopeLabel); } [Fact] public async Task CanConfirm_FollowsRowSelection() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); Assert.True(vm.CanConfirm); vm.SelectNoneCommand.Execute(null); Assert.False(vm.CanConfirm); Assert.All(vm.Tasks, t => Assert.False(t.IsSelected)); vm.Tasks[0].IsSelected = true; // single row re-enables via PropertyChanged hook Assert.True(vm.CanConfirm); vm.SelectAllCommand.Execute(null); Assert.All(vm.Tasks, t => Assert.True(t.IsSelected)); } [Fact] public async Task Confirm_ReturnsSelectedIds_InRowOrder() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); vm.SelectNoneCommand.Execute(null); vm.Tasks.Single(t => t.Id == "t-review").IsSelected = true; vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = true; var closed = false; vm.CloseAction = () => closed = true; vm.ConfirmCommand.Execute(null); var result = await vm.Result.Task; Assert.NotNull(result); // Row order (SortOrder): t-idle was seeded before t-review. Assert.Equal(new[] { "t-idle", "t-review" }, result); Assert.True(closed); } [Fact] public async Task Cancel_ReturnsNull() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); var closed = false; vm.CloseAction = () => closed = true; vm.CancelCommand.Execute(null); Assert.Null(await vm.Result.Task); Assert.True(closed); } [Fact] public async Task Load_NestsChildUnderParent_InOrder() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "parent", ListId = "L1", Title = "parent", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "sibling", ListId = "L1", Title = "sibling", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1 }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "child", ListId = "L1", Title = "child", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2, ParentTaskId = "parent" }); await db.SaveChangesAsync(); } var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); // Child is nested directly after its parent, ahead of the later sibling. Assert.Equal(new[] { "parent", "child", "sibling" }, vm.Tasks.Select(t => t.Id)); Assert.True(vm.Tasks.Single(t => t.Id == "child").IsChild); Assert.False(vm.Tasks.Single(t => t.Id == "child").IsOrphanChild); Assert.False(vm.Tasks.Single(t => t.Id == "parent").IsChild); } [Fact] public async Task Load_ChildOfFilteredOutParent_RendersTopLevelAsOrphan() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "parent-done", ListId = "L1", Title = "parent", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "child", ListId = "L1", Title = "child", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, ParentTaskId = "parent-done" }); await db.SaveChangesAsync(); } var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); // The parent is Done and filtered out; the child must not disappear, and must be // flagged as an orphan instead of rendering as a (broken) child. Assert.Single(vm.Tasks); var row = vm.Tasks[0]; Assert.Equal("child", row.Id); Assert.False(row.IsChild); Assert.True(row.IsOrphanChild); } [Fact] public async Task Toggle_Parent_CascadesSelectionToChildren() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "parent", ListId = "L1", Title = "parent", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "child1", ListId = "L1", Title = "child1", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, ParentTaskId = "parent" }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "child2", ListId = "L1", Title = "child2", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2, ParentTaskId = "parent" }); await db.SaveChangesAsync(); } var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); vm.SelectNoneCommand.Execute(null); Assert.All(vm.Tasks, t => Assert.False(t.IsSelected)); vm.Tasks.Single(t => t.Id == "parent").IsSelected = true; Assert.True(vm.Tasks.Single(t => t.Id == "child1").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "child2").IsSelected); // A child can be individually deselected afterwards without touching the parent. vm.Tasks.Single(t => t.Id == "child1").IsSelected = false; Assert.True(vm.Tasks.Single(t => t.Id == "parent").IsSelected); Assert.False(vm.Tasks.Single(t => t.Id == "child1").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "child2").IsSelected); vm.Tasks.Single(t => t.Id == "parent").IsSelected = false; Assert.False(vm.Tasks.Single(t => t.Id == "child2").IsSelected); } [Fact] public async Task Confirm_ReturnsParentsBeforeChildren() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "parent", ListId = "L1", Title = "parent", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "child", ListId = "L1", Title = "child", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, ParentTaskId = "parent" }); await db.SaveChangesAsync(); } var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); vm.ConfirmCommand.Execute(null); var result = await vm.Result.Task; Assert.Equal(new[] { "parent", "child" }, result); } [Fact] public async Task Load_NoCandidates_HasTasksFalse_CannotConfirm() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "t-done", ListId = "L1", Title = "done", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); } var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); Assert.False(vm.HasTasks); Assert.False(vm.CanConfirm); } // The tick reloads by rebuilding every row, so it remembers and replays the user's ticks. // A second tick entering while the first is between its reload and its restore would snapshot // rows that are back at IsTickedByDefault and write that default back — silently re-ticking // whatever the user had deliberately unticked. Only one tick may be in flight. [Fact] public async Task ReconcileTick_ignores_a_second_tick_while_one_is_still_in_flight() { await SeedAllStatusesAsync(); var factory = new CountingDbFactory(NewContext); var vm = new MergeHelperSelectionModalViewModel(factory); vm.Configure("L1", "Work"); await vm.LoadAsync(); var unticked = vm.Tasks.Single(t => t.Id == "t-idle"); unticked.IsSelected = false; var reached = new TaskCompletionSource(); var proceed = new TaskCompletionSource(); vm.ReconcileTickTestBarrier = async () => { reached.TrySetResult(); await proceed.Task; }; var first = vm.ReconcileTickAsync(); await reached.Task; var loadsBefore = factory.CreateCalls; await vm.ReconcileTickAsync(); Assert.Equal(loadsBefore, factory.CreateCalls); // the overlapping tick did not reload proceed.TrySetResult(); await first; Assert.False(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected); } private sealed class CountingDbFactory : IDbContextFactory { private readonly Func _create; public int CreateCalls { get; private set; } public CountingDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() { CreateCalls++; return _create(); } } }