From 64a44cb2b1c207861226873199143f08531556f9 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:04:39 +1000 Subject: [PATCH 1/3] refactor(winui): share routed host setup - Extract the common routed-host initialization behind a testable contract. - Add real WinUI coverage across every supported Windows target. - Mark executable test projects explicitly as tests and non-packable. --- src/ReactiveUI.Maui/Common/RoutedViewHost.cs | 28 ++--- .../Common/RoutedViewHost{TViewModel}.cs | 26 ++-- .../Internal/IMauiRoutedViewHost.cs | 29 +++++ .../Internal/MauiReactiveHelpers.cs | 38 +++--- src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj | 3 + src/reactiveui.slnx | 1 + .../ReactiveUI.Blazor.Tests.csproj | 1 + .../ReactiveUI.Builder.Maui.Tests.csproj | 1 + .../ReactiveUI.Builder.Tests.csproj | 1 + .../ReactiveUI.Maui.Tests.csproj | 1 + ...ReactiveUI.NonParallel.Mobile.Tests.csproj | 2 + .../ReactiveUI.Routing.Tests.csproj | 2 + .../ReactiveUI.Splat.Tests.csproj | 1 + .../ReactiveUI.Testing.Tests.csproj | 2 + .../ReactiveUI.Tests/ReactiveUI.Tests.csproj | 2 + .../ReactiveUI.WinForms.Tests.csproj | 2 + .../ReactiveUI.WinUI.Tests.csproj | 34 +++++ .../WinUI/AssemblyHooks.cs | 17 +++ .../WinUI/MauiReactiveHelpersTest.cs | 116 ++++++++++++++++++ .../WinUI/WinUITestExecutor.cs | 100 +++++++++++++++ .../WinUIFallbackTest.cs | 16 +++ .../ReactiveUI.Wpf.Tests.csproj | 2 + 22 files changed, 372 insertions(+), 53 deletions(-) create mode 100644 src/ReactiveUI.Maui/Internal/IMauiRoutedViewHost.cs create mode 100644 src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj create mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs create mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs create mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs create mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs diff --git a/src/ReactiveUI.Maui/Common/RoutedViewHost.cs b/src/ReactiveUI.Maui/Common/RoutedViewHost.cs index 572894e9c8..a18a5047ba 100644 --- a/src/ReactiveUI.Maui/Common/RoutedViewHost.cs +++ b/src/ReactiveUI.Maui/Common/RoutedViewHost.cs @@ -11,8 +11,6 @@ #else using ReactiveUI.Maui.Internal; #endif -using Splat; - #if REACTIVE_SHIM namespace ReactiveUI.Reactive; #else @@ -26,7 +24,7 @@ namespace ReactiveUI; /// [RequiresUnreferencedCode("This class uses reflection to determine view model types at runtime through ViewLocator, which may be incompatible with trimming.")] [RequiresDynamicCode("ViewLocator.ResolveView uses reflection which is incompatible with AOT compilation.")] -public partial class RoutedViewHost : TransitioningContentControl, IActivatableView, IEnableLogger +public partial class RoutedViewHost : TransitioningContentControl, IActivatableView, IMauiRoutedViewHost { /// The router dependency property. public static readonly DependencyProperty RouterProperty = @@ -56,14 +54,7 @@ public RoutedViewHost() HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; - MauiReactiveHelpers.InitializeRoutedViewHost( - (this, this.Log(), observable => ViewContractObservable = observable), - (nameof(Router), RouterProperty, () => Router), - (nameof(ViewContractObservable), ViewContractObservableProperty, () => ViewContractObservable), - () => ViewContract, - contract => _viewContract = contract, - ResolveViewForViewModel, - _subscriptions); + MauiReactiveHelpers.InitializeRoutedViewHost(this, RouterProperty, ViewContractObservableProperty, _subscriptions, ResolveViewForViewModel); } /// Gets or sets the of the view model stack. @@ -107,23 +98,26 @@ public string? ViewContract /// public IViewLocator? ViewLocator { get; set; } + /// + void IMauiRoutedViewHost.SetObservedViewContract(string? contract) => _viewContract = contract; + /// Resolves and hosts the view for the supplied view model/contract pair. - /// The view model and contract to resolve a view for. + /// The view model and contract to resolve a view for. [RequiresUnreferencedCode("This method uses reflection to determine the view model type at runtime, which may be incompatible with trimming.")] [RequiresDynamicCode("If some of the generic arguments are annotated (either with DynamicallyAccessedMembersAttribute, " + "or generic constraints), trimming can't validate that the requirements of those annotations are met.")] - private void ResolveViewForViewModel((IRoutableViewModel? viewModel, string? contract) x) + private void ResolveViewForViewModel((IRoutableViewModel? viewModel, string? contract) route) { - if (x.viewModel is null) + if (route.viewModel is null) { Content = DefaultContent; return; } var viewLocator = ViewLocator ?? ReactiveUI.ViewLocator.Current; - var view = (viewLocator.ResolveView(x.viewModel, x.contract) ?? viewLocator.ResolveView(x.viewModel)) - ?? throw new InvalidOperationException($"Couldn't find view for '{x.viewModel}'."); - view.ViewModel = x.viewModel; + var view = (viewLocator.ResolveView(route.viewModel, route.contract) ?? viewLocator.ResolveView(route.viewModel)) + ?? throw new InvalidOperationException($"Couldn't find view for '{route.viewModel}'."); + view.ViewModel = route.viewModel; Content = view; } } diff --git a/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs b/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs index ddd0d83a68..bf158a711b 100644 --- a/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs +++ b/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs @@ -11,8 +11,6 @@ #else using ReactiveUI.Maui.Internal; #endif -using Splat; - #if REACTIVE_SHIM namespace ReactiveUI.Reactive; #else @@ -27,7 +25,7 @@ namespace ReactiveUI; /// /// The type of the view model. Must have a public parameterless constructor and implement IRoutableViewModel. public partial class RoutedViewHost< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TViewModel> : TransitioningContentControl, IActivatableView, IEnableLogger + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TViewModel> : TransitioningContentControl, IActivatableView, IMauiRoutedViewHost where TViewModel : class, IRoutableViewModel { /// The router dependency property. @@ -58,14 +56,7 @@ public RoutedViewHost() HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; - MauiReactiveHelpers.InitializeRoutedViewHost( - (this, this.Log(), observable => ViewContractObservable = observable), - (nameof(Router), RouterProperty, () => Router), - (nameof(ViewContractObservable), ViewContractObservableProperty, () => ViewContractObservable), - () => ViewContract, - contract => _viewContract = contract, - ResolveViewForViewModel, - _subscriptions); + MauiReactiveHelpers.InitializeRoutedViewHost(this, RouterProperty, ViewContractObservableProperty, _subscriptions, ResolveViewForViewModel); } /// Gets or sets the view locator. @@ -109,14 +100,17 @@ public string? ViewContract } } + /// + void IMauiRoutedViewHost.SetObservedViewContract(string? contract) => _viewContract = contract; + /// /// Resolves and displays the view for the given view model and contract. /// This method uses the generic ViewLocator.ResolveView{TViewModel} which is AOT-safe. /// - /// Tuple containing the view model and contract. - private void ResolveViewForViewModel((IRoutableViewModel? viewModel, string? contract) x) + /// Tuple containing the view model and contract. + private void ResolveViewForViewModel((IRoutableViewModel? viewModel, string? contract) route) { - if (x.viewModel is null) + if (route.viewModel is null) { Content = DefaultContent; return; @@ -125,9 +119,9 @@ private void ResolveViewForViewModel((IRoutableViewModel? viewModel, string? con var viewLocator = ViewLocator ?? ReactiveUI.ViewLocator.Current; // Use the generic ResolveView method - this is AOT-safe! - var view = viewLocator.ResolveView(x.contract) ?? viewLocator.ResolveView() + var view = viewLocator.ResolveView(route.contract) ?? viewLocator.ResolveView() ?? throw new InvalidOperationException($"Couldn't find view for '{nameof(TViewModel)}'."); - view.ViewModel = x.viewModel as TViewModel; + view.ViewModel = route.viewModel as TViewModel; Content = view; } } diff --git a/src/ReactiveUI.Maui/Internal/IMauiRoutedViewHost.cs b/src/ReactiveUI.Maui/Internal/IMauiRoutedViewHost.cs new file mode 100644 index 0000000000..bf84c1df7b --- /dev/null +++ b/src/ReactiveUI.Maui/Internal/IMauiRoutedViewHost.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Splat; + +#if REACTIVE_SHIM +namespace ReactiveUI.Reactive.Maui.Internal; +#else +namespace ReactiveUI.Maui.Internal; +#endif + +/// Supplies the host-specific operations used to initialize a WinUI routed host. +internal interface IMauiRoutedViewHost : IEnableLogger +{ + /// Gets the router. + RoutingState Router { get; } + + /// Gets or sets the view-contract observable. + IObservable ViewContractObservable { get; set; } + + /// Gets the current view contract. + string? ViewContract { get; } + + /// Stores the latest observed view contract without replacing its source observable. + /// The observed contract. + void SetObservedViewContract(string? contract); +} diff --git a/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs b/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs index 96045b4e63..6a1f687c4f 100644 --- a/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs +++ b/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs @@ -141,29 +141,27 @@ internal static IObservable CreatePropertyValueObservable( } /// Initializes a WinUI routed host and its view-contract subscriptions. - /// The host, logger, and view-contract setter. - /// The router property metadata and accessor. - /// The view-contract observable property metadata and accessor. - /// Gets the current view contract. - /// Stores the latest view contract. - /// Resolves a routed view model and contract. + /// The routed-host type. + /// The host to initialize. + /// The host's router dependency property. + /// The host's view-contract observable dependency property. /// Collects the host subscription. - internal static void InitializeRoutedViewHost( - (FrameworkElement Source, IFullLogger Logger, Action> SetViewContractObservable) host, - (string Name, DependencyProperty Property, Func GetValue) router, - (string Name, DependencyProperty Property, Func> GetValue) viewContractObservable, - Func getViewContract, - Action setViewContract, - Action<(IRoutableViewModel? viewModel, string? contract)> resolveView, - MultipleDisposable subscriptions) + /// Resolves a routed view model and contract. + internal static void InitializeRoutedViewHost( + THost host, + DependencyProperty routerProperty, + DependencyProperty viewContractObservableProperty, + MultipleDisposable subscriptions, + Action<(IRoutableViewModel? viewModel, string? contract)> resolveView) + where THost : FrameworkElement, IMauiRoutedViewHost { - host.SetViewContractObservable(CreateViewContractObservable(host.Source, host.Logger)); + host.ViewContractObservable = CreateViewContractObservable(host, host.Log()); SubscribeRoutedViewHost( - host.Source, - router, - viewContractObservable, - getViewContract, - setViewContract, + host, + (nameof(host.Router), routerProperty, () => host.Router), + (nameof(host.ViewContractObservable), viewContractObservableProperty, () => host.ViewContractObservable), + () => host.ViewContract, + host.SetObservedViewContract, resolveView, subscriptions); } diff --git a/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj b/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj index c12dd0dc45..68b1caa192 100644 --- a/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj +++ b/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj @@ -29,6 +29,9 @@ + + + diff --git a/src/reactiveui.slnx b/src/reactiveui.slnx index 66ed88f6e3..0b4b06faca 100644 --- a/src/reactiveui.slnx +++ b/src/reactiveui.slnx @@ -48,6 +48,7 @@ + diff --git a/src/tests/ReactiveUI.Blazor.Tests/ReactiveUI.Blazor.Tests.csproj b/src/tests/ReactiveUI.Blazor.Tests/ReactiveUI.Blazor.Tests.csproj index 3df0c92aed..3ea128d3ef 100644 --- a/src/tests/ReactiveUI.Blazor.Tests/ReactiveUI.Blazor.Tests.csproj +++ b/src/tests/ReactiveUI.Blazor.Tests/ReactiveUI.Blazor.Tests.csproj @@ -9,6 +9,7 @@ $(ReactiveUIModernTargets) Exe true + false enable enable net10.0-android;net10.0-ios Exe + true + false $(NoWarn);CS1591 diff --git a/src/tests/ReactiveUI.Routing.Tests/ReactiveUI.Routing.Tests.csproj b/src/tests/ReactiveUI.Routing.Tests/ReactiveUI.Routing.Tests.csproj index 8f581e5280..6e67acca76 100644 --- a/src/tests/ReactiveUI.Routing.Tests/ReactiveUI.Routing.Tests.csproj +++ b/src/tests/ReactiveUI.Routing.Tests/ReactiveUI.Routing.Tests.csproj @@ -9,6 +9,8 @@ $(ReactiveUITestingTargets) Exe ReactiveUI.Tests + true + false diff --git a/src/tests/ReactiveUI.Splat.Tests/ReactiveUI.Splat.Tests.csproj b/src/tests/ReactiveUI.Splat.Tests/ReactiveUI.Splat.Tests.csproj index 72ec181213..e839f5d667 100644 --- a/src/tests/ReactiveUI.Splat.Tests/ReactiveUI.Splat.Tests.csproj +++ b/src/tests/ReactiveUI.Splat.Tests/ReactiveUI.Splat.Tests.csproj @@ -2,6 +2,7 @@ $(ReactiveUITestingTargets) Exe + true false diff --git a/src/tests/ReactiveUI.Testing.Tests/ReactiveUI.Testing.Tests.csproj b/src/tests/ReactiveUI.Testing.Tests/ReactiveUI.Testing.Tests.csproj index 410e7b9135..e5921809c7 100644 --- a/src/tests/ReactiveUI.Testing.Tests/ReactiveUI.Testing.Tests.csproj +++ b/src/tests/ReactiveUI.Testing.Tests/ReactiveUI.Testing.Tests.csproj @@ -2,6 +2,8 @@ $(ReactiveUITestingTargets) Exe + true + false netstandard2.0 $(TargetFramework) diff --git a/src/tests/ReactiveUI.Tests/ReactiveUI.Tests.csproj b/src/tests/ReactiveUI.Tests/ReactiveUI.Tests.csproj index 0cb93a8579..607e00b719 100644 --- a/src/tests/ReactiveUI.Tests/ReactiveUI.Tests.csproj +++ b/src/tests/ReactiveUI.Tests/ReactiveUI.Tests.csproj @@ -8,6 +8,8 @@ $(ReactiveUITestingTargets) Exe + true + false diff --git a/src/tests/ReactiveUI.WinForms.Tests/ReactiveUI.WinForms.Tests.csproj b/src/tests/ReactiveUI.WinForms.Tests/ReactiveUI.WinForms.Tests.csproj index 9eae3ee806..45c7aa557c 100644 --- a/src/tests/ReactiveUI.WinForms.Tests/ReactiveUI.WinForms.Tests.csproj +++ b/src/tests/ReactiveUI.WinForms.Tests/ReactiveUI.WinForms.Tests.csproj @@ -8,6 +8,8 @@ $(ReactiveUITestingUITargets) Exe + true + false diff --git a/src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj b/src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj new file mode 100644 index 0000000000..8cb7995cbb --- /dev/null +++ b/src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj @@ -0,0 +1,34 @@ + + + $(ReactiveUITestingUITargets) + Exe + true + false + + + + + + + + + + + + true + None + false + false + false + + + + + + + + + + + diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs new file mode 100644 index 0000000000..9e824c4ea9 --- /dev/null +++ b/src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +[assembly: NotInParallel] + +namespace ReactiveUI.WinUI.Tests; + +/// Controls the lifetime of the WinUI application used by this test assembly. +public static class AssemblyHooks +{ + /// Stops the WinUI application after all tests complete. + /// A representing the asynchronous operation. + [After(Assembly)] + public static Task AssemblyCleanup() => WinUITestExecutor.StopAsync(); +} diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs new file mode 100644 index 0000000000..89f08b1eec --- /dev/null +++ b/src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.UI.Xaml; +using ReactiveUI.Maui.Internal; +using TUnit.Core.Executors; + +namespace ReactiveUI.WinUI.Tests; + +/// Tests the shared WinUI routed-host initialization. +public sealed class MauiReactiveHelpersTest +{ + /// Verifies that routed-host initialization assigns its contract stream and starts route resolution. + /// A representing the asynchronous operation. + [Test] + [TestExecutor] + public async Task InitializeRoutedViewHost_InitializesContractAndRouteSubscriptions() + { + var host = new TestWinUiRoutedHost(); + var resolutions = new List<(IRoutableViewModel? viewModel, string? contract)>(); + MultipleDisposable subscriptions = []; + + MauiReactiveHelpers.InitializeRoutedViewHost( + host, + TestWinUiRoutedHost.RouterProperty, + TestWinUiRoutedHost.ViewContractObservableProperty, + subscriptions, + resolutions.Add); + + await Assert.That(host.ViewContractObservable).IsNotNull(); + await Assert.That(resolutions.Count).IsEqualTo(1); + await Assert.That(resolutions[0].viewModel).IsNull(); + await Assert.That(resolutions[0].contract).IsNull(); + + subscriptions.Dispose(); + } + + /// Verifies that the non-generic routed-host overload uses the shared initialization. + /// A representing the asynchronous operation. + [Test] + [TestExecutor] + public async Task RoutedViewHost_InitializesViewContractObservable() + { + var host = new RoutedViewHost(); + + await Assert.That(host.ViewContractObservable).IsNotNull(); + await Assert.That(host.ViewContract).IsNull(); + } + + /// Verifies that the generic routed-host overload uses the shared initialization. + /// A representing the asynchronous operation. + [Test] + [TestExecutor] + public async Task GenericRoutedViewHost_InitializesViewContractObservable() + { + var host = new RoutedViewHost(); + + await Assert.That(host.ViewContractObservable).IsNotNull(); + await Assert.That(host.ViewContract).IsNull(); + } + + /// Minimal routed host used to exercise the shared initialization helper directly. + private sealed class TestWinUiRoutedHost : FrameworkElement, IMauiRoutedViewHost + { + /// The router dependency property. + public static readonly DependencyProperty RouterProperty = + DependencyProperty.Register(nameof(Router), typeof(RoutingState), typeof(TestWinUiRoutedHost), new(null)); + + /// The view-contract observable dependency property. + public static readonly DependencyProperty ViewContractObservableProperty = + DependencyProperty.Register( + nameof(ViewContractObservable), + typeof(IObservable), + typeof(TestWinUiRoutedHost), + new(Signal.Emit(null))); + + /// + public RoutingState Router + { + get => (RoutingState)GetValue(RouterProperty); + set => SetValue(RouterProperty, value); + } + + /// + public IObservable ViewContractObservable + { + get => (IObservable)GetValue(ViewContractObservableProperty); + set => SetValue(ViewContractObservableProperty, value); + } + + /// + public string? ViewContract { get; private set; } + + /// + void IMauiRoutedViewHost.SetObservedViewContract(string? contract) => ViewContract = contract; + } + + /// Minimal view model used to construct the generic routed host. + private sealed class TestWinUiRoutableViewModel : ReactiveObject, IRoutableViewModel + { + /// + public string? UrlPathSegment => null; + + /// + public IScreen HostScreen { get; } = new TestWinUiScreen(); + } + + /// Minimal screen used by the generic routed-host view model. + private sealed class TestWinUiScreen : IScreen + { + /// + public RoutingState Router { get; } = new(); + } +} diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs new file mode 100644 index 0000000000..a84701c037 --- /dev/null +++ b/src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using ReactiveUI.Tests.Utilities.AppBuilder; +using TUnit.Core.Interfaces; + +namespace ReactiveUI.WinUI.Tests; + +/// Runs tests on a shared STA thread initialized with a WinUI XAML application. +public sealed class WinUITestExecutor : ITestExecutor +{ + /// Receives the shared dispatcher after the WinUI application initializes. + private static readonly TaskCompletionSource DispatcherReady = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Initializes static members of the class. + static WinUITestExecutor() => StartDispatcher(); + + /// + public async ValueTask ExecuteTest(TestContext context, Func action) + { + ArgumentNullException.ThrowIfNull(action); + + var dispatcher = await DispatcherReady.Task.ConfigureAwait(false); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!dispatcher.TryEnqueue(() => _ = ExecuteAsync(action, completion))) + { + throw new InvalidOperationException("The WinUI test dispatcher is unavailable."); + } + + await completion.Task.ConfigureAwait(false); + } + + /// Stops the shared WinUI application after the test assembly completes. + /// A representing the asynchronous operation. + internal static async Task StopAsync() + { + var dispatcher = await DispatcherReady.Task.ConfigureAwait(false); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!dispatcher.TryEnqueue(() => + { + Application.Current.Exit(); + completion.SetResult(); + })) + { + return; + } + + await completion.Task.ConfigureAwait(false); + } + + /// Starts the shared WinUI application thread. + private static void StartDispatcher() + { + var thread = new Thread(static () => + Application.Start(static ignored => + { + _ = new TestApplication(); + DispatcherReady.SetResult(DispatcherQueue.GetForCurrentThread()); + })); + thread.IsBackground = true; + thread.Name = "WinUI test dispatcher"; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + + /// Executes a test and reports its completion to the calling test thread. + /// The test action. + /// Receives the test result. + /// A representing the asynchronous operation. + private static async Task ExecuteAsync(Func action, TaskCompletionSource completion) + { + var helper = new AppBuilderTestHelper(); + try + { + helper.Initialize(static builder => _ = builder.WithCoreServices()); + try + { + await action().ConfigureAwait(true); + } + finally + { + helper.CleanUp(); + } + + completion.SetResult(); + } + catch (Exception exception) + { + completion.SetException(exception); + } + } + + /// Minimal WinUI application used to initialize XAML for the test process. + private sealed class TestApplication : Application; +} diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs new file mode 100644 index 0000000000..f4fdd1d1f5 --- /dev/null +++ b/src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.WinUI.Tests; + +/// Verifies the non-Windows fallback used by the cross-platform solution build. +public sealed class WinUIFallbackTest +{ + /// Verifies that the fallback target does not load WinUI. + /// A representing the asynchronous operation. + [Test] + public async Task WinUIAssembly_IsNotLoaded() => + await Assert.That(Type.GetType("Microsoft.UI.Xaml.Application, Microsoft.WinUI")).IsNull(); +} diff --git a/src/tests/ReactiveUI.Wpf.Tests/ReactiveUI.Wpf.Tests.csproj b/src/tests/ReactiveUI.Wpf.Tests/ReactiveUI.Wpf.Tests.csproj index 431e00a13b..d7dcfd7f1f 100644 --- a/src/tests/ReactiveUI.Wpf.Tests/ReactiveUI.Wpf.Tests.csproj +++ b/src/tests/ReactiveUI.Wpf.Tests/ReactiveUI.Wpf.Tests.csproj @@ -9,6 +9,8 @@ $(ReactiveUITestingUITargets) Exe ReactiveUI.Tests + true + false From 169a80ccb068b4d524f4dc5d7cecb663fe6659b1 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:04:39 +1000 Subject: [PATCH 2/3] build(deps): update primitives to 7.1.0 - Use the latest compatible ReactiveUI.Primitives release across the target framework matrix. --- src/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index e3585ebcda..5fd5935ac9 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,7 +6,7 @@ 20.2.0 - 7.0.0 + 7.1.0 1.61.38 From 09c7c7e05a14c6b08921e50dd2d4edfa63d50a0d Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:37:26 +1000 Subject: [PATCH 3/3] remove redundant tests --- src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj | 3 - src/reactiveui.slnx | 1 - .../ReactiveUI.WinUI.Tests.csproj | 34 ----- .../WinUI/AssemblyHooks.cs | 17 --- .../WinUI/MauiReactiveHelpersTest.cs | 116 ------------------ .../WinUI/WinUITestExecutor.cs | 100 --------------- .../WinUIFallbackTest.cs | 16 --- 7 files changed, 287 deletions(-) delete mode 100644 src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj delete mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs delete mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs delete mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs delete mode 100644 src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs diff --git a/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj b/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj index 68b1caa192..c12dd0dc45 100644 --- a/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj +++ b/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj @@ -29,9 +29,6 @@ - - - diff --git a/src/reactiveui.slnx b/src/reactiveui.slnx index 0b4b06faca..66ed88f6e3 100644 --- a/src/reactiveui.slnx +++ b/src/reactiveui.slnx @@ -48,7 +48,6 @@ - diff --git a/src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj b/src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj deleted file mode 100644 index 8cb7995cbb..0000000000 --- a/src/tests/ReactiveUI.WinUI.Tests/ReactiveUI.WinUI.Tests.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - $(ReactiveUITestingUITargets) - Exe - true - false - - - - - - - - - - - - true - None - false - false - false - - - - - - - - - - - diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs deleted file mode 100644 index 9e824c4ea9..0000000000 --- a/src/tests/ReactiveUI.WinUI.Tests/WinUI/AssemblyHooks.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -[assembly: NotInParallel] - -namespace ReactiveUI.WinUI.Tests; - -/// Controls the lifetime of the WinUI application used by this test assembly. -public static class AssemblyHooks -{ - /// Stops the WinUI application after all tests complete. - /// A representing the asynchronous operation. - [After(Assembly)] - public static Task AssemblyCleanup() => WinUITestExecutor.StopAsync(); -} diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs deleted file mode 100644 index 89f08b1eec..0000000000 --- a/src/tests/ReactiveUI.WinUI.Tests/WinUI/MauiReactiveHelpersTest.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using Microsoft.UI.Xaml; -using ReactiveUI.Maui.Internal; -using TUnit.Core.Executors; - -namespace ReactiveUI.WinUI.Tests; - -/// Tests the shared WinUI routed-host initialization. -public sealed class MauiReactiveHelpersTest -{ - /// Verifies that routed-host initialization assigns its contract stream and starts route resolution. - /// A representing the asynchronous operation. - [Test] - [TestExecutor] - public async Task InitializeRoutedViewHost_InitializesContractAndRouteSubscriptions() - { - var host = new TestWinUiRoutedHost(); - var resolutions = new List<(IRoutableViewModel? viewModel, string? contract)>(); - MultipleDisposable subscriptions = []; - - MauiReactiveHelpers.InitializeRoutedViewHost( - host, - TestWinUiRoutedHost.RouterProperty, - TestWinUiRoutedHost.ViewContractObservableProperty, - subscriptions, - resolutions.Add); - - await Assert.That(host.ViewContractObservable).IsNotNull(); - await Assert.That(resolutions.Count).IsEqualTo(1); - await Assert.That(resolutions[0].viewModel).IsNull(); - await Assert.That(resolutions[0].contract).IsNull(); - - subscriptions.Dispose(); - } - - /// Verifies that the non-generic routed-host overload uses the shared initialization. - /// A representing the asynchronous operation. - [Test] - [TestExecutor] - public async Task RoutedViewHost_InitializesViewContractObservable() - { - var host = new RoutedViewHost(); - - await Assert.That(host.ViewContractObservable).IsNotNull(); - await Assert.That(host.ViewContract).IsNull(); - } - - /// Verifies that the generic routed-host overload uses the shared initialization. - /// A representing the asynchronous operation. - [Test] - [TestExecutor] - public async Task GenericRoutedViewHost_InitializesViewContractObservable() - { - var host = new RoutedViewHost(); - - await Assert.That(host.ViewContractObservable).IsNotNull(); - await Assert.That(host.ViewContract).IsNull(); - } - - /// Minimal routed host used to exercise the shared initialization helper directly. - private sealed class TestWinUiRoutedHost : FrameworkElement, IMauiRoutedViewHost - { - /// The router dependency property. - public static readonly DependencyProperty RouterProperty = - DependencyProperty.Register(nameof(Router), typeof(RoutingState), typeof(TestWinUiRoutedHost), new(null)); - - /// The view-contract observable dependency property. - public static readonly DependencyProperty ViewContractObservableProperty = - DependencyProperty.Register( - nameof(ViewContractObservable), - typeof(IObservable), - typeof(TestWinUiRoutedHost), - new(Signal.Emit(null))); - - /// - public RoutingState Router - { - get => (RoutingState)GetValue(RouterProperty); - set => SetValue(RouterProperty, value); - } - - /// - public IObservable ViewContractObservable - { - get => (IObservable)GetValue(ViewContractObservableProperty); - set => SetValue(ViewContractObservableProperty, value); - } - - /// - public string? ViewContract { get; private set; } - - /// - void IMauiRoutedViewHost.SetObservedViewContract(string? contract) => ViewContract = contract; - } - - /// Minimal view model used to construct the generic routed host. - private sealed class TestWinUiRoutableViewModel : ReactiveObject, IRoutableViewModel - { - /// - public string? UrlPathSegment => null; - - /// - public IScreen HostScreen { get; } = new TestWinUiScreen(); - } - - /// Minimal screen used by the generic routed-host view model. - private sealed class TestWinUiScreen : IScreen - { - /// - public RoutingState Router { get; } = new(); - } -} diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs deleted file mode 100644 index a84701c037..0000000000 --- a/src/tests/ReactiveUI.WinUI.Tests/WinUI/WinUITestExecutor.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml; -using ReactiveUI.Tests.Utilities.AppBuilder; -using TUnit.Core.Interfaces; - -namespace ReactiveUI.WinUI.Tests; - -/// Runs tests on a shared STA thread initialized with a WinUI XAML application. -public sealed class WinUITestExecutor : ITestExecutor -{ - /// Receives the shared dispatcher after the WinUI application initializes. - private static readonly TaskCompletionSource DispatcherReady = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Initializes static members of the class. - static WinUITestExecutor() => StartDispatcher(); - - /// - public async ValueTask ExecuteTest(TestContext context, Func action) - { - ArgumentNullException.ThrowIfNull(action); - - var dispatcher = await DispatcherReady.Task.ConfigureAwait(false); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!dispatcher.TryEnqueue(() => _ = ExecuteAsync(action, completion))) - { - throw new InvalidOperationException("The WinUI test dispatcher is unavailable."); - } - - await completion.Task.ConfigureAwait(false); - } - - /// Stops the shared WinUI application after the test assembly completes. - /// A representing the asynchronous operation. - internal static async Task StopAsync() - { - var dispatcher = await DispatcherReady.Task.ConfigureAwait(false); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!dispatcher.TryEnqueue(() => - { - Application.Current.Exit(); - completion.SetResult(); - })) - { - return; - } - - await completion.Task.ConfigureAwait(false); - } - - /// Starts the shared WinUI application thread. - private static void StartDispatcher() - { - var thread = new Thread(static () => - Application.Start(static ignored => - { - _ = new TestApplication(); - DispatcherReady.SetResult(DispatcherQueue.GetForCurrentThread()); - })); - thread.IsBackground = true; - thread.Name = "WinUI test dispatcher"; - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - } - - /// Executes a test and reports its completion to the calling test thread. - /// The test action. - /// Receives the test result. - /// A representing the asynchronous operation. - private static async Task ExecuteAsync(Func action, TaskCompletionSource completion) - { - var helper = new AppBuilderTestHelper(); - try - { - helper.Initialize(static builder => _ = builder.WithCoreServices()); - try - { - await action().ConfigureAwait(true); - } - finally - { - helper.CleanUp(); - } - - completion.SetResult(); - } - catch (Exception exception) - { - completion.SetException(exception); - } - } - - /// Minimal WinUI application used to initialize XAML for the test process. - private sealed class TestApplication : Application; -} diff --git a/src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs b/src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs deleted file mode 100644 index f4fdd1d1f5..0000000000 --- a/src/tests/ReactiveUI.WinUI.Tests/WinUIFallbackTest.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace ReactiveUI.WinUI.Tests; - -/// Verifies the non-Windows fallback used by the cross-platform solution build. -public sealed class WinUIFallbackTest -{ - /// Verifies that the fallback target does not load WinUI. - /// A representing the asynchronous operation. - [Test] - public async Task WinUIAssembly_IsNotLoaded() => - await Assert.That(Type.GetType("Microsoft.UI.Xaml.Application, Microsoft.WinUI")).IsNull(); -}