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:
@@ -9,21 +9,21 @@ public class SharePointPaginationHelperTests
|
||||
public void BuildPagedViewXml_NullInput_ReturnsViewWithRowLimit()
|
||||
{
|
||||
var result = SharePointPaginationHelper.BuildPagedViewXml(null, 2000);
|
||||
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
|
||||
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPagedViewXml_EmptyString_ReturnsViewWithRowLimit()
|
||||
{
|
||||
var result = SharePointPaginationHelper.BuildPagedViewXml("", 2000);
|
||||
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
|
||||
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPagedViewXml_WhitespaceOnly_ReturnsViewWithRowLimit()
|
||||
{
|
||||
var result = SharePointPaginationHelper.BuildPagedViewXml(" ", 2000);
|
||||
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
|
||||
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -31,7 +31,15 @@ public class SharePointPaginationHelperTests
|
||||
{
|
||||
var input = "<View><RowLimit>100</RowLimit></View>";
|
||||
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000);
|
||||
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
|
||||
Assert.Equal("<View><RowLimit Paged='TRUE'>2000</RowLimit></View>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPagedViewXml_ExistingPagedRowLimit_ReplacesWithNewSize()
|
||||
{
|
||||
var input = "<View><RowLimit Paged='TRUE'>100</RowLimit></View>";
|
||||
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 5000);
|
||||
Assert.Equal("<View><RowLimit Paged='TRUE'>5000</RowLimit></View>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -39,10 +47,9 @@ public class SharePointPaginationHelperTests
|
||||
{
|
||||
var input = "<View><Query><OrderBy><FieldRef Name='Title'/></OrderBy></Query></View>";
|
||||
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000);
|
||||
Assert.Contains("<RowLimit>2000</RowLimit>", result);
|
||||
Assert.Contains("<RowLimit Paged='TRUE'>2000</RowLimit>", result);
|
||||
Assert.EndsWith("</View>", result);
|
||||
// Ensure RowLimit is inserted before the closing </View>
|
||||
var rowLimitIndex = result.IndexOf("<RowLimit>2000</RowLimit>", StringComparison.Ordinal);
|
||||
var rowLimitIndex = result.IndexOf("<RowLimit Paged='TRUE'>2000</RowLimit>", StringComparison.Ordinal);
|
||||
var closingViewIndex = result.LastIndexOf("</View>", StringComparison.Ordinal);
|
||||
Assert.True(rowLimitIndex < closingViewIndex, "RowLimit should appear before </View>");
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public class HtmlExportServiceTests
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
|
||||
|
||||
Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html);
|
||||
Assert.Contains("data-group-target=\"grpmem0\"", html);
|
||||
Assert.Contains("class=\"user-pill group-expandable\"", html);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,8 @@ public class HtmlExportServiceTests
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
|
||||
|
||||
Assert.Contains("function toggleGroup", html);
|
||||
Assert.Contains("data-group-target", html);
|
||||
Assert.Contains("getAttribute('data-group-target')", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -165,7 +166,7 @@ public class HtmlExportServiceTests
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { simplifiedEntry }, null, groupMembers);
|
||||
|
||||
Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html);
|
||||
Assert.Contains("data-group-target=\"grpmem0\"", html);
|
||||
Assert.Contains("class=\"user-pill group-expandable\"", html);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user