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.
144 lines
5.0 KiB
C#
144 lines
5.0 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Data.Tests;
|
|
|
|
public sealed class MoveToListTests : IDisposable
|
|
{
|
|
private readonly string _dbPath;
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
|
|
public MoveToListTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_movelist_{Guid.NewGuid():N}.db");
|
|
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
_ctx = new ClaudeDoDbContext(options);
|
|
_ctx.Database.EnsureCreated();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ctx.Dispose();
|
|
try { File.Delete(_dbPath); } catch { }
|
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
|
}
|
|
|
|
private async Task SeedListsAsync(params string[] ids)
|
|
{
|
|
foreach (var id in ids)
|
|
_ctx.Lists.Add(new ListEntity { Id = id, Name = id, CreatedAt = DateTime.UtcNow });
|
|
await _ctx.SaveChangesAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_changes_ListId_and_appends_at_end_of_target()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
// Existing tasks already in the target list, so we can assert the moved task lands after them.
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "existing1", ListId = "target", Title = "Existing 1", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "existing2", ListId = "target", Title = "Existing 2", CreatedAt = DateTime.UtcNow, SortOrder = 1,
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
|
|
|
|
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal("target", moved.ListId);
|
|
Assert.Equal(2, moved.SortOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_moves_all_descendants_recursively()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "parent", ListId = "source", Title = "Parent", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "child", ListId = "source", Title = "Child", CreatedAt = DateTime.UtcNow,
|
|
SortOrder = 1, ParentTaskId = "parent",
|
|
});
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "grandchild", ListId = "source", Title = "Grandchild", CreatedAt = DateTime.UtcNow,
|
|
SortOrder = 2, ParentTaskId = "child",
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("parent", "target");
|
|
|
|
var all = await _ctx.Tasks.AsNoTracking()
|
|
.Where(t => t.Id == "parent" || t.Id == "child" || t.Id == "grandchild")
|
|
.ToListAsync();
|
|
|
|
Assert.All(all, t => Assert.Equal("target", t.ListId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_first_task_in_empty_target_gets_SortOrder_zero()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 3,
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
|
|
|
|
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal("target", moved.ListId);
|
|
Assert.Equal(0, moved.SortOrder);
|
|
}
|
|
|
|
// A corrupt parent chain must not hang the descendant walk.
|
|
[Fact(Timeout = 15000)]
|
|
public async Task MoveToList_survives_a_self_parenting_task()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
_ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
|
});
|
|
await _ctx.SaveChangesAsync();
|
|
// Set the cycle after insert — the FK tolerates it, the walk must too.
|
|
await _ctx.Tasks.Where(t => t.Id == "t1")
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, "t1"));
|
|
_ctx.ChangeTracker.Clear();
|
|
|
|
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
|
|
|
|
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal("target", moved.ListId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToList_throws_when_task_missing()
|
|
{
|
|
await SeedListsAsync("source", "target");
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
new TaskRepository(_ctx).MoveToListAsync("nope", "target"));
|
|
}
|
|
}
|