diff --git a/src/bunit.core/Rendering/RenderModeMisMatchException.cs b/src/bunit.core/Rendering/RenderModeMisMatchException.cs new file mode 100644 index 000000000..8187988e3 --- /dev/null +++ b/src/bunit.core/Rendering/RenderModeMisMatchException.cs @@ -0,0 +1,22 @@ +#if NET9_0_OR_GREATER +namespace Bunit.Rendering; + +/// +/// Represents an exception that is thrown when a component under test has mismatching render modes assigned between parent and child components. +/// +public sealed class RenderModeMisMatchException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public RenderModeMisMatchException() + : base(""" + A component under test has mismatching render modes assigned between parent and child components. + Ensure that the render mode of the parent component matches the render mode of the child component. + Learn more about render modes at https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-9.0#render-mode-propagation. + """) + { + HelpLink = "https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-9.0#render-mode-propagation"; + } +} +#endif diff --git a/src/bunit.core/Rendering/TestRenderer.cs b/src/bunit.core/Rendering/TestRenderer.cs index df41b817a..f26e77fd0 100644 --- a/src/bunit.core/Rendering/TestRenderer.cs +++ b/src/bunit.core/Rendering/TestRenderer.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Logging; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; +using Microsoft.Extensions.Logging; namespace Bunit.Rendering; @@ -247,18 +247,55 @@ protected override IComponent ResolveComponentForRenderMode(Type componentType, { ArgumentNullException.ThrowIfNull(component); - var renderModeAttribute = component.GetType() - .GetCustomAttribute(); + // Search from the current component all the way up the render tree. + // All components must have the same render mode specified (or none at all). + // Return the render mode that is found after checking the full tree. + return GetAndValidateRenderMode(component, childRenderMode: null); - if (renderModeAttribute is not null) + IComponentRenderMode? GetAndValidateRenderMode(IComponent component, IComponentRenderMode? childRenderMode) { - return renderModeAttribute.Mode; + var componentState = GetComponentState(component); + var renderMode = GetRenderModeForComponent(componentState); + + if (childRenderMode is not null && renderMode is not null && childRenderMode != renderMode) + { + throw new RenderModeMisMatchException(); + } + + return componentState.ParentComponentState is null + ? renderMode ?? childRenderMode + : GetAndValidateRenderMode(componentState.ParentComponentState.Component, renderMode ?? childRenderMode); } - var parentComponentState = GetComponentState(component).ParentComponentState; - return parentComponentState is not null - ? GetComponentRenderMode(parentComponentState.Component) - : null; + IComponentRenderMode? GetRenderModeForComponent(ComponentState componentState) + { + var renderModeAttribute = componentState.Component.GetType().GetCustomAttribute(); + if (renderModeAttribute is { Mode: not null }) + { + return renderModeAttribute.Mode; + } + + if (componentState.ParentComponentState is not null) + { + var parentFrames = GetCurrentRenderTreeFrames(componentState.ParentComponentState.ComponentId); + var foundComponentStart = false; + for (var i = 0; i < parentFrames.Count; i++) + { + ref var frame = ref parentFrames.Array[i]; + + if (frame.FrameType is RenderTreeFrameType.Component) + { + foundComponentStart = frame.ComponentId == componentState.ComponentId; + } + else if (foundComponentStart && frame.FrameType is RenderTreeFrameType.ComponentRenderMode) + { + return frame.ComponentRenderMode; + } + } + } + + return null; + } } #endif diff --git a/tests/bunit.core.tests/Rendering/RenderModeTests.cs b/tests/bunit.core.tests/Rendering/RenderModeTests.cs deleted file mode 100644 index 352da9dec..000000000 --- a/tests/bunit.core.tests/Rendering/RenderModeTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -#if NET9_0_OR_GREATER -using Bunit.TestAssets.RenderModes; - -namespace Bunit.Rendering; - -public class RenderModeTests : TestContext -{ - [Fact(DisplayName = "TestRenderer provides RendererInfo")] - public void Test001() - { - Renderer.SetRendererInfo(new RendererInfo("Server", true)); - var cut = RenderComponent(); - - cut.MarkupMatches(""" -

Is interactive: True

-

Rendermode: Server

