using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Localization; using ClaudeDo.Ui; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals.Settings; using Microsoft.EntityFrameworkCore; using Xunit; namespace ClaudeDo.Ui.Tests.ViewModels; public class SettingsModalViewModelTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_settingsmodal_test_{Guid.NewGuid():N}.db"); public SettingsModalViewModelTests() { using var ctx = NewContext(); ctx.Database.EnsureCreated(); } public void Dispose() { try { File.Delete(_dbPath); } catch { } try { File.Delete(_dbPath + "-wal"); } catch { } try { File.Delete(_dbPath + "-shm"); } catch { } } private ClaudeDoDbContext NewContext() { var opts = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); } private sealed class FakeWorker : StubWorkerClient { public AppSettingsDto? AppToReturn; public AppSettingsDto? Saved; public List InstalledSkills = new(); public override Task GetAppSettingsAsync() => Task.FromResult(AppToReturn); public override Task UpdateAppSettingsAsync(AppSettingsDto dto) { Saved = dto; return Task.CompletedTask; } public override Task> GetSessionSkillsAsync() => Task.FromResult(InstalledSkills); } private sealed class FakePrimeApi : IPrimeScheduleApi { public Task> ListAsync() => Task.FromResult(new List()); public Task UpsertAsync(PrimeScheduleDto dto) => Task.FromResult(dto); public Task DeleteAsync(Guid id) => Task.CompletedTask; } private static Localizer MakeLocalizer() { var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, "en.json"), """{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" } }"""); File.WriteAllText(Path.Combine(dir, "de.json"), """{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }"""); return new Localizer(LocaleStore.Load(dir), "en"); } private static AppSettingsDto DtoWith(int fiveHourPct, int sevenDayPct) => new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30, DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling", CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7, ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5, SessionSkills: null, ModelPresets: null, UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct); [Fact] public async Task Save_carries_dragged_throttle_stages_through_untouched() { // The throttle stages are only editable by dragging the usage-monitor gauges. Saving the // Settings modal rebuilds the whole DTO, so it must not reset them to the defaults. var worker = new FakeWorker { AppToReturn = DtoWith(65, 95) with { UsageThrottleFiveHourSoftPct = 42, UsageThrottleFiveHourHardPct = 58, UsageThrottleSevenDaySoftPct = 71, UsageThrottleSevenDayHardPct = 88, }, }; var vm = MakeVm(worker); await vm.LoadAsync(); await vm.SaveCommand.ExecuteAsync(null); Assert.NotNull(worker.Saved); Assert.Equal(42, worker.Saved!.UsageThrottleFiveHourSoftPct); Assert.Equal(58, worker.Saved.UsageThrottleFiveHourHardPct); Assert.Equal(71, worker.Saved.UsageThrottleSevenDaySoftPct); Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct); } private SettingsModalViewModel MakeVm(FakeWorker worker) => new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(), MakeLocalizer(), new AppSettings(), new TestDbFactory(NewContext)); [Fact] public async Task LoadAsync_fills_usage_gate_fields_from_dto() { var worker = new FakeWorker { AppToReturn = DtoWith(65, 95) }; var vm = MakeVm(worker); await vm.LoadAsync(); Assert.Equal(65, vm.General.UsageGateFiveHourPct); Assert.Equal(95, vm.General.UsageGateSevenDayPct); } [Fact] public async Task Save_sends_usage_gate_fields_in_dto() { var worker = new FakeWorker(); var vm = MakeVm(worker); vm.General.UsageGateFiveHourPct = 42; vm.General.UsageGateSevenDayPct = 77; await vm.SaveCommand.ExecuteAsync(null); Assert.NotNull(worker.Saved); Assert.Equal(42, worker.Saved!.UsageGateFiveHourPct); Assert.Equal(77, worker.Saved.UsageGateSevenDayPct); } [Fact] public async Task HasLinkedRepo_false_when_no_list_has_a_working_dir() { using (var ctx = NewContext()) { ctx.Lists.Add(new ListEntity { Id = "l1", Name = "No repo", CreatedAt = DateTime.UtcNow, WorkingDir = null }); ctx.SaveChanges(); } var vm = MakeVm(new FakeWorker()); await vm.LoadAsync(); Assert.False(vm.HasLinkedRepo); } [Fact] public async Task HasLinkedRepo_true_when_a_list_has_a_working_dir() { using (var ctx = NewContext()) { ctx.Lists.Add(new ListEntity { Id = "l1", Name = "Repo", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo" }); ctx.SaveChanges(); } var vm = MakeVm(new FakeWorker()); await vm.LoadAsync(); Assert.True(vm.HasLinkedRepo); } [Fact] public void SelectedCategory_updates_SelectedIndex_and_ignores_null() { var vm = MakeVm(new FakeWorker()); var worktrees = vm.Categories.Single(c => c.Key == "Worktrees"); vm.SelectedCategory = worktrees; Assert.Equal(vm.Categories.ToList().IndexOf(worktrees), vm.SelectedIndex); // Simulated deselection push from the sibling sidebar ListBox — must not clobber the pick. vm.SelectedCategory = null; Assert.Equal(worktrees, vm.SelectedCategory); } [Fact] public void Categories_default_to_first_entry_and_mark_the_repo_gated_ones() { var vm = MakeVm(new FakeWorker()); Assert.Equal(vm.Categories[0], vm.SelectedCategory); Assert.True(vm.Categories.Single(c => c.Key == "Worktrees").RequiresRepo); Assert.True(vm.Categories.Single(c => c.Key == "Prime").RequiresRepo); Assert.True(vm.Categories.Single(c => c.Key == "SessionSkills").RequiresRepo); Assert.True(vm.Categories.Single(c => c.Key == "Reports").RequiresRepo); Assert.False(vm.Categories.Single(c => c.Key == "General").RequiresRepo); Assert.False(vm.Categories.Single(c => c.Key == "Files").RequiresRepo); } [Fact] public async Task SessionSkillSelection_survives_a_save_and_reload_round_trip() { // The only real regression risk of the Settings-sidebar rework: Session Skills used to be // two separate lists (a General-tab checkbox list + the Skills-tab management list) and is // now one merged list. Setting the selection, saving, and reloading into a *fresh* VM must // come back with the identical selection. var installed = new List { new("alpha", "", "url-alpha", "ref", DateTimeOffset.UtcNow), new("beta", "", "url-beta", "ref", DateTimeOffset.UtcNow), }; var worker = new FakeWorker { AppToReturn = DtoWith(80, 90), InstalledSkills = installed }; var vm = MakeVm(worker); await vm.LoadAsync(); vm.SessionSkills.Skills.Single(s => s.Name == "alpha").IsSelected = true; vm.SessionSkills.Skills.Single(s => s.Name == "beta").IsSelected = false; await vm.SaveCommand.ExecuteAsync(null); Assert.Equal(new List { "alpha" }, worker.Saved!.SessionSkills); // Reload into a brand-new VM instance, as if the modal were closed and reopened. var worker2 = new FakeWorker { AppToReturn = worker.Saved, InstalledSkills = installed }; var vm2 = MakeVm(worker2); await vm2.LoadAsync(); Assert.True(vm2.SessionSkills.Skills.Single(s => s.Name == "alpha").IsSelected); Assert.False(vm2.SessionSkills.Skills.Single(s => s.Name == "beta").IsSelected); Assert.Equal(new List { "alpha" }, vm2.SessionSkills.SelectedSkillNames()); } }