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.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user