Skip to content

Commit bbb433d

Browse files
committed
feat: Add FindByText in bunit.web.query
1 parent 59eaade commit bbb433d

5 files changed

Lines changed: 629 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using AngleSharp.Dom;
2+
using Bunit.Web.AngleSharp;
3+
4+
namespace Bunit;
5+
6+
internal sealed class ByTextElementFactory : IElementWrapperFactory
7+
{
8+
private readonly IRenderedComponent<IComponent> testTarget;
9+
private readonly string searchText;
10+
private readonly ByTextOptions options;
11+
12+
public Action? OnElementReplaced { get; set; }
13+
14+
public ByTextElementFactory(IRenderedComponent<IComponent> testTarget, string searchText, ByTextOptions options)
15+
{
16+
this.testTarget = testTarget;
17+
this.searchText = searchText;
18+
this.options = options;
19+
testTarget.OnMarkupUpdated += FragmentsMarkupUpdated;
20+
}
21+
22+
private void FragmentsMarkupUpdated(object? sender, EventArgs args)
23+
=> OnElementReplaced?.Invoke();
24+
25+
public TElement GetElement<TElement>() where TElement : class, IElement
26+
{
27+
var element = testTarget.FindByTextInternal(searchText, options) as TElement;
28+
29+
return element ?? throw new ElementRemovedFromDomException(searchText);
30+
}
31+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace Bunit;
2+
3+
/// <summary>
4+
/// Allows overrides of behavior for FindByText method
5+
/// </summary>
6+
public record class ByTextOptions
7+
{
8+
/// <summary>
9+
/// The default behavior used by FindByText if no overrides are specified
10+
/// </summary>
11+
internal static readonly ByTextOptions Default = new();
12+
13+
/// <summary>
14+
/// The StringComparison used for comparing the desired text to the element's text content. Defaults to Ordinal (case sensitive).
15+
/// </summary>
16+
public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal;
17+
18+
/// <summary>
19+
/// The CSS selector used to filter which elements are searched. Defaults to "*" (all elements).
20+
/// </summary>
21+
public string Selector { get; set; } = "*";
22+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace Bunit;
2+
3+
/// <summary>
4+
/// Represents a failure to find an element in the searched target
5+
/// using the element's text content.
6+
/// </summary>
7+
public sealed class TextNotFoundException : Exception
8+
{
9+
/// <summary>
10+
/// Gets the text used to search with.
11+
/// </summary>
12+
public string SearchText { get; }
13+
14+
/// <summary>
15+
/// Initializes a new instance of the <see cref="TextNotFoundException"/> class.
16+
/// </summary>
17+
/// <param name="searchText">The text that was searched for.</param>
18+
public TextNotFoundException(string searchText)
19+
: base($"Unable to find an element with the text '{searchText}'.")
20+
{
21+
SearchText = searchText;
22+
}
23+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System.Text.RegularExpressions;
2+
using AngleSharp.Dom;
3+
using Bunit.Web.AngleSharp;
4+
5+
namespace Bunit;
6+
7+
/// <summary>
8+
/// Extension methods for querying <see cref="IRenderedComponent{TComponent}" /> by text content
9+
/// </summary>
10+
public static partial class TextQueryExtensions
11+
{
12+
private static readonly HashSet<string> IgnoredNodeNames = new(StringComparer.OrdinalIgnoreCase) { "SCRIPT", "STYLE" };
13+
14+
/// <summary>
15+
/// Returns the first element whose text content matches the given text.
16+
/// </summary>
17+
/// <param name="renderedComponent">The rendered fragment to search.</param>
18+
/// <param name="searchText">The text content to search for.</param>
19+
/// <param name="configureOptions">Method used to override the default behavior of FindByText.</param>
20+
/// <returns>The first element matching the specified text.</returns>
21+
/// <exception cref="TextNotFoundException">Thrown when no element matching the provided text is found.</exception>
22+
public static IElement FindByText(this IRenderedComponent<IComponent> renderedComponent, string searchText, Action<ByTextOptions>? configureOptions = null)
23+
{
24+
ArgumentNullException.ThrowIfNull(renderedComponent);
25+
ArgumentNullException.ThrowIfNull(searchText);
26+
27+
var options = ByTextOptions.Default;
28+
if (configureOptions is not null)
29+
{
30+
options = options with { };
31+
configureOptions.Invoke(options);
32+
}
33+
34+
return FindByTextInternal(renderedComponent, searchText, options) ?? throw new TextNotFoundException(searchText);
35+
}
36+
37+
/// <summary>
38+
/// Returns all elements whose text content matches the given text.
39+
/// </summary>
40+
/// <param name="renderedComponent">The rendered fragment to search.</param>
41+
/// <param name="searchText">The text content to search for.</param>
42+
/// <param name="configureOptions">Method used to override the default behavior of FindAllByText.</param>
43+
/// <returns>A read-only collection of elements matching the text. Returns an empty collection if no matches are found.</returns>
44+
public static IReadOnlyList<IElement> FindAllByText(this IRenderedComponent<IComponent> renderedComponent, string searchText, Action<ByTextOptions>? configureOptions = null)
45+
{
46+
ArgumentNullException.ThrowIfNull(renderedComponent);
47+
ArgumentNullException.ThrowIfNull(searchText);
48+
49+
var options = ByTextOptions.Default;
50+
if (configureOptions is not null)
51+
{
52+
options = options with { };
53+
configureOptions.Invoke(options);
54+
}
55+
56+
return FindAllByTextInternal(renderedComponent, searchText, options);
57+
}
58+
59+
internal static IElement? FindByTextInternal(this IRenderedComponent<IComponent> renderedComponent, string searchText, ByTextOptions options)
60+
{
61+
var elements = renderedComponent.Nodes.TryQuerySelectorAll(options.Selector);
62+
var normalizedSearchText = NormalizeWhitespace(searchText);
63+
64+
foreach (var element in elements)
65+
{
66+
if (IgnoredNodeNames.Contains(element.NodeName))
67+
continue;
68+
69+
var normalizedTextContent = NormalizeWhitespace(element.TextContent);
70+
71+
if (normalizedTextContent.Equals(normalizedSearchText, options.ComparisonType))
72+
return element.WrapUsing(new ByTextElementFactory(renderedComponent, searchText, options));
73+
}
74+
75+
return null;
76+
}
77+
78+
internal static IReadOnlyList<IElement> FindAllByTextInternal(this IRenderedComponent<IComponent> renderedComponent, string searchText, ByTextOptions options)
79+
{
80+
var elements = renderedComponent.Nodes.TryQuerySelectorAll(options.Selector);
81+
var normalizedSearchText = NormalizeWhitespace(searchText);
82+
var results = new List<IElement>();
83+
var seen = new HashSet<IElement>();
84+
85+
foreach (var element in elements)
86+
{
87+
if (IgnoredNodeNames.Contains(element.NodeName))
88+
continue;
89+
90+
var normalizedTextContent = NormalizeWhitespace(element.TextContent);
91+
92+
if (!normalizedTextContent.Equals(normalizedSearchText, options.ComparisonType))
93+
continue;
94+
95+
var underlyingElement = element.Unwrap();
96+
if (seen.Add(underlyingElement))
97+
{
98+
results.Add(element.WrapUsing(new ByTextElementFactory(renderedComponent, searchText, options)));
99+
}
100+
}
101+
102+
return results;
103+
}
104+
105+
internal static string NormalizeWhitespace(string text)
106+
{
107+
var trimmed = text.Trim();
108+
return CollapseWhitespaceRegex().Replace(trimmed, " ");
109+
}
110+
111+
[GeneratedRegex(@"\s+")]
112+
private static partial Regex CollapseWhitespaceRegex();
113+
}

0 commit comments

Comments
 (0)