Dragging a task row onto a user list in the Lists island now reassigns it to that list. The task drag holds the pointer capture, so the release is resolved geometrically (new case between the Mission Control and reorder cases) instead of going through the Lists island's own DragDrop path, which never sees a DragEventArgs during a task drag. TaskRepository.MoveToListAsync reassigns the task plus every descendant (a child must never sit in a different list than its parent) and appends the task at the end of the target list. Guards: running tasks and tasks holding an Active/Kept worktree are rejected to the footer error strip; a move that changes repo asks for confirmation naming both repos. The source repo is read from the task's own list rather than the island's current list, which is a smart/virtual list with no working dir of its own whenever one of those is shown.
347 lines
13 KiB
C#
347 lines
13 KiB
C#
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<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
return new ClaudeDoDbContext(opts);
|
|
}
|
|
|
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
private readonly Func<ClaudeDoDbContext> _create;
|
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
|
}
|
|
|
|
private sealed class FakeDialogService : IDialogService
|
|
{
|
|
public bool ConfirmResult;
|
|
public int ConfirmCalls;
|
|
public string? LastMessage;
|
|
|
|
public Task<bool> 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 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<IReadOnlyList<string>?> 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();
|
|
public void ShowDetachedMonitor(TaskMonitorViewModel monitor, Action onClosed) => 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<TaskEntity> 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);
|
|
}
|
|
}
|