- """); - } - - [Fact(DisplayName = "Renderer throws exception if RendererInfo is not specified")] - public void Test002() - { - Action act = () => RenderComponent(); - - act.ShouldThrow(); - } - - [Fact(DisplayName = "Renderer should set the RenderModeAttribute on the component")] - public void Test003() - { - var cut = RenderComponent(); - - cut.MarkupMatches("
Assigned render mode: InteractiveServerRenderMode
"); - } - - [Fact(DisplayName = "The AssignedRenderMode is based on the RenderModeAttribute in the component hierarchy where parent component has no RenderMode")] - public void Test004() - { - var cut = RenderComponent( - c => c.AddChildContent()); - - cut.MarkupMatches(""" -
Parent assigned render mode:
-
Assigned render mode: InteractiveWebAssemblyRenderMode
- """); - } - - [Fact(DisplayName = "Parent and child render mode is specified")] - public void Test005() - { - var cut = RenderComponent( - c => c.AddChildContent()); - - cut.MarkupMatches(""" -
Parent assigned render mode: InteractiveWebAssemblyRenderMode
-
Assigned render mode: InteractiveServerRenderMode
- """); - } - - [Fact(DisplayName = "Parent and child render mode is not specified")] - public void Test006() - { - var cut = RenderComponent( - c => c.AddChildContent()); - - cut.MarkupMatches(""" -
Parent assigned render mode:
-
Assigned render mode:
- """); - - } -} -#endif \ No newline at end of file diff --git a/tests/bunit.core.tests/Rendering/RenderModeTests.razor b/tests/bunit.core.tests/Rendering/RenderModeTests.razor new file mode 100644 index 000000000..d9a0fb514 --- /dev/null +++ b/tests/bunit.core.tests/Rendering/RenderModeTests.razor @@ -0,0 +1,142 @@ +@using Bunit.TestAssets.RenderModes; +@inherits TestContext +@code { +#if NET9_0_OR_GREATER + [Fact(DisplayName = "TestRenderer provides RendererInfo")] + public void Test001() + { + Renderer.SetRendererInfo(new RendererInfo("Server", true)); + var cut = RenderComponent(); + + cut.MarkupMatches( + @ +

Is interactive: True

+

Rendermode: Server

+
); + } + + [Fact(DisplayName = "Renderer throws exception if RendererInfo is not specified")] + public void Test002() + { + Action act = () => RenderComponent(); + + act.ShouldThrow(); + } + + [Fact(DisplayName = "Renderer should set the RenderModeAttribute on the component")] + public void Test003() + { + var cut = RenderComponent(); + + cut.MarkupMatches(@
Assigned render mode: InteractiveServerRenderMode
); + } + + [Fact(DisplayName = "The AssignedRenderMode is based on the RenderModeAttribute in the component hierarchy where parent component has no RenderMode")] + public void Test004() + { + var cut = Render( + @ + + ); + + cut.MarkupMatches( + @ +
Parent assigned render mode:
+
Assigned render mode: InteractiveWebAssemblyRenderMode
+
); + } + + [Fact(DisplayName = "Parent and child render mode is specified")] + public void Test005() + { + var cut = Render( + @ + + ); + + cut.MarkupMatches( + @ +
Parent assigned render mode: InteractiveServerRenderMode
+
Assigned render mode: InteractiveServerRenderMode
+
); + } + + [Fact(DisplayName = "Parent and child render mode is not specified")] + public void Test006() + { + var cut = Render( + @ + + ); + + cut.MarkupMatches( + @ +
Parent assigned render mode:
+
Assigned render mode:
+
); + } + + [Fact(DisplayName = "Rendermode specified on child")] + public void Test007() + { + var cut = Render( + @ + + ); + + cut.MarkupMatches(@

Assigned Render Mode: InteractiveServerRenderMode

); + } + + [Fact(DisplayName = "Assigned Render Mode is inherited all the way down the component hierarchy")] + public void Test008() + { + var cut = Render( + @ + + + + ); + + cut.MarkupMatches(@

Assigned Render Mode: InteractiveServerRenderMode

); + } + + [Fact(DisplayName = "Having a component with section outlet and RenderMode is specifying for child component")] + public void Test009() + { + // See: https://learn.microsoft.com/en-us/aspnet/core/blazor/components/sections?view=aspnetcore-8.0#section-interaction-with-other-blazor-features + var cut = Render(@); + + cut.MarkupMatches(@

Assigned Render Mode: InteractiveWebAssemblyRenderMode

); + } + + [Fact(DisplayName = "Assigned Render Mode on siblings")] + public void Test010() + { + var cut = Render( + @ + + + ); + + cut.MarkupMatches( + @ +

Assigned Render Mode: InteractiveServerRenderMode

+

Assigned Render Mode: InteractiveWebAssemblyRenderMode

+
); + } + + + [Fact(DisplayName = "Different assigned RenderMode between child and parent throws")] + public void Test020() + { + var act = () => Render( + @ + + + + ); + + act.ShouldThrow(); // todo: figure out good exception to use + } +#endif +} diff --git a/tests/bunit.testassets/RenderModes/ComponentThatPrintsAssignedRenderMode.razor b/tests/bunit.testassets/RenderModes/ComponentThatPrintsAssignedRenderMode.razor new file mode 100644 index 000000000..effe0ff2c --- /dev/null +++ b/tests/bunit.testassets/RenderModes/ComponentThatPrintsAssignedRenderMode.razor @@ -0,0 +1,9 @@ +@{ +#if NET9_0_OR_GREATER +} + +

Assigned Render Mode: @AssignedRenderMode?.GetType().Name

+ +@{ +#endif +} diff --git a/tests/bunit.testassets/RenderModes/ComponentWithChildContent.razor b/tests/bunit.testassets/RenderModes/ComponentWithChildContent.razor new file mode 100644 index 000000000..c8b0cd4e1 --- /dev/null +++ b/tests/bunit.testassets/RenderModes/ComponentWithChildContent.razor @@ -0,0 +1,6 @@ +@ChildContent +@code { + + [Parameter] public RenderFragment ChildContent { get; set; } = default!; + +} \ No newline at end of file diff --git a/tests/bunit.testassets/RenderModes/ComponentWithServerRenderMode.razor b/tests/bunit.testassets/RenderModes/ComponentWithServerRenderMode.razor index 3ce1d35a0..dd008df6c 100644 --- a/tests/bunit.testassets/RenderModes/ComponentWithServerRenderMode.razor +++ b/tests/bunit.testassets/RenderModes/ComponentWithServerRenderMode.razor @@ -12,4 +12,4 @@ @{ #endif -} \ No newline at end of file +} diff --git a/tests/bunit.testassets/RenderModes/SectionOutletComponent.razor b/tests/bunit.testassets/RenderModes/SectionOutletComponent.razor new file mode 100644 index 000000000..7e860e07d --- /dev/null +++ b/tests/bunit.testassets/RenderModes/SectionOutletComponent.razor @@ -0,0 +1,12 @@ +@{ +#if NET9_0_OR_GREATER +} + + + + + + +@{ +#endif +}