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:
@@ -570,20 +570,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (_suppressDescSave || Task is null) return;
|
if (_suppressDescSave || Task is null) return;
|
||||||
_descSaveCts?.Cancel();
|
_descSaveCts?.Cancel();
|
||||||
_descSaveCts = new CancellationTokenSource();
|
_descSaveCts = new CancellationTokenSource();
|
||||||
_ = SaveDescriptionAsync(_descSaveCts.Token);
|
// Capture the row so a task switch mid-debounce cannot write A's text into B's description.
|
||||||
|
_ = SaveDescriptionAsync(Task, value, _descSaveCts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct)
|
private async System.Threading.Tasks.Task SaveDescriptionAsync(
|
||||||
|
TaskRowViewModel row, string value, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await System.Threading.Tasks.Task.Delay(400, ct);
|
await System.Threading.Tasks.Task.Delay(400, ct);
|
||||||
if (Task is null) return;
|
|
||||||
await using var ctx = _dbFactory.CreateDbContext();
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
var repo = new TaskRepository(ctx);
|
var repo = new TaskRepository(ctx);
|
||||||
var entity = await repo.GetByIdAsync(Task.Id);
|
var entity = await repo.GetByIdAsync(row.Id);
|
||||||
if (entity is null) return;
|
if (entity is null) return;
|
||||||
entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription;
|
entity.Description = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
await repo.UpdateAsync(entity);
|
await repo.UpdateAsync(entity);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
@@ -617,6 +618,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_loadCts = new CancellationTokenSource();
|
_loadCts = new CancellationTokenSource();
|
||||||
var ct = _loadCts.Token;
|
var ct = _loadCts.Token;
|
||||||
|
|
||||||
|
// A pending debounced title/description save targets the row being unbound —
|
||||||
|
// let it be, don't have it land on whichever row replaces it.
|
||||||
|
_titleSaveCts?.Cancel();
|
||||||
|
_descSaveCts?.Cancel();
|
||||||
|
|
||||||
Task = row;
|
Task = row;
|
||||||
OnPropertyChanged(nameof(TaskIdBadge));
|
OnPropertyChanged(nameof(TaskIdBadge));
|
||||||
Monitor.Reset();
|
Monitor.Reset();
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||||
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||||
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
|
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||||
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
_worker.ConnectionRestoredEvent += () => _ = ReloadAfterReconnectAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
|
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
|
||||||
@@ -295,6 +295,19 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
SelectedList = Items.FirstOrDefault();
|
SelectedList = Items.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reconnect can have missed offline list creates/renames/deletes (SignalR was down) — unlike
|
||||||
|
/// the task/worktree events, which only need a count refresh — so this re-reads the full list
|
||||||
|
/// set instead, restoring the prior selection if it still exists.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReloadAfterReconnectAsync()
|
||||||
|
{
|
||||||
|
var selectedId = SelectedList?.Id;
|
||||||
|
await LoadAsync();
|
||||||
|
if (selectedId is not null && Items.FirstOrDefault(i => i.Id == selectedId) is { } restored)
|
||||||
|
SelectedList = restored;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
@@ -438,15 +451,24 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// The worker broadcasts the raw list id (ListMcpTools/ConfigMcpTools), never the
|
||||||
|
// "user:" prefixed row id used in this VM's own collections — match on the raw id
|
||||||
|
// either way so an MCP-driven create/rename is recognized against the loaded row.
|
||||||
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
|
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
|
||||||
var row = UserLists.FirstOrDefault(r => r.Id == rowId);
|
var row = UserLists.FirstOrDefault(r =>
|
||||||
if (row is null) return;
|
(r.Id.StartsWith("user:") ? r.Id["user:".Length..] : r.Id) == rawId);
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
var lists = new ListRepository(ctx);
|
var lists = new ListRepository(ctx);
|
||||||
var entity = await lists.GetByIdAsync(rawId);
|
var entity = await lists.GetByIdAsync(rawId);
|
||||||
if (entity is null) return;
|
if (entity is null) return;
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
await AddRowAsync(lists, entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
row.Name = entity.Name;
|
row.Name = entity.Name;
|
||||||
row.WorkingDir = entity.WorkingDir;
|
row.WorkingDir = entity.WorkingDir;
|
||||||
row.DefaultCommitType = entity.DefaultCommitType;
|
row.DefaultCommitType = entity.DefaultCommitType;
|
||||||
@@ -455,4 +477,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
catch { /* best-effort refresh */ }
|
catch { /* best-effort refresh */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A `create_list` from a running MCP session has no local row yet — build one from the DB
|
||||||
|
/// entity and insert it at its DB sort position, instead of waiting for the next full reload.
|
||||||
|
/// </summary>
|
||||||
|
private async Task AddRowAsync(ListRepository lists, ListEntity entity)
|
||||||
|
{
|
||||||
|
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||||
|
var item = new ListNavItemViewModel
|
||||||
|
{
|
||||||
|
Id = $"user:{entity.Id}",
|
||||||
|
Name = entity.Name,
|
||||||
|
Kind = ListKind.User,
|
||||||
|
IconKey = "Folder",
|
||||||
|
DotColorKey = dotColors[UserLists.Count % dotColors.Length],
|
||||||
|
WorkingDir = entity.WorkingDir,
|
||||||
|
DefaultCommitType = entity.DefaultCommitType,
|
||||||
|
IsManual = entity.IsManual,
|
||||||
|
FindingsTracked = entity.FindingsTracked,
|
||||||
|
};
|
||||||
|
|
||||||
|
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||||
|
var ordered = (await lists.GetAllAsync()).Where(l => !seedNames.Contains(l.Name)).ToList();
|
||||||
|
var dbIndex = ordered.FindIndex(l => l.Id == entity.Id);
|
||||||
|
var insertAt = dbIndex < 0 ? UserLists.Count : Math.Min(dbIndex, UserLists.Count);
|
||||||
|
|
||||||
|
UserLists.Insert(insertAt, item);
|
||||||
|
Items.Insert(SmartLists.Count + insertAt, item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private bool _applyingRange;
|
private bool _applyingRange;
|
||||||
|
private bool _isClosed;
|
||||||
|
|
||||||
public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker;
|
public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker;
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Close()
|
private void Close()
|
||||||
{
|
{
|
||||||
|
_isClosed = true;
|
||||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,11 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Snapshot = await _worker.GetUsageSnapshotAsync();
|
var snapshot = await _worker.GetUsageSnapshotAsync();
|
||||||
|
// The modal can be closed while the first-load scan is still running (see remarks
|
||||||
|
// above) — subscribing after that would leak this VM onto the long-lived WorkerClient.
|
||||||
|
if (_isClosed) return;
|
||||||
|
Snapshot = snapshot;
|
||||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||||
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
||||||
ApplyPresetRange(SelectedPresetDays);
|
ApplyPresetRange(SelectedPresetDays);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
||||||
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||||
public void RaiseConnectionRestored() => ConnectionRestoredEvent?.Invoke();
|
public void RaiseConnectionRestored() => ConnectionRestoredEvent?.Invoke();
|
||||||
|
public void RaiseListUpdated(string listId) => ListUpdatedEvent?.Invoke(listId);
|
||||||
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
||||||
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
||||||
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,24 @@ public class UsageMonitorModalViewModelTests
|
|||||||
Assert.False(vm.IsBusy);
|
Assert.False(vm.IsBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Leak on early close (Polish-Audit 2026-08-20 #2) ────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_ClosedBeforeSnapshotArrives_NeverSubscribesToUsageUpdated()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { SnapshotGate = new TaskCompletionSource<UsageSnapshotDto?>() };
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
|
||||||
|
var load = vm.LoadAsync();
|
||||||
|
vm.CloseCommand.Execute(null);
|
||||||
|
worker.SnapshotGate.SetResult(Snapshot(new[] { Limit("session") }));
|
||||||
|
await load;
|
||||||
|
|
||||||
|
worker.RaiseUsageUpdated(Snapshot(new[] { Limit("session"), Limit("weekly_all") }));
|
||||||
|
|
||||||
|
Assert.Null(vm.Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Manual refresh ──────────────────────────────────────────────────────
|
// ── Manual refresh ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user