Skip to content

Commit ca409ec

Browse files
committed
feat: Implement FindAllByLabelText
1 parent 19d984e commit ca409ec

File tree

9 files changed

+225
-30
lines changed

9 files changed

+225
-30
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ All notable changes to **bUnit** will be documented in this file. The project ad
66

77
## [Unreleased]
88

9+
## Added
10+
- Added `FindByAllByLabel` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet).
11+
912
## [2.1.1] - 2025-11-21
1013

1114
### Changed

src/bunit.web.query/Labels/LabelQueryExtensions.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using AngleSharp.Dom;
22
using Bunit.Labels.Strategies;
3+
using Bunit.Web.AngleSharp;
34

45
namespace Bunit;
56

@@ -35,6 +36,25 @@ public static IElement FindByLabelText(this IRenderedComponent<IComponent> rende
3536
return FindByLabelTextInternal(renderedComponent, labelText, options) ?? throw new LabelNotFoundException(labelText);
3637
}
3738

39+
/// <summary>
40+
/// Returns all elements (i.e. input, select, textarea, etc. elements) associated with the given label text.
41+
/// </summary>
42+
/// <param name="renderedComponent">The rendered fragment to search.</param>
43+
/// <param name="labelText">The text of the label to search (i.e. the InnerText of the Label, such as "First Name" for a `<label>First Name</label>`)</param>
44+
/// <param name="configureOptions">Method used to override the default behavior of FindAllByLabelText.</param>
45+
/// <returns>A read-only collection of elements matching the label text. Returns an empty collection if no matches are found.</returns>
46+
public static IReadOnlyList<IElement> FindAllByLabelText(this IRenderedComponent<IComponent> renderedComponent, string labelText, Action<ByLabelTextOptions>? configureOptions = null)
47+
{
48+
var options = ByLabelTextOptions.Default;
49+
if (configureOptions is not null)
50+
{
51+
options = options with { };
52+
configureOptions.Invoke(options);
53+
}
54+
55+
return FindAllByLabelTextInternal(renderedComponent, labelText, options);
56+
}
57+
3858
internal static IElement? FindByLabelTextInternal(this IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
3959
{
4060
foreach (var strategy in LabelTextQueryStrategies)
@@ -47,4 +67,28 @@ public static IElement FindByLabelText(this IRenderedComponent<IComponent> rende
4767

4868
return null;
4969
}
70+
71+
internal static IReadOnlyList<IElement> FindAllByLabelTextInternal(this IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
72+
{
73+
var results = new List<IElement>();
74+
75+
foreach (var strategy in LabelTextQueryStrategies)
76+
{
77+
results.AddRange(strategy.FindElements(renderedComponent, labelText, options));
78+
}
79+
80+
var seen = new HashSet<IElement>();
81+
var distinctResults = new List<IElement>();
82+
83+
foreach (var element in results)
84+
{
85+
var underlyingElement = element.Unwrap();
86+
if (seen.Add(underlyingElement))
87+
{
88+
distinctResults.Add(element);
89+
}
90+
}
91+
92+
return distinctResults;
93+
}
5094
}

src/bunit.web.query/Labels/Strategies/ILabelTextQueryStrategy.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ namespace Bunit.Labels.Strategies;
44

55
internal interface ILabelTextQueryStrategy
66
{
7-
IElement? FindElement(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options);
7+
IElement? FindElement(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
8+
=> FindElements(renderedComponent, labelText, options).FirstOrDefault();
9+
10+
IEnumerable<IElement> FindElements(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options);
811
}

src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelStrategy.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace Bunit.Labels.Strategies;
55

66
internal sealed class LabelTextUsingAriaLabelStrategy : ILabelTextQueryStrategy
77
{
8-
public IElement? FindElement(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
8+
public IEnumerable<IElement> FindElements(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
99
{
1010
var caseSensitivityQualifier = options.ComparisonType switch
1111
{
@@ -15,11 +15,11 @@ internal sealed class LabelTextUsingAriaLabelStrategy : ILabelTextQueryStrategy
1515
_ => ""
1616
};
1717

18-
var element = renderedComponent.Nodes.TryQuerySelector($"[aria-label='{labelText}'{caseSensitivityQualifier}]");
18+
var elements = renderedComponent.Nodes.TryQuerySelectorAll($"[aria-label='{labelText}'{caseSensitivityQualifier}]");
1919

20-
if (element is null)
21-
return null;
22-
23-
return element.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
20+
foreach (var element in elements)
21+
{
22+
yield return element.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
23+
}
2424
}
2525
}

src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelledByStrategy.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,15 @@ namespace Bunit.Labels.Strategies;
55

66
internal sealed class LabelTextUsingAriaLabelledByStrategy : ILabelTextQueryStrategy
77
{
8-
public IElement? FindElement(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
8+
public IEnumerable<IElement> FindElements(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
99
{
1010
var elementsWithAriaLabelledBy = renderedComponent.Nodes.TryQuerySelectorAll("[aria-labelledby]");
1111

1212
foreach (var element in elementsWithAriaLabelledBy)
1313
{
1414
var labelElement = renderedComponent.Nodes.TryQuerySelector($"#{element.GetAttribute("aria-labelledby")}");
1515
if (labelElement is not null && labelElement.GetInnerText().Equals(labelText, options.ComparisonType))
16-
return element.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
16+
yield return element.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
1717
}
18-
19-
return null;
2018
}
2119
}

src/bunit.web.query/Labels/Strategies/LabelTextUsingForAttributeStrategy.cs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ namespace Bunit.Labels.Strategies;
55

66
internal sealed class LabelTextUsingForAttributeStrategy : ILabelTextQueryStrategy
77
{
8-
public IElement? FindElement(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
8+
public IEnumerable<IElement> FindElements(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
99
{
10-
var matchingLabel = renderedComponent.Nodes.TryQuerySelectorAll("label")
11-
.SingleOrDefault(l => l.TextContent.Trim().Equals(labelText, options.ComparisonType));
10+
var matchingLabels = renderedComponent.Nodes.TryQuerySelectorAll("label")
11+
.Where(l => l.TextContent.Trim().Equals(labelText, options.ComparisonType));
1212

13-
if (matchingLabel is null)
14-
return null;
13+
foreach (var matchingLabel in matchingLabels)
14+
{
15+
var forAttribute = matchingLabel.GetAttribute("for");
16+
if (string.IsNullOrEmpty(forAttribute))
17+
continue;
1518

16-
var matchingElement = renderedComponent.Nodes.TryQuerySelector($"#{matchingLabel.GetAttribute("for")}");
17-
18-
if (matchingElement is null)
19-
return null;
20-
21-
return matchingElement.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
19+
var matchingElement = renderedComponent.Nodes.TryQuerySelector($"#{forAttribute}");
20+
if (matchingElement is not null)
21+
yield return matchingElement.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
22+
}
2223
}
2324
}

src/bunit.web.query/Labels/Strategies/LabelTextUsingWrappedElementStrategy.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ namespace Bunit.Labels.Strategies;
55

66
internal sealed class LabelTextUsingWrappedElementStrategy : ILabelTextQueryStrategy
77
{
8-
public IElement? FindElement(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
8+
public IEnumerable<IElement> FindElements(IRenderedComponent<IComponent> renderedComponent, string labelText, ByLabelTextOptions options)
99
{
10-
var matchingLabel = renderedComponent.Nodes.TryQuerySelectorAll("label")
11-
.SingleOrDefault(l => l.GetInnerText().Trim().StartsWith(labelText, options.ComparisonType));
10+
var matchingLabels = renderedComponent.Nodes.TryQuerySelectorAll("label")
11+
.Where(l => l.GetInnerText().Trim().StartsWith(labelText, options.ComparisonType));
1212

13-
var matchingElement = matchingLabel?
14-
.Children
15-
.SingleOrDefault(n => n.IsHtmlElementThatCanHaveALabel());
16-
17-
return matchingElement?.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
13+
foreach (var matchingLabel in matchingLabels)
14+
{
15+
var matchingElements = matchingLabel.Children.Where(n => n.IsHtmlElementThatCanHaveALabel());
16+
foreach (var matchingElement in matchingElements)
17+
{
18+
yield return matchingElement.WrapUsing(new ByLabelTextElementFactory(renderedComponent, labelText, options));
19+
}
20+
}
1821
}
1922
}

src/bunit/InternalsVisibleTo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bunit.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001be6b1a2ca57b09b7040e2ab0993e515296ae22aef4031a4fe388a1336fe21f69c7e8610e9935de6ed18d94b5c98429f99ef62ce3d0af28a7088f856239368ea808ad4c448aa2a8075ed581f989f36ed0d0b8b1cfcaf1ff6a4506c8a99b7024b6eb56996d08e3c9c1cf5db59bff96fcc63ccad155ef7fc63aab6a69862437b6")]
2+
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bunit.Web.Query, PublicKey=002400000480000094000000060200000024000052534131000400000100010001be6b1a2ca57b09b7040e2ab0993e515296ae22aef4031a4fe388a1336fe21f69c7e8610e9935de6ed18d94b5c98429f99ef62ce3d0af28a7088f856239368ea808ad4c448aa2a8075ed581f989f36ed0d0b8b1cfcaf1ff6a4506c8a99b7024b6eb56996d08e3c9c1cf5db59bff96fcc63ccad155ef7fc63aab6a69862437b6")]

tests/bunit.web.query.tests/Labels/LabelQueryExtensionsTest.cs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,146 @@ public void Test021(string htmlElementWithLabel)
351351
input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase);
352352
input.Id.ShouldBe($"{htmlElementWithLabel}-with-label");
353353
}
354+
355+
[Fact(DisplayName = "FindAllByLabelText should return empty collection when no elements match")]
356+
public void Test100()
357+
{
358+
var cut = Render<Wrapper>(ps =>
359+
ps.AddChildContent("<div>No labels here</div>"));
360+
361+
var elements = cut.FindAllByLabelText("Non-existent label");
362+
363+
elements.ShouldBeEmpty();
364+
}
365+
366+
[Fact(DisplayName = "FindAllByLabelText should return multiple elements with same label text using for attribute")]
367+
public void Test101()
368+
{
369+
var labelText = "Same Label";
370+
var cut = Render<Wrapper>(ps =>
371+
ps.AddChildContent($"""
372+
<label for="input-1">{labelText}</label>
373+
<input id="input-1" />
374+
<label for="input-2">{labelText}</label>
375+
<input id="input-2" />
376+
"""));
377+
378+
var elements = cut.FindAllByLabelText(labelText);
379+
380+
elements.Count.ShouldBe(2);
381+
elements[0].Id.ShouldBe("input-1");
382+
elements[1].Id.ShouldBe("input-2");
383+
}
384+
385+
[Fact(DisplayName = "FindAllByLabelText should return multiple elements with same aria-label")]
386+
public void Test102()
387+
{
388+
var labelText = "Aria Label";
389+
var cut = Render<Wrapper>(ps =>
390+
ps.AddChildContent($"""
391+
<input id="input-1" aria-label="{labelText}" />
392+
<input id="input-2" aria-label="{labelText}" />
393+
<button id="button-1" aria-label="{labelText}" />
394+
"""));
395+
396+
var elements = cut.FindAllByLabelText(labelText);
397+
398+
elements.Count.ShouldBe(3);
399+
elements[0].Id.ShouldBe("input-1");
400+
elements[1].Id.ShouldBe("input-2");
401+
elements[2].Id.ShouldBe("button-1");
402+
}
403+
404+
[Fact(DisplayName = "FindAllByLabelText should return multiple elements wrapped in labels")]
405+
public void Test103()
406+
{
407+
var labelText = "Wrapped Label";
408+
var cut = Render<Wrapper>(ps =>
409+
ps.AddChildContent($"""
410+
<label>{labelText}<input id="input-1" /></label>
411+
<label>{labelText}<input id="input-2" /></label>
412+
"""));
413+
414+
var elements = cut.FindAllByLabelText(labelText);
415+
416+
elements.Count.ShouldBe(2);
417+
elements[0].Id.ShouldBe("input-1");
418+
elements[1].Id.ShouldBe("input-2");
419+
}
420+
421+
[Fact(DisplayName = "FindAllByLabelText should return multiple elements using aria-labelledby")]
422+
public void Test104()
423+
{
424+
var labelText = "Aria Labelled By";
425+
var cut = Render<Wrapper>(ps =>
426+
ps.AddChildContent($"""
427+
<h2 id="heading-1">{labelText}</h2>
428+
<input id="input-1" aria-labelledby="heading-1" />
429+
<input id="input-2" aria-labelledby="heading-1" />
430+
"""));
431+
432+
var elements = cut.FindAllByLabelText(labelText);
433+
434+
elements.Count.ShouldBe(2);
435+
elements[0].Id.ShouldBe("input-1");
436+
elements[1].Id.ShouldBe("input-2");
437+
}
438+
439+
[Fact(DisplayName = "FindAllByLabelText should return elements from different strategies")]
440+
public void Test105()
441+
{
442+
var labelText = "Mixed Label";
443+
var cut = Render<Wrapper>(ps =>
444+
ps.AddChildContent($"""
445+
<label for="input-for">{labelText}</label>
446+
<input id="input-for" />
447+
<input id="input-aria" aria-label="{labelText}" />
448+
<label>{labelText}<input id="input-wrapped" /></label>
449+
<h2 id="heading-1">{labelText}</h2>
450+
<input id="input-labelledby" aria-labelledby="heading-1" />
451+
"""));
452+
453+
var elements = cut.FindAllByLabelText(labelText);
454+
455+
elements.Count.ShouldBe(4);
456+
var ids = elements.Select(e => e.Id).ToList();
457+
ids.ShouldContain("input-for");
458+
ids.ShouldContain("input-aria");
459+
ids.ShouldContain("input-wrapped");
460+
ids.ShouldContain("input-labelledby");
461+
}
462+
463+
[Fact(DisplayName = "FindAllByLabelText should deduplicate elements matched by multiple strategies")]
464+
public void Test106()
465+
{
466+
var labelText = "Duplicate Label";
467+
var cut = Render<Wrapper>(ps =>
468+
ps.AddChildContent($"""
469+
<label for="input-1">{labelText}</label>
470+
<input id="input-1" aria-label="{labelText}" />
471+
"""));
472+
473+
var elements = cut.FindAllByLabelText(labelText);
474+
475+
// The same input is matched by both 'for' attribute and 'aria-label' strategies
476+
// but should only appear once in the result
477+
elements.Count.ShouldBe(1);
478+
elements[0].Id.ShouldBe("input-1");
479+
}
480+
481+
[Fact(DisplayName = "FindAllByLabelText should respect case-insensitive comparison option")]
482+
public void Test107()
483+
{
484+
var cut = Render<Wrapper>(ps =>
485+
ps.AddChildContent("""
486+
<label for="input-1">Label Text</label>
487+
<input id="input-1" />
488+
<label for="input-2">LABEL TEXT</label>
489+
<input id="input-2" />
490+
"""));
491+
492+
var elements = cut.FindAllByLabelText("label text", o => o.ComparisonType = StringComparison.OrdinalIgnoreCase);
493+
494+
elements.Count.ShouldBe(2);
495+
}
354496
}

0 commit comments

Comments
 (0)