Auto-seeding of monitor tiles was disabled in 724814f; Mission Control now only shows ConPTY panes. Removes Monitors/EnsureMonitor/SeedActive, the detach/re-dock machinery, MonitorPaneView and the detached monitor window, plus their tests and orphaned localization keys. TaskMonitorViewModel itself stays: DetailsIslandViewModel still uses it as the backing state for the task Log/AgentState/AskUser-question UI, so only its Mission-Control-only members (Title/DisplayTitle, detach, cancel command, IMissionControlPane) were stripped. IMissionControlPane/Panes were kept (now a 1:1 mirror of ConPtySessions) rather than dissolved, to avoid churning the still-live ConPTY pane tests and AXAML for a single-implementer interface. Visual verification still open: Mission Control with several open ConPTY tiles, and the Focus/Overview toggle.
346 lines
13 KiB
C#
346 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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|