using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Localization; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; // UX-Audit #1: Stop/Enqueue/Dequeue/Reset&Retry used to swallow worker failures with a // bare `catch { }` — nothing surfaced in the footer strip. These now raise ErrorReported. public class DetailsIslandErrorFeedbackTests : IDisposable { private readonly string _dbPath; public DetailsIslandErrorFeedbackTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_error_feedback_test_{Guid.NewGuid():N}.db"); using var ctx = NewContext(); ctx.Database.EnsureCreated(); // Loc is a process-wide ambient singleton other tests also mutate — pin it to // the real locale data so this test's assertions don't depend on run order. var dir = AppContext.BaseDirectory; while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) dir = Path.GetDirectoryName(dir); Loc.Current = new Localizer( LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); } 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 ThrowingWorkerClient : StubWorkerClient { public bool Connected { get; set; } = true; public override bool IsConnected => Connected; public Exception? ThrowOnCancelTask; public Exception? ThrowOnSetTaskStatus; public Exception? ThrowOnSetTaskDone; public Exception? ThrowOnUnsetTaskDone; public override Task CancelTaskAsync(string taskId) { if (ThrowOnCancelTask is not null) throw ThrowOnCancelTask; return Task.CompletedTask; } public override Task SetTaskStatusAsync(string taskId, TaskStatus status) { if (ThrowOnSetTaskStatus is not null) throw ThrowOnSetTaskStatus; return Task.FromResult(null); } public override Task SetTaskDoneAsync(string taskId) { if (ThrowOnSetTaskDone is not null) throw ThrowOnSetTaskDone; return Task.CompletedTask; } public override Task UnsetTaskDoneAsync(string taskId) { if (ThrowOnUnsetTaskDone is not null) throw ThrowOnUnsetTaskDone; return Task.CompletedTask; } } private DetailsIslandViewModel BuildVm(StubWorkerClient worker) { var factory = new TestDbFactory(NewContext); return new DetailsIslandViewModel( factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); } [Fact] public async Task Stop_WhenWorkerOffline_ReportsError() { var worker = new ThrowingWorkerClient { Connected = false }; var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-stop-offline", Status = TaskStatus.Running }); vm.Monitor.ApplyState(TaskStatus.Running); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.StopCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.NotEqual(string.Empty, reportedError); } [Fact] public async Task Stop_WhenWorkerThrows_ReportsError() { var worker = new ThrowingWorkerClient { ThrowOnCancelTask = new Exception("cancel slot busy") }; var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-stop-throw", Status = TaskStatus.Running }); vm.Monitor.ApplyState(TaskStatus.Running); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.StopCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.Contains("cancel slot busy", reportedError); } [Fact] public async Task Enqueue_WhenWorkerThrows_ReportsError() { var worker = new ThrowingWorkerClient { ThrowOnSetTaskStatus = new Exception("queue offline") }; var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-enqueue-throw", Status = TaskStatus.Idle }); vm.Monitor.ApplyState(TaskStatus.Idle); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.EnqueueCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.Contains("queue offline", reportedError); } [Fact] public async Task Dequeue_WhenWorkerThrows_ReportsError() { var worker = new ThrowingWorkerClient { ThrowOnSetTaskStatus = new Exception("dequeue offline") }; var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-dequeue-throw", Status = TaskStatus.Queued }); vm.Monitor.ApplyState(TaskStatus.Queued); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.DequeueCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.Contains("dequeue offline", reportedError); } [Fact] public async Task ResetAndRetry_WhenWorkerThrows_ReportsError() { var worker = new ThrowingWorkerClient { ThrowOnSetTaskStatus = new Exception("reset offline") }; var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-reset-throw", Status = TaskStatus.Failed }); vm.Monitor.ApplyState(TaskStatus.Failed); vm.ConfirmAsync = _ => Task.FromResult(true); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.ResetAndRetryCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.Contains("reset offline", reportedError); } [Fact] public async Task ToggleDone_MarkDone_WhenWorkerThrows_ReportsError_AndRevertsTask() { var worker = new ThrowingWorkerClient { ThrowOnSetTaskDone = new Exception("mark done offline") }; var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-toggle-done-throw", Status = TaskStatus.Idle, Done = false }); vm.Monitor.ApplyState(TaskStatus.Idle); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.ToggleDoneCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.Contains("mark done offline", reportedError); Assert.False(vm.Task!.Done); Assert.Equal(TaskStatus.Idle, vm.Task!.Status); } [Fact] public async Task ToggleDone_Untoggle_WhenWorkerThrows_ReportsError_AndRevertsTask() { var worker = new ThrowingWorkerClient { ThrowOnUnsetTaskDone = new Exception("unmark done offline") }; var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-untoggle-done-throw", Status = TaskStatus.Done, Done = true }); vm.Monitor.ApplyState(TaskStatus.Done); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.ToggleDoneCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.Contains("unmark done offline", reportedError); Assert.True(vm.Task!.Done); Assert.Equal(TaskStatus.Done, vm.Task!.Status); } [Fact] public async Task ToggleDone_MarkDone_WhenWorkerSucceeds_UpdatesTask_NoError() { var worker = new ThrowingWorkerClient(); var vm = BuildVm(worker); vm.Bind(new TaskRowViewModel { Id = "task-toggle-done-ok", Status = TaskStatus.Idle, Done = false }); vm.Monitor.ApplyState(TaskStatus.Idle); string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.ToggleDoneCommand.ExecuteAsync(null); Assert.Null(reportedError); Assert.True(vm.Task!.Done); Assert.Equal(TaskStatus.Done, vm.Task!.Status); } }