using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; // DependsOnTaskId chain display — Slice 1 (VM + Regroup grouping only, no AXAML). Design: // docs/superpowers/specs/2026-08-11-dependency-chain-display-design.md public class TasksIslandChainTests : IDisposable { private readonly string _dbPath; public TasksIslandChainTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_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 TasksIslandViewModel BuildViewModel() { var factory = new TestDbFactory(NewContext); return new TasksIslandViewModel(factory, worker: null); } private static ListNavItemViewModel UserList(string listEntityId, string name) => new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list) { vm.LoadForList(list); var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline) { await Task.Delay(25); if (vm.Items.Count > 0) break; } await Task.Delay(50); } private async Task> SeedAsync( params (string Id, TaskStatus Status, string? DependsOnTaskId, string? ParentTaskId, int SortOrder)[] tasks) { var numbers = new Dictionary(); await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow }); foreach (var t in tasks) { var number = TestTaskNumbers.Next(); numbers[t.Id] = number; db.Tasks.Add(new TaskEntity { Number = number, Id = t.Id, ListId = "list1", Title = t.Id, CreatedAt = DateTime.UtcNow, Status = t.Status, DependsOnTaskId = t.DependsOnTaskId, ParentTaskId = t.ParentTaskId, SortOrder = t.SortOrder, }); } await db.SaveChangesAsync(); return numbers; } [Fact] public async Task LinearChain_StepsIncreaseWithHopDistance() { await SeedAsync( ("head", TaskStatus.Idle, null, null, 0), ("s1", TaskStatus.Idle, "head", null, 1), ("s2", TaskStatus.Idle, "s1", null, 2), ("s3", TaskStatus.Idle, "s2", null, 3)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var head = vm.Items.First(r => r.Id == "head"); var s1 = vm.Items.First(r => r.Id == "s1"); var s2 = vm.Items.First(r => r.Id == "s2"); var s3 = vm.Items.First(r => r.Id == "s3"); Assert.False(head.ShowAsChainMember); Assert.Null(head.ChainStep); Assert.True(s1.ShowAsChainMember); Assert.Equal(1, s1.ChainStep); Assert.True(s2.ShowAsChainMember); Assert.Equal(2, s2.ChainStep); Assert.True(s3.ShowAsChainMember); Assert.Equal(3, s3.ChainStep); } [Fact] public async Task TwoTasksSamePredecessor_ShareTheSameStep() { await SeedAsync( ("head", TaskStatus.Idle, null, null, 0), ("s1", TaskStatus.Idle, "head", null, 1), ("s2a", TaskStatus.Idle, "s1", null, 2), ("s2b", TaskStatus.Idle, "s1", null, 3)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var s2a = vm.Items.First(r => r.Id == "s2a"); var s2b = vm.Items.First(r => r.Id == "s2b"); Assert.True(s2a.ShowAsChainMember); Assert.True(s2b.ShowAsChainMember); Assert.Equal(2, s2a.ChainStep); Assert.Equal(2, s2b.ChainStep); } [Fact] public async Task HeadInDifferentSection_DependentRendersFlatWithLabel() { // Same list, but "head" is Done (-> Completed section) while its dependent stays open. // The two are never adjacent, so the dependent must not claim group membership. var numbers = await SeedAsync( ("head", TaskStatus.Done, null, null, 0), ("dependent", TaskStatus.Idle, "head", null, 1)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var dependent = vm.Items.First(r => r.Id == "dependent"); Assert.False(dependent.ShowAsChainMember); Assert.Null(dependent.ChainStep); Assert.NotNull(dependent.ChainAfterLabel); Assert.Contains(numbers["head"].ToString(), dependent.ChainAfterLabel); } [Fact] public async Task PlanningChildWithDependsOnTaskId_NeverBecomesChainMember() { await SeedAsync( ("predecessor", TaskStatus.Idle, null, null, 0), ("parent", TaskStatus.Idle, null, null, 1), ("child", TaskStatus.Idle, "predecessor", "parent", 2)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); // Planning parents with children collapse by default — expand so the child is part of // the flat list Regroup actually classifies (and thus reaches chain-grouping at all). vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "parent")); var child = vm.Items.First(r => r.Id == "child"); Assert.True(child.IsChild); Assert.False(child.ShowAsChainMember); Assert.Null(child.ChainStep); Assert.NotNull(child.ChainAfterLabel); } [Fact] public async Task ChainGroup_IsPulledTogether_RegardlessOfSortOrder() { // "head" sorts after its dependents by SortOrder — grouping must still put the // dependents directly after the head in Rows. await SeedAsync( ("s1", TaskStatus.Idle, "head", null, 0), ("unrelated", TaskStatus.Idle, null, null, 1), ("head", TaskStatus.Idle, null, null, 2)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var rows = vm.Rows.OfType().Select(r => r.Id).ToList(); var headIdx = rows.IndexOf("head"); var s1Idx = rows.IndexOf("s1"); Assert.Equal(headIdx + 1, s1Idx); } [Fact] public async Task TaskWithoutDependencyOrDependents_IsUnaffected() { await SeedAsync(("lonely", TaskStatus.Idle, null, null, 0)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var lonely = vm.Items.First(r => r.Id == "lonely"); Assert.False(lonely.ShowAsChainMember); Assert.Null(lonely.ChainStep); Assert.Null(lonely.ChainAfterLabel); } [Fact] public async Task ChainProperties_RecomputeAfterTaskUpdated() { await SeedAsync( ("head", TaskStatus.Idle, null, null, 0), ("later", TaskStatus.Idle, null, null, 1)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var later = vm.Items.First(r => r.Id == "later"); Assert.False(later.ShowAsChainMember); await using (var db = NewContext()) { var entity = await db.Tasks.FirstAsync(t => t.Id == "later"); entity.DependsOnTaskId = "head"; await db.SaveChangesAsync(); } await vm.RefreshTaskFromWorkerAsync("later"); var updated = vm.Items.First(r => r.Id == "later"); Assert.True(updated.ShowAsChainMember); Assert.Equal(1, updated.ChainStep); } }