Skip to content

Commit 5bda93c

Browse files
committed
feat: Use source generator for event dispatch extensions
1 parent 0218349 commit 5bda93c

31 files changed

+356
-3233
lines changed

docs/samples/tests/razor/ClickMeTest.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
// Act
1313
buttonElement.Click();
14-
buttonElement.Click(detail: 3, ctrlKey: true);
14+
buttonElement.Click(new MouseEventArgs { Detail = 3, CtrlKey = true });
1515
buttonElement.Click(new MouseEventArgs());
1616

1717
// Assert

docs/samples/tests/xunit/ClickMeTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public void Test()
1515

1616
// Act
1717
buttonElement.Click();
18-
buttonElement.Click(detail: 3, ctrlKey: true);
18+
buttonElement.Click(new MouseEventArgs { Detail = 3, CtrlKey = true });
1919
buttonElement.Click(new MouseEventArgs());
2020

2121
// Assert

docs/site/docs/interaction/trigger-event-handlers.md

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,7 @@ Blazor makes it possible to bind many event handlers to elements in a Blazor com
99

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

12-
**The built-in dispatch event helpers are:**
13-
14-
- [Clipboard events](xref:Bunit.ClipboardEventDispatchExtensions)
15-
- [Drag events](xref:Bunit.DragEventDispatchExtensions)
16-
- [Focus events](xref:Bunit.FocusEventDispatchExtensions)
17-
- [General events](xref:Bunit.GeneralEventDispatchExtensions)
18-
- [Input events](xref:Bunit.InputEventDispatchExtensions)
19-
- [Keyboard events](xref:Bunit.KeyboardEventDispatchExtensions)
20-
- [Media events](xref:Bunit.MediaEventDispatchExtensions)
21-
- [Mouse events](xref:Bunit.MouseEventDispatchExtensions)
22-
- [Pointer events](xref:Bunit.PointerEventDispatchExtensions)
23-
- [Progress events](xref:Bunit.ProgressEventDispatchExtensions)
24-
- [Touch event](xref:Bunit.TouchEventDispatchExtensions)
12+
**The built-in dispatch event helpers are:** [here](xref:Bunit.EventHandlerDispatchExtensions).
2513

2614
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.
2715

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

5240
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.
53-
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:
54-
- 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.
55-
- 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.
41+
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.
5642

5743
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.
5844

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

11197
// Assert that the custom event data was passed correctly
11298
cut.Find("p:last-child").MarkupMatches("<p>You pasted: FOO</p>");
99+
```
100+
101+
## Using the `Async` version
102+
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.
103+
104+
Example:
105+
106+
```csharp
107+
await cut.Find("button").ClickAsync();
113108
```

docs/site/docs/migrations/1to2.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The `Fake` prefix used for various fake implementations has been renamed to `Bun
2929
* `FakeAuthrozitationContext` to `BunitAuthorizationContext`
3030
* `FakeuthorizationPolicyProvider` to `BunitAuthorizationPolicyProvider`
3131

32-
## Unified the `Render` methods
32+
## Unified the `Render` methods
3333
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:
3434

