chore: release v2.4

- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager)
- Add InputDialog, Spinner common view
- Add DuplicatesCsvExportService
- Refresh views, dialogs, and view models across tabs
- Update localization strings (en/fr)
- Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit 12dd1de9f2
93 changed files with 8708 additions and 1159 deletions
@@ -0,0 +1,88 @@
using System.ComponentModel;
using SharepointToolbox.ViewModels.Dialogs;
using SharepointToolbox.Views.Dialogs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels.Dialogs;
public class SitePickerDialogLogicTests
{
private static SitePickerItem Item(string url, string title, long sizeMb = 0, string template = "")
=> new(url, title, sizeMb, 0, template);
[Fact]
public void ApplyFilter_TextFilter_MatchesUrlOrTitle_CaseInsensitive()
{
var items = new[]
{
Item("https://t/sites/hr", "HR Team"),
Item("https://t/sites/finance", "Finance"),
};
var result = SitePickerDialogLogic.ApplyFilter(items, "fINaNce", 0, long.MaxValue, "All").ToList();
Assert.Single(result);
Assert.Equal("Finance", result[0].Title);
}
[Fact]
public void ApplyFilter_SizeRange_FiltersInclusively()
{
var items = new[]
{
Item("a", "A", sizeMb: 100),
Item("b", "B", sizeMb: 500),
Item("c", "C", sizeMb: 1200),
};
var result = SitePickerDialogLogic.ApplyFilter(items, "", 100, 600, "All").ToList();
Assert.Equal(2, result.Count);
Assert.Contains(result, i => i.Title == "A");
Assert.Contains(result, i => i.Title == "B");
}
[Fact]
public void ApplyFilter_KindAll_SkipsKindCheck()
{
var items = new[] { Item("a", "A", template: "STS#3"), Item("b", "B", template: "GROUP#0") };
var result = SitePickerDialogLogic.ApplyFilter(items, "", 0, long.MaxValue, "All").ToList();
Assert.Equal(2, result.Count);
}
[Fact]
public void ApplySort_UnknownColumn_ReturnsInputUnchanged()
{
var items = new[] { Item("b", "B"), Item("a", "A") };
var result = SitePickerDialogLogic.ApplySort(items, "Nonexistent", ListSortDirection.Ascending).ToList();
Assert.Equal("B", result[0].Title);
Assert.Equal("A", result[1].Title);
}
[Fact]
public void ApplySort_Title_Ascending_And_Descending()
{
var items = new[] { Item("b", "B"), Item("a", "A"), Item("c", "C") };
var asc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Ascending).ToList();
var desc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Descending).ToList();
Assert.Equal(new[] { "A", "B", "C" }, asc.Select(i => i.Title));
Assert.Equal(new[] { "C", "B", "A" }, desc.Select(i => i.Title));
}
[Theory]
[InlineData("", 42L, 42L)]
[InlineData(" ", 42L, 42L)]
[InlineData("not-a-number", 42L, 42L)]
[InlineData("100", 42L, 100L)]
[InlineData(" 100 ", 42L, 100L)]
public void ParseLongOrDefault_HandlesEmptyAndInvalid(string input, long fallback, long expected)
{
Assert.Equal(expected, SitePickerDialogLogic.ParseLongOrDefault(input, fallback));
}
}
@@ -44,19 +44,25 @@ public class FeatureViewModelBaseTests
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
{
var vm = new TestViewModel();
int midProgress = -1;
string? midStatus = null;
vm.OperationFunc = async (ct, progress) =>
{
progress.Report(new OperationProgress(50, 100, "halfway"));
await Task.Yield();
// Let the Progress<T> callback dispatch before sampling.
await Task.Delay(20, ct);
midProgress = vm.ProgressValue;
midStatus = vm.StatusMessage;
};
await vm.RunCommand.ExecuteAsync(null);
// Allow dispatcher to process
await Task.Delay(20);
Assert.Equal(50, vm.ProgressValue);
Assert.Equal("halfway", vm.StatusMessage);
// Mid-operation snapshot confirms IProgress reaches bound properties.
// Post-completion, FeatureViewModelBase snaps to 100% / "Complete"
// so stale "Scanning X" labels don't linger after a successful run.
Assert.Equal(50, midProgress);
Assert.Equal("halfway", midStatus);
}
[Fact]
@@ -87,11 +87,11 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
}
[Fact]
public async Task RegisterApp_ShowsFallback_WhenNotAdmin()
public async Task RegisterApp_ShowsFallback_WhenGraphReturnsFallbackRequired()
{
_mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(AppRegistrationResult.FallbackRequired());
var vm = CreateViewModel();
vm.SelectedProfile = MakeProfile(appId: null);
@@ -105,10 +105,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
public async Task RegisterApp_SetsAppId_OnSuccess()
{
_mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockAppReg
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(AppRegistrationResult.Success("new-app-id-123"));
var profileService = new ProfileService(new ProfileRepository(_tempFile));
@@ -132,7 +129,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable
public async Task RemoveApp_ClearsAppId()
{
_mockAppReg
.Setup(s => s.RemoveAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Setup(s => s.RemoveAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_mockAppReg
.Setup(s => s.ClearMsalSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
@@ -32,7 +32,7 @@ public class SettingsViewModelLogoTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = brandingService ?? new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger);
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
@@ -30,7 +30,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger);
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
@@ -50,7 +50,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
var vm = new SettingsViewModel(settingsService, mockBranding, logger);
var vm = new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
await vm.LoadAsync();
vm.AutoTakeOwnership = true;