diff --git a/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs index f79be36e5c..da5f740ef7 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Extensions/PopupExtensionsTests.cs @@ -121,6 +121,58 @@ public async Task ClosePopupT_NullNavigation_ShouldThrowArgumentNullException() #pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. } + [Fact] + public void ShowPopup_NullPage_ShouldThrowArgumentNullException() + { + // Arrange / Act / Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + Assert.Throws(() => PopupExtensions.ShowPopup((Page?)null, new Grid(), PopupOptions.Empty)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + [Fact] + public async Task ShowPopupAsync_NullNavigation_ShouldThrowArgumentNullException() + { + // Arrange / Act / Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await Assert.ThrowsAsync(() => PopupExtensions.ShowPopupAsync((INavigation?)null, new Grid(), PopupOptions.Empty, TestContext.Current.CancellationToken)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + [Fact] + public async Task ShowPopupAsync_NullView_ShouldThrowArgumentNullException() + { + // Arrange / Act / Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await Assert.ThrowsAsync(() => navigation.ShowPopupAsync((View?)null, PopupOptions.Empty, TestContext.Current.CancellationToken)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + [Fact] + public async Task ShowPopupAsync_Shell_NullShell_ShouldThrowArgumentNullException() + { + // Arrange / Act / Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await Assert.ThrowsAsync(() => PopupExtensions.ShowPopupAsync((Shell?)null, new Grid(), PopupOptions.Empty, shellParameters, TestContext.Current.CancellationToken)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + [Fact] + public async Task ShowPopupAsync_Shell_NullView_ShouldThrowArgumentNullException() + { + // Arrange + var shell = new Shell(); + shell.Items.Add(new MockPage(new MockPageViewModel())); + + Assert.NotNull(Application.Current); + Application.Current.Windows[0].Page = shell; + + // Act / Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await Assert.ThrowsAsync(() => shell.ShowPopupAsync((View?)null, PopupOptions.Empty, shellParameters, TestContext.Current.CancellationToken)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + [Fact(Timeout = (int)TestDuration.Short)] public async Task ShowPopupAsync_WithPopupType_ShowsPopupAndClosesPopup() { @@ -1749,6 +1801,22 @@ async Task WaitForModalStackCountAsync(int expectedCount, CancellationToken toke } } } + + [Fact(Timeout = (int)TestDuration.Short)] + public async Task ShowPopupAsync_NonNullableValueType_ReturnsDefault_WhenClosedWithoutTypedResult() + { + // Arrange + var showPopupTask = navigation.ShowPopupAsync(new Popup(), PopupOptions.Empty, TestContext.Current.CancellationToken); + var popupPage = (PopupPage)navigation.ModalStack.Last(); + + // Act + await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken); + var result = await showPopupTask; + + // Assert + Assert.False(result.WasDismissedByTappingOutsideOfPopup); + Assert.Equal(default, result.Result); + } } sealed class ViewWithIQueryAttributable : Button, IQueryAttributable diff --git a/src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs b/src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs index a9701cb1c4..0c5802d0c5 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Services/PopupServiceTests.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System.Collections.ObjectModel; +using System.ComponentModel; using CommunityToolkit.Maui.Services; using CommunityToolkit.Maui.UnitTests.Mocks; using CommunityToolkit.Maui.Views; @@ -10,6 +11,10 @@ namespace CommunityToolkit.Maui.UnitTests.Services; public class PopupServiceTests : BaseViewTest { readonly INavigation navigation; + readonly ReadOnlyDictionary shellParameters = new Dictionary + { + [nameof(View.BackgroundColor)] = Colors.Green + }.AsReadOnly(); public PopupServiceTests() { @@ -83,6 +88,77 @@ public void ShowPopupAsync_UsingPage_WithViewType_ShowsPopup() Assert.IsType(navigation.ModalStack[0]); } + [Fact(Timeout = (int)TestDuration.Short)] + public async Task ShowPopup_UsingShell_WithViewType_ShowsPopup() + { + // Arrange + var shell = new Shell(); + shell.Items.Add(new MockPage(new MockPageViewModel())); + + Assert.NotNull(Application.Current); + Application.Current.Windows[0].Page = shell; + + var popupService = ServiceProvider.GetRequiredService(); + + // Act + popupService.ShowPopup(shell, PopupOptions.Empty, shellParameters); + + for (var i = 0; i < 50 && shell.Navigation.ModalStack.Count == 0; i++) + { + await Task.Delay(10, TestContext.Current.CancellationToken); + } + + // Assert + Assert.Single(shell.Navigation.ModalStack); + Assert.IsType(shell.Navigation.ModalStack[0]); + + var popupPage = (PopupPage)shell.Navigation.ModalStack[0]; + var popup = (ShortLivedSelfClosingPopup)(popupPage.Content.PopupBorder.Content ?? throw new InvalidOperationException("Popup content cannot be null")); + Assert.Equal((Color)shellParameters[nameof(View.BackgroundColor)], popup.BackgroundColor); + } + + [Fact(Timeout = (int)TestDuration.Long)] + public async Task ShowPopupAsync_UsingShell_WithViewType_ShowsPopupAndReturnsResult() + { + // Arrange + var shell = new Shell(); + shell.Items.Add(new MockPage(new MockPageViewModel())); + + Assert.NotNull(Application.Current); + Application.Current.Windows[0].Page = shell; + + var popupService = ServiceProvider.GetRequiredService(); + + // Act + var result = await popupService.ShowPopupAsync(shell, PopupOptions.Empty, shellParameters, TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.WasDismissedByTappingOutsideOfPopup); + Assert.Empty(shell.Navigation.ModalStack); + } + + [Fact(Timeout = (int)TestDuration.Long)] + public async Task ShowPopupAsyncT_UsingShell_WithViewType_ShowsPopupAndReturnsResult() + { + // Arrange + var shell = new Shell(); + shell.Items.Add(new MockPage(new MockPageViewModel())); + + Assert.NotNull(Application.Current); + Application.Current.Windows[0].Page = shell; + + var popupService = ServiceProvider.GetRequiredService(); + var expectedPopup = ServiceProvider.GetRequiredService(); + + // Act + var result = await popupService.ShowPopupAsync(shell, PopupOptions.Empty, shellParameters, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expectedPopup.Result, result.Result); + Assert.False(result.WasDismissedByTappingOutsideOfPopup); + Assert.Empty(shell.Navigation.ModalStack); + } + [Fact(Timeout = (int)TestDuration.Long)] public async Task ShowPopupAsync_AwaitingShowPopupAsync_EnsurePreviousPopupClosed() { @@ -322,6 +398,60 @@ public void ShowPopup_UsingPage_ShouldThrowArgumentNullException_WhenNavigationI #pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. } + [Fact] + public void ShowPopup_UsingShell_ShouldThrowArgumentNullException_WhenShellIsNull() + { + // Arrange + var popupService = ServiceProvider.GetRequiredService(); + + // Act // Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + Assert.Throws(() => popupService.ShowPopup((Shell?)null, PopupOptions.Empty, shellParameters)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + [Fact(Timeout = (int)TestDuration.Short)] + public async Task ShowPopupAsync_UsingShell_ShouldThrowArgumentNullException_WhenShellIsNull() + { + // Arrange + var popupService = ServiceProvider.GetRequiredService(); + + // Act // Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await Assert.ThrowsAsync(() => popupService.ShowPopupAsync((Shell?)null, PopupOptions.Empty, shellParameters, TestContext.Current.CancellationToken)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + [Fact(Timeout = (int)TestDuration.Short)] + public async Task ShowPopupAsyncT_UsingShell_ShouldThrowArgumentNullException_WhenShellIsNull() + { + // Arrange + var popupService = ServiceProvider.GetRequiredService(); + + // Act // Assert +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await Assert.ThrowsAsync(() => popupService.ShowPopupAsync((Shell?)null, PopupOptions.Empty, shellParameters, TestContext.Current.CancellationToken)); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + [Fact(Timeout = (int)TestDuration.Short)] + public async Task ShowPopupAsync_UsingShell_ShouldThrowOperationCanceledException_WhenTokenCanceled() + { + // Arrange + var shell = new Shell(); + shell.Items.Add(new MockPage(new MockPageViewModel())); + + Assert.NotNull(Application.Current); + Application.Current.Windows[0].Page = shell; + + var popupService = ServiceProvider.GetRequiredService(); + var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act / Assert + await Assert.ThrowsAsync(() => popupService.ShowPopupAsync(shell, PopupOptions.Empty, shellParameters, cts.Token)); + } + [Fact(Timeout = (int)TestDuration.Short)] public async Task ClosePopupAsync_UsingPage_ShouldThrowArgumentNullException_WhenPageIsNull() { diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs index ac6cfc284a..aedbed18c8 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupOptionsSettingsTests.cs @@ -20,6 +20,59 @@ public DefaultPopupOptionsSettingsTests() navigation = page.Navigation; } + [Fact] + public void DefaultPopupOptionsSettings_DefaultConstructor_UsesExpectedDefaults() + { + // Arrange + IPopupOptions defaults = new DefaultPopupOptionsSettings(); + + // Assert + Assert.True(defaults.CanBeDismissedByTappingOutsideOfPopup); + Assert.Null(defaults.OnTappingOutsideOfPopup); + Assert.Equal(Colors.Black.WithAlpha(0.3f), defaults.PageOverlayColor); + Assert.Equal(Colors.LightGray, ((RoundRectangle?)defaults.Shape)?.Stroke); + Assert.Equal(2, ((RoundRectangle?)defaults.Shape)?.StrokeThickness); + Assert.Equal(Colors.Black, defaults.Shadow?.Brush); + } + + [Fact] + public void DefaultPopupOptionsSettings_WithOverrides_UsesProvidedValues() + { + // Arrange + bool actionInvoked = false; + var expectedShape = new Ellipse + { + Stroke = Colors.Red, + StrokeThickness = 8 + }; + var expectedShadow = new Shadow + { + Brush = Colors.Blue, + Offset = new Point(1, 2), + Radius = 3, + Opacity = 0.4f + }; + + IPopupOptions options = new DefaultPopupOptionsSettings + { + CanBeDismissedByTappingOutsideOfPopup = false, + OnTappingOutsideOfPopup = () => actionInvoked = true, + PageOverlayColor = Colors.Green, + Shape = expectedShape, + Shadow = expectedShadow + }; + + // Act + options.OnTappingOutsideOfPopup?.Invoke(); + + // Assert + Assert.False(options.CanBeDismissedByTappingOutsideOfPopup); + Assert.True(actionInvoked); + Assert.Equal(Colors.Green, options.PageOverlayColor); + Assert.Same(expectedShape, options.Shape); + Assert.Same(expectedShadow, options.Shadow); + } + [Fact] public void Popup_SetPopupOptionsDefaultsNotCalled_UsesPopupOptionsDefaults() { diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupSettingsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupSettingsTests.cs index 7557e0c409..af0b9a6660 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupSettingsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/DefaultPopupSettingsTests.cs @@ -7,6 +7,44 @@ namespace CommunityToolkit.Maui.UnitTests.Views; public class DefaultPopupSettingsTests : BaseViewTest { + [Fact] + public void DefaultPopupSettings_DefaultConstructor_UsesExpectedDefaults() + { + // Arrange + var settings = new DefaultPopupSettings(); + + // Assert + Assert.True(settings.CanBeDismissedByTappingOutsideOfPopup); + Assert.Equal(new Thickness(30), settings.Margin); + Assert.Equal(new Thickness(15), settings.Padding); + Assert.Equal(LayoutOptions.Center, settings.HorizontalOptions); + Assert.Equal(LayoutOptions.Center, settings.VerticalOptions); + Assert.Equal(Colors.White, settings.BackgroundColor); + } + + [Fact] + public void DefaultPopupSettings_WithOverrides_UsesProvidedValues() + { + // Arrange + var settings = new DefaultPopupSettings + { + CanBeDismissedByTappingOutsideOfPopup = false, + BackgroundColor = Colors.Orange, + HorizontalOptions = LayoutOptions.End, + VerticalOptions = LayoutOptions.Start, + Margin = 72, + Padding = 4 + }; + + // Assert + Assert.False(settings.CanBeDismissedByTappingOutsideOfPopup); + Assert.Equal(Colors.Orange, settings.BackgroundColor); + Assert.Equal(LayoutOptions.End, settings.HorizontalOptions); + Assert.Equal(LayoutOptions.Start, settings.VerticalOptions); + Assert.Equal(new Thickness(72), settings.Margin); + Assert.Equal(new Thickness(4), settings.Padding); + } + [Fact] public void Popup_SetPopupDefaultsNotCalled_UsesPopupDefaults() { diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs index f1573c6c80..ba3940ed59 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupOptionsTests.cs @@ -172,6 +172,42 @@ public void HorizontalOptions_ShouldReturnCorrectLayoutOptions() Assert.Equal(horizontalOptions, options.HorizontalOptions); } + [Fact] + public void Empty_ShouldReturnSameInstance_AndExposeDefaultValues() + { + // Arrange + var emptyA = PopupOptions.Empty; + var emptyB = PopupOptions.Empty; + + // Assert + Assert.Same(emptyA, emptyB); + Assert.True(emptyA.CanBeDismissedByTappingOutsideOfPopup); + Assert.Equal(Colors.Black.WithAlpha(0.3f), emptyA.PageOverlayColor); + Assert.Null(emptyA.OnTappingOutsideOfPopup); + Assert.Equal(Colors.LightGray, ((RoundRectangle?)emptyA.Shape)?.Stroke); + } + + [Fact] + public void PopupOptions_ShouldImplementIPopupOptionsContract() + { + // Arrange + IPopupOptions options = new PopupOptions + { + CanBeDismissedByTappingOutsideOfPopup = false, + PageOverlayColor = Colors.Purple, + OnTappingOutsideOfPopup = null, + Shape = null, + Shadow = null + }; + + // Assert + Assert.False(options.CanBeDismissedByTappingOutsideOfPopup); + Assert.Equal(Colors.Purple, options.PageOverlayColor); + Assert.Null(options.OnTappingOutsideOfPopup); + Assert.Null(options.Shape); + Assert.Null(options.Shadow); + } + sealed class MockPopupOptions : IPopupOptions { public bool CanBeDismissedByTappingOutsideOfPopup { get; set; } diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs index 310e8a4ff6..09da80b652 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs @@ -31,7 +31,7 @@ public void Constructor_ShouldThrowArgumentNullException_WhenPopupIsNull() act.Should().Throw(); } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task Close_ShouldThrowInvalidOperationException_NoPopupPageFound() { // Arrange @@ -44,7 +44,7 @@ public async Task Close_ShouldThrowInvalidOperationException_NoPopupPageFound() await Assert.ThrowsAnyAsync(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task Close_ShouldSetResultAndPopModalAsync() { // Arrange @@ -115,7 +115,7 @@ public void PopupPageT_Constructor_ShouldThrowArgumentNullException_WhenPopupIsN act.Should().Throw(); } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task PopupPageT_Close_ShouldSetResultAndPopModalAsync() { // Arrange @@ -147,7 +147,7 @@ void HandlePopupClosed(object? sender, IPopupResult e) } } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task PopupPageT_CloseAfterAdditionalModalPage_ShouldThrowInvalidOperationException() { // Arrange @@ -170,7 +170,7 @@ public async Task PopupPageT_CloseAfterAdditionalModalPage_ShouldThrowInvalidOpe await Assert.ThrowsAnyAsync(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task PopupPageT_CloseWhenUsingCustomNavigationPage_ShouldClose() { // Arrange @@ -204,14 +204,44 @@ async void HandlePageNavigatedTo(object? sender, NavigatedToEventArgs e) await customNavigationPage.Navigation.PushModalAsync(popupPage); } } - void HandlePopupPageClosed(object? sender, IPopupResult e) { wasPopupPageClosed = true; } } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] + public async Task PopupPageT_CloseWhenUsingCustomNavigationPageInsideShell_ModalPoppedShouldReportContainerPage() + { + // Arrange + if (Application.Current?.Windows[0] is not Window window) + { + throw new InvalidOperationException("Unable to locate Window"); + } + + var popupPage = new PopupPage(new ContentView(), new MockPopupOptions()); + var customNavigationPage = new NavigationPage(new ContentPage()); + Page? poppedModal = null; + + window.ModalPopped += HandleModalPopped; + + // Act + await Shell.Current.Navigation.PushModalAsync(customNavigationPage, true); + await customNavigationPage.Navigation.PushModalAsync(popupPage); + await window.Navigation.PopModalAsync(false); + + window.ModalPopped -= HandleModalPopped; + + // Assert + Assert.Same(customNavigationPage, poppedModal); + + void HandleModalPopped(object? sender, ModalPoppedEventArgs e) + { + poppedModal = e.Modal; + } + } + + [Fact(Timeout = (int)TestDuration.Short)] public async Task PopupPageT_CloseAfterAdditionalModalPageToCustomNavigationPage_ShouldThrowPopupBlockedException() { // Arrange @@ -252,7 +282,7 @@ void HandlePopupPageClosed(object? sender, IPopupResult e) } } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task PopupPageT_CloseAfterAdditionalModalPageToCustomNavigationPage_ShouldThrowPopupNotFound() { // Arrange @@ -296,7 +326,7 @@ void HandlePopupPageClosed(object? sender, IPopupResult e) } } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task ShowPopupAsync_FromModalNavigationPage_ShouldCloseSuccessfully() { // Remove shell navigation @@ -500,7 +530,7 @@ public void Constructor_WithNullPopup_ThrowsArgumentNullException() #pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task OnTappingOutsideOfPopup_ShouldClosePopupWhenCanBeDismissedIsTrue() { // Arrange @@ -642,7 +672,7 @@ public void BackButton_ShouldNotClosePopup_WhenCanBeDismissedIsFalse() Assert.True(result); } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task Close_ShouldThrowException_WhenCalledOnNonModalPopup() { // Arrange @@ -654,7 +684,7 @@ public async Task Close_ShouldThrowException_WhenCalledOnNonModalPopup() await Assert.ThrowsAsync(async () => await popupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task CloseAsync_ShouldThrowPopupBlockedException_WhenPopupIsBehindModalNavigationPage() { // Arrange @@ -698,7 +728,7 @@ void HandlePopupPageClosed(object? sender, IPopupResult e) } } - [Fact] + [Fact(Timeout = (int)TestDuration.Short)] public async Task CloseAsync_ShouldThrowPopupBlockedException_WhenPopupIsHiddenBehindAnotherPopupAndNavigationPage() { // Arrange @@ -735,6 +765,30 @@ void HandlePopupPageClosed(object? sender, IPopupResult e) } } + [Fact(Timeout = (int)TestDuration.Short)] + public async Task CloseAsync_ShouldThrowPopupBlockedException_WhenResolvedPopupPageToCloseHasDifferentContent() + { + // Arrange + if (Application.Current?.Windows[0].Page?.Navigation is not INavigation navigation) + { + throw new InvalidOperationException("Unable to locate Navigation page"); + } + + var requestedPopupPage = new PopupPage(new ContentView(), new MockPopupOptions()); + var resolvedPopupPageToClose = new PopupPage(new Button(), new MockPopupOptions()); + var pageOnTop = new NonContentModalPage(); + + // Act + await navigation.PushModalAsync(requestedPopupPage); + await navigation.PushModalAsync(resolvedPopupPageToClose); + await navigation.PushModalAsync(pageOnTop); + + // Assert + await Assert.ThrowsAsync(async () => await requestedPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(async () => await requestedPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(async () => await requestedPopupPage.CloseAsync(new PopupResult(false), TestContext.Current.CancellationToken)); + } + [Fact] public void PopupPage_ShouldRespectLayoutOptions() { @@ -755,6 +809,46 @@ public void PopupPage_ShouldRespectLayoutOptions() Assert.Equal(LayoutOptions.End, border.HorizontalOptions); } + [Fact] + public void ApplyQueryAttributes_ShouldForwardToPopupAndPopupContent_WhenBothImplementIQueryAttributable() + { + // Arrange + const string popupValue = "popup"; + const string contentValue = "content"; + var query = new Dictionary + { + [nameof(QueryAttributablePopup.PopupValue)] = popupValue, + [nameof(QueryAttributableContentView.ContentValue)] = contentValue + }; + var popupContent = new QueryAttributableContentView(); + var popup = new QueryAttributablePopup + { + Content = popupContent + }; + var popupPage = new PopupPage(popup, PopupOptions.Empty); + + // Act + ((IQueryAttributable)popupPage).ApplyQueryAttributes(query); + + // Assert + Assert.Equal(popupValue, popup.PopupValue); + Assert.Equal(contentValue, popupContent.ContentValue); + } + + [Fact] + public void ApplyQueryAttributes_ShouldNotThrow_WhenPopupAndContentDoNotImplementIQueryAttributable() + { + // Arrange + var popupPage = new PopupPage(new Popup(), PopupOptions.Empty); + var query = new Dictionary(); + + // Act + var exception = Record.Exception(() => ((IQueryAttributable)popupPage).ApplyQueryAttributes(query)); + + // Assert + Assert.Null(exception); + } + // Helper class for testing protected methods sealed class TestablePopupPage(View view, IPopupOptions popupOptions) : PopupPage(view, popupOptions) { @@ -764,6 +858,30 @@ public bool TestOnBackButtonPressed() } } + sealed class NonContentModalPage : Page + { + } + + sealed class QueryAttributablePopup : Popup, IQueryAttributable + { + public string? PopupValue { get; private set; } + + void IQueryAttributable.ApplyQueryAttributes(IDictionary query) + { + PopupValue = (string)query[nameof(PopupValue)]; + } + } + + sealed class QueryAttributableContentView : ContentView, IQueryAttributable + { + public string? ContentValue { get; private set; } + + void IQueryAttributable.ApplyQueryAttributes(IDictionary query) + { + ContentValue = (string)query[nameof(ContentValue)]; + } + } + sealed class MockPopupOptions : IPopupOptions { public bool CanBeDismissedByTappingOutsideOfPopup { get; set; } diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupResultTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupResultTests.cs index 642d3d0b59..e69d36dac8 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupResultTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupResultTests.cs @@ -93,4 +93,53 @@ public void PopupResultT_Constructor_AllowsReferenceTypeResult() Assert.False(result.WasDismissedByTappingOutsideOfPopup); Assert.Equal(expectedResult, result.Result); } + + [Fact] + public void PopupResultT_Constructor_UsesDefaultValueForNonNullableValueType_WhenResultIsNullAndNotDismissed() + { + // Arrange + object? expectedResult = null; + bool wasDismissedByTappingOutside = false; + + // Act + var result = new PopupResult(expectedResult, wasDismissedByTappingOutside); + + // Assert + Assert.False(result.WasDismissedByTappingOutsideOfPopup); + Assert.Equal(default, result.Result); + } + + [Fact] + public void PopupResultT_Result_ThrowsPopupResultException_WhenDismissedAndTypeIsNonNullable() + { + // Arrange + var result = new PopupResult(42, true); + + // Act + Func act = () => result.Result; + + // Assert + Assert.Throws(() => act()); + } + + [Fact] + public void PopupResult_ShouldImplementIPopupResult() + { + // Arrange + IPopupResult result = new PopupResult(false); + + // Assert + Assert.False(result.WasDismissedByTappingOutsideOfPopup); + } + + [Fact] + public void PopupResultT_ShouldImplementIPopupResultT() + { + // Arrange + IPopupResult result = new PopupResult(7, false); + + // Assert + Assert.Equal(7, result.Result); + Assert.False(result.WasDismissedByTappingOutsideOfPopup); + } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupTests.cs index e99ab57465..3e4e603f9d 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupTests.cs @@ -187,6 +187,32 @@ public async Task ShowPopupAsync_TaskShouldCompleteWhenPopupCloseAsyncIsCalled() Assert.Empty(page.Navigation.ModalStack); } + [Fact] + public void PopupNotFoundException_Message_ShouldContainGuidance() + { + // Arrange + var exception = new PopupNotFoundException(); + + // Assert + Assert.Contains(nameof(PopupExtensions.ShowPopup), exception.Message); + Assert.Contains(nameof(Popup.CloseAsync), exception.Message); + } + + [Fact] + public void PopupBlockedException_Message_ShouldContainBlockedPageType() + { + // Arrange + var page = new ContentPage(); + var exception = new PopupBlockedException(page); + + // Assert + var fullName = page.GetType().FullName; + + Assert.NotNull(fullName); + Assert.Contains(fullName, exception.Message); + Assert.Contains(nameof(Page.Navigation), exception.Message); + } + sealed class PopupOverridingClose : Popup { public override Task CloseAsync(CancellationToken token = default) => Task.CompletedTask; diff --git a/src/CommunityToolkit.Maui/Extensions/PopupExtensions.shared.cs b/src/CommunityToolkit.Maui/Extensions/PopupExtensions.shared.cs index a25c013a4d..8be4a4615e 100644 --- a/src/CommunityToolkit.Maui/Extensions/PopupExtensions.shared.cs +++ b/src/CommunityToolkit.Maui/Extensions/PopupExtensions.shared.cs @@ -8,8 +8,6 @@ namespace CommunityToolkit.Maui.Extensions; /// public static class PopupExtensions { - static readonly SemaphoreSlim showPopupSemaphoreSlim = new(1, 1); - /// /// Shows a popup with the specified options. /// @@ -38,16 +36,7 @@ public static async void ShowPopup(this INavigation navigation, View view, IPopu var popupPage = new PopupPage(view, options); - await showPopupSemaphoreSlim.WaitAsync(); - - try - { - await navigation.PushModalAsync(popupPage, false); - } - finally - { - showPopupSemaphoreSlim.Release(); - } + await popupPage.ShowAsync(navigation); } /// @@ -147,17 +136,7 @@ public static async Task ShowPopupAsync(this INavigation navigatio var popupPage = new PopupPage(view, options); popupPage.PopupClosed += HandlePopupClosed; - await showPopupSemaphoreSlim.WaitAsync(token); - - try - { - token.ThrowIfCancellationRequested(); - await navigation.PushModalAsync(popupPage, false); - } - finally - { - showPopupSemaphoreSlim.Release(); - } + await popupPage.ShowAsync(navigation, token); return await taskCompletionSource.Task.WaitAsync(token); @@ -195,33 +174,7 @@ public static async Task ShowPopupAsync(this Shell shell, View vie try { - await showPopupSemaphoreSlim.WaitAsync(token); - - if (shellParameters is null) - { - try - { - token.ThrowIfCancellationRequested(); - await shell.GoToAsync(popupPageRoute); - } - finally - { - showPopupSemaphoreSlim.Release(); - } - } - else - { - try - { - token.ThrowIfCancellationRequested(); - await shell.GoToAsync(popupPageRoute, shellParameters); - } - finally - { - showPopupSemaphoreSlim.Release(); - } - } - + await popupPage.ShowAsync(shell, popupPageRoute, shellParameters, token); return await taskCompletionSource.Task.WaitAsync(token); } finally diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs index 4f748b87e1..0ceaa57b70 100644 --- a/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs +++ b/src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs @@ -2,6 +2,7 @@ using System.Globalization; using CommunityToolkit.Maui.Converters; using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Extensions; using Microsoft.Maui.Controls.PlatformConfiguration; using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific; using Microsoft.Maui.Controls.Shapes; @@ -23,10 +24,18 @@ public PopupPage(View view, IPopupOptions? popupOptions) partial class PopupPage : ContentPage, IQueryAttributable { + /// + /// Used by all ShowPopup and ClosePopup flows to ensure navigation operations for + /// are queued and never run concurrently, avoiding race conditions with the .NET MAUI navigation stack. + /// (e.g. , , and ). + /// + static readonly SemaphoreSlim navigationSemaphoreSlim = new(1, 1); + readonly Popup popup; readonly IPopupOptions popupOptions; readonly Command tapOutsideOfPopupCommand; + public PopupPage(View view, IPopupOptions? popupOptions) : this(view as Popup ?? CreatePopupFromView(view), popupOptions) { @@ -69,6 +78,46 @@ public PopupPage(Popup popup, IPopupOptions? popupOptions) // Casts `PopupPage.Content` to return typeof(PopupPageLayout) internal new PopupPageLayout Content => (PopupPageLayout)base.Content; + public async Task ShowAsync(INavigation navigation, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(navigation); + await navigationSemaphoreSlim.WaitAsync(token); + + try + { + token.ThrowIfCancellationRequested(); + await navigation.PushModalAsync(this, false); + } + finally + { + navigationSemaphoreSlim.Release(); + } + } + + public async Task ShowAsync(Shell shell, string shellRoute, IDictionary? shellParameters = null, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(shell); + ArgumentException.ThrowIfNullOrEmpty(shellRoute, nameof(shellRoute)); + await navigationSemaphoreSlim.WaitAsync(token); + + try + { + token.ThrowIfCancellationRequested(); + if (shellParameters is null) + { + await shell.GoToAsync(shellRoute); + } + else + { + await shell.GoToAsync(shellRoute, shellParameters); + } + } + finally + { + navigationSemaphoreSlim.Release(); + } + } + public async Task CloseAsync(PopupResult result, CancellationToken token = default) { // We first call `.ThrowIfCancellationRequested()` to ensure we don't throw one of the `InvalidOperationException`s (below) if the `CancellationToken` has already been canceled. @@ -84,8 +133,8 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau } var popupPageToClose = navigationPageOnModalStackContainingPopupPage?.CurrentPage as PopupPage - ?? Navigation.ModalStack.OfType().LastOrDefault() - ?? throw new PopupNotFoundException(); + ?? Navigation.ModalStack.OfType().LastOrDefault() + ?? throw new PopupNotFoundException(); // PopModalAsync will pop the last (top) page from the ModalStack // Ensure that the PopupPage the user is attempting to close is the last (top) page on the Modal stack before calling Navigation.PopModalAsync @@ -110,20 +159,76 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau throw new PopupBlockedException(popupPageToClose); } - // We call `.ThrowIfCancellationRequested()` again to avoid a race condition where a developer cancels the CancellationToken after we check for an InvalidOperationException - // At first glance, it may look redundant given that we are using `.WaitAsync(token)` in the next step, - // However, `Navigation.PopModalAsync()` may return a completed Task, and when a completed Task is returned, `.WaitAsync(token)` is never invoked. - // In other words, `.WaitAsync(token)` may not throw an `OperationCanceledException` as expected which is why we call `.ThrowIfCancellationRequested()` again here - // Here's the .NET MAUI Source code demonstrating that `Navigation.PopModalAsync()` sometimes returns `Task.FromResult()`: https://github.com/dotnet/maui/blob/e5c252ec7f430cbaf28c8a815a249e3270b49844/src/Controls/src/Core/NavigationProxy.cs#L192-L196 - token.ThrowIfCancellationRequested(); - await Navigation.PopModalAsync(false).WaitAsync(token); + var popupConfirmedPoppedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var parentWindow = GetParentWindow(); + parentWindow.ModalPopped += HandleModalPagePopped; + NavigatedFrom += HandleNavigatedFrom; + - // Clean up Popup resources - Content.TapGestureGestureOverlay.GestureRecognizers.Clear(); - popup.PropertyChanged -= HandlePopupPropertyChanged; + try + { + // navigationSemaphoreSlim must be in the outer try/finally block to ensure we unsubscribe ModalPopped and NavigatedFrom events when CancellationToken is canceled + // When CancellationToken is canceled during navigationSemaphoreSlim.WaitAsync(), the SemaphoreSlim is never acquired. We only want to release navigationSemaphoreSlim in a finally block if it has been acquired (i.e. after navigationSemaphoreSlim.WaitAsync executes successfully) + // The inner try/finally block ensures we release navigationSemaphoreSlim after its acquisition if any subsequent line of code throws an exception or completes successfully + await navigationSemaphoreSlim.WaitAsync(token); + + try + { + await Navigation.PopModalAsync(false); + + // Clean up Popup resources + Content.TapGestureGestureOverlay.GestureRecognizers.Clear(); + popup.PropertyChanged -= HandlePopupPropertyChanged; + if (popupOptions is BindableObject bindablePopupOptions) + { + bindablePopupOptions.PropertyChanged -= HandlePopupOptionsPropertyChanged; + } + + // Wait for MAUI to confirm the PopupPage has been popped before invoking the PopupClosed event and notifying the Popup that it may invoke its `Popup.Closed` event. + // This guarantees the Popup has been removed from MAUI's ModalStack + await popupConfirmedPoppedTcs.Task; + + PopupClosed?.Invoke(this, result); + popup.NotifyPopupIsClosed(); + } + finally + { + navigationSemaphoreSlim.Release(); + } + } + finally + { + parentWindow.ModalPopped -= HandleModalPagePopped; + NavigatedFrom -= HandleNavigatedFrom; + } + + void HandleModalPagePopped(object? sender, ModalPoppedEventArgs e) + { + if (e.Modal == this) + { + popupConfirmedPoppedTcs.TrySetResult(); + } + } + + void HandleNavigatedFrom(object? sender, NavigatedFromEventArgs e) + { + if (e.IsDestinationPageACommunityToolkitPopupPage()) + { + // Ignore transitions where the destination is another PopupPage + // that means this popup is still active in popup-navigation flows. + return; + } + + if (navigationPageOnModalStackContainingPopupPage?.CurrentPage == this) + { + // When `navigationPageOnModalStackContainingPopupPage.CurrentPage == this` is true, + // this PopupPage is still the active page in the custom IPageContainer. + // In other words, MAUI has not yet popped it off the ModalStack. + return; + } - PopupClosed?.Invoke(this, result); - popup.NotifyPopupIsClosed(); + popupConfirmedPoppedTcs.TrySetResult(); + } } protected override bool OnBackButtonPressed()