Skip to content

Commit 46f5dfb

Browse files
committed
feat: Navigation will set parameters
1 parent 760383c commit 46f5dfb

File tree

15 files changed

+461
-16
lines changed

15 files changed

+461
-16
lines changed

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad
88

99
### Added
1010
- Extension packages (`bunit.generators` and `bunit.web.query`) are flagged as stable.
11-
11+
- 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).
12+
-
1213
## [1.34.0] - 2024-11-01
1314

1415
### Fixed

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

+3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl
4545
services.AddSingleton<FakeWebAssemblyHostEnvironment>();
4646
services.AddSingleton<IWebAssemblyHostEnvironment>(s => s.GetRequiredService<FakeWebAssemblyHostEnvironment>());
4747

48+
services.AddSingleton<ComponentRouteParameterService>();
49+
services.AddSingleton<ComponentRegistry>();
50+
4851
#if NET8_0_OR_GREATER
4952
// bUnits fake ScrollToLocationHash
5053
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

+6-5
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ public virtual IRenderedComponent<TComponent> RenderComponent<TComponent>(Action
6464
/// <param name="renderFragment">The render fragment to render.</param>
6565
/// <returns>The <see cref="IRenderedComponent{TComponent}"/>.</returns>
6666
public virtual IRenderedComponent<TComponent> Render<TComponent>(RenderFragment renderFragment)
67-
where TComponent : IComponent
68-
=> (IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);
67+
where TComponent : IComponent =>
68+
(IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);
6969

7070
/// <summary>
7171
/// Renders the <paramref name="renderFragment"/> and returns it as a <see cref="IRenderedFragment"/>.
@@ -86,13 +86,14 @@ protected override ITestRenderer CreateTestRenderer()
8686
{
8787
var renderedComponentActivator = Services.GetRequiredService<IRenderedComponentActivator>();
8888
var logger = Services.GetRequiredService<ILoggerFactory>();
89+
var componentRegistry = Services.GetRequiredService<ComponentRegistry>();
8990
#if !NET5_0_OR_GREATER
90-
return new WebTestRenderer(renderedComponentActivator, Services, logger);
91+
return new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger);
9192
#else
9293
var componentActivator = Services.GetService<IComponentActivator>();
9394
return componentActivator is null
94-
? new WebTestRenderer(renderedComponentActivator, Services, logger)
95-
: new WebTestRenderer(renderedComponentActivator, Services, logger, componentActivator);
95+
? new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger)
96+
: new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger, componentActivator);
9697
#endif
9798

