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 NewFactory() { var dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_listsettings_{Guid.NewGuid():N}.db"); ClaudeDoDbContext New() => new(new DbContextOptionsBuilder() .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>>? TicketProjectsFactory; public UpdateListConfigDto? SavedListConfig; public UpdateListDto? SavedList; public Exception? GetListConfigThrows; public override Task GetListConfigAsync(string listId) { GetListConfigCalls++; if (GetListConfigThrows is not null) throw GetListConfigThrows; return Task.FromResult(Cfg); } public override Task GetTicketSettingsAsync() => Task.FromResult(TicketSettings); public override Task> GetTicketProjectsAsync() => TicketProjectsFactory?.Invoke() ?? Task.FromResult(new List()); public override Task UpdateListAsync(UpdateListDto dto) { SavedList = dto; return Task.CompletedTask; } 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 void Color_SwatchAndCustomStaySynced() { var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory()); vm.SelectedSwatch = "Swatch3"; Assert.Equal("Swatch3", vm.SelectedColor); Assert.False(vm.IsCustomColor); vm.CustomColor = Avalonia.Media.Color.Parse("#112233"); Assert.Equal("#112233", vm.SelectedColor); Assert.True(vm.IsCustomColor); Assert.Null(vm.SelectedSwatch); vm.SelectedSwatch = null; // what the ListBox pushes while a custom hex is current Assert.Equal("#112233", vm.SelectedColor); vm.SelectedSwatch = ""; Assert.Equal("", vm.SelectedColor); } [Fact] public async Task Save_List_SendsColor_AndBlankMeansNull() { var worker = new CountingWorker(); var vm = new ListSettingsModalViewModel(worker, NewFactory()); await vm.LoadAsync("l1", "List One", "", "feat", color: "Swatch2"); Assert.Equal("Swatch2", vm.SelectedColor); await vm.SaveCommand.ExecuteAsync(null); Assert.Equal("Swatch2", worker.SavedList!.Color); vm.SelectedSwatch = ""; await vm.SaveCommand.ExecuteAsync(null); Assert.Null(worker.SavedList!.Color); } [Fact] public async Task Save_Folder_PersistsCustomColor() { var factory = NewFactory(); await using (var ctx = await factory.CreateDbContextAsync()) { await new ClaudeDo.Data.Repositories.ListRepository(ctx).AddFolderAsync( new ClaudeDo.Data.Models.ListFolderEntity { Id = "f1", Name = "Group", CreatedAt = DateTime.UtcNow }); } var vm = new ListSettingsModalViewModel(new CountingWorker(), factory); vm.BeginLoadFolder("f1", "Group", null, "Swatch1"); vm.CustomColor = Avalonia.Media.Color.Parse("#A1B2C3"); await vm.SaveCommand.ExecuteAsync(null); await using var check = await factory.CreateDbContextAsync(); Assert.Equal("#A1B2C3", (await check.ListFolders.FindAsync("f1"))!.Color); } [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>(); 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 { 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)); } }