using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals.Settings; namespace ClaudeDo.Ui.Tests.ViewModels; public class PrimeClaudeTabViewModelTests { private sealed class FakeApi : IPrimeScheduleApi { public List Stored { get; } = new(); public List Upserts { get; } = new(); public List Deletes { get; } = new(); public Task> ListAsync() => Task.FromResult(Stored.ToList()); public Task UpsertAsync(PrimeScheduleDto dto) { Upserts.Add(dto); return Task.FromResult(dto); } public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; } } private sealed class ThrowingApi : IPrimeScheduleApi { public string ExceptionMessage { get; init; } = "worker offline"; public Task> ListAsync() => Task.FromResult(new List()); public Task UpsertAsync(PrimeScheduleDto dto) => throw new Exception(ExceptionMessage); public Task DeleteAsync(Guid id) => Task.CompletedTask; } private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time, PrimeActionKind kind = PrimeActionKind.FillMyDay, string? prompt = null) => new(id, days, time, true, null, prompt, kind); [Fact] public async Task Load_Populates_Rows() { var api = new FakeApi(); api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0))); var vm = new PrimeClaudeTabViewModel(api); await vm.LoadAsync(); Assert.Single(vm.Rows); } [Fact] public void AddSchedule_Appends_Row_With_Defaults() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); Assert.Single(vm.Rows); Assert.True(vm.Rows[0].Enabled); Assert.True(vm.Rows[0].Monday); Assert.True(vm.Rows[0].Friday); Assert.False(vm.Rows[0].Saturday); Assert.Equal(new TimeSpan(7, 0, 0), vm.Rows[0].TimeOfDay); } [Fact] public void Row_Decomposes_And_Recomposes_Days() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); var row = vm.Rows[0]; Assert.Equal(31, row.DaysMask()); row.Saturday = true; Assert.Equal(63, row.DaysMask()); } [Fact] public async Task Save_Diffs_New_And_Removed_Rows() { var api = new FakeApi(); var keptId = Guid.NewGuid(); var deletedId = Guid.NewGuid(); api.Stored.Add(Dto(keptId, 31, new TimeSpan(7, 0, 0))); api.Stored.Add(Dto(deletedId, 31, new TimeSpan(8, 0, 0))); var vm = new PrimeClaudeTabViewModel(api); await vm.LoadAsync(); vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId)); vm.AddScheduleCommand.Execute(null); await vm.SaveAsync(); Assert.Contains(deletedId, api.Deletes); Assert.Equal(2, api.Upserts.Count); } [Fact] public void Validate_Reports_No_Days_Selected() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); var row = vm.Rows[0]; row.Monday = row.Tuesday = row.Wednesday = row.Thursday = row.Friday = false; Assert.NotNull(vm.Validate()); } [Fact] public void Validate_Passes_With_One_Day() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); Assert.Null(vm.Validate()); } // SettingsModalViewModel.Save() is the only place that catches Prime.SaveAsync's // failures (via a try/catch around the whole settings save) and surfaces them as // "Save failed: {message}". For that to work, SaveAsync must propagate the worker's // exception rather than swallow it. [Fact] public async Task Save_WhenApiThrows_PropagatesException() { var api = new ThrowingApi(); var vm = new PrimeClaudeTabViewModel(api); vm.AddScheduleCommand.Execute(null); var ex = await Assert.ThrowsAsync(() => vm.SaveAsync()); Assert.Equal(api.ExceptionMessage, ex.Message); } [Fact] public void AddSchedule_Defaults_To_Ping() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); Assert.Equal(PrimeActionKind.Ping, vm.Rows[0].Kind); Assert.True(vm.Rows[0].IsPing); Assert.False(vm.Rows[0].IsFillMyDay); Assert.False(vm.Rows[0].IsCustom); } [Fact] public void Setting_IsCustom_Flips_Kind_And_Clears_The_Others() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); var row = vm.Rows[0]; row.IsCustom = true; Assert.Equal(PrimeActionKind.Custom, row.Kind); Assert.False(row.IsPing); Assert.False(row.IsFillMyDay); } [Fact] public void Row_RadioGroup_Is_Unique_Per_Row() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); vm.AddScheduleCommand.Execute(null); Assert.NotEqual(vm.Rows[0].RadioGroup, vm.Rows[1].RadioGroup); } [Fact] public void Validate_Rejects_Custom_Without_A_Prompt() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); vm.Rows[0].IsCustom = true; vm.Rows[0].PromptOverride = " "; Assert.NotNull(vm.Validate()); } [Fact] public void Validate_Accepts_Custom_With_A_Prompt() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); vm.AddScheduleCommand.Execute(null); vm.Rows[0].IsCustom = true; vm.Rows[0].PromptOverride = "queue the oldest task"; Assert.Null(vm.Validate()); } [Fact] public void AnyFillMyDay_Tracks_Kind_And_Row_Changes() { var vm = new PrimeClaudeTabViewModel(new FakeApi()); Assert.False(vm.AnyFillMyDay); vm.AddScheduleCommand.Execute(null); Assert.False(vm.AnyFillMyDay); vm.Rows[0].IsFillMyDay = true; Assert.True(vm.AnyFillMyDay); vm.RemoveScheduleCommand.Execute(vm.Rows[0]); Assert.False(vm.AnyFillMyDay); } [Fact] public async Task Save_Round_Trips_The_Kind() { var api = new FakeApi(); var vm = new PrimeClaudeTabViewModel(api); vm.AddScheduleCommand.Execute(null); vm.Rows[0].IsCustom = true; vm.Rows[0].PromptOverride = "do the thing"; await vm.SaveAsync(); Assert.Equal(PrimeActionKind.Custom, api.Upserts[0].Kind); } }