Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandMoveToListTests.cs
T
mika kuns 8da1a12389 fix(test): give Ui.Tests task seeds a unique Number
The task-numbers slice added a UNIQUE index on tasks.number. Ui.Tests seed
TaskEntity rows directly instead of going through TaskRepository, so every row
kept the default Number = 0 and 31 tests failed with
'UNIQUE constraint failed: tasks.number'.
2026-08-11 11:32:01 +02:00

348 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 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<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();
}
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
{
Number = TestTaskNumbers.Next(),
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);
}
}