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; // A status change on the task bound in the detail pane can come from outside the UI (worker run, // MCP tool, another session) and must refresh the pane's Task without waiting for a list reload. public class DetailsIslandTaskUpdatedTests : IDisposable { private readonly string _dbPath; public DetailsIslandTaskUpdatedTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_taskupdated_{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 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 sealed class FakeWorker : StubWorkerClient { public override bool IsConnected => true; } private async Task SeedTaskAsync(string id, TaskStatus status) { await using var db = NewContext(); if (!await db.Lists.AnyAsync(l => l.Id == "L1")) db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = id, ListId = "L1", Title = "Task", Status = status, CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } private DetailsIslandViewModel BuildVm(StubWorkerClient worker) => new(new TestDbFactory(NewContext), worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); [Fact] public async Task TaskUpdated_ForBoundTask_RefreshesStatusAndPickUpInTerminal() { await SeedTaskAsync("t-bound-1", TaskStatus.Running); var worker = new FakeWorker(); var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "t-bound-1", Status = TaskStatus.Running }); await Task.Delay(50); Assert.False(vm.CanPickUpInTerminal); await using (var db = NewContext()) { (await db.Tasks.FirstAsync(t => t.Id == "t-bound-1")).Status = TaskStatus.WaitingForReview; await db.SaveChangesAsync(); } worker.RaiseTaskUpdated("t-bound-1"); var deadline = DateTime.UtcNow.AddSeconds(3); while (DateTime.UtcNow < deadline && vm.Task?.Status != TaskStatus.WaitingForReview) await Task.Delay(25); Assert.Equal(TaskStatus.WaitingForReview, vm.Task?.Status); Assert.True(vm.CanPickUpInTerminal); } [Fact] public async Task TaskUpdated_ForADifferentTask_DoesNotTouchBoundTask() { await SeedTaskAsync("t-bound-2", TaskStatus.Running); await SeedTaskAsync("t-other-2", TaskStatus.Idle); var worker = new FakeWorker(); var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "t-bound-2", Status = TaskStatus.Running }); await using (var db = NewContext()) { (await db.Tasks.FirstAsync(t => t.Id == "t-other-2")).Status = TaskStatus.Done; await db.SaveChangesAsync(); } worker.RaiseTaskUpdated("t-other-2"); await Task.Delay(150); Assert.Equal(TaskStatus.Running, vm.Task?.Status); } }