diff --git a/CHANGELOG.md b/CHANGELOG.md index 272a950da..462205df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/site/docs/providing-input/passing-parameters-to-components.md b/docs/site/docs/providing-input/passing-parameters-to-components.md index a69b91875..e9fedcfa8 100644 --- a/docs/site/docs/providing-input/passing-parameters-to-components.md +++ b/docs/site/docs/providing-input/passing-parameters-to-components.md @@ -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}" + +

Count: @InitialCount

+ +@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(); + var navigationManager = Services.GetRequiredService(); + + navigationManager.NavigateTo("/counter/123"); + + cut.Find("p").TextContent.ShouldBe("Count: 123"); + } +} +``` + ## Further Reading - diff --git a/src/bunit.core/Extensions/TestContextBaseRenderExtensions.cs b/src/bunit.core/Extensions/TestContextBaseRenderExtensions.cs index be5f03782..2f7db9a4a 100644 --- a/src/bunit.core/Extensions/TestContextBaseRenderExtensions.cs +++ b/src/bunit.core/Extensions/TestContextBaseRenderExtensions.cs @@ -21,7 +21,10 @@ public static IRenderedComponentBase RenderInsideRenderTree(baseResult); + var component = testContext.Renderer.FindComponent(baseResult); + var registry = testContext.Services.GetRequiredService(); + registry.Register(component.Instance); + return component; } /// diff --git a/src/bunit.core/Rendering/ComponentRegistry.cs b/src/bunit.core/Rendering/ComponentRegistry.cs new file mode 100644 index 000000000..ebc333012 --- /dev/null +++ b/src/bunit.core/Rendering/ComponentRegistry.cs @@ -0,0 +1,26 @@ +namespace Bunit.Rendering; + +/// +/// 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. +/// +public sealed class ComponentRegistry +{ + private readonly HashSet components = []; + + /// + /// Retrieves all components that have been rendered. + /// + public ISet Components => components; + + /// + /// Registers a component as rendered. + /// + public void Register(IComponent component) + => components.Add(component); + + /// + /// Removes all components from the registry. + /// + public void Clear() => components.Clear(); +} diff --git a/src/bunit.core/Rendering/TestRenderer.cs b/src/bunit.core/Rendering/TestRenderer.cs index 61d4deecf..c3a12f2a8 100644 --- a/src/bunit.core/Rendering/TestRenderer.cs +++ b/src/bunit.core/Rendering/TestRenderer.cs @@ -27,6 +27,7 @@ public class TestRenderer : Renderer, ITestRenderer private readonly List rootComponents = new(); private readonly ILogger logger; private readonly IRenderedComponentActivator activator; + private readonly ComponentRegistry registry; private bool disposed; private TaskCompletionSource unhandledExceptionTsc = new(TaskCreationOptions.RunContinuationsAsynchronously); private Exception? capturedUnhandledException; @@ -68,31 +69,34 @@ private bool IsBatchInProgress /// /// Initializes a new instance of the class. /// - public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ILoggerFactory loggerFactory) + public TestRenderer(IRenderedComponentActivator renderedComponentActivator, TestServiceProvider services, ComponentRegistry registry, ILoggerFactory loggerFactory) : base(services, loggerFactory) { logger = loggerFactory.CreateLogger(); this.activator = renderedComponentActivator; + this.registry = registry; } #elif NET5_0_OR_GREATER /// /// Initializes a new instance of the class. /// - 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(), null)) { logger = loggerFactory.CreateLogger(); this.activator = renderedComponentActivator; + this.registry = registry; } /// /// Initializes a new instance of the class. /// - 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(), componentActivator)) { logger = loggerFactory.CreateLogger(); this.activator = renderedComponentActivator; + this.registry = registry; } #endif @@ -211,6 +215,7 @@ public void DisposeComponents() }); rootComponents.Clear(); + registry.Clear(); AssertNoUnhandledExceptions(); } } diff --git a/src/bunit.web/Asserting/MarkupMatchesAssertExtensions.cs b/src/bunit.web/Asserting/MarkupMatchesAssertExtensions.cs index a52b1e002..be1e34f0c 100644 --- a/src/bunit.web/Asserting/MarkupMatchesAssertExtensions.cs +++ b/src/bunit.web/Asserting/MarkupMatchesAssertExtensions.cs @@ -305,6 +305,7 @@ public static void MarkupMatches(this IRenderedFragment actual, RenderFragment e using var renderer = new TestRenderer( actual.Services.GetRequiredService(), actual.Services.GetRequiredService(), + actual.Services.GetRequiredService(), actual.Services.GetRequiredService()); var renderedFragment = (IRenderedFragment)renderer.RenderFragment(expected); MarkupMatches(actual, renderedFragment, userMessage); diff --git a/src/bunit.web/Extensions/TestServiceProviderExtensions.cs b/src/bunit.web/Extensions/TestServiceProviderExtensions.cs index e23f5b002..d62c1e3ef 100644 --- a/src/bunit.web/Extensions/TestServiceProviderExtensions.cs +++ b/src/bunit.web/Extensions/TestServiceProviderExtensions.cs @@ -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; @@ -45,6 +46,11 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); + // bUnits fake Router + services.AddSingleton(); + + services.AddSingleton(); + #if NET8_0_OR_GREATER // bUnits fake ScrollToLocationHash services.AddSingleton(); diff --git a/src/bunit.web/Rendering/WebTestRenderer.cs b/src/bunit.web/Rendering/WebTestRenderer.cs index 0e710b50a..65170d25f 100644 --- a/src/bunit.web/Rendering/WebTestRenderer.cs +++ b/src/bunit.web/Rendering/WebTestRenderer.cs @@ -18,8 +18,8 @@ public class WebTestRenderer : TestRenderer /// /// Initializes a new instance of the class. /// - 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()); @@ -30,10 +30,10 @@ public WebTestRenderer(IRenderedComponentActivator renderedComponentActivator, T /// /// Initializes a new instance of the class. /// - 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()); } #endif -} \ No newline at end of file +} diff --git a/src/bunit.web/TestContext.cs b/src/bunit.web/TestContext.cs index 315aa4ddf..e299e5b89 100644 --- a/src/bunit.web/TestContext.cs +++ b/src/bunit.web/TestContext.cs @@ -1,5 +1,6 @@ using Bunit.Extensions; using Bunit.Rendering; +using Bunit.TestDoubles.Router; using Microsoft.Extensions.Logging; namespace Bunit; @@ -9,6 +10,8 @@ namespace Bunit; /// public class TestContext : TestContextBase { + private FakeRouter? router; + /// /// Gets bUnits JSInterop, that allows setting up handlers for invocations /// 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 RenderComponent(Action /// The . public virtual IRenderedComponent Render(RenderFragment renderFragment) where TComponent : IComponent - => (IRenderedComponent)this.RenderInsideRenderTree(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(); + return (IRenderedComponent)this.RenderInsideRenderTree(renderFragment); + } /// /// Renders the and returns it as a . @@ -75,6 +84,17 @@ public virtual IRenderedComponent Render(RenderFragment public virtual IRenderedFragment Render(RenderFragment renderFragment) => (IRenderedFragment)this.RenderInsideRenderTree(renderFragment); + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + router?.Dispose(); + } + + base.Dispose(disposing); + } + /// /// Dummy method required to allow Blazor's compiler to generate /// C# from .razor files. @@ -86,13 +106,14 @@ protected override ITestRenderer CreateTestRenderer() { var renderedComponentActivator = Services.GetRequiredService(); var logger = Services.GetRequiredService(); + var componentRegistry = Services.GetRequiredService(); #if !NET5_0_OR_GREATER - return new WebTestRenderer(renderedComponentActivator, Services, logger); + return new WebTestRenderer(renderedComponentActivator, Services, componentRegistry, logger); #else var componentActivator = Services.GetService(); 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 } diff --git a/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs b/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs index 574807cf3..06a727783 100644 --- a/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs +++ b/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs @@ -1,4 +1,5 @@ using Bunit.Rendering; +using Bunit.TestDoubles.Router; using Microsoft.AspNetCore.Components.Routing; namespace Bunit.TestDoubles; @@ -72,6 +73,7 @@ protected override void NavigateToCore(string uri, bool forceLoad) /// protected override void NavigateToCore(string uri, NavigationOptions options) { + _ = uri ?? throw new ArgumentNullException(nameof(uri)); var absoluteUri = GetNewAbsoluteUri(uri); var changedBaseUri = HasDifferentBaseUri(absoluteUri); diff --git a/src/bunit.web/TestDoubles/Router/FakeRouter.cs b/src/bunit.web/TestDoubles/Router/FakeRouter.cs new file mode 100644 index 000000000..df74544a5 --- /dev/null +++ b/src/bunit.web/TestDoubles/Router/FakeRouter.cs @@ -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(); +#else + var parameters = new Dictionary(); +#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() + .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)); + } +} diff --git a/tests/bunit.core.tests/Rendering/TestRendererTest.cs b/tests/bunit.core.tests/Rendering/TestRendererTest.cs index 06ba766c5..a62cfe843 100644 --- a/tests/bunit.core.tests/Rendering/TestRendererTest.cs +++ b/tests/bunit.core.tests/Rendering/TestRendererTest.cs @@ -519,6 +519,7 @@ public void Test211() private ITestRenderer CreateRenderer() => new WebTestRenderer( Services.GetRequiredService(), Services, + Services.GetRequiredService(), NullLoggerFactory.Instance); internal sealed class LifeCycleMethodInvokeCounter : ComponentBase diff --git a/tests/bunit.core.tests/Rendering/TestRendererTest.net5.cs b/tests/bunit.core.tests/Rendering/TestRendererTest.net5.cs index d60e3d78e..e53b443a8 100644 --- a/tests/bunit.core.tests/Rendering/TestRendererTest.net5.cs +++ b/tests/bunit.core.tests/Rendering/TestRendererTest.net5.cs @@ -24,6 +24,7 @@ public void Test1000() Services.GetService(), Services, NullLoggerFactory.Instance, + Services.GetRequiredService(), activatorMock); renderer.RenderComponent(new ComponentParameterCollection()); diff --git a/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs b/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs new file mode 100644 index 000000000..2294984f7 --- /dev/null +++ b/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs @@ -0,0 +1,136 @@ +namespace Bunit.TestDoubles; + +public class RouterTests : TestContext +{ + [Fact] + public void NavigatingToRouteWithPageAttributeShouldSetParameters() + { + var cut = RenderComponent(); + var navigationManager = cut.Services.GetRequiredService(); + + navigationManager.NavigateTo("/page/1/test"); + + cut.Find("p").TextContent.ShouldBe("1 / test"); + } + + [Fact] + public void ShouldParseMultiplePageAttributes() + { + var cut = RenderComponent(); + var navigationManager = cut.Services.GetRequiredService(); + + navigationManager.NavigateTo("/page/1"); + + cut.Find("p").TextContent.ShouldBe("1"); + } + + [Fact] + public void WhenParameterIsSetNavigatingShouldNotResetNonPageAttributeParameters() + { + var cut = RenderComponent(p => p.Add(ps => ps.OtherNumber, 2)); + var navigationManager = cut.Services.GetRequiredService(); + + navigationManager.NavigateTo("/page/1"); + + cut.Find("p").TextContent.ShouldBe("1/2"); + } + + [Fact] + public void GivenACatchAllRouteShouldSetParameter() + { + var cut = RenderComponent(); + var navigationManager = cut.Services.GetRequiredService(); + + navigationManager.NavigateTo("/page/1/2/3"); + + cut.Find("p").TextContent.ShouldBe("1/2/3"); + } + + [Fact] + public void ShouldInvokeParameterLifeCycleEvents() + { + var cut = RenderComponent( + p => p.Add(ps => ps.IncrementOnParametersSet, 10)); + var navigationManager = cut.Services.GetRequiredService(); + + navigationManager.NavigateTo("/page/1"); + + cut.Find("p").TextContent.ShouldBe("11"); + } + + [Route("/page/{count:int}/{name}")] + private sealed class ComponentWithPageAttribute : ComponentBase + { + [Parameter] public int Count { get; set; } + [Parameter] public string Name { get; set; } + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "p"); + builder.AddContent(1, Count); + builder.AddContent(2, " / "); + builder.AddContent(3, Name); + builder.CloseElement(); + } + } + + [Route("/page")] + [Route("/page/{count:int}")] + private sealed class ComponentWithMultiplePageAttributes : ComponentBase + { + [Parameter] public int Count { get; set; } + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "p"); + builder.AddContent(1, Count); + builder.CloseElement(); + } + } + + [Route("/page/{count:int}")] + private sealed class ComponentWithOtherParameters : ComponentBase + { + [Parameter] public int Count { get; set; } + [Parameter] public int OtherNumber { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "p"); + builder.AddContent(1, Count); + builder.AddContent(2, "/"); + builder.AddContent(3, OtherNumber); + builder.CloseElement(); + } + } + + [Route("/page/{*pageRoute}")] + private sealed class ComponentWithCatchAllRoute : ComponentBase + { + [Parameter] public string PageRoute { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "p"); + builder.AddContent(1, PageRoute); + builder.CloseElement(); + } + } + + [Route("/page/{count:int}")] + private sealed class ComponentWithCustomOnParametersSetAsyncsCall : ComponentBase + { + [Parameter] public int Count { get; set; } + [Parameter] public int IncrementOnParametersSet { get; set; } + + protected override void OnParametersSet() + { + Count += IncrementOnParametersSet; + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "p"); + builder.AddContent(1, Count); + builder.CloseElement(); + } + } +}