315 lines
12 KiB
C#
315 lines
12 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 UpdateListDto? SavedList;
|
|
|
|
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 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<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;
|
|
|
|
// 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));
|
|
}
|
|
}
|