using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Conflicts; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; public class TasksIslandMoveToListTests : IDisposable { private readonly string _dbPath; public TasksIslandMoveToListTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_movetolist_{Guid.NewGuid():N}.db"); using var ctx = NewContext(); ctx.Database.EnsureCreated(); } public void Dispose() { try { File.Delete(_dbPath); } catch { } try { File.Delete(_dbPath + "-wal"); } catch { } try { File.Delete(_dbPath + "-shm"); } catch { } } private ClaudeDoDbContext NewContext() { var opts = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); } private sealed class FakeDialogService : IDialogService { public bool ConfirmResult; public int ConfirmCalls; public string? LastMessage; public Task ConfirmAsync(string message) { ConfirmCalls++; LastMessage = message; return Task.FromResult(ConfirmResult); } public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException(); public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException(); public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException(); public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException(); public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException(); public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException(); public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException(); public Task?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException(); public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException(); public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException(); public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException(); public Task ShowErrorAsync(string message) => Task.CompletedTask; public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); } private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null); private async Task SeedListsAsync(string? sourceDir = null, string? targetDir = null) { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "source", Name = "Source", CreatedAt = DateTime.UtcNow, WorkingDir = sourceDir }); db.Lists.Add(new ListEntity { Id = "target", Name = "Target", CreatedAt = DateTime.UtcNow, WorkingDir = targetDir }); await db.SaveChangesAsync(); } private async Task SeedTaskAsync(string id, TaskStatus status, string listId = "source", string? parentTaskId = null) { await using var db = NewContext(); db.Tasks.Add(new TaskEntity { Id = id, ListId = listId, Title = id, CreatedAt = DateTime.UtcNow, Status = status, ParentTaskId = parentTaskId, SortOrder = 0, }); await db.SaveChangesAsync(); } private async Task SeedWorktreeAsync(string taskId, WorktreeState state) { await using var db = NewContext(); db.Worktrees.Add(new WorktreeEntity { TaskId = taskId, Path = $"C:/wt/{taskId}", BranchName = $"task/{taskId}", BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } private static ListNavItemViewModel SourceList(string? workingDir = null) => new() { Id = "user:source", Kind = ListKind.User, Name = "Source", WorkingDir = workingDir }; private static ListNavItemViewModel TargetList(string? workingDir = null) => new() { Id = "user:target", Kind = ListKind.User, Name = "Target", WorkingDir = workingDir }; private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list) { vm.LoadForList(list); var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline) { await Task.Delay(25); if (vm.Items.Count > 0) break; } await Task.Delay(50); } private async Task GetTaskAsync(string id) { await using var db = NewContext(); return await db.Tasks.AsNoTracking().FirstAsync(t => t.Id == id); } [Fact] public async Task Move_RunningTask_IsRejected_AndReportsError() { await SeedListsAsync(); await SeedTaskAsync("t1", TaskStatus.Running); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList()); string? error = null; vm.ErrorReported += msg => error = msg; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList()); Assert.NotNull(error); var entity = await GetTaskAsync("t1"); Assert.Equal("source", entity.ListId); } [Fact] public async Task Move_TaskWithActiveWorktree_IsRejected_AndReportsError_EvenSameRepo() { var dir = "C:/repo"; await SeedListsAsync(dir, dir); await SeedTaskAsync("t1", TaskStatus.Idle); await SeedWorktreeAsync("t1", WorktreeState.Active); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList(dir)); string? error = null; vm.ErrorReported += msg => error = msg; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList(dir)); Assert.NotNull(error); var entity = await GetTaskAsync("t1"); Assert.Equal("source", entity.ListId); } [Fact] public async Task Move_DescendantWithKeptWorktree_IsRejected() { await SeedListsAsync(); await SeedTaskAsync("parent", TaskStatus.Idle); await SeedTaskAsync("child", TaskStatus.Idle, parentTaskId: "parent"); await SeedWorktreeAsync("child", WorktreeState.Kept); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList()); string? error = null; vm.ErrorReported += msg => error = msg; var row = vm.Items.First(r => r.Id == "parent"); await vm.MoveTaskToListAsync(row, TargetList()); Assert.NotNull(error); var entity = await GetTaskAsync("parent"); Assert.Equal("source", entity.ListId); } [Fact] public async Task Move_SameWorkingDir_MovesWithoutConfirmDialog() { var dir = "C:/repo"; await SeedListsAsync(dir, dir); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList(dir)); var dialogs = new FakeDialogService(); vm.Dialogs = dialogs; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList(dir)); Assert.Equal(0, dialogs.ConfirmCalls); var entity = await GetTaskAsync("t1"); Assert.Equal("target", entity.ListId); } [Fact] public async Task Move_BothWithoutWorkingDir_MovesWithoutConfirmDialog() { await SeedListsAsync(); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList()); var dialogs = new FakeDialogService(); vm.Dialogs = dialogs; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList()); Assert.Equal(0, dialogs.ConfirmCalls); var entity = await GetTaskAsync("t1"); Assert.Equal("target", entity.ListId); } [Fact] public async Task Move_DifferentWorkingDir_AsksConfirmation_MovesOnConfirm() { await SeedListsAsync("C:/repoA", "C:/repoB"); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList("C:/repoA")); var dialogs = new FakeDialogService { ConfirmResult = true }; vm.Dialogs = dialogs; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList("C:/repoB")); Assert.Equal(1, dialogs.ConfirmCalls); var entity = await GetTaskAsync("t1"); Assert.Equal("target", entity.ListId); } [Fact] public async Task Move_DifferentWorkingDir_CancelledConfirmation_DoesNotMove() { await SeedListsAsync("C:/repoA", "C:/repoB"); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList("C:/repoA")); var dialogs = new FakeDialogService { ConfirmResult = false }; vm.Dialogs = dialogs; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList("C:/repoB")); Assert.Equal(1, dialogs.ConfirmCalls); var entity = await GetTaskAsync("t1"); Assert.Equal("source", entity.ListId); } [Fact] public async Task Move_TargetIsCurrentList_IsNoOp() { await SeedListsAsync(); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList()); var dialogs = new FakeDialogService(); vm.Dialogs = dialogs; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, SourceList()); Assert.Equal(0, dialogs.ConfirmCalls); var entity = await GetTaskAsync("t1"); Assert.Equal("source", entity.ListId); } [Fact] public async Task Move_DifferentWorkingDir_ConfirmMessageNamesBothRepos() { await SeedListsAsync("C:/repoA", "C:/repoB"); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList("C:/repoA")); var dialogs = new FakeDialogService { ConfirmResult = false }; vm.Dialogs = dialogs; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList("C:/repoB")); Assert.NotNull(dialogs.LastMessage); Assert.Contains("C:/repoA", dialogs.LastMessage); Assert.Contains("C:/repoB", dialogs.LastMessage); } // The source repo comes from the task's own list, not from the island's current list — those // differ whenever a smart/virtual list is shown, and a wrong source dir would ask (or skip // asking) for confirmation at the wrong times. [Fact] public async Task Move_SourceRepoIsReadFromTaskOwnList_NotCurrentList() { await SeedListsAsync("C:/repoA", "C:/repoA"); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); // Island shows a virtual list carrying no working dir of its own. await LoadAndWaitAsync(vm, SourceList(workingDir: null)); var dialogs = new FakeDialogService { ConfirmResult = true }; vm.Dialogs = dialogs; var row = vm.Items.First(r => r.Id == "t1"); await vm.MoveTaskToListAsync(row, TargetList("C:/repoA")); // Both lists really are the same repo, so no confirmation should have been needed. Assert.Equal(0, dialogs.ConfirmCalls); var entity = await GetTaskAsync("t1"); Assert.Equal("target", entity.ListId); } [Fact] public async Task Move_TargetIsSmartList_IsNoOp() { await SeedListsAsync(); await SeedTaskAsync("t1", TaskStatus.Idle); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, SourceList()); var row = vm.Items.First(r => r.Id == "t1"); var smart = new ListNavItemViewModel { Id = "smart:my-day", Kind = ListKind.Smart, Name = "My Day" }; await vm.MoveTaskToListAsync(row, smart); var entity = await GetTaskAsync("t1"); Assert.Equal("source", entity.ListId); } }