fix(ui): Reactivität — Description-Save-Race, UsageMonitor-Leak, Listen-Live-Refresh
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.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
// Polish-Audit 2026-08-20 #3: a list created by a running MCP session broadcasts ListUpdated with
|
||||
// the raw list id, which RefreshRowAsync had no add-branch for — the list stayed invisible until
|
||||
// the next app restart. And a reconnect only refreshed counts, missing offline list changes.
|
||||
public class ListsIslandListUpdatedTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public ListsIslandListUpdatedTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_lists_updated_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
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<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 FakeWorker : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
}
|
||||
|
||||
private async Task SeedListAsync(string id, string name, int sortOrder)
|
||||
{
|
||||
await using var db = NewContext();
|
||||
db.Lists.Add(new ListEntity { Id = id, Name = name, SortOrder = sortOrder, CreatedAt = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListUpdated_ForAnIdNotYetLoaded_AddsItToUserLists()
|
||||
{
|
||||
await SeedListAsync("existing", "Existing", sortOrder: 0);
|
||||
|
||||
var worker = new FakeWorker();
|
||||
var vm = new ListsIslandViewModel(new TestDbFactory(NewContext), worker: worker);
|
||||
await vm.LoadAsync();
|
||||
Assert.Single(vm.UserLists);
|
||||
|
||||
// A running MCP session creates the list after our initial load — the worker broadcasts
|
||||
// the raw db id, never the "user:" prefixed row id.
|
||||
await SeedListAsync("new-from-mcp", "Created via MCP", sortOrder: 1);
|
||||
worker.RaiseListUpdated("new-from-mcp");
|
||||
await Task.Delay(100);
|
||||
|
||||
Assert.Equal(2, vm.UserLists.Count);
|
||||
Assert.Contains(vm.UserLists, r => r.Id == "user:new-from-mcp" && r.Name == "Created via MCP");
|
||||
// Inserted at its DB sort position (after "existing"), not just appended — same relative
|
||||
// order as if it had been present since LoadAsync.
|
||||
Assert.Equal("user:existing", vm.UserLists[0].Id);
|
||||
Assert.Equal("user:new-from-mcp", vm.UserLists[1].Id);
|
||||
Assert.Contains(vm.Items, r => r.Id == "user:new-from-mcp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListUpdated_ForAnIdMissingFromTheDbToo_DoesNothingAndDoesNotThrow()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new ListsIslandViewModel(new TestDbFactory(NewContext), worker: worker);
|
||||
await vm.LoadAsync();
|
||||
var countBefore = vm.UserLists.Count;
|
||||
|
||||
worker.RaiseListUpdated("ghost-id");
|
||||
await Task.Delay(100);
|
||||
|
||||
Assert.Equal(countBefore, vm.UserLists.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectionRestored_FullyReloadsLists_AndKeepsSelectionIfStillPresent()
|
||||
{
|
||||
await SeedListAsync("keep-me", "Keep Me", sortOrder: 0);
|
||||
|
||||
var worker = new FakeWorker();
|
||||
var vm = new ListsIslandViewModel(new TestDbFactory(NewContext), worker: worker);
|
||||
await vm.LoadAsync();
|
||||
var toSelect = vm.UserLists.Single(r => r.Id == "user:keep-me");
|
||||
vm.SelectedList = toSelect;
|
||||
|
||||
// A list created while offline — the reconnect must pick it up, unlike a bare count refresh.
|
||||
await SeedListAsync("came-in-offline", "Came In Offline", sortOrder: 1);
|
||||
|
||||
worker.RaiseConnectionRestored();
|
||||
await Task.Delay(150);
|
||||
|
||||
Assert.Contains(vm.UserLists, r => r.Id == "user:came-in-offline");
|
||||
Assert.NotNull(vm.SelectedList);
|
||||
Assert.Equal("user:keep-me", vm.SelectedList!.Id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user