using System.Collections.ObjectModel; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; public class DetailsIslandPlanningTests : IDisposable { private readonly string _dbPath; public DetailsIslandPlanningTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_test_{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() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); } private sealed class FakeWorkerClient : StubWorkerClient { public MergeTargetsDto? MergeTargetsResult { get; set; } public override Task GetMergeTargetsAsync(string taskId) => Task.FromResult(MergeTargetsResult); } private sealed class NullServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; } private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi { public Task> ListAsync(DateOnly day) => Task.FromResult(new List()); public Task AddAsync(DateOnly day, string text) => Task.FromResult(null); public Task UpdateAsync(string id, string text) => Task.CompletedTask; public Task DeleteAsync(string id) => Task.CompletedTask; } private DetailsIslandViewModel BuildVm(StubWorkerClient worker) { var factory = new TestDbFactory(NewContext); return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi()); } // Connected worker whose review calls fail the way the hub does when the task // is no longer WaitingForReview (e.g. after "Merge all" folded the parent). private sealed class ThrowingReviewWorkerClient : StubWorkerClient { public override bool IsConnected => true; public override Task ApproveReviewAsync(string taskId, string targetBranch) => Task.FromException(new InvalidOperationException("Task is not waiting for review; cannot approve.")); } // ── Review-action resilience: a failing hub call must not crash the app ─── [Fact] public async Task ApproveReview_WhenWorkerThrows_DoesNotPropagate() { var vm = BuildVm(new ThrowingReviewWorkerClient()); vm.Bind(new TaskRowViewModel { Id = "p", Status = TaskStatus.WaitingForReview }); // Before the fix this surfaced the HubException as an unobserved // async-void exception from the command, crashing the process. var ex = await Record.ExceptionAsync(() => vm.ApproveReviewCommand.ExecuteAsync(null)); Assert.Null(ex); } // ── Branch-load test exercising the VM via Bind ────────────────────────── [Fact] public async Task MergeTargetBranches_LoadedFromWorkerOnPlanningParent() { // Seed a Planning parent with one child that has a worktree const string parentId = "parent-1"; const string childId = "child-1"; const string listId = "list-1"; await using (var ctx = NewContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "Parent", Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Active, CreatedAt = DateTime.UtcNow, }); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "Child", Status = TaskStatus.Done, ParentTaskId = parentId, CreatedAt = DateTime.UtcNow, }); ctx.Set().Add(new WorktreeEntity { TaskId = childId, Path = "/tmp/wt", BranchName = "branch", BaseCommit = "abc", CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var fake = new FakeWorkerClient { MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" }), }; var vm = BuildVm(fake); // Bind triggers BindAsync → LoadPlanningChildrenAsync → GetMergeTargetsAsync var parentRow = new TaskRowViewModel { Id = parentId }; parentRow.Status = TaskStatus.Idle; parentRow.PlanningPhase = PlanningPhase.Active; vm.Bind(parentRow); // Wait for the background load to settle var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline && vm.MergeTargetBranches.Count == 0) await Task.Delay(20); Assert.Contains("main", vm.MergeTargetBranches); Assert.Contains("dev", vm.MergeTargetBranches); Assert.Equal("main", vm.SelectedMergeTarget); } }