diff --git a/CHANGELOG.md b/CHANGELOG.md index d5da26aea..86ee0a317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ All notable changes to **bUnit** will be documented in this file. The project ad - Added generic overloads `Find{TComponent, TElement}` and `FindAll{TComponent, TElement}` to query for specific element types (e.g., `IHtmlInputElement`). By [@linkdotnet](https://github.com/linkdotnet). - Added generic overloads `WaitForElement{TComponent, TElement}` and `WaitForElements{TComponent, TElement}` to wait for specific element types. By [@linkdotnet](https://github.com/linkdotnet). +- Added `FindByRole` and `FindAllByRole` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet). ## [2.2.2] - 2025-12-08 ### Added - Added `FindByAllByLabel` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet). +- Added `FindByRole` and `FindAllByRole` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet). ### Fixed diff --git a/src/bunit.web.query/Roles/AccessibleNameComputation.cs b/src/bunit.web.query/Roles/AccessibleNameComputation.cs new file mode 100644 index 000000000..7e7a8e68b --- /dev/null +++ b/src/bunit.web.query/Roles/AccessibleNameComputation.cs @@ -0,0 +1,193 @@ +using AngleSharp.Dom; + +namespace Bunit; + +/// +/// Provides methods to compute the accessible name of an element. +/// +/// +/// This is a simplified implementation of the Accessible Name and Description Computation algorithm. +/// See https://www.w3.org/TR/accname-1.1/ for the full specification. +/// +[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Using lowercase for comparison with lowercase constant values.")] +internal static class AccessibleNameComputation +{ + /// + /// Computes the accessible name of an element. + /// + public static string? GetAccessibleName(IElement element) + { + return GetNameFromAriaLabelledBy(element) + ?? GetNameFromAriaLabel(element) + ?? GetNameFromAssociatedLabelOrNativeContent(element) + ?? GetNameFromTitleAttribute(element) + ?? GetNameFromPlaceholder(element); + } + + private static string? GetNameFromAriaLabelledBy(IElement element) + { + var labelledBy = element.GetAttribute("aria-labelledby"); + if (string.IsNullOrWhiteSpace(labelledBy)) + return null; + + return GetTextFromReferencedElements(element, labelledBy); + } + + private static string? GetNameFromAriaLabel(IElement element) + { + var ariaLabel = element.GetAttribute("aria-label"); + return string.IsNullOrWhiteSpace(ariaLabel) ? null : ariaLabel; + } + + private static string? GetNameFromAssociatedLabelOrNativeContent(IElement element) + { + var tagName = element.TagName.ToUpperInvariant(); + + if (tagName is "INPUT" or "SELECT" or "TEXTAREA") + { + return GetNameFromLinkedLabel(element) + ?? GetNameFromInputButtonValue(element); + } + + if (tagName == "IMG") + { + return GetNameFromAltAttribute(element); + } + + if (tagName is "BUTTON" or "A" or "H1" or "H2" or "H3" or "H4" or "H5" or "H6") + { + return GetNonEmptyTextContent(element); + } + + return null; + } + + private static string? GetNameFromTitleAttribute(IElement element) + { + var title = element.GetAttribute("title"); + return string.IsNullOrWhiteSpace(title) ? null : title; + } + + private static string? GetNameFromPlaceholder(IElement element) + { + var tagName = element.TagName.ToUpperInvariant(); + if (tagName is not ("INPUT" or "TEXTAREA")) + return null; + + var placeholder = element.GetAttribute("placeholder"); + return string.IsNullOrWhiteSpace(placeholder) ? null : placeholder; + } + + private static string? GetTextFromReferencedElements(IElement element, string spaceDelimitedIds) + { + var ids = spaceDelimitedIds.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var texts = new List(); + var root = GetRootElement(element); + + foreach (var id in ids) + { + var referencedElement = element.Owner?.GetElementById(id) ?? root?.QuerySelector($"#{id}"); + if (referencedElement == null) + continue; + + var text = referencedElement.TextContent.Trim(); + if (!string.IsNullOrWhiteSpace(text)) + texts.Add(text); + } + + return texts.Count > 0 ? string.Join(" ", texts) : null; + } + + private static IElement? GetRootElement(IElement element) + { + var current = element; + while (current.ParentElement != null) + { + current = current.ParentElement; + } + return current; + } + + private static string? GetNameFromLinkedLabel(IElement element) + { + var id = element.GetAttribute("id"); + if (!string.IsNullOrWhiteSpace(id)) + { + var linkedLabel = FindLabelWithForAttribute(element, id); + if (linkedLabel != null) + { + return linkedLabel.TextContent.Trim(); + } + } + + var wrappingLabel = element.Closest("label"); + if (wrappingLabel != null) + { + return GetTextContentExcludingElement(wrappingLabel, element); + } + + return null; + } + + private static IElement? FindLabelWithForAttribute(IElement element, string id) + { + var label = element.Owner?.QuerySelector($"label[for='{id}']"); + if (label != null) + return label; + + var root = GetRootElement(element); + return root?.QuerySelector($"label[for='{id}']"); + } + + private static string? GetNameFromInputButtonValue(IElement element) + { + if (!element.TagName.Equals("INPUT", StringComparison.OrdinalIgnoreCase)) + return null; + + var inputType = element.GetAttribute("type")?.ToLowerInvariant(); + if (inputType is not ("button" or "submit" or "reset")) + return null; + + var value = element.GetAttribute("value"); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? GetNameFromAltAttribute(IElement element) + { + var alt = element.GetAttribute("alt"); + return string.IsNullOrWhiteSpace(alt) ? null : alt; + } + + private static string? GetNonEmptyTextContent(IElement element) + { + var textContent = element.TextContent.Trim(); + return string.IsNullOrWhiteSpace(textContent) ? null : textContent; + } + + private static string GetTextContentExcludingElement(IElement container, IElement excludeElement) + { + var texts = new List(); + CollectTextNodesExcluding(container, excludeElement, texts); + return string.Join(" ", texts).Trim(); + } + + private static void CollectTextNodesExcluding(INode node, IElement excludeElement, List texts) + { + foreach (var child in node.ChildNodes) + { + if (child == excludeElement) + continue; + + if (child.NodeType == NodeType.Text) + { + var text = child.TextContent.Trim(); + if (!string.IsNullOrWhiteSpace(text)) + texts.Add(text); + } + else if (child.NodeType == NodeType.Element) + { + CollectTextNodesExcluding(child, excludeElement, texts); + } + } + } +} diff --git a/src/bunit.web.query/Roles/AriaRole.cs b/src/bunit.web.query/Roles/AriaRole.cs new file mode 100644 index 000000000..f158de9c7 --- /dev/null +++ b/src/bunit.web.query/Roles/AriaRole.cs @@ -0,0 +1,475 @@ +namespace Bunit; + +/// +/// Represents ARIA roles that can be used to query elements. +/// +/// +/// See https://www.w3.org/TR/wai-aria-1.2/#role_definitions for the full specification. +/// +public enum AriaRole +{ + /// + /// A message with important, and usually time-sensitive, information. + /// + Alert, + + /// + /// A type of dialog that contains an alert message. + /// + AlertDialog, + + /// + /// A structure containing one or more focusable elements requiring user input. + /// + Application, + + /// + /// An independent section of the document, page, or site. + /// + Article, + + /// + /// A section of content that is quoted from another source. + /// + Blockquote, + + /// + /// An input that allows for user-triggered actions when clicked or pressed. + /// + Button, + + /// + /// Visible content that names, and may also describe, a figure, table, grid, or treegrid. + /// + Caption, + + /// + /// A checkable input that has three possible values: true, false, or mixed. + /// + Checkbox, + + /// + /// A cell in a tabular container. + /// + Cell, + + /// + /// A section whose content represents a fragment of computer code. + /// + Code, + + /// + /// A cell containing header information for a column. + /// + ColumnHeader, + + /// + /// A composite widget containing a single-line textbox and another element. + /// + Combobox, + + /// + /// A form of widget that performs an action but does not receive input data. (Abstract) + /// + Command, + + /// + /// A supporting section of the document, designed to be complementary to the main content. + /// + Complementary, + + /// + /// A large perceivable region that contains information about the parent document. + /// + ContentInfo, + + /// + /// A widget that may contain navigable descendants or owned children. (Abstract) + /// + Composite, + + /// + /// A definition of a term or concept. + /// + Definition, + + /// + /// A deletion contains content that is marked as removed or suggested for removal. + /// + Deletion, + + /// + /// A dialog is a descendant window of the primary window of a web application. + /// + Dialog, + + /// + /// A structure containing one or more focusable elements. + /// + Document, + + /// + /// [Deprecated in ARIA 1.2] A list of references to members of a group. + /// + Directory, + + /// + /// A scrollable list of articles. + /// + Feed, + + /// + /// A figure is a perceivable section of content. + /// + Figure, + + /// + /// A landmark region that contains a collection of items and objects. + /// + Form, + + /// + /// A set of user interface objects that are not intended to be included in a page summary. + /// + Generic, + + /// + /// A grid is an interactive control that contains cells of tabular data arranged in rows and columns. + /// + Grid, + + /// + /// A cell in a grid or treegrid. + /// + GridCell, + + /// + /// A set of user interface objects that are not intended to be included in a page summary or table of contents. + /// + Group, + + /// + /// A heading for a section of the page. + /// + Heading, + + /// + /// A container for a collection of elements that form an image. + /// + Img, + + /// + /// A generic type of widget that allows user input. (Abstract) + /// + Input, + + /// + /// An insertion contains content that is marked as added or suggested for addition. + /// + Insertion, + + /// + /// A perceivable section for a specific purpose that users can navigate to. (Abstract) + /// + Landmark, + + /// + /// An interactive reference to an internal or external resource. + /// + Link, + + /// + /// A section containing listitem elements. + /// + List, + + /// + /// A widget that allows the user to select one or more items from a list of choices. + /// + Listbox, + + /// + /// A single item in a list or directory. + /// + Listitem, + + /// + /// A type of live region where new information is added in meaningful order. + /// + Log, + + /// + /// The main content of a document. + /// + Main, + + /// + /// A type of live region where non-essential information changes frequently. + /// + Marquee, + + /// + /// Content that represents a mathematical expression. + /// + Math, + + /// + /// A type of widget that offers a list of choices to the user. + /// + Menu, + + /// + /// A presentation of menu that usually remains visible and is usually presented horizontally. + /// + Menubar, + + /// + /// An option in a set of choices contained by a menu or menubar. + /// + Menuitem, + + /// + /// A menuitem with a checkable state. + /// + MenuitemCheckbox, + + /// + /// A checkable menuitem in a set of elements with the same role. + /// + MenuitemRadio, + + /// + /// A graphical display of values. + /// + Meter, + + /// + /// A section of a page that represents navigation links. + /// + Navigation, + + /// + /// A section whose content represents additional information. + /// + Note, + + /// + /// An element whose implicit native role semantics will not be mapped to the accessibility API. Synonym of Presentation. + /// + None, + + /// + /// A selectable item in a select list. + /// + Option, + + /// + /// A paragraph of content. + /// + Paragraph, + + /// + /// An element whose implicit native role semantics will not be mapped to the accessibility API. + /// + Presentation, + + /// + /// An element that displays the progress status for tasks that take a long time. + /// + Progressbar, + + /// + /// A checkable input in a group of elements with the same role. + /// + Radio, + + /// + /// A group of radio buttons. + /// + RadioGroup, + + /// + /// An element representing a range of values. (Abstract) + /// + Range, + + /// + /// A perceivable section containing content that is relevant to a specific, author-specified purpose. + /// + Region, + + /// + /// The base role from which all other roles inherit. (Abstract) + /// + RoleType, + + /// + /// A row of cells in a tabular container. + /// + Row, + + /// + /// A group containing one or more row elements in a grid. + /// + RowGroup, + + /// + /// A cell containing header information for a row in a grid. + /// + RowHeader, + + /// + /// A graphical object that controls the scrolling of content within a viewing area. + /// + Scrollbar, + + /// + /// A landmark region that contains a collection of items and objects that, as a whole, combine to create a search facility. + /// + Search, + + /// + /// A type of textbox intended for specifying search criteria. + /// + Searchbox, + + /// + /// A divider that separates and distinguishes sections of content or groups of menuitems. + /// + Separator, + + /// + /// An input where the user selects a value from within a given range. + /// + Slider, + + /// + /// A form of range that expects the user to select from among discrete choices. + /// + Spinbutton, + + /// + /// A type of live region whose content is advisory information for the user. + /// + Status, + + /// + /// A type of checkbox that represents on/off values. + /// + Switch, + + /// + /// A grouping label providing a mechanism for selecting the tab content that is to be rendered to the user. + /// + Tab, + + /// + /// A section containing data arranged in rows and columns. + /// + Table, + + /// + /// A list of tab elements, which are references to tabpanel elements. + /// + Tablist, + + /// + /// A container for the resources associated with a tab. + /// + Tabpanel, + + /// + /// An element that represents a specific value within a range. + /// + Term, + + /// + /// A type of input that allows free-form text as its value. + /// + Textbox, + + /// + /// An element that represents a specific point in time. + /// + Time, + + /// + /// A type of live region containing a numerical counter. + /// + Timer, + + /// + /// A collection of commonly used function buttons or controls represented in compact visual form. + /// + Toolbar, + + /// + /// A contextual popup that displays a description for an element. + /// + Tooltip, + + /// + /// A widget that allows the user to select one or more items from a hierarchically organized collection. + /// + Tree, + + /// + /// A grid whose rows can be expanded and collapsed in the same manner as for a tree. + /// + Treegrid, + + /// + /// An item in a tree. + /// + Treeitem, + + /// + /// A document structural element. (Abstract) + /// + Structure, + + /// + /// One or more subscripted characters. + /// + Subscript, + + /// + /// One or more superscripted characters. + /// + Superscript, + + /// + /// Content that is important, serious, or urgent. + /// + Strong, + + /// + /// A renderable structural containment unit in a document or application. (Abstract) + /// + Section, + + /// + /// A structure that labels or summarizes the topic of its related section. (Abstract) + /// + Sectionhead, + + /// + /// A form widget that allows the user to make selections from a set of choices. (Abstract) + /// + Select, + + /// + /// An interactive component of a graphical user interface (GUI). (Abstract) + /// + Widget, + + /// + /// A browser or application window. + /// + Window, + + /// + /// A landmark that contains mostly site-oriented content, rather than page-specific content. + /// + Banner, +} diff --git a/src/bunit.web.query/Roles/ByRoleOptions.cs b/src/bunit.web.query/Roles/ByRoleOptions.cs new file mode 100644 index 000000000..aea7a637b --- /dev/null +++ b/src/bunit.web.query/Roles/ByRoleOptions.cs @@ -0,0 +1,71 @@ +namespace Bunit; + +/// +/// Allows overrides of behavior for FindByRole method. +/// +public record class ByRoleOptions +{ + /// + /// The default behavior used by FindByRole if no overrides are specified. + /// + internal static readonly ByRoleOptions Default = new(); + + /// + /// The StringComparison used for comparing the accessible name. Defaults to Ordinal (case sensitive). + /// + public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal; + + /// + /// If true, includes elements that are normally excluded from the accessibility tree. + /// Elements can be excluded via aria-hidden="true" or the hidden attribute. + /// Defaults to false. + /// + public bool Hidden { get; set; } + + /// + /// Filters elements by their accessible name (e.g., aria-label, button text, label text). + /// When null, no filtering by name is applied. + /// + public string? Name { get; set; } + + /// + /// Filters elements by their selected state (aria-selected). + /// Use for elements like tabs or options. + /// + public bool? Selected { get; set; } + + /// + /// Filters elements by their checked state (aria-checked or native checked). + /// Use for checkboxes and radio buttons. + /// + public bool? Checked { get; set; } + + /// + /// Filters elements by their pressed state (aria-pressed). + /// Use for toggle buttons. + /// + public bool? Pressed { get; set; } + + /// + /// Filters elements by their current state (aria-current). + /// Can be true/false or a specific token like "page", "step", "location", "date", "time". + /// + public object? Current { get; set; } + + /// + /// Filters elements by their expanded state (aria-expanded). + /// Use for elements like accordions, dropdowns, or tree items. + /// + public bool? Expanded { get; set; } + + /// + /// Filters elements by their busy state (aria-busy). + /// + public bool? Busy { get; set; } + + /// + /// Filters headings by their level (1-6). + /// Only applicable when searching for the "heading" role. + /// + public int? Level { get; set; } +} diff --git a/src/bunit.web.query/Roles/ImplicitRoleMapper.cs b/src/bunit.web.query/Roles/ImplicitRoleMapper.cs new file mode 100644 index 000000000..bddadf806 --- /dev/null +++ b/src/bunit.web.query/Roles/ImplicitRoleMapper.cs @@ -0,0 +1,178 @@ +using AngleSharp.Dom; + +namespace Bunit; + +/// +/// Maps HTML elements to their implicit ARIA roles. +/// +/// +/// See https://www.w3.org/TR/html-aria/ for the full specification. +/// +internal static class ImplicitRoleMapper +{ + private static readonly string[] SectioningElements = ["ARTICLE", "ASIDE", "MAIN", "NAV", "SECTION"]; + + /// + /// Gets the implicit ARIA role for an HTML element. + /// + public static AriaRole? GetImplicitRole(IElement element) + { + var tagName = element.TagName.ToUpperInvariant(); + + return tagName switch + { + "A" when element.HasAttribute("href") => AriaRole.Link, + "AREA" when element.HasAttribute("href") => AriaRole.Link, + "ARTICLE" => AriaRole.Article, + "ASIDE" => AriaRole.Complementary, + "BUTTON" => AriaRole.Button, + "DATALIST" => AriaRole.Listbox, + "DETAILS" => AriaRole.Group, + "DIALOG" => AriaRole.Dialog, + "FIGURE" => AriaRole.Figure, + "FORM" => GetFormRoleWhenHasAccessibleName(element), + "FOOTER" => GetFooterRoleWhenNotInSectioningContent(element), + "H1" or "H2" or "H3" or "H4" or "H5" or "H6" => AriaRole.Heading, + "HEADER" => GetHeaderRoleWhenNotInSectioningContent(element), + "HR" => AriaRole.Separator, + "IMG" => GetImgRoleBasedOnAltAttribute(element), + "INPUT" => GetInputRole(element), + "LI" => AriaRole.Listitem, + "MAIN" => AriaRole.Main, + "MATH" => AriaRole.Math, + "MENU" => AriaRole.List, + "METER" => AriaRole.Meter, + "NAV" => AriaRole.Navigation, + "OL" => AriaRole.List, + "OPTGROUP" => AriaRole.Group, + "OPTION" => AriaRole.Option, + "OUTPUT" => AriaRole.Status, + "PROGRESS" => AriaRole.Progressbar, + "SECTION" => GetSectionRoleWhenHasAccessibleName(element), + "SELECT" => GetSelectRoleBasedOnMultipleOrSize(element), + "SUMMARY" => AriaRole.Button, + "TABLE" => AriaRole.Table, + "TBODY" or "TFOOT" or "THEAD" => AriaRole.RowGroup, + "TD" => AriaRole.Cell, + "TEXTAREA" => AriaRole.Textbox, + "TH" => GetThRoleBasedOnScopeAndContext(element), + "TR" => AriaRole.Row, + "UL" => AriaRole.List, + _ => null + }; + } + + /// + /// Gets the heading level for heading elements. + /// + public static int? GetHeadingLevel(IElement element) + { + var explicitAriaLevel = element.GetAttribute("aria-level"); + if (!string.IsNullOrWhiteSpace(explicitAriaLevel) && int.TryParse(explicitAriaLevel, out var level)) + { + return level; + } + + return element.TagName.ToUpperInvariant() switch + { + "H1" => 1, + "H2" => 2, + "H3" => 3, + "H4" => 4, + "H5" => 5, + "H6" => 6, + _ => null + }; + } + + private static AriaRole? GetFormRoleWhenHasAccessibleName(IElement element) => + HasAccessibleName(element) ? AriaRole.Form : null; + + private static AriaRole? GetFooterRoleWhenNotInSectioningContent(IElement element) => + IsDescendantOfSectioningContent(element) ? null : AriaRole.ContentInfo; + + private static AriaRole? GetHeaderRoleWhenNotInSectioningContent(IElement element) => + IsDescendantOfSectioningContent(element) ? null : AriaRole.Banner; + + private static AriaRole? GetImgRoleBasedOnAltAttribute(IElement element) + { + var alt = element.GetAttribute("alt"); + var hasEmptyAltForDecorativeImage = alt is { Length: 0 }; + return hasEmptyAltForDecorativeImage ? AriaRole.Presentation : AriaRole.Img; + } + + private static AriaRole? GetInputRole(IElement element) + { + var type = element.GetAttribute("type")?.ToUpperInvariant() ?? "TEXT"; + + return type switch + { + "BUTTON" or "IMAGE" or "RESET" or "SUBMIT" => AriaRole.Button, + "CHECKBOX" => AriaRole.Checkbox, + "EMAIL" or "TEL" or "TEXT" or "URL" => GetTextInputRoleBasedOnDatalist(element, AriaRole.Textbox), + "NUMBER" => AriaRole.Spinbutton, + "RADIO" => AriaRole.Radio, + "RANGE" => AriaRole.Slider, + "SEARCH" => GetTextInputRoleBasedOnDatalist(element, AriaRole.Searchbox), + _ => null + }; + } + + private static AriaRole GetTextInputRoleBasedOnDatalist(IElement element, AriaRole defaultRole) + { + var listAttributeId = element.GetAttribute("list"); + if (string.IsNullOrWhiteSpace(listAttributeId)) + return defaultRole; + + var hasAssociatedDatalist = element.Owner?.GetElementById(listAttributeId) != null; + return hasAssociatedDatalist ? AriaRole.Combobox : defaultRole; + } + + private static AriaRole? GetSectionRoleWhenHasAccessibleName(IElement element) => + HasAccessibleName(element) ? AriaRole.Region : null; + + private static AriaRole GetSelectRoleBasedOnMultipleOrSize(IElement element) + { + if (element.HasAttribute("multiple")) + return AriaRole.Listbox; + + var size = element.GetAttribute("size"); + var hasMultipleVisibleOptions = !string.IsNullOrWhiteSpace(size) + && int.TryParse(size, out var sizeValue) + && sizeValue > 1; + + return hasMultipleVisibleOptions ? AriaRole.Listbox : AriaRole.Combobox; + } + + private static AriaRole GetThRoleBasedOnScopeAndContext(IElement element) + { + var scope = element.GetAttribute("scope")?.ToUpperInvariant(); + if (scope == "ROW") + { + return AriaRole.RowHeader; + } + + var isInTableHeader = element.Closest("thead") != null; + return isInTableHeader ? AriaRole.ColumnHeader : AriaRole.Cell; + } + + private static bool HasAccessibleName(IElement element) + { + return !string.IsNullOrWhiteSpace(element.GetAttribute("aria-label")) + || !string.IsNullOrWhiteSpace(element.GetAttribute("aria-labelledby")) + || !string.IsNullOrWhiteSpace(element.GetAttribute("title")); + } + + private static bool IsDescendantOfSectioningContent(IElement element) + { + var parent = element.ParentElement; + while (parent != null) + { + if (SectioningElements.Contains(parent.TagName.ToUpperInvariant())) + return true; + parent = parent.ParentElement; + } + + return false; + } +} diff --git a/src/bunit.web.query/Roles/RoleElementExtensions.cs b/src/bunit.web.query/Roles/RoleElementExtensions.cs new file mode 100644 index 000000000..a31489332 --- /dev/null +++ b/src/bunit.web.query/Roles/RoleElementExtensions.cs @@ -0,0 +1,193 @@ +using AngleSharp.Dom; + +namespace Bunit; + +/// +/// Provides helper methods for checking element state related to ARIA roles. +/// +internal static class RoleElementExtensions +{ + private const string AriaHiddenTrue = "true"; + + /// + /// Determines if an element is hidden from the accessibility tree. + /// + public static bool IsHiddenFromAccessibilityTree(this IElement element) + { + return HasAriaHiddenTrue(element) + || element.HasAttribute("hidden") + || HasHiddenAncestor(element); + } + + /// + /// Gets the checked state of an element. + /// + public static bool? GetCheckedState(this IElement element) + { + var ariaChecked = element.GetAttribute("aria-checked"); + return !string.IsNullOrWhiteSpace(ariaChecked) + ? ParseTriStateBoolean(ariaChecked) + : GetNativeCheckedStateForCheckboxOrRadio(element); + } + + /// + /// Gets the selected state of an element. + /// + public static bool? GetSelectedState(this IElement element) + { + var ariaSelected = element.GetAttribute("aria-selected"); + return !string.IsNullOrWhiteSpace(ariaSelected) + ? ParseBooleanAttribute(ariaSelected) + : GetNativeSelectedStateForOption(element); + } + + /// + /// Gets the pressed state of an element. + /// + public static bool? GetPressedState(this IElement element) + { + var ariaPressed = element.GetAttribute("aria-pressed"); + return string.IsNullOrWhiteSpace(ariaPressed) + ? null + : ParseTriStateBoolean(ariaPressed); + } + + /// + /// Gets the expanded state of an element. + /// + public static bool? GetExpandedState(this IElement element) + { + var ariaExpanded = element.GetAttribute("aria-expanded"); + return !string.IsNullOrWhiteSpace(ariaExpanded) + ? ParseBooleanAttribute(ariaExpanded) + : GetNativeExpandedStateForDetails(element); + } + + /// + /// Gets the busy state of an element. + /// + public static bool? GetBusyState(this IElement element) + { + var ariaBusy = element.GetAttribute("aria-busy"); + return string.IsNullOrWhiteSpace(ariaBusy) ? null : ParseBooleanAttribute(ariaBusy); + } + + /// + /// Gets the current state of an element. + /// + public static object? GetCurrentState(this IElement element) + { + var ariaCurrent = element.GetAttribute("aria-current"); + if (string.IsNullOrWhiteSpace(ariaCurrent)) + { + return null; + } + + return ariaCurrent.ToUpperInvariant() switch + { + "TRUE" => true, + "FALSE" => false, + _ => ariaCurrent + }; + } + + /// + /// Gets the explicit role of an element from the role attribute. + /// + public static AriaRole? GetExplicitRole(this IElement element) + { + var role = element.GetAttribute("role"); + if (string.IsNullOrWhiteSpace(role)) + { + return null; + } + + var firstRole = role.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(firstRole)) + { + return null; + } + + return Enum.TryParse(firstRole, ignoreCase: true, out var ariaRole) + ? ariaRole + : null; + } + + /// + /// Gets the role of an element, checking explicit role first, then implicit role. + /// + public static AriaRole? GetRole(this IElement element) + { + return element.GetExplicitRole() ?? ImplicitRoleMapper.GetImplicitRole(element); + } + + private static bool HasAriaHiddenTrue(IElement element) => + element.GetAttribute("aria-hidden") == AriaHiddenTrue; + + private static bool HasHiddenAncestor(IElement element) + { + var parent = element.ParentElement; + while (parent != null) + { + if (parent.GetAttribute("aria-hidden") == AriaHiddenTrue || parent.HasAttribute("hidden")) + { + return true; + } + + parent = parent.ParentElement; + } + + return false; + } + + private static bool? ParseTriStateBoolean(string value) + { + return value.ToUpperInvariant() switch + { + "TRUE" => true, + "FALSE" => false, + "MIXED" => null, + _ => null + }; + } + + private static bool? ParseBooleanAttribute(string value) + { + return value.ToUpperInvariant() switch + { + "TRUE" => true, + "FALSE" => false, + _ => null + }; + } + + private static bool? GetNativeCheckedStateForCheckboxOrRadio(IElement element) + { + if (!element.TagName.Equals("INPUT", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var type = element.GetAttribute("type")?.ToUpperInvariant(); + if (type is not ("CHECKBOX" or "RADIO")) + { + return null; + } + + return element.HasAttribute("checked"); + } + + private static bool? GetNativeSelectedStateForOption(IElement element) + { + return !element.TagName.Equals("OPTION", StringComparison.OrdinalIgnoreCase) + ? null + : element.HasAttribute("selected"); + } + + private static bool? GetNativeExpandedStateForDetails(IElement element) + { + return !element.TagName.Equals("DETAILS", StringComparison.OrdinalIgnoreCase) + ? null + : element.HasAttribute("open"); + } +} diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs new file mode 100644 index 000000000..efdb8cf72 --- /dev/null +++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs @@ -0,0 +1,38 @@ +namespace Bunit; + +/// +/// Represents a failure to find an element in the searched target +/// using the specified role. +/// +public sealed class RoleNotFoundException : Exception +{ + /// + /// Gets the role used to search with. + /// + public AriaRole Role { get; } + + /// + /// Gets the accessible name filter that was applied, if any. + /// + public string? AccessibleName { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The role that was searched for. + /// The accessible name filter that was applied, if any. + public RoleNotFoundException(AriaRole role, string? accessibleName = null) + : base(CreateMessage(role, accessibleName)) + { + Role = role; + AccessibleName = accessibleName; + } + + private static string CreateMessage(AriaRole role, string? accessibleName) + { + var roleString = role.ToString(); + return accessibleName is not null + ? $"Unable to find an element with the role '{roleString}' and accessible name '{accessibleName}'." + : $"Unable to find an element with the role '{roleString}'."; + } +} diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs new file mode 100644 index 000000000..e8d80e199 --- /dev/null +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -0,0 +1,154 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit; + +/// +/// Extension methods for querying by ARIA role. +/// +public static class RoleQueryExtensions +{ + /// + /// Returns the first element with the specified ARIA role. + /// + /// The rendered fragment to search. + /// The ARIA role to search for (e.g., AriaRole.Button, AriaRole.Link). + /// Method used to override the default behavior of FindByRole. + /// The first element matching the specified role and options. + /// Thrown when no element matching the role is found. + public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role, Action? configureOptions = null) + { + var options = ByRoleOptions.Default; + if (configureOptions is not null) + { + options = options with { }; + configureOptions.Invoke(options); + } + + return FindByRoleInternal(renderedComponent, role, options) ?? throw new RoleNotFoundException(role, options.Name); + } + + /// + /// Returns all elements with the specified ARIA role. + /// + /// The rendered fragment to search. + /// The ARIA role to search for (e.g., AriaRole.Button, AriaRole.Link). + /// Method used to override the default behavior of FindAllByRole. + /// A read-only collection of elements matching the role. Returns an empty collection if no matches are found. + public static IReadOnlyList FindAllByRole(this IRenderedComponent renderedComponent, AriaRole role, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(renderedComponent); + + var options = ByRoleOptions.Default; + if (configureOptions is not null) + { + options = options with { }; + configureOptions.Invoke(options); + } + + return FindAllByRoleInternal(renderedComponent, role, options); + } + + private static IElement? FindByRoleInternal(IRenderedComponent renderedComponent, AriaRole role, ByRoleOptions options) + { + var results = FindAllByRoleInternal(renderedComponent, role, options); + return results.Count > 0 ? results[0] : null; + } + + private static List FindAllByRoleInternal(IRenderedComponent renderedComponent, AriaRole role, ByRoleOptions options) + { + var allElements = renderedComponent.Nodes.TryQuerySelectorAll("*"); + var results = new List(); + var seen = new HashSet(); + + foreach (var element in allElements) + { + if (!MatchesRole(element, role, options)) + continue; + + // Deduplicate using the underlying element + var underlyingElement = element.Unwrap(); + if (seen.Add(underlyingElement)) + { + results.Add(element); + } + } + + return results; + } + + private static bool MatchesRole(IElement element, AriaRole role, ByRoleOptions options) + { + if (!options.Hidden && element.IsHiddenFromAccessibilityTree()) + return false; + + var elementRole = element.GetRole(); + if (elementRole != role) + return false; + + if (options.Name is not null) + { + var accessibleName = AccessibleNameComputation.GetAccessibleName(element); + if (accessibleName is null || !accessibleName.Equals(options.Name, options.ComparisonType)) + return false; + } + + if (options.Level.HasValue) + { + if (role != AriaRole.Heading) + return false; + + var level = ImplicitRoleMapper.GetHeadingLevel(element); + if (level != options.Level.Value) + return false; + } + + if (options.Checked.HasValue) + { + var checkedState = element.GetCheckedState(); + if (checkedState != options.Checked.Value) + return false; + } + + if (options.Selected.HasValue) + { + var selectedState = element.GetSelectedState(); + if (selectedState != options.Selected.Value) + return false; + } + + if (options.Pressed.HasValue) + { + var pressedState = element.GetPressedState(); + if (pressedState != options.Pressed.Value) + return false; + } + + if (options.Expanded.HasValue) + { + var expandedState = element.GetExpandedState(); + if (expandedState != options.Expanded.Value) + return false; + } + + if (options.Busy.HasValue) + { + var busyState = element.GetBusyState(); + if (busyState != options.Busy.Value) + return false; + } + + if (options.Current is not null) + { + var currentState = element.GetCurrentState(); + switch (options.Current) + { + case bool currentBool when !Equals(currentState, currentBool): + case string currentString when !currentString.Equals(currentState as string, options.ComparisonType): + return false; + } + } + + return true; + } +} diff --git a/src/bunit/BunitContext.Obsoletes.cs b/src/bunit/BunitContext.Obsoletes.cs index 121e95636..07ddb1caa 100644 --- a/src/bunit/BunitContext.Obsoletes.cs +++ b/src/bunit/BunitContext.Obsoletes.cs @@ -1,5 +1,6 @@ namespace Bunit; +[RemovedInFutureVersion("Obsolete in v2, removed in future version")] public partial class BunitContext { /// diff --git a/src/bunit/TestContext.cs b/src/bunit/TestContext.cs index 2f3ad5022..615dc0599 100644 --- a/src/bunit/TestContext.cs +++ b/src/bunit/TestContext.cs @@ -2,6 +2,7 @@ namespace Bunit; /// [Obsolete($"Use {nameof(BunitContext)} instead. TestContext will be removed in a future release.", false, UrlFormat = "https://bunit.dev/docs/migrations")] +[RemovedInFutureVersion("Obsolete in v2, removed in future version")] public class TestContext : BunitContext { } diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs new file mode 100644 index 000000000..68cacc214 --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -0,0 +1,825 @@ +namespace Bunit.Roles; + +public class RoleQueryExtensionsTest : BunitContext +{ + [Fact(DisplayName = "Should return element with explicit role attribute")] + public void Test001() + { + var cut = Render(ps => + ps.AddChildContent("""
Click me
""")); + + var element = cut.FindByRole(AriaRole.Button); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Click me"); + } + + [Fact(DisplayName = "Should throw RoleNotFoundException when role does not exist")] + public void Test002() + { + var cut = Render(ps => + ps.AddChildContent("""
Just a div
""")); + + var exception = Should.Throw(() => cut.FindByRole(AriaRole.Button)); + exception.Role.ShouldBe(AriaRole.Button); + } + + [Fact(DisplayName = "FindAllByRole should return empty collection when no matches")] + public void Test003() + { + var cut = Render(ps => + ps.AddChildContent("""
Just a div
""")); + + var elements = cut.FindAllByRole(AriaRole.Button); + + elements.ShouldBeEmpty(); + } + + [Fact(DisplayName = "FindAllByRole should return all matching elements")] + public void Test004() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + + """)); + + var elements = cut.FindAllByRole(AriaRole.Button); + + elements.Count.ShouldBe(3); + } [Fact(DisplayName = "Should find button element by implicit button role")] + public void Test010() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Button); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("BUTTON", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find input type=button by implicit button role")] + public void Test011() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Button); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("button"); + } + + [Fact(DisplayName = "Should find input type=submit by implicit button role")] + public void Test012() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Button); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("submit"); + } + + [Fact(DisplayName = "Should find input type=reset by implicit button role")] + public void Test013() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Button); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("reset"); + } + + [Fact(DisplayName = "Should find summary element by implicit button role")] + public void Test014() + { + var cut = Render(ps => + ps.AddChildContent("""
DetailsContent
""")); + + var element = cut.FindByRole(AriaRole.Button); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("SUMMARY", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find anchor with href by implicit link role")] + public void Test020() + { + var cut = Render(ps => + ps.AddChildContent("""Home""")); + + var element = cut.FindByRole(AriaRole.Link); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("A", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should not find anchor without href by link role")] + public void Test021() + { + var cut = Render(ps => + ps.AddChildContent("""Not a link""")); + + var elements = cut.FindAllByRole(AriaRole.Link); + + elements.ShouldBeEmpty(); + } + + [Theory(DisplayName = "Should find heading elements by implicit heading role")] + [InlineData("h1")] + [InlineData("h2")] + [InlineData("h3")] + [InlineData("h4")] + [InlineData("h5")] + [InlineData("h6")] + public void Test030(string headingTag) + { + var cut = Render(ps => + ps.AddChildContent($"<{headingTag}>Heading")); + + var element = cut.FindByRole(AriaRole.Heading); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe(headingTag, StringCompareShould.IgnoreCase); + } + + [Theory(DisplayName = "Should filter headings by level option")] + [InlineData(1, "h1")] + [InlineData(2, "h2")] + [InlineData(3, "h3")] + [InlineData(4, "h4")] + [InlineData(5, "h5")] + [InlineData(6, "h6")] + public void Test031(int level, string expectedTag) + { + var cut = Render(ps => + ps.AddChildContent(""" +

Level 1

+

Level 2

+

Level 3

+

Level 4

+
Level 5
+
Level 6
+ """)); + + var element = cut.FindByRole(AriaRole.Heading, o => o.Level = level); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe(expectedTag, StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find all headings when no level specified")] + public void Test032() + { + var cut = Render(ps => + ps.AddChildContent(""" +

Level 1

+

Level 2

+

Level 3

+ """)); + + var elements = cut.FindAllByRole(AriaRole.Heading); + + elements.Count.ShouldBe(3); + } + + [Fact(DisplayName = "Should respect aria-level attribute for heading level")] + public void Test033() + { + var cut = Render(ps => + ps.AddChildContent("""
Custom Heading
""")); + + var element = cut.FindByRole(AriaRole.Heading, o => o.Level = 2); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Custom Heading"); + } + + [Fact(DisplayName = "Should find checkbox input by implicit checkbox role")] + public void Test040() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Checkbox); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("checkbox"); + } + + [Fact(DisplayName = "Should filter checkbox by checked state")] + public void Test041() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Checkbox, o => o.Checked = true); + + element.ShouldNotBeNull(); + element.Id.ShouldBe("checked"); + } + + [Fact(DisplayName = "Should filter checkbox by unchecked state")] + public void Test042() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Checkbox, o => o.Checked = false); + + element.ShouldNotBeNull(); + element.Id.ShouldBe("unchecked"); + } + + [Fact(DisplayName = "Should find radio input by implicit radio role")] + public void Test050() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Radio); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("radio"); + } + + [Fact(DisplayName = "Should find text input by implicit textbox role")] + public void Test060() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Textbox); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("text"); + } + + [Fact(DisplayName = "Should find textarea by implicit textbox role")] + public void Test061() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Textbox); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("TEXTAREA", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find email input by implicit textbox role")] + public void Test062() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Textbox); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("email"); + } + + [Theory(DisplayName = "Should find list elements by implicit list role")] + [InlineData("ul")] + [InlineData("ol")] + public void Test070(string listTag) + { + var cut = Render(ps => + ps.AddChildContent($"<{listTag}>
  • Item
  • ")); + + var element = cut.FindByRole(AriaRole.List); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe(listTag, StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find li element by implicit listitem role")] + public void Test071() + { + var cut = Render(ps => + ps.AddChildContent("""
    • Item
    """)); + + var element = cut.FindByRole(AriaRole.Listitem); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("LI", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find nav element by implicit navigation role")] + public void Test080() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Navigation); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("NAV", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find main element by implicit main role")] + public void Test081() + { + var cut = Render(ps => + ps.AddChildContent("""
    Content
    """)); + + var element = cut.FindByRole(AriaRole.Main); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("MAIN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find aside element by implicit complementary role")] + public void Test082() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Complementary); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("ASIDE", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should filter by accessible name from aria-label")] + public void Test090() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Button, o => o.Name = "Submit form"); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Submit"); + } + + [Fact(DisplayName = "Should filter by accessible name from button text content")] + public void Test091() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Button, o => o.Name = "Submit"); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Submit"); + } + + [Fact(DisplayName = "Should filter by accessible name from aria-labelledby")] + public void Test092() + { + var cut = Render(ps => + ps.AddChildContent(""" + First Label + + Second Label + + """)); + + var element = cut.FindByRole(AriaRole.Button, o => o.Name = "First Label"); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Button 1"); + } + + [Fact(DisplayName = "Should throw RoleNotFoundException when name does not match")] + public void Test093() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var exception = Should.Throw(() => + cut.FindByRole(AriaRole.Button, o => o.Name = "NonExistent")); + exception.AccessibleName.ShouldBe("NonExistent"); + } + + [Theory(DisplayName = "Should respect ComparisonType for name filter")] + [InlineData(StringComparison.OrdinalIgnoreCase, true)] + [InlineData(StringComparison.Ordinal, false)] + public void Test094(StringComparison comparison, bool shouldFind) + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + if (shouldFind) + { + var element = cut.FindByRole(AriaRole.Button, o => + { + o.Name = "SUBMIT"; + o.ComparisonType = comparison; + }); + element.ShouldNotBeNull(); + } + else + { + Should.Throw(() => + cut.FindByRole(AriaRole.Button, o => + { + o.Name = "SUBMIT"; + o.ComparisonType = comparison; + })); + } + } + + [Fact(DisplayName = "Should filter by accessible name from linked label")] + public void Test095() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Textbox, o => o.Name = "Email Address"); + + element.ShouldNotBeNull(); + element.Id.ShouldBe("email"); + } + + [Fact(DisplayName = "Should exclude aria-hidden elements by default")] + public void Test100() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var elements = cut.FindAllByRole(AriaRole.Button); + + elements.Count.ShouldBe(1); + elements[0].TextContent.ShouldBe("Visible"); + } + + [Fact(DisplayName = "Should include aria-hidden elements when Hidden option is true")] + public void Test101() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var elements = cut.FindAllByRole(AriaRole.Button, o => o.Hidden = true); + + elements.Count.ShouldBe(2); + } + + [Fact(DisplayName = "Should exclude elements with hidden attribute by default")] + public void Test102() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var elements = cut.FindAllByRole(AriaRole.Button); + + elements.Count.ShouldBe(1); + elements[0].TextContent.ShouldBe("Visible"); + } + + [Fact(DisplayName = "Should filter by pressed state")] + public void Test110() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Button, o => o.Pressed = true); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Pressed"); + } + + [Fact(DisplayName = "Should filter by expanded state")] + public void Test120() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Button, o => o.Expanded = true); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Expanded"); + } + + [Fact(DisplayName = "Should detect expanded state from details open attribute")] + public void Test121() + { + var cut = Render(ps => + ps.AddChildContent(""" +
    + Open Details + Content +
    +
    + Closed Details + Content +
    + """)); + + var element = cut.FindByRole(AriaRole.Group, o => o.Expanded = true); + + element.ShouldNotBeNull(); + element.HasAttribute("open").ShouldBeTrue(); + } + + [Fact(DisplayName = "Should filter by selected state")] + public void Test130() + { + var cut = Render(ps => + ps.AddChildContent(""" +
    + + +
    + """)); + + var element = cut.FindByRole(AriaRole.Tab, o => o.Selected = true); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Tab 1"); + } + + [Fact(DisplayName = "Should filter by busy state")] + public void Test140() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + """)); + + var element = cut.FindByRole(AriaRole.Button, o => o.Busy = true); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Loading"); + } + + [Fact(DisplayName = "Should filter by current state with boolean")] + public void Test150() + { + var cut = Render(ps => + ps.AddChildContent(""" + Current + About + """)); + + var element = cut.FindByRole(AriaRole.Link, o => o.Current = true); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Current"); + } + + [Fact(DisplayName = "Should filter by current state with token value")] + public void Test151() + { + var cut = Render(ps => + ps.AddChildContent(""" + Home + About + """)); + + var element = cut.FindByRole(AriaRole.Link, o => o.Current = "page"); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Home"); + } + + [Fact(DisplayName = "Should combine multiple filters")] + public void Test160() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + + """)); + + var element = cut.FindByRole(AriaRole.Button, o => + { + o.Pressed = true; + o.Name = "Bold"; + }); + + element.ShouldNotBeNull(); + element.TextContent.ShouldBe("Bold"); + } + + [Fact(DisplayName = "Should find elements by combobox role for select")] + public void Test170() + { + var cut = Render(ps => + ps.AddChildContent(""" + + """)); + + var element = cut.FindByRole(AriaRole.Combobox); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("SELECT", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find elements by listbox role for select with multiple")] + public void Test171() + { + var cut = Render(ps => + ps.AddChildContent(""" + + """)); + + var element = cut.FindByRole(AriaRole.Listbox); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("SELECT", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find img element by img role")] + public void Test180() + { + var cut = Render(ps => + ps.AddChildContent("""Test image""")); + + var element = cut.FindByRole(AriaRole.Img); + + element.ShouldNotBeNull(); + element.GetAttribute("alt").ShouldBe("Test image"); + } + + [Fact(DisplayName = "Should find img with empty alt as presentation role")] + public void Test181() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Presentation); + + element.ShouldNotBeNull(); + } + + [Fact(DisplayName = "Should filter img by accessible name from alt")] + public void Test182() + { + var cut = Render(ps => + ps.AddChildContent(""" + Company Logo + Banner Image + """)); + + var element = cut.FindByRole(AriaRole.Img, o => o.Name = "Company Logo"); + + element.ShouldNotBeNull(); + element.GetAttribute("src").ShouldBe("logo.png"); + } + + [Fact(DisplayName = "Should find table element by table role")] + public void Test190() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + +
    Header
    Cell
    + """)); + + var element = cut.FindByRole(AriaRole.Table); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("TABLE", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find row elements by row role")] + public void Test191() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + +
    Row 1
    Row 2
    + """)); + + var elements = cut.FindAllByRole(AriaRole.Row); + + elements.Count.ShouldBe(2); + } + + [Fact(DisplayName = "Should find cell elements by cell role")] + public void Test192() + { + var cut = Render(ps => + ps.AddChildContent(""" + + +
    Cell 1Cell 2
    + """)); + + var elements = cut.FindAllByRole(AriaRole.Cell); + + elements.Count.ShouldBe(2); + } + + [Fact(DisplayName = "Should find progress element by progressbar role")] + public void Test200() + { + var cut = Render(ps => + ps.AddChildContent("""50%""")); + + var element = cut.FindByRole(AriaRole.Progressbar); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("PROGRESS", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find meter element by meter role")] + public void Test201() + { + var cut = Render(ps => + ps.AddChildContent("""60%""")); + + var element = cut.FindByRole(AriaRole.Meter); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("METER", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find slider input by slider role")] + public void Test202() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Slider); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("range"); + } + + [Fact(DisplayName = "Should find spinbutton input by spinbutton role")] + public void Test203() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Spinbutton); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("number"); + } + + [Fact(DisplayName = "Should find dialog element by dialog role")] + public void Test210() + { + var cut = Render(ps => + ps.AddChildContent("""Dialog content""")); + + var element = cut.FindByRole(AriaRole.Dialog); + + element.ShouldNotBeNull(); + element.TagName.ShouldBe("DIALOG", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find search input by searchbox role")] + public void Test220() + { + var cut = Render(ps => + ps.AddChildContent("""""")); + + var element = cut.FindByRole(AriaRole.Searchbox); + + element.ShouldNotBeNull(); + element.GetAttribute("type").ShouldBe("search"); + } +}