9899
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using System.Globalization;
2+
using System.Reflection;
3+
using Bunit.Rendering;
4+
#if NET6_0_OR_GREATER
5+
using ParameterViewDictionary = System.Collections.Generic.Dictionary<string, object?>;
6+
#else
7+
using ParameterViewDictionary = System.Collections.Generic.Dictionary<string, object>;
8+
#endif
9+
10+
namespace Bunit.TestDoubles;
11+
12+
/// <summary>
13+
/// This is an internal service that is used to update components with route parameters.
14+
/// It is not meant to be used outside bUnit internal classes.
15+
/// </summary>
16+
public sealed class ComponentRouteParameterService
17+
{
18+
private readonly ComponentRegistry componentRegistry;
19+
20+
/// <summary>
21+
/// Initializes a new instance of the <see cref="ComponentRouteParameterService"/> class.
22+
/// </summary>
23+
public ComponentRouteParameterService(ComponentRegistry componentRegistry)
24+
{
25+
this.componentRegistry = componentRegistry;
26+
}
27+
28+
/// <summary>
29+
/// Triggers the components to update their parameters based on the route parameters.
30+
/// </summary>
31+
public void UpdateComponentsWithRouteParameters(Uri uri)
32+
{
33+
_ = uri ?? throw new ArgumentNullException(nameof(uri));
34+
35+
var relativeUri = uri.PathAndQuery;
36+
37+
foreach (var instance in componentRegistry.Components)
38+
{
39+
var routeAttributes = GetRouteAttributesFromComponent(instance);
40+
41+
if (routeAttributes.Length == 0)
42+
{
43+
continue;
44+
}
45+
46+
foreach (var template in routeAttributes.Select(r => r.Template))
47+
{
48+
var parameters = GetParametersFromTemplateAndUri(template, relativeUri, instance);
49+
if (parameters.Count > 0)
50+
{
51+
instance.SetParametersAsync(ParameterView.FromDictionary(parameters));
52+
}
53+
}
54+
}
55+
}
56+
57+
private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance) =>
58+
instance.GetType()
59+
.GetCustomAttributes(typeof(RouteAttribute), true)
60+
.Cast<RouteAttribute>()
61+
.ToArray();
62+
63+
private static ParameterViewDictionary GetParametersFromTemplateAndUri(string template, string relativeUri, IComponent instance)
64+
{
65+
var templateSegments = template.Trim('/').Split("/");
66+
var uriSegments = relativeUri.Trim('/').Split("/");
67+
68+
if (templateSegments.Length > uriSegments.Length)
69+
{
70+
return [];
71+
}
72+
73+
var parameters = new ParameterViewDictionary();
74+
75+
for (var i = 0; i < templateSegments.Length; i++)
76+
{
77+
var templateSegment = templateSegments[i];
78+
if (templateSegment.StartsWith('{') && templateSegment.EndsWith('}'))
79+
{
80+
var parameterName = GetParameterName(templateSegment);
81+
var property = GetParameterProperty(instance, parameterName);
82+
83+
if (property is null)
84+
{
85+
continue;
86+
}
87+
88+
var isCatchAllParameter = templateSegment[1] == '*';
89+
parameters[property.Name] = isCatchAllParameter
90+
? string.Join("/", uriSegments.Skip(i))
91+
: GetValue(uriSegments[i], property);
92+
}
93+
else if (templateSegment != uriSegments[i])
94+
{
95+
return [];
96+
}
97+
}
98+
99+
return parameters;
100+
}
101+
102+
private static string GetParameterName(string templateSegment) =>
103+
templateSegment
104+
.Trim('{', '}', '*')
105+
.Replace("?", string.Empty, StringComparison.OrdinalIgnoreCase)
106+
.Split(':')[0];
107+
108+
private static PropertyInfo? GetParameterProperty(object instance, string propertyName)
109+
{
110+
var propertyInfos = instance.GetType()
111+
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
112+
113+
return Array.Find(propertyInfos, prop => prop.GetCustomAttributes(typeof(ParameterAttribute), true).Any() &&
114+
string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase));
115+
}
116+
117+
private static object GetValue(string value, PropertyInfo property)
118+
{
119+
var underlyingType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
120+
return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture);
121+
}
122+
}

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

+8-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace Bunit.TestDoubles;
1212
public sealed class FakeNavigationManager : NavigationManager
1313
{
1414
private readonly TestContextBase testContextBase;
15+
private readonly ComponentRouteParameterService componentRouteParameterService;
1516
private readonly Stack<NavigationHistory> history = new();
1617

1718
/// <summary>
@@ -27,9 +28,10 @@ public sealed class FakeNavigationManager : NavigationManager
2728
/// Initializes a new instance of the <see cref="FakeNavigationManager"/> class.
2829
/// </summary>
2930
[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "By design. Fake navigation manager defaults to local host as base URI.")]
30-
public FakeNavigationManager(TestContextBase testContextBase)
31+
public FakeNavigationManager(TestContextBase testContextBase, ComponentRouteParameterService componentRouteParameterService)
3132
{
3233
this.testContextBase = testContextBase;
34+
this.componentRouteParameterService = componentRouteParameterService;
3335
Initialize("http://localhost/", "http://localhost/");
3436
}
3537

@@ -64,6 +66,8 @@ protected override void NavigateToCore(string uri, bool forceLoad)
6466
{
6567
BaseUri = GetBaseUri(absoluteUri);
6668
}
69+
70+
componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri);
6771
});
6872
}
6973
#endif
@@ -72,6 +76,7 @@ protected override void NavigateToCore(string uri, bool forceLoad)
7276
/// <inheritdoc/>
7377
protected override void NavigateToCore(string uri, NavigationOptions options)
7478
{
79+
_ = uri ?? throw new ArgumentNullException(nameof(uri));
7580
var absoluteUri = GetNewAbsoluteUri(uri);
7681
var changedBaseUri = HasDifferentBaseUri(absoluteUri);
7782

@@ -129,6 +134,8 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
129134
{
130135
BaseUri = GetBaseUri(absoluteUri);
131136
}
137+
138+
componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri);
132139
});
133140
}
134141
#endif

tests/bunit.core.tests/Rendering/TestRendererTest.cs

+1
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ public void Test211()
519519
private ITestRenderer CreateRenderer() => new WebTestRenderer(
520520
Services.GetRequiredService<IRenderedComponentActivator>(),
521521
Services,
522+
Services.GetRequiredService<ComponentRegistry>(),
522523
NullLoggerFactory.Instance);
523524

524525
internal sealed class LifeCycleMethodInvokeCounter : ComponentBase

0 commit comments

Comments
 (0)