Drei unabhängige Reactivity-Bugs aus dem Polish-Audit 2026-08-20: 1. Description-Autosave überschrieb den falschen Task, weil SaveDescriptionAsync Task.Id/EditableDescription erst nach dem 400ms-Debounce las statt Row+Wert an der Aufrufstelle zu capturen (wie SaveTitleAsync es schon tat). Bind() cancelt jetzt zusätzlich einen laufenden Title-/Description-Save der vorherigen Row. 2. UsageMonitorModalViewModel abonnierte UsageUpdatedEvent erst nach dem Erst-Load-Await — schloss man das Modal währenddessen, lief das Unsubscribe in Close() ins Leere und die VM hing für immer am WorkerClient. Ein _isClosed-Flag wird jetzt nach dem Await geprüft, bevor abonniert wird. 3. Per MCP erstellte Listen blieben unsichtbar: RefreshRowAsync hatte keinen Add-Zweig für unbekannte Ids und verglich zudem die falsche Id-Form (der Worker broadcastet die rohe DB-Id, nie die "user:"-prefixte Row-Id). Ein Reconnect lud zudem nur Counts statt der vollen Listen neu.
141 lines
5.5 KiB
C#
141 lines
5.5 KiB
C#
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<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
return new ClaudeDoDbContext(opts);
|
|
}
|
|
|
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
private readonly Func<ClaudeDoDbContext> _create;
|
|
public TestDbFactory(Func<ClaudeDoDbContext> 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<List<DailyNoteDto>> ListAsync(DateOnly day) =>
|
|
Task.FromResult(new List<DailyNoteDto>());
|
|
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
|
|
Task.FromResult<DailyNoteDto?>(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<TaskEntity> 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);
|
|
}
|
|
}
|