Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Navigation will set parameters #1584

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad

### Added
- Extension packages (`bunit.generators` and `bunit.web.query`) are flagged as stable.

- 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).
-
## [1.34.0] - 2024-11-01

### Fixed
Expand Down
35 changes: 35 additions & 0 deletions docs/site/docs/providing-input/passing-parameters-to-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,41 @@ A simple example of how to test a component that receives parameters from the qu
}
```

## Setting parameters via routing
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).

An example component that receives parameters via routing:

```razor
@page "/counter/{initialCount:int}"

<p>Count: @InitialCount</p>

@code {
[Parameter]
public int InitialCount { get; set; }
}
```

To test a component that receives parameters via routing, set the parameters using the `NavigationManager`:

```razor
@inherits TestContext

@code {
[Fact]
public void Component_receives_parameters_from_route()
{
var cut = RenderComponent<ExampleComponent>();
var navigationManager = Services.GetRequiredService<NavigationManager>();

navigationManager.NavigateTo("/counter/123");

cut.Find("p").TextContent.ShouldBe("Count: 123");
}
}
```

## Further Reading

- <xref:inject-services>
5 changes: 4 additions & 1 deletion src/bunit.core/Extensions/TestContextBaseRenderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ public static IRenderedComponentBase<TComponent> RenderInsideRenderTree<TCompone
throw new ArgumentNullException(nameof(testContext));

var baseResult = RenderInsideRenderTree(testContext, renderFragment);
return testContext.Renderer.FindComponent<TComponent>(baseResult);
var component = testContext.Renderer.FindComponent<TComponent>(baseResult);
var registry = testContext.Services.GetRequiredService<ComponentRegistry>();
registry.Register(component.Instance);
return component;
}

/// <summary>
Expand Down
26 changes: 26 additions & 0 deletions src/bunit.core/Rendering/ComponentRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Bunit.Rendering;

/// <summary>
/// This internal class is used to keep track of all components that have been rendered.
/// This class is not intended to be used directly by users of bUnit.
/// </summary>
public sealed class ComponentRegistry
{
private readonly HashSet<IComponent> components = [];

/// <summary>
/// Retrieves all components that have been rendered.
/// </summary>
public ISet<IComponent> Components => components;

/// <summary>
/// Registers a component as rendered.
/// </summary>
public void Register(IComponent component)
=> components.Add(component);

/// <summary>
/// Removes all components from the registry.
/// </summary>
public void Clear() => components.Clear();
}
11 changes: 8 additions & 3 deletions src/bunit.core/Rendering/TestRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class TestRenderer : Renderer, ITestRenderer
private readonly List<RootComponent> rootComponents = new();
private readonly ILogger<TestRenderer> logger;
private readonly IRenderedComponentActivator activator;
private readonly ComponentRegistry registry;
private bool disposed;
private TaskCompletionSource<Exception> unhandledExceptionTsc = new(TaskCreationOptions.RunContinuationsAsynchronously);
private Exception? capturedUnhandledException;
Expand Down Expand Up @@ -68,31 +69,34 @@ private bool IsBatchInProgress
/// <summary>
/// Initializes a new instance of the <see cref="TestRenderer"/> class.
/// </summary>
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory)
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry registry, ILoggerFactory loggerFactory)
: base(services, loggerFactory)
{
logger = loggerFactory.CreateLogger<TestRenderer>();
this.activator = renderedComponentActivator;
this.registry = registry;
}
#elif NET5_0_OR_GREATER
/// <summary>
/// Initializes a new instance of the <see cref="TestRenderer"/> class.
/// </summary>
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory)
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry registry, ILoggerFactory loggerFactory)
: base(services, loggerFactory, new BunitComponentActivator(services, services.GetRequiredService<ComponentFactoryCollection>(), null))
{
logger = loggerFactory.CreateLogger<TestRenderer>();
this.activator = renderedComponentActivator;
this.registry = registry;
}

/// <summary>
/// Initializes a new instance of the <see cref="TestRenderer"/> class.
/// </summary>
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory, IComponentActivator componentActivator)
public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory, ComponentRegistry registry, IComponentActivator componentActivator)
: base(services, loggerFactory, new BunitComponentActivator(services, services.GetRequiredService<ComponentFactoryCollection>(), componentActivator))
{
logger = loggerFactory.CreateLogger<TestRenderer>();
this.activator = renderedComponentActivator;
this.registry = registry;
}
#endif

Expand Down Expand Up @@ -211,6 +215,7 @@ public void DisposeComponents()
});

rootComponents.Clear();
registry.Clear();
AssertNoUnhandledExceptions();
}
}
Expand Down
1 change: 1 addition & 0 deletions src/bunit.web/Asserting/MarkupMatchesAssertExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ public static void MarkupMatches(this IRenderedFragment actual, RenderFragment e
using var renderer = new TestRenderer(
actual.Services.GetRequiredService<IRenderedComponentActivator>(),
actual.Services.GetRequiredService<TestServiceProvider>(),
actual.Services.GetRequiredService<ComponentRegistry>(),
actual.Services.GetRequiredService<ILoggerFactory>());
var renderedFragment = (IRenderedFragment)renderer.RenderFragment(expected);
MarkupMatches(actual, renderedFragment, userMessage);
Expand Down
3 changes: 3 additions & 0 deletions src/bunit.web/Extensions/TestServiceProviderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl
services.AddSingleton<FakeWebAssemblyHostEnvironment>();
services.AddSingleton<IWebAssemblyHostEnvironment>(s => s.GetRequiredService<FakeWebAssemblyHostEnvironment>());

services.AddSingleton<ComponentRouteParameterService>();
services.AddSingleton<ComponentRegistry>();

#if NET8_0_OR_GREATER
// bUnits fake ScrollToLocationHash
services.AddSingleton<IScrollToLocationHash, BunitScrollToLocationHash>();
Expand Down
10 changes: 5 additions & 5 deletions src/bunit.web/Rendering/WebTestRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public class WebTestRenderer : TestRenderer
/// <summary>
/// Initializes a new instance of the <see cref="WebTestRenderer"/> class.
/// </summary>
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory)
: base(renderedComponentActivator, services, loggerFactory)
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry componentRegistry, ILoggerFactory loggerFactory)
: base(renderedComponentActivator, services, componentRegistry, loggerFactory)
{
#if NET5_0_OR_GREATER
ElementReferenceContext = new WebElementReferenceContext(services.GetRequiredService<IJSRuntime>());
Expand All @@ -30,10 +30,10 @@ public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, T
/// <summary>
/// Initializes a new instance of the <see cref="WebTestRenderer"/> class.
/// </summary>
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory, IComponentActivator componentActivator)
: base(renderedComponentActivator, services, loggerFactory, componentActivator)
public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry componentRegistry, ILoggerFactory loggerFactory, IComponentActivator componentActivator)
: base(renderedComponentActivator, services, loggerFactory, componentRegistry, componentActivator)
{
ElementReferenceContext = new WebElementReferenceContext(services.GetRequiredService<IJSRuntime>());
}
#endif
}
}
11 changes: 6 additions & 5 deletions src/bunit.web/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ public virtual IRenderedComponent<TComponent> RenderComponent<TComponent>(Action
/// <param name="renderFragment">The render fragment to render.</param>
/// <returns>The <see cref="IRenderedComponent{TComponent}"/>.</returns>
public virtual IRenderedComponent<TComponent> Render<TComponent>(RenderFragment renderFragment)
where TComponent : IComponent
=> (IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);
where TComponent : IComponent =>
(IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);

/// <summary>
/// Renders the <paramref name="renderFragment"/> and returns it as a <see cref="IRenderedFragment"/>.
Expand All @@ -86,13 +86,14 @@ protected override ITestRenderer CreateTestRenderer()
{
var renderedComponentActivator = Services.GetRequiredService<IRenderedComponentActivator>();
var logger = Services.GetRequiredService<ILoggerFactory>();
var componentRegistry = Services.GetRequiredService<ComponentRegistry>();
#if !NET5_0_OR_GREATER
return new WebTestRenderer(renderedComponentActivator, Services, logger);
return new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger);
#else
var componentActivator = Services.GetService<IComponentActivator>();
return componentActivator is null
? new WebTestRenderer(renderedComponentActivator, Services, logger)
: new WebTestRenderer(renderedComponentActivator, Services, logger, componentActivator);
? new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger)
: new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger, componentActivator);
#endif

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System.Globalization;
using System.Reflection;
using Bunit.Rendering;
#if NET6_0_OR_GREATER
using ParameterViewDictionary = System.Collections.Generic.Dictionary<string, object?>;
#else
using ParameterViewDictionary = System.Collections.Generic.Dictionary<string, object>;
#endif

namespace Bunit.TestDoubles;

/// <summary>
/// This is an internal service that is used to update components with route parameters.
/// It is not meant to be used outside bUnit internal classes.
/// </summary>
public sealed class ComponentRouteParameterService
{
private readonly ComponentRegistry componentRegistry;

/// <summary>
/// Initializes a new instance of the <see cref="ComponentRouteParameterService"/> class.
/// </summary>
public ComponentRouteParameterService(ComponentRegistry componentRegistry)
{
this.componentRegistry = componentRegistry;
}

/// <summary>
/// Triggers the components to update their parameters based on the route parameters.
/// </summary>
public void UpdateComponentsWithRouteParameters(Uri uri)
{
_ = uri ?? throw new ArgumentNullException(nameof(uri));

var relativeUri = uri.PathAndQuery;

foreach (var instance in componentRegistry.Components)
{
var routeAttributes = GetRouteAttributesFromComponent(instance);

if (routeAttributes.Length == 0)
{
continue;
}

foreach (var template in routeAttributes.Select(r => r.Template))
{
var parameters = GetParametersFromTemplateAndUri(template, relativeUri, instance);
if (parameters.Count > 0)
{
instance.SetParametersAsync(ParameterView.FromDictionary(parameters));
}
}
}
}

private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance) =>
instance.GetType()
.GetCustomAttributes(typeof(RouteAttribute), true)
.Cast<RouteAttribute>()
.ToArray();

private static ParameterViewDictionary GetParametersFromTemplateAndUri(string template, string relativeUri, IComponent instance)
{
var templateSegments = template.Trim('/').Split("/");
var uriSegments = relativeUri.Trim('/').Split("/");

if (templateSegments.Length > uriSegments.Length)
{
return [];
}

var parameters = new ParameterViewDictionary();

for (var i = 0; i < templateSegments.Length; i++)
{
var templateSegment = templateSegments[i];
if (templateSegment.StartsWith('{') && templateSegment.EndsWith('}'))
{
var parameterName = GetParameterName(templateSegment);
var property = GetParameterProperty(instance, parameterName);

if (property is null)
{
continue;
}

var isCatchAllParameter = templateSegment[1] == '*';
parameters[property.Name] = isCatchAllParameter
? string.Join("/", uriSegments.Skip(i))
: GetValue(uriSegments[i], property);
}
else if (templateSegment != uriSegments[i])
{
return [];
}
}

return parameters;
}

private static string GetParameterName(string templateSegment) =>
templateSegment
.Trim('{', '}', '*')
.Replace("?", string.Empty, StringComparison.OrdinalIgnoreCase)
.Split(':')[0];

private static PropertyInfo? GetParameterProperty(object instance, string propertyName)
{
var propertyInfos = instance.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance);

return Array.Find(propertyInfos, prop => prop.GetCustomAttributes(typeof(ParameterAttribute), true).Any() &&
string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase));
}

private static object GetValue(string value, PropertyInfo property)
{
var underlyingType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Bunit.TestDoubles;
public sealed class FakeNavigationManager : NavigationManager
{
private readonly TestContextBase testContextBase;
private readonly ComponentRouteParameterService componentRouteParameterService;
private readonly Stack<NavigationHistory> history = new();

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

Expand Down Expand Up @@ -64,6 +66,8 @@ protected override void NavigateToCore(string uri, bool forceLoad)
{
BaseUri = GetBaseUri(absoluteUri);
}

componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri);
});
}
#endif
Expand All @@ -72,6 +76,7 @@ protected override void NavigateToCore(string uri, bool forceLoad)
/// <inheritdoc/>
protected override void NavigateToCore(string uri, NavigationOptions options)
{
_ = uri ?? throw new ArgumentNullException(nameof(uri));
var absoluteUri = GetNewAbsoluteUri(uri);
var changedBaseUri = HasDifferentBaseUri(absoluteUri);

Expand Down Expand Up @@ -129,6 +134,8 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
{
BaseUri = GetBaseUri(absoluteUri);
}

componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri);
});
}
#endif
Expand Down
1 change: 1 addition & 0 deletions tests/bunit.core.tests/Rendering/TestRendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ public void Test211()
private ITestRenderer CreateRenderer() => new WebTestRenderer(
Services.GetRequiredService<IRenderedComponentActivator>(),
Services,
Services.GetRequiredService<ComponentRegistry>(),
NullLoggerFactory.Instance);

internal sealed class LifeCycleMethodInvokeCounter : ComponentBase
Expand Down
Loading