Files
ClaudeDo/tests/ClaudeDo.Data.Tests/ListFolderTests.cs
T
Mika KunsandClaude Sonnet 5 0dac047c46 feat(lists): allow nested sidebar folders via drag and drop
Folders can now contain other folders, to arbitrary depth, via a
self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as
lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a
folder onto another folder's header middle moves it in, the outer
quarters or any top-level/in-folder drop insert it beside a target.
Dropping a folder into itself or one of its own subfolders is silently
ignored. Deleting a folder releases its lists and subfolders back to
the top level, unchanged from the existing list behavior.

ListFolderNavItemViewModel.Lists became Children (object, mixing lists
and subfolders, mirroring TopLevel) so the existing implicit, type-matched
DataTemplates render nested folders recursively without new XAML, and each
nesting level gets its indent for free from the folder template's own
margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts
now walk the tree instead of assuming one level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:31:26 +02:00

193 lines
7.1 KiB
C#

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 ReorderSidebarAsync_numbers_folders_and_lists_in_one_sequence()
{
await using var ctx = Open();
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1"));
await repo.AddAsync(new ListEntity { Id = "inside", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" });
await repo.AddAsync(new ListEntity { Id = "top", Name = "Top", CreatedAt = DateTime.UtcNow });
// Sidebar: the top-level list above the folder, whose one child follows the folder.
await repo.ReorderSidebarAsync([("top", false), ("f1", true), ("inside", false)]);
Assert.Equal(0, (await repo.GetByIdAsync("top"))!.SortOrder);
Assert.Equal(1, (await repo.GetFoldersAsync()).Single().SortOrder);
Assert.Equal(2, (await repo.GetByIdAsync("inside"))!.SortOrder);
}
[Fact]
public async Task AddAsync_appends_past_the_folders_too()
{
await using var ctx = Open();
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1"));
await repo.AddFolderAsync(NewFolder("f2"));
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Fresh", CreatedAt = DateTime.UtcNow });
// 2, not 0 — a new list must not jump above the existing folders on the next load.
Assert.Equal(2, (await repo.GetByIdAsync("l1"))!.SortOrder);
}
[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));
}
[Fact]
public async Task SetFolderParentAsync_nests_a_folder_and_moves_it_back_out()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
}
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", "parent");
await using (var ctx = Open())
Assert.Equal("parent", (await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", null);
await using (var ctx = Open())
Assert.Null((await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
}
[Fact]
public async Task Deleting_a_folder_keeps_its_subfolder_and_moves_it_to_the_top_level()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
await repo.SetFolderParentAsync("child", "parent");
}
await using (var ctx = Open())
await new ListRepository(ctx).DeleteFolderAsync("parent");
await using (var ctx = Open())
{
var child = await ctx.ListFolders.AsNoTracking().SingleAsync();
Assert.Equal("child", child.Id);
Assert.Null(child.ParentFolderId);
}
}
}