ToggleFolderAsync war ein async [RelayCommand]; AsyncRelayCommand sperrt sich waehrend des DB-Writes selbst, schnelle Klicks gingen verloren (Befund F1, docs/test-session-2026-09-16.md). Variante A: Toggle ist jetzt synchron (reiner UI-State), Persistierung fire-and-forget mit einem CancellationTokenSource pro Ordner, damit ein zweiter Klick vor dem ersten Schreibvorgang diesen abbricht - die DB landet immer beim zuletzt sichtbaren Zustand, nie bei einem veralteten. Gleiches Muster wie SaveTitleAsync/SaveDescriptionAsync in DetailsIslandViewModel. Gegenprueft: RemoveFromFolderAsync/CreateListAsync haben denselben async-RelayCommand-Schnitt, sind aber Einzelklick-Aktionen (Kontextmenue/ Button), kein Rapid-Click-Ziel wie der Ordner-Toggle - bewusst nicht umgebaut. Die Modal-oeffnenden Commands sind unkritisch, ein Modal blockiert ohnehin.
319 lines
11 KiB
C#
319 lines
11 KiB
C#
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 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);
|
|
}
|
|
}
|