Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/samples/tests/razor/ClickMeTest.razor
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

// Act
buttonElement.Click();
buttonElement.Click(detail: 3, ctrlKey: true);
buttonElement.Click(new MouseEventArgs { Detail = 3, CtrlKey = true });
buttonElement.Click(new MouseEventArgs());

// Assert
Expand Down
2 changes: 1 addition & 1 deletion docs/samples/tests/xunit/ClickMeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public void Test()

// Act
buttonElement.Click();
buttonElement.Click(detail: 3, ctrlKey: true);
buttonElement.Click(new MouseEventArgs { Detail = 3, CtrlKey = true });
buttonElement.Click(new MouseEventArgs());

// Assert
Expand Down
27 changes: 11 additions & 16 deletions docs/site/docs/interaction/trigger-event-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,7 @@ Blazor makes it possible to bind many event handlers to elements in a Blazor com

bUnit comes with event dispatch helper methods that makes it possible to invoke event handlers for all event types supported by Blazor.

**The built-in dispatch event helpers are:**

- [Clipboard events](xref:Bunit.ClipboardEventDispatchExtensions)
- [Drag events](xref:Bunit.DragEventDispatchExtensions)
- [Focus events](xref:Bunit.FocusEventDispatchExtensions)
- [General events](xref:Bunit.GeneralEventDispatchExtensions)
- [Input events](xref:Bunit.InputEventDispatchExtensions)
- [Keyboard events](xref:Bunit.KeyboardEventDispatchExtensions)
- [Media events](xref:Bunit.MediaEventDispatchExtensions)
- [Mouse events](xref:Bunit.MouseEventDispatchExtensions)
- [Pointer events](xref:Bunit.PointerEventDispatchExtensions)
- [Progress events](xref:Bunit.ProgressEventDispatchExtensions)
- [Touch event](xref:Bunit.TouchEventDispatchExtensions)
**The built-in dispatch event helpers are:** [here](xref:Bunit.EventHandlerDispatchExtensions).

To use these, first find the element in the component under test where the event handler is bound. This is usually done with the [`Find(string cssSelector)`](xref:Bunit.RenderedComponentExtensions.Find``1(Bunit.IRenderedComponent{``0},System.String)) method. Next, invoke the event dispatch helper method of choice.

Expand Down Expand Up @@ -50,9 +38,7 @@ To trigger the `@onclick` `ClickHandler` event handler method in the `<ClickMe>`
This is what happens in the test:

1. In the arrange step of the test, the `<ClickMe>` component is rendered and the `<button>` element is found using the [`Find(string cssSelector)`](xref:Bunit.RenderedComponentExtensions.Find``1(Bunit.IRenderedComponent{``0},System.String)) method.
2. The act step of the test is the `<button>`'s click event handler. In this case, the `ClickHandler` event handler method is invoked in three different ways:
- The first and second invocations use the same [`Click`](xref:Bunit.MouseEventDispatchExtensions.Click(AngleSharp.Dom.IElement,System.Int64,System.Double,System.Double,System.Double,System.Double,System.Double,System.Double,System.Double,System.Double,System.Int64,System.Int64,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.String)) method. It has a number of optional arguments, some of which are passed in the second invocation. If any arguments are provided, they are added to an instance of the `MouseEventArgs` type, which is passed to the event handler if it has it as an argument.
- The last invocation uses the [`Click`](xref:Bunit.MouseEventDispatchExtensions.Click(AngleSharp.Dom.IElement,Microsoft.AspNetCore.Components.Web.MouseEventArgs)) method. This takes an instance of the `MouseEventArgs` type, which is passed to the event handler if it has it as an argument.
2. The act step of the test is the `<button>`'s click event handler. In this case, the `ClickHandler` event handler method is invoked by calling the [`Click`](xref:Bunit.EventHandlerDispatchExtensions.Click(AngleSharp.Dom.IElement,Microsoft.AspNetCore.Components.Web.MouseEventArgs)) extension method on the found `<button>` element. The method takes an optional `MouseEventArgs` argument, which, if not supplied, will be initialized with default values.

All the event dispatch helper methods have the same two overloads: one that takes a number of optional arguments, and one that takes one of the `EventArgs` types provided by Blazor.

