|
| 1 | +using System; |
| 2 | +using System.Collections.Concurrent; |
| 3 | +using System.Linq; |
| 4 | +using System.Threading; |
| 5 | +using System.Threading.Tasks; |
| 6 | +using FluentAssertions; |
| 7 | +using Microsoft.Extensions.DependencyInjection; |
| 8 | +using Microsoft.Extensions.Hosting; |
| 9 | +using Microsoft.Extensions.Logging; |
| 10 | +using Microsoft.UI.Xaml; |
| 11 | +using Microsoft.UI.Xaml.Controls; |
| 12 | +using Microsoft.VisualStudio.TestTools.UnitTesting; |
| 13 | +using Uno.Extensions.Hosting; |
| 14 | +using Uno.Extensions.Navigation.UI.Controls; |
| 15 | +using Uno.Extensions.Navigation.UI.Tests.Pages; |
| 16 | +using Uno.UI.RuntimeTests; |
| 17 | + |
| 18 | +namespace Uno.Extensions.Navigation.UI.Tests; |
| 19 | + |
| 20 | +/// <summary> |
| 21 | +/// Tests for how navigation surfaces a view model whose constructor (or DI |
| 22 | +/// dependency chain) throws (#3136). The failure must: |
| 23 | +/// - fault the navigation task with the original exception (not hang), |
| 24 | +/// - be logged at Error level (previously nothing was logged at any level), |
| 25 | +/// - leave the navigator usable for subsequent navigations |
| 26 | +/// (RouteUpdater.EndNavigation must run even when navigation faults). |
| 27 | +/// </summary> |
| 28 | +[TestClass] |
| 29 | +[RunsOnUIThread] |
| 30 | +public class Given_Navigation_VmCreationFailure |
| 31 | +{ |
| 32 | + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); |
| 33 | + |
| 34 | + public sealed class ThrowingCtorViewModel |
| 35 | + { |
| 36 | + public const string FailureMessage = "ThrowingCtorViewModel: constructor failure"; |
| 37 | + |
| 38 | + public ThrowingCtorViewModel() |
| 39 | + { |
| 40 | + throw new InvalidOperationException(FailureMessage); |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + private sealed class CapturingLoggerProvider : ILoggerProvider |
| 45 | + { |
| 46 | + public ConcurrentQueue<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); |
| 47 | + |
| 48 | + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this); |
| 49 | + |
| 50 | + public void Dispose() |
| 51 | + { |
| 52 | + } |
| 53 | + |
| 54 | + private sealed class CapturingLogger : ILogger |
| 55 | + { |
| 56 | + private readonly CapturingLoggerProvider _owner; |
| 57 | + |
| 58 | + public CapturingLogger(CapturingLoggerProvider owner) |
| 59 | + { |
| 60 | + _owner = owner; |
| 61 | + } |
| 62 | + |
| 63 | + public IDisposable? BeginScope<TState>(TState state) where TState : notnull => default; |
| 64 | + |
| 65 | + public bool IsEnabled(LogLevel logLevel) => true; |
| 66 | + |
| 67 | + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) |
| 68 | + => _owner.Entries.Enqueue((logLevel, formatter(state, exception), exception)); |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + private sealed class TestApp : IAsyncDisposable |
| 73 | + { |
| 74 | + private readonly IHost _host; |
| 75 | + |
| 76 | + public TestApp(ContentControl navigationRoot, INavigator frameNavigator, IHost host, CapturingLoggerProvider logs) |
| 77 | + { |
| 78 | + NavigationRoot = navigationRoot; |
| 79 | + FrameNavigator = frameNavigator; |
| 80 | + Logs = logs; |
| 81 | + _host = host; |
| 82 | + } |
| 83 | + |
| 84 | + public ContentControl NavigationRoot { get; } |
| 85 | + |
| 86 | + public INavigator FrameNavigator { get; } |
| 87 | + |
| 88 | + public CapturingLoggerProvider Logs { get; } |
| 89 | + |
| 90 | + public async ValueTask DisposeAsync() |
| 91 | + { |
| 92 | + try |
| 93 | + { |
| 94 | + await _host.StopAsync(); |
| 95 | + } |
| 96 | + finally |
| 97 | + { |
| 98 | + UnitTestsUIContentHelper.RestoreOriginalContent(); |
| 99 | + } |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + /// <summary> |
| 104 | + /// Boots an Uno host with navigation, hosted in the runtime-tests engine's |
| 105 | + /// already-displayed test window (a fresh <c>new Window()</c> never fires |
| 106 | + /// Loaded/Activate in this harness — see Given_HotReload.SetupAppAsync), and |
| 107 | + /// navigates to TestPageOne. Navigation targets the FrameView's inner |
| 108 | + /// navigator because a Page navigated into a ContentControl root is wrapped |
| 109 | + /// in a FrameView (see ContentControlNavigator.Show). |
| 110 | + /// </summary> |
| 111 | + private static async Task<TestApp> SetupAppAsync(CancellationToken ct) |
| 112 | + { |
| 113 | + var window = UnitTestsUIContentHelper.CurrentTestWindow!; |
| 114 | + var navigationRoot = new ContentControl |
| 115 | + { |
| 116 | + HorizontalAlignment = HorizontalAlignment.Stretch, |
| 117 | + VerticalAlignment = VerticalAlignment.Stretch, |
| 118 | + HorizontalContentAlignment = HorizontalAlignment.Stretch, |
| 119 | + VerticalContentAlignment = VerticalAlignment.Stretch, |
| 120 | + }; |
| 121 | + |
| 122 | + UnitTestsUIContentHelper.SaveOriginalContent(); |
| 123 | + window.Content = navigationRoot; |
| 124 | + |
| 125 | + var logs = new CapturingLoggerProvider(); |
| 126 | + IHost? host = null; |
| 127 | + try |
| 128 | + { |
| 129 | + host = await window.InitializeNavigationAsync( |
| 130 | + buildHost: async () => UnoHost |
| 131 | + .CreateDefaultBuilder(typeof(Given_Navigation_VmCreationFailure).Assembly) |
| 132 | + .ConfigureServices(services => services.AddLogging(logging => logging.AddProvider(logs))) |
| 133 | + .UseNavigation( |
| 134 | + viewRouteBuilder: (views, routes) => |
| 135 | + { |
| 136 | + views.Register( |
| 137 | + new ViewMap<TestPageOne>(), |
| 138 | + new ViewMap<TestPageTwo, ThrowingCtorViewModel>(), |
| 139 | + new ViewMap<TestPageThree>()); |
| 140 | + |
| 141 | + routes.Register( |
| 142 | + new RouteMap("", Nested: new RouteMap[] |
| 143 | + { |
| 144 | + new RouteMap("TestPageOne", View: views.FindByView<TestPageOne>()), |
| 145 | + new RouteMap("TestPageTwo", View: views.FindByView<TestPageTwo>()), |
| 146 | + new RouteMap("TestPageThree", View: views.FindByView<TestPageThree>()), |
| 147 | + })); |
| 148 | + }) |
| 149 | + .Build(), |
| 150 | + navigationRoot: navigationRoot, |
| 151 | + initialRoute: "TestPageOne"); |
| 152 | + |
| 153 | + var frameNav = await WaitForFrameNavigatorAsync(navigationRoot, Timeout, ct); |
| 154 | + await WaitForRouteAsync(frameNav, "TestPageOne", Timeout, ct); |
| 155 | + |
| 156 | + return new TestApp(navigationRoot, frameNav, host, logs); |
| 157 | + } |
| 158 | + catch |
| 159 | + { |
| 160 | + if (host is not null) |
| 161 | + { |
| 162 | + await host.StopAsync(); |
| 163 | + } |
| 164 | + UnitTestsUIContentHelper.RestoreOriginalContent(); |
| 165 | + throw; |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + [TestMethod] |
| 170 | + public async Task When_VmCtorThrows_Then_NavigationFaults_And_ErrorIsLogged() |
| 171 | + { |
| 172 | + using var cts = new CancellationTokenSource(Timeout); |
| 173 | + await using var app = await SetupAppAsync(cts.Token); |
| 174 | + |
| 175 | + var navigation = app.FrameNavigator.NavigateRouteAsync(this, "TestPageTwo"); |
| 176 | + |
| 177 | + // The navigation must complete (faulted), not hang — the hang is the |
| 178 | + // original #3136 symptom. |
| 179 | + var completed = await Task.WhenAny(navigation, Task.Delay(Timeout)); |
| 180 | + completed.Should().Be(navigation, "a failing view-model constructor must fault the navigation, not hang it"); |
| 181 | + |
| 182 | + var exception = await Assert.ThrowsExceptionAsync<InvalidOperationException>(() => navigation); |
| 183 | + exception.Message.Should().Be(ThrowingCtorViewModel.FailureMessage, "the original constructor exception must propagate unwrapped"); |
| 184 | + |
| 185 | + var errors = app.Logs.Entries.Where(e => e.Level == LogLevel.Error).ToArray(); |
| 186 | + errors.Should().Contain( |
| 187 | + e => e.Exception is InvalidOperationException && e.Message.Contains(nameof(ThrowingCtorViewModel)), |
| 188 | + "the view-model construction failure must be logged at Error with the view-model type"); |
| 189 | + } |
| 190 | + |
| 191 | + [TestMethod] |
| 192 | + public async Task When_VmCtorThrows_Then_SubsequentNavigationStillWorks() |
| 193 | + { |
| 194 | + using var cts = new CancellationTokenSource(Timeout); |
| 195 | + await using var app = await SetupAppAsync(cts.Token); |
| 196 | + |
| 197 | + var navigation = app.FrameNavigator.NavigateRouteAsync(this, "TestPageTwo"); |
| 198 | + await Assert.ThrowsExceptionAsync<InvalidOperationException>(() => navigation); |
| 199 | + |
| 200 | + // The faulted navigation must not wedge the pipeline: EndNavigation ran, |
| 201 | + // so a follow-up navigation on the same navigator succeeds. |
| 202 | + await app.FrameNavigator.NavigateRouteAsync(this, "TestPageThree"); |
| 203 | + |
| 204 | + using var routeCts = new CancellationTokenSource(Timeout); |
| 205 | + await WaitForRouteAsync(app.FrameNavigator, "TestPageThree", Timeout, routeCts.Token); |
| 206 | + |
| 207 | + app.FrameNavigator.Route?.Base.Should().Be("TestPageThree"); |
| 208 | + } |
| 209 | + |
| 210 | + private static async Task<INavigator> WaitForFrameNavigatorAsync(ContentControl root, TimeSpan timeout, CancellationToken ct) |
| 211 | + { |
| 212 | + var sw = System.Diagnostics.Stopwatch.StartNew(); |
| 213 | + while (sw.Elapsed < timeout) |
| 214 | + { |
| 215 | + ct.ThrowIfCancellationRequested(); |
| 216 | + if (root.Content is FrameView fv && fv.Navigator is { } nav) |
| 217 | + { |
| 218 | + return nav; |
| 219 | + } |
| 220 | + await Task.Delay(50, ct); |
| 221 | + } |
| 222 | + |
| 223 | + throw new TimeoutException( |
| 224 | + $"FrameView navigator did not become available within {timeout.TotalSeconds:F0}s. " + |
| 225 | + $"root.Content={root.Content?.GetType().FullName ?? "<null>"}."); |
| 226 | + } |
| 227 | + |
| 228 | + private static async Task WaitForRouteAsync(INavigator nav, string expectedBase, TimeSpan timeout, CancellationToken ct) |
| 229 | + { |
| 230 | + var sw = System.Diagnostics.Stopwatch.StartNew(); |
| 231 | + while (sw.Elapsed < timeout) |
| 232 | + { |
| 233 | + ct.ThrowIfCancellationRequested(); |
| 234 | + if (nav.Route?.Base == expectedBase) |
| 235 | + { |
| 236 | + return; |
| 237 | + } |
| 238 | + await Task.Delay(50, ct); |
| 239 | + } |
| 240 | + |
| 241 | + throw new TimeoutException( |
| 242 | + $"Navigation did not reach Base='{expectedBase}' within {timeout.TotalSeconds:F0}s. " + |
| 243 | + $"Last state: Route='{nav.Route?.Base ?? "<null>"}'."); |
| 244 | + } |
| 245 | +} |
0 commit comments