3535
```diff
@@ -64,4 +64,18 @@ public static class Extensions
6464
return loggerFactory.CreateLogger<T>();
6565
}
6666
}
67-
```
67+
```
68+
69+
## Event dispatcher does not offer overload with all parameters
70+
71+
In version 1.x, bUnit offered for example the following methods to invoke `onclick` on a component:
72+
73+
```csharp
74+
cut.Find("button").Click();
75+
cut.Find("button").ClickAsync(new MouseEventArgs());
76+
cut.Find("button").Click(detail: 2, ctrlKey: true);
77+
```
78+
79+
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.
80+
81+
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.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
using System;
2+
using System.Collections.Frozen;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Text;
6+
using Microsoft.CodeAnalysis;
7+
8+
namespace Bunit.Blazor;
9+
10+
[Generator]
11+
public class EventDispatcherExtensionGenerator : IIncrementalGenerator
12+
{
13+
private static readonly Dictionary<string, string> EventNameOverrides = new()
14+
{
15+
["ondblclick"] = "DoubleClick",
16+
["onmousewheel"] = "MouseWheel"
17+
};
18+
19+
private static readonly FrozenSet<string> WordBoundaries =
20+
[
21+
"key", "mouse", "pointer", "touch", "drag", "drop", "focus", "blur",
22+
"click", "dbl", "context", "menu", "copy", "cut", "paste",
23+
"down", "up", "over", "out", "move", "enter", "leave", "start", "end",
24+
"cancel", "change", "input", "wheel", "got", "lost", "capture",
25+
"in", "before", "after", "load", "time", "abort", "progress", "error",
26+
"activate", "deactivate", "ended", "full", "screen", "data", "metadata",
27+
"lock", "ready", "state", "scroll", "toggle", "close", "seeking", "seeked",
28+
"loaded", "duration", "emptied", "stalled", "suspend", "volume", "waiting",
29+
"play", "playing", "pause", "press", "rate", "stop", "cue", "can", "through", "update"
30+
];
31+
32+
public void Initialize(IncrementalGeneratorInitializationContext context)
33+
{
34+
var compilationProvider = context.CompilationProvider;
35+
36+
context.RegisterSourceOutput(compilationProvider, GenerateEventDispatchExtensions);
37+
}
38+
39+
private static void GenerateEventDispatchExtensions(SourceProductionContext context, Compilation compilation)
40+
{
41+
var eventHandlerAttributeSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.EventHandlerAttribute");
42+
if (eventHandlerAttributeSymbol is null)
43+
{
44+
return;
45+
}
46+
47+
var eventHandlersSymbol = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.Web.EventHandlers");
48+
if (eventHandlersSymbol is null)
49+
{
50+
return;
51+
}
52+
53+
var eventMappings = new Dictionary<string, List<(string EventName, string MethodName)>>();
54+
55+
foreach (var attribute in eventHandlersSymbol.GetAttributes())
56+
{
57+
if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, eventHandlerAttributeSymbol))
58+
continue;
59+
60+
if (attribute.ConstructorArguments.Length < 2)
61+
continue;
62+
63+
var eventName = attribute.ConstructorArguments[0].Value?.ToString();
64+
var eventArgsType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol;
65+
66+
if (string.IsNullOrEmpty(eventName) || eventArgsType == null)
67+
continue;
68+
69+
var eventArgsTypeName = eventArgsType.Name;
70+
71+
var methodName = GenerateMethodNameFromEventName(eventName);
72+
73+
if (!eventMappings.ContainsKey(eventArgsTypeName))
74+
{
75+
eventMappings[eventArgsTypeName] = [];
76+
}
77+
78+
eventMappings[eventArgsTypeName].Add((eventName, methodName));
79+
}
80+
81+
if (eventMappings.Count == 0)
82+
{
83+
return;
84+
}
85+
86+
var sourceBuilder = new StringBuilder(8000);
87+
sourceBuilder.AppendLine("#nullable enable");
88+
sourceBuilder.AppendLine("using AngleSharp.Dom;");
89+
sourceBuilder.AppendLine("using Microsoft.AspNetCore.Components.Web;");
90+
sourceBuilder.AppendLine("using System.Threading.Tasks;");
91+
sourceBuilder.AppendLine();
92+
sourceBuilder.AppendLine("namespace Bunit;");
93+
sourceBuilder.AppendLine();
94+
95+
sourceBuilder.AppendLine("/// <summary>");
96+
sourceBuilder.AppendLine("/// Event dispatch helper extension methods.");
97+
sourceBuilder.AppendLine("/// </summary>");
98+
sourceBuilder.AppendLine("public static partial class EventHandlerDispatchExtensions");
99+
sourceBuilder.AppendLine("{");
100+
101+
foreach (var kvp in eventMappings.OrderBy(x => x.Key))
102+
{
103+
GenerateExtensionsForEventArgsType(sourceBuilder, kvp.Key, kvp.Value);
104+
}
105+
106+
sourceBuilder.AppendLine("}");
107+
108+
context.AddSource("EventHandlerDispatchExtensions.g.cs", sourceBuilder.ToString());
109+
}
110+
111+
private static string GenerateMethodNameFromEventName(string eventName)
112+
{
113+
if (EventNameOverrides.TryGetValue(eventName, out var overrideName))
114+
{
115+
return overrideName;
116+
}
117+
118+
if (eventName.StartsWith("on"))
119+
{
120+
eventName = eventName[2..];
121+
}
122+
123+
if (eventName.Length == 0)
124+
{
125+
return eventName;
126+
}
127+
128+
var result = new StringBuilder();
129+
130+
var i = 0;
131+
while (i < eventName.Length)
132+
{
133+
var foundWord = false;
134+
135+
foreach (var word in WordBoundaries.OrderByDescending(w => w.Length))
136+
{
137+
var isWithinStringBounds = i + word.Length <= eventName.Length;
138+
var isWordMatch = isWithinStringBounds && eventName.AsSpan(i, word.Length).Equals(word.AsSpan(), StringComparison.OrdinalIgnoreCase);
139+
140+
if (isWordMatch)
141+
{
142+
result.Append(char.ToUpper(word[0]));
143+
result.Append(word[1..].ToLower());
144+
i += word.Length;
145+
foundWord = true;
146+
break;
147+
}
148+
}
149+
150+
if (!foundWord)
151+
{
152+
result.Append(i == 0 ? char.ToUpper(eventName[i]) : eventName[i]);
153+
i++;
154+
}
155+
}
156+
157+
return result.ToString();
158+
}
159+
160+
private static void GenerateExtensionsForEventArgsType(StringBuilder sourceBuilder, string eventArgsType, List<(string EventName, string MethodName)> events)
161+
{
162+
sourceBuilder.AppendLine($"\t// {eventArgsType} events");
163+
164+
foreach (var (eventName, methodName) in events)
165+
{
166+
var qualifiedEventArgsType = eventArgsType == "ErrorEventArgs"
167+
? "Microsoft.AspNetCore.Components.Web.ErrorEventArgs"
168+
: eventArgsType;
169+
170+
if (methodName == "Click")
171+
{
172+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 1 }");
173+
}
174+
else if (methodName == "DoubleClick")
175+
{
176+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, "new MouseEventArgs() { Detail = 2 }");
177+
}
178+
else
179+
{
180+
GenerateAsyncEventMethodWithDefaultArgs(sourceBuilder, methodName, eventName, qualifiedEventArgsType, $"new {qualifiedEventArgsType}()");
181+
}
182+
}
183+
}
184+
185+
private static void GenerateAsyncEventMethodWithDefaultArgs(StringBuilder sourceBuilder, string methodName, string eventName, string eventArgsType, string defaultArgs)
186+
{
187+
sourceBuilder.AppendLine("\t/// <summary>");
188+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
189+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
190+
sourceBuilder.AppendLine("\t/// </summary>");
191+
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
192+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
193+
sourceBuilder.AppendLine($"\tpublic static void {methodName}(this IElement element, {eventArgsType}? eventArgs = null) => _ = {methodName}Async(element, eventArgs);");
194+
sourceBuilder.AppendLine();
195+
196+
sourceBuilder.AppendLine("\t/// <summary>");
197+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on <paramref name=\"element\"/>, passing the provided <paramref name=\"eventArgs\"/>");
198+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
199+
sourceBuilder.AppendLine("\t/// </summary>");
200+
sourceBuilder.AppendLine("\t/// <param name=\"element\">The element to raise the event on.</param>");
201+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
202+
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
203+
sourceBuilder.AppendLine($"\tpublic static Task {methodName}Async(this IElement element, {eventArgsType}? eventArgs = null) => element.TriggerEventAsync(\"{eventName}\", eventArgs ?? {defaultArgs});");
204+
sourceBuilder.AppendLine();
205+
206+
sourceBuilder.AppendLine("\t/// <summary>");
207+
sourceBuilder.AppendLine($"\t/// Raises the <c>@{eventName}</c> event on the element returned by <paramref name=\"elementTask\"/>, passing the provided <paramref name=\"eventArgs\"/>");
208+
sourceBuilder.AppendLine($"\t/// to the event handler. If <paramref name=\"eventArgs\"/> is null, a new instance of {eventArgsType} will be created.");
209+
sourceBuilder.AppendLine("\t/// </summary>");
210+
sourceBuilder.AppendLine("\t/// <param name=\"elementTask\">A task that returns the element to raise the element on.</param>");
211+
sourceBuilder.AppendLine("\t/// <param name=\"eventArgs\">The event arguments to pass to the event handler.</param>");
212+
sourceBuilder.AppendLine("\t/// <returns>A task that completes when the event handler is done.</returns>");
213+
sourceBuilder.AppendLine($"\tpublic static async Task {methodName}Async(this Task<IElement> elementTask, {eventArgsType}? eventArgs = null)");
214+
sourceBuilder.AppendLine("\t{");
215+
sourceBuilder.AppendLine("\t\tvar element = await elementTask;");
216+
sourceBuilder.AppendLine($"\t\tawait {methodName}Async(element, eventArgs);");
217+
sourceBuilder.AppendLine("\t}");
218+
sourceBuilder.AppendLine();
219+
}
220+
}

0 commit comments

Comments
 (0)