Skip to content
Draft
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
31 changes: 31 additions & 0 deletions src/bunit.web.query/ByTextElementFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using AngleSharp.Dom;
using Bunit.Web.AngleSharp;

namespace Bunit;

internal sealed class ByTextElementFactory : IElementWrapperFactory
{
private readonly IRenderedComponent<IComponent> testTarget;
private readonly string searchText;
private readonly ByTextOptions options;

public Action? OnElementReplaced { get; set; }

public ByTextElementFactory(IRenderedComponent<IComponent> testTarget, string searchText, ByTextOptions options)
{
this.testTarget = testTarget;
this.searchText = searchText;
this.options = options;
testTarget.OnMarkupUpdated += FragmentsMarkupUpdated;
}

private void FragmentsMarkupUpdated(object? sender, EventArgs args)
=> OnElementReplaced?.Invoke();

public TElement GetElement<TElement>() where TElement : class, IElement
{
var element = testTarget.FindByTextInternal(searchText, options) as TElement;

return element ?? throw new ElementRemovedFromDomException(searchText);
}
}
22 changes: 22 additions & 0 deletions src/bunit.web.query/Text/ByTextOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Bunit;

/// <summary>
/// Allows overrides of behavior for FindByText method
/// </summary>
public record class ByTextOptions
{
/// <summary>
/// The default behavior used by FindByText if no overrides are specified
/// </summary>
internal static readonly ByTextOptions Default = new();

/// <summary>
/// The StringComparison used for comparing the desired text to the element's text content. Defaults to Ordinal (case sensitive).
/// </summary>
public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal;

/// <summary>
/// The CSS selector used to filter which elements are searched. Defaults to "*" (all elements).
/// </summary>
public string Selector { get; set; } = "*";
}
23 changes: 23 additions & 0 deletions src/bunit.web.query/Text/TextNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Bunit;

/// <summary>
/// Represents a failure to find an element in the searched target
/// using the element's text content.
/// </summary>
public sealed class TextNotFoundException : Exception
{
/// <summary>
/// Gets the text used to search with.
/// </summary>
public string SearchText { get; }

/// <summary>
/// Initializes a new instance of the <see cref="TextNotFoundException"/> class.
/// </summary>
/// <param name="searchText">The text that was searched for.</param>
public TextNotFoundException(string searchText)
: base($"Unable to find an element with the text '{searchText}'.")
{
SearchText = searchText;
}
}
113 changes: 113 additions & 0 deletions src/bunit.web.query/Text/TextQueryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Text.RegularExpressions;
using AngleSharp.Dom;
using Bunit.Web.AngleSharp;

namespace Bunit;

/// <summary>
/// Extension methods for querying <see cref="IRenderedComponent{TComponent}" /> by text content
/// </summary>
public static partial class TextQueryExtensions
{
private static readonly HashSet<string> IgnoredNodeNames = new(StringComparer.OrdinalIgnoreCase) { "SCRIPT", "STYLE" };

/// <summary>
/// Returns the first element whose text content matches the given text.
/// </summary>
/// <param name="renderedComponent">The rendered fragment to search.</param>
/// <param name="searchText">The text content to search for.</param>
/// <param name="configureOptions">Method used to override the default behavior of FindByText.</param>
/// <returns>The first element matching the specified text.</returns>
/// <exception cref="TextNotFoundException">Thrown when no element matching the provided text is found.</exception>
public static IElement FindByText(this IRenderedComponent<IComponent> renderedComponent, string searchText, Action<ByTextOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(renderedComponent);
ArgumentNullException.ThrowIfNull(searchText);

var options = ByTextOptions.Default;
if (configureOptions is not null)
{
options = options with { };
configureOptions.Invoke(options);
}

return FindByTextInternal(renderedComponent, searchText, options) ?? throw new TextNotFoundException(searchText);
}

/// <summary>
/// Returns all elements whose text content matches the given text.
/// </summary>
/// <param name="renderedComponent">The rendered fragment to search.</param>
/// <param name="searchText">The text content to search for.</param>
/// <param name="configureOptions">Method used to override the default behavior of FindAllByText.</param>
/// <returns>A read-only collection of elements matching the text. Returns an empty collection if no matches are found.</returns>
public static IReadOnlyList<IElement> FindAllByText(this IRenderedComponent<IComponent> renderedComponent, string searchText, Action<ByTextOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(renderedComponent);
ArgumentNullException.ThrowIfNull(searchText);

var options = ByTextOptions.Default;
if (configureOptions is not null)
{
options = options with { };
configureOptions.Invoke(options);
}

return FindAllByTextInternal(renderedComponent, searchText, options);
}

internal static IElement? FindByTextInternal(this IRenderedComponent<IComponent> renderedComponent, string searchText, ByTextOptions options)
{
var elements = renderedComponent.Nodes.TryQuerySelectorAll(options.Selector);
var normalizedSearchText = NormalizeWhitespace(searchText);

foreach (var element in elements)
{
if (IgnoredNodeNames.Contains(element.NodeName))
continue;

var normalizedTextContent = NormalizeWhitespace(element.TextContent);

if (normalizedTextContent.Equals(normalizedSearchText, options.ComparisonType))
return element.WrapUsing(new ByTextElementFactory(renderedComponent, searchText, options));
}

return null;
}

internal static IReadOnlyList<IElement> FindAllByTextInternal(this IRenderedComponent<IComponent> renderedComponent, string searchText, ByTextOptions options)
{
var elements = renderedComponent.Nodes.TryQuerySelectorAll(options.Selector);
var normalizedSearchText = NormalizeWhitespace(searchText);
var results = new List<IElement>();
var seen = new HashSet<IElement>();

foreach (var element in elements)
{
if (IgnoredNodeNames.Contains(element.NodeName))
continue;

var normalizedTextContent = NormalizeWhitespace(element.TextContent);

if (!normalizedTextContent.Equals(normalizedSearchText, options.ComparisonType))
continue;

var underlyingElement = element.Unwrap();
if (seen.Add(underlyingElement))
{
results.Add(element.WrapUsing(new ByTextElementFactory(renderedComponent, searchText, options)));
}
}

return results;
}

internal static string NormalizeWhitespace(string text)
{
var trimmed = text.Trim();
return CollapseWhitespaceRegex().Replace(trimmed, " ");
}

[GeneratedRegex(@"\s+")]
private static partial Regex CollapseWhitespaceRegex();
}
Loading
Loading