diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 2a03c653..cdb6c261 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -338,9 +338,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable IsManual = l.IsManual, FindingsTracked = l.FindingsTracked, HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0, - // A folder_id pointing at a folder that isn't there (shouldn't happen — the FK - // sets it null on delete) would otherwise hide the list entirely. - FolderId = Folders.Any(f => f.Id == l.FolderId) ? l.FolderId : null, + FolderId = l.FolderId, }; Items.Add(item); UserLists.Add(item); @@ -707,7 +705,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable IsManual = entity.IsManual, FindingsTracked = entity.FindingsTracked, HasTicketProject = hasTicketProject, - FolderId = Folders.Any(f => f.Id == entity.FolderId) ? entity.FolderId : null, + FolderId = entity.FolderId, }; var seedNames = new HashSet(new[] { "My Day", "Important", "Planned" }); diff --git a/tests/ClaudeDo.Data.Tests/ListFolderTests.cs b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs new file mode 100644 index 00000000..a6f6cca6 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs @@ -0,0 +1,117 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Tests; + +public sealed class ListFolderTests : IDisposable +{ + private readonly string _dbPath; + private readonly DbContextOptions _options; + + public ListFolderTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_folders_{Guid.NewGuid():N}.db"); + _options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + + using var ctx = new ClaudeDoDbContext(_options); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + private ClaudeDoDbContext Open() => new(_options); + + private static ListFolderEntity NewFolder(string id, string name = "Group") => + new() { Id = id, Name = name, CreatedAt = DateTime.UtcNow }; + + [Fact] + public async Task Deleting_a_folder_keeps_its_lists_and_moves_them_to_the_top_level() + { + await using (var ctx = Open()) + { + var repo = new ListRepository(ctx); + await repo.AddFolderAsync(NewFolder("f1")); + await repo.AddAsync(new ListEntity { Id = "l1", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" }); + } + + await using (var ctx = Open()) + await new ListRepository(ctx).DeleteFolderAsync("f1"); + + await using (var ctx = Open()) + { + var list = await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1"); + Assert.Null(list.FolderId); + Assert.Empty(await ctx.ListFolders.AsNoTracking().ToListAsync()); + } + } + + [Fact] + public async Task RenameFolderAsync_keeps_sort_order_and_collapse_state() + { + // The whole reason it patches instead of Update()-ing a rebuilt entity: a rename must not + // silently unfold the folder or shuffle it to the top. + await using (var ctx = Open()) + { + var repo = new ListRepository(ctx); + await repo.AddFolderAsync(NewFolder("f1", "First")); + await repo.AddFolderAsync(NewFolder("f2", "Second")); + await repo.SetFolderCollapsedAsync("f2", true); + } + + await using (var ctx = Open()) + await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star"); + + await using (var ctx = Open()) + { + var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2"); + Assert.Equal("Renamed", folder.Name); + Assert.Equal("Star", folder.Icon); + Assert.True(folder.IsCollapsed); + Assert.Equal(1, folder.SortOrder); + } + } + + [Fact] + public async Task SetListFolderAsync_moves_a_list_in_and_back_out() + { + await using (var ctx = Open()) + { + var repo = new ListRepository(ctx); + await repo.AddFolderAsync(NewFolder("f1")); + await repo.AddAsync(new ListEntity { Id = "l1", Name = "Roaming", CreatedAt = DateTime.UtcNow }); + } + + await using (var ctx = Open()) + await new ListRepository(ctx).SetListFolderAsync("l1", "f1"); + + await using (var ctx = Open()) + Assert.Equal("f1", (await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId); + + await using (var ctx = Open()) + await new ListRepository(ctx).SetListFolderAsync("l1", null); + + await using (var ctx = Open()) + Assert.Null((await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId); + } + + [Fact] + public async Task AddFolderAsync_appends_in_creation_order() + { + await using var ctx = Open(); + var repo = new ListRepository(ctx); + await repo.AddFolderAsync(NewFolder("f1", "One")); + await repo.AddFolderAsync(NewFolder("f2", "Two")); + + var folders = await repo.GetFoldersAsync(); + Assert.Equal(["One", "Two"], folders.Select(f => f.Name)); + Assert.Equal([0, 1], folders.Select(f => f.SortOrder)); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs new file mode 100644 index 00000000..b21a2127 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs @@ -0,0 +1,224 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// +/// Sidebar folders: grouping on load, the folder badge, and the two ways a list changes folder +/// (dropped onto a row in another container, dropped onto a folder header). +/// +public class ListsIslandFolderTests : IDisposable +{ + private readonly string _dbPath; + + public ListsIslandFolderTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_lists_folders_test_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class FakeWorker : StubWorkerClient + { + public override bool IsConnected => true; + } + + private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists) + { + await using var db = NewContext(); + if (folderId is not null) + db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", CreatedAt = DateTime.UtcNow }); + foreach (var (id, name, sort) in lists) + db.Lists.Add(new ListEntity { Id = id, Name = name, SortOrder = sort, CreatedAt = DateTime.UtcNow, FolderId = folderId }); + await db.SaveChangesAsync(); + } + + private async Task SeedTaskAsync(string listId, int number) + { + await using var db = NewContext(); + db.Tasks.Add(new TaskEntity + { + Id = Guid.NewGuid().ToString("N"), + Number = number, + ListId = listId, + Title = "T", + Status = Data.Models.TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + private ListsIslandViewModel NewVm() => new(new TestDbFactory(NewContext), worker: new FakeWorker()); + + [Fact] + public async Task LoadAsync_puts_lists_into_their_folder_and_leaves_the_rest_at_the_top_level() + { + await SeedAsync("f1", ("inside", "Inside", 0)); + await SeedAsync(null, ("outside", "Outside", 1)); + + var vm = NewVm(); + await vm.LoadAsync(); + + var folder = Assert.Single(vm.Folders); + Assert.Equal("Inside", Assert.Single(folder.Lists).Name); + Assert.Equal("Outside", Assert.Single(vm.RootLists).Name); + // UserLists stays the flat superset — counts and row refreshes work over it. + Assert.Equal(2, vm.UserLists.Count); + } + + [Fact] + public async Task Folder_count_is_the_sum_of_its_lists() + { + await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1)); + await SeedTaskAsync("a", 1); + await SeedTaskAsync("b", 2); + await SeedTaskAsync("b", 3); + + var vm = NewVm(); + await vm.LoadAsync(); + + Assert.Equal(3, Assert.Single(vm.Folders).Count); + } + + [Fact] + public async Task MoveToFolderAsync_moves_a_top_level_list_into_the_folder_and_persists_it() + { + await SeedAsync("f1", ("inside", "Inside", 0)); + await SeedAsync(null, ("mover", "Mover", 1)); + + var vm = NewVm(); + await vm.LoadAsync(); + var mover = vm.RootLists.Single(r => r.Id == "user:mover"); + + await vm.MoveToFolderAsync(mover, vm.Folders[0]); + + Assert.Empty(vm.RootLists); + Assert.Equal(2, vm.Folders[0].Lists.Count); + Assert.Equal("f1", mover.FolderId); + + await using var db = NewContext(); + Assert.Equal("f1", (await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "mover")).FolderId); + } + + [Fact] + public async Task MoveToFolderAsync_with_no_folder_takes_a_list_back_out() + { + await SeedAsync("f1", ("inside", "Inside", 0)); + + var vm = NewVm(); + await vm.LoadAsync(); + var row = vm.Folders[0].Lists.Single(); + + await vm.MoveToFolderAsync(row, null); + + Assert.Empty(vm.Folders[0].Lists); + Assert.Same(row, Assert.Single(vm.RootLists)); + Assert.Null(row.FolderId); + + await using var db = NewContext(); + Assert.Null((await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "inside")).FolderId); + } + + [Fact] + public async Task ReorderAsync_onto_a_row_in_another_container_also_changes_the_folder() + { + await SeedAsync("f1", ("inside", "Inside", 0)); + await SeedAsync(null, ("mover", "Mover", 1)); + + var vm = NewVm(); + await vm.LoadAsync(); + var mover = vm.RootLists.Single(); + var inside = vm.Folders[0].Lists.Single(); + + await vm.ReorderAsync(mover, inside, placeBelow: false); + + Assert.Empty(vm.RootLists); + Assert.Equal(["Mover", "Inside"], vm.Folders[0].Lists.Select(l => l.Name)); + Assert.Equal("f1", mover.FolderId); + + await using var db = NewContext(); + var stored = await db.Lists.AsNoTracking().ToListAsync(); + Assert.Equal("f1", stored.Single(l => l.Id == "mover").FolderId); + // sort_order is written as one global sequence in sidebar order: folder contents first. + Assert.Equal(0, stored.Single(l => l.Id == "mover").SortOrder); + Assert.Equal(1, stored.Single(l => l.Id == "inside").SortOrder); + } + + [Fact] + public async Task ReorderAsync_within_a_folder_keeps_the_folder() + { + await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1)); + + var vm = NewVm(); + await vm.LoadAsync(); + var a = vm.Folders[0].Lists[0]; + var b = vm.Folders[0].Lists[1]; + + await vm.ReorderAsync(b, a, placeBelow: false); + + Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name)); + Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId)); + } + + [Fact] + public async Task ToggleFolder_flips_and_persists_the_collapse_state() + { + await SeedAsync("f1", ("inside", "Inside", 0)); + + var vm = NewVm(); + await vm.LoadAsync(); + var folder = vm.Folders[0]; + Assert.False(folder.IsCollapsed); + + await vm.ToggleFolderCommand.ExecuteAsync(folder); + + Assert.True(folder.IsCollapsed); + await using var db = NewContext(); + Assert.True((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed); + + // And the state survives a reload. + var reloaded = NewVm(); + await reloaded.LoadAsync(); + Assert.True(reloaded.Folders[0].IsCollapsed); + } + + [Fact] + public async Task RemoveFromFolder_command_is_the_context_menu_route_out() + { + await SeedAsync("f1", ("inside", "Inside", 0)); + + var vm = NewVm(); + await vm.LoadAsync(); + var row = vm.Folders[0].Lists.Single(); + + await vm.RemoveFromFolderCommand.ExecuteAsync(row); + + Assert.Null(row.FolderId); + Assert.Single(vm.RootLists); + } +}