Skip to content

Commit c0bfe13

Browse files
committed
feat: Navigation will set parameters
1 parent 0ed621c commit c0bfe13

File tree

14 files changed

+367
-13
lines changed

14 files changed

+367
-13
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ All notable changes to **bUnit** will be documented in this file. The project ad
66

77
## [Unreleased]
88

9+
### Added
10+
- Implemented feature to map route templates to parameters using NavigationManager. This allows parameters to be set based on the route template when navigating to a new location. Reported by [JamesNK](https://github.com/JamesNK) in [#1580](https://github.com/bUnit-dev/bUnit/issues/1580). By [@linkdotnet](https://github.com/linkdotnet).
11+
912
## [1.33.3] - 2024-10-11
1013

1114
### Added

docs/site/docs/providing-input/passing-parameters-to-components.md

+35
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,41 @@ A simple example of how to test a component that receives parameters from the qu
501501
}
502502
```
503503

504+
## Setting parameters via routing
505+
In Blazor, components can receive parameters via routing. This is particularly useful for passing data to components based on the URL. To enable this, the component parameters need to be annotated with the `[Parameter]` attribute and the `@page` directive (or `RouteAttribute` in code behind files).
506+
507+
An example component that receives parameters via routing:
508+
509+
```razor
510+
@page "/counter/{initialCount:int}"
511+
512+
<p>Count: @InitialCount</p>
513+
514+
@code {
515+
[Parameter]
516+
public int InitialCount { get; set; }
517+
}
518+
```
519+
520+
To test a component that receives parameters via routing, set the parameters using the `NavigationManager`:
521+
522+
```razor
523+
@inherits TestContext
524+
525+
@code {
526+
[Fact]
527+
public void Component_receives_parameters_from_route()
528+
{
529+
var cut = RenderComponent<ExampleComponent>();
530+
var navigationManager = Services.GetRequiredService<NavigationManager>();
531+
532+
navigationManager.NavigateTo("/counter/123");
533+
534+
cut.Find("p").TextContent.ShouldBe("Count: 123");
535+
}
536+
}
537+
```
538+
504539
## Further Reading
505540

506541
- <xref:inject-services>

src/bunit.core/Extensions/TestContextBaseRenderExtensions.cs

+4-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ public static IRenderedComponentBase<TComponent> RenderInsideRenderTree<TCompone
2121
throw new ArgumentNullException(nameof(testContext));
2222

2323
var baseResult = RenderInsideRenderTree(testContext, renderFragment);
24-
return testContext.Renderer.FindComponent<TComponent>(baseResult);
24+
var component = testContext.Renderer.FindComponent<TComponent>(baseResult);
25+
var registry = testContext.Services.GetRequiredService<ComponentRegistry>();
26+
registry.Register(component.Instance);
27+
return component;
2528
}
2629

2730
/// <summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace Bunit.Rendering;
2+
3+
/// <summary>
4+
/// This internal class is used to keep track of all components that have been rendered.
5+
/// This class is not intended to be used directly by users of bUnit.
6+
/// </summary>
7+
public sealed class ComponentRegistry
8+
{
9+
private readonly HashSet<IComponent> components = [];
10+
11+
/// <summary>
12+
/// Retrieves all components that have been rendered.
13+
/// </summary>
14+
public ISet<IComponent> Components => components;
15+
16+
/// <summary>
17+
/// Registers a component as rendered.
18+
/// </summary>
19+
public void Register(IComponent component)
20+
=> components.Add(component);
21+
22+
/// <summary>
23+
/// Removes all components from the registry.
24+
/// </summary>
25+
public void Clear() => components.Clear();
26+
}

src/bunit.core/Rendering/TestRenderer.cs

+8-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public class TestRenderer : Renderer, ITestRenderer
2727
private readonly List<RootComponent> rootComponents = new();
2828
private readonly ILogger<TestRenderer> logger;
2929
private readonly IRenderedComponentActivator activator;
30+
private readonly ComponentRegistry registry;
3031
private bool disposed;
3132
private TaskCompletionSource<Exception> unhandledExceptionTsc = new(TaskCreationOptions.RunContinuationsAsynchronously);
3233
private Exception? capturedUnhandledException;
@@ -68,31 +69,34 @@ private bool IsBatchInProgress
6869
/// <summary>
6970
/// Initializes a new instance of the <see cref="TestRenderer"/> class.
7071
/// </summary>
71-
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory)
72+
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry registry, ILoggerFactory loggerFactory)
7273
: base(services, loggerFactory)
7374
{
7475
logger = loggerFactory.CreateLogger<TestRenderer>();
7576
this.activator = renderedComponentActivator;
77+
this.registry = registry;
7678
}
7779
#elif NET5_0_OR_GREATER
7880
/// <summary>
7981
/// Initializes a new instance of the <see cref="TestRenderer"/> class.
8082
/// </summary>
81-
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory)
83+
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry registry, ILoggerFactory loggerFactory)
8284
: base(services, loggerFactory, new BunitComponentActivator(services, services.GetRequiredService<ComponentFactoryCollection>(), null))
8385
{
8486
logger = loggerFactory.CreateLogger<TestRenderer>();
8587
this.activator = renderedComponentActivator;
88+
this.registry = registry;
8689
}
8790

8891
/// <summary>
8992
/// Initializes a new instance of the <see cref="TestRenderer"/> class.
9093
/// </summary>
91-
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory, IComponentActivator componentActivator)
94+
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory, ComponentRegistry registry, IComponentActivator componentActivator)
9295
: base(services, loggerFactory, new BunitComponentActivator(services, services.GetRequiredService<ComponentFactoryCollection>(), componentActivator))
9396
{
9497
logger = loggerFactory.CreateLogger<TestRenderer>();
9598
this.activator = renderedComponentActivator;
99+
this.registry = registry;
96100
}
97101
#endif
98102

@@ -211,6 +215,7 @@ public void DisposeComponents()
211215
});
212216

213217
rootComponents.Clear();
218+
registry.Clear();
214219
AssertNoUnhandledExceptions();
215220
}
216221
}

src/bunit.web/Asserting/MarkupMatchesAssertExtensions.cs

+1
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ public static void MarkupMatches(this IRenderedFragment actual, RenderFragment e
305305
using var renderer = new TestRenderer(
306306
actual.Services.GetRequiredService<IRenderedComponentActivator>(),
307307
actual.Services.GetRequiredService<TestServiceProvider>(),
308+
actual.Services.GetRequiredService<ComponentRegistry>(),
308309
actual.Services.GetRequiredService<ILoggerFactory>());
309310
var renderedFragment = (IRenderedFragment)renderer.RenderFragment(expected);
310311
MarkupMatches(actual, renderedFragment, userMessage);

src/bunit.web/Extensions/TestServiceProviderExtensions.cs

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Bunit.Diffing;
22
using Bunit.Rendering;
33
using Bunit.TestDoubles;
4+
using Bunit.TestDoubles.Router;
45
using Microsoft.AspNetCore.Authorization;
56
using Microsoft.AspNetCore.Components.Authorization;
67
using Microsoft.AspNetCore.Components.Routing;
@@ -45,6 +46,11 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl
4546
services.AddSingleton<FakeWebAssemblyHostEnvironment>();
4647
services.AddSingleton<IWebAssemblyHostEnvironment>(s => s.GetRequiredService<FakeWebAssemblyHostEnvironment>());
4748

49+
// bUnits fake Router
50+
services.AddSingleton<FakeRouter>();
51+
52+
services.AddSingleton<ComponentRegistry>();
53+
4854
#if NET8_0_OR_GREATER
4955
// bUnits fake ScrollToLocationHash
5056
services.AddSingleton<IScrollToLocationHash, BunitScrollToLocationHash>();

src/bunit.web/Rendering/WebTestRenderer.cs

+5-5
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ public class WebTestRenderer : TestRenderer
1818
/// <summary>
1919
/// Initializes a new instance of the <see cref="WebTestRenderer"/> class.
2020
/// </summary>
21-
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory)
22-
: base(renderedComponentActivator, services, loggerFactory)
21+
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry componentRegistry, ILoggerFactory loggerFactory)
22+
: base(renderedComponentActivator, services, componentRegistry, loggerFactory)
2323
{
2424
#if NET5_0_OR_GREATER
2525
ElementReferenceContext = new WebElementReferenceContext(services.GetRequiredService<IJSRuntime>());
@@ -30,10 +30,10 @@ public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, T
3030
/// <summary>
3131
/// Initializes a new instance of the <see cref="WebTestRenderer"/> class.
3232
/// </summary>
33-
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory, IComponentActivator componentActivator)
34-
: base(renderedComponentActivator, services, loggerFactory, componentActivator)
33+
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry componentRegistry, ILoggerFactory loggerFactory, IComponentActivator componentActivator)
34+
: base(renderedComponentActivator, services, loggerFactory, componentRegistry, componentActivator)
3535
{
3636
ElementReferenceContext = new WebElementReferenceContext(services.GetRequiredService<IJSRuntime>());
3737
}
3838
#endif
39-
}
39+
}

src/bunit.web/TestContext.cs

+25-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Bunit.Extensions;
22
using Bunit.Rendering;
3+
using Bunit.TestDoubles.Router;
34
using Microsoft.Extensions.Logging;
45

56
namespace Bunit;
@@ -9,6 +10,8 @@ namespace Bunit;
910
/// </summary>
1011
public class TestContext : TestContextBase
1112
{
13+
private FakeRouter? router;
14+
1215
/// <summary>
1316
/// Gets bUnits JSInterop, that allows setting up handlers for <see cref="IJSRuntime.InvokeAsync{TValue}(string, object[])"/> invocations
1417
/// that components under tests will issue during testing. It also makes it possible to verify that the invocations has happened as expected.
@@ -65,7 +68,13 @@ public virtual IRenderedComponent<TComponent> RenderComponent<TComponent>(Action
6568
/// <returns>The <see cref="IRenderedComponent{TComponent}"/>.</returns>
6669
public virtual IRenderedComponent<TComponent> Render<TComponent>(RenderFragment renderFragment)
6770
where TComponent : IComponent
68-
=> (IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);
71+
{
72+
// There has to be a better way of having this global thing initialized
73+
// We can't do it in the ctor because we would "materialize" the container, and it would
74+
// throw if the user tries to add a service after the ctor has run.
75+
router ??= Services.GetService<FakeRouter>();
76+
return (IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);
77+
}
6978

7079
/// <summary>
7180
/// Renders the <paramref name="renderFragment"/> and returns it as a <see cref="IRenderedFragment"/>.
@@ -75,6 +84,17 @@ public virtual IRenderedComponent<TComponent> Render<TComponent>(RenderFragment
7584
public virtual IRenderedFragment Render(RenderFragment renderFragment)
7685
=> (IRenderedFragment)this.RenderInsideRenderTree(renderFragment);
7786

87+
/// <inheritdoc/>
88+
protected override void Dispose(bool disposing)
89+
{
90+
if (disposing)
91+
{
92+
router?.Dispose();
93+
}
94+
95+
base.Dispose(disposing);
96+
}
97+
7898
/// <summary>
7999
/// Dummy method required to allow Blazor's compiler to generate
80100
/// C# from .razor files.
@@ -86,13 +106,14 @@ protected override ITestRenderer CreateTestRenderer()
86106
{
87107
var renderedComponentActivator = Services.GetRequiredService<IRenderedComponentActivator>();
88108
var logger = Services.GetRequiredService<ILoggerFactory>();
109+
var componentRegistry = Services.GetRequiredService<ComponentRegistry>();
89110
#if !NET5_0_OR_GREATER
90-
return new WebTestRenderer(renderedComponentActivator, Services, logger);
111+
return new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger);
91112
#else
92113
var componentActivator = Services.GetService<IComponentActivator>();
93114
return componentActivator is null
94-
? new WebTestRenderer(renderedComponentActivator, Services, logger)
95-
: new WebTestRenderer(renderedComponentActivator, Services, logger, componentActivator);
115+
? new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger)
116+
: new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger, componentActivator);
96117
#endif
97118

98119
}

src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Bunit.Rendering;
2+
using Bunit.TestDoubles.Router;
23
using Microsoft.AspNetCore.Components.Routing;
34

45
namespace Bunit.TestDoubles;
@@ -72,6 +73,7 @@ protected override void NavigateToCore(string uri, bool forceLoad)
7273
/// <inheritdoc/>
7374
protected override void NavigateToCore(string uri, NavigationOptions options)
7475
{
76+
_ = uri ?? throw new ArgumentNullException(nameof(uri));
7577
var absoluteUri = GetNewAbsoluteUri(uri);
7678
var changedBaseUri = HasDifferentBaseUri(absoluteUri);
7779

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using System.Globalization;
2+
using System.Reflection;
3+
using Bunit.Rendering;
4+
using Microsoft.AspNetCore.Components.Routing;
5+
6+
namespace Bunit.TestDoubles.Router;
7+
8+
internal sealed class FakeRouter : IDisposable
9+
{
10+
private readonly NavigationManager navigationManager;
11+
private readonly ComponentRegistry componentRegistry;
12+
13+
public FakeRouter(NavigationManager navigationManager, ComponentRegistry componentRegistry)
14+
{
15+
this.navigationManager = navigationManager;
16+
this.componentRegistry = componentRegistry;
17+
navigationManager.LocationChanged += UpdatePageParameters;
18+
}
19+
20+
public void Dispose() => navigationManager.LocationChanged -= UpdatePageParameters;
21+
22+
private void UpdatePageParameters(object? sender, LocationChangedEventArgs e)
23+
{
24+
var uri = new Uri(e.Location);
25+
var relativeUri = uri.PathAndQuery;
26+
27+
foreach (var instance in componentRegistry.Components)
28+
{
29+
var routeAttributes = GetRouteAttributesFromComponent(instance);
30+
31+
if (routeAttributes.Length == 0)
32+
{
33+
continue;
34+
}
35+
36+
foreach (var template in routeAttributes.Select(r => r.Template))
37+
{
38+
var templateSegments = template.Trim('/').Split("/");
39+
var uriSegments = relativeUri.Trim('/').Split("/");
40+
41+
if (templateSegments.Length > uriSegments.Length)
42+
{
43+
continue;
44+
}
45+
#if NET6_0_OR_GREATER
46+
var parameters = new Dictionary<string, object?>();
47+
#else
48+
var parameters = new Dictionary<string, object>();
49+
#endif
50+
51+
for (var i = 0; i < templateSegments.Length; i++)
52+
{
53+
var templateSegment = templateSegments[i];
54+
if (templateSegment.StartsWith('{') && templateSegment.EndsWith('}'))
55+
{
56+
var parameterName = GetParameterName(templateSegment);
57+
var property = GetParameterProperty(instance, parameterName);
58+
59+
if (property is null)
60+
{
61+
continue;
62+
}
63+
64+
var isCatchAllParameter = templateSegment[1] == '*';
65+
if (!isCatchAllParameter)
66+
{
67+
parameters[property.Name] = Convert.ChangeType(uriSegments[i], property.PropertyType,
68+
CultureInfo.InvariantCulture);
69+
}
70+
else
71+
{
72+
parameters[parameterName] = string.Join("/", uriSegments.Skip(i));
73+
}
74+
}
75+
else if (templateSegment != uriSegments[i])
76+
{
77+
break;
78+
}
79+
}
80+
81+
if (parameters.Count == 0)
82+
{
83+
continue;
84+
}
85+
86+
// Shall we await this? This should be synchronous in most cases
87+
// If not, very likely the user has overriden the SetParametersAsync method
88+
// And should use WaitForXXX methods to await the desired state
89+
instance.SetParametersAsync(ParameterView.FromDictionary(parameters));
90+
}
91+
}
92+
}
93+
94+
private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance)
95+
{
96+
var routeAttributes = instance
97+
.GetType()
98+
.GetCustomAttributes(typeof(RouteAttribute), true)
99+
.Cast<RouteAttribute>()
100+
.ToArray();
101+
return routeAttributes;
102+
}
103+
104+
private static string GetParameterName(string templateSegment) => templateSegment.Trim('{', '}', '*').Split(':')[0];
105+
106+
private static PropertyInfo? GetParameterProperty(object instance, string propertyName)
107+
{
108+
var propertyInfos = instance.GetType()
109+
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
110+
111+
return Array.Find(propertyInfos, prop => prop.GetCustomAttributes(typeof(ParameterAttribute), true).Any() &&
112+
string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase));
113+
}
114+
}

0 commit comments

Comments
 (0)