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 SeedAsync(folderId, 0, lists); private async Task SeedAsync(string? folderId, int folderSort, 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}", SortOrder = folderSort, 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 one sequence over the whole sidebar, folders included: f1 takes 0, its // two children 1 and 2. Assert.Equal(0, (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).SortOrder); Assert.Equal(1, stored.Single(l => l.Id == "mover").SortOrder); Assert.Equal(2, stored.Single(l => l.Id == "inside").SortOrder); } [Fact] public async Task DropAsync_reorders_folders_among_each_other_and_persists_it() { await SeedAsync("f1", folderSort: 0); await SeedAsync("f2", folderSort: 1); var vm = NewVm(); await vm.LoadAsync(); var (first, second) = (vm.Folders[0], vm.Folders[1]); await vm.DropAsync(second, first, placeBelow: false); Assert.Equal([second, first], vm.Folders); await using var db = NewContext(); var stored = await db.ListFolders.AsNoTracking().ToListAsync(); Assert.Equal(0, stored.Single(f => f.Id == second.Id).SortOrder); Assert.Equal(1, stored.Single(f => f.Id == first.Id).SortOrder); // And it survives a reload — the load path merges folders and lists by sort_order. var reloaded = NewVm(); await reloaded.LoadAsync(); Assert.Equal([second.Id, first.Id], reloaded.Folders.Select(f => f.Id)); } [Fact] public async Task DropAsync_puts_a_list_above_a_folder_at_the_top_level() { await SeedAsync("f1", ("inside", "Inside", 1)); await SeedAsync(null, ("mover", "Mover", 2)); var vm = NewVm(); await vm.LoadAsync(); var folder = vm.Folders[0]; var mover = vm.RootLists.Single(); await vm.DropAsync(mover, folder, placeBelow: false); Assert.Equal(new object[] { mover, folder }, vm.TopLevel); Assert.Null(mover.FolderId); var reloaded = NewVm(); await reloaded.LoadAsync(); Assert.Equal(["user:mover", "f1"], reloaded.TopLevel.Select(i => i is ListNavItemViewModel l ? l.Id : ((ListFolderNavItemViewModel)i).Id)); } [Fact] public async Task DropAsync_never_nests_a_folder_in_a_folder() { await SeedAsync("f1", 0, ("inside", "Inside", 1)); await SeedAsync("f2", folderSort: 2); var vm = NewVm(); await vm.LoadAsync(); var inside = vm.Folders[0].Lists.Single(); var f2 = vm.Folders[1]; await vm.DropAsync(f2, inside, placeBelow: true); Assert.Equal(2, vm.TopLevel.Count); Assert.Single(vm.Folders[0].Lists); } [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); vm.ToggleFolderCommand.Execute(folder); await Task.Delay(100); 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 ToggleFolder_rapid_double_click_both_toggles_land() { // Bug F1 (docs/test-session-2026-09-16.md): the command used to be an async // [RelayCommand], so AsyncRelayCommand's own CanExecute lock swallowed the second click // while the first one's DB write was still in flight. Firing two toggles back-to-back // (no await between them) reproduces exactly that window. await SeedAsync("f1", ("inside", "Inside", 0)); var vm = NewVm(); await vm.LoadAsync(); var folder = vm.Folders[0]; Assert.False(folder.IsCollapsed); vm.ToggleFolderCommand.Execute(folder); vm.ToggleFolderCommand.Execute(folder); await Task.Delay(100); // Two flips land back on the original (visible) state... Assert.False(folder.IsCollapsed); // ...and the DB ends up agreeing with it, not with the stale mid-flight value. await using var db = NewContext(); Assert.False((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).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); } }