Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs
T
mika kuns f24654dfb8 perf(ui): show list settings modal before its seven-round-trip load finishes
OpenListSettingsAsync/CreateListAsync used to await LoadAsync (name/agent
config/list_config x2/ticket settings/ticket projects, all serial) before
showing the window -- an unreachable ticket system alone could add a 10s
HTTP timeout to every open. LoadAsync now runs via BeginLoad (fire-and-forget,
mirrors UsageMonitorModalViewModel), the modal shows immediately and fields
fill in behind it.

GetListConfigAsync no longer runs twice per open -- AgentConfigEditorViewModel
exposes the row it already fetched (LastLoadedListConfig) instead of the
caller re-fetching it. Ticket-project loading is split into its own
fire-and-forget step so a slow/unreachable ticket system can't block the
rest of the fields, and its tri-state Save logic now also treats "never
finished loading" like "not available" -- leaving the stored
TicketProjectId link untouched instead of overwriting it with 0. Save/Delete
are gated (CanExecute) on the core load being done, so a save can't land
before the fields it writes are populated.

Not measured with OperationTiming against a live worker in this session (no
running app instance available) -- verified instead via 4 new unit tests
that assert GetListConfigAsync fires exactly once and that a hung/failing
ticket-projects call neither blocks LoadAsync's other fields nor corrupts
the saved TicketProjectId. Before/after OperationTiming numbers and a visual
pass are still open follow-ups.
2026-09-02 22:18:54 +02:00

129 lines
5.0 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
// Perf fix: the modal used to await seven serial hub round-trips (including a 10s-timeout
// ticket-system HTTP call) before showing itself. LoadAsync must fetch the list config exactly
// once and must never let a slow/failing ticket-projects call block or corrupt the rest of the
// load, or the save that follows it.
public class ListSettingsModalViewModelTests
{
static ListSettingsModalViewModelTests()
{
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");
}
private static IDbContextFactory<ClaudeDoDbContext> NewFactory()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_listsettings_{Guid.NewGuid():N}.db");
ClaudeDoDbContext New() => new(new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={dbPath}").Options);
using (var ctx = New()) ctx.Database.EnsureCreated();
return new TestDbFactory(New);
}
private sealed class CountingWorker : StubWorkerClient
{
public int GetListConfigCalls;
public ListConfigDto? Cfg;
public TicketSettingsDto? TicketSettings = new("http://ticket.example", true);
public Func<Task<List<TicketProjectDto>>>? TicketProjectsFactory;
public UpdateListConfigDto? SavedListConfig;
public override Task<ListConfigDto?> GetListConfigAsync(string listId)
{
GetListConfigCalls++;
return Task.FromResult(Cfg);
}
public override Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult(TicketSettings);
public override Task<List<TicketProjectDto>> GetTicketProjectsAsync() =>
TicketProjectsFactory?.Invoke() ?? Task.FromResult(new List<TicketProjectDto>());
public override Task UpdateListConfigAsync(UpdateListConfigDto dto)
{
SavedListConfig = dto;
return Task.CompletedTask;
}
}
[Fact]
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
{
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.Equal(1, worker.GetListConfigCalls);
}
[Fact]
public async Task LoadAsync_TicketProjectsNeverResolving_DoesNotBlockRestOfLoad()
{
var pending = new TaskCompletionSource<List<TicketProjectDto>>();
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, 42, null, "make test", "acceptEdits", 7, true),
TicketProjectsFactory = () => pending.Task,
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
// LoadAsync must complete on its own even though the ticket-projects call it kicked off
// never resolves -- ticket loading is fire-and-forget precisely so a slow/unreachable
// ticket system can't hold up the rest of the modal.
var loadTask = vm.LoadAsync("l1", "List One", "/repo", "feat");
var completed = await Task.WhenAny(loadTask, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(loadTask, completed);
Assert.Equal("make test", vm.VerifyCommand);
Assert.True(vm.PowerUseAllowed);
Assert.False(vm.IsLoading);
}
[Fact]
public async Task Save_WhileTicketProjectsUnreachable_PreservesStoredTicketLink()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, null, null, null, null, 7, false),
TicketProjectsFactory = () => throw new InvalidOperationException("ticket system unreachable"),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(worker.SavedListConfig);
Assert.Null(worker.SavedListConfig!.TicketProjectId);
}
[Fact]
public async Task Save_AfterSuccessfulTicketLoad_WritesSelectedTicketProject()
{
var worker = new CountingWorker
{
Cfg = new ListConfigDto("opus", null, null, null, null, null, null, 7, false),
TicketProjectsFactory = () => Task.FromResult(new List<TicketProjectDto>
{
new(7, "Fix the thing", "Dev"),
}),
};
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal(7, worker.SavedListConfig!.TicketProjectId);
}
}