From 53440d354fa85f95c82bcbc86d418736e8974b3e Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 13 Feb 2020 11:18:32 +0000 Subject: [PATCH 01/27] Initial changes --- src/Extensions/ElementQueryExtensions.cs | 2 +- .../RenderedFragmentQueryExtensions.cs | 44 +++++++++++++++++++ src/Rendering/IRenderedFragment.cs | 32 +++----------- 3 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 src/Extensions/RenderedFragmentQueryExtensions.cs diff --git a/src/Extensions/ElementQueryExtensions.cs b/src/Extensions/ElementQueryExtensions.cs index b82d136a5..258a7768b 100644 --- a/src/Extensions/ElementQueryExtensions.cs +++ b/src/Extensions/ElementQueryExtensions.cs @@ -11,7 +11,7 @@ namespace Bunit /// Helper methods for querying types. /// public static class ElementQueryExtensions - { + { /// /// Returns the first element within this element (using depth-first pre-order traversal /// of the document's nodes) that matches the specified group of selectors. diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs new file mode 100644 index 000000000..e85b986dc --- /dev/null +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AngleSharp.Dom; +using Xunit.Sdk; + +namespace Bunit +{ + /// + /// Helper methods for querying . + /// + public static class RenderedFragmentQueryExtensions + { + /// + /// Returns the first element from the rendered fragment or component under test, + /// using the provided , in a depth-first pre-order traversal + /// of the rendered nodes. + /// + /// The group of selectors to use. + public static IElement Find(this IRenderedFragment renderedFragment, string cssSelector) + { + if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + var result = renderedFragment.Nodes.QuerySelector(cssSelector); + if (result is null) + throw new ElementNotFoundException(cssSelector); + else + return result; + } + + /// + /// Returns a list of elements from the rendered fragment or component under test, + /// using the provided , in a depth-first pre-order traversal + /// of the rendered nodes. + /// + /// The group of selectors to use. + public static IHtmlCollection FindAll(this IRenderedFragment renderedFragment, string cssSelector) + { + if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + return renderedFragment.Nodes.QuerySelectorAll(cssSelector); + } + } +} diff --git a/src/Rendering/IRenderedFragment.cs b/src/Rendering/IRenderedFragment.cs index 39fb7e549..a159bcc7b 100644 --- a/src/Rendering/IRenderedFragment.cs +++ b/src/Rendering/IRenderedFragment.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using AngleSharp.Diffing.Core; using AngleSharp.Dom; using Xunit.Sdk; @@ -12,6 +13,11 @@ namespace Bunit /// public interface IRenderedFragment { + /// + /// Gets a which will complete when the is rendered again. + /// + Task NextRender { get; } + /// /// Gets the which this rendered fragment belongs to. /// @@ -50,31 +56,5 @@ public interface IRenderedFragment /// the snapshot and the rendered markup at that time. /// void SaveSnapshot(); - - /// - /// Returns the first element from the rendered fragment or component under test, - /// using the provided , in a depth-first pre-order traversal - /// of the rendered nodes. - /// - /// The group of selectors to use. - public IElement Find(string cssSelector) - { - var result = Nodes.QuerySelector(cssSelector); - if (result is null) - throw new ElementNotFoundException(cssSelector); - else - return result; - } - - /// - /// Returns a list of elements from the rendered fragment or component under test, - /// using the provided , in a depth-first pre-order traversal - /// of the rendered nodes. - /// - /// The group of selectors to use. - public IHtmlCollection FindAll(string cssSelector) - { - return Nodes.QuerySelectorAll(cssSelector); - } } } \ No newline at end of file From 5565063ce55b2c0e96e5fc21a936a11bc2f870fd Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 13 Feb 2020 21:50:42 +0000 Subject: [PATCH 02/27] Switched to IObservable instead of Task NextRender --- src/ComponentTestFixture.cs | 5 ++ src/Components/TestComponentBase.cs | 16 ++-- .../ElementNotFoundException.cs | 0 src/Extensions/TestContextExtensions.cs | 44 ++++++++++ src/ITestContext.cs | 11 +-- src/RenderEventSubscriber.cs | 86 +++++++++++++++++++ src/Rendering/IRenderedFragment.cs | 9 +- src/Rendering/RenderEvent.cs | 23 +++++ src/Rendering/RenderEventPublisher.cs | 53 ++++++++++++ src/Rendering/RenderedFragmentBase.cs | 9 +- src/Rendering/StructAction.cs | 16 ---- src/Rendering/TestRenderer.cs | 44 ++-------- src/TestContext.cs | 14 --- .../RenderedComponentTest.cs} | 2 +- tests/{ => Rendering}/RenderedFragmentTest.cs | 8 +- tests/SampleComponents/TwoChildren.razor | 6 ++ tests/TestContextTest.cs | 26 ++++++ 17 files changed, 278 insertions(+), 94 deletions(-) rename src/{ => Extensions}/ElementNotFoundException.cs (100%) create mode 100644 src/Extensions/TestContextExtensions.cs create mode 100644 src/RenderEventSubscriber.cs create mode 100644 src/Rendering/RenderEvent.cs create mode 100644 src/Rendering/RenderEventPublisher.cs delete mode 100644 src/Rendering/StructAction.cs rename tests/{RenderComponentTest.cs => Rendering/RenderedComponentTest.cs} (95%) rename tests/{ => Rendering}/RenderedFragmentTest.cs (93%) create mode 100644 tests/SampleComponents/TwoChildren.razor create mode 100644 tests/TestContextTest.cs diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index 796b34b88..fbb8f75b5 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -13,6 +13,11 @@ namespace Bunit /// public abstract class ComponentTestFixture : TestContext { + protected void WaitForNextRender(Action? renderTrigger = null, TimeSpan? timeout = null) + { + TestContextExtensions.WaitForNextRender(this, renderTrigger, timeout); + } + /// /// Creates a with an as parameter value /// for this and diff --git a/src/Components/TestComponentBase.cs b/src/Components/TestComponentBase.cs index 086abb423..13687d882 100644 --- a/src/Components/TestComponentBase.cs +++ b/src/Components/TestComponentBase.cs @@ -86,14 +86,14 @@ public override IRenderedComponent RenderComponent(param ? _testContextAdapter.RenderComponent(parameters) : base.RenderComponent(parameters); - /// - public override void WaitForNextRender(Action renderTrigger, TimeSpan? timeout = null) - { - if (_testContextAdapter.HasActiveContext) - _testContextAdapter.WaitForNextRender(renderTrigger, timeout); - else - base.WaitForNextRender(renderTrigger, timeout); - } + ///// + //public override void WaitForNextRender(Action renderTrigger, TimeSpan? timeout = null) + //{ + // if (_testContextAdapter.HasActiveContext) + // _testContextAdapter.WaitForNextRender(renderTrigger, timeout); + // else + // base.WaitForNextRender(renderTrigger, timeout); + //} private async Task ExecuteFixtureTests(ContainerComponent container) { diff --git a/src/ElementNotFoundException.cs b/src/Extensions/ElementNotFoundException.cs similarity index 100% rename from src/ElementNotFoundException.cs rename to src/Extensions/ElementNotFoundException.cs diff --git a/src/Extensions/TestContextExtensions.cs b/src/Extensions/TestContextExtensions.cs new file mode 100644 index 000000000..c9641b262 --- /dev/null +++ b/src/Extensions/TestContextExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Bunit +{ + /// + /// Helper methods for . + /// + public static class TestContextExtensions + { + /// + /// Executes the provided action and waits for a render to occur. + /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. + /// + /// The context to wait against. + /// The action that somehow causes one or more components to render. + /// The maximum time to wait for the next render. If not provided the default is 1 second. + /// Thrown when the next render did not happen within the specified . + public static void WaitForNextRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) + { + if (testContext is null) throw new ArgumentNullException(nameof(testContext)); + + var waitTime = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : timeout ?? TimeSpan.FromSeconds(1); + + using var rvs = new RenderEventSubscriber(testContext.Renderer.RenderEvents); + + renderTrigger?.Invoke(); + + if (rvs.RenderCount >= 1) return; + + if (SpinWait.SpinUntil(() => rvs.RenderCount >= 1, waitTime)) + return; + else + throw new TimeoutException("No render occurred within the timeout period."); + } + } + + +} diff --git a/src/ITestContext.cs b/src/ITestContext.cs index c3b0f7af0..fa65dc41c 100644 --- a/src/ITestContext.cs +++ b/src/ITestContext.cs @@ -1,6 +1,6 @@ using System; +using System.Threading.Tasks; using AngleSharp.Dom; -using Bunit.Diffing; using Microsoft.AspNetCore.Components; namespace Bunit @@ -36,14 +36,5 @@ public interface ITestContext : IDisposable /// Parameters to pass to the component when it is rendered /// The rendered IRenderedComponent RenderComponent(params ComponentParameter[] parameters) where TComponent : class, IComponent; - - /// - /// Executes the provided action and waits for a render to occur. - /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. - /// - /// The action that somehow causes one or more components to render. - /// The maximum time to wait for the next render. If not provided the default is 1 second. - /// Thrown when the next render did not happen within the specified . - void WaitForNextRender(Action renderTrigger, TimeSpan? timeout = null); } } \ No newline at end of file diff --git a/src/RenderEventSubscriber.cs b/src/RenderEventSubscriber.cs new file mode 100644 index 000000000..660144428 --- /dev/null +++ b/src/RenderEventSubscriber.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bunit +{ + /// + /// Represents a subscriber to s, published by + /// the . + /// + public sealed class RenderEventSubscriber : IObserver, IDisposable + { + private IDisposable _unsubscriber; + + /// + /// Gets the number of renders that have occurred since subscribing. + /// + public int RenderCount { get; private set; } + + /// + /// Gets whether the is disposed an no more + /// renders will happen. + /// + public bool IsCompleted { get; private set; } + + /// + /// Gets the latests received by the . + /// + public RenderEvent? LatestRenderEvent { get; private set; } + + /// + /// Gets or sets a callback to invoke when a is received. + /// + public Action? OnRender { get; set; } + + /// + /// Gets or sets a callback to invoke when the is + /// disposed and no more renders will happen. + /// + public Action? OnCompleted { get; set; } + + /// + /// Creates an instance of the , and + /// subscribes to the provided . + /// + public RenderEventSubscriber(IObservable observable) + { + if (observable is null) throw new ArgumentNullException(nameof(observable)); + _unsubscriber = observable.Subscribe(this); + } + + /// + /// Unsubscribes from the observable. + /// + public void Unsubscribe() + { + _unsubscriber.Dispose(); + } + + /// + /// Unsubscribes from the observable. + /// + public void Dispose() => Unsubscribe(); + + /// + void IObserver.OnNext(RenderEvent value) + { + RenderCount += 1; + LatestRenderEvent = value; + OnRender?.Invoke(value); + } + + /// + void IObserver.OnCompleted() + { + IsCompleted = true; + OnCompleted?.Invoke(); + } + + /// + void IObserver.OnError(Exception error) + => throw new AggregateException("The renderer throw an error", error); + } +} diff --git a/src/Rendering/IRenderedFragment.cs b/src/Rendering/IRenderedFragment.cs index a159bcc7b..caedeed38 100644 --- a/src/Rendering/IRenderedFragment.cs +++ b/src/Rendering/IRenderedFragment.cs @@ -13,10 +13,11 @@ namespace Bunit /// public interface IRenderedFragment { - /// - /// Gets a which will complete when the is rendered again. - /// - Task NextRender { get; } + ///// + ///// Gets an which will provide subscribers with s from the + ///// during its life time. + ///// + //IObservable RenderEvents { get; } /// /// Gets the which this rendered fragment belongs to. diff --git a/src/Rendering/RenderEvent.cs b/src/Rendering/RenderEvent.cs new file mode 100644 index 000000000..370614399 --- /dev/null +++ b/src/Rendering/RenderEvent.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Bunit +{ + /// + /// Represents a render event for a or generally from the . + /// + public readonly struct RenderEvent + { + /// + /// Gets the related from the render. + /// + public RenderBatch RenderBatch { get; } + + /// + /// Creates an instance of the type. + /// + public RenderEvent(in RenderBatch renderBatch) + { + RenderBatch = renderBatch; + } + } +} \ No newline at end of file diff --git a/src/Rendering/RenderEventPublisher.cs b/src/Rendering/RenderEventPublisher.cs new file mode 100644 index 000000000..35a3b11d4 --- /dev/null +++ b/src/Rendering/RenderEventPublisher.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Bunit +{ + /// + /// Represents a publisher. + /// + internal class RenderEventPublisher : IObservable + { + private readonly HashSet> _observers = new HashSet>(); + + public IDisposable Subscribe(IObserver observer) + { + if (!_observers.Contains(observer)) + _observers.Add(observer); + return new Unsubscriber(_observers, observer); + } + + public void OnRender(in RenderEvent renderEvent) + { + foreach (var observer in _observers) + { + observer.OnNext(renderEvent); + } + } + + public void OnCompleted() + { + foreach (var observer in _observers) + { + observer.OnCompleted(); + } + } + + private sealed class Unsubscriber : IDisposable + { + private HashSet> _observers; + private IObserver _observer; + + public Unsubscriber(HashSet> observers, IObserver observer) + { + _observers = observers; + _observer = observer; + } + + public void Dispose() + { + _observers.Remove(_observer); + } + } + } +} diff --git a/src/Rendering/RenderedFragmentBase.cs b/src/Rendering/RenderedFragmentBase.cs index f5db47ba2..2d5a58c22 100644 --- a/src/Rendering/RenderedFragmentBase.cs +++ b/src/Rendering/RenderedFragmentBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using AngleSharp.Diffing.Core; using AngleSharp.Dom; using Microsoft.AspNetCore.Components; @@ -13,6 +14,7 @@ namespace Bunit /// public abstract class RenderedFragmentBase : IRenderedFragment { + private readonly RenderEventSubscriber _renderEventSubscriber; private string? _snapshotMarkup; private string? _latestRenderMarkup; private INodeList? _firstRenderNodes; @@ -69,7 +71,8 @@ public RenderedFragmentBase(ITestContext testContext, RenderFragment renderFragm TestContext = testContext; Container = new ContainerComponent(testContext.Renderer); Container.Render(renderFragment); - testContext.Renderer.OnRenderingHasComponentUpdates += ComponentMarkupChanged; + _renderEventSubscriber = new RenderEventSubscriber(testContext.Renderer.RenderEvents); + _renderEventSubscriber.OnRender = ComponentMarkupChanged; } /// @@ -99,9 +102,9 @@ public IReadOnlyList GetChangesSinceFirstRender() return Nodes.CompareTo(_firstRenderNodes); } - private void ComponentMarkupChanged(in RenderBatch renderBatch) + private void ComponentMarkupChanged(RenderEvent renderBatch) { - if (renderBatch.HasUpdatesTo(ComponentId) || HasChildComponentUpdated(renderBatch, ComponentId)) + if (renderBatch.RenderBatch.HasUpdatesTo(ComponentId) || HasChildComponentUpdated(renderBatch.RenderBatch, ComponentId)) { ResetLatestRenderCache(); } diff --git a/src/Rendering/StructAction.cs b/src/Rendering/StructAction.cs deleted file mode 100644 index 9d0afed48..000000000 --- a/src/Rendering/StructAction.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Bunit -{ - /// - /// Represents an Action delegate, that allows readonly struct to be passed using - /// the in argument modifier. - /// - /// The struct type. - /// The input argument - public delegate void StructAction(in T input) where T : struct; -} diff --git a/src/Rendering/TestRenderer.cs b/src/Rendering/TestRenderer.cs index 647370559..f8bcfad48 100644 --- a/src/Rendering/TestRenderer.cs +++ b/src/Rendering/TestRenderer.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging; using System; using System.Collections; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.ExceptionServices; @@ -17,29 +16,24 @@ namespace Bunit [SuppressMessage("Usage", "BL0006:Do not use RenderTree types", Justification = "")] public class TestRenderer : Renderer { + private readonly RenderEventPublisher _renderEventPublisher; private Exception? _unhandledException; - private TaskCompletionSource _nextRenderTcs = new TaskCompletionSource(); - - /// - /// Gets or sets an action that will be triggered whenever the renderer - /// detects changes in rendered markup in components or fragments - /// after a render. - /// - public StructAction? OnRenderingHasComponentUpdates { get; set; } - /// public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); /// - /// Gets a task that completes after the next render. + /// Gets an which will provide subscribers with s from the + /// during its life time. /// - public Task NextRender => _nextRenderTcs.Task; + public IObservable RenderEvents { get; } /// public TestRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) : base(serviceProvider, loggerFactory) { + _renderEventPublisher = new RenderEventPublisher(); + RenderEvents = _renderEventPublisher; } /// @@ -78,31 +72,11 @@ protected override void HandleException(Exception exception) /// protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { - // TODO: Capture batches (and the state of component output) for individual inspection - var prevTcs = _nextRenderTcs; - _nextRenderTcs = new TaskCompletionSource(); - - NotifyOfComponentsWithChangedMarkup(renderBatch); - - prevTcs.SetResult(null); + var renderEvent = new RenderEvent(in renderBatch); + _renderEventPublisher.OnRender(renderEvent); return Task.CompletedTask; } - private void NotifyOfComponentsWithChangedMarkup(in RenderBatch renderBatch) - { - if (renderBatch.UpdatedComponents.Count > 0) - { - for (int i = 0; i < renderBatch.UpdatedComponents.Count; i++) - { - if (renderBatch.UpdatedComponents.Array[i].Edits.Count > 0) - { - OnRenderingHasComponentUpdates?.Invoke(renderBatch); - return; - } - } - } - } - /// /// Dispatches an callback in the context of the renderer synchronously and /// asserts no errors happened during dispatch @@ -117,7 +91,7 @@ public void DispatchAndAssertNoSynchronousErrors(Action callback) /// protected override void Dispose(bool disposing) { - OnRenderingHasComponentUpdates = null; + _renderEventPublisher.OnCompleted(); base.Dispose(disposing); } diff --git a/src/TestContext.cs b/src/TestContext.cs index 77d5916c2..6732cdd8a 100644 --- a/src/TestContext.cs +++ b/src/TestContext.cs @@ -58,20 +58,6 @@ public virtual IRenderedComponent RenderComponent(params return result; } - /// - public virtual void WaitForNextRender(Action renderTrigger, TimeSpan? timeout = null) - { - if (renderTrigger is null) throw new ArgumentNullException(nameof(renderTrigger)); - var task = Renderer.NextRender; - renderTrigger(); - task.Wait(timeout ?? TimeSpan.FromSeconds(1)); - - if (!task.IsCompleted) - { - throw new TimeoutException("No render occurred within the timeout period."); - } - } - #region IDisposable Support private bool _disposed = false; diff --git a/tests/RenderComponentTest.cs b/tests/Rendering/RenderedComponentTest.cs similarity index 95% rename from tests/RenderComponentTest.cs rename to tests/Rendering/RenderedComponentTest.cs index 8f8b3c019..787ea9995 100644 --- a/tests/RenderComponentTest.cs +++ b/tests/Rendering/RenderedComponentTest.cs @@ -4,7 +4,7 @@ namespace Bunit { - public class RenderComponentTest : ComponentTestFixture + public class RenderedComponentTest : ComponentTestFixture { [Fact(DisplayName = "Nodes should return the same instance " + "when a render has not resulted in any changes")] diff --git a/tests/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs similarity index 93% rename from tests/RenderedFragmentTest.cs rename to tests/Rendering/RenderedFragmentTest.cs index 37b5e9a16..f2ca68285 100644 --- a/tests/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -37,12 +37,14 @@ public void Test003() var testData = new AsyncNameDep(); Services.AddSingleton(testData); var cut = RenderComponent(); - var initialValue = cut.Nodes.Find("p").OuterHtml; + var initialValue = cut.Nodes.Find("p").TextContent; + var expectedValue = "Steve Sanderson"; - WaitForNextRender(() => testData.SetResult("Steve Sanderson")); + WaitForNextRender(() => testData.SetResult(expectedValue)); - var steveValue = cut.Nodes.Find("p").OuterHtml; + var steveValue = cut.Nodes.Find("p").TextContent; steveValue.ShouldNotBe(initialValue); + steveValue.ShouldBe(expectedValue); } [Fact(DisplayName = "Nodes should return new instance when " + diff --git a/tests/SampleComponents/TwoChildren.razor b/tests/SampleComponents/TwoChildren.razor new file mode 100644 index 000000000..12922e157 --- /dev/null +++ b/tests/SampleComponents/TwoChildren.razor @@ -0,0 +1,6 @@ +

+ +

+
+ +
\ No newline at end of file diff --git a/tests/TestContextTest.cs b/tests/TestContextTest.cs new file mode 100644 index 000000000..2941adf4f --- /dev/null +++ b/tests/TestContextTest.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Bunit.SampleComponents; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Shouldly; +using Xunit; + +namespace Bunit +{ + public class TestContextTest : ComponentTestFixture + { + [Fact] + public void MyTestMethod() + { + using var res = new RenderEventSubscriber(Renderer.RenderEvents); + var sut = RenderComponent(); + sut.Find("button").Click(); + res.RenderCount.ShouldBe(2); + } + + } +} From 3a1a79c7049c2ff0a7fedbb4ad1cb02c3d00d259 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 13 Feb 2020 21:57:30 +0000 Subject: [PATCH 03/27] Added comments to code --- src/ComponentTestFixture.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index fbb8f75b5..fed4319c6 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -13,6 +13,15 @@ namespace Bunit ///
public abstract class ComponentTestFixture : TestContext { + /// + /// Executes the provided action and waits for a render to occur. + /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. + /// + /// The context to wait against. + /// The action that somehow causes one or more components to render. + /// The maximum time to wait for the next render. If not provided the default is 1 second. + /// Thrown when the next render did not happen within the specified . + [Obsolete("TODO - Use IRenderedFragment.VerifyChange or IRenderedFragment.WaitForState instead.", false)] protected void WaitForNextRender(Action? renderTrigger = null, TimeSpan? timeout = null) { TestContextExtensions.WaitForNextRender(this, renderTrigger, timeout); From 959083c9fba4aa273e0488999662b87493a13feb Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 13 Feb 2020 22:03:00 +0000 Subject: [PATCH 04/27] cleanup --- src/Components/TestComponentBase.cs | 11 +---------- src/Rendering/IRenderedFragment.cs | 6 ------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/Components/TestComponentBase.cs b/src/Components/TestComponentBase.cs index 13687d882..fa7e0345e 100644 --- a/src/Components/TestComponentBase.cs +++ b/src/Components/TestComponentBase.cs @@ -85,16 +85,7 @@ public override IRenderedComponent RenderComponent(param => _testContextAdapter.HasActiveContext ? _testContextAdapter.RenderComponent(parameters) : base.RenderComponent(parameters); - - ///// - //public override void WaitForNextRender(Action renderTrigger, TimeSpan? timeout = null) - //{ - // if (_testContextAdapter.HasActiveContext) - // _testContextAdapter.WaitForNextRender(renderTrigger, timeout); - // else - // base.WaitForNextRender(renderTrigger, timeout); - //} - + private async Task ExecuteFixtureTests(ContainerComponent container) { foreach (var (_, fixture) in container.GetComponents()) diff --git a/src/Rendering/IRenderedFragment.cs b/src/Rendering/IRenderedFragment.cs index caedeed38..29d47a171 100644 --- a/src/Rendering/IRenderedFragment.cs +++ b/src/Rendering/IRenderedFragment.cs @@ -13,12 +13,6 @@ namespace Bunit /// public interface IRenderedFragment { - ///// - ///// Gets an which will provide subscribers with s from the - ///// during its life time. - ///// - //IObservable RenderEvents { get; } - /// /// Gets the which this rendered fragment belongs to. /// From 980740819c54da884a10b3c12f557ad9d3275e02 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Sat, 15 Feb 2020 20:02:46 +0000 Subject: [PATCH 05/27] Added RenderEvents to IRenderedFragment and moved change and render detection logic to RenderEvent --- src/ComponentTestFixture.cs | 4 +- src/Components/ContainerComponent.cs | 4 +- .../InputEventDispatchExtensions.cs | 2 +- .../Internal/RenderBatchExtensions.cs | 32 ------ src/Extensions/RenderedFragmentExtensions.cs | 10 ++ .../RenderedFragmentQueryExtensions.cs | 2 + src/Extensions/TestContextExtensions.cs | 30 ++--- src/GlobalSuppressions.cs | 3 +- src/Rendering/ComponentNotFoundException.cs | 19 ++++ src/Rendering/IRenderedFragment.cs | 29 +++++ src/Rendering/Internal/Htmlizer.cs | 4 +- src/Rendering/RenderEvent.cs | 93 +++++++++++++++- src/Rendering/RenderEventPublisher.cs | 105 +++++++++++++----- src/{ => Rendering}/RenderEventSubscriber.cs | 7 +- src/Rendering/RenderedComponent.cs | 13 ++- src/Rendering/RenderedFragment.cs | 4 +- src/Rendering/RenderedFragmentBase.cs | 79 +++++++------ src/Rendering/TestRenderer.cs | 2 +- ...tAndFragmentFromRazorTestContextTest.razor | 4 +- .../LifeCycleTrackerTest.razor | 2 +- tests/Rendering/RenderEventPubSubTest.cs | 49 ++++++++ tests/Rendering/RenderedFragmentTest.cs | 91 +++++++++++++++ tests/SampleComponents/LifeCycleTracker.razor | 24 ++-- .../SampleComponents/ToggleClickHandler.razor | 10 ++ .../TwoComponentWrapper.razor | 6 + ...TestContextTest.cs => TestRendererTest.cs} | 12 +- 26 files changed, 495 insertions(+), 145 deletions(-) delete mode 100644 src/Extensions/Internal/RenderBatchExtensions.cs create mode 100644 src/Extensions/RenderedFragmentExtensions.cs create mode 100644 src/Rendering/ComponentNotFoundException.cs rename src/{ => Rendering}/RenderEventSubscriber.cs (94%) create mode 100644 tests/Rendering/RenderEventPubSubTest.cs create mode 100644 tests/SampleComponents/ToggleClickHandler.razor create mode 100644 tests/SampleComponents/TwoComponentWrapper.razor rename tests/{TestContextTest.cs => TestRendererTest.cs} (58%) diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index fed4319c6..024df6335 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -17,11 +17,9 @@ public abstract class ComponentTestFixture : TestContext /// Executes the provided action and waits for a render to occur. /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. /// - /// The context to wait against. /// The action that somehow causes one or more components to render. - /// The maximum time to wait for the next render. If not provided the default is 1 second. + /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. /// Thrown when the next render did not happen within the specified . - [Obsolete("TODO - Use IRenderedFragment.VerifyChange or IRenderedFragment.WaitForState instead.", false)] protected void WaitForNextRender(Action? renderTrigger = null, TimeSpan? timeout = null) { TestContextExtensions.WaitForNextRender(this, renderTrigger, timeout); diff --git a/src/Components/ContainerComponent.cs b/src/Components/ContainerComponent.cs index c69f9b1cf..60fc76d42 100644 --- a/src/Components/ContainerComponent.cs +++ b/src/Components/ContainerComponent.cs @@ -58,14 +58,14 @@ public void Render(RenderFragment renderFragment) /// component is found, its child content is also searched recursively. /// /// The type of component to find - /// When a component of type was not found. + /// When a component of type was not found. public (int Id, TComponent Component) GetComponent() where TComponent : IComponent { var result = GetComponent(ComponentId); if (result.HasValue) return result.Value; else - throw new InvalidOperationException($"No components of type {typeof(TComponent)} were found in the render tree."); + throw new ComponentNotFoundException(typeof(TComponent)); } /// diff --git a/src/EventDispatchExtensions/InputEventDispatchExtensions.cs b/src/EventDispatchExtensions/InputEventDispatchExtensions.cs index a18c61f51..e94af59f3 100644 --- a/src/EventDispatchExtensions/InputEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/InputEventDispatchExtensions.cs @@ -19,7 +19,7 @@ public static class InputEventDispatchExtensions /// /// The element to raise the event on. /// The new value - public static void Change(this IElement element, string value) + public static void Change(this IElement element, object value) => _ = ChangeAsync(element, new ChangeEventArgs { Value = value }); /// diff --git a/src/Extensions/Internal/RenderBatchExtensions.cs b/src/Extensions/Internal/RenderBatchExtensions.cs deleted file mode 100644 index 0bb80097e..000000000 --- a/src/Extensions/Internal/RenderBatchExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.RenderTree; - -namespace Bunit -{ - /// - /// Helper methods for working with . - /// - internal static class RenderBatchExtensions - { - /// - /// Checks a for updates to a component with the specified . - /// - /// RenderBatch to search. - /// Id of component to check for updates to. - /// True if contains updates to component, false otherwise. - public static bool HasUpdatesTo(in this RenderBatch renderBatch, int componentId) - { - for (int i = 0; i < renderBatch.UpdatedComponents.Count; i++) - { - var update = renderBatch.UpdatedComponents.Array[i]; - if (update.ComponentId == componentId && update.Edits.Count > 0) - return true; - } - return false; - } - } -} diff --git a/src/Extensions/RenderedFragmentExtensions.cs b/src/Extensions/RenderedFragmentExtensions.cs new file mode 100644 index 000000000..21d90db54 --- /dev/null +++ b/src/Extensions/RenderedFragmentExtensions.cs @@ -0,0 +1,10 @@ +namespace Bunit +{ + /// + /// Helper methods for . + /// + public static class RenderedFragmentExtensions + { + + } +} diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs index e85b986dc..acd318559 100644 --- a/src/Extensions/RenderedFragmentQueryExtensions.cs +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -18,6 +18,7 @@ public static class RenderedFragmentQueryExtensions /// using the provided , in a depth-first pre-order traversal /// of the rendered nodes. /// + /// The rendered fragment to search. /// The group of selectors to use. public static IElement Find(this IRenderedFragment renderedFragment, string cssSelector) { @@ -34,6 +35,7 @@ public static IElement Find(this IRenderedFragment renderedFragment, string cssS /// using the provided , in a depth-first pre-order traversal /// of the rendered nodes. /// + /// The rendered fragment to search. /// The group of selectors to use. public static IHtmlCollection FindAll(this IRenderedFragment renderedFragment, string cssSelector) { diff --git a/src/Extensions/TestContextExtensions.cs b/src/Extensions/TestContextExtensions.cs index c9641b262..3d1c21de1 100644 --- a/src/Extensions/TestContextExtensions.cs +++ b/src/Extensions/TestContextExtensions.cs @@ -19,24 +19,28 @@ public static class TestContextExtensions /// /// The context to wait against. /// The action that somehow causes one or more components to render. - /// The maximum time to wait for the next render. If not provided the default is 1 second. - /// Thrown when the next render did not happen within the specified . + /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. public static void WaitForNextRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) { if (testContext is null) throw new ArgumentNullException(nameof(testContext)); var waitTime = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : timeout ?? TimeSpan.FromSeconds(1); - - using var rvs = new RenderEventSubscriber(testContext.Renderer.RenderEvents); - - renderTrigger?.Invoke(); - - if (rvs.RenderCount >= 1) return; - - if (SpinWait.SpinUntil(() => rvs.RenderCount >= 1, waitTime)) - return; - else - throw new TimeoutException("No render occurred within the timeout period."); + var rvs = new RenderEventSubscriber(testContext.Renderer.RenderEvents); + try + { + renderTrigger?.Invoke(); + + if (rvs.RenderCount >= 1) return; + + if (SpinWait.SpinUntil(() => rvs.RenderCount >= 1, waitTime)) + return; + else + throw new TimeoutException("No render occurred within the timeout period."); + } + finally + { + rvs.Unsubscribe(); + } } } diff --git a/src/GlobalSuppressions.cs b/src/GlobalSuppressions.cs index 0713c8c08..7b090b59b 100644 --- a/src/GlobalSuppressions.cs +++ b/src/GlobalSuppressions.cs @@ -8,4 +8,5 @@ Scope = "namespaceanddescendants", Target = "Bunit")] [assembly: SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", - Justification = "No need to translate at this point", Scope = "namespaceanddescendants", Target = "Bunit")] \ No newline at end of file + Justification = "No need to translate at this point", Scope = "namespaceanddescendants", Target = "Bunit")] +[assembly: SuppressMessage("Design", "CA1032:Implement standard exception constructors")] diff --git a/src/Rendering/ComponentNotFoundException.cs b/src/Rendering/ComponentNotFoundException.cs new file mode 100644 index 000000000..0cc4b5914 --- /dev/null +++ b/src/Rendering/ComponentNotFoundException.cs @@ -0,0 +1,19 @@ +using System; + +namespace Bunit +{ + /// + /// Represents an exception that is thrown when a search for a component in a + /// did not succeed. + /// + public class ComponentNotFoundException : Exception + { + /// + /// Creates an instance of the type. + /// + /// The type of component that was not found. + public ComponentNotFoundException(Type componentType) : base($"A component of type {componentType?.Name} was not found in the render tree.") + { + } + } +} \ No newline at end of file diff --git a/src/Rendering/IRenderedFragment.cs b/src/Rendering/IRenderedFragment.cs index 29d47a171..12a968ca7 100644 --- a/src/Rendering/IRenderedFragment.cs +++ b/src/Rendering/IRenderedFragment.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using AngleSharp.Diffing.Core; using AngleSharp.Dom; +using Microsoft.AspNetCore.Components; using Xunit.Sdk; namespace Bunit @@ -13,6 +14,34 @@ namespace Bunit /// public interface IRenderedFragment { + /// + /// Gets the id of the rendered component or fragment. + /// + int ComponentId { get; } + + /// + /// Gets an which will provide subscribers with s + /// whenever the is rendered. + /// + IObservable RenderEvents { get; } + + /// + /// Finds the first component of type in the render tree of + /// this . + /// + /// Type of component to find. + /// Thrown if a component of type was not found in the render tree. + /// The . + IRenderedComponent FindComponent() where TComponent : class, IComponent; + + /// + /// Finds all components of type in the render tree of + /// this . + /// + /// Type of components to find. + /// The s + IReadOnlyList> FindComponents() where TComponent : class, IComponent; + /// /// Gets the which this rendered fragment belongs to. /// diff --git a/src/Rendering/Internal/Htmlizer.cs b/src/Rendering/Internal/Htmlizer.cs index e0abcf54c..71b6057d9 100644 --- a/src/Rendering/Internal/Htmlizer.cs +++ b/src/Rendering/Internal/Htmlizer.cs @@ -249,13 +249,13 @@ private static int RenderAttributes( private class HtmlRenderingContext { + public TestRenderer Renderer { get; } + public HtmlRenderingContext(TestRenderer renderer) { Renderer = renderer; } - public TestRenderer Renderer { get; } - public List Result { get; } = new List(); public string? ClosestSelectValueAsString { get; set; } diff --git a/src/Rendering/RenderEvent.cs b/src/Rendering/RenderEvent.cs index 370614399..fb4743a18 100644 --- a/src/Rendering/RenderEvent.cs +++ b/src/Rendering/RenderEvent.cs @@ -1,12 +1,15 @@ -using Microsoft.AspNetCore.Components.RenderTree; +using System; +using Microsoft.AspNetCore.Components.RenderTree; namespace Bunit { /// /// Represents a render event for a or generally from the . /// - public readonly struct RenderEvent + public readonly struct RenderEvent : IEquatable { + private readonly TestRenderer _renderer; + /// /// Gets the related from the render. /// @@ -15,9 +18,93 @@ public readonly struct RenderEvent /// /// Creates an instance of the type. /// - public RenderEvent(in RenderBatch renderBatch) + public RenderEvent(in RenderBatch renderBatch, TestRenderer renderer) { RenderBatch = renderBatch; + _renderer = renderer; + } + + /// + /// Checks whether the component with or one or more of + /// its sub components was changed during the . + /// + /// Id of component to check for updates to. + /// True if contains updates to component, false otherwise. + public bool HasChangesTo(int componentId) + { + for (int i = 0; i < RenderBatch.UpdatedComponents.Count; i++) + { + var update = RenderBatch.UpdatedComponents.Array[i]; + if (update.ComponentId == componentId && update.Edits.Count > 0) + return true; + } + for (int i = 0; i < RenderBatch.DisposedEventHandlerIDs.Count; i++) + { + if (RenderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) + return true; + } + return HasChangedToChildren(_renderer.GetCurrentRenderTreeFrames(componentId)); + } + + /// + /// Checks whether the component with or one or more of + /// its sub components was rendered during the . + /// + /// Id of component to check if rendered. + /// True if the component or a sub component rendered, false otherwise. + public bool DidComponentRender(int componentId) + { + for (int i = 0; i < RenderBatch.UpdatedComponents.Count; i++) + { + var update = RenderBatch.UpdatedComponents.Array[i]; + if (update.ComponentId == componentId) + return true; + } + for (int i = 0; i < RenderBatch.DisposedEventHandlerIDs.Count; i++) + { + if (RenderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) + return true; + } + return DidChildComponentRender(_renderer.GetCurrentRenderTreeFrames(componentId)); } + + private bool HasChangedToChildren(ArrayRange componentRenderTreeFrames) + { + for (int i = 0; i < componentRenderTreeFrames.Count; i++) + { + var frame = componentRenderTreeFrames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component) + if (HasChangesTo(frame.ComponentId)) + return true; + } + return false; + } + + private bool DidChildComponentRender(ArrayRange componentRenderTreeFrames) + { + for (int i = 0; i < componentRenderTreeFrames.Count; i++) + { + var frame = componentRenderTreeFrames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component) + if (DidComponentRender(frame.ComponentId)) + return true; + } + return false; + } + + /// + public bool Equals(RenderEvent other) => RenderBatch.Equals(other.RenderBatch); + + /// + public override bool Equals(object obj) => obj is RenderEvent other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(RenderBatch); + + /// + public static bool operator ==(RenderEvent left, RenderEvent right) => left.Equals(right); + + /// + public static bool operator !=(RenderEvent left, RenderEvent right) => !left.Equals(right); } } \ No newline at end of file diff --git a/src/Rendering/RenderEventPublisher.cs b/src/Rendering/RenderEventPublisher.cs index 35a3b11d4..b14f5bd11 100644 --- a/src/Rendering/RenderEventPublisher.cs +++ b/src/Rendering/RenderEventPublisher.cs @@ -3,51 +3,106 @@ namespace Bunit { - /// - /// Represents a publisher. - /// - internal class RenderEventPublisher : IObservable + internal class RenderEventObservable : IObservable { - private readonly HashSet> _observers = new HashSet>(); + protected HashSet> Observers { get; } = new HashSet>(); - public IDisposable Subscribe(IObserver observer) + public virtual IDisposable Subscribe(IObserver observer) { - if (!_observers.Contains(observer)) - _observers.Add(observer); - return new Unsubscriber(_observers, observer); + if (!Observers.Contains(observer)) + Observers.Add(observer); + return new Unsubscriber(this, observer); } - public void OnRender(in RenderEvent renderEvent) + protected virtual void RemoveSubscription(IObserver observer) { - foreach (var observer in _observers) - { - observer.OnNext(renderEvent); - } + Observers.Remove(observer); } - public void OnCompleted() + private sealed class Unsubscriber : IDisposable { - foreach (var observer in _observers) + private RenderEventObservable _observable; + private IObserver _observer; + + public Unsubscriber(RenderEventObservable observable, IObserver observer) { - observer.OnCompleted(); + _observable = observable; + _observer = observer; + } + + public void Dispose() + { + _observable.RemoveSubscription(_observer); } } + } - private sealed class Unsubscriber : IDisposable + internal sealed class RenderEventFilter : RenderEventObservable, IObservable, IObserver + { + private readonly IObservable _source; + private readonly Func _forwardEvent; + private IDisposable? _subscription; + + public RenderEventFilter(IObservable source, Func forwardEvent) { - private HashSet> _observers; - private IObserver _observer; + _source = source; + _forwardEvent = forwardEvent; + } - public Unsubscriber(HashSet> observers, IObserver observer) + public override IDisposable Subscribe(IObserver observer) + { + if (_subscription is null) { - _observers = observers; - _observer = observer; + _subscription = _source.Subscribe(this); } + return base.Subscribe(observer); + } - public void Dispose() + protected override void RemoveSubscription(IObserver observer) + { + base.RemoveSubscription(observer); + if (Observers.Count == 0 && _subscription is { }) { - _observers.Remove(_observer); + _subscription.Dispose(); + _subscription = null; } } + + void IObserver.OnCompleted() + { + foreach (var observer in Observers) + observer.OnCompleted(); + } + void IObserver.OnError(Exception error) + { + foreach (var observer in Observers) + observer.OnError(error); + } + + void IObserver.OnNext(RenderEvent renderEvent) + { + if (!_forwardEvent(renderEvent)) return; + foreach (var observer in Observers) + observer.OnNext(renderEvent); + } + } + + internal sealed class RenderEventPublisher : RenderEventObservable, IObservable + { + private bool _isCompleted; + + public void OnRender(in RenderEvent renderEvent) + { + if (_isCompleted) throw new InvalidOperationException($"Calling {nameof(OnRender)} is not allowed after {nameof(OnCompleted)} has been called."); + foreach (var observer in Observers) + observer.OnNext(renderEvent); + } + + public void OnCompleted() + { + _isCompleted = true; + foreach (var observer in Observers) + observer.OnCompleted(); + } } } diff --git a/src/RenderEventSubscriber.cs b/src/Rendering/RenderEventSubscriber.cs similarity index 94% rename from src/RenderEventSubscriber.cs rename to src/Rendering/RenderEventSubscriber.cs index 660144428..92583bc1a 100644 --- a/src/RenderEventSubscriber.cs +++ b/src/Rendering/RenderEventSubscriber.cs @@ -10,7 +10,7 @@ namespace Bunit /// Represents a subscriber to s, published by /// the . /// - public sealed class RenderEventSubscriber : IObserver, IDisposable + public sealed class RenderEventSubscriber : IObserver { private IDisposable _unsubscriber; @@ -59,11 +59,6 @@ public void Unsubscribe() _unsubscriber.Dispose(); } - /// - /// Unsubscribes from the observable. - /// - public void Dispose() => Unsubscribe(); - /// void IObserver.OnNext(RenderEvent value) { diff --git a/src/Rendering/RenderedComponent.cs b/src/Rendering/RenderedComponent.cs index 263cadcc3..2af7fe8e8 100644 --- a/src/Rendering/RenderedComponent.cs +++ b/src/Rendering/RenderedComponent.cs @@ -11,15 +11,14 @@ internal class RenderedComponent : RenderedFragmentBase, IRenderedCo where TComponent : class, IComponent { /// - protected override int ComponentId { get; } + protected override string FirstRenderMarkup { get; } /// - protected override string FirstRenderMarkup { get; } + public override int ComponentId { get; } /// public TComponent Instance { get; } - /// /// Instantiates a which will render a component of type /// with the provided . @@ -45,6 +44,14 @@ public RenderedComponent(ITestContext testContext, RenderFragment renderFragment FirstRenderMarkup = Markup; } + internal RenderedComponent(ITestContext testContext, ContainerComponent container, int componentId, TComponent component) + : base(testContext, container) + { + ComponentId = componentId; + Instance = component; + FirstRenderMarkup = Markup; + } + /// public void Render() => SetParametersAndRender(ParameterView.Empty); diff --git a/src/Rendering/RenderedFragment.cs b/src/Rendering/RenderedFragment.cs index ea3bea458..4bd0d9a1d 100644 --- a/src/Rendering/RenderedFragment.cs +++ b/src/Rendering/RenderedFragment.cs @@ -14,10 +14,10 @@ namespace Bunit public class RenderedFragment : RenderedFragmentBase { /// - protected override int ComponentId => Container.ComponentId; + protected override string FirstRenderMarkup { get; } /// - protected override string FirstRenderMarkup { get; } + public override int ComponentId => Container.ComponentId; /// /// Instantiates a which will render the passed to it. diff --git a/src/Rendering/RenderedFragmentBase.cs b/src/Rendering/RenderedFragmentBase.cs index 2d5a58c22..c48a8b733 100644 --- a/src/Rendering/RenderedFragmentBase.cs +++ b/src/Rendering/RenderedFragmentBase.cs @@ -21,11 +21,6 @@ public abstract class RenderedFragmentBase : IRenderedFragment private INodeList? _latestRenderNodes; private INodeList? _snapshotNodes; - /// - /// Gets the id of the rendered component or fragment. - /// - protected abstract int ComponentId { get; } - /// /// Gets the first rendered markup. /// @@ -39,6 +34,9 @@ public abstract class RenderedFragmentBase : IRenderedFragment /// public ITestContext TestContext { get; } + /// + public abstract int ComponentId { get; } + /// public string Markup { @@ -61,18 +59,51 @@ public INodeList Nodes } } + /// + public IObservable RenderEvents { get; } + /// /// Creates an instance of the class. /// - public RenderedFragmentBase(ITestContext testContext, RenderFragment renderFragment) + protected RenderedFragmentBase(ITestContext testContext, RenderFragment renderFragment) + : this(testContext, testContext is { } ctx ? new ContainerComponent(ctx.Renderer) : throw new ArgumentNullException(nameof(testContext))) + { + Container.Render(renderFragment); + } + + /// + /// Creates an instance of the class. + /// + protected RenderedFragmentBase(ITestContext testContext, ContainerComponent container) { if (testContext is null) throw new ArgumentNullException(nameof(testContext)); + if (container is null) throw new ArgumentNullException(nameof(container)); TestContext = testContext; - Container = new ContainerComponent(testContext.Renderer); - Container.Render(renderFragment); - _renderEventSubscriber = new RenderEventSubscriber(testContext.Renderer.RenderEvents); - _renderEventSubscriber.OnRender = ComponentMarkupChanged; + Container = container; + RenderEvents = new RenderEventFilter(testContext.Renderer.RenderEvents, RenderFilter); + _renderEventSubscriber = new RenderEventSubscriber(testContext.Renderer.RenderEvents) + { + OnRender = ComponentRendered + }; + } + + /// + public IRenderedComponent FindComponent() where T : class, IComponent + { + var (id, component) = Container.GetComponent(); + return new RenderedComponent(TestContext, Container, id, component); + } + + /// + public IReadOnlyList> FindComponents() where T : class, IComponent + { + var result = new List>(); + foreach (var (id, component) in Container.GetComponents()) + { + result.Add(new RenderedComponent(TestContext, Container, id, component)); + } + return result; } /// @@ -102,34 +133,14 @@ public IReadOnlyList GetChangesSinceFirstRender() return Nodes.CompareTo(_firstRenderNodes); } - private void ComponentMarkupChanged(RenderEvent renderBatch) - { - if (renderBatch.RenderBatch.HasUpdatesTo(ComponentId) || HasChildComponentUpdated(renderBatch.RenderBatch, ComponentId)) - { - ResetLatestRenderCache(); - } - } + private bool RenderFilter(RenderEvent renderEvent) => renderEvent.DidComponentRender(ComponentId); - private bool HasChildComponentUpdated(in RenderBatch renderBatch, int componentId) + private void ComponentRendered(RenderEvent renderEvent) { - var frames = TestContext.Renderer.GetCurrentRenderTreeFrames(componentId); - - for (int i = 0; i < frames.Count; i++) + if (renderEvent.HasChangesTo(ComponentId)) { - var frame = frames.Array[i]; - if (frame.FrameType == RenderTreeFrameType.Component) - { - if (renderBatch.HasUpdatesTo(frame.ComponentId)) - { - return true; - } - if (HasChildComponentUpdated(in renderBatch, frame.ComponentId)) - { - return true; - } - } + ResetLatestRenderCache(); } - return false; } private void ResetLatestRenderCache() diff --git a/src/Rendering/TestRenderer.cs b/src/Rendering/TestRenderer.cs index f8bcfad48..97e6b3516 100644 --- a/src/Rendering/TestRenderer.cs +++ b/src/Rendering/TestRenderer.cs @@ -72,7 +72,7 @@ protected override void HandleException(Exception exception) /// protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { - var renderEvent = new RenderEvent(in renderBatch); + var renderEvent = new RenderEvent(in renderBatch, this); _renderEventPublisher.OnRender(renderEvent); return Task.CompletedTask; } diff --git a/tests/Components/TestComponentBaseTest/GettingCutAndFragmentFromRazorTestContextTest.razor b/tests/Components/TestComponentBaseTest/GettingCutAndFragmentFromRazorTestContextTest.razor index fc27ce7f0..b84445697 100644 --- a/tests/Components/TestComponentBaseTest/GettingCutAndFragmentFromRazorTestContextTest.razor +++ b/tests/Components/TestComponentBaseTest/GettingCutAndFragmentFromRazorTestContextTest.razor @@ -69,8 +69,8 @@ @code{ void CallingGetCutOrGetFragmentWithWrongGenericTypeThrows() { - Assert.Throws(() => GetComponentUnderTest()); - Assert.Throws(() => GetFragment()); + Assert.Throws(() => GetComponentUnderTest()); + Assert.Throws(() => GetFragment()); } } diff --git a/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor b/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor index 7963020a6..371b64457 100644 --- a/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor +++ b/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor @@ -19,7 +19,7 @@ void Test() { var cut = GetComponentUnderTest(); - var callLog = tracker.LifeCycleMethodCallLog; + var callLog = tracker.CallLog; // assert first render called expected methods callLog["SetParametersAsync"].ShouldBe(1); diff --git a/tests/Rendering/RenderEventPubSubTest.cs b/tests/Rendering/RenderEventPubSubTest.cs new file mode 100644 index 000000000..d9b959b7b --- /dev/null +++ b/tests/Rendering/RenderEventPubSubTest.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Bunit.Rendering +{ + public class RenderEventPubSubTest + { + [Fact(DisplayName = "When a subscriber subscribes to a publisher it can receives events from publisher")] + public void Test001() + { + var pub = new RenderEventPublisher(); + var sub = new RenderEventSubscriber(pub); + + pub.OnRender(new RenderEvent()); + + sub.RenderCount.ShouldBe(1); + sub.LatestRenderEvent.ShouldNotBeNull(); + } + + [Fact(DisplayName = "Publisher cannot call OnRender after OnComplete")] + public void Test002() + { + var pub = new RenderEventPublisher(); + + pub.OnCompleted(); + + Should.Throw(() => pub.OnRender(new RenderEvent())); + } + + [Fact(DisplayName = "Calling Unsubscribe on subscriber unsubscribes it from publisher")] + public void Test003() + { + var pub = new RenderEventPublisher(); + var sub = new RenderEventSubscriber(pub); + + sub.Unsubscribe(); + + pub.OnRender(new RenderEvent()); + + sub.RenderCount.ShouldBe(0); + sub.LatestRenderEvent.ShouldBeNull(); + } + } +} diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index f2ca68285..62ebb729c 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -118,7 +118,98 @@ public void Test008() Assert.Same(initialNodes, cut.Nodes); } + [Fact(DisplayName = "Changes to event handler should return a new instance of DOM tree")] + public void Test009() + { + var cut = RenderComponent(); + cut.Find("#btn").Click(); + + cut.Instance.Counter.ShouldBe(1); + + cut.SetParametersAndRender((nameof(ToggleClickHandler.HandleClicks), false)); + + cut.Find("#btn").Click(); + + cut.Instance.Counter.ShouldBe(1); + } + + [Fact(DisplayName = "GetComponent returns first component of requested type using a depth first search")] + public void Test100() + { + var wrapper = RenderComponent( + RenderFragment(nameof(TwoComponentWrapper.First), + ChildContent((nameof(Simple1.Header), "First") + )), + RenderFragment(nameof(TwoComponentWrapper.Second), (nameof(Simple1.Header), "Second")) + ); + var cut = wrapper.FindComponent(); + cut.Instance.Header.ShouldBe("First"); + } + + [Fact(DisplayName = "GetComponent returns CUT if it is the first component of the requested type")] + public void Test101() + { + var cut = RenderComponent(); + + var cutAgain = cut.FindComponent(); + + cut.Instance.ShouldBe(cutAgain.Instance); + } + + [Fact(DisplayName = "GetComponent throws when component of requested type is not in the render tree")] + public void Test102() + { + var wrapper = RenderComponent(); + + Should.Throw(() => wrapper.FindComponent()); + } + + [Fact(DisplayName = "GetComponents returns all components of requested type using a depth first order")] + public void Test103() + { + var wrapper = RenderComponent( + RenderFragment(nameof(TwoComponentWrapper.First), + ChildContent((nameof(Simple1.Header), "First") + )), + RenderFragment(nameof(TwoComponentWrapper.Second), (nameof(Simple1.Header), "Second")) + ); + var cuts = wrapper.FindComponents(); + + cuts.Count.ShouldBe(2); + cuts[0].Instance.Header.ShouldBe("First"); + cuts[1].Instance.Header.ShouldBe("Second"); + } + + [Fact(DisplayName = "Render events for non-rendered sub components are not emitted")] + public void Test010() + { + var renderSub = new RenderEventSubscriber(Renderer.RenderEvents); + var wrapper = RenderComponent( + RenderFragment(nameof(TwoComponentWrapper.First)), + RenderFragment(nameof(TwoComponentWrapper.Second)) + ); + var cuts = wrapper.FindComponents(); + var wrapperSub = new RenderEventSubscriber(wrapper.RenderEvents); + var cutSub1 = new RenderEventSubscriber(cuts[0].RenderEvents); + var cutSub2 = new RenderEventSubscriber(cuts[1].RenderEvents); + + renderSub.RenderCount.ShouldBe(1); + + cuts[0].Render(); + + renderSub.RenderCount.ShouldBe(2); + wrapperSub.RenderCount.ShouldBe(1); + cutSub1.RenderCount.ShouldBe(1); + cutSub2.RenderCount.ShouldBe(0); + + cuts[1].Render(); + + renderSub.RenderCount.ShouldBe(3); + wrapperSub.RenderCount.ShouldBe(2); + cutSub1.RenderCount.ShouldBe(1); + cutSub2.RenderCount.ShouldBe(1); + } } } diff --git a/tests/SampleComponents/LifeCycleTracker.razor b/tests/SampleComponents/LifeCycleTracker.razor index 815f62b54..18d7d53a9 100644 --- a/tests/SampleComponents/LifeCycleTracker.razor +++ b/tests/SampleComponents/LifeCycleTracker.razor @@ -1,7 +1,9 @@ @implements IDisposable +

+ +@code { + public int Counter { get; set; } + + [Parameter] + public bool HandleClicks { get; set; } = true; + + void IncrementCount() { Counter++; } +} \ No newline at end of file diff --git a/tests/SampleComponents/TwoComponentWrapper.razor b/tests/SampleComponents/TwoComponentWrapper.razor new file mode 100644 index 000000000..44c685617 --- /dev/null +++ b/tests/SampleComponents/TwoComponentWrapper.razor @@ -0,0 +1,6 @@ +
@First
+
@Second
+@code { + [Parameter] public RenderFragment? First { get; set; } + [Parameter] public RenderFragment? Second { get; set; } +} \ No newline at end of file diff --git a/tests/TestContextTest.cs b/tests/TestRendererTest.cs similarity index 58% rename from tests/TestContextTest.cs rename to tests/TestRendererTest.cs index 2941adf4f..a63224c20 100644 --- a/tests/TestContextTest.cs +++ b/tests/TestRendererTest.cs @@ -11,14 +11,18 @@ namespace Bunit { - public class TestContextTest : ComponentTestFixture + public class TestRendererTest : ComponentTestFixture { - [Fact] - public void MyTestMethod() + [Fact(DisplayName = "Renderer pushes render events to subscribers when renders occur")] + public void Test001() { - using var res = new RenderEventSubscriber(Renderer.RenderEvents); + var res = new RenderEventSubscriber(Renderer.RenderEvents); var sut = RenderComponent(); + + res.RenderCount.ShouldBe(1); + sut.Find("button").Click(); + res.RenderCount.ShouldBe(2); } From 063d6e7a67e1f63772e42011d50adb4baaaea3d8 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 17 Feb 2020 10:29:46 +0000 Subject: [PATCH 06/27] VerifyAsyncChanges added --- src/Extensions/Internal/TimeSpanExtensions.cs | 26 +++++++++ src/Extensions/RenderedFragmentExtensions.cs | 53 ++++++++++++++++++- src/Extensions/TestContextExtensions.cs | 10 ++-- src/Extensions/Xunit/XunitLogger.cs | 42 +++++++++++++++ src/Extensions/Xunit/XunitLoggerExtensions.cs | 24 +++++++++ src/Extensions/Xunit/XunitLoggerFactory.cs | 16 ++++++ src/Extensions/Xunit/XunitLoggerProvider.cs | 34 ++++++++++++ .../HasChangesRenderEventSubscriber.cs | 28 ++++++++++ src/Rendering/RenderEvent.cs | 50 +++++++++-------- src/Rendering/RenderEventSubscriber.cs | 40 ++++++-------- src/Rendering/RenderedFragmentBase.cs | 10 ++-- src/Rendering/TestRenderer.cs | 1 + src/bunit.csproj | 2 + tests/Rendering/RenderedFragmentTest.cs | 41 +++++++++++++- .../TwoRendersTwoChanges.razor | 38 +++++++++++++ 15 files changed, 357 insertions(+), 58 deletions(-) create mode 100644 src/Extensions/Internal/TimeSpanExtensions.cs create mode 100644 src/Extensions/Xunit/XunitLogger.cs create mode 100644 src/Extensions/Xunit/XunitLoggerExtensions.cs create mode 100644 src/Extensions/Xunit/XunitLoggerFactory.cs create mode 100644 src/Extensions/Xunit/XunitLoggerProvider.cs create mode 100644 src/Rendering/HasChangesRenderEventSubscriber.cs create mode 100644 tests/SampleComponents/TwoRendersTwoChanges.razor diff --git a/src/Extensions/Internal/TimeSpanExtensions.cs b/src/Extensions/Internal/TimeSpanExtensions.cs new file mode 100644 index 000000000..4c547921f --- /dev/null +++ b/src/Extensions/Internal/TimeSpanExtensions.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Bunit +{ + /// + /// Helper methods for working with . + /// + internal static class TimeSpanExtensions + { + /// + /// Returns a timeout time as a , set to + /// if , or the provided if it is not null. + /// If it is null, the default of one second is used. + /// + public static TimeSpan GetRuntimeTimeout(this TimeSpan? timeout) + { + return Debugger.IsAttached ? Timeout.InfiniteTimeSpan : timeout ?? TimeSpan.FromSeconds(1); + } + } +} diff --git a/src/Extensions/RenderedFragmentExtensions.cs b/src/Extensions/RenderedFragmentExtensions.cs index 21d90db54..d37e7439d 100644 --- a/src/Extensions/RenderedFragmentExtensions.cs +++ b/src/Extensions/RenderedFragmentExtensions.cs @@ -1,10 +1,61 @@ -namespace Bunit +using System; +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Diagnostics.CodeAnalysis; + +namespace Bunit { /// /// Helper methods for . /// public static class RenderedFragmentExtensions { + /// + /// Uses the provided action to verify + /// that an expected change has occurred in the + /// without the specified (default is one second). + /// + /// + /// + /// + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")] + public static void VerifyAsyncChanges(this IRenderedFragment renderedFragment, Action verification, TimeSpan? timeout = null) + { + if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + if (verification is null) throw new ArgumentNullException(nameof(verification)); + + const int FAILING = 0; + const int PASSED = 1; + + var spinTime = timeout.GetRuntimeTimeout(); + var failure = default(Exception); + var status = FAILING; + + using var rvs = new HasChangesRenderEventSubscriber(renderedFragment, TryVerification); + + TryVerification(); + if (status == PASSED) return; + + SpinWait.SpinUntil(ShouldSpin, spinTime); + + if (status != PASSED && failure is { }) throw failure; + + void TryVerification(RenderEvent _ = default) + { + try + { + verification(); + status = PASSED; + failure = null; + } + catch (Exception e) + { + failure = e; + } + } + bool ShouldSpin() => status != FAILING || rvs.IsCompleted; + } } } diff --git a/src/Extensions/TestContextExtensions.cs b/src/Extensions/TestContextExtensions.cs index 3d1c21de1..116b22d44 100644 --- a/src/Extensions/TestContextExtensions.cs +++ b/src/Extensions/TestContextExtensions.cs @@ -24,15 +24,17 @@ public static void WaitForNextRender(this ITestContext testContext, Action? rend { if (testContext is null) throw new ArgumentNullException(nameof(testContext)); - var waitTime = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : timeout ?? TimeSpan.FromSeconds(1); + var waitTime = timeout.GetRuntimeTimeout(); + var rvs = new RenderEventSubscriber(testContext.Renderer.RenderEvents); + try { renderTrigger?.Invoke(); - if (rvs.RenderCount >= 1) return; + if (rvs.RenderCount > 0) return; - if (SpinWait.SpinUntil(() => rvs.RenderCount >= 1, waitTime)) + if (SpinWait.SpinUntil(ShouldSpin, waitTime) && rvs.RenderCount > 0) return; else throw new TimeoutException("No render occurred within the timeout period."); @@ -41,6 +43,8 @@ public static void WaitForNextRender(this ITestContext testContext, Action? rend { rvs.Unsubscribe(); } + + bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; } } diff --git a/src/Extensions/Xunit/XunitLogger.cs b/src/Extensions/Xunit/XunitLogger.cs new file mode 100644 index 000000000..13efd8171 --- /dev/null +++ b/src/Extensions/Xunit/XunitLogger.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Bunit.Extensions.Xunit +{ + /// + /// Represents a that will write logs to the provided . + /// + public class XunitLogger : ILogger + { + private readonly ITestOutputHelper _output; + private readonly string _name; + private readonly LogLevel _minimumLogLevel; + + /// + public XunitLogger(ITestOutputHelper output, string name, LogLevel minimumLogLevel) + { + _output = output; + _name = name; + _minimumLogLevel = minimumLogLevel; + } + + /// + public IDisposable BeginScope(TState state) => throw new NotImplementedException("Scoped logging is not supported by XunitLogger."); + + /// + public bool IsEnabled(LogLevel logLevel) => logLevel >= _minimumLogLevel; + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (!IsEnabled(logLevel)) return; + if (formatter is null) return; + _output.WriteLine($"{logLevel} | {_name} | {eventId.Id}:{eventId.Name} | {formatter(state, exception)}"); + } + } +} diff --git a/src/Extensions/Xunit/XunitLoggerExtensions.cs b/src/Extensions/Xunit/XunitLoggerExtensions.cs new file mode 100644 index 000000000..bef80bd7e --- /dev/null +++ b/src/Extensions/Xunit/XunitLoggerExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Bunit.Extensions.Xunit +{ + /// + /// Helper method for registering the xUnit test logger. + /// + public static class XunitLoggerExtensions + { + /// + /// Adds the xUnit Logger to the service collection. All log statements logged during a test, + /// matching the specified (default ), + /// will be available in the output from each unit tests. + /// + public static IServiceCollection AddXunitLogger(this IServiceCollection services, ITestOutputHelper testOutput, LogLevel minimumLogLevel = LogLevel.Debug) + { + services.AddSingleton(srv => new XunitLoggerProvider(testOutput, minimumLogLevel)); + services.AddSingleton(); + return services; + } + } +} diff --git a/src/Extensions/Xunit/XunitLoggerFactory.cs b/src/Extensions/Xunit/XunitLoggerFactory.cs new file mode 100644 index 000000000..ae38fa3a1 --- /dev/null +++ b/src/Extensions/Xunit/XunitLoggerFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + +namespace Bunit.Extensions.Xunit +{ + /// + /// Represents a xUnit logger factory + /// + public class XunitLoggerFactory : LoggerFactory + { + /// + public XunitLoggerFactory(XunitLoggerProvider xunitLoggerProvider) + { + AddProvider(xunitLoggerProvider); + } + } +} diff --git a/src/Extensions/Xunit/XunitLoggerProvider.cs b/src/Extensions/Xunit/XunitLoggerProvider.cs new file mode 100644 index 000000000..d525519ad --- /dev/null +++ b/src/Extensions/Xunit/XunitLoggerProvider.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Bunit.Extensions.Xunit +{ + /// + /// Represents an for logging to . + /// + public sealed class XunitLoggerProvider : ILoggerProvider + { + private readonly ITestOutputHelper _output; + private readonly LogLevel _minimumLogLevel; + + /// + /// Creates an instance of the . + /// + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minimumLogLevel = LogLevel.Debug) + { + _output = output; + _minimumLogLevel = minimumLogLevel; + } + + /// + public ILogger CreateLogger(string categoryName) => new XunitLogger(_output, categoryName, _minimumLogLevel); + + /// + public void Dispose() { } + } +} diff --git a/src/Rendering/HasChangesRenderEventSubscriber.cs b/src/Rendering/HasChangesRenderEventSubscriber.cs new file mode 100644 index 000000000..99957dfe0 --- /dev/null +++ b/src/Rendering/HasChangesRenderEventSubscriber.cs @@ -0,0 +1,28 @@ +using System; + +namespace Bunit +{ + /// + public sealed class HasChangesRenderEventSubscriber : RenderEventSubscriber, IDisposable + { + private readonly IRenderedFragment _testTarget; + + /// + /// Creates an instance of the . + /// + public HasChangesRenderEventSubscriber(IRenderedFragment testTarget, Action? onRender = null, Action? onCompleted = null) + : base((testTarget ?? throw new ArgumentNullException(nameof(testTarget))).RenderEvents, onRender, onCompleted) + { + _testTarget = testTarget; + } + + /// + public override void OnNext(RenderEvent value) + { + if (value.HasChangesTo(_testTarget)) + base.OnNext(value); + } + /// + public void Dispose() => Unsubscribe(); + } +} diff --git a/src/Rendering/RenderEvent.cs b/src/Rendering/RenderEvent.cs index fb4743a18..6fcf95475 100644 --- a/src/Rendering/RenderEvent.cs +++ b/src/Rendering/RenderEvent.cs @@ -25,39 +25,29 @@ public RenderEvent(in RenderBatch renderBatch, TestRenderer renderer) } /// - /// Checks whether the component with or one or more of + /// Checks whether the or one or more of /// its sub components was changed during the . /// - /// Id of component to check for updates to. + /// Component to check for updates to. /// True if contains updates to component, false otherwise. - public bool HasChangesTo(int componentId) - { - for (int i = 0; i < RenderBatch.UpdatedComponents.Count; i++) - { - var update = RenderBatch.UpdatedComponents.Array[i]; - if (update.ComponentId == componentId && update.Edits.Count > 0) - return true; - } - for (int i = 0; i < RenderBatch.DisposedEventHandlerIDs.Count; i++) - { - if (RenderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) - return true; - } - return HasChangedToChildren(_renderer.GetCurrentRenderTreeFrames(componentId)); - } + public bool HasChangesTo(IRenderedFragment renderedFragment) + => HasChangesTo((renderedFragment ?? throw new ArgumentNullException(nameof(renderedFragment))).ComponentId); /// - /// Checks whether the component with or one or more of + /// Checks whether the or one or more of /// its sub components was rendered during the . /// - /// Id of component to check if rendered. + /// Component to check if rendered. /// True if the component or a sub component rendered, false otherwise. - public bool DidComponentRender(int componentId) + public bool DidComponentRender(IRenderedFragment renderedFragment) + => DidComponentRender((renderedFragment ?? throw new ArgumentNullException(nameof(renderedFragment))).ComponentId); + + private bool HasChangesTo(int componentId) { for (int i = 0; i < RenderBatch.UpdatedComponents.Count; i++) { var update = RenderBatch.UpdatedComponents.Array[i]; - if (update.ComponentId == componentId) + if (update.ComponentId == componentId && update.Edits.Count > 0) return true; } for (int i = 0; i < RenderBatch.DisposedEventHandlerIDs.Count; i++) @@ -65,7 +55,7 @@ public bool DidComponentRender(int componentId) if (RenderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) return true; } - return DidChildComponentRender(_renderer.GetCurrentRenderTreeFrames(componentId)); + return HasChangedToChildren(_renderer.GetCurrentRenderTreeFrames(componentId)); } private bool HasChangedToChildren(ArrayRange componentRenderTreeFrames) @@ -80,6 +70,22 @@ private bool HasChangedToChildren(ArrayRange componentRenderTre return false; } + private bool DidComponentRender(int componentId) + { + for (int i = 0; i < RenderBatch.UpdatedComponents.Count; i++) + { + var update = RenderBatch.UpdatedComponents.Array[i]; + if (update.ComponentId == componentId) + return true; + } + for (int i = 0; i < RenderBatch.DisposedEventHandlerIDs.Count; i++) + { + if (RenderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) + return true; + } + return DidChildComponentRender(_renderer.GetCurrentRenderTreeFrames(componentId)); + } + private bool DidChildComponentRender(ArrayRange componentRenderTreeFrames) { for (int i = 0; i < componentRenderTreeFrames.Count; i++) diff --git a/src/Rendering/RenderEventSubscriber.cs b/src/Rendering/RenderEventSubscriber.cs index 92583bc1a..b5ac027da 100644 --- a/src/Rendering/RenderEventSubscriber.cs +++ b/src/Rendering/RenderEventSubscriber.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Bunit { @@ -10,9 +6,11 @@ namespace Bunit /// Represents a subscriber to s, published by /// the . ///
- public sealed class RenderEventSubscriber : IObserver + public class RenderEventSubscriber : IObserver { - private IDisposable _unsubscriber; + private readonly IDisposable _unsubscriber; + private readonly Action? _onRender; + private readonly Action? _onCompleted; /// /// Gets the number of renders that have occurred since subscribing. @@ -30,24 +28,18 @@ public sealed class RenderEventSubscriber : IObserver /// public RenderEvent? LatestRenderEvent { get; private set; } - /// - /// Gets or sets a callback to invoke when a is received. - /// - public Action? OnRender { get; set; } - - /// - /// Gets or sets a callback to invoke when the is - /// disposed and no more renders will happen. - /// - public Action? OnCompleted { get; set; } - /// /// Creates an instance of the , and /// subscribes to the provided . /// - public RenderEventSubscriber(IObservable observable) + /// The observable to observe. + /// A callback to invoke when a is received. + /// A callback to invoke when no more renders will happen. + public RenderEventSubscriber(IObservable observable, Action? onRender = null, Action? onCompleted = null) { if (observable is null) throw new ArgumentNullException(nameof(observable)); + _onRender = onRender; + _onCompleted = onCompleted; _unsubscriber = observable.Subscribe(this); } @@ -60,22 +52,22 @@ public void Unsubscribe() } /// - void IObserver.OnNext(RenderEvent value) + public virtual void OnNext(RenderEvent value) { RenderCount += 1; LatestRenderEvent = value; - OnRender?.Invoke(value); + _onRender?.Invoke(value); } /// - void IObserver.OnCompleted() + public virtual void OnCompleted() { IsCompleted = true; - OnCompleted?.Invoke(); + _onCompleted?.Invoke(); } /// - void IObserver.OnError(Exception error) - => throw new AggregateException("The renderer throw an error", error); + public virtual void OnError(Exception exception) + => throw new AggregateException("The renderer throw an error", exception); } } diff --git a/src/Rendering/RenderedFragmentBase.cs b/src/Rendering/RenderedFragmentBase.cs index c48a8b733..af38c02f7 100644 --- a/src/Rendering/RenderedFragmentBase.cs +++ b/src/Rendering/RenderedFragmentBase.cs @@ -82,10 +82,7 @@ protected RenderedFragmentBase(ITestContext testContext, ContainerComponent cont TestContext = testContext; Container = container; RenderEvents = new RenderEventFilter(testContext.Renderer.RenderEvents, RenderFilter); - _renderEventSubscriber = new RenderEventSubscriber(testContext.Renderer.RenderEvents) - { - OnRender = ComponentRendered - }; + _renderEventSubscriber = new RenderEventSubscriber(testContext.Renderer.RenderEvents, ComponentRendered); } /// @@ -133,11 +130,12 @@ public IReadOnlyList GetChangesSinceFirstRender() return Nodes.CompareTo(_firstRenderNodes); } - private bool RenderFilter(RenderEvent renderEvent) => renderEvent.DidComponentRender(ComponentId); + private bool RenderFilter(RenderEvent renderEvent) + => renderEvent.DidComponentRender(this); private void ComponentRendered(RenderEvent renderEvent) { - if (renderEvent.HasChangesTo(ComponentId)) + if (renderEvent.HasChangesTo(this)) { ResetLatestRenderCache(); } diff --git a/src/Rendering/TestRenderer.cs b/src/Rendering/TestRenderer.cs index 97e6b3516..38e29f10e 100644 --- a/src/Rendering/TestRenderer.cs +++ b/src/Rendering/TestRenderer.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Runtime.ExceptionServices; using System.Threading.Tasks; +using System.Threading; namespace Bunit { diff --git a/src/bunit.csproj b/src/bunit.csproj index e33bc5848..aad2bfd66 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -41,6 +41,8 @@ + + diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index 62ebb729c..b0486b996 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -1,4 +1,5 @@ -using Bunit.Mocking.JSInterop; +using Bunit.Extensions.Xunit; +using Bunit.Mocking.JSInterop; using Bunit.SampleComponents; using Bunit.SampleComponents.Data; using Microsoft.AspNetCore.Components; @@ -9,12 +10,18 @@ using System.Linq; using System.Text; using Xunit; +using Xunit.Abstractions; using Xunit.Sdk; namespace Bunit { public class RenderedFragmentTest : ComponentTestFixture { + public RenderedFragmentTest(ITestOutputHelper output) + { + Services.AddXunitLogger(output); + } + [Fact(DisplayName = "Find throws an exception if no element matches the css selector")] public void Test001() { @@ -70,7 +77,7 @@ public void Test005() var cut = RenderComponent(ChildContent()); var initialValue = cut.Nodes; - WaitForNextRender(() => invocation.SetResult("Steve Sanderson"), TimeSpan.FromDays(1)); + WaitForNextRender(() => invocation.SetResult("Steve Sanderson"), TimeSpan.FromSeconds(2)); Assert.NotSame(initialValue, cut.Nodes); } @@ -210,6 +217,36 @@ public void Test010() cutSub1.RenderCount.ShouldBe(1); cutSub2.RenderCount.ShouldBe(1); } + + [Fact] + public void CanTriggerAsyncEventHandlers() + { + // Initial state is stopped + var cut = RenderComponent(); + var stateElement = cut.Find("#state"); + stateElement.TextContent.ShouldBe("Stopped"); + + // Clicking 'tick' changes the state, and starts a task + cut.Find("#tick").Click(); + cut.Find("#state").TextContent.ShouldBe("Started"); + + // Clicking 'tock' completes the task, which updates the state + // This click causes two renders, thus something is needed to await here. + cut.Find("#tock").Click(); + cut.VerifyAsyncChanges( + () => cut.Find("#state").TextContent.ShouldBe("Stopped") + ); + } + + [Fact(DisplayName = "VerifyAyncChanges throws verification exception after timeout")] + public void Test011() + { + var cut = RenderComponent(); + + Should.Throw(() => + cut.VerifyAsyncChanges(() => cut.Markup.ShouldBeEmpty(), TimeSpan.FromMilliseconds(100)) + ); + } } } diff --git a/tests/SampleComponents/TwoRendersTwoChanges.razor b/tests/SampleComponents/TwoRendersTwoChanges.razor new file mode 100644 index 000000000..bbef04657 --- /dev/null +++ b/tests/SampleComponents/TwoRendersTwoChanges.razor @@ -0,0 +1,38 @@ +@using System.Threading.Tasks + +
+ @state + + +
+ +@code +{ + bool tockEnabled = false; + TaskCompletionSource? _tcs; + string state = "Stopped"; + + Task Tick(MouseEventArgs e) + { + if (_tcs is null) + { + _tcs = new TaskCompletionSource(); + + state = "Started"; + tockEnabled = true; + return _tcs.Task.ContinueWith((task) => + { + state = "Stopped"; + _tcs = null; + }); + } + + return Task.CompletedTask; + } + + void Tock(MouseEventArgs e) + { + tockEnabled = false; + _tcs?.TrySetResult(null); + } +} \ No newline at end of file From 62f2acfbfee6d91618daae0accf1f97df8241bae Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 17 Feb 2020 10:33:52 +0000 Subject: [PATCH 07/27] Moved files around --- ...s.cs => AsyncRenderingHelperExtensions.cs} | 40 ++++++++++++-- src/Extensions/TestContextExtensions.cs | 52 ------------------- 2 files changed, 37 insertions(+), 55 deletions(-) rename src/Extensions/{RenderedFragmentExtensions.cs => AsyncRenderingHelperExtensions.cs} (51%) delete mode 100644 src/Extensions/TestContextExtensions.cs diff --git a/src/Extensions/RenderedFragmentExtensions.cs b/src/Extensions/AsyncRenderingHelperExtensions.cs similarity index 51% rename from src/Extensions/RenderedFragmentExtensions.cs rename to src/Extensions/AsyncRenderingHelperExtensions.cs index d37e7439d..4569430f1 100644 --- a/src/Extensions/RenderedFragmentExtensions.cs +++ b/src/Extensions/AsyncRenderingHelperExtensions.cs @@ -7,10 +7,45 @@ namespace Bunit { /// - /// Helper methods for . + /// Helper methods dealing with async rendering during testing. /// - public static class RenderedFragmentExtensions + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")] + public static class AsyncRenderingHelperExtensions { + /// + /// Executes the provided action and waits for a render to occur. + /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. + /// + /// The context to wait against. + /// The action that somehow causes one or more components to render. + /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. + public static void WaitForNextRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) + { + if (testContext is null) throw new ArgumentNullException(nameof(testContext)); + + var waitTime = timeout.GetRuntimeTimeout(); + + var rvs = new RenderEventSubscriber(testContext.Renderer.RenderEvents); + + try + { + renderTrigger?.Invoke(); + + if (rvs.RenderCount > 0) return; + + if (SpinWait.SpinUntil(ShouldSpin, waitTime) && rvs.RenderCount > 0) + return; + else + throw new TimeoutException("No render occurred within the timeout period."); + } + finally + { + rvs.Unsubscribe(); + } + + bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; + } + /// /// Uses the provided action to verify /// that an expected change has occurred in the @@ -19,7 +54,6 @@ public static class RenderedFragmentExtensions /// /// /// - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")] public static void VerifyAsyncChanges(this IRenderedFragment renderedFragment, Action verification, TimeSpan? timeout = null) { if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); diff --git a/src/Extensions/TestContextExtensions.cs b/src/Extensions/TestContextExtensions.cs deleted file mode 100644 index 116b22d44..000000000 --- a/src/Extensions/TestContextExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Bunit -{ - /// - /// Helper methods for . - /// - public static class TestContextExtensions - { - /// - /// Executes the provided action and waits for a render to occur. - /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. - /// - /// The context to wait against. - /// The action that somehow causes one or more components to render. - /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. - public static void WaitForNextRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) - { - if (testContext is null) throw new ArgumentNullException(nameof(testContext)); - - var waitTime = timeout.GetRuntimeTimeout(); - - var rvs = new RenderEventSubscriber(testContext.Renderer.RenderEvents); - - try - { - renderTrigger?.Invoke(); - - if (rvs.RenderCount > 0) return; - - if (SpinWait.SpinUntil(ShouldSpin, waitTime) && rvs.RenderCount > 0) - return; - else - throw new TimeoutException("No render occurred within the timeout period."); - } - finally - { - rvs.Unsubscribe(); - } - - bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; - } - } - - -} From ca28b0cd11269a8f0cc9dd6df3670ed52f757bbb Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 17 Feb 2020 10:51:51 +0000 Subject: [PATCH 08/27] Added WaitForState helper --- src/ComponentTestFixture.cs | 2 +- .../AsyncRenderingHelperExtensions.cs | 46 +++++++++++++++++-- .../HasChangesRenderEventSubscriber.cs | 4 +- tests/Rendering/RenderedFragmentTest.cs | 34 +++++++++++++- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index 024df6335..b1b05994e 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -22,7 +22,7 @@ public abstract class ComponentTestFixture : TestContext /// Thrown when the next render did not happen within the specified . protected void WaitForNextRender(Action? renderTrigger = null, TimeSpan? timeout = null) { - TestContextExtensions.WaitForNextRender(this, renderTrigger, timeout); + AsyncRenderingHelperExtensions.WaitForNextRender(this, renderTrigger, timeout); } /// diff --git a/src/Extensions/AsyncRenderingHelperExtensions.cs b/src/Extensions/AsyncRenderingHelperExtensions.cs index 4569430f1..0dd8b60d2 100644 --- a/src/Extensions/AsyncRenderingHelperExtensions.cs +++ b/src/Extensions/AsyncRenderingHelperExtensions.cs @@ -46,14 +46,50 @@ public static void WaitForNextRender(this ITestContext testContext, Action? rend bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; } + + /// - /// Uses the provided action to verify - /// that an expected change has occurred in the - /// without the specified (default is one second). + /// Uses the provided action to verify + /// that an expected state has been reached in the + /// within the specified (default is one second). /// /// - /// + /// /// + public static void WaitForState(this IRenderedFragment renderedFragment, Func statePredicate, TimeSpan? timeout = null) + { + if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + if (statePredicate is null) throw new ArgumentNullException(nameof(statePredicate)); + + var spinTime = timeout.GetRuntimeTimeout(); + var predicateResult = false; + + var rvs = new RenderEventSubscriber(renderedFragment.RenderEvents, onRender: TryPredicate); + try + { + predicateResult = statePredicate(); + if (predicateResult) return; + + SpinWait.SpinUntil(ShouldSpin, spinTime); + + if (!predicateResult) throw new TimeoutException("The predicate did not pass within the timeout period."); + } + finally + { + rvs.Unsubscribe(); + } + bool ShouldSpin() => predicateResult || rvs.IsCompleted; + void TryPredicate(RenderEvent _ = default) => predicateResult = statePredicate(); + } + + /// + /// Uses the provided action to verify + /// that an expected change has occurred in the + /// within the specified (default is one second). + /// + /// The rendered component or fragment to verify against. + /// The verification or assertion to perform. + /// The maximum time to attempt the verification. public static void VerifyAsyncChanges(this IRenderedFragment renderedFragment, Action verification, TimeSpan? timeout = null) { if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); @@ -66,7 +102,7 @@ public static void VerifyAsyncChanges(this IRenderedFragment renderedFragment, A var failure = default(Exception); var status = FAILING; - using var rvs = new HasChangesRenderEventSubscriber(renderedFragment, TryVerification); + using var rvs = new HasChangesRenderEventSubscriber(renderedFragment, onChange: TryVerification); TryVerification(); if (status == PASSED) return; diff --git a/src/Rendering/HasChangesRenderEventSubscriber.cs b/src/Rendering/HasChangesRenderEventSubscriber.cs index 99957dfe0..ae8467682 100644 --- a/src/Rendering/HasChangesRenderEventSubscriber.cs +++ b/src/Rendering/HasChangesRenderEventSubscriber.cs @@ -10,8 +10,8 @@ public sealed class HasChangesRenderEventSubscriber : RenderEventSubscriber, IDi /// /// Creates an instance of the . /// - public HasChangesRenderEventSubscriber(IRenderedFragment testTarget, Action? onRender = null, Action? onCompleted = null) - : base((testTarget ?? throw new ArgumentNullException(nameof(testTarget))).RenderEvents, onRender, onCompleted) + public HasChangesRenderEventSubscriber(IRenderedFragment testTarget, Action? onChange = null, Action? onCompleted = null) + : base((testTarget ?? throw new ArgumentNullException(nameof(testTarget))).RenderEvents, onChange, onCompleted) { _testTarget = testTarget; } diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index b0486b996..13061240a 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -218,8 +218,8 @@ public void Test010() cutSub2.RenderCount.ShouldBe(1); } - [Fact] - public void CanTriggerAsyncEventHandlers() + [Fact(DisplayName = "VerifyAsyncChanges can wait for multiple renders and changes to occur")] + public void Test110() { // Initial state is stopped var cut = RenderComponent(); @@ -247,6 +247,36 @@ public void Test011() cut.VerifyAsyncChanges(() => cut.Markup.ShouldBeEmpty(), TimeSpan.FromMilliseconds(100)) ); } + + [Fact(DisplayName = "WaitForState throws TimeoutException exception after timeout")] + public void Test012() + { + var cut = RenderComponent(); + + Should.Throw(() => + cut.WaitForState(() => string.IsNullOrEmpty(cut.Markup), TimeSpan.FromMilliseconds(100)) + ); + } + + [Fact(DisplayName = "WaitForState can wait for multiple renders and changes to occur")] + public void Test013() + { + // Initial state is stopped + var cut = RenderComponent(); + var stateElement = cut.Find("#state"); + stateElement.TextContent.ShouldBe("Stopped"); + + // Clicking 'tick' changes the state, and starts a task + cut.Find("#tick").Click(); + cut.Find("#state").TextContent.ShouldBe("Started"); + + // Clicking 'tock' completes the task, which updates the state + // This click causes two renders, thus something is needed to await here. + cut.Find("#tock").Click(); + cut.WaitForState(() => cut.Find("#state").TextContent == "Stopped"); + cut.Find("#state").TextContent.ShouldBe("Stopped"); + } + } } From 1ffb37d0dde87132bfdaaa4c5a7eb0449c9c82c9 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 17 Feb 2020 21:52:33 +0000 Subject: [PATCH 09/27] Made checks safe across cpu boundaries. --- .../AsyncRenderingHelperExtensions.cs | 33 +++++++++++++--- src/Rendering/RenderEvent.cs | 38 ++++++------------- tests/GlobalSuppressions.cs | 1 + tests/Rendering/RenderEventPubSubTest.cs | 10 +++-- 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/src/Extensions/AsyncRenderingHelperExtensions.cs b/src/Extensions/AsyncRenderingHelperExtensions.cs index 0dd8b60d2..ea95f088b 100644 --- a/src/Extensions/AsyncRenderingHelperExtensions.cs +++ b/src/Extensions/AsyncRenderingHelperExtensions.cs @@ -33,6 +33,9 @@ public static void WaitForNextRender(this ITestContext testContext, Action? rend if (rvs.RenderCount > 0) return; + // RenderEventSubscriber (rvs) receive render events on the renderer's thread, where as + // the WaitForNextRender is started from the test runners thread. + // Thus it is safe to SpinWait on the test thread and wait for the RenderCount to go above 0. if (SpinWait.SpinUntil(ShouldSpin, waitTime) && rvs.RenderCount > 0) return; else @@ -67,9 +70,18 @@ public static void WaitForState(this IRenderedFragment renderedFragment, Func predicateResult || rvs.IsCompleted; - void TryPredicate(RenderEvent _ = default) => predicateResult = statePredicate(); + bool ShouldSpin() => Volatile.Read(ref predicateResult) || rvs.IsCompleted; + void TryPredicate(RenderEvent _ = default!) => Volatile.Write(ref predicateResult, statePredicate()); } /// @@ -107,16 +119,25 @@ public static void VerifyAsyncChanges(this IRenderedFragment renderedFragment, A TryVerification(); if (status == PASSED) return; + // HasChangesRenderEventSubscriber (rvs) receive render events on the renderer's thread, where as + // the VerifyAsyncChanges is started from the test runners thread. + // Thus it is safe to SpinWait on the test thread and wait for verification to pass. + // When a render event is received by rvs, the verification action will execute on the + // renderer thread. + // + // Therefore, we use Volatile.Read/Volatile.Write in the helper methods below to ensure + // that an update to the variable status is not cached in a local CPU, and + // not available in a secondary CPU, if the two threads are running on a different CPUs SpinWait.SpinUntil(ShouldSpin, spinTime); if (status != PASSED && failure is { }) throw failure; - void TryVerification(RenderEvent _ = default) + void TryVerification(RenderEvent _ = default!) { try { verification(); - status = PASSED; + Volatile.Write(ref status, PASSED); failure = null; } catch (Exception e) @@ -125,7 +146,7 @@ void TryVerification(RenderEvent _ = default) } } - bool ShouldSpin() => status != FAILING || rvs.IsCompleted; + bool ShouldSpin() => Volatile.Read(ref status) != FAILING || rvs.IsCompleted; } } } diff --git a/src/Rendering/RenderEvent.cs b/src/Rendering/RenderEvent.cs index 6fcf95475..2812a8962 100644 --- a/src/Rendering/RenderEvent.cs +++ b/src/Rendering/RenderEvent.cs @@ -6,21 +6,22 @@ namespace Bunit /// /// Represents a render event for a or generally from the . /// - public readonly struct RenderEvent : IEquatable + public sealed class RenderEvent { private readonly TestRenderer _renderer; + private readonly RenderBatch _renderBatch; /// /// Gets the related from the render. /// - public RenderBatch RenderBatch { get; } + public ref readonly RenderBatch RenderBatch => ref _renderBatch; /// /// Creates an instance of the type. /// public RenderEvent(in RenderBatch renderBatch, TestRenderer renderer) { - RenderBatch = renderBatch; + _renderBatch = renderBatch; _renderer = renderer; } @@ -44,15 +45,15 @@ public bool DidComponentRender(IRenderedFragment renderedFragment) private bool HasChangesTo(int componentId) { - for (int i = 0; i < RenderBatch.UpdatedComponents.Count; i++) + for (int i = 0; i < _renderBatch.UpdatedComponents.Count; i++) { - var update = RenderBatch.UpdatedComponents.Array[i]; + var update = _renderBatch.UpdatedComponents.Array[i]; if (update.ComponentId == componentId && update.Edits.Count > 0) return true; } - for (int i = 0; i < RenderBatch.DisposedEventHandlerIDs.Count; i++) + for (int i = 0; i < _renderBatch.DisposedEventHandlerIDs.Count; i++) { - if (RenderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) + if (_renderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) return true; } return HasChangedToChildren(_renderer.GetCurrentRenderTreeFrames(componentId)); @@ -72,15 +73,15 @@ private bool HasChangedToChildren(ArrayRange componentRenderTre private bool DidComponentRender(int componentId) { - for (int i = 0; i < RenderBatch.UpdatedComponents.Count; i++) + for (int i = 0; i < _renderBatch.UpdatedComponents.Count; i++) { - var update = RenderBatch.UpdatedComponents.Array[i]; + var update = _renderBatch.UpdatedComponents.Array[i]; if (update.ComponentId == componentId) return true; } - for (int i = 0; i < RenderBatch.DisposedEventHandlerIDs.Count; i++) + for (int i = 0; i < _renderBatch.DisposedEventHandlerIDs.Count; i++) { - if (RenderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) + if (_renderBatch.DisposedEventHandlerIDs.Array[i].Equals(componentId)) return true; } return DidChildComponentRender(_renderer.GetCurrentRenderTreeFrames(componentId)); @@ -97,20 +98,5 @@ private bool DidChildComponentRender(ArrayRange componentRender } return false; } - - /// - public bool Equals(RenderEvent other) => RenderBatch.Equals(other.RenderBatch); - - /// - public override bool Equals(object obj) => obj is RenderEvent other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(RenderBatch); - - /// - public static bool operator ==(RenderEvent left, RenderEvent right) => left.Equals(right); - - /// - public static bool operator !=(RenderEvent left, RenderEvent right) => !left.Equals(right); } } \ No newline at end of file diff --git a/tests/GlobalSuppressions.cs b/tests/GlobalSuppressions.cs index 8a82ddb38..d61cd78fc 100644 --- a/tests/GlobalSuppressions.cs +++ b/tests/GlobalSuppressions.cs @@ -5,3 +5,4 @@ [assembly: SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "In tests its ok to catch the general exception type")] [assembly: SuppressMessage("Usage", "CA2234:Pass system uri objects instead of strings", Justification = "")] +[assembly: SuppressMessage("Usage", "BL0006:Do not use RenderTree types", Justification = "")] diff --git a/tests/Rendering/RenderEventPubSubTest.cs b/tests/Rendering/RenderEventPubSubTest.cs index d9b959b7b..0adcae24d 100644 --- a/tests/Rendering/RenderEventPubSubTest.cs +++ b/tests/Rendering/RenderEventPubSubTest.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Moq; using Shouldly; using Xunit; @@ -16,7 +18,7 @@ public void Test001() var pub = new RenderEventPublisher(); var sub = new RenderEventSubscriber(pub); - pub.OnRender(new RenderEvent()); + pub.OnRender(new RenderEvent(new RenderBatch(), null!)); sub.RenderCount.ShouldBe(1); sub.LatestRenderEvent.ShouldNotBeNull(); @@ -29,7 +31,9 @@ public void Test002() pub.OnCompleted(); - Should.Throw(() => pub.OnRender(new RenderEvent())); + Should.Throw( + () => pub.OnRender(new RenderEvent(new RenderBatch(), null!)) + ); } [Fact(DisplayName = "Calling Unsubscribe on subscriber unsubscribes it from publisher")] @@ -40,7 +44,7 @@ public void Test003() sub.Unsubscribe(); - pub.OnRender(new RenderEvent()); + pub.OnRender(new RenderEvent(new RenderBatch(), null!)); sub.RenderCount.ShouldBe(0); sub.LatestRenderEvent.ShouldBeNull(); From ac1ef0915034b7e8357e89ebe075a6f43f4407a9 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Tue, 18 Feb 2020 21:42:12 +0000 Subject: [PATCH 10/27] Changed VerifyAsyncChange to WaitForAssertion, upgraded dependencies to latest version --- src/Extensions/AsyncRenderingHelperExtensions.cs | 14 ++++++-------- src/bunit.csproj | 9 +++++---- template/template/Company.RazorTests1.csproj | 2 +- tests/Rendering/RenderedFragmentTest.cs | 4 ++-- tests/bunit.tests.csproj | 2 +- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Extensions/AsyncRenderingHelperExtensions.cs b/src/Extensions/AsyncRenderingHelperExtensions.cs index ea95f088b..5c2c7bed6 100644 --- a/src/Extensions/AsyncRenderingHelperExtensions.cs +++ b/src/Extensions/AsyncRenderingHelperExtensions.cs @@ -18,7 +18,7 @@ public static class AsyncRenderingHelperExtensions /// /// The context to wait against. /// The action that somehow causes one or more components to render. - /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. + /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. public static void WaitForNextRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) { if (testContext is null) throw new ArgumentNullException(nameof(testContext)); @@ -49,8 +49,6 @@ public static void WaitForNextRender(this ITestContext testContext, Action? rend bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; } - - /// /// Uses the provided action to verify /// that an expected state has been reached in the @@ -95,17 +93,17 @@ public static void WaitForState(this IRenderedFragment renderedFragment, Func - /// Uses the provided action to verify + /// Uses the provided action to verify/assert /// that an expected change has occurred in the /// within the specified (default is one second). /// /// The rendered component or fragment to verify against. - /// The verification or assertion to perform. + /// The verification or assertion to perform. /// The maximum time to attempt the verification. - public static void VerifyAsyncChanges(this IRenderedFragment renderedFragment, Action verification, TimeSpan? timeout = null) + public static void WaitForAssertion(this IRenderedFragment renderedFragment, Action assertion, TimeSpan? timeout = null) { if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); - if (verification is null) throw new ArgumentNullException(nameof(verification)); + if (assertion is null) throw new ArgumentNullException(nameof(assertion)); const int FAILING = 0; const int PASSED = 1; @@ -136,7 +134,7 @@ void TryVerification(RenderEvent _ = default!) { try { - verification(); + assertion(); Volatile.Write(ref status, PASSED); failure = null; } diff --git a/src/bunit.csproj b/src/bunit.csproj index aad2bfd66..f68385a5e 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -16,6 +16,7 @@ git #{BRANCH}# #{COMMIT}# + 1.0.0-beta-6 https://github.com/egil/razor-components-testing-library bUnit;razor components;blazor components;unit testing;testing blazor components;blazor server;blazor wasm Egil Hansen @@ -41,14 +42,14 @@ - - + + - - + + diff --git a/template/template/Company.RazorTests1.csproj b/template/template/Company.RazorTests1.csproj index 8157e996f..2e2d3fd38 100644 --- a/template/template/Company.RazorTests1.csproj +++ b/template/template/Company.RazorTests1.csproj @@ -8,7 +8,7 @@ - + all diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index 13061240a..93629c78d 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -233,7 +233,7 @@ public void Test110() // Clicking 'tock' completes the task, which updates the state // This click causes two renders, thus something is needed to await here. cut.Find("#tock").Click(); - cut.VerifyAsyncChanges( + cut.WaitForAssertion( () => cut.Find("#state").TextContent.ShouldBe("Stopped") ); } @@ -244,7 +244,7 @@ public void Test011() var cut = RenderComponent(); Should.Throw(() => - cut.VerifyAsyncChanges(() => cut.Markup.ShouldBeEmpty(), TimeSpan.FromMilliseconds(100)) + cut.WaitForAssertion(() => cut.Markup.ShouldBeEmpty(), TimeSpan.FromMilliseconds(100)) ); } diff --git a/tests/bunit.tests.csproj b/tests/bunit.tests.csproj index 9d4766547..6659671f9 100644 --- a/tests/bunit.tests.csproj +++ b/tests/bunit.tests.csproj @@ -20,7 +20,7 @@ all - + From c3defd8923d23d021b082c7a26f9580d544f6eea Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Tue, 18 Feb 2020 21:45:19 +0000 Subject: [PATCH 11/27] Template update --- src/bunit.csproj | 1 - template/template/.template.config/template.json | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bunit.csproj b/src/bunit.csproj index f68385a5e..ec0cfd0e7 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -16,7 +16,6 @@ git #{BRANCH}# #{COMMIT}# - 1.0.0-beta-6 https://github.com/egil/razor-components-testing-library bUnit;razor components;blazor components;unit testing;testing blazor components;blazor server;blazor wasm Egil Hansen diff --git a/template/template/.template.config/template.json b/template/template/.template.config/template.json index ce4fa57b3..4647c838c 100644 --- a/template/template/.template.config/template.json +++ b/template/template/.template.config/template.json @@ -4,8 +4,8 @@ "classifications": [ "Test", "Razor", "Blazor", "Library" ], - "name": "Razor Component Testing Project", - "description": "A project for a testing Blazor/Razor components using the Razor.Components.Testing.Library library.", + "name": "bUnit Testing Project", + "description": "A project for a testing Blazor/Razor components using the bUnit library.", "generatorVersions": "[1.0.0.0-*)", "identity": "BunitProject", "groupIdentity": "Bunit", From 6c7c231a5dd54fce6f7387d88034e2f2bf566c9c Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 24 Feb 2020 14:34:16 +0000 Subject: [PATCH 12/27] Added wrapping, removed http mocking --- src/Diffing/HtmlComparer.cs | 5 +- .../AsyncRenderingHelperExtensions.cs | 2 +- src/Extensions/ElementQueryExtensions.cs | 4 +- .../RenderedFragmentQueryExtensions.cs | 53 +++++++++++++-- src/Mocking/MockHttpExtensions.cs | 66 ------------------- ...r.cs => ComponentChangeEventSubscriber.cs} | 6 +- src/bunit.csproj | 7 +- tests/Mocking/MockHttpExtensionsTest.cs | 56 ---------------- tests/bunit.tests.csproj | 7 -- 9 files changed, 60 insertions(+), 146 deletions(-) delete mode 100644 src/Mocking/MockHttpExtensions.cs rename src/Rendering/{HasChangesRenderEventSubscriber.cs => ComponentChangeEventSubscriber.cs} (65%) delete mode 100644 tests/Mocking/MockHttpExtensionsTest.cs diff --git a/src/Diffing/HtmlComparer.cs b/src/Diffing/HtmlComparer.cs index 1efdd7e37..be7e51019 100644 --- a/src/Diffing/HtmlComparer.cs +++ b/src/Diffing/HtmlComparer.cs @@ -5,6 +5,7 @@ using AngleSharp.Diffing.Core; using Xunit.Abstractions; using Bunit.Diffing; +using AngleSharpWrappers; namespace Bunit.Diffing { @@ -31,7 +32,7 @@ public HtmlComparer() /// public IEnumerable Compare(INode controlHtml, INode testHtml) { - return _differenceEngine.Compare(controlHtml, testHtml); + return _differenceEngine.Compare(controlHtml.Unwrap(), testHtml.Unwrap()); } /// @@ -39,7 +40,7 @@ public IEnumerable Compare(INode controlHtml, INode testHtml) /// public IEnumerable Compare(IEnumerable controlHtml, IEnumerable testHtml) { - return _differenceEngine.Compare(controlHtml, testHtml); + return _differenceEngine.Compare(controlHtml.Unwrap(), testHtml.Unwrap()); } } } \ No newline at end of file diff --git a/src/Extensions/AsyncRenderingHelperExtensions.cs b/src/Extensions/AsyncRenderingHelperExtensions.cs index 5c2c7bed6..6208e779f 100644 --- a/src/Extensions/AsyncRenderingHelperExtensions.cs +++ b/src/Extensions/AsyncRenderingHelperExtensions.cs @@ -112,7 +112,7 @@ public static void WaitForAssertion(this IRenderedFragment renderedFragment, Act var failure = default(Exception); var status = FAILING; - using var rvs = new HasChangesRenderEventSubscriber(renderedFragment, onChange: TryVerification); + using var rvs = new ComponentChangeEventSubscriber(renderedFragment, onChange: TryVerification); TryVerification(); if (status == PASSED) return; diff --git a/src/Extensions/ElementQueryExtensions.cs b/src/Extensions/ElementQueryExtensions.cs index 258a7768b..d0e4c9f65 100644 --- a/src/Extensions/ElementQueryExtensions.cs +++ b/src/Extensions/ElementQueryExtensions.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; +using AngleSharpWrappers; namespace Bunit { @@ -21,7 +22,8 @@ public static class ElementQueryExtensions public static IElement Find(this IElement element, string selector) { if (element is null) throw new ArgumentNullException(nameof(element)); - return element.QuerySelector(selector); + + return element.QuerySelector(selector); } /// diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs index acd318559..b79d696db 100644 --- a/src/Extensions/RenderedFragmentQueryExtensions.cs +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; +using AngleSharpWrappers; using Xunit.Sdk; namespace Bunit @@ -24,10 +26,8 @@ public static IElement Find(this IRenderedFragment renderedFragment, string cssS { if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); var result = renderedFragment.Nodes.QuerySelector(cssSelector); - if (result is null) - throw new ElementNotFoundException(cssSelector); - else - return result; + if (result is null) throw new ElementNotFoundException(cssSelector); + return WrapperFactory.Create(new ElemenFactory(renderedFragment, result, cssSelector)); } /// @@ -43,4 +43,49 @@ public static IHtmlCollection FindAll(this IRenderedFragment renderedF return renderedFragment.Nodes.QuerySelectorAll(cssSelector); } } + + internal sealed class ElemenFactory : RenderEventSubscriber, IElementFactory + where TElement : class, IElement + { + private readonly IRenderedFragment _testTarget; + private readonly string _cssSelector; + private TElement? _element; + + public ElemenFactory(IRenderedFragment testTarget, TElement initialElement, string cssSelector) + : base((testTarget ?? throw new ArgumentNullException(nameof(testTarget))).RenderEvents) + { + _testTarget = testTarget; + _cssSelector = cssSelector; + _element = initialElement; + } + + public override void OnNext(RenderEvent value) + { + if (value.HasChangesTo(_testTarget)) + _element = null; + } + + TElement IElementFactory.GetElement() + { + if (_element is null) + { + var queryResult = _testTarget.Nodes.QuerySelector(_cssSelector); + if(queryResult is TElement element) + _element = element; + } + return _element ?? throw new ElementNotFoundException(); + } + } + + /// + /// Represents an exception that is thrown when a wrapped element is no longer available in the DOM tree. + /// + [SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "")] + public class ElementRemovedException : Exception + { + /// + public ElementRemovedException() : base("The DOM element you tried to access is no longer available in the DOM tree. It has probably been removed after a render.") + { + } + } } diff --git a/src/Mocking/MockHttpExtensions.cs b/src/Mocking/MockHttpExtensions.cs deleted file mode 100644 index a9bb2010d..000000000 --- a/src/Mocking/MockHttpExtensions.cs +++ /dev/null @@ -1,66 +0,0 @@ -using RichardSzalay.MockHttp; -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using System.Text.Json; -using System.Net.Http.Headers; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.DependencyInjection; - -namespace Bunit -{ - /// - /// Helper methods for adding a Mock HTTP client to a service provider. - /// - public static class MockHttpExtensions - { - /// - /// Create a and adds it to the - /// as a . - /// - /// - /// The . - public static MockHttpMessageHandler AddMockHttp(this TestServiceProvider serviceProvider) - { - if (serviceProvider is null) throw new ArgumentNullException(nameof(serviceProvider)); - - var mockHttp = new MockHttpMessageHandler(); - var httpClient = mockHttp.ToHttpClient(); - httpClient.BaseAddress = new Uri("http://example.com"); - serviceProvider.AddSingleton(httpClient); - return mockHttp; - } - - /// - /// Configure a to capture requests to - /// a . - /// - /// - /// Url of requests to capture. - /// A that can be used to send a response to a captured request. - [SuppressMessage("Reliability", "CA2008:Do not create tasks without passing a TaskScheduler", Justification = "")] - [SuppressMessage("Design", "CA1054:Uri parameters should not be strings", Justification = "")] - public static TaskCompletionSource Capture(this MockHttpMessageHandler handler, string url) - { - if (handler is null) throw new ArgumentNullException(nameof(handler)); - - var tcs = new TaskCompletionSource(); - - handler.When(url).Respond(() => - { - return tcs.Task.ContinueWith(task => - { - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(JsonSerializer.Serialize(task.Result)) - }; - response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - return response; - }); - }); - - return tcs; - } - } -} diff --git a/src/Rendering/HasChangesRenderEventSubscriber.cs b/src/Rendering/ComponentChangeEventSubscriber.cs similarity index 65% rename from src/Rendering/HasChangesRenderEventSubscriber.cs rename to src/Rendering/ComponentChangeEventSubscriber.cs index ae8467682..96c86f94f 100644 --- a/src/Rendering/HasChangesRenderEventSubscriber.cs +++ b/src/Rendering/ComponentChangeEventSubscriber.cs @@ -3,14 +3,14 @@ namespace Bunit { /// - public sealed class HasChangesRenderEventSubscriber : RenderEventSubscriber, IDisposable + public sealed class ComponentChangeEventSubscriber : RenderEventSubscriber, IDisposable { private readonly IRenderedFragment _testTarget; /// - /// Creates an instance of the . + /// Creates an instance of the . /// - public HasChangesRenderEventSubscriber(IRenderedFragment testTarget, Action? onChange = null, Action? onCompleted = null) + public ComponentChangeEventSubscriber(IRenderedFragment testTarget, Action? onChange = null, Action? onCompleted = null) : base((testTarget ?? throw new ArgumentNullException(nameof(testTarget))).RenderEvents, onChange, onCompleted) { _testTarget = testTarget; diff --git a/src/bunit.csproj b/src/bunit.csproj index ec0cfd0e7..41da2590c 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -44,17 +44,12 @@ - - - - - - + diff --git a/tests/Mocking/MockHttpExtensionsTest.cs b/tests/Mocking/MockHttpExtensionsTest.cs deleted file mode 100644 index f6ff1d492..000000000 --- a/tests/Mocking/MockHttpExtensionsTest.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using RichardSzalay.MockHttp; -using Shouldly; -using Xunit; - -namespace Bunit -{ - public class MockHttpExtensionsTest - { - [Fact(DisplayName = "AddMockHttp throws if the service provider is null")] - public void Test001() - { - TestServiceProvider provider = default!; - - Should.Throw(() => provider.AddMockHttp()); - } - - [Fact(DisplayName = "AddMockHttp registers a mock HttpClient in the service provider")] - public void Test002() - { - using var provider = new TestServiceProvider(); - - var mock = provider.AddMockHttp(); - - provider.GetService().ShouldNotBeNull(); - } - - [Fact(DisplayName = "Capture throws if the handler is null")] - public void Test003() - { - MockHttpMessageHandler handler = default!; - - Should.Throw(() => handler.Capture("")); - } - - [Fact(DisplayName = "Capture returns a task, that when completed, " + - "provides a response to the captured url")] - public async Task Test004() - { - using var provider = new TestServiceProvider(); - var mock = provider.AddMockHttp(); - var httpClient = provider.GetService(); - var captured = mock.Capture("/ping"); - - captured.SetResult("pong"); - - var actual = await httpClient.GetStringAsync("/ping"); - actual.ShouldBe("\"pong\""); - } - } -} diff --git a/tests/bunit.tests.csproj b/tests/bunit.tests.csproj index 6659671f9..dba02c101 100644 --- a/tests/bunit.tests.csproj +++ b/tests/bunit.tests.csproj @@ -7,13 +7,6 @@ Bunit.Tests - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive From 9fce1bdb96402975bab7dcb205ad2fd247e004bc Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 24 Feb 2020 22:32:24 +0000 Subject: [PATCH 13/27] Added better error messages through custom exceptions --- src/ComponentTestFixture.cs | 8 +- src/Components/TestContextAdapter.cs | 2 +- .../AsyncRenderingHelperExtensions.cs | 150 ------------- src/Extensions/ElementQueryExtensions.cs | 65 ------ src/Extensions/ElementRemovedException.cs | 17 ++ src/Extensions/Internal/ElementFactory.cs | 40 ++++ .../RenderWaitingHelperExtensions.cs | 206 ++++++++++++++++++ .../RenderedFragmentQueryExtensions.cs | 48 +--- .../WaitForAssertionFailedException.cs | 16 ++ src/Extensions/WaitForStateFailedException.cs | 27 +++ .../ComponentChangeEventSubscriber.cs | 4 +- ....cs => ConcurrentRenderEventSubscriber.cs} | 22 +- src/Rendering/RenderedFragmentBase.cs | 4 +- tests/Rendering/RenderEventPubSubTest.cs | 4 +- .../RenderWaitingHelperExtensionsTest.cs | 166 ++++++++++++++ tests/Rendering/RenderedFragmentTest.cs | 119 +--------- tests/SampleComponents/ClickCounter.razor | 6 +- tests/TestRendererTest.cs | 2 +- 18 files changed, 508 insertions(+), 398 deletions(-) delete mode 100644 src/Extensions/AsyncRenderingHelperExtensions.cs delete mode 100644 src/Extensions/ElementQueryExtensions.cs create mode 100644 src/Extensions/ElementRemovedException.cs create mode 100644 src/Extensions/Internal/ElementFactory.cs create mode 100644 src/Extensions/RenderWaitingHelperExtensions.cs create mode 100644 src/Extensions/WaitForAssertionFailedException.cs create mode 100644 src/Extensions/WaitForStateFailedException.cs rename src/Rendering/{RenderEventSubscriber.cs => ConcurrentRenderEventSubscriber.cs} (70%) create mode 100644 tests/Rendering/RenderWaitingHelperExtensionsTest.cs diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index b1b05994e..0ed1409e1 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -18,11 +18,11 @@ public abstract class ComponentTestFixture : TestContext /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. /// /// The action that somehow causes one or more components to render. - /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. - /// Thrown when the next render did not happen within the specified . - protected void WaitForNextRender(Action? renderTrigger = null, TimeSpan? timeout = null) + /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. + /// Thrown if no render happens within the specified , or the default of 1 second, if non is specified. + protected void WaitForRender(Action? renderTrigger = null, TimeSpan? timeout = null) { - AsyncRenderingHelperExtensions.WaitForNextRender(this, renderTrigger, timeout); + RenderWaitingHelperExtensions.WaitForRender(this, renderTrigger, timeout); } /// diff --git a/src/Components/TestContextAdapter.cs b/src/Components/TestContextAdapter.cs index 6558ac103..548cb61d7 100644 --- a/src/Components/TestContextAdapter.cs +++ b/src/Components/TestContextAdapter.cs @@ -62,7 +62,7 @@ public void WaitForNextRender(Action renderTrigger, TimeSpan? timeout = null) if (_testContext is null) throw new InvalidOperationException("No active test context in the adapter"); else - _testContext.WaitForNextRender(renderTrigger, timeout); + _testContext.WaitForRender(renderTrigger, timeout); } public IRenderedComponent RenderComponent(params ComponentParameter[] parameters) where TComponent : class, IComponent diff --git a/src/Extensions/AsyncRenderingHelperExtensions.cs b/src/Extensions/AsyncRenderingHelperExtensions.cs deleted file mode 100644 index 6208e779f..000000000 --- a/src/Extensions/AsyncRenderingHelperExtensions.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Diagnostics; -using System.Runtime.ExceptionServices; -using System.Threading; -using System.Diagnostics.CodeAnalysis; - -namespace Bunit -{ - /// - /// Helper methods dealing with async rendering during testing. - /// - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")] - public static class AsyncRenderingHelperExtensions - { - /// - /// Executes the provided action and waits for a render to occur. - /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. - /// - /// The context to wait against. - /// The action that somehow causes one or more components to render. - /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. - public static void WaitForNextRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) - { - if (testContext is null) throw new ArgumentNullException(nameof(testContext)); - - var waitTime = timeout.GetRuntimeTimeout(); - - var rvs = new RenderEventSubscriber(testContext.Renderer.RenderEvents); - - try - { - renderTrigger?.Invoke(); - - if (rvs.RenderCount > 0) return; - - // RenderEventSubscriber (rvs) receive render events on the renderer's thread, where as - // the WaitForNextRender is started from the test runners thread. - // Thus it is safe to SpinWait on the test thread and wait for the RenderCount to go above 0. - if (SpinWait.SpinUntil(ShouldSpin, waitTime) && rvs.RenderCount > 0) - return; - else - throw new TimeoutException("No render occurred within the timeout period."); - } - finally - { - rvs.Unsubscribe(); - } - - bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; - } - - /// - /// Uses the provided action to verify - /// that an expected state has been reached in the - /// within the specified (default is one second). - /// - /// - /// - /// - public static void WaitForState(this IRenderedFragment renderedFragment, Func statePredicate, TimeSpan? timeout = null) - { - if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); - if (statePredicate is null) throw new ArgumentNullException(nameof(statePredicate)); - - var spinTime = timeout.GetRuntimeTimeout(); - var predicateResult = false; - - var rvs = new RenderEventSubscriber(renderedFragment.RenderEvents, onRender: TryPredicate); - try - { - TryPredicate(); - if (predicateResult) return; - - // RenderEventSubscriber (rvs) receive render events on the renderer's thread, where as - // the WaitForState is started from the test runners thread. - // Thus it is safe to SpinWait on the test thread and wait for statePredicate to pass. - // When a render event is received by rvs, the state predicate will execute on the - // renderer thread. - // - // Therefore, we use Volatile.Read/Volatile.Write in the helper methods below to ensure - // that an update to the variable predicateResult is not cached in a local CPU, and - // not available in a secondary CPU, if the two threads are running on a different CPUs - SpinWait.SpinUntil(ShouldSpin, spinTime); - - if (!predicateResult) throw new TimeoutException("The predicate did not pass within the timeout period."); - } - finally - { - rvs.Unsubscribe(); - } - bool ShouldSpin() => Volatile.Read(ref predicateResult) || rvs.IsCompleted; - void TryPredicate(RenderEvent _ = default!) => Volatile.Write(ref predicateResult, statePredicate()); - } - - /// - /// Uses the provided action to verify/assert - /// that an expected change has occurred in the - /// within the specified (default is one second). - /// - /// The rendered component or fragment to verify against. - /// The verification or assertion to perform. - /// The maximum time to attempt the verification. - public static void WaitForAssertion(this IRenderedFragment renderedFragment, Action assertion, TimeSpan? timeout = null) - { - if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); - if (assertion is null) throw new ArgumentNullException(nameof(assertion)); - - const int FAILING = 0; - const int PASSED = 1; - - var spinTime = timeout.GetRuntimeTimeout(); - var failure = default(Exception); - var status = FAILING; - - using var rvs = new ComponentChangeEventSubscriber(renderedFragment, onChange: TryVerification); - - TryVerification(); - if (status == PASSED) return; - - // HasChangesRenderEventSubscriber (rvs) receive render events on the renderer's thread, where as - // the VerifyAsyncChanges is started from the test runners thread. - // Thus it is safe to SpinWait on the test thread and wait for verification to pass. - // When a render event is received by rvs, the verification action will execute on the - // renderer thread. - // - // Therefore, we use Volatile.Read/Volatile.Write in the helper methods below to ensure - // that an update to the variable status is not cached in a local CPU, and - // not available in a secondary CPU, if the two threads are running on a different CPUs - SpinWait.SpinUntil(ShouldSpin, spinTime); - - if (status != PASSED && failure is { }) throw failure; - - void TryVerification(RenderEvent _ = default!) - { - try - { - assertion(); - Volatile.Write(ref status, PASSED); - failure = null; - } - catch (Exception e) - { - failure = e; - } - } - - bool ShouldSpin() => Volatile.Read(ref status) != FAILING || rvs.IsCompleted; - } - } -} diff --git a/src/Extensions/ElementQueryExtensions.cs b/src/Extensions/ElementQueryExtensions.cs deleted file mode 100644 index d0e4c9f65..000000000 --- a/src/Extensions/ElementQueryExtensions.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using AngleSharp.Dom; -using AngleSharpWrappers; - -namespace Bunit -{ - /// - /// Helper methods for querying types. - /// - public static class ElementQueryExtensions - { - /// - /// Returns the first element within this element (using depth-first pre-order traversal - /// of the document's nodes) that matches the specified group of selectors. - /// - /// The group of selectors to use. - /// The element to search within - public static IElement Find(this IElement element, string selector) - { - if (element is null) throw new ArgumentNullException(nameof(element)); - - return element.QuerySelector(selector); - } - - /// - /// Returns a list of the elements within the rendered fragment or component under test, - /// (using depth-first pre-order traversal of the document's nodes) that match the specified group of selectors. - /// - /// The group of selectors to use. - /// The element to search within - public static IHtmlCollection FindAll(this IElement element, string selector) - { - if (element is null) throw new ArgumentNullException(nameof(element)); - return element.QuerySelectorAll(selector); - } - - /// - /// Returns the first element within this element (using depth-first pre-order traversal - /// of the document's nodes) that matches the specified group of selectors. - /// - /// The group of selectors to use. - /// The elements to search within - public static IElement Find(this INodeList nodelist, string selector) - { - if (nodelist is null) throw new ArgumentNullException(nameof(nodelist)); - return nodelist.QuerySelector(selector); - } - - /// - /// Returns a list of the elements within the rendered fragment or component under test, - /// (using depth-first pre-order traversal of the document's nodes) that match the specified group of selectors. - /// - /// The group of selectors to use. - /// The elements to search within - public static IHtmlCollection FindAll(this INodeList nodelist, string selector) - { - if (nodelist is null) throw new ArgumentNullException(nameof(nodelist)); - return nodelist.QuerySelectorAll(selector); - } - } -} diff --git a/src/Extensions/ElementRemovedException.cs b/src/Extensions/ElementRemovedException.cs new file mode 100644 index 000000000..5be7e020d --- /dev/null +++ b/src/Extensions/ElementRemovedException.cs @@ -0,0 +1,17 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Bunit +{ + /// + /// Represents an exception that is thrown when a wrapped element is no longer available in the DOM tree. + /// + [SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "")] + public class ElementRemovedException : Exception + { + /// + public ElementRemovedException() : base("The DOM element you tried to access is no longer available in the DOM tree. It has probably been removed after a render.") + { + } + } +} diff --git a/src/Extensions/Internal/ElementFactory.cs b/src/Extensions/Internal/ElementFactory.cs new file mode 100644 index 000000000..7a1cbf968 --- /dev/null +++ b/src/Extensions/Internal/ElementFactory.cs @@ -0,0 +1,40 @@ +using System; +using AngleSharp.Dom; +using AngleSharpWrappers; +using Xunit.Sdk; + +namespace Bunit +{ + internal sealed class ElementFactory : ConcurrentRenderEventSubscriber, IElementFactory + where TElement : class, IElement + { + private readonly IRenderedFragment _testTarget; + private readonly string _cssSelector; + private TElement? _element; + + public ElementFactory(IRenderedFragment testTarget, TElement initialElement, string cssSelector) + : base((testTarget ?? throw new ArgumentNullException(nameof(testTarget))).RenderEvents) + { + _testTarget = testTarget; + _cssSelector = cssSelector; + _element = initialElement; + } + + public override void OnNext(RenderEvent value) + { + if (value.HasChangesTo(_testTarget)) + _element = null; + } + + TElement IElementFactory.GetElement() + { + if (_element is null) + { + var queryResult = _testTarget.Nodes.QuerySelector(_cssSelector); + if(queryResult is TElement element) + _element = element; + } + return _element ?? throw new ElementNotFoundException(); + } + } +} diff --git a/src/Extensions/RenderWaitingHelperExtensions.cs b/src/Extensions/RenderWaitingHelperExtensions.cs new file mode 100644 index 000000000..abd8d2f0f --- /dev/null +++ b/src/Extensions/RenderWaitingHelperExtensions.cs @@ -0,0 +1,206 @@ +using System; +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Diagnostics.CodeAnalysis; + +namespace Bunit +{ + /// + /// Represents an exception that is thrown when a render does not happen within the specified wait period. + /// + public class WaitForRenderFailedException : Exception + { + private const string MESSAGE = "No render happened before the timeout period passed."; + + /// + public override string Message => MESSAGE; + } + + /// + /// Helper methods dealing with async rendering during testing. + /// + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")] + public static class RenderWaitingHelperExtensions + { + /// + /// Executes the provided action and waits for a render to occur. + /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. + /// + /// The context to wait against. + /// The action that somehow causes one or more components to render. + /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. + /// Thrown if is null + /// Thrown if no render happens within the specified , or the default of 1 second, if non is specified. + public static void WaitForRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) + { + if (testContext is null) throw new ArgumentNullException(nameof(testContext)); + + var waitTime = timeout.GetRuntimeTimeout(); + + var rvs = new ConcurrentRenderEventSubscriber(testContext.Renderer.RenderEvents); + + try + { + renderTrigger?.Invoke(); + + if (rvs.RenderCount > 0) return; + + // RenderEventSubscriber (rvs) receive render events on the renderer's thread, where as + // the WaitForNextRender is started from the test runners thread. + // Thus it is safe to SpinWait on the test thread and wait for the RenderCount to go above 0. + if (SpinWait.SpinUntil(ShouldSpin, waitTime) && rvs.RenderCount > 0) + return; + else + throw new WaitForRenderFailedException(); + } + finally + { + rvs.Unsubscribe(); + } + + bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; + } + + /// + /// Uses the provided action to verify + /// that an expected state has been reached. The is + /// invoked every time the renders, and will + /// break the waiting as soon as the passes. + /// + /// The waiting will terminate with a when the specified (default is one second) is reached. + /// + /// + /// + /// + /// Thrown if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details. + public static void WaitForState(this IRenderedFragment renderedFragment, Func statePredicate, TimeSpan? timeout = null) + { + if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + if (statePredicate is null) throw new ArgumentNullException(nameof(statePredicate)); + + const int STATE_MISMATCH = 0; + const int STATE_MATCH = 1; + const int STATE_EXCEPTION = -1; + + var spinTime = timeout.GetRuntimeTimeout(); + var failure = default(Exception); + var status = STATE_MISMATCH; + + var rvs = new ComponentChangeEventSubscriber(renderedFragment, onChange: TryVerification); + try + { + TryVerification(); + WaitingResultHandler(continueIfMisMatch: true); + + // ComponentChangeEventSubscriber (rvs) receive render events on the renderer's thread, where as + // the VerifyAsyncChanges is started from the test runners thread. + // Thus it is safe to SpinWait on the test thread and wait for verification to pass. + // When a render event is received by rvs, the verification action will execute on the + // renderer thread. + // + // Therefore, we use Volatile.Read/Volatile.Write in the helper methods below to ensure + // that an update to the variable status is not cached in a local CPU, and + // not available in a secondary CPU, if the two threads are running on a different CPUs + SpinWait.SpinUntil(ShouldSpin, spinTime); + WaitingResultHandler(continueIfMisMatch: false); + } + finally + { + rvs.Unsubscribe(); + } + + void WaitingResultHandler(bool continueIfMisMatch) + { + switch (status) + { + case STATE_MATCH: return; + case STATE_MISMATCH when !continueIfMisMatch && failure is null: + throw WaitForStateFailedException.CreateNoMatchBeforeTimeout(); + case STATE_EXCEPTION when failure is { }: + throw WaitForStateFailedException.CreatePredicateThrowException(failure); + } + } + + void TryVerification(RenderEvent _ = default!) + { + try + { + if (statePredicate()) Volatile.Write(ref status, STATE_MATCH); + } + catch (Exception e) + { + failure = e; + Volatile.Write(ref status, STATE_EXCEPTION); + } + } + + bool ShouldSpin() => Volatile.Read(ref status) == STATE_MATCH || rvs.IsCompleted; + } + + /// + /// Uses the provided action to verify/assert + /// that an expected change has occurred in the + /// within the specified (default is one second). + /// + /// The rendered component or fragment to verify against. + /// The verification or assertion to perform. + /// The maximum time to attempt the verification. + /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception. + public static void WaitForAssertion(this IRenderedFragment renderedFragment, Action assertion, TimeSpan? timeout = null) + { + if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + if (assertion is null) throw new ArgumentNullException(nameof(assertion)); + + const int FAILING = 0; + const int PASSED = 1; + + var spinTime = timeout.GetRuntimeTimeout(); + var failure = default(Exception); + var status = FAILING; + + var rvs = new ComponentChangeEventSubscriber(renderedFragment, onChange: TryVerification); + try + { + TryVerification(); + if (status == PASSED) return; + + // HasChangesRenderEventSubscriber (rvs) receive render events on the renderer's thread, where as + // the VerifyAsyncChanges is started from the test runners thread. + // Thus it is safe to SpinWait on the test thread and wait for verification to pass. + // When a render event is received by rvs, the verification action will execute on the + // renderer thread. + // + // Therefore, we use Volatile.Read/Volatile.Write in the helper methods below to ensure + // that an update to the variable status is not cached in a local CPU, and + // not available in a secondary CPU, if the two threads are running on a different CPUs + SpinWait.SpinUntil(ShouldSpin, spinTime); + + if (status == FAILING && failure is { }) + { + throw new WaitForAssertionFailedException(failure); + } + } + finally + { + rvs.Unsubscribe(); + } + + void TryVerification(RenderEvent _ = default!) + { + try + { + assertion(); + Volatile.Write(ref status, PASSED); + failure = null; + } + catch (Exception e) + { + failure = e; + } + } + + bool ShouldSpin() => Volatile.Read(ref status) == PASSED || rvs.IsCompleted; + } + } +} diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs index b79d696db..e7d8f657e 100644 --- a/src/Extensions/RenderedFragmentQueryExtensions.cs +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -27,7 +26,7 @@ public static IElement Find(this IRenderedFragment renderedFragment, string cssS if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); var result = renderedFragment.Nodes.QuerySelector(cssSelector); if (result is null) throw new ElementNotFoundException(cssSelector); - return WrapperFactory.Create(new ElemenFactory(renderedFragment, result, cssSelector)); + return WrapperFactory.Create(new ElementFactory(renderedFragment, result, cssSelector)); } /// @@ -43,49 +42,4 @@ public static IHtmlCollection FindAll(this IRenderedFragment renderedF return renderedFragment.Nodes.QuerySelectorAll(cssSelector); } } - - internal sealed class ElemenFactory : RenderEventSubscriber, IElementFactory - where TElement : class, IElement - { - private readonly IRenderedFragment _testTarget; - private readonly string _cssSelector; - private TElement? _element; - - public ElemenFactory(IRenderedFragment testTarget, TElement initialElement, string cssSelector) - : base((testTarget ?? throw new ArgumentNullException(nameof(testTarget))).RenderEvents) - { - _testTarget = testTarget; - _cssSelector = cssSelector; - _element = initialElement; - } - - public override void OnNext(RenderEvent value) - { - if (value.HasChangesTo(_testTarget)) - _element = null; - } - - TElement IElementFactory.GetElement() - { - if (_element is null) - { - var queryResult = _testTarget.Nodes.QuerySelector(_cssSelector); - if(queryResult is TElement element) - _element = element; - } - return _element ?? throw new ElementNotFoundException(); - } - } - - /// - /// Represents an exception that is thrown when a wrapped element is no longer available in the DOM tree. - /// - [SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "")] - public class ElementRemovedException : Exception - { - /// - public ElementRemovedException() : base("The DOM element you tried to access is no longer available in the DOM tree. It has probably been removed after a render.") - { - } - } } diff --git a/src/Extensions/WaitForAssertionFailedException.cs b/src/Extensions/WaitForAssertionFailedException.cs new file mode 100644 index 000000000..9378bfafb --- /dev/null +++ b/src/Extensions/WaitForAssertionFailedException.cs @@ -0,0 +1,16 @@ +using System; + +namespace Bunit +{ + /// + /// Represents an exception thrown when the awaited assertion does not pass. + /// + public class WaitForAssertionFailedException : Exception + { + private const string MESSAGE = "The assertion did not pass within the timeout period."; + + internal WaitForAssertionFailedException(Exception assertionException) : base(MESSAGE, assertionException) + { + } + } +} diff --git a/src/Extensions/WaitForStateFailedException.cs b/src/Extensions/WaitForStateFailedException.cs new file mode 100644 index 000000000..4ccbd124c --- /dev/null +++ b/src/Extensions/WaitForStateFailedException.cs @@ -0,0 +1,27 @@ +using System; + +namespace Bunit +{ + /// + /// Represents an exception thrown when the state predicate does not pass or if it throws itself. + /// + public class WaitForStateFailedException : Exception + { + private const string TIMEOUT_NO_RENDER = "The state predicate did not pass before the timeout period passed."; + private const string EXCEPTION_IN_PREDICATE = "The state predicate throw an unhandled exception."; + + private WaitForStateFailedException() : base(TIMEOUT_NO_RENDER, new TimeoutException(TIMEOUT_NO_RENDER)) + { + } + + private WaitForStateFailedException(Exception innerException) : base(EXCEPTION_IN_PREDICATE, innerException) + { + } + + internal static WaitForStateFailedException CreateNoMatchBeforeTimeout() + => new WaitForStateFailedException(); + + internal static WaitForStateFailedException CreatePredicateThrowException(Exception innerException) + => new WaitForStateFailedException(innerException); + } +} diff --git a/src/Rendering/ComponentChangeEventSubscriber.cs b/src/Rendering/ComponentChangeEventSubscriber.cs index 96c86f94f..1b4772e6b 100644 --- a/src/Rendering/ComponentChangeEventSubscriber.cs +++ b/src/Rendering/ComponentChangeEventSubscriber.cs @@ -3,7 +3,7 @@ namespace Bunit { /// - public sealed class ComponentChangeEventSubscriber : RenderEventSubscriber, IDisposable + public sealed class ComponentChangeEventSubscriber : ConcurrentRenderEventSubscriber { private readonly IRenderedFragment _testTarget; @@ -22,7 +22,5 @@ public override void OnNext(RenderEvent value) if (value.HasChangesTo(_testTarget)) base.OnNext(value); } - /// - public void Dispose() => Unsubscribe(); } } diff --git a/src/Rendering/RenderEventSubscriber.cs b/src/Rendering/ConcurrentRenderEventSubscriber.cs similarity index 70% rename from src/Rendering/RenderEventSubscriber.cs rename to src/Rendering/ConcurrentRenderEventSubscriber.cs index b5ac027da..7eff8f328 100644 --- a/src/Rendering/RenderEventSubscriber.cs +++ b/src/Rendering/ConcurrentRenderEventSubscriber.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; namespace Bunit { @@ -6,36 +7,39 @@ namespace Bunit /// Represents a subscriber to s, published by /// the . /// - public class RenderEventSubscriber : IObserver + public class ConcurrentRenderEventSubscriber : IObserver { private readonly IDisposable _unsubscriber; private readonly Action? _onRender; private readonly Action? _onCompleted; + private int _renderCount; + private bool _isCompleted; + private RenderEvent? _latestRenderEvent; /// /// Gets the number of renders that have occurred since subscribing. /// - public int RenderCount { get; private set; } + public int RenderCount => Volatile.Read(ref _renderCount); /// /// Gets whether the is disposed an no more /// renders will happen. /// - public bool IsCompleted { get; private set; } + public bool IsCompleted => Volatile.Read(ref _isCompleted); /// /// Gets the latests received by the . /// - public RenderEvent? LatestRenderEvent { get; private set; } + public RenderEvent? LatestRenderEvent => Volatile.Read(ref _latestRenderEvent); /// - /// Creates an instance of the , and + /// Creates an instance of the , and /// subscribes to the provided . /// /// The observable to observe. /// A callback to invoke when a is received. /// A callback to invoke when no more renders will happen. - public RenderEventSubscriber(IObservable observable, Action? onRender = null, Action? onCompleted = null) + public ConcurrentRenderEventSubscriber(IObservable observable, Action? onRender = null, Action? onCompleted = null) { if (observable is null) throw new ArgumentNullException(nameof(observable)); _onRender = onRender; @@ -54,15 +58,15 @@ public void Unsubscribe() /// public virtual void OnNext(RenderEvent value) { - RenderCount += 1; - LatestRenderEvent = value; + Interlocked.Increment(ref _renderCount); + Volatile.Write(ref _latestRenderEvent, value); _onRender?.Invoke(value); } /// public virtual void OnCompleted() { - IsCompleted = true; + Volatile.Write(ref _isCompleted, true); _onCompleted?.Invoke(); } diff --git a/src/Rendering/RenderedFragmentBase.cs b/src/Rendering/RenderedFragmentBase.cs index af38c02f7..c35a7388d 100644 --- a/src/Rendering/RenderedFragmentBase.cs +++ b/src/Rendering/RenderedFragmentBase.cs @@ -14,7 +14,7 @@ namespace Bunit /// public abstract class RenderedFragmentBase : IRenderedFragment { - private readonly RenderEventSubscriber _renderEventSubscriber; + private readonly ConcurrentRenderEventSubscriber _renderEventSubscriber; private string? _snapshotMarkup; private string? _latestRenderMarkup; private INodeList? _firstRenderNodes; @@ -82,7 +82,7 @@ protected RenderedFragmentBase(ITestContext testContext, ContainerComponent cont TestContext = testContext; Container = container; RenderEvents = new RenderEventFilter(testContext.Renderer.RenderEvents, RenderFilter); - _renderEventSubscriber = new RenderEventSubscriber(testContext.Renderer.RenderEvents, ComponentRendered); + _renderEventSubscriber = new ConcurrentRenderEventSubscriber(testContext.Renderer.RenderEvents, ComponentRendered); } /// diff --git a/tests/Rendering/RenderEventPubSubTest.cs b/tests/Rendering/RenderEventPubSubTest.cs index 0adcae24d..6e6d64f22 100644 --- a/tests/Rendering/RenderEventPubSubTest.cs +++ b/tests/Rendering/RenderEventPubSubTest.cs @@ -16,7 +16,7 @@ public class RenderEventPubSubTest public void Test001() { var pub = new RenderEventPublisher(); - var sub = new RenderEventSubscriber(pub); + var sub = new ConcurrentRenderEventSubscriber(pub); pub.OnRender(new RenderEvent(new RenderBatch(), null!)); @@ -40,7 +40,7 @@ public void Test002() public void Test003() { var pub = new RenderEventPublisher(); - var sub = new RenderEventSubscriber(pub); + var sub = new ConcurrentRenderEventSubscriber(pub); sub.Unsubscribe(); diff --git a/tests/Rendering/RenderWaitingHelperExtensionsTest.cs b/tests/Rendering/RenderWaitingHelperExtensionsTest.cs new file mode 100644 index 000000000..8de4aa632 --- /dev/null +++ b/tests/Rendering/RenderWaitingHelperExtensionsTest.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AngleSharp.Dom; +using Bunit.Mocking.JSInterop; +using Bunit.SampleComponents; +using Bunit.SampleComponents.Data; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Bunit.Rendering +{ + public class RenderWaitingHelperExtensionsTest : ComponentTestFixture + { + [Fact(DisplayName = "Nodes should return new instance when " + + "async operation during OnInit causes component to re-render")] + public void Test003() + { + var testData = new AsyncNameDep(); + Services.AddSingleton(testData); + var cut = RenderComponent(); + var initialValue = cut.Nodes.QuerySelector("p").TextContent; + var expectedValue = "Steve Sanderson"; + + WaitForRender(() => testData.SetResult(expectedValue)); + + var steveValue = cut.Nodes.QuerySelector("p").TextContent; + steveValue.ShouldNotBe(initialValue); + steveValue.ShouldBe(expectedValue); + } + + [Fact(DisplayName = "Nodes should return new instance when " + + "async operation/StateHasChanged during OnAfterRender causes component to re-render")] + public void Test004() + { + var invocation = Services.AddMockJsRuntime().Setup("getdata"); + var cut = RenderComponent(); + var initialValue = cut.Nodes.QuerySelector("p").OuterHtml; + + WaitForRender(() => invocation.SetResult("Steve Sanderson")); + + var steveValue = cut.Nodes.QuerySelector("p").OuterHtml; + steveValue.ShouldNotBe(initialValue); + } + + [Fact(DisplayName = "Nodes on a components with child component returns " + + "new instance when the child component has changes")] + public void Test005() + { + var invocation = Services.AddMockJsRuntime().Setup("getdata"); + var notcut = RenderComponent(ChildContent()); + var cut = RenderComponent(ChildContent()); + var initialValue = cut.Nodes; + + WaitForRender(() => invocation.SetResult("Steve Sanderson"), TimeSpan.FromSeconds(2)); + + Assert.NotSame(initialValue, cut.Nodes); + } + + [Fact(DisplayName = "WaitForRender throws WaitForRenderFailedException when a render does not happen within the timeout period")] + public void Test006() + { + const string expectedMessage = "No render happened before the timeout period passed."; + var cut = RenderComponent(); + + var expected = Should.Throw(() => + WaitForRender(timeout: TimeSpan.FromMilliseconds(10)) + ); + + expected.Message.ShouldBe(expectedMessage); + } + + [Fact(DisplayName = "WaitForAssertion can wait for multiple renders and changes to occur")] + public void Test110() + { + // Initial state is stopped + var cut = RenderComponent(); + var stateElement = cut.Find("#state"); + stateElement.TextContent.ShouldBe("Stopped"); + + // Clicking 'tick' changes the state, and starts a task + cut.Find("#tick").Click(); + cut.Find("#state").TextContent.ShouldBe("Started"); + + // Clicking 'tock' completes the task, which updates the state + // This click causes two renders, thus something is needed to await here. + cut.Find("#tock").Click(); + cut.WaitForAssertion( + () => cut.Find("#state").TextContent.ShouldBe("Stopped") + ); + } + + [Fact(DisplayName = "WaitForAssertion throws verification exception after timeout")] + public void Test011() + { + const string expectedMessage = "The assertion did not pass within the timeout period."; + var cut = RenderComponent(); + + var expected = Should.Throw(() => + cut.WaitForAssertion(() => cut.Markup.ShouldBeEmpty(), TimeSpan.FromMilliseconds(10)) + ); + expected.Message.ShouldBe(expectedMessage); + expected.InnerException.ShouldBeOfType(); + } + + [Fact(DisplayName = "WaitForState throws WaitForRenderFailedException exception after timeout")] + public void Test012() + { + const string expectedMessage = "The state predicate did not pass before the timeout period passed."; + var cut = RenderComponent(); + + var expected = Should.Throw(() => + cut.WaitForState(() => string.IsNullOrEmpty(cut.Markup), TimeSpan.FromMilliseconds(100)) + ); + + expected.Message.ShouldBe(expectedMessage); + expected.InnerException.ShouldBeOfType() + .Message.ShouldBe(expectedMessage); + } + + [Fact(DisplayName = "WaitForState throws WaitForRenderFailedException exception if statePredicate throws on a later render")] + public void Test013() + { + const string expectedMessage = "The state predicate throw an unhandled exception."; + const string expectedInnerMessage = "INNER MESSAGE"; + var cut = RenderComponent(); + cut.Find("#tick").Click(); + cut.Find("#tock").Click(); + + var expected = Should.Throw(() => + cut.WaitForState(() => + { + if (cut.Find("#state").TextContent == "Stopped") + throw new InvalidOperationException(expectedInnerMessage); + return false; + }) + ); + + expected.Message.ShouldBe(expectedMessage); + expected.InnerException.ShouldBeOfType() + .Message.ShouldBe(expectedInnerMessage); + } + + [Fact(DisplayName = "WaitForState can wait for multiple renders and changes to occur")] + public void Test100() + { + // Initial state is stopped + var cut = RenderComponent(); + var stateElement = cut.Find("#state"); + stateElement.TextContent.ShouldBe("Stopped"); + + // Clicking 'tick' changes the state, and starts a task + cut.Find("#tick").Click(); + cut.Find("#state").TextContent.ShouldBe("Started"); + + // Clicking 'tock' completes the task, which updates the state + // This click causes two renders, thus something is needed to await here. + cut.Find("#tock").Click(); + cut.WaitForState(() => cut.Find("#state").TextContent == "Stopped"); + cut.Find("#state").TextContent.ShouldBe("Stopped"); + } + } +} diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index 93629c78d..cbf3022e3 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -1,4 +1,5 @@ -using Bunit.Extensions.Xunit; +using AngleSharp.Dom; +using Bunit.Extensions.Xunit; using Bunit.Mocking.JSInterop; using Bunit.SampleComponents; using Bunit.SampleComponents.Data; @@ -37,50 +38,7 @@ public void Test002() result.ShouldNotBeNull(); } - [Fact(DisplayName = "Nodes should return new instance when " + - "async operation during OnInit causes component to re-render")] - public void Test003() - { - var testData = new AsyncNameDep(); - Services.AddSingleton(testData); - var cut = RenderComponent(); - var initialValue = cut.Nodes.Find("p").TextContent; - var expectedValue = "Steve Sanderson"; - - WaitForNextRender(() => testData.SetResult(expectedValue)); - - var steveValue = cut.Nodes.Find("p").TextContent; - steveValue.ShouldNotBe(initialValue); - steveValue.ShouldBe(expectedValue); - } - - [Fact(DisplayName = "Nodes should return new instance when " + - "async operation/StateHasChanged during OnAfterRender causes component to re-render")] - public void Test004() - { - var invocation = Services.AddMockJsRuntime().Setup("getdata"); - var cut = RenderComponent(); - var initialValue = cut.Nodes.Find("p").OuterHtml; - - WaitForNextRender(() => invocation.SetResult("Steve Sanderson")); - - var steveValue = cut.Nodes.Find("p").OuterHtml; - steveValue.ShouldNotBe(initialValue); - } - - [Fact(DisplayName = "Nodes on a components with child component returns " + - "new instance when the child component has changes")] - public void Test005() - { - var invocation = Services.AddMockJsRuntime().Setup("getdata"); - var notcut = RenderComponent(ChildContent()); - var cut = RenderComponent(ChildContent()); - var initialValue = cut.Nodes; - - WaitForNextRender(() => invocation.SetResult("Steve Sanderson"), TimeSpan.FromSeconds(2)); - - Assert.NotSame(initialValue, cut.Nodes); - } + [Fact(DisplayName = "Nodes should return new instance " + @@ -191,15 +149,15 @@ public void Test103() [Fact(DisplayName = "Render events for non-rendered sub components are not emitted")] public void Test010() { - var renderSub = new RenderEventSubscriber(Renderer.RenderEvents); + var renderSub = new ConcurrentRenderEventSubscriber(Renderer.RenderEvents); var wrapper = RenderComponent( RenderFragment(nameof(TwoComponentWrapper.First)), RenderFragment(nameof(TwoComponentWrapper.Second)) ); var cuts = wrapper.FindComponents(); - var wrapperSub = new RenderEventSubscriber(wrapper.RenderEvents); - var cutSub1 = new RenderEventSubscriber(cuts[0].RenderEvents); - var cutSub2 = new RenderEventSubscriber(cuts[1].RenderEvents); + var wrapperSub = new ConcurrentRenderEventSubscriber(wrapper.RenderEvents); + var cutSub1 = new ConcurrentRenderEventSubscriber(cuts[0].RenderEvents); + var cutSub2 = new ConcurrentRenderEventSubscriber(cuts[1].RenderEvents); renderSub.RenderCount.ShouldBe(1); @@ -216,67 +174,6 @@ public void Test010() wrapperSub.RenderCount.ShouldBe(2); cutSub1.RenderCount.ShouldBe(1); cutSub2.RenderCount.ShouldBe(1); - } - - [Fact(DisplayName = "VerifyAsyncChanges can wait for multiple renders and changes to occur")] - public void Test110() - { - // Initial state is stopped - var cut = RenderComponent(); - var stateElement = cut.Find("#state"); - stateElement.TextContent.ShouldBe("Stopped"); - - // Clicking 'tick' changes the state, and starts a task - cut.Find("#tick").Click(); - cut.Find("#state").TextContent.ShouldBe("Started"); - - // Clicking 'tock' completes the task, which updates the state - // This click causes two renders, thus something is needed to await here. - cut.Find("#tock").Click(); - cut.WaitForAssertion( - () => cut.Find("#state").TextContent.ShouldBe("Stopped") - ); - } - - [Fact(DisplayName = "VerifyAyncChanges throws verification exception after timeout")] - public void Test011() - { - var cut = RenderComponent(); - - Should.Throw(() => - cut.WaitForAssertion(() => cut.Markup.ShouldBeEmpty(), TimeSpan.FromMilliseconds(100)) - ); - } - - [Fact(DisplayName = "WaitForState throws TimeoutException exception after timeout")] - public void Test012() - { - var cut = RenderComponent(); - - Should.Throw(() => - cut.WaitForState(() => string.IsNullOrEmpty(cut.Markup), TimeSpan.FromMilliseconds(100)) - ); - } - - [Fact(DisplayName = "WaitForState can wait for multiple renders and changes to occur")] - public void Test013() - { - // Initial state is stopped - var cut = RenderComponent(); - var stateElement = cut.Find("#state"); - stateElement.TextContent.ShouldBe("Stopped"); - - // Clicking 'tick' changes the state, and starts a task - cut.Find("#tick").Click(); - cut.Find("#state").TextContent.ShouldBe("Started"); - - // Clicking 'tock' completes the task, which updates the state - // This click causes two renders, thus something is needed to await here. - cut.Find("#tock").Click(); - cut.WaitForState(() => cut.Find("#state").TextContent == "Stopped"); - cut.Find("#state").TextContent.ShouldBe("Stopped"); - } - + } } - } diff --git a/tests/SampleComponents/ClickCounter.razor b/tests/SampleComponents/ClickCounter.razor index 8750a39e7..0b6fdd613 100644 --- a/tests/SampleComponents/ClickCounter.razor +++ b/tests/SampleComponents/ClickCounter.razor @@ -1,7 +1,7 @@  -

@count

+

@Count

@code { - int count = 0; - void IncreaseCount() => count++; + public int Count { get; private set; } = 0; + void IncreaseCount() => Count++; } \ No newline at end of file diff --git a/tests/TestRendererTest.cs b/tests/TestRendererTest.cs index a63224c20..0a260a227 100644 --- a/tests/TestRendererTest.cs +++ b/tests/TestRendererTest.cs @@ -16,7 +16,7 @@ public class TestRendererTest : ComponentTestFixture [Fact(DisplayName = "Renderer pushes render events to subscribers when renders occur")] public void Test001() { - var res = new RenderEventSubscriber(Renderer.RenderEvents); + var res = new ConcurrentRenderEventSubscriber(Renderer.RenderEvents); var sut = RenderComponent(); res.RenderCount.ShouldBe(1); From 4b84be915a31c93dc9b42238eb54087ae582d6c2 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 24 Feb 2020 22:33:42 +0000 Subject: [PATCH 14/27] Moved class to own file --- src/Extensions/RenderWaitingHelperExtensions.cs | 10 ---------- src/Extensions/WaitForRenderFailedException.cs | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 src/Extensions/WaitForRenderFailedException.cs diff --git a/src/Extensions/RenderWaitingHelperExtensions.cs b/src/Extensions/RenderWaitingHelperExtensions.cs index abd8d2f0f..60caca0c2 100644 --- a/src/Extensions/RenderWaitingHelperExtensions.cs +++ b/src/Extensions/RenderWaitingHelperExtensions.cs @@ -6,16 +6,6 @@ namespace Bunit { - /// - /// Represents an exception that is thrown when a render does not happen within the specified wait period. - /// - public class WaitForRenderFailedException : Exception - { - private const string MESSAGE = "No render happened before the timeout period passed."; - - /// - public override string Message => MESSAGE; - } /// /// Helper methods dealing with async rendering during testing. diff --git a/src/Extensions/WaitForRenderFailedException.cs b/src/Extensions/WaitForRenderFailedException.cs new file mode 100644 index 000000000..a555a9543 --- /dev/null +++ b/src/Extensions/WaitForRenderFailedException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Bunit +{ + /// + /// Represents an exception that is thrown when a render does not happen within the specified wait period. + /// + public class WaitForRenderFailedException : Exception + { + private const string MESSAGE = "No render happened before the timeout period passed."; + + /// + public override string Message => MESSAGE; + } +} From 3fa7a98fc400e49cc9d78e48845c3d93b97a345b Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Tue, 25 Feb 2020 09:39:22 +0000 Subject: [PATCH 15/27] Updates to sample --- .../tests/RazorTestComponents/Components/AlertRazorTest.razor | 2 +- sample/tests/RazorTestComponents/Pages/FetchDataTest.razor | 2 +- sample/tests/Tests/Components/AlertTest.cs | 4 ++-- sample/tests/Tests/Components/WikiSearchTest.cs | 2 +- sample/tests/Tests/Pages/FetchDataTest.cs | 2 +- sample/tests/Tests/Pages/TodosTest.cs | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor b/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor index 9b83d74ff..3e266e471 100644 --- a/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor +++ b/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor @@ -201,7 +201,7 @@ Assert.DoesNotContain("show", cut.Find(".alert").ClassList); // Act - complete - WaitForNextRender(() => + WaitForRender(() => { plannedInvocation.SetResult(default!); }); diff --git a/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor b/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor index 2f2b021e1..9106178eb 100644 --- a/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor +++ b/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor @@ -42,7 +42,7 @@ var cut = RenderComponent(); // act - WaitForNextRender(() => forecastService.Task.SetResult(forecasts)); + WaitForRender(() => forecastService.Task.SetResult(forecasts)); // assert cut.GetChangesSinceFirstRender().ShouldHaveChanges( diff --git a/sample/tests/Tests/Components/AlertTest.cs b/sample/tests/Tests/Components/AlertTest.cs index 8d384b6e8..41e549341 100644 --- a/sample/tests/Tests/Components/AlertTest.cs +++ b/sample/tests/Tests/Components/AlertTest.cs @@ -172,7 +172,7 @@ public void Test() Assert.NotNull(dismissingEvent); // Act - WaitForNextRender(() => + WaitForRender(() => { plannedInvocation.SetResult(default!); }); @@ -198,7 +198,7 @@ public void Test007() Assert.DoesNotContain("show", cut.Find(".alert").ClassList); // Act - complete - WaitForNextRender(() => + WaitForRender(() => { plannedInvocation.SetResult(default!); }); diff --git a/sample/tests/Tests/Components/WikiSearchTest.cs b/sample/tests/Tests/Components/WikiSearchTest.cs index 99d1758ed..2124e29c1 100644 --- a/sample/tests/Tests/Components/WikiSearchTest.cs +++ b/sample/tests/Tests/Components/WikiSearchTest.cs @@ -52,7 +52,7 @@ public void Test002() // Use the WaitForNextRender to block until the component has finished re-rendered. // The plannedInvocation.SetResult will return the result to the component is waiting // for in its OnAfterRender from the await jsRuntime.InvokeAsync("queryWiki", "blazor") call. - WaitForNextRender(() => plannedInvocation.SetResult(expectedSearchResult)); + WaitForRender(() => plannedInvocation.SetResult(expectedSearchResult)); // Assert // Verify that the result was received and correct placed in the paragraph element. diff --git a/sample/tests/Tests/Pages/FetchDataTest.cs b/sample/tests/Tests/Pages/FetchDataTest.cs index 763faf467..9a3a91f86 100644 --- a/sample/tests/Tests/Pages/FetchDataTest.cs +++ b/sample/tests/Tests/Pages/FetchDataTest.cs @@ -41,7 +41,7 @@ public void Test002() var cut = RenderComponent(); // Act - pass the test forecasts to the component via the mock services - WaitForNextRender(() => mockForecastService.Task.SetResult(forecasts)); + WaitForRender(() => mockForecastService.Task.SetResult(forecasts)); // Assert // Render an new instance of the ForecastDataTable, passing in the test data diff --git a/sample/tests/Tests/Pages/TodosTest.cs b/sample/tests/Tests/Pages/TodosTest.cs index 2da2c3f0d..cac5fc39f 100644 --- a/sample/tests/Tests/Pages/TodosTest.cs +++ b/sample/tests/Tests/Pages/TodosTest.cs @@ -34,7 +34,7 @@ public void Test001() // act var page = RenderComponent(); - WaitForNextRender(() => getTask.SetResult(todos)); + WaitForRender(() => getTask.SetResult(todos)); // assert page.FindAll("li") From 0ce19489d23027855cd12b9dce5d9ac78460fef9 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Tue, 25 Feb 2020 10:12:50 +0000 Subject: [PATCH 16/27] Changed waiting operations to retest on render, not no change --- .../RenderWaitingHelperExtensions.cs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Extensions/RenderWaitingHelperExtensions.cs b/src/Extensions/RenderWaitingHelperExtensions.cs index 60caca0c2..55335f5af 100644 --- a/src/Extensions/RenderWaitingHelperExtensions.cs +++ b/src/Extensions/RenderWaitingHelperExtensions.cs @@ -14,10 +14,10 @@ namespace Bunit public static class RenderWaitingHelperExtensions { /// - /// Executes the provided action and waits for a render to occur. - /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. + /// Wait for the next render to happen, or the is reached (default is one second). + /// If a action is provided, it is invoked before the waiting. /// - /// The context to wait against. + /// The test context to wait against. /// The action that somehow causes one or more components to render. /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. /// Thrown if is null @@ -53,12 +53,10 @@ public static void WaitForRender(this ITestContext testContext, Action? renderTr } /// - /// Uses the provided action to verify - /// that an expected state has been reached. The is - /// invoked every time the renders, and will - /// break the waiting as soon as the passes. - /// - /// The waiting will terminate with a when the specified (default is one second) is reached. + /// Wait until the provided action returns true, + /// or the is reached (default is one second). + /// The is evaluated initially, and then each time + /// the renders. /// /// /// @@ -77,7 +75,7 @@ public static void WaitForState(this IRenderedFragment renderedFragment, Func - /// Uses the provided action to verify/assert - /// that an expected change has occurred in the - /// within the specified (default is one second). + /// Wait until the provided action passes (i.e. does not throw an + /// assertion exception), or the is reached (default is one second). + /// + /// The is attempted initially, and then each time + /// the renders. /// /// The rendered component or fragment to verify against. /// The verification or assertion to perform. @@ -149,7 +149,7 @@ public static void WaitForAssertion(this IRenderedFragment renderedFragment, Act var failure = default(Exception); var status = FAILING; - var rvs = new ComponentChangeEventSubscriber(renderedFragment, onChange: TryVerification); + var rvs = new ConcurrentRenderEventSubscriber(renderedFragment.RenderEvents, onRender: TryVerification); try { TryVerification(); From 101c69df32d1b5554b8027c68bd98d9d7a254500 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Tue, 25 Feb 2020 14:46:24 +0000 Subject: [PATCH 17/27] Added obsolete to WaitForNextRender, exposed WaitForState and WaitForAssertion at ITextContext level --- .../Components/AlertRazorTest.razor | 2 +- .../Pages/FetchDataTest.razor | 2 +- sample/tests/Tests/Components/AlertTest.cs | 4 +- .../tests/Tests/Components/WikiSearchTest.cs | 2 +- sample/tests/Tests/Pages/FetchDataTest.cs | 2 +- sample/tests/Tests/Pages/TodosTest.cs | 2 +- src/ComponentTestFixture.cs | 37 +++++- src/Components/TestContextAdapter.cs | 10 +- .../RenderWaitingHelperExtensions.cs | 106 ++++++++++++------ .../LifeCycleTrackerTest.razor | 1 - .../SnapshotTestTest.razor | 1 - .../RenderWaitingHelperExtensionsTest.cs | 12 +- 12 files changed, 121 insertions(+), 60 deletions(-) diff --git a/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor b/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor index 3e266e471..9b83d74ff 100644 --- a/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor +++ b/sample/tests/RazorTestComponents/Components/AlertRazorTest.razor @@ -201,7 +201,7 @@ Assert.DoesNotContain("show", cut.Find(".alert").ClassList); // Act - complete - WaitForRender(() => + WaitForNextRender(() => { plannedInvocation.SetResult(default!); }); diff --git a/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor b/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor index 9106178eb..2f2b021e1 100644 --- a/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor +++ b/sample/tests/RazorTestComponents/Pages/FetchDataTest.razor @@ -42,7 +42,7 @@ var cut = RenderComponent(); // act - WaitForRender(() => forecastService.Task.SetResult(forecasts)); + WaitForNextRender(() => forecastService.Task.SetResult(forecasts)); // assert cut.GetChangesSinceFirstRender().ShouldHaveChanges( diff --git a/sample/tests/Tests/Components/AlertTest.cs b/sample/tests/Tests/Components/AlertTest.cs index 41e549341..8d384b6e8 100644 --- a/sample/tests/Tests/Components/AlertTest.cs +++ b/sample/tests/Tests/Components/AlertTest.cs @@ -172,7 +172,7 @@ public void Test() Assert.NotNull(dismissingEvent); // Act - WaitForRender(() => + WaitForNextRender(() => { plannedInvocation.SetResult(default!); }); @@ -198,7 +198,7 @@ public void Test007() Assert.DoesNotContain("show", cut.Find(".alert").ClassList); // Act - complete - WaitForRender(() => + WaitForNextRender(() => { plannedInvocation.SetResult(default!); }); diff --git a/sample/tests/Tests/Components/WikiSearchTest.cs b/sample/tests/Tests/Components/WikiSearchTest.cs index 2124e29c1..99d1758ed 100644 --- a/sample/tests/Tests/Components/WikiSearchTest.cs +++ b/sample/tests/Tests/Components/WikiSearchTest.cs @@ -52,7 +52,7 @@ public void Test002() // Use the WaitForNextRender to block until the component has finished re-rendered. // The plannedInvocation.SetResult will return the result to the component is waiting // for in its OnAfterRender from the await jsRuntime.InvokeAsync("queryWiki", "blazor") call. - WaitForRender(() => plannedInvocation.SetResult(expectedSearchResult)); + WaitForNextRender(() => plannedInvocation.SetResult(expectedSearchResult)); // Assert // Verify that the result was received and correct placed in the paragraph element. diff --git a/sample/tests/Tests/Pages/FetchDataTest.cs b/sample/tests/Tests/Pages/FetchDataTest.cs index 9a3a91f86..763faf467 100644 --- a/sample/tests/Tests/Pages/FetchDataTest.cs +++ b/sample/tests/Tests/Pages/FetchDataTest.cs @@ -41,7 +41,7 @@ public void Test002() var cut = RenderComponent(); // Act - pass the test forecasts to the component via the mock services - WaitForRender(() => mockForecastService.Task.SetResult(forecasts)); + WaitForNextRender(() => mockForecastService.Task.SetResult(forecasts)); // Assert // Render an new instance of the ForecastDataTable, passing in the test data diff --git a/sample/tests/Tests/Pages/TodosTest.cs b/sample/tests/Tests/Pages/TodosTest.cs index cac5fc39f..2da2c3f0d 100644 --- a/sample/tests/Tests/Pages/TodosTest.cs +++ b/sample/tests/Tests/Pages/TodosTest.cs @@ -34,7 +34,7 @@ public void Test001() // act var page = RenderComponent(); - WaitForRender(() => getTask.SetResult(todos)); + WaitForNextRender(() => getTask.SetResult(todos)); // assert page.FindAll("li") diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index 0ed1409e1..e1f948061 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -14,16 +14,41 @@ namespace Bunit public abstract class ComponentTestFixture : TestContext { /// - /// Executes the provided action and waits for a render to occur. - /// Use this when you have a component that is awaiting e.g. a service to return data to it before rendering again. + /// Wait for the next render to happen, or the is reached (default is one second). + /// If a action is provided, it is invoked before the waiting. /// /// The action that somehow causes one or more components to render. /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. /// Thrown if no render happens within the specified , or the default of 1 second, if non is specified. - protected void WaitForRender(Action? renderTrigger = null, TimeSpan? timeout = null) - { - RenderWaitingHelperExtensions.WaitForRender(this, renderTrigger, timeout); - } + [Obsolete("Use either the WaitForState or WaitForAssertion method instead. It will make your test more resilient to insignificant changes, as they will wait across multiple renders instead of just one. To make the change, run any render trigger first, then call either WaitForState or WaitForAssertion with the appropriate input. This method will be removed before the 1.0.0 release.", false)] + protected void WaitForNextRender(Action? renderTrigger = null, TimeSpan? timeout = null) + => RenderWaitingHelperExtensions.WaitForNextRender(this, renderTrigger, timeout); + + /// + /// Wait until the provided action returns true, + /// or the is reached (default is one second). + /// + /// The is evaluated initially, and then each time + /// the renderer in the test context renders. + /// + /// The predicate to invoke after each render, which returns true when the desired state has been reached. + /// The maximum time to wait for the desired state. + /// Thrown if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details. + protected void WaitForState(Func statePredicate, TimeSpan? timeout = null) + => RenderWaitingHelperExtensions.WaitForState(this, statePredicate, timeout); + + /// + /// Wait until the provided action passes (i.e. does not throw an + /// assertion exception), or the is reached (default is one second). + /// + /// The is attempted initially, and then each time + /// the renderer in the test context renders. + /// + /// The verification or assertion to perform. + /// The maximum time to attempt the verification. + /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception. + protected void WaitForAssertion(Action assertion, TimeSpan? timeout = null) + => RenderWaitingHelperExtensions.WaitForAssertion(this, assertion, timeout); /// /// Creates a with an as parameter value diff --git a/src/Components/TestContextAdapter.cs b/src/Components/TestContextAdapter.cs index 548cb61d7..5f3082a2a 100644 --- a/src/Components/TestContextAdapter.cs +++ b/src/Components/TestContextAdapter.cs @@ -57,18 +57,10 @@ public IRenderedFragment GetFragment(string? id = null) public IRenderedComponent GetFragment(string? id) where TComponent : class, IComponent => _razorTestContext?.GetFragment(id) ?? throw new InvalidOperationException($"{nameof(GetFragment)} is only available in Razor based tests."); - public void WaitForNextRender(Action renderTrigger, TimeSpan? timeout = null) - { - if (_testContext is null) - throw new InvalidOperationException("No active test context in the adapter"); - else - _testContext.WaitForRender(renderTrigger, timeout); - } - public IRenderedComponent RenderComponent(params ComponentParameter[] parameters) where TComponent : class, IComponent => _testContext?.RenderComponent(parameters) ?? throw new InvalidOperationException("No active test context in the adapter"); public INodeList CreateNodes(string markup) => _testContext?.CreateNodes(markup) ?? throw new InvalidOperationException("No active test context in the adapter"); - } + } } diff --git a/src/Extensions/RenderWaitingHelperExtensions.cs b/src/Extensions/RenderWaitingHelperExtensions.cs index 55335f5af..9ccd18c31 100644 --- a/src/Extensions/RenderWaitingHelperExtensions.cs +++ b/src/Extensions/RenderWaitingHelperExtensions.cs @@ -17,18 +17,81 @@ public static class RenderWaitingHelperExtensions /// Wait for the next render to happen, or the is reached (default is one second). /// If a action is provided, it is invoked before the waiting. /// - /// The test context to wait against. + /// The test context to wait for renders from. /// The action that somehow causes one or more components to render. /// The maximum time to wait for the next render. If not provided the default is 1 second. During debugging, the timeout is automatically set to infinite. - /// Thrown if is null + /// Thrown if is null. /// Thrown if no render happens within the specified , or the default of 1 second, if non is specified. - public static void WaitForRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) + [Obsolete("Use either the WaitForState or WaitForAssertion method instead. It will make your test more resilient to insignificant changes, as they will wait across multiple renders instead of just one. To make the change, run any render trigger first, then call either WaitForState or WaitForAssertion with the appropriate input. This method will be removed before the 1.0.0 release.", false)] + public static void WaitForNextRender(this ITestContext testContext, Action? renderTrigger = null, TimeSpan? timeout = null) + => WaitForRender(testContext?.Renderer.RenderEvents ?? throw new ArgumentNullException(nameof(testContext)), renderTrigger, timeout); + + /// + /// Wait until the provided action returns true, + /// or the is reached (default is one second). + /// + /// The is evaluated initially, and then each time + /// the renderer in the renders. + /// + /// The test context to wait for renders from. + /// The predicate to invoke after each render, which returns true when the desired state has been reached. + /// The maximum time to wait for the desired state. + /// Thrown if is null. + /// Thrown if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details. + public static void WaitForState(this ITestContext testContext, Func statePredicate, TimeSpan? timeout = null) + => WaitForState(testContext?.Renderer.RenderEvents ?? throw new ArgumentNullException(nameof(testContext)), statePredicate, timeout); + + /// + /// Wait until the provided action passes (i.e. does not throw an + /// assertion exception), or the is reached (default is one second). + /// + /// The is attempted initially, and then each time + /// the renderer in the renders. + /// + /// The test context to wait for renders from. + /// The verification or assertion to perform. + /// The maximum time to attempt the verification. + /// Thrown if is null. + /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception. + public static void WaitForAssertion(this ITestContext testContext, Action assertion, TimeSpan? timeout = null) + => WaitForAssertion(testContext?.Renderer.RenderEvents ?? throw new ArgumentNullException(nameof(testContext)), assertion, timeout); + + /// + /// Wait until the provided action returns true, + /// or the is reached (default is one second). + /// The is evaluated initially, and then each time + /// the renders. + /// + /// The rendered fragment to wait for renders from. + /// The predicate to invoke after each render, which returns true when the desired state has been reached. + /// The maximum time to wait for the desired state. + /// Thrown if is null. + /// Thrown if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details. + public static void WaitForState(this IRenderedFragment renderedFragment, Func statePredicate, TimeSpan? timeout = null) + => WaitForState(renderedFragment?.RenderEvents ?? throw new ArgumentNullException(nameof(renderedFragment)), statePredicate, timeout); + + /// + /// Wait until the provided action passes (i.e. does not throw an + /// assertion exception), or the is reached (default is one second). + /// + /// The is attempted initially, and then each time + /// the renders. + /// + /// The rendered fragment to wait for renders from. + /// The verification or assertion to perform. + /// The maximum time to attempt the verification. + /// Thrown if is null. + /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception. + public static void WaitForAssertion(this IRenderedFragment renderedFragment, Action assertion, TimeSpan? timeout = null) + => WaitForAssertion(renderedFragment?.RenderEvents ?? throw new ArgumentNullException(nameof(renderedFragment)), assertion, timeout); + + private static void WaitForRender(IObservable renderEventObservable, Action? renderTrigger = null, TimeSpan? timeout = null) { - if (testContext is null) throw new ArgumentNullException(nameof(testContext)); + if (renderEventObservable is null) throw new ArgumentNullException(nameof(renderEventObservable)); var waitTime = timeout.GetRuntimeTimeout(); - var rvs = new ConcurrentRenderEventSubscriber(testContext.Renderer.RenderEvents); + var rvs = new ConcurrentRenderEventSubscriber(renderEventObservable); try { @@ -52,19 +115,9 @@ public static void WaitForRender(this ITestContext testContext, Action? renderTr bool ShouldSpin() => rvs.RenderCount > 0 || rvs.IsCompleted; } - /// - /// Wait until the provided action returns true, - /// or the is reached (default is one second). - /// The is evaluated initially, and then each time - /// the renders. - /// - /// - /// - /// - /// Thrown if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details. - public static void WaitForState(this IRenderedFragment renderedFragment, Func statePredicate, TimeSpan? timeout = null) + private static void WaitForState(IObservable renderEventObservable, Func statePredicate, TimeSpan? timeout = null) { - if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + if (renderEventObservable is null) throw new ArgumentNullException(nameof(renderEventObservable)); if (statePredicate is null) throw new ArgumentNullException(nameof(statePredicate)); const int STATE_MISMATCH = 0; @@ -75,7 +128,7 @@ public static void WaitForState(this IRenderedFragment renderedFragment, Func Volatile.Read(ref status) == STATE_MATCH || rvs.IsCompleted; } - /// - /// Wait until the provided action passes (i.e. does not throw an - /// assertion exception), or the is reached (default is one second). - /// - /// The is attempted initially, and then each time - /// the renders. - /// - /// The rendered component or fragment to verify against. - /// The verification or assertion to perform. - /// The maximum time to attempt the verification. - /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception. - public static void WaitForAssertion(this IRenderedFragment renderedFragment, Action assertion, TimeSpan? timeout = null) + private static void WaitForAssertion(IObservable renderEventObservable, Action assertion, TimeSpan? timeout = null) { - if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); + if (renderEventObservable is null) throw new ArgumentNullException(nameof(renderEventObservable)); if (assertion is null) throw new ArgumentNullException(nameof(assertion)); const int FAILING = 0; @@ -149,7 +191,7 @@ public static void WaitForAssertion(this IRenderedFragment renderedFragment, Act var failure = default(Exception); var status = FAILING; - var rvs = new ConcurrentRenderEventSubscriber(renderedFragment.RenderEvents, onRender: TryVerification); + var rvs = new ConcurrentRenderEventSubscriber(renderEventObservable, onRender: TryVerification); try { TryVerification(); diff --git a/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor b/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor index 371b64457..b664a1ceb 100644 --- a/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor +++ b/tests/Components/TestComponentBaseTest/LifeCycleTrackerTest.razor @@ -1,5 +1,4 @@ @inherits TestComponentBase -@using Shouldly diff --git a/tests/Components/TestComponentBaseTest/SnapshotTestTest.razor b/tests/Components/TestComponentBaseTest/SnapshotTestTest.razor index 46049852e..bd021072d 100644 --- a/tests/Components/TestComponentBaseTest/SnapshotTestTest.razor +++ b/tests/Components/TestComponentBaseTest/SnapshotTestTest.razor @@ -1,5 +1,4 @@ @inherits TestComponentBase -@using Shouldly @code { class Dep1 : ITestDep { public string Name { get; } = "FOO"; } diff --git a/tests/Rendering/RenderWaitingHelperExtensionsTest.cs b/tests/Rendering/RenderWaitingHelperExtensionsTest.cs index 8de4aa632..3dee4eb74 100644 --- a/tests/Rendering/RenderWaitingHelperExtensionsTest.cs +++ b/tests/Rendering/RenderWaitingHelperExtensionsTest.cs @@ -17,6 +17,7 @@ public class RenderWaitingHelperExtensionsTest : ComponentTestFixture { [Fact(DisplayName = "Nodes should return new instance when " + "async operation during OnInit causes component to re-render")] + [Obsolete("Calls to WaitForNextRender is obsolete, but still needs tests")] public void Test003() { var testData = new AsyncNameDep(); @@ -25,7 +26,7 @@ public void Test003() var initialValue = cut.Nodes.QuerySelector("p").TextContent; var expectedValue = "Steve Sanderson"; - WaitForRender(() => testData.SetResult(expectedValue)); + WaitForNextRender(() => testData.SetResult(expectedValue)); var steveValue = cut.Nodes.QuerySelector("p").TextContent; steveValue.ShouldNotBe(initialValue); @@ -34,13 +35,14 @@ public void Test003() [Fact(DisplayName = "Nodes should return new instance when " + "async operation/StateHasChanged during OnAfterRender causes component to re-render")] + [Obsolete("Calls to WaitForNextRender is obsolete, but still needs tests")] public void Test004() { var invocation = Services.AddMockJsRuntime().Setup("getdata"); var cut = RenderComponent(); var initialValue = cut.Nodes.QuerySelector("p").OuterHtml; - WaitForRender(() => invocation.SetResult("Steve Sanderson")); + WaitForNextRender(() => invocation.SetResult("NEW DATA")); var steveValue = cut.Nodes.QuerySelector("p").OuterHtml; steveValue.ShouldNotBe(initialValue); @@ -48,6 +50,7 @@ public void Test004() [Fact(DisplayName = "Nodes on a components with child component returns " + "new instance when the child component has changes")] + [Obsolete("Calls to WaitForNextRender is obsolete, but still needs tests")] public void Test005() { var invocation = Services.AddMockJsRuntime().Setup("getdata"); @@ -55,19 +58,20 @@ public void Test005() var cut = RenderComponent(ChildContent()); var initialValue = cut.Nodes; - WaitForRender(() => invocation.SetResult("Steve Sanderson"), TimeSpan.FromSeconds(2)); + WaitForNextRender(() => invocation.SetResult("NEW DATA"), TimeSpan.FromSeconds(2)); Assert.NotSame(initialValue, cut.Nodes); } [Fact(DisplayName = "WaitForRender throws WaitForRenderFailedException when a render does not happen within the timeout period")] + [Obsolete("Calls to WaitForNextRender is obsolete, but still needs tests")] public void Test006() { const string expectedMessage = "No render happened before the timeout period passed."; var cut = RenderComponent(); var expected = Should.Throw(() => - WaitForRender(timeout: TimeSpan.FromMilliseconds(10)) + WaitForNextRender(timeout: TimeSpan.FromMilliseconds(10)) ); expected.Message.ShouldBe(expectedMessage); From 422b8dd01a925afbbccb6c38c538e2581b201079 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 26 Feb 2020 11:20:59 +0000 Subject: [PATCH 18/27] Added changelog to project --- CHANGELOG.md | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++ bunit.sln | 1 + 2 files changed, 120 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..88b06da25 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,119 @@ +# Changelog +All notable changes to this project will be documented in this file. The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +This release includes a name change from Blazor Components Testing Library to **bUnit**. It also brings along two extra helper methods for working with asynchronously rendering components during testing, and a bunch of internal optimizations and tweaks to the code. + +*Why change the name?* Naming is hard, and I initial chose a very product-namy name, that quite clearly stated what the library was for. However, the name isn't very searchable, since it just contains generic keywords, plus, bUnit is just much cooler. It also gave me the opportunity to remove my name from all the namespaces and simplify those. + +### Added +- **`WaitForState(Func statePredicate, TimeSpan? timeout = 1 second)` has been added to `ITestContext` and `IRenderedFragment`.** + + This method will wait (block) until the provided statePredicate returns true, or the timeout is reached (during debugging the timeout is disabled). Each time the renderer in the test context renders, or the rendered fragment renders, the statePredicate is evaluated. + + You use this method, if you have a component under test, that requires _one or more asynchronous triggered renders_, to get to a desired state, before the test can continue. + +- **`WaitForAssertion(Action assertion, TimeSpan? timeout = 1 second)` has been added to `ITestContext` and `IRenderedFragment`.** + + This method will wait (block) until the provided assertion method passes, i.e. runs without throwing an assert exception, or until the timeout is reached (during debugging the timeout is disabled). Each time the renderer in the test context renders, or the rendered fragment renders, the assertion is attempted. + + You use this method, if you have a component under test, that requires _one or more asynchronous triggered renders_, to get to a desired state, before the test can continue. + +- **Added support for capturing log statements from the renderer and components under test into the test output.** + + To enable this, add a constructor to your test classes that takes the `ITestOutputHelper` as input, then in the constructor call `Services.AddXunitLogger` and pass the `ITestOutputHelper` to it, e.g.: + + ```csharp + // ComponentTest.cs + public class ComponentTest : ComponentTestFixture + { + public ComponentTest(ITestOutputHelper output) + { + Services.AddXunitLogger(output, minimumLogLevel: LogLevel.Debug); + } + + [Fact] + public void Test1() ... + } + ``` + + For Razor and Snapshot tests, the logger can be added almost the same way. The big difference is that it must be added during *Setup*, e.g.: + + ```cshtml + // RazorComponentTest.razor + + ... + + @code { + private ITestOutputHelper _output; + + public RazorComponentTest(ITestOutputHelper output) + { + _output = output; + } + + void Setup() + { + Services.AddXunitLogger(_output, minimumLogLevel: LogLevel.Debug); + } + } + ``` + +- **Added simpler `Template` helper method** + + To make it easier to test components with `RenderFragment` parameters (template components) in C# based tests, a new `Template(string name, Func markupFactory)` helper methods have been added. It allows you to create a mock template that uses the `markupFactory` to create the rendered markup from the template. + + This is an example of testing the `SimpleWithTemplate.razor`, which looks like this: + + ```cshtml + @typeparam T + @foreach (var d in Data) + { + @Template(d); + } + @code + { + [Parameter] public RenderFragment Template { get; set; } + [Parameter] public IReadOnlyList Data { get; set; } = Array.Empty(); + } + ``` + + And the test code: + + ```csharp + var cut = RenderComponent>( + ("Data", new int[] { 1, 2 }), + Template("Template", num => $"

{num}

") + ); + + cut.MarkupMatches("

1

2

"); + ``` + + Using the more general `Template` helper methods, you need to write the `RenderTreeBuilder` logic yourself, e.g.: + + ```csharp + var cut = RenderComponent>( + ("Data", new int[] { 1, 2 }), + Template("Template", num => builder => builder.AddMarkupContent(0, $"

{num}

")) + ); + ``` + +### Changed +- **Namespaces is now `Bunit`** + The namespaces have changed from `Egil.RazorComponents.Testing.Library.*` to simply `Bunit` for the library, and `Bunit.Mocking.JSInterop` for the JSInterop mocking support. + +- **Auto-updating `IElement`s returned from `Find()`** + `IRenderedFragment.Find(string cssSelector)` now returns a `IElement`, which internally will update itself, whenever the rendered fragment it was found in, changes. This means you can now search for an element once in your test and assign it to a variable, and then continue to assert against the same instance, even after triggering renders of the component under test. + + For example, instead of having `cut.Find("p")` in multiple places in the same test, you can do `var p = cut.Find("p")` once, and the use the variable `p` all the places you would otherwise have the `Find(...)` statement. + +### Deprecated +- `WaitForNextRender` has been deprecated (marked as obsolete), since the added `WaitForState` and `WaitForAssertion` provide a much better foundation to build stable tests on. The plan is to remove completely from the library with the final 1.0.0 release. + +- `AddMockHttp` and related helper methods for working with the [mockhttp](https://github.com/richardszalay/mockhttp) library has been removed from the library. This was done because the library really shouldn't have a dependency on a 3. party mocking library. It adds maintenance overhead and uneeded dependencies to it. + + If you are using mockhttp, you can easily add again to your testing project. See [TODO Guide to mocking HttpClient](#) in the docs to learn how. + +### Removed +### Fixed +### Security \ No newline at end of file diff --git a/bunit.sln b/bunit.sln index 6aefb23c5..df67ae451 100644 --- a/bunit.sln +++ b/bunit.sln @@ -6,6 +6,7 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A5D7B605-02D8-468C-9BDF-864CF93B12F9}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + CHANGELOG.md = CHANGELOG.md Directory.Build.props = Directory.Build.props LICENSE = LICENSE README.md = README.md From 76f2bffda7ce463600138b00ddaf8b99506e8042 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 26 Feb 2020 11:22:50 +0000 Subject: [PATCH 19/27] Added Template helper method --- src/ComponentTestFixture.cs | 18 +++++++++++++++++- tests/ComponentTestFixtureTest.cs | 16 ++++++++++++++++ .../SampleComponents/SimpleWithTemplate.razor | 13 +++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/SampleComponents/SimpleWithTemplate.razor diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index e1f948061..d2e70de27 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -4,6 +4,8 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Xunit.Abstractions; using EC = Microsoft.AspNetCore.Components.EventCallback; namespace Bunit @@ -228,7 +230,7 @@ protected static ComponentParameter RenderFragment(string name, para } /// - /// Creates a component parameter which will pass the + /// Creates a template component parameter which will pass the /// to the parameter with the name . /// /// The value used to build the content. @@ -239,5 +241,19 @@ protected static ComponentParameter Template(string name, RenderFragment { return ComponentParameter.CreateParameter(name, template); } + + /// + /// Creates a template component parameter which will pass the a + /// to the parameter with the name . + /// The will be used to generate the markup inside the template. + /// + /// The value used to build the content. + /// Parameter name. + /// A markup factory that takes a as input and returns markup/HTML. + /// The . + protected static ComponentParameter Template(string name, Func markupFactory) + { + return Template(name, value => (RenderTreeBuilder builder) => builder.AddMarkupContent(0, markupFactory(value))); + } } } diff --git a/tests/ComponentTestFixtureTest.cs b/tests/ComponentTestFixtureTest.cs index 0ad5ef750..8229cd99b 100644 --- a/tests/ComponentTestFixtureTest.cs +++ b/tests/ComponentTestFixtureTest.cs @@ -92,5 +92,21 @@ public void Test003() Should.Throw(() => cut.SetParametersAndRender(CascadingValue(42))); Should.Throw(() => cut.SetParametersAndRender(CascadingValue(nameof(AllTypesOfParams.NamedCascadingValue), 1337))); } + + [Fact(DisplayName = "Template(name, markupFactory) helper correctly renders markup template")] + public void Test100() + { + var cut = RenderComponent>( + (nameof(SimpleWithTemplate.Data), new int[] { 1, 2 }), + Template(nameof(SimpleWithTemplate.Template), num => $"

{num}

") + ); + + var expected = RenderComponent>( + (nameof(SimpleWithTemplate.Data), new int[] { 1, 2 }), + Template(nameof(SimpleWithTemplate.Template), num => builder => builder.AddMarkupContent(0, $"

{num}

")) + ); + + cut.MarkupMatches(expected); + } } } diff --git a/tests/SampleComponents/SimpleWithTemplate.razor b/tests/SampleComponents/SimpleWithTemplate.razor new file mode 100644 index 000000000..f3d8c017a --- /dev/null +++ b/tests/SampleComponents/SimpleWithTemplate.razor @@ -0,0 +1,13 @@ +@typeparam T +@foreach (var d in Data) +{ + if (Template is { }) + { + @Template(d); + } +} +@code +{ + [Parameter] public RenderFragment? Template { get; set; } + [Parameter] public IReadOnlyList Data { get; set; } = Array.Empty(); +} \ No newline at end of file From f559e691cc4d6d1dce864919c250040b741ef0e9 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 26 Feb 2020 11:23:11 +0000 Subject: [PATCH 20/27] Added general support for XunitLogger to razor and snapshot tests --- src/Components/TestComponentBase.cs | 1 + src/Extensions/Internal/ElementFactory.cs | 4 ++-- src/Extensions/RenderedFragmentQueryExtensions.cs | 1 + src/Extensions/Xunit/XunitLoggerExtensions.cs | 5 +++-- .../CorrectImplicitRazorTestContextAvailable.razor | 9 +++++++++ tests/Rendering/RenderedFragmentTest.cs | 8 +++----- tests/_Imports.razor | 3 ++- 7 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/Components/TestComponentBase.cs b/src/Components/TestComponentBase.cs index fa7e0345e..f836ee961 100644 --- a/src/Components/TestComponentBase.cs +++ b/src/Components/TestComponentBase.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using Xunit.Abstractions; using Xunit.Sdk; namespace Bunit diff --git a/src/Extensions/Internal/ElementFactory.cs b/src/Extensions/Internal/ElementFactory.cs index 7a1cbf968..d97f10ab9 100644 --- a/src/Extensions/Internal/ElementFactory.cs +++ b/src/Extensions/Internal/ElementFactory.cs @@ -31,8 +31,8 @@ TElement IElementFactory.GetElement() if (_element is null) { var queryResult = _testTarget.Nodes.QuerySelector(_cssSelector); - if(queryResult is TElement element) - _element = element; + if (queryResult is TElement element) + _element = element; } return _element ?? throw new ElementNotFoundException(); } diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs index e7d8f657e..e41e23190 100644 --- a/src/Extensions/RenderedFragmentQueryExtensions.cs +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; +using AngleSharp.Html.Dom; using AngleSharpWrappers; using Xunit.Sdk; diff --git a/src/Extensions/Xunit/XunitLoggerExtensions.cs b/src/Extensions/Xunit/XunitLoggerExtensions.cs index bef80bd7e..dc58564f6 100644 --- a/src/Extensions/Xunit/XunitLoggerExtensions.cs +++ b/src/Extensions/Xunit/XunitLoggerExtensions.cs @@ -1,8 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using Bunit.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit.Abstractions; -namespace Bunit.Extensions.Xunit +namespace Bunit { /// /// Helper method for registering the xUnit test logger. diff --git a/tests/Components/TestComponentBaseTest/CorrectImplicitRazorTestContextAvailable.razor b/tests/Components/TestComponentBaseTest/CorrectImplicitRazorTestContextAvailable.razor index f5fdad84c..e4d23af02 100644 --- a/tests/Components/TestComponentBaseTest/CorrectImplicitRazorTestContextAvailable.razor +++ b/tests/Components/TestComponentBaseTest/CorrectImplicitRazorTestContextAvailable.razor @@ -9,12 +9,21 @@ @code { + private ITestOutputHelper _output; + + public CorrectImplicitRazorTestContextAvailable(ITestOutputHelper output) + { + _output = output; + Services.AddXunitLogger(_output); + } + class Dep1 : ITestDep { public string Name => nameof(Dep1); } ITestDep dep1Expected = new Dep1(); void Setup1() { Services.AddSingleton(dep1Expected); + Services.AddXunitLogger(_output); } void Test1() diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index cbf3022e3..0ce41f1cd 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -5,6 +5,7 @@ using Bunit.SampleComponents.Data; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Shouldly; using System; using System.Collections.Generic; @@ -20,7 +21,7 @@ public class RenderedFragmentTest : ComponentTestFixture { public RenderedFragmentTest(ITestOutputHelper output) { - Services.AddXunitLogger(output); + Services.AddXunitLogger(output, minimumLogLevel: LogLevel.Debug); } [Fact(DisplayName = "Find throws an exception if no element matches the css selector")] @@ -38,9 +39,6 @@ public void Test002() result.ShouldNotBeNull(); } - - - [Fact(DisplayName = "Nodes should return new instance " + "when a event handler trigger has caused changes to DOM tree")] public void Test006() @@ -174,6 +172,6 @@ public void Test010() wrapperSub.RenderCount.ShouldBe(2); cutSub1.RenderCount.ShouldBe(1); cutSub2.RenderCount.ShouldBe(1); - } + } } } diff --git a/tests/_Imports.razor b/tests/_Imports.razor index 782cc6e77..b915ef597 100644 --- a/tests/_Imports.razor +++ b/tests/_Imports.razor @@ -8,4 +8,5 @@ @using Bunit.SampleComponents @using Bunit.SampleComponents.Data @using Shouldly -@using Xunit \ No newline at end of file +@using Xunit +@using Xunit.Abstractions \ No newline at end of file From 3966798e6629c3f485ebe2c0c292225c78669a94 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 27 Feb 2020 11:41:31 +0000 Subject: [PATCH 21/27] First set of blazor e2e tests, smarter FindAll --- CHANGELOG.md | 74 +- .../GeneralEventDispatchExtensions.cs | 5 +- .../KeyboardEventDispatchExtensions.cs | 24 +- .../MissingEventHandlerException.cs | 36 + .../IRefreshableElementCollection.cs | 25 + .../RefreshableElementCollection.cs | 57 ++ .../RenderedFragmentQueryExtensions.cs | 9 +- src/GlobalSuppressions.cs | 1 + src/Rendering/TestRenderer.cs | 15 +- .../AddRemoveChildComponents.razor | 28 + .../AsyncEventHandlerComponent.razor | 35 + .../BasicTestApp/ComponentRefComponent.razor | 36 + .../BasicTestApp/CounterComponent.razor | 29 + .../CounterComponentUsingChild.razor | 15 + .../CounterComponentWrapper.razor | 7 + .../BasicTestApp/DataDashComponent.razor | 5 + .../BasicTestApp/ElementRefComponent.razor | 37 + .../Subdir/ComponentUsingImports.razor | 5 + .../Subdir/_Imports.razor | 1 + .../HierarchicalImportsTest/_Imports.razor | 1 + .../BasicTestApp/HtmlBlockChildContent.razor | 1 + .../HtmlEncodedChildContent.razor | 7 + .../BasicTestApp/HtmlMixedChildContent.razor | 7 + .../BasicTestApp/KeyPressEventComponent.razor | 19 + .../LogicalElementInsertionCases.razor | 10 + .../BasicTestApp/MessageComponent.razor | 5 + .../BasicTestApp/ParentChildComponent.razor | 4 + .../PassThroughContentComponent.razor | 7 + .../PropertiesChangedHandlerChild.razor | 14 + .../PropertiesChangedHandlerParent.razor | 6 + .../BasicTestApp/RedTextComponent.razor | 1 + .../BasicTestApp/RenderFragmentToggler.razor | 31 + .../BasicTestApp/SvgCircleComponent.razor | 1 + .../BlazorE2E/BasicTestApp/SvgComponent.razor | 9 + .../BasicTestApp/SvgWithChildComponent.razor | 5 + .../BasicTestApp/TextOnlyComponent.razor | 1 + tests/BlazorE2E/BasicTestApp/_Imports.razor | 1 + tests/BlazorE2E/ComponentRenderingTest.cs | 639 ++++++++++++++++++ .../GeneralEventDispatchExtensionsTest.cs | 2 +- tests/GlobalSuppressions.cs | 1 + tests/RefreshableQueryCollectionTest.cs | 74 ++ tests/SampleComponents/ClickAddsLi.razor | 10 + tests/SampleComponents/Simple1.razor | 2 +- tests/TestRendererTest.cs | 40 ++ 44 files changed, 1310 insertions(+), 32 deletions(-) create mode 100644 src/EventDispatchExtensions/MissingEventHandlerException.cs create mode 100644 src/Extensions/IRefreshableElementCollection.cs create mode 100644 src/Extensions/RefreshableElementCollection.cs create mode 100644 tests/BlazorE2E/BasicTestApp/AddRemoveChildComponents.razor create mode 100644 tests/BlazorE2E/BasicTestApp/AsyncEventHandlerComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/ComponentRefComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/CounterComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/CounterComponentUsingChild.razor create mode 100644 tests/BlazorE2E/BasicTestApp/CounterComponentWrapper.razor create mode 100644 tests/BlazorE2E/BasicTestApp/DataDashComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/ElementRefComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/ComponentUsingImports.razor create mode 100644 tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/_Imports.razor create mode 100644 tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/_Imports.razor create mode 100644 tests/BlazorE2E/BasicTestApp/HtmlBlockChildContent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/HtmlEncodedChildContent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/HtmlMixedChildContent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/KeyPressEventComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/LogicalElementInsertionCases.razor create mode 100644 tests/BlazorE2E/BasicTestApp/MessageComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/ParentChildComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/PassThroughContentComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerChild.razor create mode 100644 tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerParent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/RedTextComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/RenderFragmentToggler.razor create mode 100644 tests/BlazorE2E/BasicTestApp/SvgCircleComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/SvgComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/SvgWithChildComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/TextOnlyComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/_Imports.razor create mode 100644 tests/BlazorE2E/ComponentRenderingTest.cs create mode 100644 tests/RefreshableQueryCollectionTest.cs create mode 100644 tests/SampleComponents/ClickAddsLi.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b06da25..7f118c899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,19 +8,16 @@ This release includes a name change from Blazor Components Testing Library to ** ### Added - **`WaitForState(Func statePredicate, TimeSpan? timeout = 1 second)` has been added to `ITestContext` and `IRenderedFragment`.** - This method will wait (block) until the provided statePredicate returns true, or the timeout is reached (during debugging the timeout is disabled). Each time the renderer in the test context renders, or the rendered fragment renders, the statePredicate is evaluated. You use this method, if you have a component under test, that requires _one or more asynchronous triggered renders_, to get to a desired state, before the test can continue. - **`WaitForAssertion(Action assertion, TimeSpan? timeout = 1 second)` has been added to `ITestContext` and `IRenderedFragment`.** - This method will wait (block) until the provided assertion method passes, i.e. runs without throwing an assert exception, or until the timeout is reached (during debugging the timeout is disabled). Each time the renderer in the test context renders, or the rendered fragment renders, the assertion is attempted. You use this method, if you have a component under test, that requires _one or more asynchronous triggered renders_, to get to a desired state, before the test can continue. - **Added support for capturing log statements from the renderer and components under test into the test output.** - To enable this, add a constructor to your test classes that takes the `ITestOutputHelper` as input, then in the constructor call `Services.AddXunitLogger` and pass the `ITestOutputHelper` to it, e.g.: ```csharp @@ -60,7 +57,6 @@ This release includes a name change from Blazor Components Testing Library to ** ``` - **Added simpler `Template` helper method** - To make it easier to test components with `RenderFragment` parameters (template components) in C# based tests, a new `Template(string name, Func markupFactory)` helper methods have been added. It allows you to create a mock template that uses the `markupFactory` to create the rendered markup from the template. This is an example of testing the `SimpleWithTemplate.razor`, which looks like this: @@ -98,22 +94,82 @@ This release includes a name change from Blazor Components Testing Library to ** ); ``` +- **Added logging to TestRenderer.** To make it easier to understand the rendering life-cycle during a test, the `TestRenderer` will now log when ever it dispatches an event or renders a component (the log statements can be access by capturing debug logs in the test results, as mentioned above). + ### Changed - **Namespaces is now `Bunit`** The namespaces have changed from `Egil.RazorComponents.Testing.Library.*` to simply `Bunit` for the library, and `Bunit.Mocking.JSInterop` for the JSInterop mocking support. -- **Auto-updating `IElement`s returned from `Find()`** - `IRenderedFragment.Find(string cssSelector)` now returns a `IElement`, which internally will update itself, whenever the rendered fragment it was found in, changes. This means you can now search for an element once in your test and assign it to a variable, and then continue to assert against the same instance, even after triggering renders of the component under test. +- **Auto-refreshing `IElement`s returned from `Find()`** + `IRenderedFragment.Find(string cssSelector)` now returns a `IElement`, which internally will refresh itself, whenever the rendered fragment it was found in, changes. This means you can now search for an element once in your test and assign it to a variable, and then continue to assert against the same instance, even after triggering renders of the component under test. For example, instead of having `cut.Find("p")` in multiple places in the same test, you can do `var p = cut.Find("p")` once, and the use the variable `p` all the places you would otherwise have the `Find(...)` statement. +- **Refreshable element collection returned from `FindAll`.** + The `FindAll` query method on `IRenderedFragment` now returns a new type, the `IRefreshableElementCollection` type, and the method also takes a second optional argument now, `bool enableAutoRefresh = false`. + + The `IRefreshableElementCollection` is a special collection type that can rerun the query to refresh its the collection of elements that are found by the CSS selector. This can either be done manually by calling the `Refresh()` method, or automatically whenever the rendered fragment renders and has changes, by setting the property `EnableAutoRefresh` to `true` (default set to `false`). + + Here are two example tests, that both test the following `ClickAddsLi.razor` component: + + ```cshtml +
    + @foreach (var x in Enumerable.Range(0, Counter)) + { +
  • @x
  • + } +
+ + @code { + public int Counter { get; set; } = 0; + } + ``` + + The first tests uses auto refresh, set through the optional parameter `enableAutoRefresh` passed to FindAll: + + ```csharp + public void AutoRefreshQueriesForNewElementsAutomatically() + { + var cut = RenderComponent(); + var liElements = cut.FindAll("li", enableAutoRefresh: true); + liElements.Count.ShouldBe(0); + + cut.Find("button").Click(); + + liElements.Count.ShouldBe(1); + } + ``` + + The second test refreshes the collection manually through the `Refresh()` method on the collection: + + ```csharp + public void RefreshQueriesForNewElements() + { + var cut = RenderComponent(); + var liElements = cut.FindAll("li"); + liElements.Count.ShouldBe(0); + + cut.Find("button").Click(); + + liElements.Refresh(); // Refresh the collection + liElements.Count.ShouldBe(1); + } + ``` + +- **Custom exception when event handler is missing.** Attempting to triggering a event handler on an element which does not have an handler attached now throws a `MissingEventHandlerException` exception, instead of an `ArgumentException`. + ### Deprecated -- `WaitForNextRender` has been deprecated (marked as obsolete), since the added `WaitForState` and `WaitForAssertion` provide a much better foundation to build stable tests on. The plan is to remove completely from the library with the final 1.0.0 release. +- **`WaitForNextRender` has been deprecated (marked as obsolete)**, since the added `WaitForState` and `WaitForAssertion` provide a much better foundation to build stable tests on. The plan is to remove completely from the library with the final 1.0.0 release. -- `AddMockHttp` and related helper methods for working with the [mockhttp](https://github.com/richardszalay/mockhttp) library has been removed from the library. This was done because the library really shouldn't have a dependency on a 3. party mocking library. It adds maintenance overhead and uneeded dependencies to it. +### Removed +- **`AddMockHttp` and related helper methods have been removed.** + The mocking of HTTPClient, supported through the [mockhttp](https://github.com/richardszalay/mockhttp) library, has been removed from the library. This was done because the library really shouldn't have a dependency on a 3. party mocking library. It adds maintenance overhead and uneeded dependencies to it. If you are using mockhttp, you can easily add again to your testing project. See [TODO Guide to mocking HttpClient](#) in the docs to learn how. -### Removed ### Fixed +- **Wrong casing on keyboard event dispatch helpers.** + The helper methods for the keyboard events was not probably cased, so that has been updated. E.g. from `Keypress(...)` to `KeyPress(...)`. + + ### Security \ No newline at end of file diff --git a/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs b/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs index e19a857b3..a3f9b6c8f 100644 --- a/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs @@ -6,6 +6,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq; using System.Threading.Tasks; namespace Bunit @@ -31,7 +32,7 @@ public static Task TriggerEventAsync(this IElement element, string eventName, Ev var eventHandlerIdString = element.GetAttribute(Htmlizer.ToBlazorAttribute(eventName)); if (string.IsNullOrEmpty(eventHandlerIdString)) - throw new ArgumentException($"The element does not have an event handler for the event '{eventName}'."); + throw new MissingEventHandlerException(element, eventName); var eventHandlerId = ulong.Parse(eventHandlerIdString, CultureInfo.InvariantCulture); @@ -39,7 +40,7 @@ public static Task TriggerEventAsync(this IElement element, string eventName, Ev if (renderer is null) throw new InvalidOperationException($"Blazor events can only be raised on elements rendered with the Blazor test renderer '{nameof(TestRenderer)}'."); - return renderer.DispatchEventAsync(eventHandlerId, new EventFieldInfo(), eventArgs); + return renderer.DispatchEventAsync(eventHandlerId, new EventFieldInfo() { FieldValue = eventName }, eventArgs); } /// diff --git a/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs b/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs index 078675d13..636657f62 100644 --- a/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs @@ -36,8 +36,8 @@ public static class KeyboardEventDispatchExtensions /// true if the alt key was down when the event was fired. false otherwise. /// true if the meta key was down when the event was fired. false otherwise. /// The type of the event. - public static void Keydown(this IElement element, string key, string? code = default, float location = default, bool repeat = default, bool ctrlKey = default, bool shiftKey = default, bool altKey = default, bool metaKey = default, string? type = default) - => KeydownAsync(element, new KeyboardEventArgs { Key = key, Code = code, Location = location, Repeat = repeat, CtrlKey = ctrlKey, ShiftKey = shiftKey, AltKey = altKey, MetaKey = metaKey, Type = type }); + public static void KeyDown(this IElement element, string key, string? code = default, float location = default, bool repeat = default, bool ctrlKey = default, bool shiftKey = default, bool altKey = default, bool metaKey = default, string? type = default) + => KeyDownAsync(element, new KeyboardEventArgs { Key = key, Code = code, Location = location, Repeat = repeat, CtrlKey = ctrlKey, ShiftKey = shiftKey, AltKey = altKey, MetaKey = metaKey, Type = type }); /// /// Raises the @onkeydown event on , passing the provided @@ -45,7 +45,7 @@ public static void Keydown(this IElement element, string key, string? code = def /// /// The element to raise the event on. /// The event arguments to pass to the event handler. - public static void Keydown(this IElement element, KeyboardEventArgs eventArgs) => _ = KeydownAsync(element, eventArgs); + public static void KeyDown(this IElement element, KeyboardEventArgs eventArgs) => _ = KeyDownAsync(element, eventArgs); /// /// Raises the @onkeydown event on , passing the provided @@ -54,7 +54,7 @@ public static void Keydown(this IElement element, string key, string? code = def /// /// /// A task that completes when the event handler is done. - public static Task KeydownAsync(this IElement element, KeyboardEventArgs eventArgs) => element.TriggerEventAsync("onkeydown", eventArgs); + public static Task KeyDownAsync(this IElement element, KeyboardEventArgs eventArgs) => element.TriggerEventAsync("onkeydown", eventArgs); /// /// Raises the @onkeyup event on , passing the provided @@ -79,8 +79,8 @@ public static void Keydown(this IElement element, string key, string? code = def /// true if the alt key was down when the event was fired. false otherwise. /// true if the meta key was down when the event was fired. false otherwise. /// The type of the event. - public static void Keyup(this IElement element, string key, string? code = default, float location = default, bool repeat = default, bool ctrlKey = default, bool shiftKey = default, bool altKey = default, bool metaKey = default, string? type = default) - => KeyupAsync(element, new KeyboardEventArgs { Key = key, Code = code, Location = location, Repeat = repeat, CtrlKey = ctrlKey, ShiftKey = shiftKey, AltKey = altKey, MetaKey = metaKey, Type = type }); + public static void KeyUp(this IElement element, string key, string? code = default, float location = default, bool repeat = default, bool ctrlKey = default, bool shiftKey = default, bool altKey = default, bool metaKey = default, string? type = default) + => KeyUpAsync(element, new KeyboardEventArgs { Key = key, Code = code, Location = location, Repeat = repeat, CtrlKey = ctrlKey, ShiftKey = shiftKey, AltKey = altKey, MetaKey = metaKey, Type = type }); /// /// Raises the @onkeyup event on , passing the provided @@ -88,7 +88,7 @@ public static void Keyup(this IElement element, string key, string? code = defau /// /// The element to raise the event on. /// The event arguments to pass to the event handler. - public static void Keyup(this IElement element, KeyboardEventArgs eventArgs) => _ = KeyupAsync(element, eventArgs); + public static void KeyUp(this IElement element, KeyboardEventArgs eventArgs) => _ = KeyUpAsync(element, eventArgs); /// /// Raises the @onkeyup event on , passing the provided @@ -97,7 +97,7 @@ public static void Keyup(this IElement element, string key, string? code = defau /// /// /// A task that completes when the event handler is done. - public static Task KeyupAsync(this IElement element, KeyboardEventArgs eventArgs) => element.TriggerEventAsync("onkeyup", eventArgs); + public static Task KeyUpAsync(this IElement element, KeyboardEventArgs eventArgs) => element.TriggerEventAsync("onkeyup", eventArgs); /// /// Raises the @onkeypress event on , passing the provided @@ -122,8 +122,8 @@ public static void Keyup(this IElement element, string key, string? code = defau /// true if the alt key was down when the event was fired. false otherwise. /// true if the meta key was down when the event was fired. false otherwise. /// The type of the event. - public static void Keypress(this IElement element, string key, string? code = default, float location = default, bool repeat = default, bool ctrlKey = default, bool shiftKey = default, bool altKey = default, bool metaKey = default, string? type = default) - => KeypressAsync(element, new KeyboardEventArgs { Key = key, Code = code, Location = location, Repeat = repeat, CtrlKey = ctrlKey, ShiftKey = shiftKey, AltKey = altKey, MetaKey = metaKey, Type = type }); + public static void KeyPress(this IElement element, string key, string? code = default, float location = default, bool repeat = default, bool ctrlKey = default, bool shiftKey = default, bool altKey = default, bool metaKey = default, string? type = default) + => KeyPressAsync(element, new KeyboardEventArgs { Key = key, Code = code, Location = location, Repeat = repeat, CtrlKey = ctrlKey, ShiftKey = shiftKey, AltKey = altKey, MetaKey = metaKey, Type = type }); /// /// Raises the @onkeypress event on , passing the provided @@ -131,7 +131,7 @@ public static void Keypress(this IElement element, string key, string? code = de /// /// The element to raise the event on. /// The event arguments to pass to the event handler. - public static void Keypress(this IElement element, KeyboardEventArgs eventArgs) => _ = KeypressAsync(element, eventArgs); + public static void KeyPress(this IElement element, KeyboardEventArgs eventArgs) => _ = KeyPressAsync(element, eventArgs); /// /// Raises the @onkeypress event on , passing the provided @@ -140,6 +140,6 @@ public static void Keypress(this IElement element, string key, string? code = de /// /// /// A task that completes when the event handler is done. - public static Task KeypressAsync(this IElement element, KeyboardEventArgs eventArgs) => element.TriggerEventAsync("onkeypress", eventArgs); + public static Task KeyPressAsync(this IElement element, KeyboardEventArgs eventArgs) => element.TriggerEventAsync("onkeypress", eventArgs); } } diff --git a/src/EventDispatchExtensions/MissingEventHandlerException.cs b/src/EventDispatchExtensions/MissingEventHandlerException.cs new file mode 100644 index 000000000..8a613230c --- /dev/null +++ b/src/EventDispatchExtensions/MissingEventHandlerException.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Runtime.Serialization; +using AngleSharp.Dom; + +namespace Bunit +{ + /// + /// Represents an exception that is thrown when triggering an event handler failed because it wasn't available on the targeted . + /// + internal class MissingEventHandlerException : Exception + { + public MissingEventHandlerException(IElement element, string missingEventName) : base(CreateErrorMessage(element, missingEventName)) + { + } + + private static string CreateErrorMessage(IElement element, string missingEventName) + { + var result = $"The element does not have an event handler for the event '{missingEventName}"; + var eventHandlers = element.Attributes? + .Where(x => x.Name.StartsWith(Htmlizer.BLAZOR_ATTR_PREFIX, StringComparison.Ordinal) && !x.Name.StartsWith(Htmlizer.ELEMENT_REFERENCE_ATTR_NAME, StringComparison.Ordinal)) + .Select(x => $"'{x.Name.Remove(0, Htmlizer.BLAZOR_ATTR_PREFIX.Length)}'") + .ToArray() ?? Array.Empty(); + + var suggestAlternatives = ", nor any other events."; + + if (eventHandlers.Length > 1) + suggestAlternatives = $". The element has event handlers for these events, {string.Join(", ", eventHandlers)}, that you can try instead."; + if (eventHandlers.Length == 1) + suggestAlternatives = $". The element has an event handler for {eventHandlers[0]} event, that you can try instead."; + + return $"{result}{suggestAlternatives}"; + } + + } +} \ No newline at end of file diff --git a/src/Extensions/IRefreshableElementCollection.cs b/src/Extensions/IRefreshableElementCollection.cs new file mode 100644 index 000000000..227c57192 --- /dev/null +++ b/src/Extensions/IRefreshableElementCollection.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using AngleSharp.Dom; + +namespace Bunit +{ + /// + /// Represents a collection, which queries and finds its + /// elements in an , based on a CSS selector. + /// The collection can be refreshed either manually or automatically. + /// + /// The type of in the collection. + public interface IRefreshableElementCollection : IReadOnlyList where T : IElement + { + /// + /// Gets or sets whether the collection automatically refreshes when the + /// changes. + /// + bool EnableAutoRefresh { get; set; } + + /// + /// Trigger a refresh of the elements in the collection, by querying the rendered fragments DOM tree. + /// + void Refresh(); + } +} diff --git a/src/Extensions/RefreshableElementCollection.cs b/src/Extensions/RefreshableElementCollection.cs new file mode 100644 index 000000000..0eb0f7d0d --- /dev/null +++ b/src/Extensions/RefreshableElementCollection.cs @@ -0,0 +1,57 @@ +using System.Collections; +using System.Collections.Generic; +using AngleSharp.Dom; + +namespace Bunit +{ + internal class RefreshableElementCollection : IRefreshableElementCollection + { + private readonly IRenderedFragment _renderedFragment; + private readonly string _cssSelector; + private IHtmlCollection _elements; + private ComponentChangeEventSubscriber? _changeEvents; + private bool _enableAutoRefresh = false; + + public bool EnableAutoRefresh + { + get => _enableAutoRefresh; + set + { + // not enabled and should enable + if (value && !_enableAutoRefresh) + { + _changeEvents?.Unsubscribe(); + _changeEvents = new ComponentChangeEventSubscriber(_renderedFragment, _ => Refresh()); + } + if (!value && _enableAutoRefresh) + { + _changeEvents?.Unsubscribe(); + _changeEvents = null; + } + _enableAutoRefresh = value; + } + } + + public RefreshableElementCollection(IRenderedFragment renderedFragment, string cssSelector) + { + _renderedFragment = renderedFragment; + _cssSelector = cssSelector; + _elements = RefreshInternal(); + } + + public void Refresh() + { + _elements = RefreshInternal(); + } + + public IElement this[int index] => _elements[index]; + + public int Count => _elements.Length; + + public IEnumerator GetEnumerator() => _elements.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private IHtmlCollection RefreshInternal() => _renderedFragment.Nodes.QuerySelectorAll(_cssSelector); + } +} diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs index e41e23190..c1895a8a5 100644 --- a/src/Extensions/RenderedFragmentQueryExtensions.cs +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -31,16 +30,18 @@ public static IElement Find(this IRenderedFragment renderedFragment, string cssS } /// - /// Returns a list of elements from the rendered fragment or component under test, + /// Returns a refreshable collection of s from the rendered fragment or component under test, /// using the provided , in a depth-first pre-order traversal /// of the rendered nodes. /// /// The rendered fragment to search. /// The group of selectors to use. - public static IHtmlCollection FindAll(this IRenderedFragment renderedFragment, string cssSelector) + /// If true, the returned will automatically refresh its s whenever the changes. + /// An , that can be refreshed to execute the search again. + public static IRefreshableElementCollection FindAll(this IRenderedFragment renderedFragment, string cssSelector, bool enableAutoRefresh = false) { if (renderedFragment is null) throw new ArgumentNullException(nameof(renderedFragment)); - return renderedFragment.Nodes.QuerySelectorAll(cssSelector); + return new RefreshableElementCollection(renderedFragment, cssSelector) { EnableAutoRefresh = enableAutoRefresh }; } } } diff --git a/src/GlobalSuppressions.cs b/src/GlobalSuppressions.cs index 7b090b59b..0201cdfdb 100644 --- a/src/GlobalSuppressions.cs +++ b/src/GlobalSuppressions.cs @@ -10,3 +10,4 @@ [assembly: SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "No need to translate at this point", Scope = "namespaceanddescendants", Target = "Bunit")] [assembly: SuppressMessage("Design", "CA1032:Implement standard exception constructors")] +[assembly: SuppressMessage("Design", "CA1064:Exceptions should be public")] diff --git a/src/Rendering/TestRenderer.cs b/src/Rendering/TestRenderer.cs index 38e29f10e..2a19cd79d 100644 --- a/src/Rendering/TestRenderer.cs +++ b/src/Rendering/TestRenderer.cs @@ -8,6 +8,7 @@ using System.Runtime.ExceptionServices; using System.Threading.Tasks; using System.Threading; +using Microsoft.Extensions.Logging.Abstractions; namespace Bunit { @@ -18,6 +19,7 @@ namespace Bunit public class TestRenderer : Renderer { private readonly RenderEventPublisher _renderEventPublisher; + private readonly ILogger _logger; private Exception? _unhandledException; /// @@ -30,10 +32,10 @@ public class TestRenderer : Renderer public IObservable RenderEvents { get; } /// - public TestRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) - : base(serviceProvider, loggerFactory) + public TestRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) : base(serviceProvider, loggerFactory) { _renderEventPublisher = new RenderEventPublisher(); + _logger = loggerFactory?.CreateLogger(GetType().FullName) ?? NullLogger.Instance; RenderEvents = _renderEventPublisher; } @@ -48,11 +50,14 @@ public int AttachTestRootComponent(IComponent testRootComponent) /// public new Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo fieldInfo, EventArgs eventArgs) { + if (fieldInfo is null) throw new ArgumentNullException(nameof(fieldInfo)); + _logger.LogDebug(new EventId(1, nameof(DispatchEventAsync)), $"Starting trigger of '{fieldInfo.FieldValue}'"); + var task = Dispatcher.InvokeAsync(() => { try { - base.DispatchEventAsync(eventHandlerId, fieldInfo, eventArgs); + return base.DispatchEventAsync(eventHandlerId, fieldInfo, eventArgs); } catch (Exception e) { @@ -60,7 +65,10 @@ public int AttachTestRootComponent(IComponent testRootComponent) throw; } }); + AssertNoSynchronousErrors(); + + _logger.LogDebug(new EventId(1, nameof(DispatchEventAsync)), $"Finished trigger of '{fieldInfo.FieldValue}'"); return task; } @@ -73,6 +81,7 @@ protected override void HandleException(Exception exception) /// protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { + _logger.LogDebug(new EventId(0, nameof(UpdateDisplayAsync)), $"New render batch with ReferenceFrames = {renderBatch.ReferenceFrames.Count}, UpdatedComponents = {renderBatch.UpdatedComponents.Count}, DisposedComponentIDs = {renderBatch.DisposedComponentIDs.Count}, DisposedEventHandlerIDs = {renderBatch.DisposedEventHandlerIDs.Count}"); var renderEvent = new RenderEvent(in renderBatch, this); _renderEventPublisher.OnRender(renderEvent); return Task.CompletedTask; diff --git a/tests/BlazorE2E/BasicTestApp/AddRemoveChildComponents.razor b/tests/BlazorE2E/BasicTestApp/AddRemoveChildComponents.razor new file mode 100644 index 000000000..1c05a7166 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/AddRemoveChildComponents.razor @@ -0,0 +1,28 @@ +@using System.Collections.Generic +Child components follow. + + + +@foreach (var message in currentChildrenMessages) +{ +

+} + +@code { + int numAdded = 0; + List currentChildrenMessages = new List(); + + void AddChild() + { + numAdded++; + currentChildrenMessages.Add($"Child {numAdded}"); + } + + void RemoveChild() + { + if (currentChildrenMessages.Count > 0) + { + currentChildrenMessages.RemoveAt(currentChildrenMessages.Count - 1); + } + } +} diff --git a/tests/BlazorE2E/BasicTestApp/AsyncEventHandlerComponent.razor b/tests/BlazorE2E/BasicTestApp/AsyncEventHandlerComponent.razor new file mode 100644 index 000000000..1eceb8a84 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/AsyncEventHandlerComponent.razor @@ -0,0 +1,35 @@ +@using System.Threading.Tasks + +
+ @state + + +
+ +@code +{ + #nullable disable + TaskCompletionSource _tcs; + string state = "Stopped"; + Task Tick(MouseEventArgs e) + { + if (_tcs == null) + { + _tcs = new TaskCompletionSource(); + + state = "Started"; + return _tcs.Task.ContinueWith((task) => + { + state = "Stopped"; + _tcs = null; + }); + } + + return Task.CompletedTask; + } + + void Tock(MouseEventArgs e) + { + _tcs.TrySetResult(null); + } +} diff --git a/tests/BlazorE2E/BasicTestApp/ComponentRefComponent.razor b/tests/BlazorE2E/BasicTestApp/ComponentRefComponent.razor new file mode 100644 index 000000000..a26e4ccf9 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/ComponentRefComponent.razor @@ -0,0 +1,36 @@ +

Component capture

+ +

+ This shows how a component reference may be captured as a field value using 'ref' syntax. + This feature is intended only for cases where you're triggering an action on the child + instance. It should not be used as a way of mutating state in the child, + because that would bypass all the benefits of flowing parameters to children and re-rendering + automatically only when required. +

+ +@if (_toggleCapturedComponentPresence) +{ +
+ +
+} + +
+ External controls + + +
+ +@code { + #nullable disable + bool _toggleCapturedComponentPresence = true; + CounterComponent _myChildCounter; + + void ResetChildCounter() + { + _myChildCounter.Reset(); + } +} diff --git a/tests/BlazorE2E/BasicTestApp/CounterComponent.razor b/tests/BlazorE2E/BasicTestApp/CounterComponent.razor new file mode 100644 index 000000000..9de91502e --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/CounterComponent.razor @@ -0,0 +1,29 @@ +

Counter

+

Current count: @currentCount

+

+ + + +@if (handleClicks) +{ +

Listening

+} + +@code { + int currentCount = 0; + bool handleClicks = true; + + void IncrementCount() + { + currentCount++; + } + + public void Reset() + { + currentCount = 0; + StateHasChanged(); + } +} diff --git a/tests/BlazorE2E/BasicTestApp/CounterComponentUsingChild.razor b/tests/BlazorE2E/BasicTestApp/CounterComponentUsingChild.razor new file mode 100644 index 000000000..74a639a50 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/CounterComponentUsingChild.razor @@ -0,0 +1,15 @@ +

Counter

+ + +

Current count:

+ + + +@code { + int currentCount = 0; + + void IncrementCount() + { + currentCount++; + } +} diff --git a/tests/BlazorE2E/BasicTestApp/CounterComponentWrapper.razor b/tests/BlazorE2E/BasicTestApp/CounterComponentWrapper.razor new file mode 100644 index 000000000..5b957df6a --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/CounterComponentWrapper.razor @@ -0,0 +1,7 @@ +

Counter wrapper

+ +This is the parent component. Here comes the counter: + + + +Finished. diff --git a/tests/BlazorE2E/BasicTestApp/DataDashComponent.razor b/tests/BlazorE2E/BasicTestApp/DataDashComponent.razor new file mode 100644 index 000000000..435ef0fc0 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/DataDashComponent.razor @@ -0,0 +1,5 @@ +
@TabId
+ +@code { + string TabId = "17"; +} diff --git a/tests/BlazorE2E/BasicTestApp/ElementRefComponent.razor b/tests/BlazorE2E/BasicTestApp/ElementRefComponent.razor new file mode 100644 index 000000000..36a003b48 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/ElementRefComponent.razor @@ -0,0 +1,37 @@ +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime + +

Element capture

+ +

+ This shows how an element reference may be captured as a field value using 'ref' syntax and then + passed to JavaScript code, which receives the actual DOM element instance. +

+

+ Note that 'ref' syntax is primarily intended for use with JavaScript interop. It is not + recommended to use it for mutating the DOM routinely. All DOM construction and mutation that can be + done declaratively is better, as it automatically happens at the correct time and with minimal diffs. + Plus, whenever you use 'ref', you will not be able to run the same code during unit tests or + server-side rendering. So it's always better to prefer declarative UI construction when possible. +

+ +@if (_toggleCapturedElementPresence) +{ + +} + + + +@code { + int _count = 0; + bool _toggleCapturedElementPresence = true; + ElementReference _myInput; + + async Task MakeInteropCall() + { + await JSRuntime.InvokeAsync("setElementValue", _myInput, $"Clicks: {++_count}"); + } +} diff --git a/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/ComponentUsingImports.razor b/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/ComponentUsingImports.razor new file mode 100644 index 000000000..c7b1530e5 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/ComponentUsingImports.razor @@ -0,0 +1,5 @@ +The following two outputs rely on "using" directives in _ViewImports files at the current and ancestor levels. + +

@(typeof(Complex).FullName)

+ +

@(typeof(AssemblyHashAlgorithm).FullName)

diff --git a/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/_Imports.razor b/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/_Imports.razor new file mode 100644 index 000000000..ff4b8e8cd --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/Subdir/_Imports.razor @@ -0,0 +1 @@ +@using System.Configuration.Assemblies diff --git a/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/_Imports.razor b/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/_Imports.razor new file mode 100644 index 000000000..ce4779352 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/HierarchicalImportsTest/_Imports.razor @@ -0,0 +1 @@ +@using System.Numerics diff --git a/tests/BlazorE2E/BasicTestApp/HtmlBlockChildContent.razor b/tests/BlazorE2E/BasicTestApp/HtmlBlockChildContent.razor new file mode 100644 index 000000000..411bf362f --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/HtmlBlockChildContent.razor @@ -0,0 +1 @@ +

Some-Static-Text

\ No newline at end of file diff --git a/tests/BlazorE2E/BasicTestApp/HtmlEncodedChildContent.razor b/tests/BlazorE2E/BasicTestApp/HtmlEncodedChildContent.razor new file mode 100644 index 000000000..e621b8589 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/HtmlEncodedChildContent.razor @@ -0,0 +1,7 @@ +
+ +

Some-Static-Text

+
<span>More-Static-Text</span>
+
@("Some-Dynamic-Text")But this is static
+
+
\ No newline at end of file diff --git a/tests/BlazorE2E/BasicTestApp/HtmlMixedChildContent.razor b/tests/BlazorE2E/BasicTestApp/HtmlMixedChildContent.razor new file mode 100644 index 000000000..97808e288 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/HtmlMixedChildContent.razor @@ -0,0 +1,7 @@ +
+ +

Some-Static-Text

+
More-Static-Text
+
@("Some-Dynamic-Text")But this is static
+
+
\ No newline at end of file diff --git a/tests/BlazorE2E/BasicTestApp/KeyPressEventComponent.razor b/tests/BlazorE2E/BasicTestApp/KeyPressEventComponent.razor new file mode 100644 index 000000000..dd96e41d5 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/KeyPressEventComponent.razor @@ -0,0 +1,19 @@ +@using System.Text.Json + +Type here: +
    + @foreach (var key in keysPressed) + { +
  • @key
  • + } +
+ +@code { + List keysPressed = new List(); + + void OnKeyPressed(KeyboardEventArgs eventArgs) + { + Console.WriteLine(JsonSerializer.Serialize(eventArgs)); + keysPressed.Add(eventArgs.Key); + } +} diff --git a/tests/BlazorE2E/BasicTestApp/LogicalElementInsertionCases.razor b/tests/BlazorE2E/BasicTestApp/LogicalElementInsertionCases.razor new file mode 100644 index 000000000..50a261097 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/LogicalElementInsertionCases.razor @@ -0,0 +1,10 @@ + +First +Second +Third diff --git a/tests/BlazorE2E/BasicTestApp/MessageComponent.razor b/tests/BlazorE2E/BasicTestApp/MessageComponent.razor new file mode 100644 index 000000000..184d9da51 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/MessageComponent.razor @@ -0,0 +1,5 @@ +@Message +@code { + #nullable disable + [Parameter] public string Message { get; set; } +} diff --git a/tests/BlazorE2E/BasicTestApp/ParentChildComponent.razor b/tests/BlazorE2E/BasicTestApp/ParentChildComponent.razor new file mode 100644 index 000000000..a9d6823b1 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/ParentChildComponent.razor @@ -0,0 +1,4 @@ +
+ Parent component + +
diff --git a/tests/BlazorE2E/BasicTestApp/PassThroughContentComponent.razor b/tests/BlazorE2E/BasicTestApp/PassThroughContentComponent.razor new file mode 100644 index 000000000..caa9a5ba5 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/PassThroughContentComponent.razor @@ -0,0 +1,7 @@ +@ChildContent +@code { + // Note: The lack of any whitespace or other output besides @ChildContent is important for + // what scenarios this component is used for in E2E tests. + #nullable disable + [Parameter] public RenderFragment ChildContent { get; set; } +} diff --git a/tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerChild.razor b/tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerChild.razor new file mode 100644 index 000000000..131b5c6ff --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerChild.razor @@ -0,0 +1,14 @@ +
You supplied: @SuppliedValue
+
I computed: @computedValue
+ +@code { + [Parameter] + public int SuppliedValue { get; set; } + + private int computedValue; + + protected override void OnParametersSet() + { + computedValue = SuppliedValue * 2; + } +} diff --git a/tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerParent.razor b/tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerParent.razor new file mode 100644 index 000000000..476e8cbb1 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/PropertiesChangedHandlerParent.razor @@ -0,0 +1,6 @@ + + + +@code { + private int valueToSupply = 100; +} diff --git a/tests/BlazorE2E/BasicTestApp/RedTextComponent.razor b/tests/BlazorE2E/BasicTestApp/RedTextComponent.razor new file mode 100644 index 000000000..fa16e4f9b --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/RedTextComponent.razor @@ -0,0 +1 @@ +

Hello, world!

diff --git a/tests/BlazorE2E/BasicTestApp/RenderFragmentToggler.razor b/tests/BlazorE2E/BasicTestApp/RenderFragmentToggler.razor new file mode 100644 index 000000000..a13806f75 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/RenderFragmentToggler.razor @@ -0,0 +1,31 @@ +
+

Fragment will be toggled below

+ + @if (showFragment) + { + @ExampleFragment + } + + +

The end

+
+ +@code { + bool showFragment; + + static RenderFragment ExampleFragment = builder => + { + // TODO: Improve syntax + // Ideally we'd support inline Razor syntax here and are investigating this + // Could be: + // static RenderFragment ExampleFragment() + // => @

Some text

Child text

; // spaced over multiple lines, of course + // Failing that, we could have an C#-based representation, e.g., + // new Element.P { "Some text", new Element.Div { "Child text" } }, etc. + builder.OpenElement(100, "p"); + builder.AddAttribute(101, "name", "fragment-element"); + builder.AddAttribute(102, "style", "color: red"); + builder.AddContent(103, "This is from the fragment"); + builder.CloseElement(); + }; +} diff --git a/tests/BlazorE2E/BasicTestApp/SvgCircleComponent.razor b/tests/BlazorE2E/BasicTestApp/SvgCircleComponent.razor new file mode 100644 index 000000000..1c2849b7c --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/SvgCircleComponent.razor @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/BlazorE2E/BasicTestApp/SvgComponent.razor b/tests/BlazorE2E/BasicTestApp/SvgComponent.razor new file mode 100644 index 000000000..2cece5930 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/SvgComponent.razor @@ -0,0 +1,9 @@ + + + + + + +@code { + int radius = 10; +} diff --git a/tests/BlazorE2E/BasicTestApp/SvgWithChildComponent.razor b/tests/BlazorE2E/BasicTestApp/SvgWithChildComponent.razor new file mode 100644 index 000000000..8ef5bd204 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/SvgWithChildComponent.razor @@ -0,0 +1,5 @@ +

SVG with Child Component

+ + + + \ No newline at end of file diff --git a/tests/BlazorE2E/BasicTestApp/TextOnlyComponent.razor b/tests/BlazorE2E/BasicTestApp/TextOnlyComponent.razor new file mode 100644 index 000000000..e1225d91c --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/TextOnlyComponent.razor @@ -0,0 +1 @@ +Hello from TextOnlyComponent \ No newline at end of file diff --git a/tests/BlazorE2E/BasicTestApp/_Imports.razor b/tests/BlazorE2E/BasicTestApp/_Imports.razor new file mode 100644 index 000000000..2b9557ce5 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/_Imports.razor @@ -0,0 +1 @@ +@using Microsoft.AspNetCore.Components.Web \ No newline at end of file diff --git a/tests/BlazorE2E/ComponentRenderingTest.cs b/tests/BlazorE2E/ComponentRenderingTest.cs new file mode 100644 index 000000000..051a897f0 --- /dev/null +++ b/tests/BlazorE2E/ComponentRenderingTest.cs @@ -0,0 +1,639 @@ +using System; +using System.Collections.Generic; +using System.Configuration.Assemblies; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using AngleSharp.Dom; +using Bunit.BlazorE2E.BasicTestApp; +using Bunit.BlazorE2E.BasicTestApp.HierarchicalImportsTest.Subdir; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace Bunit.BlazorE2E +{ + /// + /// This tests are based on the tests from the following AspNetCore tests class. + /// The aim is to only modify the original tests to not use Selenium, and instead use the + /// . + /// https://github.com/dotnet/aspnetcore/blob/master/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs + /// + public class ComponentRenderingTest : ComponentTestFixture + { + public ComponentRenderingTest(ITestOutputHelper output) + { + Services.AddXunitLogger(output); + } + + [Fact] + public void CanRenderTextOnlyComponent() + { + var cut = RenderComponent(); + Assert.Equal("Hello from TextOnlyComponent", cut.Markup); + } + + // This verifies that we've correctly configured the Razor language version via MSBuild. + // See #974 + [Fact] + public void CanRenderComponentWithDataDash() + { + var cut = RenderComponent(); + var element = cut.Find("#cool_beans"); + Assert.Equal("17", element.GetAttribute("data-tab")); + Assert.Equal("17", element.TextContent); + } + + [Fact] + public void CanRenderComponentWithAttributes() + { + var cut = RenderComponent(); + var styledElement = cut.Find("h1"); + Assert.Equal("Hello, world!", styledElement.TextContent); + Assert.Equal("color: red;", styledElement.GetAttribute("style")); + Assert.Equal("somevalue", styledElement.GetAttribute("customattribute")); + } + + [Fact] + public void CanTriggerEvents() + { + // Initial count is zero + var cut = RenderComponent(); + var countDisplayElement = cut.Find("p"); + Assert.Equal("Current count: 0", countDisplayElement.TextContent); + + // Clicking button increments count + cut.Find("button").Click(); + Assert.Equal("Current count: 1", countDisplayElement.TextContent); + } + + [Fact] + public void CanTriggerAsyncEventHandlers() + { + // Initial state is stopped + var cut = RenderComponent(); + var stateElement = cut.Find("#state"); + Assert.Equal("Stopped", stateElement.TextContent); + + // Clicking 'tick' changes the state, and starts a task + cut.Find("#tick").Click(); + Assert.Equal("Started", stateElement.TextContent); + + cut.Find("#tock").Click(); + cut.WaitForAssertion(() => Assert.Equal("Stopped", stateElement.TextContent)); + } + + [Fact] + public void CanTriggerKeyPressEvents() + { + // List is initially empty + var cut = RenderComponent(); + var inputElement = cut.Find("input"); + var liElements = cut.FindAll("li", enableAutoRefresh: true); + liElements.ShouldBeEmpty(); + + // Typing adds element + inputElement.KeyPress("a"); + liElements.ShouldAllBe(li => Assert.Equal("a", li.TextContent)); + + // Typing again adds another element + inputElement.KeyPress("b"); + liElements.ShouldAllBe( + li => Assert.Equal("a", li.TextContent), + li => Assert.Equal("b", li.TextContent) + ); + } + + [Fact(DisplayName = "After KeyPress event is triggered, contains keys passed to KeyPress", Skip = "Issue #46 - https://github.com/egil/razor-components-testing-library/issues/46")] + public void Test001() + { + var cut = RenderComponent(); + + cut.Find("input").KeyPress("abc"); + + cut.Find("input").GetAttribute("value").ShouldBe("abc"); + } + + [Fact] + public void CanAddAndRemoveEventHandlersDynamically() + { + var cut = RenderComponent(); + var countDisplayElement = cut.Find("p"); + var incrementButton = cut.Find("button"); + var toggleClickHandlerCheckbox = cut.Find("[type=checkbox]"); + + // Initial count is zero; clicking button increments count + Assert.Equal("Current count: 0", countDisplayElement.TextContent); + incrementButton.Click(); + Assert.Equal("Current count: 1", countDisplayElement.TextContent); + + // We can remove an event handler + toggleClickHandlerCheckbox.Change(false); + Assert.Empty(cut.FindAll("#listening-message")); + incrementButton.Click(); + Assert.Equal("Current count: 1", countDisplayElement.TextContent); + + // We can add an event handler + toggleClickHandlerCheckbox.Change(true); + cut.Find("#listening-message"); // throws if non is found. + incrementButton.Click(); + Assert.Equal("Current count: 2", countDisplayElement.TextContent); + } + + [Fact] + public void CanRenderChildComponents() + { + var cut = RenderComponent(); + Assert.Equal("Parent component", cut.Find("fieldset > legend").TextContent); + + var styledElement = cut.Find("fieldset > h1"); + Assert.Equal("Hello, world!", styledElement.TextContent); + Assert.Equal("color: red;", styledElement.GetAttribute("style")); + Assert.Equal("somevalue", styledElement.GetAttribute("customattribute")); + } + + [Fact(DisplayName = "Verifies we can render HTML content as a single block")] + public void CanRenderChildContent_StaticHtmlBlock() + { + var cut = RenderComponent(); + Assert.Equal("

Some-Static-Text

", cut.Find("#foo").InnerHtml); + } + + [Fact(DisplayName = "Verifies we can rewite more complex HTML content into blocks")] + public void CanRenderChildContent_MixedHtmlBlock() + { + var cut = RenderComponent(); + + var one = cut.Find("#one"); + Assert.Equal("

Some-Static-Text

", one.InnerHtml); + + var two = cut.Find("#two"); + Assert.Equal("More-Static-Text", two.InnerHtml); + + var three = cut.Find("#three"); + Assert.Equal("Some-Dynamic-Text", three.InnerHtml); + + var four = cut.Find("#four"); + Assert.Equal("But this is static", four.InnerHtml); + } + + [Fact(DisplayName = "Verifies we can rewrite HTML blocks with encoded HTML")] + public void CanRenderChildContent_EncodedHtmlInBlock() + { + var cut = RenderComponent(); + + var one = cut.Find("#one"); + Assert.Equal("

Some-Static-Text

", one.InnerHtml); + + var two = cut.Find("#two"); + Assert.Equal("<span>More-Static-Text</span>", two.InnerHtml); + + var three = cut.Find("#three"); + Assert.Equal("Some-Dynamic-Text", three.InnerHtml); + + var four = cut.Find("#four"); + Assert.Equal("But this is static", four.InnerHtml); + } + + [Fact] + public void CanTriggerEventsOnChildComponents() + { + // Counter is displayed as child component. Initial count is zero. + var cut = RenderComponent(); + + // Clicking increments count in child component + cut.Find("button").Click(); + + Assert.Equal("Current count: 1", cut.Find("h1+p").TextContent); + } + + [Fact] + public void ChildComponentsRerenderWhenPropertiesChanged() + { + // Count value is displayed in child component with initial value zero + var cut = RenderComponent(); + var wholeCounterElement = cut.Find("p"); + var messageElementInChild = cut.Find("p .message"); + Assert.Equal("Current count: 0", wholeCounterElement.TextContent); + Assert.Equal("0", messageElementInChild.TextContent); + + // Clicking increments count in child element + cut.Find("button").Click(); + Assert.Equal("1", messageElementInChild.TextContent); + } + + [Fact] + public void CanAddAndRemoveChildComponentsDynamically() + { + // Initially there are zero child components + var cut = RenderComponent(); + var addButton = cut.Find(".addChild"); + var removeButton = cut.Find(".removeChild"); + Assert.Empty(cut.FindAll("p")); + + // Click to add/remove some child components + addButton.Click(); + Assert.Collection(cut.FindAll("p .message"), + msg => Assert.Equal("Child 1", msg.TextContent)); + + addButton.Click(); + Assert.Collection(cut.FindAll("p .message"), + msg => Assert.Equal("Child 1", msg.TextContent), + msg => Assert.Equal("Child 2", msg.TextContent)); + + removeButton.Click(); + Assert.Collection(cut.FindAll("p .message"), + msg => Assert.Equal("Child 1", msg.TextContent)); + + addButton.Click(); + Assert.Collection(cut.FindAll("p .message"), + msg => Assert.Equal("Child 1", msg.TextContent), + msg => Assert.Equal("Child 3", msg.TextContent)); + } + + [Fact] + public void ChildComponentsNotifiedWhenPropertiesChanged() + { + // Child component receives notification that lets it compute a property before first render + var cut = RenderComponent(); + var suppliedValueElement = cut.Find(".supplied"); + var computedValueElement = cut.Find(".computed"); + var incrementButton = cut.Find("button"); + Assert.Equal("You supplied: 100", suppliedValueElement.TextContent); + Assert.Equal("I computed: 200", computedValueElement.TextContent); + + // When property changes, child is renotified before rerender + incrementButton.Click(); + Assert.Equal("You supplied: 101", suppliedValueElement.TextContent); + Assert.Equal("I computed: 202", computedValueElement.TextContent); + } + + [Fact] + public void CanRenderFragmentsWhilePreservingSurroundingElements() + { + // Initially, the region isn't shown + var cut = RenderComponent(); + var originalButton = cut.Find("button"); + + var fragmentElements = cut.FindAll("p[name=fragment-element]", enableAutoRefresh: true); + Assert.Empty(fragmentElements); + + // The JS-side DOM builder handles regions correctly, placing elements + // after the region after the corresponding elements + Assert.Equal("The end", cut.Find("div > *:last-child").TextContent); + + // When we click the button, the region is shown + originalButton.Click(); + fragmentElements.Single(); + + // The button itself was preserved, so we can click it again and see the effect + originalButton.Click(); + Assert.Empty(fragmentElements); + } + + [Fact] + public void CanUseViewImportsHierarchically() + { + // The component is able to compile and output these type names only because + // of the _ViewImports.cshtml files at the same and ancestor levels + var cut = RenderComponent(); + Assert.Collection(cut.FindAll("p"), + elem => Assert.Equal(typeof(Complex).FullName, elem.TextContent), + elem => Assert.Equal(typeof(AssemblyHashAlgorithm).FullName, elem.TextContent)); + } + + [Fact(Skip = "Test doesn't make sense in this context")] + public void CanUseComponentAndStaticContentFromExternalNuGetPackage() + { + //var appElement = Browser.MountTestComponent(); + + //// NuGet packages can use JS interop features to provide + //// .NET code access to browser APIs + //var showPromptButton = appElement.FindElements(By.TagName("button")).First(); + //showPromptButton.Click(); + + //var modal = new WebDriverWait(Browser, TimeSpan.FromSeconds(3)) + // .Until(SwitchToAlert); + //modal.SendKeys("Some value from test"); + //modal.Accept(); + //var promptResult = appElement.FindElement(By.TagName("strong")); + //Browser.Equal("Some value from test", () => promptResult.Text); + + //// NuGet packages can also embed entire components (themselves + //// authored as Razor files), including static content. The CSS value + //// here is in a .css file, so if it's correct we know that static content + //// file was loaded. + //var specialStyleDiv = appElement.FindElement(By.ClassName("special-style")); + //Assert.Equal("50px", specialStyleDiv.GetCssValue("padding")); + + //// The external components are fully functional, not just static HTML + //var externalComponentButton = specialStyleDiv.FindElement(By.TagName("button")); + //Assert.Equal("Click me", externalComponentButton.Text); + //externalComponentButton.Click(); + //Browser.Equal("It works", () => externalComponentButton.Text); + } + + [Fact] + public void CanRenderSvgWithCorrectNamespace() + { + var cut = RenderComponent(); + + var svgElement = cut.Find("svg"); + Assert.NotNull(svgElement); + + var svgCircleElement = cut.Find("svg circle"); + Assert.NotNull(svgCircleElement); + Assert.Equal("10", svgCircleElement.GetAttribute("r")); + + cut.Find("button").Click(); + Assert.Equal("20", svgCircleElement.GetAttribute("r")); + } + + [Fact] + public void CanRenderSvgChildComponentWithCorrectNamespace() + { + var cut = RenderComponent(); + + var svgElement = cut.Find("svg"); + Assert.NotNull(svgElement); + + var svgCircleElement = cut.Find("svg circle"); + Assert.NotNull(svgCircleElement); + } + + [Fact] + public void LogicalElementInsertionWorksHierarchically() + { + var cut = RenderComponent(); + cut.MarkupMatches("First Second Third"); + } + + [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] + public void CanUseJsInteropToReferenceElements() + { + //var cut = RenderComponent(); + //var inputElement = cut.Find("#capturedElement"); + //var buttonElement = cut.Find("button"); + + //Assert.Equal(string.Empty, inputElement.GetAttribute("value")); + + //buttonElement.Click(); + //Assert.Equal("Clicks: 1", inputElement.GetAttribute("value")); + //buttonElement.Click(); + //Assert.Equal("Clicks: 2", inputElement.GetAttribute("value")); + } + + [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] + public void CanCaptureReferencesToDynamicallyAddedElements() + { + //var cut = RenderComponent(); + //var buttonElement = cut.Find("button"); + //var checkbox = cut.Find("input[type=checkbox]"); + + //// We're going to remove the input. But first, put in some contents + //// so we can observe it's not the same instance later + //cut.Find("#capturedElement").SendKeys("some text"); + + //// Remove the captured element + //checkbox.Click(); + //Browser.Empty(() => cut.FindAll("#capturedElement")); + + //// Re-add it; observe it starts empty again + //checkbox.Click(); + //var inputElement = cut.Find("#capturedElement"); + //Assert.Equal(string.Empty, inputElement.GetAttribute("value")); + + //// See that the capture variable was automatically updated to reference the new instance + //buttonElement.Click(); + //Assert.Equal("Clicks: 1", () => inputElement.GetAttribute("value")); + } + + [Fact] + public void CanCaptureReferencesToDynamicallyAddedComponents() + { + var cut = RenderComponent(); + var incrementButtonSelector = "#child-component button"; + var currentCountTextSelector = "#child-component p:first-of-type"; + var resetButton = cut.Find("#reset-child"); + var toggleChildCheckbox = cut.Find("#toggle-child"); + Func currentCountText = () => cut.Find(currentCountTextSelector).TextContent; + + // Verify the reference was captured initially + cut.Find(incrementButtonSelector).Click(); + Assert.Equal("Current count: 1", currentCountText()); + resetButton.Click(); + Assert.Equal("Current count: 0", currentCountText()); + cut.Find(incrementButtonSelector).Click(); + Assert.Equal("Current count: 1", currentCountText()); + + // Remove and re-add a new instance of the child, checking the text was reset + toggleChildCheckbox.Change(false); + Assert.Empty(cut.FindAll(incrementButtonSelector)); + toggleChildCheckbox.Change(true); + Assert.Equal("Current count: 0", currentCountText()); + + // Verify we have a new working reference + cut.Find(incrementButtonSelector).Click(); + Assert.Equal("Current count: 1", currentCountText()); + resetButton.Click(); + Assert.Equal("Current count: 0", currentCountText()); + } + + //[Fact] + //public void CanUseJsInteropForRefElementsDuringOnAfterRender() + //{ + // var cut = RenderComponent(); + // Assert.Equal("Value set after render", () => Browser.Find("input").GetAttribute("value")); + //} + + //[Fact] + //public void CanRenderMarkupBlocks() + //{ + // var cut = RenderComponent(); + + // // Static markup + // Assert.Equal( + // "attributes", + // cut.FindElement(By.CssSelector("p span#attribute-example")).TextContent); + + // // Dynamic markup (from a custom RenderFragment) + // Assert.Equal( + // "[Here is an example. We support multiple-top-level nodes.]", + // cut.Find("#dynamic-markup-block").TextContent); + // Assert.Equal( + // "example", + // cut.FindElement(By.CssSelector("#dynamic-markup-block strong#dynamic-element em")).TextContent); + + // // Dynamic markup (from a MarkupString) + // Assert.Equal( + // "This is a markup string.", + // cut.FindElement(By.ClassName("markup-string-value")).TextContent); + // Assert.Equal( + // "markup string", + // cut.Find(".markup-string-value em").TextContent); + + // // Updating markup blocks + // cut.Find("button").Click(); + // Browser.Equal( + // "[The output was changed completely.]", + // () => cut.Find("#dynamic-markup-block").TextContent); + // Assert.Equal( + // "changed", + // cut.FindElement(By.CssSelector("#dynamic-markup-block span em")).TextContent); + //} + + //[Fact] + //public void CanRenderRazorTemplates() + //{ + // var cut = RenderComponent(); + + // // code block template (component parameter) + // var element = cut.FindElement(By.CssSelector("div#codeblocktemplate ol")); + // Assert.Collection( + // element.FindAll("li"), + // e => Assert.Equal("#1 - a", e.TextContent), + // e => Assert.Equal("#2 - b", e.TextContent), + // e => Assert.Equal("#3 - c", e.TextContent)); + //} + + //[Fact] + //public void CanRenderMultipleChildContent() + //{ + // var cut = RenderComponent(); + + // var table = cut.Find("table"); + + // var thead = table.Find("thead"); + // Assert.Collection( + // thead.FindAll("th"), + // e => Assert.Equal("Col1", e.TextContent), + // e => Assert.Equal("Col2", e.TextContent), + // e => Assert.Equal("Col3", e.TextContent)); + + // var tfoot = table.Find("tfoot"); + // Assert.Empty(tfoot.FindAll("td")); + + // var toggle = cut.Find("#toggle"); + // toggle.Click(); + + // Browser.Collection( + // () => tfoot.FindAll("td"), + // e => Assert.Equal("The", e.TextContent), + // e => Assert.Equal("", e.TextContent), + // e => Assert.Equal("End", e.TextContent)); + //} + + //[Fact] + //public async Task CanAcceptSimultaneousRenderRequests() + //{ + // var expectedOutput = string.Join( + // string.Empty, + // Enumerable.Range(0, 100).Select(_ => "😊")); + + // var cut = RenderComponent(); + + // // It's supposed to pause the rendering for this long. The WaitAssert below + // // allows it to take up extra time if needed. + // await Task.Delay(1000); + + // var outputElement = cut.Find("#concurrent-render-output"); + // Assert.Equal(expectedOutput, () => outputElement.TextContent); + //} + + //[Fact] + //public void CanDispatchRenderToSyncContext() + //{ + // var cut = RenderComponent(); + // var result = cut.Find("#result"); + + // cut.Find("#run-with-dispatch").Click(); + + // Assert.Equal("Success (completed synchronously)", () => result.TextContent); + //} + + //[Fact] + //public void CanDoubleDispatchRenderToSyncContext() + //{ + // var cut = RenderComponent(); + // var result = cut.Find("#result"); + + // cut.Find("#run-with-double-dispatch").Click(); + + // Assert.Equal("Success (completed synchronously)", () => result.TextContent); + //} + + //[Fact] + //public void CanDispatchAsyncWorkToSyncContext() + //{ + // var cut = RenderComponent(); + // var result = cut.Find("#result"); + + // cut.Find("#run-async-with-dispatch").Click(); + + // Assert.Equal("First Second Third Fourth Fifth", () => result.TextContent); + //} + + //[Fact] + //public void CanPerformInteropImmediatelyOnComponentInsertion() + //{ + // var cut = RenderComponent(); + // Assert.Equal("Hello from interop call", () => cut.Find("#val-get-by-interop").TextContent); + // Assert.Equal("Hello from interop call", () => cut.Find("#val-set-by-interop").GetAttribute("value")); + //} + + //[Fact] + //public void CanUseAddMultipleAttributes() + //{ + // var cut = RenderComponent(); + + // var selector = By.CssSelector("#duplicate-on-element > div"); + // Browser.Exists(selector); + + // var element = cut.FindElement(selector); + // Assert.Equal(string.Empty, element.GetAttribute("bool")); // attribute is present + // Assert.Equal("middle-value", element.GetAttribute("string")); + // Assert.Equal("unmatched-value", element.GetAttribute("unmatched")); + + // selector = By.CssSelector("#duplicate-on-element-override > div"); + // element = cut.FindElement(selector); + // Assert.Null(element.GetAttribute("bool")); // attribute is not present + // Assert.Equal("other-text", element.GetAttribute("string")); + // Assert.Equal("unmatched-value", element.GetAttribute("unmatched")); + //} + + //[Fact] + //public void CanPatchRenderTreeToMatchLatestDOMState() + //{ + // var cut = RenderComponent(); + // var incompleteItemsSelector = By.CssSelector(".incomplete-items li"); + // var completeItemsSelector = By.CssSelector(".complete-items li"); + // Browser.Exists(incompleteItemsSelector); + + // // Mark first item as done; observe the remaining incomplete item appears unchecked + // // because the diff algorithm explicitly unchecks it + // cut.Find(".incomplete-items .item-isdone").Click(); + // Browser.True(() => + // { + // var incompleteLIs = cut.FindElements(incompleteItemsSelector); + // return incompleteLIs.Count == 1 + // && !incompleteLIs[0].Find(".item-isdone").Selected; + // }); + + // // Mark first done item as not done; observe the remaining complete item appears checked + // // because the diff algorithm explicitly re-checks it + // cut.Find(".complete-items .item-isdone").Click(); + // Browser.True(() => + // { + // var completeLIs = cut.FindElements(completeItemsSelector); + // return completeLIs.Count == 2 + // && completeLIs[0].Find(".item-isdone").Selected; + // }); + //} + + } +} diff --git a/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs index dab70c9bf..abd6bf4fc 100644 --- a/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs @@ -41,7 +41,7 @@ public void Test002() var elmMock = new Mock(); elmMock.Setup(x => x.GetAttribute(It.IsAny())).Returns(() => null!); - Should.Throw(() => elmMock.Object.TriggerEventAsync("click", EventArgs.Empty)); + Should.Throw(() => elmMock.Object.Click()); } [Fact(DisplayName = "TriggerEventAsync throws if element was not rendered through blazor (has a TestRendere in its context)")] diff --git a/tests/GlobalSuppressions.cs b/tests/GlobalSuppressions.cs index d61cd78fc..c0a1020ee 100644 --- a/tests/GlobalSuppressions.cs +++ b/tests/GlobalSuppressions.cs @@ -6,3 +6,4 @@ [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "In tests its ok to catch the general exception type")] [assembly: SuppressMessage("Usage", "CA2234:Pass system uri objects instead of strings", Justification = "")] [assembly: SuppressMessage("Usage", "BL0006:Do not use RenderTree types", Justification = "")] +[assembly: SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] diff --git a/tests/RefreshableQueryCollectionTest.cs b/tests/RefreshableQueryCollectionTest.cs new file mode 100644 index 000000000..f96c249da --- /dev/null +++ b/tests/RefreshableQueryCollectionTest.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Bunit.SampleComponents; +using Shouldly; +using Xunit; + +namespace Bunit +{ + public class RefreshableQueryCollectionTest : ComponentTestFixture + { + [Fact(DisplayName = "When the query returns no elements, the collection is empty")] + public void Test001() + { + var cut = RenderComponent(); + + var sut = new RefreshableElementCollection(cut, ".foo"); + + sut.ShouldBeEmpty(); + } + + [Fact(DisplayName = "When the query returns elements, the collection contains those elements")] + public void Test002() + { + var cut = RenderComponent(); + + var sut = new RefreshableElementCollection(cut, "h1"); + + sut.Count.ShouldBe(1); + sut[0].TagName.ShouldBe("H1"); + } + + + [Fact(DisplayName = "When Refresh is called, the query is run again and new elements are made available")] + public void Test003() + { + var cut = RenderComponent(); + var sut = new RefreshableElementCollection(cut, "li"); + sut.Count.ShouldBe(0); + + cut.Find("button").Click(); + + sut.Refresh(); + sut.Count.ShouldBe(1); + } + + [Fact(DisplayName = "Enabling auto refresh automatically refreshes query when the rendered fragment renders and has changes")] + public void Test004() + { + var cut = RenderComponent(); + var sut = new RefreshableElementCollection(cut, "li") { EnableAutoRefresh = true }; + sut.Count.ShouldBe(0); + + cut.Find("button").Click(); + + sut.Count.ShouldBe(1); + } + + [Fact(DisplayName = "Disabling auto refresh turns off automatic refreshing queries on when rendered fragment changes")] + public void Test005() + { + var cut = RenderComponent(); + var sut = new RefreshableElementCollection(cut, "li") { EnableAutoRefresh = true }; + + sut.EnableAutoRefresh = false; + + cut.Find("button").Click(); + + sut.Count.ShouldBe(0); + } + } +} diff --git a/tests/SampleComponents/ClickAddsLi.razor b/tests/SampleComponents/ClickAddsLi.razor new file mode 100644 index 000000000..d50d19b92 --- /dev/null +++ b/tests/SampleComponents/ClickAddsLi.razor @@ -0,0 +1,10 @@ +
    + @foreach (var x in Enumerable.Range(0, Counter)) + { +
  • @x
  • + } +
+ +@code { + public int Counter { get; set; } = 0; +} \ No newline at end of file diff --git a/tests/SampleComponents/Simple1.razor b/tests/SampleComponents/Simple1.razor index 5dd10f31a..a59cab706 100644 --- a/tests/SampleComponents/Simple1.razor +++ b/tests/SampleComponents/Simple1.razor @@ -2,4 +2,4 @@ [Parameter] public string Header { get; set; } = string.Empty; [Parameter] public string AttrValue { get; set; } = string.Empty; } -

@Header

\ No newline at end of file +

@Header

\ No newline at end of file diff --git a/tests/TestRendererTest.cs b/tests/TestRendererTest.cs index 0a260a227..f82998372 100644 --- a/tests/TestRendererTest.cs +++ b/tests/TestRendererTest.cs @@ -26,5 +26,45 @@ public void Test001() res.RenderCount.ShouldBe(2); } + [Fact(DisplayName = "Can pass reference to elements to JsInterop")] + public void Test010() + { + //var cut = RenderComponent(); + //var inputElement = cut.Find("#capturedElement"); + //var buttonElement = cut.Find("button"); + + //Assert.Equal(string.Empty, inputElement.GetAttribute("value")); + + //buttonElement.Click(); + //Assert.Equal("Clicks: 1", inputElement.GetAttribute("value")); + //buttonElement.Click(); + //Assert.Equal("Clicks: 2", inputElement.GetAttribute("value")); + } + + [Fact(DisplayName = "Can capture reference to dynamically added elements and pass to JsInterop")] + public void Test011() + { + //var cut = RenderComponent(); + //var buttonElement = cut.Find("button"); + //var checkbox = cut.Find("input[type=checkbox]"); + + //// We're going to remove the input. But first, put in some contents + //// so we can observe it's not the same instance later + //cut.Find("#capturedElement").SendKeys("some text"); + + //// Remove the captured element + //checkbox.Click(); + //Browser.Empty(() => cut.FindAll("#capturedElement")); + + //// Re-add it; observe it starts empty again + //checkbox.Click(); + //var inputElement = cut.Find("#capturedElement"); + //Assert.Equal(string.Empty, inputElement.GetAttribute("value")); + + //// See that the capture variable was automatically updated to reference the new instance + //buttonElement.Click(); + //Assert.Equal("Clicks: 1", () => inputElement.GetAttribute("value")); + } + } } From b87c312a0002849165484cf688c582cd306cbe28 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 28 Feb 2020 09:01:43 +0000 Subject: [PATCH 22/27] Using cleanup --- src/Asserting/CollectionAssertExtensions.cs | 1 - src/Asserting/DiffAssertExtensions.cs | 4 - src/Asserting/HtmlEqualException.cs | 2 - .../ShouldBeAdditionAssertExtensions.cs | 1 - .../ShouldBeRemovalAssertExtensions.cs | 1 - .../ShouldBeTextChangeAssertExtensions.cs | 1 - src/ComponentTestFixture.cs | 4 - src/Components/ComponentUnderTest.cs | 3 +- src/Components/ContainerComponent.cs | 3 - src/Components/FixtureFailedException.cs | 4 - src/Components/SnapshotTest.cs | 3 - src/Components/TestComponentBase.cs | 4 - src/Components/TestContextAdapter.cs | 1 - src/Diffing/BlazorDiffingHelpers.cs | 3 +- src/Diffing/HtmlComparer.cs | 2 - src/Diffing/TestHtmlParser.cs | 1 - .../ClipboardEventDispatchExtensions.cs | 3 - .../DragEventDispatchExtensions.cs | 6 +- .../FocusEventDispatchExtensions.cs | 1 - .../GeneralEventDispatchExtensions.cs | 6 +- .../InputEventDispatchExtensions.cs | 3 - .../KeyboardEventDispatchExtensions.cs | 6 +- .../MediaEventDispatchExtensions.cs | 3 - .../MissingEventHandlerException.cs | 7 +- .../MouseEventDispatchExtensions.cs | 6 +- .../PointerEventDispatchExtensions.cs | 6 +- .../ProgressEventDispatchExtensions.cs | 6 +- .../TouchEventDispatchExtensions.cs | 3 - src/Extensions/ElementNotFoundException.cs | 5 - .../Internal/AngleSharpExtensions.cs | 10 +- src/Extensions/Internal/BlazorExtensions.cs | 2 - src/Extensions/Internal/TimeSpanExtensions.cs | 4 - src/Extensions/NodePrintExtensions.cs | 2 - .../RenderWaitingHelperExtensions.cs | 2 - .../RenderedFragmentQueryExtensions.cs | 4 - src/Extensions/Xunit/XunitLogger.cs | 4 - src/Extensions/Xunit/XunitLoggerProvider.cs | 7 +- src/ITestContext.cs | 1 - .../JsInvokeCountExpectedException.cs | 4 +- .../JSInterop/JsRuntimePlannedInvocation.cs | 1 - .../MissingMockJsRuntimeException.cs | 3 - .../JSInterop/MockJsRuntimeExtensions.cs | 1 - .../JSInterop/MockJsRuntimeInvokeHandler.cs | 1 - src/Mocking/JSInterop/PlaceholderJsRuntime.cs | 6 +- .../UnplannedJsInvocationException.cs | 2 - src/Rendering/ComponentParameter.cs | 4 - src/Rendering/IRenderedComponent.cs | 3 +- src/Rendering/IRenderedFragment.cs | 3 - src/Rendering/RenderedComponent.cs | 2 - src/Rendering/RenderedFragment.cs | 8 +- src/Rendering/RenderedFragmentBase.cs | 3 - src/Rendering/TestRenderer.cs | 3 - src/SnapshotTestContext.cs | 1 - src/TestContext.cs | 5 - src/bunit.csproj | 2 +- .../CompareToDiffingExtensionsTest.cs | 3 - tests/Asserting/DiffAssertExtensionsTest.cs | 4 - .../GenericCollectionAssertExtensionsTest.cs | 4 - .../BasicTestApp/ConcurrentRenderChild.razor | 16 + .../BasicTestApp/ConcurrentRenderParent.razor | 7 + .../BasicTestApp/DispatchingComponent.razor | 83 ++++ .../DuplicateAttributesComponent.razor | 22 ++ ...ateAttributesOnElementChildComponent.razor | 24 ++ .../BasicTestApp/MarkupBlockComponent.razor | 45 +++ .../MovingCheckboxesComponent.razor | 57 +++ .../BasicTestApp/MultipleChildContent.razor | 33 ++ .../BlazorE2E/BasicTestApp/OrderedList.razor | 25 ++ .../BasicTestApp/RazorTemplates.razor | 10 + .../BasicTestApp/TemplatedTable.razor | 36 ++ tests/BlazorE2E/ComponentRenderingTest.cs | 365 ++++++++---------- tests/ComponentTestFixtureTest.cs | 2 - .../ClipboardEventDispatchExtensionsTest.cs | 9 +- .../EventDispatchExtensionsTest.cs | 1 - .../FocusEventDispatchExtensionsTest.cs | 1 - .../GeneralEventDispatchExtensionsTest.cs | 3 - .../MediaEventDispatchExtensionsTest.cs | 1 - .../PointerEventDispatchExtensionsTest.cs | 6 +- .../ProgressEventDispatchExtensionsTest.cs | 6 +- .../TouchEventDispatchExtensionsTest.cs | 6 +- .../TriggerEventSpy.cs | 1 - .../JsRuntimeAssertExtensionsTest.cs | 2 - .../JSInterop/JsRuntimeInvocationTest.cs | 3 - .../MockJsRuntimeInvokeHandlerTest.cs | 2 - tests/RefreshableQueryCollectionTest.cs | 7 +- tests/Rendering/ComponentParameterTest.cs | 3 - tests/Rendering/RenderEventPubSubTest.cs | 5 - .../RenderWaitingHelperExtensionsTest.cs | 4 - tests/Rendering/RenderedFragmentTest.cs | 11 +- tests/SampleComponents/Data/AsyncNameDep.cs | 6 +- tests/SampleComponents/Data/IAsyncTestDep.cs | 6 +- tests/SampleComponents/Data/ITestDep.cs | 8 +- tests/SampleComponents/TriggerTester.cs | 4 - tests/TestRendererTest.cs | 9 +- tests/TestServiceProviderTest.cs | 6 +- 94 files changed, 554 insertions(+), 479 deletions(-) create mode 100644 tests/BlazorE2E/BasicTestApp/ConcurrentRenderChild.razor create mode 100644 tests/BlazorE2E/BasicTestApp/ConcurrentRenderParent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/DispatchingComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/DuplicateAttributesComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/DuplicateAttributesOnElementChildComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/MarkupBlockComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/MovingCheckboxesComponent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/MultipleChildContent.razor create mode 100644 tests/BlazorE2E/BasicTestApp/OrderedList.razor create mode 100644 tests/BlazorE2E/BasicTestApp/RazorTemplates.razor create mode 100644 tests/BlazorE2E/BasicTestApp/TemplatedTable.razor diff --git a/src/Asserting/CollectionAssertExtensions.cs b/src/Asserting/CollectionAssertExtensions.cs index 8f8fdf8b3..17d3a7968 100644 --- a/src/Asserting/CollectionAssertExtensions.cs +++ b/src/Asserting/CollectionAssertExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using Xunit; using Xunit.Sdk; diff --git a/src/Asserting/DiffAssertExtensions.cs b/src/Asserting/DiffAssertExtensions.cs index 73c9c2fc9..2bbefeff7 100644 --- a/src/Asserting/DiffAssertExtensions.cs +++ b/src/Asserting/DiffAssertExtensions.cs @@ -1,9 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text; -using System.Threading.Tasks; -using AngleSharp; using AngleSharp.Diffing.Core; using Xunit; diff --git a/src/Asserting/HtmlEqualException.cs b/src/Asserting/HtmlEqualException.cs index 276c8436d..506a6ea2d 100644 --- a/src/Asserting/HtmlEqualException.cs +++ b/src/Asserting/HtmlEqualException.cs @@ -1,11 +1,9 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using AngleSharp; using AngleSharp.Diffing.Core; -using AngleSharp.Dom; using Bunit; namespace Xunit.Sdk diff --git a/src/Asserting/ShouldBeAdditionAssertExtensions.cs b/src/Asserting/ShouldBeAdditionAssertExtensions.cs index d00d3499f..c50a5c455 100644 --- a/src/Asserting/ShouldBeAdditionAssertExtensions.cs +++ b/src/Asserting/ShouldBeAdditionAssertExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using AngleSharp; using AngleSharp.Diffing.Core; using AngleSharp.Dom; using Bunit.Diffing; diff --git a/src/Asserting/ShouldBeRemovalAssertExtensions.cs b/src/Asserting/ShouldBeRemovalAssertExtensions.cs index 38c1b303c..06d215b6f 100644 --- a/src/Asserting/ShouldBeRemovalAssertExtensions.cs +++ b/src/Asserting/ShouldBeRemovalAssertExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using AngleSharp; using AngleSharp.Diffing.Core; using AngleSharp.Dom; using Bunit.Diffing; diff --git a/src/Asserting/ShouldBeTextChangeAssertExtensions.cs b/src/Asserting/ShouldBeTextChangeAssertExtensions.cs index 36b842df9..8b74e92e8 100644 --- a/src/Asserting/ShouldBeTextChangeAssertExtensions.cs +++ b/src/Asserting/ShouldBeTextChangeAssertExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using AngleSharp; using AngleSharp.Diffing.Core; using AngleSharp.Dom; using Bunit.Diffing; diff --git a/src/ComponentTestFixture.cs b/src/ComponentTestFixture.cs index d2e70de27..95200128b 100644 --- a/src/ComponentTestFixture.cs +++ b/src/ComponentTestFixture.cs @@ -1,11 +1,7 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -using Xunit.Abstractions; using EC = Microsoft.AspNetCore.Components.EventCallback; namespace Bunit diff --git a/src/Components/ComponentUnderTest.cs b/src/Components/ComponentUnderTest.cs index 2a444819b..86dab541b 100644 --- a/src/Components/ComponentUnderTest.cs +++ b/src/Components/ComponentUnderTest.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading.Tasks; +using System.Threading.Tasks; using Microsoft.AspNetCore.Components; namespace Bunit diff --git a/src/Components/ContainerComponent.cs b/src/Components/ContainerComponent.cs index 60fc76d42..2480a4099 100644 --- a/src/Components/ContainerComponent.cs +++ b/src/Components/ContainerComponent.cs @@ -2,11 +2,8 @@ using Microsoft.AspNetCore.Components.RenderTree; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Threading.Tasks; -using AngleSharp.Css.Dom; namespace Bunit { diff --git a/src/Components/FixtureFailedException.cs b/src/Components/FixtureFailedException.cs index 9803d7842..70eb2ab8e 100644 --- a/src/Components/FixtureFailedException.cs +++ b/src/Components/FixtureFailedException.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Bunit; using System.Diagnostics.CodeAnalysis; diff --git a/src/Components/SnapshotTest.cs b/src/Components/SnapshotTest.cs index 68f06c058..a39db2d76 100644 --- a/src/Components/SnapshotTest.cs +++ b/src/Components/SnapshotTest.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; diff --git a/src/Components/TestComponentBase.cs b/src/Components/TestComponentBase.cs index f836ee961..56f54f9fe 100644 --- a/src/Components/TestComponentBase.cs +++ b/src/Components/TestComponentBase.cs @@ -1,17 +1,13 @@ using System; using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; -using Bunit.Diffing; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; -using Xunit.Abstractions; using Xunit.Sdk; namespace Bunit diff --git a/src/Components/TestContextAdapter.cs b/src/Components/TestContextAdapter.cs index 5f3082a2a..cec0ed727 100644 --- a/src/Components/TestContextAdapter.cs +++ b/src/Components/TestContextAdapter.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using AngleSharp.Dom; -using Bunit.Diffing; using Microsoft.AspNetCore.Components; namespace Bunit diff --git a/src/Diffing/BlazorDiffingHelpers.cs b/src/Diffing/BlazorDiffingHelpers.cs index daed7dc01..10335862f 100644 --- a/src/Diffing/BlazorDiffingHelpers.cs +++ b/src/Diffing/BlazorDiffingHelpers.cs @@ -1,5 +1,4 @@ -using System; -using AngleSharp.Diffing.Core; +using AngleSharp.Diffing.Core; namespace Bunit.Diffing { diff --git a/src/Diffing/HtmlComparer.cs b/src/Diffing/HtmlComparer.cs index be7e51019..735609522 100644 --- a/src/Diffing/HtmlComparer.cs +++ b/src/Diffing/HtmlComparer.cs @@ -3,8 +3,6 @@ using AngleSharp.Diffing; using AngleSharp.Dom; using AngleSharp.Diffing.Core; -using Xunit.Abstractions; -using Bunit.Diffing; using AngleSharpWrappers; namespace Bunit.Diffing diff --git a/src/Diffing/TestHtmlParser.cs b/src/Diffing/TestHtmlParser.cs index 13d7076cf..98d438c01 100644 --- a/src/Diffing/TestHtmlParser.cs +++ b/src/Diffing/TestHtmlParser.cs @@ -2,7 +2,6 @@ using AngleSharp; using AngleSharp.Dom; using System; -using Bunit.Diffing; namespace Bunit.Diffing { diff --git a/src/EventDispatchExtensions/ClipboardEventDispatchExtensions.cs b/src/EventDispatchExtensions/ClipboardEventDispatchExtensions.cs index d0880ec91..9174ba27d 100644 --- a/src/EventDispatchExtensions/ClipboardEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/ClipboardEventDispatchExtensions.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components.Web; diff --git a/src/EventDispatchExtensions/DragEventDispatchExtensions.cs b/src/EventDispatchExtensions/DragEventDispatchExtensions.cs index 2b6b23dae..b5ea59508 100644 --- a/src/EventDispatchExtensions/DragEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/DragEventDispatchExtensions.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components.Web; diff --git a/src/EventDispatchExtensions/FocusEventDispatchExtensions.cs b/src/EventDispatchExtensions/FocusEventDispatchExtensions.cs index 9705f8269..5a031f0bc 100644 --- a/src/EventDispatchExtensions/FocusEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/FocusEventDispatchExtensions.cs @@ -1,5 +1,4 @@ using AngleSharp.Dom; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using System.Threading.Tasks; diff --git a/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs b/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs index a3f9b6c8f..3f1e10d6e 100644 --- a/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/GeneralEventDispatchExtensions.cs @@ -1,12 +1,8 @@ -using AngleSharp; -using AngleSharp.Dom; -using Microsoft.AspNetCore.Components; +using AngleSharp.Dom; using Microsoft.AspNetCore.Components.RenderTree; -using Microsoft.AspNetCore.Components.Web; using System; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Linq; using System.Threading.Tasks; namespace Bunit diff --git a/src/EventDispatchExtensions/InputEventDispatchExtensions.cs b/src/EventDispatchExtensions/InputEventDispatchExtensions.cs index e94af59f3..aa112fe60 100644 --- a/src/EventDispatchExtensions/InputEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/InputEventDispatchExtensions.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components; diff --git a/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs b/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs index 636657f62..5749dcb1e 100644 --- a/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/KeyboardEventDispatchExtensions.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components.Web; diff --git a/src/EventDispatchExtensions/MediaEventDispatchExtensions.cs b/src/EventDispatchExtensions/MediaEventDispatchExtensions.cs index 23a16376a..d21641325 100644 --- a/src/EventDispatchExtensions/MediaEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/MediaEventDispatchExtensions.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; diff --git a/src/EventDispatchExtensions/MissingEventHandlerException.cs b/src/EventDispatchExtensions/MissingEventHandlerException.cs index 8a613230c..366788e76 100644 --- a/src/EventDispatchExtensions/MissingEventHandlerException.cs +++ b/src/EventDispatchExtensions/MissingEventHandlerException.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Runtime.Serialization; using AngleSharp.Dom; namespace Bunit @@ -16,7 +15,7 @@ public MissingEventHandlerException(IElement element, string missingEventName) : private static string CreateErrorMessage(IElement element, string missingEventName) { - var result = $"The element does not have an event handler for the event '{missingEventName}"; + var result = $"The element does not have an event handler for the event '{missingEventName}'"; var eventHandlers = element.Attributes? .Where(x => x.Name.StartsWith(Htmlizer.BLAZOR_ATTR_PREFIX, StringComparison.Ordinal) && !x.Name.StartsWith(Htmlizer.ELEMENT_REFERENCE_ATTR_NAME, StringComparison.Ordinal)) .Select(x => $"'{x.Name.Remove(0, Htmlizer.BLAZOR_ATTR_PREFIX.Length)}'") @@ -25,9 +24,9 @@ private static string CreateErrorMessage(IElement element, string missingEventNa var suggestAlternatives = ", nor any other events."; if (eventHandlers.Length > 1) - suggestAlternatives = $". The element has event handlers for these events, {string.Join(", ", eventHandlers)}, that you can try instead."; + suggestAlternatives = $". It does however have event handlers for these events, {string.Join(", ", eventHandlers)}."; if (eventHandlers.Length == 1) - suggestAlternatives = $". The element has an event handler for {eventHandlers[0]} event, that you can try instead."; + suggestAlternatives = $". It does however have an event handler for the {eventHandlers[0]} event."; return $"{result}{suggestAlternatives}"; } diff --git a/src/EventDispatchExtensions/MouseEventDispatchExtensions.cs b/src/EventDispatchExtensions/MouseEventDispatchExtensions.cs index 44872af97..63e6ca3be 100644 --- a/src/EventDispatchExtensions/MouseEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/MouseEventDispatchExtensions.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components.Web; diff --git a/src/EventDispatchExtensions/PointerEventDispatchExtensions.cs b/src/EventDispatchExtensions/PointerEventDispatchExtensions.cs index 632eb3ca1..f9f8fa206 100644 --- a/src/EventDispatchExtensions/PointerEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/PointerEventDispatchExtensions.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components.Web; diff --git a/src/EventDispatchExtensions/ProgressEventDispatchExtensions.cs b/src/EventDispatchExtensions/ProgressEventDispatchExtensions.cs index f7b3ddd5b..a8d55aa28 100644 --- a/src/EventDispatchExtensions/ProgressEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/ProgressEventDispatchExtensions.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components.Web; diff --git a/src/EventDispatchExtensions/TouchEventDispatchExtensions.cs b/src/EventDispatchExtensions/TouchEventDispatchExtensions.cs index e392c2043..124f2a1fd 100644 --- a/src/EventDispatchExtensions/TouchEventDispatchExtensions.cs +++ b/src/EventDispatchExtensions/TouchEventDispatchExtensions.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components.Web; diff --git a/src/Extensions/ElementNotFoundException.cs b/src/Extensions/ElementNotFoundException.cs index a4792322c..3886349ac 100644 --- a/src/Extensions/ElementNotFoundException.cs +++ b/src/Extensions/ElementNotFoundException.cs @@ -1,9 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.Serialization; -using System.Text; -using System.Threading.Tasks; namespace Xunit.Sdk { diff --git a/src/Extensions/Internal/AngleSharpExtensions.cs b/src/Extensions/Internal/AngleSharpExtensions.cs index fd6dd942a..75c2e83e6 100644 --- a/src/Extensions/Internal/AngleSharpExtensions.cs +++ b/src/Extensions/Internal/AngleSharpExtensions.cs @@ -1,14 +1,6 @@ -using AngleSharp; -using AngleSharp.Dom; -using AngleSharp.Html; -using AngleSharp.Text; +using AngleSharp.Dom; using Bunit.Diffing; -using System; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Bunit { diff --git a/src/Extensions/Internal/BlazorExtensions.cs b/src/Extensions/Internal/BlazorExtensions.cs index 84d7edbf4..5e77d1bae 100644 --- a/src/Extensions/Internal/BlazorExtensions.cs +++ b/src/Extensions/Internal/BlazorExtensions.cs @@ -1,6 +1,4 @@ using System; -using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Components; namespace Bunit diff --git a/src/Extensions/Internal/TimeSpanExtensions.cs b/src/Extensions/Internal/TimeSpanExtensions.cs index 4c547921f..21399917f 100644 --- a/src/Extensions/Internal/TimeSpanExtensions.cs +++ b/src/Extensions/Internal/TimeSpanExtensions.cs @@ -1,10 +1,6 @@ using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; -using System.Text; using System.Threading; -using System.Threading.Tasks; namespace Bunit { diff --git a/src/Extensions/NodePrintExtensions.cs b/src/Extensions/NodePrintExtensions.cs index d0e71610f..d6134b45d 100644 --- a/src/Extensions/NodePrintExtensions.cs +++ b/src/Extensions/NodePrintExtensions.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text; -using System.Threading.Tasks; using AngleSharp; using AngleSharp.Dom; using AngleSharp.Html; diff --git a/src/Extensions/RenderWaitingHelperExtensions.cs b/src/Extensions/RenderWaitingHelperExtensions.cs index 9ccd18c31..56c7723d1 100644 --- a/src/Extensions/RenderWaitingHelperExtensions.cs +++ b/src/Extensions/RenderWaitingHelperExtensions.cs @@ -1,6 +1,4 @@ using System; -using System.Diagnostics; -using System.Runtime.ExceptionServices; using System.Threading; using System.Diagnostics.CodeAnalysis; diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs index c1895a8a5..aac22efdc 100644 --- a/src/Extensions/RenderedFragmentQueryExtensions.cs +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -1,9 +1,5 @@ using System; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using AngleSharp.Dom; -using AngleSharp.Html.Dom; using AngleSharpWrappers; using Xunit.Sdk; diff --git a/src/Extensions/Xunit/XunitLogger.cs b/src/Extensions/Xunit/XunitLogger.cs index 13efd8171..3b594ae8e 100644 --- a/src/Extensions/Xunit/XunitLogger.cs +++ b/src/Extensions/Xunit/XunitLogger.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Xunit.Abstractions; diff --git a/src/Extensions/Xunit/XunitLoggerProvider.cs b/src/Extensions/Xunit/XunitLoggerProvider.cs index d525519ad..22612cb56 100644 --- a/src/Extensions/Xunit/XunitLoggerProvider.cs +++ b/src/Extensions/Xunit/XunitLoggerProvider.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Xunit.Abstractions; namespace Bunit.Extensions.Xunit diff --git a/src/ITestContext.cs b/src/ITestContext.cs index fa65dc41c..068a813ce 100644 --- a/src/ITestContext.cs +++ b/src/ITestContext.cs @@ -1,5 +1,4 @@ using System; -using System.Threading.Tasks; using AngleSharp.Dom; using Microsoft.AspNetCore.Components; diff --git a/src/Mocking/JSInterop/JsInvokeCountExpectedException.cs b/src/Mocking/JSInterop/JsInvokeCountExpectedException.cs index 486e4ed90..9729f3af1 100644 --- a/src/Mocking/JSInterop/JsInvokeCountExpectedException.cs +++ b/src/Mocking/JSInterop/JsInvokeCountExpectedException.cs @@ -1,7 +1,5 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using Bunit.Mocking.JSInterop; -using Xunit.Sdk; namespace Xunit.Sdk { diff --git a/src/Mocking/JSInterop/JsRuntimePlannedInvocation.cs b/src/Mocking/JSInterop/JsRuntimePlannedInvocation.cs index f5b5b1e90..36f57625a 100644 --- a/src/Mocking/JSInterop/JsRuntimePlannedInvocation.cs +++ b/src/Mocking/JSInterop/JsRuntimePlannedInvocation.cs @@ -1,7 +1,6 @@ using System.Threading.Tasks; using System.Collections.Generic; using System; -using System.Diagnostics.CodeAnalysis; namespace Bunit.Mocking.JSInterop { diff --git a/src/Mocking/JSInterop/MissingMockJsRuntimeException.cs b/src/Mocking/JSInterop/MissingMockJsRuntimeException.cs index 894f16729..9e4451f83 100644 --- a/src/Mocking/JSInterop/MissingMockJsRuntimeException.cs +++ b/src/Mocking/JSInterop/MissingMockJsRuntimeException.cs @@ -1,8 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Diagnostics.CodeAnalysis; namespace Xunit.Sdk diff --git a/src/Mocking/JSInterop/MockJsRuntimeExtensions.cs b/src/Mocking/JSInterop/MockJsRuntimeExtensions.cs index 1da9475e7..3d5c5ba04 100644 --- a/src/Mocking/JSInterop/MockJsRuntimeExtensions.cs +++ b/src/Mocking/JSInterop/MockJsRuntimeExtensions.cs @@ -1,5 +1,4 @@ using System; -using Bunit.Mocking.JSInterop; using Microsoft.Extensions.DependencyInjection; namespace Bunit.Mocking.JSInterop diff --git a/src/Mocking/JSInterop/MockJsRuntimeInvokeHandler.cs b/src/Mocking/JSInterop/MockJsRuntimeInvokeHandler.cs index 69f208200..92a59fc11 100644 --- a/src/Mocking/JSInterop/MockJsRuntimeInvokeHandler.cs +++ b/src/Mocking/JSInterop/MockJsRuntimeInvokeHandler.cs @@ -2,7 +2,6 @@ using Microsoft.JSInterop; using System.Threading; using System.Collections.Generic; -using Xunit; using System; using System.Linq; using Xunit.Sdk; diff --git a/src/Mocking/JSInterop/PlaceholderJsRuntime.cs b/src/Mocking/JSInterop/PlaceholderJsRuntime.cs index 6eff60eca..16bf978d9 100644 --- a/src/Mocking/JSInterop/PlaceholderJsRuntime.cs +++ b/src/Mocking/JSInterop/PlaceholderJsRuntime.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Microsoft.JSInterop; using Xunit.Sdk; diff --git a/src/Mocking/JSInterop/UnplannedJsInvocationException.cs b/src/Mocking/JSInterop/UnplannedJsInvocationException.cs index e43b619b9..edc0c0f2d 100644 --- a/src/Mocking/JSInterop/UnplannedJsInvocationException.cs +++ b/src/Mocking/JSInterop/UnplannedJsInvocationException.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Diagnostics.CodeAnalysis; using Bunit.Mocking.JSInterop; diff --git a/src/Rendering/ComponentParameter.cs b/src/Rendering/ComponentParameter.cs index 842c21eb8..29f1f974b 100644 --- a/src/Rendering/ComponentParameter.cs +++ b/src/Rendering/ComponentParameter.cs @@ -1,9 +1,5 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Bunit { diff --git a/src/Rendering/IRenderedComponent.cs b/src/Rendering/IRenderedComponent.cs index a4d47818f..eec8d931b 100644 --- a/src/Rendering/IRenderedComponent.cs +++ b/src/Rendering/IRenderedComponent.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components; namespace Bunit { diff --git a/src/Rendering/IRenderedFragment.cs b/src/Rendering/IRenderedFragment.cs index 12a968ca7..86e2f4607 100644 --- a/src/Rendering/IRenderedFragment.cs +++ b/src/Rendering/IRenderedFragment.cs @@ -1,11 +1,8 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using AngleSharp.Diffing.Core; using AngleSharp.Dom; using Microsoft.AspNetCore.Components; -using Xunit.Sdk; namespace Bunit { diff --git a/src/Rendering/RenderedComponent.cs b/src/Rendering/RenderedComponent.cs index 2af7fe8e8..a6e5587d4 100644 --- a/src/Rendering/RenderedComponent.cs +++ b/src/Rendering/RenderedComponent.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using AngleSharp.Diffing.Core; using Microsoft.AspNetCore.Components; namespace Bunit diff --git a/src/Rendering/RenderedFragment.cs b/src/Rendering/RenderedFragment.cs index 4bd0d9a1d..ca02193d8 100644 --- a/src/Rendering/RenderedFragment.cs +++ b/src/Rendering/RenderedFragment.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AngleSharp.Diffing.Core; -using AngleSharp.Dom; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components; namespace Bunit { diff --git a/src/Rendering/RenderedFragmentBase.cs b/src/Rendering/RenderedFragmentBase.cs index c35a7388d..52d98c45b 100644 --- a/src/Rendering/RenderedFragmentBase.cs +++ b/src/Rendering/RenderedFragmentBase.cs @@ -1,11 +1,8 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using AngleSharp.Diffing.Core; using AngleSharp.Dom; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.RenderTree; namespace Bunit { diff --git a/src/Rendering/TestRenderer.cs b/src/Rendering/TestRenderer.cs index 2a19cd79d..c077707cd 100644 --- a/src/Rendering/TestRenderer.cs +++ b/src/Rendering/TestRenderer.cs @@ -2,12 +2,9 @@ using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.Extensions.Logging; using System; -using System.Collections; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Runtime.ExceptionServices; using System.Threading.Tasks; -using System.Threading; using Microsoft.Extensions.Logging.Abstractions; namespace Bunit diff --git a/src/SnapshotTestContext.cs b/src/SnapshotTestContext.cs index b2597fa1e..4db40b2a9 100644 --- a/src/SnapshotTestContext.cs +++ b/src/SnapshotTestContext.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using Microsoft.AspNetCore.Components; namespace Bunit { diff --git a/src/TestContext.cs b/src/TestContext.cs index 6732cdd8a..b4da0f2cb 100644 --- a/src/TestContext.cs +++ b/src/TestContext.cs @@ -2,16 +2,11 @@ using Bunit.Diffing; using Bunit.Mocking.JSInterop; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.JSInterop; using System; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Linq.Expressions; -using System.Threading.Tasks; namespace Bunit { diff --git a/src/bunit.csproj b/src/bunit.csproj index 41da2590c..dc0c32470 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -49,7 +49,7 @@ - + diff --git a/tests/Asserting/CompareToDiffingExtensionsTest.cs b/tests/Asserting/CompareToDiffingExtensionsTest.cs index 9b50d45bf..dc42e81f3 100644 --- a/tests/Asserting/CompareToDiffingExtensionsTest.cs +++ b/tests/Asserting/CompareToDiffingExtensionsTest.cs @@ -2,9 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text; -using System.Threading.Tasks; -using AngleSharp.Dom; using Bunit.SampleComponents; using Bunit.TestUtililities; using Shouldly; diff --git a/tests/Asserting/DiffAssertExtensionsTest.cs b/tests/Asserting/DiffAssertExtensionsTest.cs index 2fa418894..c4341f21e 100644 --- a/tests/Asserting/DiffAssertExtensionsTest.cs +++ b/tests/Asserting/DiffAssertExtensionsTest.cs @@ -1,10 +1,6 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using AngleSharp.Diffing.Core; -using Bunit.Diffing; using Moq; using Shouldly; using Xunit; diff --git a/tests/Asserting/GenericCollectionAssertExtensionsTest.cs b/tests/Asserting/GenericCollectionAssertExtensionsTest.cs index c8ca4dea4..d8a90a2d3 100644 --- a/tests/Asserting/GenericCollectionAssertExtensionsTest.cs +++ b/tests/Asserting/GenericCollectionAssertExtensionsTest.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Shouldly; using Xunit; using Xunit.Sdk; diff --git a/tests/BlazorE2E/BasicTestApp/ConcurrentRenderChild.razor b/tests/BlazorE2E/BasicTestApp/ConcurrentRenderChild.razor new file mode 100644 index 000000000..980343734 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/ConcurrentRenderChild.razor @@ -0,0 +1,16 @@ +@(isAfterDelay ? "😊" :"WAITING") +@code +{ + protected bool isAfterDelay; + + protected override async Task OnInitializedAsync() + { + // If there are lots of instances of this component, the following delay + // will result in a lot of them triggering a re-render simultaneously + // on different threads. + // This test is to verify that the renderer correctly accepts all the + // simultaneous render requests. + await Task.Delay(1000); + isAfterDelay = true; + } +} diff --git a/tests/BlazorE2E/BasicTestApp/ConcurrentRenderParent.razor b/tests/BlazorE2E/BasicTestApp/ConcurrentRenderParent.razor new file mode 100644 index 000000000..1c219bcbe --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/ConcurrentRenderParent.razor @@ -0,0 +1,7 @@ +

+ After a 1 second delay, the output should be 100x😊, with no remaining "WAITING" markers. +

+ +
+ @for (var i = 0; i < 100; i++) {} +
diff --git a/tests/BlazorE2E/BasicTestApp/DispatchingComponent.razor b/tests/BlazorE2E/BasicTestApp/DispatchingComponent.razor new file mode 100644 index 000000000..519f1c2f7 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/DispatchingComponent.razor @@ -0,0 +1,83 @@ +

Dispatching

+ +

+ Sometimes, renders need to be triggered in response to non-lifecyle events. + The current thread will not be associated with the renderer's sync context, + so the render request has to be marshalled onto that sync context. +

+ +

+ Result: @result +

+ + + + + + +@code { + #nullable disable + string result; + + async Task RunWithoutDispatch() + { + await Task.Delay(1).ConfigureAwait(false); + AttemptToRender(); + } + + async Task RunWithDispatch() + { + await Task.Delay(1).ConfigureAwait(false); + await InvokeAsync(AttemptToRender); + + // So we can observe that the dispatched work item completed by now + if (result == "Success") + { + result += " (completed synchronously)"; + } + } + + async Task RunWithDoubleDispatch() + { + await Task.Delay(1).ConfigureAwait(false); + await InvokeAsync(() => InvokeAsync(AttemptToRender)); + + // So we can observe that the dispatched work item completed by now + if (result == "Success") + { + result += " (completed synchronously)"; + } + } + + async Task RunAsyncWorkWithDispatch() + { + await Task.Delay(1).ConfigureAwait(false); + + result = "First"; + + var invokeTask = InvokeAsync(async () => + { + // When the sync context is idle, queued work items start synchronously + result += " Second"; + await Task.Delay(250); + result += " Fourth"; + }); + + result += " Third"; + await invokeTask; + result += " Fifth"; + } + + void AttemptToRender() + { + try + { + result = "Success"; + StateHasChanged(); + } + catch (Exception ex) + { + result = ex.ToString(); + } + } +} diff --git a/tests/BlazorE2E/BasicTestApp/DuplicateAttributesComponent.razor b/tests/BlazorE2E/BasicTestApp/DuplicateAttributesComponent.razor new file mode 100644 index 000000000..8d7bcc2e3 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/DuplicateAttributesComponent.razor @@ -0,0 +1,22 @@ +
+ +
+ +
+ +
+ +@functions { + Dictionary elementValues = new Dictionary() + { + { "bool", true }, + { "string", "middle-value" }, + { "unmatched", "unmatched-value" }, + }; +} diff --git a/tests/BlazorE2E/BasicTestApp/DuplicateAttributesOnElementChildComponent.razor b/tests/BlazorE2E/BasicTestApp/DuplicateAttributesOnElementChildComponent.razor new file mode 100644 index 000000000..aff30683e --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/DuplicateAttributesOnElementChildComponent.razor @@ -0,0 +1,24 @@ +@if (StringAttributeBefore != null) +{ +
+
+} +else +{ +
+
+} + +@code { + #nullable disable + [Parameter] public string StringAttributeBefore { get; set; } + [Parameter] public bool BoolAttributeBefore { get; set; } + [Parameter] public string StringAttributeAfter { get; set; } + [Parameter] public bool? BoolAttributeAfter { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] public Dictionary UnmatchedValues { get; set; } +} diff --git a/tests/BlazorE2E/BasicTestApp/MarkupBlockComponent.razor b/tests/BlazorE2E/BasicTestApp/MarkupBlockComponent.razor new file mode 100644 index 000000000..ee7f9a185 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/MarkupBlockComponent.razor @@ -0,0 +1,45 @@ +@using Microsoft.AspNetCore.Components.Rendering +

Markup blocks

+ +

+ This component contains blocks of static HTML markup that will be + represented in the render instructions as single frames. + + This includes nested elements with attributes. +

+ +

Dynamic markup

+ +

It's also possible to emit markup blocks from render fragments:

+ +
+ [@((RenderFragment)EmitMarkupBlock)] +
+ + + +

Markup string

+ +

It's also possible to declare a value of a special type that renders as markup:

+ +@((MarkupString)myMarkup) + +@code { + #nullable disable + bool changeOutput; + + string myMarkup = "

This is a markup string.

"; + + void EmitMarkupBlock(RenderTreeBuilder builder) + { + // To show we detect and apply changes to markup blocks + if (!changeOutput) + { + builder.AddMarkupContent(0, "Here is an example. We support multiple-top-level nodes."); + } + else + { + builder.AddMarkupContent(0, "The output was changed completely."); + } + } +} diff --git a/tests/BlazorE2E/BasicTestApp/MovingCheckboxesComponent.razor b/tests/BlazorE2E/BasicTestApp/MovingCheckboxesComponent.razor new file mode 100644 index 000000000..fbb1f4cf5 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/MovingCheckboxesComponent.razor @@ -0,0 +1,57 @@ +

+ This component represents a case that's difficult for the diff algorithm if it doesn't + understand how the underlying DOM gets mutated when you check a box. +

+

+ If we didn't have the RenderTreeUpdater, then if you checked the first incomplete item, + the diff algoritm would see the subsequent render has only one "todo" item left, and would + match it with the existing 'li' element. Since that's still not done, the algorithm would + think no change was needed to the checkbox. But since you just clicked that checkbox, the + UI would show it as checked. It would look as if you have completed all four items instead + of just three. +

+

+ RenderTreeUpdater fixes this by patching the old render tree to match the latest state of + the DOM, so the diff algoritm sees it must explicitly uncheck the remaining 'todo' box. +

+ +

To do

+ +
    + @foreach (var item in items.Where(x => !x.IsDone)) + { +
  • + + @item.Text +
  • + } +
+ +

Done

+ +
    + @foreach (var item in items.Where(x => x.IsDone)) + { +
  • + + @item.Text +
  • + } +
+ +@code { + #nullable disable + List items = new List + { + new TodoItem { Text = "Alpha" }, + new TodoItem { Text = "Beta" }, + new TodoItem { Text = "Gamma", IsDone = true }, + new TodoItem { Text = "Delta", IsDone = true }, + }; + + class TodoItem + { + public bool IsDone { get; set; } + public string Text { get; set; } + } +} diff --git a/tests/BlazorE2E/BasicTestApp/MultipleChildContent.razor b/tests/BlazorE2E/BasicTestApp/MultipleChildContent.razor new file mode 100644 index 000000000..78026c5db --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/MultipleChildContent.razor @@ -0,0 +1,33 @@ + +
Col1Col2Col3
+
+ @if (ShowFooter) + { + TheEnd + } +
+ + @row.Col1@row.Col2@row.Col3 + +
+ +Toggle: + +@code { + #nullable disable + List Items { get; } = new List() + { + new Item(){ Col1 = "a0", Col2 = "b0", Col3 = "c0", }, + new Item(){ Col1 = "a1", Col2 = "b1", Col3 = "c1", }, + new Item(){ Col1 = "a2", Col2 = "b2", Col3 = "c2", }, + }; + + bool ShowFooter; + + public class Item + { + public string Col1 { get; set; } + public string Col2 { get; set; } + public string Col3 { get; set; } + } +} diff --git a/tests/BlazorE2E/BasicTestApp/OrderedList.razor b/tests/BlazorE2E/BasicTestApp/OrderedList.razor new file mode 100644 index 000000000..45f8c46f0 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/OrderedList.razor @@ -0,0 +1,25 @@ +@typeparam TItem + +
    + @{ + var index = 1; + } + @foreach (var item in Items) + { + @Template(new Context() { Index = index++, Item = item, }); + } +
+ +@code{ + #nullable disable + [Parameter] public IEnumerable Items { get; set; } + + [Parameter] public RenderFragment Template { get; set; } + + public class Context + { + public int Index { get; set; } + + public TItem Item { get; set; } + } +} diff --git a/tests/BlazorE2E/BasicTestApp/RazorTemplates.razor b/tests/BlazorE2E/BasicTestApp/RazorTemplates.razor new file mode 100644 index 000000000..05ba535c0 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/RazorTemplates.razor @@ -0,0 +1,10 @@ +@{ + var items = new string[] { "A", "B", "c", }; +} + +
+ @{ + RenderFragment.Context> template = (context) => @
  • #@context.Index - @context.Item.ToLower()
  • ; + } + +
    diff --git a/tests/BlazorE2E/BasicTestApp/TemplatedTable.razor b/tests/BlazorE2E/BasicTestApp/TemplatedTable.razor new file mode 100644 index 000000000..5263bf821 --- /dev/null +++ b/tests/BlazorE2E/BasicTestApp/TemplatedTable.razor @@ -0,0 +1,36 @@ +@typeparam TItem + + @if (Header != null) + { + + @Header + + } + + @for (var i = 0; i < Items.Count; i++) + { + var item = Items[i]; + @ItemTemplate(item) + } + + + @if (Footer != null) + { + @Footer + } +
    + +@code { + #nullable disable + [Parameter] + public RenderFragment Header { get; set; } + + [Parameter] + public RenderFragment ItemTemplate { get; set; } + + [Parameter] + public RenderFragment Footer { get; set; } + + [Parameter] + public IReadOnlyList Items { get; set; } +} diff --git a/tests/BlazorE2E/ComponentRenderingTest.cs b/tests/BlazorE2E/ComponentRenderingTest.cs index 051a897f0..ffa757af4 100644 --- a/tests/BlazorE2E/ComponentRenderingTest.cs +++ b/tests/BlazorE2E/ComponentRenderingTest.cs @@ -1,17 +1,9 @@ using System; -using System.Collections.Generic; using System.Configuration.Assemblies; using System.Linq; using System.Numerics; -using System.Text; -using System.Threading.Tasks; -using AngleSharp.Dom; using Bunit.BlazorE2E.BasicTestApp; using Bunit.BlazorE2E.BasicTestApp.HierarchicalImportsTest.Subdir; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using Xunit.Abstractions; @@ -444,196 +436,173 @@ public void CanCaptureReferencesToDynamicallyAddedComponents() Assert.Equal("Current count: 0", currentCountText()); } - //[Fact] - //public void CanUseJsInteropForRefElementsDuringOnAfterRender() - //{ - // var cut = RenderComponent(); - // Assert.Equal("Value set after render", () => Browser.Find("input").GetAttribute("value")); - //} - - //[Fact] - //public void CanRenderMarkupBlocks() - //{ - // var cut = RenderComponent(); - - // // Static markup - // Assert.Equal( - // "attributes", - // cut.FindElement(By.CssSelector("p span#attribute-example")).TextContent); - - // // Dynamic markup (from a custom RenderFragment) - // Assert.Equal( - // "[Here is an example. We support multiple-top-level nodes.]", - // cut.Find("#dynamic-markup-block").TextContent); - // Assert.Equal( - // "example", - // cut.FindElement(By.CssSelector("#dynamic-markup-block strong#dynamic-element em")).TextContent); - - // // Dynamic markup (from a MarkupString) - // Assert.Equal( - // "This is a markup string.", - // cut.FindElement(By.ClassName("markup-string-value")).TextContent); - // Assert.Equal( - // "markup string", - // cut.Find(".markup-string-value em").TextContent); - - // // Updating markup blocks - // cut.Find("button").Click(); - // Browser.Equal( - // "[The output was changed completely.]", - // () => cut.Find("#dynamic-markup-block").TextContent); - // Assert.Equal( - // "changed", - // cut.FindElement(By.CssSelector("#dynamic-markup-block span em")).TextContent); - //} - - //[Fact] - //public void CanRenderRazorTemplates() - //{ - // var cut = RenderComponent(); - - // // code block template (component parameter) - // var element = cut.FindElement(By.CssSelector("div#codeblocktemplate ol")); - // Assert.Collection( - // element.FindAll("li"), - // e => Assert.Equal("#1 - a", e.TextContent), - // e => Assert.Equal("#2 - b", e.TextContent), - // e => Assert.Equal("#3 - c", e.TextContent)); - //} - - //[Fact] - //public void CanRenderMultipleChildContent() - //{ - // var cut = RenderComponent(); - - // var table = cut.Find("table"); - - // var thead = table.Find("thead"); - // Assert.Collection( - // thead.FindAll("th"), - // e => Assert.Equal("Col1", e.TextContent), - // e => Assert.Equal("Col2", e.TextContent), - // e => Assert.Equal("Col3", e.TextContent)); - - // var tfoot = table.Find("tfoot"); - // Assert.Empty(tfoot.FindAll("td")); - - // var toggle = cut.Find("#toggle"); - // toggle.Click(); - - // Browser.Collection( - // () => tfoot.FindAll("td"), - // e => Assert.Equal("The", e.TextContent), - // e => Assert.Equal("", e.TextContent), - // e => Assert.Equal("End", e.TextContent)); - //} - - //[Fact] - //public async Task CanAcceptSimultaneousRenderRequests() - //{ - // var expectedOutput = string.Join( - // string.Empty, - // Enumerable.Range(0, 100).Select(_ => "😊")); - - // var cut = RenderComponent(); - - // // It's supposed to pause the rendering for this long. The WaitAssert below - // // allows it to take up extra time if needed. - // await Task.Delay(1000); - - // var outputElement = cut.Find("#concurrent-render-output"); - // Assert.Equal(expectedOutput, () => outputElement.TextContent); - //} - - //[Fact] - //public void CanDispatchRenderToSyncContext() - //{ - // var cut = RenderComponent(); - // var result = cut.Find("#result"); - - // cut.Find("#run-with-dispatch").Click(); - - // Assert.Equal("Success (completed synchronously)", () => result.TextContent); - //} - - //[Fact] - //public void CanDoubleDispatchRenderToSyncContext() - //{ - // var cut = RenderComponent(); - // var result = cut.Find("#result"); - - // cut.Find("#run-with-double-dispatch").Click(); - - // Assert.Equal("Success (completed synchronously)", () => result.TextContent); - //} - - //[Fact] - //public void CanDispatchAsyncWorkToSyncContext() - //{ - // var cut = RenderComponent(); - // var result = cut.Find("#result"); - - // cut.Find("#run-async-with-dispatch").Click(); - - // Assert.Equal("First Second Third Fourth Fifth", () => result.TextContent); - //} - - //[Fact] - //public void CanPerformInteropImmediatelyOnComponentInsertion() - //{ - // var cut = RenderComponent(); - // Assert.Equal("Hello from interop call", () => cut.Find("#val-get-by-interop").TextContent); - // Assert.Equal("Hello from interop call", () => cut.Find("#val-set-by-interop").GetAttribute("value")); - //} - - //[Fact] - //public void CanUseAddMultipleAttributes() - //{ - // var cut = RenderComponent(); - - // var selector = By.CssSelector("#duplicate-on-element > div"); - // Browser.Exists(selector); - - // var element = cut.FindElement(selector); - // Assert.Equal(string.Empty, element.GetAttribute("bool")); // attribute is present - // Assert.Equal("middle-value", element.GetAttribute("string")); - // Assert.Equal("unmatched-value", element.GetAttribute("unmatched")); - - // selector = By.CssSelector("#duplicate-on-element-override > div"); - // element = cut.FindElement(selector); - // Assert.Null(element.GetAttribute("bool")); // attribute is not present - // Assert.Equal("other-text", element.GetAttribute("string")); - // Assert.Equal("unmatched-value", element.GetAttribute("unmatched")); - //} - - //[Fact] - //public void CanPatchRenderTreeToMatchLatestDOMState() - //{ - // var cut = RenderComponent(); - // var incompleteItemsSelector = By.CssSelector(".incomplete-items li"); - // var completeItemsSelector = By.CssSelector(".complete-items li"); - // Browser.Exists(incompleteItemsSelector); - - // // Mark first item as done; observe the remaining incomplete item appears unchecked - // // because the diff algorithm explicitly unchecks it - // cut.Find(".incomplete-items .item-isdone").Click(); - // Browser.True(() => - // { - // var incompleteLIs = cut.FindElements(incompleteItemsSelector); - // return incompleteLIs.Count == 1 - // && !incompleteLIs[0].Find(".item-isdone").Selected; - // }); - - // // Mark first done item as not done; observe the remaining complete item appears checked - // // because the diff algorithm explicitly re-checks it - // cut.Find(".complete-items .item-isdone").Click(); - // Browser.True(() => - // { - // var completeLIs = cut.FindElements(completeItemsSelector); - // return completeLIs.Count == 2 - // && completeLIs[0].Find(".item-isdone").Selected; - // }); - //} + [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] + public void CanUseJsInteropForRefElementsDuringOnAfterRender() + { + //var cut = RenderComponent(); + //Assert.Equal("Value set after render", () => Browser.Find("input").GetAttribute("value")); + } + + [Fact] + public void CanRenderMarkupBlocks() + { + var cut = RenderComponent(); + + // Static markup + Assert.Equal("attributes", cut.Find("p span#attribute-example").TextContent); + + // Dynamic markup (from a custom RenderFragment) + Assert.Equal("[Here is an example. We support multiple-top-level nodes.]", cut.Find("#dynamic-markup-block").TextContent.Trim()); + Assert.Equal("example", cut.Find("#dynamic-markup-block strong#dynamic-element em").TextContent); + + // Dynamic markup (from a MarkupString) + Assert.Equal("This is a markup string.", cut.Find(".markup-string-value").TextContent); + Assert.Equal("markup string", cut.Find(".markup-string-value em").TextContent); + + // Updating markup blocks + cut.Find("button").Click(); + Assert.Equal("[The output was changed completely.]", cut.Find("#dynamic-markup-block").TextContent.Trim()); + Assert.Equal("changed", cut.Find("#dynamic-markup-block span em").TextContent); + } + + [Fact] + public void CanRenderRazorTemplates() + { + var cut = RenderComponent(); + + // code block template (component parameter) + var element = cut.Find("div#codeblocktemplate ol"); + Assert.Collection(element.QuerySelectorAll("li"), + e => Assert.Equal("#1 - a", e.TextContent), + e => Assert.Equal("#2 - b", e.TextContent), + e => Assert.Equal("#3 - c", e.TextContent)); + } + + [Fact] + public void CanRenderMultipleChildContent() + { + var cut = RenderComponent(); + + var table = cut.Find("table"); + + var thead = table.QuerySelector("thead"); + Assert.Collection( + thead.QuerySelectorAll("th"), + e => Assert.Equal("Col1", e.TextContent), + e => Assert.Equal("Col2", e.TextContent), + e => Assert.Equal("Col3", e.TextContent)); + + var tfootElements = cut.FindAll("table tfoot td", enableAutoRefresh: true); + Assert.Empty(tfootElements); + var toggle = cut.Find("#toggle"); + toggle.Change(true); + + Assert.Collection(tfootElements, + e => Assert.Equal("The", e.TextContent), + e => Assert.Equal("", e.TextContent), + e => Assert.Equal("End", e.TextContent) + ); + } + + [Fact] + public void CanAcceptSimultaneousRenderRequests() + { + var expectedOutput = string.Join( + string.Empty, + Enumerable.Range(0, 100).Select(_ => "😊")); + + var cut = RenderComponent(); + + // It's supposed to pause the rendering for this long. The WaitAssert below + // allows it to take up extra time if needed. + //await Task.Delay(1000); + + var outputElement = cut.Find("#concurrent-render-output"); + + cut.WaitForAssertion( + () => Assert.Equal(expectedOutput, outputElement.TextContent.Trim()), + timeout: TimeSpan.FromMilliseconds(2000) + ); + } + + [Fact] + public void CanDispatchRenderToSyncContext() + { + var cut = RenderComponent(); + var result = cut.Find("#result"); + + cut.Find("#run-with-dispatch").Click(); + + cut.WaitForAssertion(() => Assert.Equal("Success (completed synchronously)", result.TextContent.Trim())); + } + + [Fact] + public void CanDoubleDispatchRenderToSyncContext() + { + var cut = RenderComponent(); + var result = cut.Find("#result"); + + cut.Find("#run-with-double-dispatch").Click(); + + cut.WaitForAssertion(() => Assert.Equal("Success (completed synchronously)", result.TextContent.Trim())); + } + + [Fact] + public void CanDispatchAsyncWorkToSyncContext() + { + var cut = RenderComponent(); + var result = cut.Find("#result"); + + cut.Find("#run-async-with-dispatch").Click(); + + cut.WaitForAssertion(() => Assert.Equal("First Second Third Fourth Fifth", result.TextContent.Trim()), timeout: TimeSpan.FromSeconds(2)); + } + + [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] + public void CanPerformInteropImmediatelyOnComponentInsertion() + { + //var cut = RenderComponent(); + //Assert.Equal("Hello from interop call", () => cut.Find("#val-get-by-interop").TextContent); + //Assert.Equal("Hello from interop call", () => cut.Find("#val-set-by-interop").GetAttribute("value")); + } + + [Fact] + public void CanUseAddMultipleAttributes() + { + var cut = RenderComponent(); + + var element = cut.Find("#duplicate-on-element > div"); + Assert.True(element.HasAttribute("bool")); // attribute is present + Assert.Equal("middle-value", element.GetAttribute("string")); + Assert.Equal("unmatched-value", element.GetAttribute("unmatched")); + + element = cut.Find("#duplicate-on-element-override > div"); + Assert.False(element.HasAttribute("bool")); // attribute is not present + Assert.Equal("other-text", element.GetAttribute("string")); + Assert.Equal("unmatched-value", element.GetAttribute("unmatched")); + } + + [Fact] + public void CanPatchRenderTreeToMatchLatestDOMState() + { + var cut = RenderComponent(); + var incompleteItemsSelector = ".incomplete-items li"; + var completeItemsSelector = ".complete-items li"; + + // Mark first item as done; observe the remaining incomplete item appears unchecked + // because the diff algorithm explicitly unchecks it + cut.Find(".incomplete-items .item-isdone").Change(true); + var incompleteLIs = cut.FindAll(incompleteItemsSelector); + Assert.Equal(1, incompleteLIs.Count); + Assert.False(incompleteLIs[0].QuerySelector(".item-isdone").HasAttribute("checked")); + + // Mark first done item as not done; observe the remaining complete item appears checked + // because the diff algorithm explicitly re-checks it + cut.Find(".complete-items .item-isdone").Change(false); + var completeLIs = cut.FindAll(completeItemsSelector); + Assert.Equal(2, completeLIs.Count); + Assert.True(completeLIs[0].QuerySelector(".item-isdone").HasAttribute("checked")); + } } } diff --git a/tests/ComponentTestFixtureTest.cs b/tests/ComponentTestFixtureTest.cs index 8229cd99b..42c7ec84e 100644 --- a/tests/ComponentTestFixtureTest.cs +++ b/tests/ComponentTestFixtureTest.cs @@ -1,8 +1,6 @@ using System; -using Microsoft.AspNetCore.Components; using Xunit; using Shouldly; -using System.Threading.Tasks; using Bunit.SampleComponents; using System.Diagnostics.CodeAnalysis; using Bunit.Mocking.JSInterop; diff --git a/tests/EventDispatchExtensions/ClipboardEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/ClipboardEventDispatchExtensionsTest.cs index 7f746180d..26f718ec3 100644 --- a/tests/EventDispatchExtensions/ClipboardEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/ClipboardEventDispatchExtensionsTest.cs @@ -1,13 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading; +using System.Reflection; using System.Threading.Tasks; -using Bunit.SampleComponents; -using Bunit.SampleComponents.Data; using Microsoft.AspNetCore.Components.Web; -using Moq; -using Shouldly; using Xunit; namespace Bunit diff --git a/tests/EventDispatchExtensions/EventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/EventDispatchExtensionsTest.cs index b23b09523..371fb7ad6 100644 --- a/tests/EventDispatchExtensions/EventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/EventDispatchExtensionsTest.cs @@ -7,7 +7,6 @@ using DeepEqual.Syntax; using Bunit.SampleComponents; using Shouldly; -using Xunit; using System.Diagnostics.CodeAnalysis; namespace Bunit diff --git a/tests/EventDispatchExtensions/FocusEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/FocusEventDispatchExtensionsTest.cs index 9839295a0..4182a0972 100644 --- a/tests/EventDispatchExtensions/FocusEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/FocusEventDispatchExtensionsTest.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Web; using Xunit; diff --git a/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs index abd6bf4fc..287b245cb 100644 --- a/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs @@ -1,8 +1,5 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; -using System.Text; using System.Threading.Tasks; using AngleSharp; using AngleSharp.Dom; diff --git a/tests/EventDispatchExtensions/MediaEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/MediaEventDispatchExtensionsTest.cs index 43d54e21f..4a35d8a23 100644 --- a/tests/EventDispatchExtensions/MediaEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/MediaEventDispatchExtensionsTest.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; using Xunit; diff --git a/tests/EventDispatchExtensions/PointerEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/PointerEventDispatchExtensionsTest.cs index 176769dff..e8037aaea 100644 --- a/tests/EventDispatchExtensions/PointerEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/PointerEventDispatchExtensionsTest.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Web; using Xunit; diff --git a/tests/EventDispatchExtensions/ProgressEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/ProgressEventDispatchExtensionsTest.cs index 40f49a2ce..966770f64 100644 --- a/tests/EventDispatchExtensions/ProgressEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/ProgressEventDispatchExtensionsTest.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Web; using Xunit; diff --git a/tests/EventDispatchExtensions/TouchEventDispatchExtensionsTest.cs b/tests/EventDispatchExtensions/TouchEventDispatchExtensionsTest.cs index 07a9d60af..28bae3f56 100644 --- a/tests/EventDispatchExtensions/TouchEventDispatchExtensionsTest.cs +++ b/tests/EventDispatchExtensions/TouchEventDispatchExtensionsTest.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Web; using Xunit; diff --git a/tests/EventDispatchExtensions/TriggerEventSpy.cs b/tests/EventDispatchExtensions/TriggerEventSpy.cs index abe8bed58..02956e551 100644 --- a/tests/EventDispatchExtensions/TriggerEventSpy.cs +++ b/tests/EventDispatchExtensions/TriggerEventSpy.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; using System.Threading.Tasks; using AngleSharp.Dom; using Bunit.SampleComponents; diff --git a/tests/Mocking/JSInterop/JsRuntimeAssertExtensionsTest.cs b/tests/Mocking/JSInterop/JsRuntimeAssertExtensionsTest.cs index dff880423..59171f599 100644 --- a/tests/Mocking/JSInterop/JsRuntimeAssertExtensionsTest.cs +++ b/tests/Mocking/JSInterop/JsRuntimeAssertExtensionsTest.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading.Tasks; using AngleSharp.Dom; using Bunit.Diffing; diff --git a/tests/Mocking/JSInterop/JsRuntimeInvocationTest.cs b/tests/Mocking/JSInterop/JsRuntimeInvocationTest.cs index 2778e5478..4a6471c57 100644 --- a/tests/Mocking/JSInterop/JsRuntimeInvocationTest.cs +++ b/tests/Mocking/JSInterop/JsRuntimeInvocationTest.cs @@ -1,9 +1,6 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; -using System.Threading.Tasks; using Shouldly; using Xunit; diff --git a/tests/Mocking/JSInterop/MockJsRuntimeInvokeHandlerTest.cs b/tests/Mocking/JSInterop/MockJsRuntimeInvokeHandlerTest.cs index c6f7c46b5..f08b98234 100644 --- a/tests/Mocking/JSInterop/MockJsRuntimeInvokeHandlerTest.cs +++ b/tests/Mocking/JSInterop/MockJsRuntimeInvokeHandlerTest.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.JSInterop; diff --git a/tests/RefreshableQueryCollectionTest.cs b/tests/RefreshableQueryCollectionTest.cs index f96c249da..53b54c1ec 100644 --- a/tests/RefreshableQueryCollectionTest.cs +++ b/tests/RefreshableQueryCollectionTest.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Bunit.SampleComponents; +using Bunit.SampleComponents; using Shouldly; using Xunit; diff --git a/tests/Rendering/ComponentParameterTest.cs b/tests/Rendering/ComponentParameterTest.cs index 2fadf0500..9cf78db23 100644 --- a/tests/Rendering/ComponentParameterTest.cs +++ b/tests/Rendering/ComponentParameterTest.cs @@ -1,8 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Shouldly; using Xunit; diff --git a/tests/Rendering/RenderEventPubSubTest.cs b/tests/Rendering/RenderEventPubSubTest.cs index 6e6d64f22..07bb4c229 100644 --- a/tests/Rendering/RenderEventPubSubTest.cs +++ b/tests/Rendering/RenderEventPubSubTest.cs @@ -1,10 +1,5 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Components.RenderTree; -using Moq; using Shouldly; using Xunit; diff --git a/tests/Rendering/RenderWaitingHelperExtensionsTest.cs b/tests/Rendering/RenderWaitingHelperExtensionsTest.cs index 3dee4eb74..495f12e90 100644 --- a/tests/Rendering/RenderWaitingHelperExtensionsTest.cs +++ b/tests/Rendering/RenderWaitingHelperExtensionsTest.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using AngleSharp.Dom; using Bunit.Mocking.JSInterop; using Bunit.SampleComponents; diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index 0ce41f1cd..b1fa96751 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -1,16 +1,7 @@ -using AngleSharp.Dom; -using Bunit.Extensions.Xunit; -using Bunit.Mocking.JSInterop; -using Bunit.SampleComponents; -using Bunit.SampleComponents.Data; +using Bunit.SampleComponents; using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Shouldly; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using Xunit; using Xunit.Abstractions; using Xunit.Sdk; diff --git a/tests/SampleComponents/Data/AsyncNameDep.cs b/tests/SampleComponents/Data/AsyncNameDep.cs index 1aab24318..af01c9524 100644 --- a/tests/SampleComponents/Data/AsyncNameDep.cs +++ b/tests/SampleComponents/Data/AsyncNameDep.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; namespace Bunit.SampleComponents.Data { diff --git a/tests/SampleComponents/Data/IAsyncTestDep.cs b/tests/SampleComponents/Data/IAsyncTestDep.cs index 1e648a4be..09ea254eb 100644 --- a/tests/SampleComponents/Data/IAsyncTestDep.cs +++ b/tests/SampleComponents/Data/IAsyncTestDep.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; namespace Bunit.SampleComponents.Data { diff --git a/tests/SampleComponents/Data/ITestDep.cs b/tests/SampleComponents/Data/ITestDep.cs index 1e3a569fe..7d34fd951 100644 --- a/tests/SampleComponents/Data/ITestDep.cs +++ b/tests/SampleComponents/Data/ITestDep.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Bunit.SampleComponents.Data +namespace Bunit.SampleComponents.Data { public interface ITestDep { diff --git a/tests/SampleComponents/TriggerTester.cs b/tests/SampleComponents/TriggerTester.cs index 9e7110dbd..34ec63314 100644 --- a/tests/SampleComponents/TriggerTester.cs +++ b/tests/SampleComponents/TriggerTester.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; diff --git a/tests/TestRendererTest.cs b/tests/TestRendererTest.cs index f82998372..fa9c128a5 100644 --- a/tests/TestRendererTest.cs +++ b/tests/TestRendererTest.cs @@ -1,11 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Bunit.SampleComponents; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; +using Bunit.SampleComponents; using Shouldly; using Xunit; diff --git a/tests/TestServiceProviderTest.cs b/tests/TestServiceProviderTest.cs index 6e17223f8..3d4329bc5 100644 --- a/tests/TestServiceProviderTest.cs +++ b/tests/TestServiceProviderTest.cs @@ -1,14 +1,10 @@ -using Bunit; -using Bunit.Mocking.JSInterop; +using Bunit.Mocking.JSInterop; using Bunit.SampleComponents; -using Bunit.SampleComponents.Data; using Microsoft.Extensions.DependencyInjection; using Shouldly; using System; using System.Collections; -using System.Collections.Generic; using System.Linq; -using System.Text; using Xunit; using Xunit.Sdk; From 9aa109f061e334c6d3d91bf4b4b48f5535d1dbc7 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 28 Feb 2020 15:49:20 +0000 Subject: [PATCH 23/27] Tweaks to test --- CHANGELOG.md | 77 ++++++++- src/Extensions/ElementNotFoundException.cs | 2 +- src/Extensions/Internal/ElementFactory.cs | 2 +- .../RenderedFragmentQueryExtensions.cs | 2 +- .../JSInterop/JsRuntimeAssertExtensions.cs | 6 +- src/Rendering/Internal/Htmlizer.cs | 22 +-- src/bunit.csproj | 2 +- tests/BlazorE2E/ComponentRenderingTest.cs | 156 ++++++++++++------ tests/Rendering/RenderedFragmentTest.cs | 4 +- tests/TestRendererTest.cs | 53 +----- 10 files changed, 200 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f118c899..c61dbe6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,85 @@ # Changelog -All notable changes to this project will be documented in this file. The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +All notable changes to **bUnit** will be documented in this file. The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -This release includes a name change from Blazor Components Testing Library to **bUnit**. It also brings along two extra helper methods for working with asynchronously rendering components during testing, and a bunch of internal optimizations and tweaks to the code. +This release includes a **name change from Blazor Components Testing Library to bUnit**. It also brings along two extra helper methods for working with asynchronously rendering components during testing, and a bunch of internal optimizations and tweaks to the code. *Why change the name?* Naming is hard, and I initial chose a very product-namy name, that quite clearly stated what the library was for. However, the name isn't very searchable, since it just contains generic keywords, plus, bUnit is just much cooler. It also gave me the opportunity to remove my name from all the namespaces and simplify those. +### NuGet +The latest version of the library is availble on NuGet: + +| | Type | Link | +| ------------- | ----- | ---- | +| [![Nuget](https://img.shields.io/nuget/dt/bunit?logo=nuget&style=flat-square)](https://www.nuget.org/packages/bunit/) | Library | [https://www.nuget.org/packages/bunit/](https://www.nuget.org/packages/bunit/) | +| [![Nuget](https://img.shields.io/nuget/dt/bunit.template?logo=nuget&style=flat-square)](https://www.nuget.org/packages/bunit.template/) | Template | [https://www.nuget.org/packages/bunit.template/](https://www.nuget.org/packages/bunit.template/) | + ### Added - **`WaitForState(Func statePredicate, TimeSpan? timeout = 1 second)` has been added to `ITestContext` and `IRenderedFragment`.** This method will wait (block) until the provided statePredicate returns true, or the timeout is reached (during debugging the timeout is disabled). Each time the renderer in the test context renders, or the rendered fragment renders, the statePredicate is evaluated. You use this method, if you have a component under test, that requires _one or more asynchronous triggered renders_, to get to a desired state, before the test can continue. + The following example tests the `DelayedRenderOnClick.razor` component: + + ```cshtml + // DelayedRenderOnClick.razor +

    Times Clicked: @TimesClicked

    + + @code + { + public int TimesClicked { get; private set; } + + async Task ClickCounter() + { + await Task.Delay(1); // wait 1 millisecond + TimesClicked += 1; + } + } + ``` + + This is a test that uses `WaitForState` to wait until the component under test has a desired state, before the test continues: + + ```csharp + [Fact] + public void WaitForStateExample() + { + // Arrange + var cut = RenderComponent(); + + // Act + cut.Find("button").Click(); + cut.WaitForState(() => cut.Instance.TimesClicked == 1); + + // Assert + cut.Find("p").TextContent.ShouldBe("Times Clicked: 1"); + } + ``` + - **`WaitForAssertion(Action assertion, TimeSpan? timeout = 1 second)` has been added to `ITestContext` and `IRenderedFragment`.** This method will wait (block) until the provided assertion method passes, i.e. runs without throwing an assert exception, or until the timeout is reached (during debugging the timeout is disabled). Each time the renderer in the test context renders, or the rendered fragment renders, the assertion is attempted. You use this method, if you have a component under test, that requires _one or more asynchronous triggered renders_, to get to a desired state, before the test can continue. + This is a test that tests the `DelayedRenderOnClick.razor` listed above, and that uses `WaitForAssertion` to attempt the assertion each time the component under test renders: + + ```csharp + [Fact] + public void WaitForAssertionExample() + { + // Arrange + var cut = RenderComponent(); + + // Act + cut.Find("button").Click(); + + // Assert + cut.WaitForAssertion( + () => cut.Find("p").TextContent.ShouldBe("Times Clicked: 1") + ); + } + ``` + - **Added support for capturing log statements from the renderer and components under test into the test output.** To enable this, add a constructor to your test classes that takes the `ITestOutputHelper` as input, then in the constructor call `Services.AddXunitLogger` and pass the `ITestOutputHelper` to it, e.g.: @@ -96,6 +159,11 @@ This release includes a name change from Blazor Components Testing Library to ** - **Added logging to TestRenderer.** To make it easier to understand the rendering life-cycle during a test, the `TestRenderer` will now log when ever it dispatches an event or renders a component (the log statements can be access by capturing debug logs in the test results, as mentioned above). +- **Added some of the Blazor frameworks end-2-end tests.** To get better test coverage of the many rendering scenarios supported by Blazor, the [ComponentRenderingTest.cs](https://github.com/dotnet/aspnetcore/blob/master/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs) tests from the Blazor frameworks test suite has been converted from a Selenium to a bUnit. The testing style is very similar, so few changes was necessary to port the tests. The two test classes are here, if you want to compare: + + - [bUnit's ComponentRenderingTest.cs](/master/tests/BlazorE2E/ComponentRenderingTest.cs) + - [Blazor's ComponentRenderingTest.cs](https://github.com/dotnet/aspnetcore/blob/master/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs) + ### Changed - **Namespaces is now `Bunit`** The namespaces have changed from `Egil.RazorComponents.Testing.Library.*` to simply `Bunit` for the library, and `Bunit.Mocking.JSInterop` for the JSInterop mocking support. @@ -169,7 +237,4 @@ This release includes a name change from Blazor Components Testing Library to ** ### Fixed - **Wrong casing on keyboard event dispatch helpers.** - The helper methods for the keyboard events was not probably cased, so that has been updated. E.g. from `Keypress(...)` to `KeyPress(...)`. - - -### Security \ No newline at end of file + The helper methods for the keyboard events was not probably cased, so that has been updated. E.g. from `Keypress(...)` to `KeyPress(...)`. \ No newline at end of file diff --git a/src/Extensions/ElementNotFoundException.cs b/src/Extensions/ElementNotFoundException.cs index 3886349ac..585b32163 100644 --- a/src/Extensions/ElementNotFoundException.cs +++ b/src/Extensions/ElementNotFoundException.cs @@ -1,6 +1,6 @@ using System; -namespace Xunit.Sdk +namespace Bunit { /// /// Represents a failure to find an element in the searched target diff --git a/src/Extensions/Internal/ElementFactory.cs b/src/Extensions/Internal/ElementFactory.cs index d97f10ab9..ea637e93d 100644 --- a/src/Extensions/Internal/ElementFactory.cs +++ b/src/Extensions/Internal/ElementFactory.cs @@ -1,7 +1,7 @@ using System; using AngleSharp.Dom; using AngleSharpWrappers; -using Xunit.Sdk; +using Bunit.Extensions; namespace Bunit { diff --git a/src/Extensions/RenderedFragmentQueryExtensions.cs b/src/Extensions/RenderedFragmentQueryExtensions.cs index aac22efdc..b0da5b5e2 100644 --- a/src/Extensions/RenderedFragmentQueryExtensions.cs +++ b/src/Extensions/RenderedFragmentQueryExtensions.cs @@ -1,7 +1,7 @@ using System; using AngleSharp.Dom; using AngleSharpWrappers; -using Xunit.Sdk; +using Bunit.Extensions; namespace Bunit { diff --git a/src/Mocking/JSInterop/JsRuntimeAssertExtensions.cs b/src/Mocking/JSInterop/JsRuntimeAssertExtensions.cs index c00c9b08d..260e77e7e 100644 --- a/src/Mocking/JSInterop/JsRuntimeAssertExtensions.cs +++ b/src/Mocking/JSInterop/JsRuntimeAssertExtensions.cs @@ -48,10 +48,14 @@ public static JsRuntimeInvocation VerifyInvoke(this MockJsRuntimeInvokeHandler h public static IReadOnlyList VerifyInvoke(this MockJsRuntimeInvokeHandler handler, string identifier, int calledTimes, string? userMessage = null) { if (handler is null) throw new ArgumentNullException(nameof(handler)); + if (calledTimes < 1) throw new ArgumentException($"Use {nameof(VerifyNotInvoke)} to verify an identifier has not been invoked.", nameof(calledTimes)); - var invocations = handler.Invocations[identifier]; + if (!handler.Invocations.TryGetValue(identifier, out var invocations) ) + { + throw new JsInvokeCountExpectedException(identifier, calledTimes, 0, nameof(VerifyInvoke), userMessage); + } if (invocations.Count != calledTimes) { diff --git a/src/Rendering/Internal/Htmlizer.cs b/src/Rendering/Internal/Htmlizer.cs index 71b6057d9..8ad0cf9ab 100644 --- a/src/Rendering/Internal/Htmlizer.cs +++ b/src/Rendering/Internal/Htmlizer.cs @@ -1,8 +1,10 @@ -using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Encodings.Web; namespace Bunit @@ -188,26 +190,16 @@ private static int RenderAttributes( var candidateIndex = position + i; ref var frame = ref frames.Array[candidateIndex]; - // NOTE: The following is the original from HtmlRenderer.cs - // - // if (frame.FrameType != RenderTreeFrameType.Attribute) - // { - // return candidateIndex; - // } - // - // The next two if block have been added instead. - // This will enable verification of element ref capture in unit tests. - if (frame.FrameType != RenderTreeFrameType.Attribute && frame.FrameType != RenderTreeFrameType.ElementReferenceCapture) + // Added to write ElementReferenceCaptureId to DOM + if (frame.FrameType == RenderTreeFrameType.ElementReferenceCapture) { - return candidateIndex; + result.Add($" {ELEMENT_REFERENCE_ATTR_NAME}=\"{frame.ElementReferenceCaptureId}\""); } - if (frame.FrameType == RenderTreeFrameType.ElementReferenceCapture) + if (frame.FrameType != RenderTreeFrameType.Attribute) { - result.Add($" {ELEMENT_REFERENCE_ATTR_NAME}=\"{frame.AttributeName}\""); return candidateIndex; } - // End of addition if (frame.AttributeName.Equals("value", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/bunit.csproj b/src/bunit.csproj index dc0c32470..55649adb3 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -49,7 +49,7 @@ - + diff --git a/tests/BlazorE2E/ComponentRenderingTest.cs b/tests/BlazorE2E/ComponentRenderingTest.cs index ffa757af4..37e302093 100644 --- a/tests/BlazorE2E/ComponentRenderingTest.cs +++ b/tests/BlazorE2E/ComponentRenderingTest.cs @@ -4,6 +4,8 @@ using System.Numerics; using Bunit.BlazorE2E.BasicTestApp; using Bunit.BlazorE2E.BasicTestApp.HierarchicalImportsTest.Subdir; +using Bunit.Mocking.JSInterop; +using Microsoft.AspNetCore.Components; using Shouldly; using Xunit; using Xunit.Abstractions; @@ -299,36 +301,37 @@ public void CanUseViewImportsHierarchically() elem => Assert.Equal(typeof(AssemblyHashAlgorithm).FullName, elem.TextContent)); } - [Fact(Skip = "Test doesn't make sense in this context")] - public void CanUseComponentAndStaticContentFromExternalNuGetPackage() - { - //var appElement = Browser.MountTestComponent(); - - //// NuGet packages can use JS interop features to provide - //// .NET code access to browser APIs - //var showPromptButton = appElement.FindElements(By.TagName("button")).First(); - //showPromptButton.Click(); - - //var modal = new WebDriverWait(Browser, TimeSpan.FromSeconds(3)) - // .Until(SwitchToAlert); - //modal.SendKeys("Some value from test"); - //modal.Accept(); - //var promptResult = appElement.FindElement(By.TagName("strong")); - //Browser.Equal("Some value from test", () => promptResult.Text); - - //// NuGet packages can also embed entire components (themselves - //// authored as Razor files), including static content. The CSS value - //// here is in a .css file, so if it's correct we know that static content - //// file was loaded. - //var specialStyleDiv = appElement.FindElement(By.ClassName("special-style")); - //Assert.Equal("50px", specialStyleDiv.GetCssValue("padding")); - - //// The external components are fully functional, not just static HTML - //var externalComponentButton = specialStyleDiv.FindElement(By.TagName("button")); - //Assert.Equal("Click me", externalComponentButton.Text); - //externalComponentButton.Click(); - //Browser.Equal("It works", () => externalComponentButton.Text); - } + // Test removed since doesn't make sense in this context. + //[Fact] + //public void CanUseComponentAndStaticContentFromExternalNuGetPackage() + //{ + // var appElement = Browser.MountTestComponent(); + + // // NuGet packages can use JS interop features to provide + // // .NET code access to browser APIs + // var showPromptButton = appElement.FindElements(By.TagName("button")).First(); + // showPromptButton.Click(); + + // var modal = new WebDriverWait(Browser, TimeSpan.FromSeconds(3)) + // .Until(SwitchToAlert); + // modal.SendKeys("Some value from test"); + // modal.Accept(); + // var promptResult = appElement.FindElement(By.TagName("strong")); + // Browser.Equal("Some value from test", () => promptResult.Text); + + // // NuGet packages can also embed entire components (themselves + // // authored as Razor files), including static content. The CSS value + // // here is in a .css file, so if it's correct we know that static content + // // file was loaded. + // var specialStyleDiv = appElement.FindElement(By.ClassName("special-style")); + // Assert.Equal("50px", specialStyleDiv.GetCssValue("padding")); + + // // The external components are fully functional, not just static HTML + // var externalComponentButton = specialStyleDiv.FindElement(By.TagName("button")); + // Assert.Equal("Click me", externalComponentButton.Text); + // externalComponentButton.Click(); + // Browser.Equal("It works", () => externalComponentButton.Text); + //} [Fact] public void CanRenderSvgWithCorrectNamespace() @@ -365,24 +368,40 @@ public void LogicalElementInsertionWorksHierarchically() cut.MarkupMatches("First Second Third"); } - [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] + [Fact] public void CanUseJsInteropToReferenceElements() { - //var cut = RenderComponent(); - //var inputElement = cut.Find("#capturedElement"); - //var buttonElement = cut.Find("button"); + // NOTE: This test required JS to modify the DOM. Test rewritten to use MockJsRuntime + // The original test code is here: + // var cut = RenderComponent(); + // var inputElement = cut.Find("#capturedElement"); + // var buttonElement = cut.Find("button"); - //Assert.Equal(string.Empty, inputElement.GetAttribute("value")); + // Assert.Equal(string.Empty, inputElement.GetAttribute("value")); - //buttonElement.Click(); - //Assert.Equal("Clicks: 1", inputElement.GetAttribute("value")); - //buttonElement.Click(); - //Assert.Equal("Clicks: 2", inputElement.GetAttribute("value")); + // buttonElement.Click(); + // Assert.Equal("Clicks: 1", inputElement.GetAttribute("value")); + // buttonElement.Click(); + // Assert.Equal("Clicks: 2", inputElement.GetAttribute("value")); + + var mockJs = Services.AddMockJsRuntime(); + var cut = RenderComponent(); + var inputElement = cut.Find("#capturedElement"); + var refId = inputElement.GetAttribute(Htmlizer.ELEMENT_REFERENCE_ATTR_NAME); + var buttonElement = cut.Find("button"); + + buttonElement.Click(); + mockJs.VerifyInvoke("setElementValue") + .Arguments[0] + .ShouldBeOfType() + .Id.ShouldBe(refId); } - [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] + [Fact] public void CanCaptureReferencesToDynamicallyAddedElements() { + // NOTE: This test required JS to modify the DOM. Test rewritten to use MockJsRuntime + // The original test code is here: //var cut = RenderComponent(); //var buttonElement = cut.Find("button"); //var checkbox = cut.Find("input[type=checkbox]"); @@ -403,6 +422,33 @@ public void CanCaptureReferencesToDynamicallyAddedElements() //// See that the capture variable was automatically updated to reference the new instance //buttonElement.Click(); //Assert.Equal("Clicks: 1", () => inputElement.GetAttribute("value")); + + var mockJs = Services.AddMockJsRuntime(); + + var cut = RenderComponent(); + var buttonElement = cut.Find("button"); + var checkbox = cut.Find("input[type=checkbox]"); + + // We're going to remove the input. But first, put in some contents + // so we can observe it's not the same instance later + cut.Find("#capturedElement"); + + // Remove the captured element + checkbox.Change(false); + Should.Throw(() => cut.Find("#capturedElement")); + + // Re-add it; observe it starts empty again + checkbox.Change(true); + var inputElement = cut.Find("#capturedElement"); + var refId = inputElement.GetAttribute(Htmlizer.ELEMENT_REFERENCE_ATTR_NAME); + + // See that the capture variable was automatically updated to reference the new instance + buttonElement.Click(); + + mockJs.VerifyInvoke("setElementValue") + .Arguments[0] + .ShouldBeOfType() + .Id.ShouldBe(refId); } [Fact] @@ -436,12 +482,13 @@ public void CanCaptureReferencesToDynamicallyAddedComponents() Assert.Equal("Current count: 0", currentCountText()); } - [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] - public void CanUseJsInteropForRefElementsDuringOnAfterRender() - { - //var cut = RenderComponent(); - //Assert.Equal("Value set after render", () => Browser.Find("input").GetAttribute("value")); - } + // Test depends on javascript changing the DOM, thus doesnt make sense in this context. + //[Fact] + //public void CanUseJsInteropForRefElementsDuringOnAfterRender() + //{ + // var cut = RenderComponent(); + // Assert.Equal("Value set after render", () => Browser.Find("input").GetAttribute("value")); + //} [Fact] public void CanRenderMarkupBlocks() @@ -521,7 +568,7 @@ public void CanAcceptSimultaneousRenderRequests() cut.WaitForAssertion( () => Assert.Equal(expectedOutput, outputElement.TextContent.Trim()), - timeout: TimeSpan.FromMilliseconds(2000) + timeout: TimeSpan.FromSeconds(2000) ); } @@ -558,13 +605,14 @@ public void CanDispatchAsyncWorkToSyncContext() cut.WaitForAssertion(() => Assert.Equal("First Second Third Fourth Fifth", result.TextContent.Trim()), timeout: TimeSpan.FromSeconds(2)); } - [Fact(Skip = "Test depends on javascript changing the DOM, thus doesnt make sense in this context. Test recreated in TestRendererTest.")] - public void CanPerformInteropImmediatelyOnComponentInsertion() - { - //var cut = RenderComponent(); - //Assert.Equal("Hello from interop call", () => cut.Find("#val-get-by-interop").TextContent); - //Assert.Equal("Hello from interop call", () => cut.Find("#val-set-by-interop").GetAttribute("value")); - } + // Test removed since it does not have any value in this context. + //[Fact] + //public void CanPerformInteropImmediatelyOnComponentInsertion() + //{ + // var cut = RenderComponent(); + // Assert.Equal("Hello from interop call", () => cut.Find("#val-get-by-interop").TextContent); + // Assert.Equal("Hello from interop call", () => cut.Find("#val-set-by-interop").GetAttribute("value")); + //} [Fact] public void CanUseAddMultipleAttributes() diff --git a/tests/Rendering/RenderedFragmentTest.cs b/tests/Rendering/RenderedFragmentTest.cs index b1fa96751..84cdf03a6 100644 --- a/tests/Rendering/RenderedFragmentTest.cs +++ b/tests/Rendering/RenderedFragmentTest.cs @@ -1,10 +1,10 @@ -using Bunit.SampleComponents; +using Bunit.Extensions; +using Bunit.SampleComponents; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using Xunit.Abstractions; -using Xunit.Sdk; namespace Bunit { diff --git a/tests/TestRendererTest.cs b/tests/TestRendererTest.cs index fa9c128a5..ced3d8bee 100644 --- a/tests/TestRendererTest.cs +++ b/tests/TestRendererTest.cs @@ -1,4 +1,10 @@ -using Bunit.SampleComponents; +using System; +using System.Linq; +using Bunit.BlazorE2E.BasicTestApp; +using Bunit.Extensions; +using Bunit.Mocking.JSInterop; +using Bunit.SampleComponents; +using Microsoft.AspNetCore.Components; using Shouldly; using Xunit; @@ -11,53 +17,12 @@ public void Test001() { var res = new ConcurrentRenderEventSubscriber(Renderer.RenderEvents); var sut = RenderComponent(); - + res.RenderCount.ShouldBe(1); sut.Find("button").Click(); - - res.RenderCount.ShouldBe(2); - } - - [Fact(DisplayName = "Can pass reference to elements to JsInterop")] - public void Test010() - { - //var cut = RenderComponent(); - //var inputElement = cut.Find("#capturedElement"); - //var buttonElement = cut.Find("button"); - - //Assert.Equal(string.Empty, inputElement.GetAttribute("value")); - - //buttonElement.Click(); - //Assert.Equal("Clicks: 1", inputElement.GetAttribute("value")); - //buttonElement.Click(); - //Assert.Equal("Clicks: 2", inputElement.GetAttribute("value")); - } - [Fact(DisplayName = "Can capture reference to dynamically added elements and pass to JsInterop")] - public void Test011() - { - //var cut = RenderComponent(); - //var buttonElement = cut.Find("button"); - //var checkbox = cut.Find("input[type=checkbox]"); - - //// We're going to remove the input. But first, put in some contents - //// so we can observe it's not the same instance later - //cut.Find("#capturedElement").SendKeys("some text"); - - //// Remove the captured element - //checkbox.Click(); - //Browser.Empty(() => cut.FindAll("#capturedElement")); - - //// Re-add it; observe it starts empty again - //checkbox.Click(); - //var inputElement = cut.Find("#capturedElement"); - //Assert.Equal(string.Empty, inputElement.GetAttribute("value")); - - //// See that the capture variable was automatically updated to reference the new instance - //buttonElement.Click(); - //Assert.Equal("Clicks: 1", () => inputElement.GetAttribute("value")); + res.RenderCount.ShouldBe(2); } - } } From 0e679ea748e7e4a22ee243a8011c2df2158622d4 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 28 Feb 2020 15:53:39 +0000 Subject: [PATCH 24/27] Changed anglesharp.wrappers version dep --- src/bunit.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bunit.csproj b/src/bunit.csproj index 55649adb3..dc0c32470 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -49,7 +49,7 @@ - + From 561495278613ce2ebbfbf0d8e187885dca1561c5 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 28 Feb 2020 16:36:37 +0000 Subject: [PATCH 25/27] Tweaks to template --- src/bunit.csproj | 11 ++++++----- template/template/.template.config/template.json | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/bunit.csproj b/src/bunit.csproj index dc0c32470..d50e65f64 100644 --- a/src/bunit.csproj +++ b/src/bunit.csproj @@ -14,6 +14,7 @@ LICENSE https://github.com/egil/razor-components-testing-library git + 1.0.0-beta-6 #{BRANCH}# #{COMMIT}# https://github.com/egil/razor-components-testing-library @@ -41,11 +42,10 @@ - - - - - + + + + @@ -53,6 +53,7 @@ + diff --git a/template/template/.template.config/template.json b/template/template/.template.config/template.json index 4647c838c..e86ec4c11 100644 --- a/template/template/.template.config/template.json +++ b/template/template/.template.config/template.json @@ -2,14 +2,14 @@ "$schema": "http://json.schemastore.org/template", "author": "Egil Hansen", "classifications": [ - "Test", "Razor", "Blazor", "Library" + "Test", "bUnit", "Blazor" ], - "name": "bUnit Testing Project", + "name": "bUnit Test Project", "description": "A project for a testing Blazor/Razor components using the bUnit library.", "generatorVersions": "[1.0.0.0-*)", "identity": "BunitProject", "groupIdentity": "Bunit", - "shortName": "razortest", + "shortName": "bunit", "tags": { "language": "C#", "type": "project" From f7fe4495ea07b803c3e9922ff8e9704f895164e7 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 28 Feb 2020 16:42:22 +0000 Subject: [PATCH 26/27] Update CI.yml --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 53eb1ccc6..42def49ff 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -38,6 +38,6 @@ jobs: - name: Verifying template run: | dotnet new --install ${GITHUB_WORKSPACE}/template/bunit.template.$VERSION.nupkg - dotnet new razortest -o ${GITHUB_WORKSPACE}/Test + dotnet new bunit -o ${GITHUB_WORKSPACE}/Test dotnet restore ${GITHUB_WORKSPACE}/Test/Test.csproj --source ${GITHUB_WORKSPACE}/lib dotnet test ${GITHUB_WORKSPACE}/Test From 94eb85721cc5252090d8180141c516e56a750eac Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 28 Feb 2020 16:42:44 +0000 Subject: [PATCH 27/27] Update nuget-pack-push.yml --- .github/workflows/nuget-pack-push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nuget-pack-push.yml b/.github/workflows/nuget-pack-push.yml index b875e8601..1ad5e15e8 100644 --- a/.github/workflows/nuget-pack-push.yml +++ b/.github/workflows/nuget-pack-push.yml @@ -41,7 +41,7 @@ jobs: - name: Verifying template run: | dotnet new --install ${GITHUB_WORKSPACE}/template/bunit.template.$VERSION.nupkg - dotnet new razortest -o ${GITHUB_WORKSPACE}/Test + dotnet new bunit -o ${GITHUB_WORKSPACE}/Test dotnet restore ${GITHUB_WORKSPACE}/Test/Test.csproj --source ${GITHUB_WORKSPACE}/lib dotnet test ${GITHUB_WORKSPACE}/Test - name: Push packages to NuGet.org