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; // Polish-Audit 2026-08-20 #1: typing in task A, then binding to task B inside the 400 ms debounce // window used to write A's text into B's row, because SaveDescriptionAsync/SaveTitleAsync read // `Task`/`EditableDescription` after the delay instead of capturing them at the call site. public class DetailsIslandDescriptionSaveRaceTests : IDisposable { private readonly string _dbPath; public DetailsIslandDescriptionSaveRaceTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_desc_race_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 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, string title, string? description) { 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 { Number = TestTaskNumbers.Next(), Id = id, ListId = "L1", Title = title, Description = description, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } private DetailsIslandViewModel BuildVm() => new(new TestDbFactory(NewContext), new FakeWorker(), new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); private async Task ReadTaskAsync(string id) { await using var db = NewContext(); return await db.Tasks.FirstAsync(t => t.Id == id); } [Fact] public async Task TypingInA_ThenTaskSwappedUnderneath_StillWritesOntoA_NeverB() { // Reproduces the exact split the audit called out: `Bind()` assigns `Task` synchronously, // while `EditableDescription` is only reset once `BindAsync`'s DB round trip completes — // so for a window, `Task` already points at B while `EditableDescription` still holds A's // freshly typed text. Setting `Task` directly (bypassing `Bind()`'s own DB round trip and // its now-added save cancellation) isolates that exact window deterministically, instead of // racing the real 400 ms debounce against however long a real bind happens to take. await SeedTaskAsync("A", "Task A", "original A"); await SeedTaskAsync("B", "Task B", "original B"); var vm = BuildVm(); vm.Bind(new TaskRowViewModel { Id = "A", Title = "Task A" }); await Task.Delay(50); vm.EditableDescription = "typed into A"; // Simulate the mid-debounce moment where Task already points at B but EditableDescription // has not been reset yet — without going through Bind() (which would cancel the pending // save and dispose this exact race). vm.Task = new TaskRowViewModel { Id = "B", Title = "Task B" }; await Task.Delay(600); // past the debounce window var a = await ReadTaskAsync("A"); var b = await ReadTaskAsync("B"); Assert.Equal("typed into A", a.Description); Assert.Equal("original B", b.Description); } [Fact] public async Task Bind_CancelsPendingTitleAndDescriptionSaves_ForThePreviousTask() { await SeedTaskAsync("A", "Task A", "original A"); await SeedTaskAsync("B", "Task B", "original B"); var vm = BuildVm(); vm.Bind(new TaskRowViewModel { Id = "A", Title = "Task A" }); await Task.Delay(50); vm.EditableTitle = "typed title A"; vm.EditableDescription = "typed desc A"; vm.Bind(new TaskRowViewModel { Id = "B", Title = "Task B" }); await Task.Delay(600); // past the debounce window var a = await ReadTaskAsync("A"); Assert.Equal("Task A", a.Title); Assert.Equal("original A", a.Description); } }