Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeleteGuardTests.cs
T
Mika Kuns c1ab042d0a fix(ui): block delete on interactive-session tasks, clear ghost selection
Delete (row context menu + detail header) now gates on HasInteractiveSession
the same way SendToQueue already does, so an open ConPTY session's claude
process can't be orphaned by deleting its task out from under it.

Row removal (in-UI delete and the delta-refresh path for externally deleted
tasks) now clears SelectedTask via SelectFrom(null, "row-removed") when the
removed row was selected, so the detail pane no longer keeps showing a
deleted task with live buttons.
2026-08-26 15:46:40 +02:00

119 lines
4.3 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
// Deleting a row with a live ConPTY session would orphan the claude process in Mission Control
// (WorkerHub.DeleteTask only blocks Status == Running, an interactive session stays Idle) — the
// row command must gate on HasInteractiveSession the same way SendToQueue already does. Separately,
// removing the selected row (in-UI delete or an externally-driven delta removal) must clear
// SelectedTask, otherwise the detail pane keeps showing a deleted task with live buttons.
public class TasksIslandDeleteGuardTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandDeleteGuardTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_delete_guard_{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 RecordingWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
public string? DeletedTaskId { get; private set; }
public override Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
{
DeletedTaskId = taskId;
return Task.FromResult<(bool, string?)>((true, null));
}
}
[Fact]
public async Task DeleteTaskCommand_RowHasInteractiveSession_NoOpAndNoHubCall()
{
var worker = new RecordingWorkerClient();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
var row = new TaskRowViewModel { Id = "T1", Status = TaskStatus.Idle, HasInteractiveSession = true };
vm.Items.Add(row);
await vm.DeleteTaskCommand.ExecuteAsync(row);
Assert.Null(worker.DeletedTaskId);
Assert.Contains(row, vm.Items);
}
[Fact]
public async Task DeleteTaskCommand_RemovesSelectedRow_ClearsSelection()
{
var worker = new RecordingWorkerClient();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
var row = new TaskRowViewModel { Id = "T2", Status = TaskStatus.Idle };
vm.Items.Add(row);
vm.SelectFrom(row, "test");
await vm.DeleteTaskCommand.ExecuteAsync(row);
Assert.Equal("T2", worker.DeletedTaskId);
Assert.DoesNotContain(row, vm.Items);
Assert.Null(vm.SelectedTask);
}
[Fact]
public async Task DeltaRemoval_OfSelectedRow_ClearsSelection()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity
{
Number = TestTaskNumbers.Next(),
Id = "T3", ListId = "L1", Title = "Deleted elsewhere",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
await db.SaveChangesAsync();
}
var worker = new RecordingWorkerClient();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker);
vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User });
if (vm.LoadTask is { } lt) await lt;
Assert.True(await vm.SelectByIdAsync("T3"));
Assert.NotNull(vm.SelectedTask);
await using (var db = NewContext())
{
db.Tasks.Remove(await db.Tasks.FirstAsync(t => t.Id == "T3"));
await db.SaveChangesAsync();
}
worker.RaiseTaskUpdated("T3");
var deadline = DateTime.UtcNow.AddSeconds(3);
while (DateTime.UtcNow < deadline && vm.Items.Any(r => r.Id == "T3"))
await Task.Delay(25);
Assert.DoesNotContain(vm.Items, r => r.Id == "T3");
Assert.Null(vm.SelectedTask);
}
}