Expand Down Expand Up @@ -110,4 +96,13 @@ cut.Find("input").TriggerEvent("oncustompaste", new CustomPasteEventArgs

// Assert that the custom event data was passed correctly
cut.Find("p:last-child").MarkupMatches("<p>You pasted: FOO</p>");
```

## Using the `Async` version
All event dispatch helper methods have an `Async` version that returns a `Task`. Important to note is that the `Async` version will await the event handler callback **but not** the rendercycle that may be triggered by the event handler.

Example:

```csharp
await cut.Find("button").ClickAsync();
```
18 changes: 16 additions & 2 deletions docs/site/docs/migrations/1to2.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The `Fake` prefix used for various fake implementations has been renamed to `Bun
* `FakeAuthrozitationContext` to `BunitAuthorizationContext`
* `FakeuthorizationPolicyProvider` to `BunitAuthorizationPolicyProvider`

## Unified the `Render` methods
## Unified the `Render` methods
In v1 there were multiple `RenderXXX`methods (like `RenderComponent`, `Render` and `SetParametersAndRender`) that were used to render components and markup. In v2, these methods have been unified into a single `Render` method that can handle both components and markup) via the simple `Render` method:

```diff
Expand Down Expand Up @@ -64,4 +64,18 @@ public static class Extensions
return loggerFactory.CreateLogger<T>();
}
}
```
```

## Event dispatcher does not offer overload with all parameters

In version 1.x, bUnit offered for example the following methods to invoke `onclick` on a component:

```csharp
cut.Find("button").Click();
cut.Find("button").ClickAsync(new MouseEventArgs());
cut.Find("button").Click(detail: 2, ctrlKey: true);
```

The last one was a method with all parameters of `MouseEventArgs` as optional parameters. This method has been removed in favor of using the `MouseEventArgs` directly.

Also `ClickAsync` - to align with its synchronous counterpart - doesn't take `MouseEventArgs` as mandatory parameter anymore. If not set, a default instance will be created.
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;

namespace Bunit.Blazor;

[Generator]
public class EventDispatcherExtensionGenerator : IIncrementalGenerator
{
private static readonly Dictionary<string, string> EventNameOverrides = new()
{
["ondblclick"] = "DoubleClick",
["onmousewheel"] = "MouseWheel"
};

private static readonly FrozenSet<string> WordBoundaries =
[
"key", "mouse", "pointer", "touch", "drag", "drop", "focus", "blur",
"click", "dbl", "context", "menu", "copy", "cut", "paste",
"down", "up", "over", "out", "move", "enter", "leave", "start", "end",
"cancel", "change", "input", "wheel", "got", "lost", "capture",
"in", "before", "after", "load", "time", "abort", "progress", "error",
"activate", "deactivate", "ended", "full", "screen", "data", "metadata",
"lock", "ready", "state", "scroll", "toggle", "close", "seeking", "seeked",
"loaded", "duration", "emptied", "stalled", "suspend", "volume", "waiting",
"play", "playing", "pause", "press", "rate", "stop", "cue", "can", "through", "update"
];

public void Initialize(IncrementalGeneratorInitializationContext context)
{
var compilationProvider = context.CompilationProvider;

context.RegisterSourceOutput(compilationProvider, GenerateEventDispatchExtensions);
}

private static void GenerateEventDispatchExtensions(SourceProductionContext context, Compilation compilation)
{
var eventHandlerAttributeSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.EventHandlerAttribute");
if (eventHandlerAttributeSymbol is null)
{
return;
}

var eventHandlersSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.Web.EventHandlers");
if (eventHandlersSymbol is null)
{
return;
}

var eventMappings = new Dictionary<string, List<(string EventName, string MethodName)>>();

foreach (var attribute in eventHandlersSymbol.GetAttributes())
{
if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, eventHandlerAttributeSymbol))
continue;

if (attribute.ConstructorArguments.Length < 2)
continue;

var eventName = attribute.ConstructorArguments[0].Value?.ToString();
var eventArgsType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol;

if (string.IsNullOrEmpty(eventName) || eventArgsType == null)
continue;

var eventArgsTypeName = eventArgsType.Name;

var methodName = GenerateMethodNameFromEventName(eventName);

if (!eventMappings.ContainsKey(eventArgsTypeName))
{
eventMappings[eventArgsTypeName] = [];
}

eventMappings[eventArgsTypeName].Add((eventName, methodName));
}

if (eventMappings.Count == 0)
{
return;
}

var sourceBuilder = new StringBuilder(8000);
sourceBuilder.AppendLine("#nullable enable");
sourceBuilder.AppendLine("using AngleSharp.Dom;");
sourceBuilder.AppendLine("using Microsoft.AspNetCore.Components.Web;");
sourceBuilder.AppendLine("using System.Threading.Tasks;");
sourceBuilder.AppendLine();
sourceBuilder.AppendLine("namespace Bunit;");
sourceBuilder.AppendLine();

sourceBuilder.AppendLine("/// <summary>");
sourceBuilder.AppendLine("/// Event dispatch helper extension methods.");
sourceBuilder.AppendLine("/// </summary>");
sourceBuilder.AppendLine("public static partial class EventHandlerDispatchExtensions");
sourceBuilder.AppendLine("{");

foreach (var kvp in eventMappings.OrderBy(x => x.Key))
{
GenerateExtensionsForEventArgsType(sourceBuilder, kvp.Key, kvp.Value);
}

sourceBuilder.AppendLine("}");

context.AddSource("EventHandlerDispatchExtensions.g.cs", sourceBuilder.ToString());
}

private static string GenerateMethodNameFromEventName(string eventName)
{
if (EventNameOverrides.TryGetValue(eventName, out var overrideName))
{
return overrideName;
}

if (eventName.StartsWith("on"))
{
eventName = eventName[2..];
}

if (eventName.Length == 0)
{
return eventName;
}

var result = new StringBuilder();

var i = 0;
while (i < eventName.Length)
{
var foundWord = false;

foreach (var word in WordBoundaries.OrderByDescending(w => w.Length))
{
var isWithinStringBounds = i + word.Length <= eventName.Length;
var isWordMatch = isWithinStringBounds && eventName.AsSpan(i, word.Length).Equals(word.AsSpan(), StringComparison.OrdinalIgnoreCase);

if (isWordMatch)
{
result.Append(char.ToUpper(word[0]));
result.Append(word[1..].ToLower());
i += word.Length;
foundWord = true;
break;
}
}

if (!foundWord)
{
result.Append(i == 0 ? char.ToUpper(eventName[i]) : eventName[i]);
i++;
}
}

return result.ToString();
}

private static void GenerateExtensionsForEventArgsType(StringBuilder sourceBuilder, string eventArgsType, List<(string EventName, string MethodName)> events)
{
sourceBuilder.AppendLine($"\t// {eventArgsType} events");

foreach (var (eventName, methodName) in events)
{
var qualifiedEventArgsType = eventArgsType == "ErrorEventArgs"
? "Microsoft.AspNetCore.Components.Web.ErrorEventArgs"
: eventArgsType;
Comment on lines +166 to +168
Copy link

Copilot AI Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coding the special case for ErrorEventArgs makes the code brittle. Consider creating a mapping dictionary or using reflection to determine the fully qualified type name for better maintainability.

Copilot uses AI. Check for mistakes.

if (methodName == "Click")
{
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 1 }");
}
else if (methodName == "DoubleClick")
{
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 2 }");
}
else
{
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, $"new {qualifiedEventArgsType}()");
}
}
}

private static void GenerateAsyncEventMethodWithDefaultArgs(StringBuilder sourceBuilder, string methodName, string eventName, string eventArgsType, string defaultArgs)
{
sourceBuilder.AppendLine("\t/// <summary>");
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
sourceBuilder.AppendLine("\t/// </summary>");
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
sourceBuilder.AppendLine($"\tpublic static void {methodName}(this IElement element, {eventArgsType}? eventArgs = null) => _ = {methodName}Async(element, eventArgs);");
sourceBuilder.AppendLine();

sourceBuilder.AppendLine("\t/// <summary>");
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
sourceBuilder.AppendLine("\t/// </summary>");
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
sourceBuilder.AppendLine($"\tpublic static Task {methodName}Async(this IElement element, {eventArgsType}? eventArgs = null) => element.TriggerEventAsync(\"{eventName}\", eventArgs ?? {defaultArgs});");
sourceBuilder.AppendLine();

sourceBuilder.AppendLine("\t/// <summary>");
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on the element returned by <paramref name=\"elementTask\"/>, passing the provided <paramref name=\"eventArgs\"/>");
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
sourceBuilder.AppendLine("\t/// </summary>");
sourceBuilder.AppendLine("\t/// <param name=\"elementTask\">A task that returns the element to raise the element on.</param>");
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
sourceBuilder.AppendLine($"\tpublic static async Task {methodName}Async(this Task<IElement> elementTask, {eventArgsType}? eventArgs = null)");
sourceBuilder.AppendLine("\t{");
sourceBuilder.AppendLine("\t\tvar element = await elementTask;");
sourceBuilder.AppendLine($"\t\tawait {methodName}Async(element, eventArgs);");
sourceBuilder.AppendLine("\t}");
sourceBuilder.AppendLine();
}
}
Loading
Loading