Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs
T

269 lines
10 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 void FolderToggle_SwapsUntouchedDefaultIcon_KeepsCustomChoice()
{
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
vm.IsFolder = true;
Assert.Equal(ListSettingsModalViewModel.DefaultFolderIcon, vm.Icon);
vm.IsFolder = false;
Assert.Equal(ListSettingsModalViewModel.DefaultIcon, vm.Icon);
vm.Icon = "Star";
vm.IsFolder = true;
Assert.Equal("Star", vm.Icon);
}
[Fact]
public void IconSearch_FindsMdiIcons_AndClearingKeepsTheChoice()
{
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
vm.IconSearch = "rocket";
Assert.Contains("mdi:RocketLaunch", vm.Icons);
Assert.DoesNotContain("Star", vm.Icons);
vm.SelectedIconOption = "mdi:RocketLaunch";
vm.SelectedIconOption = null; // what the ListBox pushes when Icons is swapped
Assert.Equal("mdi:RocketLaunch", vm.Icon);
vm.IconSearch = "";
Assert.Equal("mdi:RocketLaunch", vm.Icons[0]);
Assert.Equal("mdi:RocketLaunch", vm.SelectedIconOption);
Assert.True(ListSettingsModalViewModel.IsKnownIcon("mdi:RocketLaunch"));
Assert.False(ListSettingsModalViewModel.IsKnownIcon("mdi:NoSuchIcon"));
}
[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 Save_sends_BaseBranch_explicitly()
{
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null, BaseBranch: "dev") };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", "/repo", "feat");
Assert.Equal("dev", vm.BaseBranch);
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal("dev", worker.SavedListConfig!.BaseBranch);
// "" (not null) so clearing the field really clears the stored branch.
vm.BaseBranch = " ";
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal("", worker.SavedListConfig!.BaseBranch);
}
[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;
// Empty working dir: this test is about ticket-project load isolation, not the
// working-dir-is-a-repo gate added for the "not a git repo" Save check.
await vm.LoadAsync("l1", "List One", "", "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);
}
[Fact]
public async Task LoadAsync_WorkingDirNotAGitRepo_WarnsAndBlocksSave()
{
var plainDir = Directory.CreateTempSubdirectory().FullName;
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", plainDir, "feat");
Assert.NotEmpty(vm.WorkingDirWarning);
Assert.False(vm.SaveCommand.CanExecute(null));
}
[Fact]
public async Task LoadAsync_WorkingDirIsAGitRepo_NoWarningAndSaveAllowed()
{
var repoDir = Directory.CreateTempSubdirectory().FullName;
Directory.CreateDirectory(Path.Combine(repoDir, ".git"));
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", repoDir, "feat");
Assert.Empty(vm.WorkingDirWarning);
Assert.True(vm.SaveCommand.CanExecute(null));
}
[Fact]
public void WorkingDir_ClearedAfterNotARepoWarning_ClearsWarningAndAllowsSave()
{
var plainDir = Directory.CreateTempSubdirectory().FullName;
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory()) { WorkingDir = plainDir };
Assert.NotEmpty(vm.WorkingDirWarning);
vm.WorkingDir = "";
Assert.Empty(vm.WorkingDirWarning);
Assert.True(vm.SaveCommand.CanExecute(null));
}
}