Skip to content

Commit

Permalink
feat: Navigation will set parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
linkdotnet committed Oct 12, 2024
1 parent 0ed621c commit c0bfe13
Show file tree
Hide file tree
Showing 14 changed files with 367 additions and 13 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ All notable changes to **bUnit** will be documented in this file. The project ad

## [Unreleased]

### Added
- 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.33.3] - 2024-10-11

### Added
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
6 changes: 6 additions & 0 deletions src/bunit.web/Extensions/TestServiceProviderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Bunit.Diffing;
using Bunit.Rendering;
using Bunit.TestDoubles;
using Bunit.TestDoubles.Router;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Routing;
Expand Down Expand Up @@ -45,6 +46,11 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl
services.AddSingleton<FakeWebAssemblyHostEnvironment>();
services.AddSingleton<IWebAssemblyHostEnvironment>(s => s.GetRequiredService<FakeWebAssemblyHostEnvironment>());

// bUnits fake Router
services.AddSingleton<FakeRouter>();

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
}
}
29 changes: 25 additions & 4 deletions src/bunit.web/TestContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Bunit.Extensions;
using Bunit.Rendering;
using Bunit.TestDoubles.Router;
using Microsoft.Extensions.Logging;

namespace Bunit;
Expand All @@ -9,6 +10,8 @@ namespace Bunit;
/// </summary>
public class TestContext : TestContextBase
{
private FakeRouter? router;

/// <summary>
/// Gets bUnits JSInterop, that allows setting up handlers for <see cref="IJSRuntime.InvokeAsync{TValue}(string, object[])"/> invocations
/// that components under tests will issue during testing. It also makes it possible to verify that the invocations has happened as expected.
Expand Down Expand Up @@ -65,7 +68,13 @@ public virtual IRenderedComponent<TComponent> RenderComponent<TComponent>(Action
/// <returns>The <see cref="IRenderedComponent{TComponent}"/>.</returns>
public virtual IRenderedComponent<TComponent> Render<TComponent>(RenderFragment renderFragment)
where TComponent : IComponent
=> (IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);
{
// There has to be a better way of having this global thing initialized
// We can't do it in the ctor because we would "materialize" the container, and it would
// throw if the user tries to add a service after the ctor has run.
router ??= Services.GetService<FakeRouter>();
return (IRenderedComponent<TComponent>)this.RenderInsideRenderTree<TComponent>(renderFragment);
}

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

/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposing)
{
router?.Dispose();
}

base.Dispose(disposing);
}

/// <summary>
/// Dummy method required to allow Blazor's compiler to generate
/// C# from .razor files.
Expand All @@ -86,13 +106,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
@@ -1,4 +1,5 @@
using Bunit.Rendering;
using Bunit.TestDoubles.Router;
using Microsoft.AspNetCore.Components.Routing;

namespace Bunit.TestDoubles;
Expand Down Expand Up @@ -72,6 +73,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
114 changes: 114 additions & 0 deletions src/bunit.web/TestDoubles/Router/FakeRouter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Globalization;
using System.Reflection;
using Bunit.Rendering;
using Microsoft.AspNetCore.Components.Routing;

namespace Bunit.TestDoubles.Router;

internal sealed class FakeRouter : IDisposable
{
private readonly NavigationManager navigationManager;
private readonly ComponentRegistry componentRegistry;

public FakeRouter(NavigationManager navigationManager, ComponentRegistry componentRegistry)
{
this.navigationManager = navigationManager;
this.componentRegistry = componentRegistry;
navigationManager.LocationChanged += UpdatePageParameters;
}

public void Dispose() => navigationManager.LocationChanged -= UpdatePageParameters;

private void UpdatePageParameters(object? sender, LocationChangedEventArgs e)
{
var uri = new Uri(e.Location);
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 templateSegments = template.Trim('/').Split("/");
var uriSegments = relativeUri.Trim('/').Split("/");

if (templateSegments.Length > uriSegments.Length)
{
continue;
}
#if NET6_0_OR_GREATER
var parameters = new Dictionary<string, object?>();
#else
var parameters = new Dictionary<string, object>();
#endif

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] == '*';
if (!isCatchAllParameter)
{
parameters[property.Name] = Convert.ChangeType(uriSegments[i], property.PropertyType,
CultureInfo.InvariantCulture);
}
else
{
parameters[parameterName] = string.Join("/", uriSegments.Skip(i));
}
}
else if (templateSegment != uriSegments[i])
{
break;
}
}

if (parameters.Count == 0)
{
continue;
}

// Shall we await this? This should be synchronous in most cases
// If not, very likely the user has overriden the SetParametersAsync method
// And should use WaitForXXX methods to await the desired state
instance.SetParametersAsync(ParameterView.FromDictionary(parameters));
}
}
}

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

private static string GetParameterName(string templateSegment) => templateSegment.Trim('{', '}', '*').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));
}
}
Loading

0 comments on commit c0bfe13

Please sign in to comment.