From cb98b69aa70ab5a4a58a8020296ce325d6b7ca41 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sat, 12 Oct 2024 13:14:26 +0200 Subject: [PATCH 1/4] feat: Navigation will set parameters --- CHANGELOG.md | 3 +- .../passing-parameters-to-components.md | 35 ++++ .../TestContextBaseRenderExtensions.cs | 5 +- src/bunit.core/Rendering/ComponentRegistry.cs | 26 +++ src/bunit.core/Rendering/TestRenderer.cs | 11 +- .../MarkupMatchesAssertExtensions.cs | 1 + .../TestServiceProviderExtensions.cs | 6 + src/bunit.web/Rendering/WebTestRenderer.cs | 10 +- src/bunit.web/TestContext.cs | 29 +++- .../FakeNavigationManager.cs | 2 + .../TestDoubles/Router/FakeRouter.cs | 123 +++++++++++++ .../Rendering/TestRendererTest.cs | 1 + .../Rendering/TestRendererTest.net5.cs | 1 + .../NavigationManager/RouterTests.cs | 162 ++++++++++++++++++ 14 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 src/bunit.core/Rendering/ComponentRegistry.cs create mode 100644 src/bunit.web/TestDoubles/Router/FakeRouter.cs create mode 100644 tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a5588e..51d50321c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..83159704d --- /dev/null +++ b/src/bunit.web/TestDoubles/Router/FakeRouter.cs @@ -0,0 +1,123 @@ +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] = GetValue(uriSegments[i], property); + } + 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('{', '}', '*') + .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); + } +} 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..bba2c3bbe --- /dev/null +++ b/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs @@ -0,0 +1,162 @@ +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"); + } + + [Theory] + [InlineData("/page/1", "1")] + [InlineData("/page", "")] + public void ShouldSetOptionalParameter(string uri, string expectedTextContent) + { + var cut = RenderComponent(); + var navigationManager = cut.Services.GetRequiredService(); + + navigationManager.NavigateTo(uri); + + cut.Find("p").TextContent.ShouldBe(expectedTextContent); + } + + [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(); + } + } + + [Route("/page/{count?:int}")] + private sealed class ComponentWithOptionalParameter : ComponentBase + { + [Parameter] public int? Count { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "p"); + builder.AddContent(1, Count); + builder.CloseElement(); + } + } +} From b5f87686fca5dfc5b0448b5fa665ae9ee11c58e7 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 13 Oct 2024 11:33:59 +0200 Subject: [PATCH 2/4] refactor: Remove FakeRouter --- .../TestServiceProviderExtensions.cs | 5 +--- src/bunit.web/TestContext.cs | 24 ++--------------- .../ComponentRouteParameterService.cs} | 27 +++++++++++-------- .../FakeNavigationManager.cs | 9 +++++-- 4 files changed, 26 insertions(+), 39 deletions(-) rename src/bunit.web/TestDoubles/{Router/FakeRouter.cs => NavigationManager/ComponentRouteParameterService.cs} (81%) diff --git a/src/bunit.web/Extensions/TestServiceProviderExtensions.cs b/src/bunit.web/Extensions/TestServiceProviderExtensions.cs index d62c1e3ef..3e4f0f735 100644 --- a/src/bunit.web/Extensions/TestServiceProviderExtensions.cs +++ b/src/bunit.web/Extensions/TestServiceProviderExtensions.cs @@ -1,7 +1,6 @@ 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; @@ -46,9 +45,7 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); - // bUnits fake Router - services.AddSingleton(); - + services.AddSingleton(); services.AddSingleton(); #if NET8_0_OR_GREATER diff --git a/src/bunit.web/TestContext.cs b/src/bunit.web/TestContext.cs index e299e5b89..d7b554210 100644 --- a/src/bunit.web/TestContext.cs +++ b/src/bunit.web/TestContext.cs @@ -1,6 +1,5 @@ using Bunit.Extensions; using Bunit.Rendering; -using Bunit.TestDoubles.Router; using Microsoft.Extensions.Logging; namespace Bunit; @@ -10,8 +9,6 @@ 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. @@ -67,14 +64,8 @@ public virtual IRenderedComponent RenderComponent(Action /// The render fragment to render. /// The . public virtual IRenderedComponent Render(RenderFragment renderFragment) - where TComponent : IComponent - { - // 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); - } + where TComponent : IComponent => + (IRenderedComponent)this.RenderInsideRenderTree(renderFragment); /// /// Renders the and returns it as a . @@ -84,17 +75,6 @@ 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. diff --git a/src/bunit.web/TestDoubles/Router/FakeRouter.cs b/src/bunit.web/TestDoubles/NavigationManager/ComponentRouteParameterService.cs similarity index 81% rename from src/bunit.web/TestDoubles/Router/FakeRouter.cs rename to src/bunit.web/TestDoubles/NavigationManager/ComponentRouteParameterService.cs index 83159704d..b96dba528 100644 --- a/src/bunit.web/TestDoubles/Router/FakeRouter.cs +++ b/src/bunit.web/TestDoubles/NavigationManager/ComponentRouteParameterService.cs @@ -1,27 +1,32 @@ using System.Globalization; using System.Reflection; using Bunit.Rendering; -using Microsoft.AspNetCore.Components.Routing; -namespace Bunit.TestDoubles.Router; +namespace Bunit.TestDoubles; -internal sealed class FakeRouter : IDisposable +/// +/// 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. +/// +public sealed class ComponentRouteParameterService { - private readonly NavigationManager navigationManager; private readonly ComponentRegistry componentRegistry; - public FakeRouter(NavigationManager navigationManager, ComponentRegistry componentRegistry) + /// + /// Initializes a new instance of the class. + /// + public ComponentRouteParameterService(ComponentRegistry componentRegistry) { - this.navigationManager = navigationManager; this.componentRegistry = componentRegistry; - navigationManager.LocationChanged += UpdatePageParameters; } - public void Dispose() => navigationManager.LocationChanged -= UpdatePageParameters; - - private void UpdatePageParameters(object? sender, LocationChangedEventArgs e) + /// + /// Triggers the components to update their parameters based on the route parameters. + /// + public void UpdateComponentsWithRouteParameters(Uri uri) { - var uri = new Uri(e.Location); + _ = uri ?? throw new ArgumentNullException(nameof(uri)); + var relativeUri = uri.PathAndQuery; foreach (var instance in componentRegistry.Components) diff --git a/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs b/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs index 06a727783..6566d255a 100644 --- a/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs +++ b/src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs @@ -1,5 +1,4 @@ using Bunit.Rendering; -using Bunit.TestDoubles.Router; using Microsoft.AspNetCore.Components.Routing; namespace Bunit.TestDoubles; @@ -13,6 +12,7 @@ namespace Bunit.TestDoubles; public sealed class FakeNavigationManager : NavigationManager { private readonly TestContextBase testContextBase; + private readonly ComponentRouteParameterService componentRouteParameterService; private readonly Stack history = new(); /// @@ -28,9 +28,10 @@ public sealed class FakeNavigationManager : NavigationManager /// Initializes a new instance of the class. /// [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/"); } @@ -65,6 +66,8 @@ protected override void NavigateToCore(string uri, bool forceLoad) { BaseUri = GetBaseUri(absoluteUri); } + + componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri); }); } #endif @@ -131,6 +134,8 @@ protected override void NavigateToCore(string uri, NavigationOptions options) { BaseUri = GetBaseUri(absoluteUri); } + + componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri); }); } #endif From b2137f14e226341f7c1fb23f69380feb6b845f2d Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 13 Oct 2024 11:43:14 +0200 Subject: [PATCH 3/4] Added more tests --- .../RouterTests.Components.cs | 144 ++++++++++++++++++ .../NavigationManager/RouterTests.cs | 101 +++--------- 2 files changed, 161 insertions(+), 84 deletions(-) create mode 100644 tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.Components.cs diff --git a/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.Components.cs b/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.Components.cs new file mode 100644 index 000000000..0a7c182d4 --- /dev/null +++ b/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.Components.cs @@ -0,0 +1,144 @@ +namespace Bunit.TestDoubles; + +public partial class RouterTests : TestContext +{ + [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(); + } + } + + [Route("/page/{count?:int}")] + private sealed class ComponentWithOptionalParameter : 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 ComponentThatNavigatesToSelfOnButtonClick : ComponentBase + { + [Parameter] public int Count { get; set; } + + [Inject] private NavigationManager NavigationManager { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "button"); + builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}"))); + builder.AddContent(2, "Increment"); + builder.CloseElement(); + builder.OpenElement(3, "p"); + builder.AddContent(4, Count); + builder.CloseElement(); + } + } + +#if NET7_0_OR_GREATER + [Route("/page/{count:int}")] + private sealed class ComponentThatNavigatesToSelfOnButtonClickIntercepted : ComponentBase + { + [Parameter] public int Count { get; set; } + + [Inject] private NavigationManager NavigationManager { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "button"); + builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}"))); + builder.AddContent(2, "Increment"); + builder.CloseElement(); + builder.OpenElement(3, "p"); + builder.AddContent(4, Count); + builder.CloseElement(); + builder.OpenComponent(5); + builder.AddAttribute(6, "OnBeforeInternalNavigation", + EventCallback.Factory.Create(this, + InterceptNavigation + )); + builder.CloseComponent(); + } + + private static void InterceptNavigation(LocationChangingContext context) + { + context.PreventNavigation(); + } + } +#endif +} diff --git a/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs b/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs index bba2c3bbe..3adab3ae1 100644 --- a/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs +++ b/tests/bunit.web.tests/TestDoubles/NavigationManager/RouterTests.cs @@ -1,6 +1,6 @@ namespace Bunit.TestDoubles; -public class RouterTests : TestContext +public partial class RouterTests : TestContext { [Fact] public void NavigatingToRouteWithPageAttributeShouldSetParameters() @@ -71,92 +71,25 @@ public void ShouldSetOptionalParameter(string uri, string expectedTextContent) cut.Find("p").TextContent.ShouldBe(expectedTextContent); } - [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 + [Fact] + public void ComponentThatNavigatesToSelfOnClickShouldBeUpdated() { - [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(); - } + var cut = RenderComponent(); + + cut.Find("button").Click(); + + cut.Find("p").TextContent.ShouldBe("1"); } - [Route("/page/{count?:int}")] - private sealed class ComponentWithOptionalParameter : ComponentBase +#if NET7_0_OR_GREATER + [Fact] + public void ComponentThatInterceptsNavigationShouldNotBeUpdated() { - [Parameter] public int? Count { get; set; } - - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - builder.OpenElement(0, "p"); - builder.AddContent(1, Count); - builder.CloseElement(); - } + var cut = RenderComponent(); + + cut.Find("button").Click(); + + cut.Find("p").TextContent.ShouldBe("0"); } +#endif } From 640d4869605550c792851b3e541df4244be2fd73 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 3 Nov 2024 11:03:06 +0100 Subject: [PATCH 4/4] refactor: Extract methods --- .../ComponentRouteParameterService.cs | 98 +++++++++---------- 1 file changed, 46 insertions(+), 52 deletions(-) diff --git a/src/bunit.web/TestDoubles/NavigationManager/ComponentRouteParameterService.cs b/src/bunit.web/TestDoubles/NavigationManager/ComponentRouteParameterService.cs index b96dba528..ec51d5026 100644 --- a/src/bunit.web/TestDoubles/NavigationManager/ComponentRouteParameterService.cs +++ b/src/bunit.web/TestDoubles/NavigationManager/ComponentRouteParameterService.cs @@ -1,6 +1,11 @@ using System.Globalization; using System.Reflection; using Bunit.Rendering; +#if NET6_0_OR_GREATER +using ParameterViewDictionary = System.Collections.Generic.Dictionary; +#else +using ParameterViewDictionary = System.Collections.Generic.Dictionary; +#endif namespace Bunit.TestDoubles; @@ -40,69 +45,58 @@ public void UpdateComponentsWithRouteParameters(Uri uri) foreach (var template in routeAttributes.Select(r => r.Template)) { - var templateSegments = template.Trim('/').Split("/"); - var uriSegments = relativeUri.Trim('/').Split("/"); - - if (templateSegments.Length > uriSegments.Length) + var parameters = GetParametersFromTemplateAndUri(template, relativeUri, instance); + if (parameters.Count > 0) { - continue; + instance.SetParametersAsync(ParameterView.FromDictionary(parameters)); } -#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] = GetValue(uriSegments[i], property); - } - else - { - parameters[parameterName] = string.Join("/", uriSegments.Skip(i)); - } - } - else if (templateSegment != uriSegments[i]) - { - break; - } - } + private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance) => + instance.GetType() + .GetCustomAttributes(typeof(RouteAttribute), true) + .Cast() + .ToArray(); - if (parameters.Count == 0) + 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; } - // 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)); + var isCatchAllParameter = templateSegment[1] == '*'; + parameters[property.Name] = isCatchAllParameter + ? string.Join("/", uriSegments.Skip(i)) + : GetValue(uriSegments[i], property); + } + else if (templateSegment != uriSegments[i]) + { + return []; } } - } - private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance) - { - var routeAttributes = instance - .GetType() - .GetCustomAttributes(typeof(RouteAttribute), true) - .Cast() - .ToArray(); - return routeAttributes; + return parameters; } private static string GetParameterName(string templateSegment) =>