test: cover list folders in the data and sidebar layers
Data: folder delete keeps its lists (ON DELETE SET NULL), rename patches name/icon without resetting sort order or collapse state. Sidebar: grouping on load, folder badge as the sum of its children, both move routes, and the persisted collapse state. Drops the LoadAsync/AddRowAsync normalization of a dangling folder_id: FK enforcement is on for every connection, so the row it guarded against cannot exist, and ContainerOf already falls back to the top level.
This commit is contained in:
@@ -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<string>(new[] { "My Day", "Important", "Planned" });
|
||||
|
||||
@@ -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<ClaudeDoDbContext> _options;
|
||||
|
||||
public ListFolderTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_folders_{Guid.NewGuid():N}.db");
|
||||
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<ClaudeDoDbContext>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user