Skip to content

Commit 9adc818

Browse files
committed
test: cover view-model creation failures during navigation
1 parent 645f8a7 commit 9adc818

1 file changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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

Comments
 (0)