Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandDescriptionSaveRaceTests.cs
T
CubeGameLPandClaude Opus 5 0f04b796f4 feat(notes): notes get their own list, a day log and an Enter chain
The editor was reachable from one button that only appeared in My Day,
which is how it got forgotten. It is now a permanent sidebar entry next
to My Day and Planned, and it fills the tasks island rather than the
details pane, because a note list is content.

Inside, the date picker is gone: today sits at the top with the capture
box, older days follow as dated groups. Enter in a note saves it and
opens the next one, Shift+Enter breaks the line so notes can be several,
Backspace on an empty note deletes it and steps back up. Deleting also
has a visible hover button instead of only "clear the text and hope".
Agent-written notes carry a marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:19:37 +02:00

122 lines
4.6 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 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 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);
}
}