BeginLoad's fire-and-forget LoadAsync now catches instead of leaving an unobserved task exception, reports it via the new ErrorReported event (wired into FlashFooterError like the other modal VMs), and keeps Save locked so the failure can't clobber list_config with blank fields. LoadTicketProjectsAsync's empty catch now reports too; behavior for the dropdown itself is unchanged.
173 lines
6.7 KiB
C#
173 lines
6.7 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 Exception? GetListConfigThrows;
|
|
|
|
public override Task<ListConfigDto?> GetListConfigAsync(string listId)
|
|
{
|
|
GetListConfigCalls++;
|
|
if (GetListConfigThrows is not null) throw GetListConfigThrows;
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_CoreLoadThrows_BlocksSaveAndDoesNotOverwriteConfig()
|
|
{
|
|
var worker = new CountingWorker
|
|
{
|
|
Cfg = new ListConfigDto("opus", null, null),
|
|
GetListConfigThrows = new InvalidOperationException("worker offline"),
|
|
};
|
|
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
|
string? reported = null;
|
|
vm.ErrorReported += m => reported = m;
|
|
|
|
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
|
|
|
Assert.False(vm.SaveCommand.CanExecute(null));
|
|
Assert.NotNull(reported);
|
|
Assert.Contains("worker offline", reported);
|
|
Assert.Null(worker.SavedListConfig);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_TicketProjectsThrow_ReportsErrorButRestOfLoadStillWorks()
|
|
{
|
|
var worker = new CountingWorker
|
|
{
|
|
Cfg = new ListConfigDto("opus", null, null, null, null, "make test", null, 7, true),
|
|
TicketProjectsFactory = () => throw new InvalidOperationException("ticket system down"),
|
|
};
|
|
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
|
string? reported = null;
|
|
vm.ErrorReported += m => reported = m;
|
|
|
|
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
|
|
|
Assert.Equal("make test", vm.VerifyCommand);
|
|
Assert.True(vm.PowerUseAllowed);
|
|
Assert.True(vm.SaveCommand.CanExecute(null));
|
|
Assert.NotNull(reported);
|
|
Assert.Contains("ticket system down", reported);
|
|
}
|
|
}
|