from enum import Enum
from time import sleep
from typing import Optional, Union, List, Tuple, Any

from selenium.common.exceptions import ElementClickInterceptedException, TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait

from Helpers.CSSEnumerations import CSSPropertyValue
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Helpers.IASelenium import IASelenium
from Helpers.PerspectivePages.Quality import PerspectiveQuality
from Helpers.Point import Point


class RequestPrintTarget(Enum):
    """
    The expected target to be supplied as a parameter to the requestPrint method available to components.
    """
    COMPONENT = 'component'
    VIEW = 'view'
    PAGE = 'page'


class ComponentPiece:
    """
    Component Pieces may be understood as distinct Web Elements which are actually pieces of a greater component.
    """
    # For use on checkboxes that are pieces of another component
    _CHECKBOX_LOCATOR = (By.CSS_SELECTOR, 'label.ia_checkbox svg')
    _CHECKBOX_CHECKED = 'ia_checkbox__checkedIcon'
    _CHECKBOX_UNCHECKED = 'ia_checkbox__uncheckedIcon'

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[list] = None,
            wait_timeout: float = 10.0,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        """
        locator: Tuple
            A tuple which defines the CSS selector which would be used to locate the component.
        driver: WebDriver
            The WebDriver used to drive the session/page.
        parent_locator_list: List
            A list of the locators used to build the parent of this component. A value of None will be interpreted
            as opting into ignorance of the parentage of the component.
        wait_timeout: float
            The amount of time to poll the DOM before throwing a potential TimeoutException in the event this component
            could not be located.
        poll_freq: float
            A value used to determine how frequently to poll the DOM. Used primarily for performance testing.
        """
        self._locator = locator
        self.driver = driver
        self.wait_timeout = wait_timeout
        self.wait = WebDriverWait(driver=self.driver, timeout=wait_timeout, poll_frequency=poll_freq)
        self.description = description
        self._parent_locator_list = parent_locator_list.copy() if parent_locator_list else []
        self._update_locator_list()
        self.poll_freq = poll_freq

    def click(self, wait_timeout: Optional[Union[int, float]] = None, binding_wait_time: float = 0) -> None:
        """
        Click the component.

        :param wait_timeout: Poll the DOM up to this amount of time before potentially throwing a TimeoutException.
            Overrides the default of the component.
        :param binding_wait_time: The amount of time to wait after the click event occurs before continuing.

        :raises TimeoutException: If the component is not found in the DOM.
        """
        self.find(wait_timeout=wait_timeout).click()
        self.wait_on_binding(time_to_wait=binding_wait_time)

    def click_with_offset(self, x_offset: int, y_offset: int) -> None:
        """
        Click this element, but offset by some pixel count.

        :param x_offset: The count of pixels to move right before clicking.
        :param y_offset: The count of pixels to move down before clicking.

        :raises TimeoutException: If the component is not found in the DOM.
        :raises ElementClickInterceptedException: If the offset click is intercepted by some other element.
        """
        try:
            ActionChains(self.driver).move_to_element_with_offset(
                to_element=self.find(),
                xoffset=x_offset,
                yoffset=y_offset
            ).click().perform()
        except ElementClickInterceptedException as ecie:
            IASelenium(self.driver).take_screenshot_of_element(self.find())
            raise ecie

    def double_click(self, binding_wait_time: float = 0) -> None:
        """
        Double-click the component.

        :param binding_wait_time: The amount of time to wait after the double-click event occurs before continuing.

        :raises TimeoutException: If the component is not found in the DOM.
        """
        ActionChains(self.driver).move_to_element(to_element=self.find()).double_click(on_element=self.find()).perform()
        self.wait_on_binding(time_to_wait=binding_wait_time)

    def find(self, wait_timeout: Optional[Union[int, float]] = None) -> WebElement:
        """
        Get a singular Web Element in the DOM which matches the supplied locator for this component. If more than one
        Web Element would match, return only the first match encountered in the DOM from top to bottom in the document
        - NOT the viewable area. Distinct from `xfind`, which uses XPATH.

        :param wait_timeout: The amount of time (in seconds) to wait to locate the Web Element.

        :returns: The first Web Element found given the supplied locator for this component.

        :raises TimeoutException: If no element matches the locator of this component.
        """
        if wait_timeout is not None:  # 0 is acceptable
            local_wait = WebDriverWait(
                self.driver,
                wait_timeout)
        else:
            local_wait = self.wait
        css_locator = self.get_full_css_locator()
        try:
            return local_wait.until(
                ec.presence_of_element_located(
                    css_locator))
        except TimeoutException as toe:
            description = f"\nDescription: {self.description}" if self.description else ''
            raise TimeoutException(
                msg=f"Unable to locate element with CSS locator: {css_locator}{description}") from toe

    def find_all(self, wait_timeout: Optional[Union[int, float]] = None) -> List[WebElement]:
        """
        Get a list of all Web Elements in the DOM which match the supplied locator for this component.  Distinct from
        `xfind_all`, which uses XPATH.

        :param wait_timeout: The amount of time (in seconds) to wait to locate the Web Element.

        :returns: A list of all Web Elements in the DOM which match the supplied locator for this component

        :raises TimeoutException: If no elements match the locator of this component.
        """
        if wait_timeout is not None:  # 0 is acceptable
            local_wait = WebDriverWait(self.driver, wait_timeout)
        else:
            local_wait = self.wait
        css_locator = self.get_full_css_locator()
        try:
            return local_wait.until(ec.presence_of_all_elements_located(css_locator))
        except TimeoutException as toe:
            description = f"\nDescription: {self.description}" if self.description else ''
            raise TimeoutException(
                msg=f"Unable to locate any elements with CSS locator: {css_locator}{description}") from toe

    def get_computed_height(self, include_units: bool = False) -> str:
        """
        Get the computed height of the component. Must return as a string because of the possibility of included units.

        :param include_units: Include the units of height (typically "px") if True, otherwise return only the numeric
            value (as a str).

        :returns: The computed height of the component as a string.

        :raises TimeoutException: If the component is not found in the DOM.
        """
        height = self.find().value_of_css_property("height")
        # special handling for flex containers
        if height == "auto":
            # no need to strip units here because it's always numeric
            return self.find().rect['height']
        if not include_units:
            height = height.split("px")[0]
        return height

    def get_computed_width(self, include_units: bool = False) -> str:
        """
        Get the computed width of the component. Must return as a string because of the possibility of included units.

        :param include_units: Include the units of width (typically "px") if True, otherwise return only the numeric
            value (as a str).

        :returns: The computed width of the component as a string.

        :raises TimeoutException: If the component is not found in the DOM.
        """
        width = self.find().value_of_css_property("width")
        # special handling for flex containers
        if width == "auto":
            # no need to strip units here because it's always numeric
            return self.find().rect['width']
        if not include_units:
            width = width.split("px")[0]
        return width

    def get_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Get a CSS property value of the Web Element defined by this component.

        :param property_name: The name of the CSS property you would like to retrieve the value of.

        :raises TimeoutException: If the element is not present on the page.

         :returns: The CSS property value of the Web Element defined by this component.
        """
        # cast arg as string to get underlying value of CSSPropertyValue type.
        return self.find().value_of_css_property(property_name=str(property_name))

    def get_full_css_locator(self) -> Tuple[Union[By, str], str]:
        """
        Get the fully constructed CSS selector of this component.

        :returns: The fully constructed CSS selector of this component.
        """
        return _LocatorBuilder.get_css_locator(locators=self.locator_list)

    def get_full_xpath_locator(self) -> Tuple[Union[By, str], str]:
        """
        Get the fully constructed XPATH selector of this component.

        :returns: The fully constructed XPATH selector of this component.
        """
        return _LocatorBuilder.get_xpath_locator(locators=self.locator_list)

    def get_origin(self) -> Point:
        """
        Get the Cartesian Coordinate of the upper-left corner of the component, measured from the
        top-left of the viewport.

        :returns: The Cartesian Coordinate of the upper-left corner of the component, measured from the
            top-left of the viewport.
        """
        rect = self.find().rect
        return Point(x=rect['x'], y=rect['y'])

    def get_termination(self) -> Point:
        """
        Get the Cartesian Coordinate of the bottom-right corner of the component, measured from the
        top-left of the viewport.

        :returns: The Cartesian Coordinate of the bottom-right corner of the component, measured from the
            top-left of the viewport.
        """
        rect = self.find().rect
        return Point(rect['x'] + rect['width'], rect['y'] + rect['height'])

    def get_text(self) -> str:
        """
        Get the text of this component.
        :returns: The text of this component as a string.

        :raises TimeoutException: If the element is not present on the page.
        """
        return self.find().text

    def hover(self) -> None:
        """
        Hover over this component.

        :raises TimeoutException: If no element matches the locator of this component.
        """
        ActionChains(self.driver).move_to_element(self.find()).perform()

    def release_focus(self) -> None:
        """
        Forces a blur() event on the Web Element.
        """
        self.driver.execute_script('arguments[0].blur()', self.find())

    def right_click(self, binding_wait_time: Optional[float] = 0) -> None:
        """
        Right-click this component.

        :param binding_wait_time: The amount of time to wait after the click event occurs before continuing.

        :raises TimeoutException: If no element matches the locator of this component.
        """
        IASelenium(driver=self.driver).right_click(web_element=self.find())
        self.wait_on_binding(time_to_wait=binding_wait_time)

    def scroll_to_element(self, align_to_top: bool = True) -> None:
        """
        Vertical scroll to this element in the viewport.

        :param align_to_top: Aligns the top of the element with the top of the page (as much as possible) if True,
            else aligns the bottom of the element to the bottom of the viewport (as much as possible).

        :raises TimeoutException: If no element matches the locator of this component.
        """
        self.driver.execute_script('arguments[0].scrollIntoView(' + str(align_to_top).lower() + ');',
                                   self.find())

    def set_locator(self, new_locator: Tuple[By, str]) -> None:
        """
        Force a change in the locator/selector used to locate the component in the DOM.

        :param new_locator: The new locator to change to.
        """
        self._locator = new_locator
        self._update_locator_list()

    def wait_on_text_condition(
            self,
            text_to_compare: Optional[Any],
            condition: IAec.TextCondition,
            wait_timeout: Optional[float] = None) -> str:
        """
        Obtain the text of this Component, after potentially waiting some period of time for the Component to display
        that text.

        :param text_to_compare: The text to be used in conjunction with the supplied condition. If the supplied value
            is None, then no wait will ever occur and the text of the component will be immediately returned.
        :param condition: The condition to be used to compare the Component text to the provided text.
        :param wait_timeout: The amount of time (in seconds) you are willing to wait for the Component to display
            the specified text. If not supplied, this will default to the wait timeout supplied in the constructor
            of the Component.

        :returns: The text of the Component, after potentially having waited for an expected text match.
        """
        cond_wait = WebDriverWait(driver=self.driver, timeout=wait_timeout) if wait_timeout is not None else self.wait
        text_to_compare = str(text_to_compare) if text_to_compare is not None else text_to_compare
        text = ""

        def compare_against_condition():
            """
            Comparisons are only completed when the supplied comparison value is not None. One check is always executed
            to safeguard against StaleElementReferenceExceptions.

            :return: True, if the text of the Component satisfies the specified condition - False otherwise.

            :raises NotImplementError: If the specified condition is not supported.
            """
            nonlocal text
            nonlocal text_to_compare
            try:
                text = self.get_text()
            except TimeoutException as get_text_toe:
                get_text_toe.should_raise = True  # toe from failing to locate the component/element should be raised
                raise
            if text_to_compare is None:
                # immediately return because the text will NEVER compare correctly against None
                return text
            if condition == IAec.TextCondition.EQUALS:
                return text_to_compare == text
            elif condition == IAec.TextCondition.DOES_NOT_EQUAL:
                return text_to_compare != text
            elif condition == IAec.TextCondition.CONTAINS:
                return text_to_compare in text
            elif condition == IAec.TextCondition.DOES_NOT_CONTAIN:
                return text_to_compare not in text
            else:
                raise NotImplementedError("Unhandled condition while comparing text.")

        try:
            cond_wait.until(IAec.function_returns_true(custom_function=compare_against_condition, function_args={}))
        except TimeoutException as toe:
            if getattr(toe, 'should_raise', False):
                raise  # due to failing to find the component/element
            pass  # otherwise pass because toe originated from not meeting condition
        return text

    def xfind(self, wait_timeout: Optional[int] = None) -> WebElement:
        """
        Get a singular Web Element in the DOM which matches the supplied locator for this component. If more than one
        Web Element would match, return only the first match encountered in the DOM from top to bottom in the document
        - NOT the viewable area. Distinct from `find`, which uses CSS_SELECTOR.

        :param wait_timeout: The amount of time (in seconds) to wait to locate the Web Element.

        :raises TimeoutException: If no element matches the locator of this component.
        """
        if wait_timeout:
            local_wait = WebDriverWait(self.driver, wait_timeout)
        else:
            local_wait = self.wait
        xpath_locator = self.get_full_xpath_locator()
        try:
            return local_wait.until(ec.presence_of_element_located(xpath_locator))
        except TimeoutException as toe:
            description = f"\nDescription: {self.description}" if self.description else ''
            raise TimeoutException(
                msg=f"Unable to locate any elements with XPath locator: {xpath_locator}{description}") from toe

    def xfind_all(self, wait_timeout: Optional[int] = None) -> List[WebElement]:
        """
        Get a list of Web Elements in the DOM which matches the supplied locator for this component. Distinct from
        `find_all`, which uses CSS_SELECTOR.

        :param wait_timeout: The amount of time (in seconds) to wait to locate the Web Element.

        :raises TimeoutException: If no element matches the locator of this component.
        """
        if wait_timeout:
            local_wait = WebDriverWait(self.driver, wait_timeout)
        else:
            local_wait = self.wait
        xpath_locator = self.get_full_xpath_locator()
        try:
            return local_wait.until(ec.presence_of_all_elements_located(xpath_locator))
        except TimeoutException as toe:
            description = f"\nDescription: {self.description}" if self.description else ''
            raise TimeoutException(
                msg=f"Unable to locate any elements with XPath locator: {xpath_locator}{description}") from toe

    @staticmethod
    def wait_on_binding(time_to_wait=0.5) -> None:
        """
        A glorified hard-coded sleep, used to force the code to wait as bindings evaluate before allowing code to
        continue.

        :param time_to_wait: The amount of time (in seconds) to wait.
        """
        sleep(time_to_wait)

    def _has_text(self, text: str) -> bool:
        """
        Used to determine if a Web Element currently has the exact supplied text.

        :param text: Check to see if the Web Element has this exact text

        :raises TimeoutException: If no element matches the locator of this component.
        """
        return self.find().text == str(text)

    def _update_locator_list(self) -> None:
        """
        Used to force an update of the internal locator list used to define the component.
        """
        if self._parent_locator_list:
            # Used for subcomponents
            self.locator_list = self._parent_locator_list.copy()
            if self._locator:
                self.locator_list.append(self._locator)
        else:
            self.locator_list = [self._locator]


class BasicComponent(ComponentPiece):
    """
    A BasicComponent is analogous to a Web Element, albeit with custom wrapping and convenience methods.
    """
    pass


class BasicPerspectiveComponent(BasicComponent):
    """
    A BasicPerspectiveComponent may only exist inside a Perspective session, and is distinct from a BasicComponent
    in providing access and insight into Quality Overlays.
    """
    _QUALITY_OVERLAY_STATE_DIV_LOCATOR = (By.CSS_SELECTOR, "div.cfo-parent")
    _QUALITY_OVERLAY_FOOTER_LOCATOR = (By.CSS_SELECTOR, "div.cfo-footer")
    _QUALITY_OVERLAY_HEADER_LOCATOR = (By.CSS_SELECTOR, "div.cfo-header")
    _HEADER_POPOVER_ICON_LOCATOR = (By.CSS_SELECTOR, "div.icon-wrapper svg")
    _MICRO_OVERLAY_ICON_LOCATOR = (By.CSS_SELECTOR, "div.micro-icon")
    _POPOVER_LOCATOR = (By.CSS_SELECTOR, "div.component-popover")
    _POPOVER_SUBSECTION_LOCATOR = (By.CSS_SELECTOR, "div.popover-body-section div.body-content")
    _POPOVER_SUBCODE_INDEX = 0
    _POPOVER_PROPERTY_INDEX = 1
    _POPOVER_DESCRIPTION_INDEX = 2

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        """
        locator: Tuple
            A tuple which defines the CSS selector which would be used to locate the component.
        driver: WebDriver
            The WebDriver used to drive the session/page.
        parent_locator_list: List
            A list of the locators used to build the parent of this component. A value of None will be interpreted
            as opting into ignorance of the parentage of the component.
        wait_timeout: float
            The amount of time to poll the DOM before throwing a potential TimeoutException in the event this component
            could not be located.
        poll_freq: float
            A value used to determine how frequently to poll the DOM. Used primarily for performance testing.
        """
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._quality_overlay_state_div = ComponentPiece(
            locator=self._QUALITY_OVERLAY_STATE_DIV_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._quality_overlay_footer = ComponentPiece(
            locator=self._QUALITY_OVERLAY_FOOTER_LOCATOR,
            driver=driver,
            parent_locator_list=self._quality_overlay_state_div.locator_list,
            wait_timeout=0,
            poll_freq=poll_freq)
        self._quality_overlay_header = ComponentPiece(
            locator=self._QUALITY_OVERLAY_HEADER_LOCATOR,
            driver=driver,
            parent_locator_list=self._quality_overlay_state_div.locator_list,
            wait_timeout=0,
            poll_freq=poll_freq)
        self._header_popover_badges = ComponentPiece(
            locator=self._HEADER_POPOVER_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._quality_overlay_header.locator_list,
            wait_timeout=0,
            poll_freq=poll_freq)
        self._micro_overlay_icons = ComponentPiece(
            locator=self._MICRO_OVERLAY_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._quality_overlay_footer.locator_list,
            wait_timeout=0,
            poll_freq=poll_freq)
        self._quality_popover = ComponentPiece(
            locator=self._POPOVER_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._quality_popover_subsections = ComponentPiece(
            locator=self._POPOVER_SUBSECTION_LOCATOR,
            driver=driver,
            parent_locator_list=self._quality_popover.locator_list,
            poll_freq=poll_freq)

    def click_quality_overlay_popover_icon(self) -> None:
        """
        Click the popover icon of the quality overlay for this component.

        This click occurs every time because there is no way to know what component any open popover belongs to.
        """
        if self.quality_overlay_is_in_micro_mode():
            # fallback to "first"
            self._micro_overlay_icons.click(binding_wait_time=0.1)
        else:
            self._header_popover_badges.click(binding_wait_time=0.1)

    def get_column_span(self) -> int:
        """
        Get the count of columns this component spans inside a Column Container.

        :returns: The count of columns this component spans inside a Column Container as an integer.

        :raises IndexError: If the component is not inside a Column Container.
        :raises TimeoutException: If no element matches the locator of this component.
        """
        return int(self.find().value_of_css_property('grid-column-end').split('span ')[1])

    def get_display_column(self) -> int:
        """
        Get the column in which this component begins to display. Note that components may span multiple columns, but
        this function only returns the first column in which this component is displayed.

        :returns: The index of the column in which this component begins to display.

        :raises IndexError: If the component is not inside a Column Container.
        :raises TimeoutException: If no element matches the locator of this component.
        """
        return int(self.find().value_of_css_property(property_name="grid-column").split(" / span")[0])

    def get_display_row(self) -> int:
        """
        Get the row in which this component is displayed.

        :returns: The row in which this component is displayed.

        :raises IndexError: If the component is not inside a Column Container.
        :raises TimeoutException: If no element matches the locator of this component.
        """
        return int(self.find().find_element(By.XPATH, "..").get_attribute("data-row-index"))

    def get_display_span(self) -> int:
        """
        Get the count of columns this component spans.

        :returns: The count of columns this component spans.

        :raises IndexError: If the component is not inside a Column Container.
        :raises TimeoutException: If no element matches the locator of this component.
        """
        return int(self.find().value_of_css_property(property_name="span"))

    def get_origin_within_coordinate_parent(self) -> Point:
        """
        Get the origin of the component within its parent Coordinate Container. Distinct from get_origin which provides
        the origin within the viewport.

        :returns: The origin of the component within its parent Coordinate Container.

        :raises TimeoutException: If no element matches the locator of this component.
        """
        left = self.find().value_of_css_property('left')
        top = self.find().value_of_css_property('top')
        left = left.split("px")[0]
        top = top.split("px")[0]
        return Point(x=float(left), y=float(top))

    def get_quality_overlay_footer_text(self) -> str:
        """
        Get the text of the footer of the Quality Overlay of this component.

        :returns: The text of the footer of the Quality Overlay of this component.

        :raises TimeoutException: If the component has no Quality Overlay, or if the Quality Overlay is in Micro mode.
        """
        return self._quality_overlay_footer.find().text

    def get_quality_popover_description(self) -> str:
        """
        Get the text of the description of the Quality Overlay Popover for this component.

        :returns: The text of the description of the Quality Overlay Popover for this component.

        :raises TimeoutException: If the component has no Quality Overlay, or if the Quality Overlay is in Micro mode.
        """
        self.click_quality_overlay_popover_icon()
        return self._quality_popover_subsections.find_all()[self._POPOVER_DESCRIPTION_INDEX].text

    def get_quality_popover_property(self) -> str:
        """
        Get the text of the quality property of the Quality Overlay Popover for this component.

        :returns: The text of the quality property of the Quality Overlay Popover for this component.

        :raises TimeoutException: If the component has no Quality Overlay, or if the Quality Overlay is in Micro mode.
        """
        self.click_quality_overlay_popover_icon()
        return self._quality_popover_subsections.find_all()[self._POPOVER_PROPERTY_INDEX].text

    def get_quality_popover_subcode(self) -> str:
        """
        Get the text of the quality sub-code of the Quality Overlay Popover for this component.

        :returns: The text of the quality sub-code of the Quality Overlay Popover for this component.

        :raises TimeoutException: If the component has no Quality Overlay, or if the Quality Overlay is in Micro mode.
        """
        self.click_quality_overlay_popover_icon()
        return self._quality_popover_subsections.find_all()[self._POPOVER_SUBCODE_INDEX].text

    def is_in_percent_mode(self) -> bool:
        """
        Determine if the component is a direct child of a Coordinate Container which is using percent mode to render
        children.

        :returns: True, if the component is a direct child of a Coordinate Container which is using percent mode to
            render children - False otherwise.

        :raises TimeoutException: If no element matches the locator of this component.
        """
        # When working on QA-1563, we found that .get_css_property() always returns px value instead of %
        # This issue is on the Selenium side so this method remains using .get_attribute('style') for it to work.
        return '%' in self.find().get_attribute('style').split('width: ')[1].split(';')[0]

    def quality_overlay_contains_quality(self, quality: PerspectiveQuality) -> bool:
        """
        Determine if the quality status is present within the quality overlay for this component.

        :returns: True, if the supplied quality is present - False otherwise.

        :param quality: The quality status to check for that is present on the quality overlay of this component.
        """
        if self.quality_overlay_is_in_micro_mode():
            targets = self._micro_overlay_icons.find_all()
        else:
            targets = self._header_popover_badges.find_all()
        for target in targets:
            if quality.value in target.get_attribute("class"):
                return target.is_displayed()
        return False

    def quality_overlay_footer_text_is_displayed(self) -> bool:
        """
        Determine if the quality overlay footer text is present within the quality overlay for this component.

        :returns: True, if quality overlay is present - False otherwise.
        """
        try:
            return len(self.get_quality_overlay_footer_text()) > 0
        except TimeoutException:
            return False

    def quality_popover_is_displayed(self) -> bool:
        """
        Determine if the quality popover is displayed.

        :returns: True, if the quality popover is displayed - False otherwise.
        """
        try:
            return self._quality_popover.find() is not None
        except TimeoutException:
            return False

    def quality_overlay_is_in_micro_mode(self) -> bool:
        """
        Determine if the quality overlay is in micro mode.

        :returns: True, if the quality overlay is in micro mode - False otherwise.
        """
        try:
            # fallback to "first"
            return self._micro_overlay_icons.find().is_displayed()
        except TimeoutException:
            return False

    def quality_overlay_is_displayed(self) -> bool:
        """
        Determine if the quality overlay is displayed.

        :returns: True, if the quality overlay is displayed - False otherwise.
        """
        try:
            return self._quality_overlay_state_div.find() is not None
        except TimeoutException:
            return False

    def wait_for_no_overlay(
            self, time_to_wait: int = 5, raise_exception: bool = False, wait_on_binding: float = 0) -> None:
        """
        Wait for any overlay attached to this component to be removed.

        :param time_to_wait: The amount of time (in seconds) to wait for the overlay to disappear.
        :param raise_exception: If True, will raise a TimeoutException exception when encountered. Otherwise,
            TimeoutException will be ignored.
        :param wait_on_binding: The amount of time to wait after the overlay disperses. This can be useful to ensure
            that OPC Tag writes make it to the PLC and that all bindings are returning the current value on the PLC.

        :raises TimeoutException: If the overlay is still present in the DOM after the value supplied to time_to_wait
            expires.
        """
        try:
            WebDriverWait(driver=self.driver, timeout=time_to_wait).until_not(
                IAec.function_returns_true(custom_function=self.quality_overlay_is_displayed, function_args={}))
            self.wait_on_binding(time_to_wait=wait_on_binding)
        except TimeoutException as toe:
            if raise_exception:
                raise toe


class _LocatorBuilder:
    @staticmethod
    def get_css_locator(locators: list) -> Tuple[Union[By, str], str]:
        css_list = []
        for locator in locators:
            by = By.CSS_SELECTOR
            value = locator
            if type(locator) == tuple:
                by = locator[0]
                value = locator[1]
            if by == By.XPATH:
                raise TypeError(
                    f'Locator: {locator} has a By type of XPATH which is incompatible with the css_selector builder.')
            elif by == By.ID:
                css_list.append(f'[id="{value}"]')
            elif by == By.CLASS_NAME:
                css_list.append(f'.{value}')
            elif by == By.NAME:
                css_list.append(f'[name="{value}"]')
            else:
                css_list.append(value)
        return By.CSS_SELECTOR, " ".join(css_list)

    @staticmethod
    def get_xpath_locator(locators: list) -> Tuple[Union[By, str], str]:
        xpath_list = []
        for locator in locators:
            by = locator[0]
            value = locator[1]
            if by == By.CSS_SELECTOR:
                raise TypeError(
                    f'Locator: {locator} has a By type of CSS_SELECTOR which is incompatible with the xpath builder.')
            elif by == By.ID:
                xpath_list.append(f'//*[@id="{value}"]')
            elif by == By.CLASS_NAME:
                xpath_list.append(f'//*[contains(concat(" ", normalize-space(@class), " "),"{value}")]')
            elif by == By.NAME:
                xpath_list.append(f'//*[@name="{value}"]')
            elif by == By.LINK_TEXT:
                xpath_list.append(f'//a[normalize-space()="{value}"]')
            elif by == By.PARTIAL_LINK_TEXT:
                xpath_list.append(f'//a[contains(normalize-space(),"{value}")]')
            elif by == By.TAG_NAME:
                xpath_list.append(f'//{value}')
            else:
                xpath_list.append(value)
        return By.XPATH, "".join(xpath_list)

Components/BasicComponent.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, List, Optional, Union

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.CSSEnumerations import CSSPropertyValue
from Helpers.Point import Point


class CommonButton(ComponentPiece):
    """
    A common button, exposing common functions which EVERY button in Perspective can use. This includes buttons within
    other components, like the Acknowledge button found inside the Alarm Status Table component.
    """
    _INTERNAL_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button')
    _INTERNAL_ICON_LOCATOR = (By.TAG_NAME, "svg")
    _INTERNAL_TEXT_LOCATOR = (By.CSS_SELECTOR, "div.text")
    _INTERNAL_IMAGE_LOCATOR = (By.TAG_NAME, "img")
    PRIMARY_CLASS = 'ia_button--primary'
    SECONDARY_CLASS = 'ia_button--secondary'

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._internal_button = ComponentPiece(
            locator=self._INTERNAL_BUTTON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            description="The internal HTML <button> element of the Button.",
            poll_freq=poll_freq)
        self._internal_icon = CommonIcon(
            locator=self._INTERNAL_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The icon in use by the Button.",
            poll_freq=poll_freq)
        self._internal_text = ComponentPiece(
            locator=self._INTERNAL_TEXT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The container in which the text of the Button resides.",
            poll_freq=poll_freq)
        self._internal_image = ComponentPiece(
            locator=self._INTERNAL_IMAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The image within the Button.",
            poll_freq=poll_freq)

    def click(self, wait_timeout=None, binding_wait_time: float = 0.5) -> None:
        """
        Click the button.
        """
        _button = self._get_html_button()
        if _button == self:
            super().click(wait_timeout=wait_timeout, binding_wait_time=binding_wait_time)
        else:
            _button.click(wait_timeout=wait_timeout, binding_wait_time=binding_wait_time)

    def get_css_property_of_icon(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Get some CSS property value of the icon within the button.
        """
        return self._internal_icon.get_css_property(property_name=property_name)

    def get_origin_of_icon(self) -> Optional[Point]:
        """
        Obtain the origin of the icon contained within this Button.

        :returns: A Point which marks the upper-left corner of the icon contained within this button, or None if no icon
            is present within the Button.
        """
        try:
            return self._internal_icon.get_origin()
        except TimeoutException:
            return None

    def get_origin_of_text(self) -> Optional[Point]:
        """
        Obtain the origin of the text of this Button.

        :returns: A Point which marks the upper-left corner of the text contained within this button, or None if no text
            is present within the Button.
        """
        try:
            return self._internal_text.get_origin()
        except TimeoutException:
            return None

    def icon_is_present_in_button(self) -> bool:
        """
        Determine if the Button contains an icon. This is not the same as :func:`icon_is_displayed_in_button`, which
        determines VISIBILITY of any such icon.

        :returns: True, if an image is currently present inside the button - False otherwise.
        """
        try:
            return self._internal_icon.find() is not None
        except TimeoutException:
            return False

    def icon_is_displayed_in_button(self) -> bool:
        """
        Determine if the Button is displaying an internal icon.

        :returns: True, if an icon is currently displayed inside the button - False otherwise.
        """
        try:
            return self._internal_icon.find().is_displayed()
        except TimeoutException:
            return False

    def image_is_displayed_in_button(self) -> bool:
        """
        Determine if the Button is displaying an internal image.

        :returns: True, if an image is currently displayed inside the button - False otherwise.
        """
        try:
            return self._internal_image.find().is_displayed()
        except TimeoutException:
            return False

    def is_enabled(self) -> bool:
        """
        Determine if this Button is enabled.

        :returns: True, if the Button is currently enabled - False otherwise.
        """
        return self._get_html_button().find().is_enabled()

    def is_primary(self) -> bool:
        """
        Determine if this button is currently displaying as a Primary Button.

        :returns: True, if this button is currently displaying as a Primary Button - False otherwise.
        """
        clazz = self.find().get_attribute("class")
        return (self.PRIMARY_CLASS in clazz) and (self.SECONDARY_CLASS not in clazz)

    def is_secondary(self) -> bool:
        """
        Determine if this button is currently displaying as a Secondary Button.

        :returns: True, if this button is currently displaying as a Secondary Button - False otherwise.
        """
        clazz = self.find().get_attribute("class")
        return (self.SECONDARY_CLASS in clazz) and (self.PRIMARY_CLASS not in clazz)

    def _get_html_button(self) -> ComponentPiece:
        """
        Obtain the true button of this component.

        :returns: A ComponentPiece which may be used as a <button>.
        """
        if self.find().tag_name != 'button':
            return self._internal_button
        else:
            return self

Components/Common/Button.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicComponent, ComponentPiece


class FileUpload(BasicComponent):
    """
    A Common/shared implementation of a File upload, used both within Perspective and the gateway.
    """
    _BUTTON_LOCATOR = (By.TAG_NAME, f"button.primary")
    _INPUT_LOCATOR = (By.CSS_SELECTOR, "input")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._button = ComponentPiece(
            locator=self._BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            description="The button within the File Upload which actually opens the OS dialog.",
            poll_freq=poll_freq)
        self._input = ComponentPiece(
            locator=self._INPUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            description="The hidden input within the File Upload which can be used to specify file paths.")

    def get_button_text(self) -> str:
        """
        Obtain the text displayed within the button of the File Upload.

        :returns: The text displayed to the user within the button of the File Upload.

        :raises TimeoutException: If the File Upload is rendering in a manner which does not include a button.
        """
        return self._button.get_text()

    def is_enabled(self) -> bool:
        """
        Determine if the File Upload is enabled.

        :returns: True, if the File Upload is currently enabled - False otherwise.

        :raises TimeoutException: If the File Upload is rendering in a manner which does not include a button.
        """
        return "disabled" not in self._button.find().get_attribute("class")

    def upload_file_by_path(self, normalized_file_path: str) -> None:
        """
        Supply the location of a file to be uploaded.

        :param normalized_file_path: The normalized (OS-agnostic) path to the file to be uploaded.
        """
        self._input.find().send_keys(normalized_file_path)

Components/Common/FileUpload.py
inductiveautomation/ignition-automation-tools
from typing import Optional, Union, Tuple, List

from selenium.common.exceptions import ElementClickInterceptedException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Helpers.IAAssert import IAAssert


class CommonTextInput(ComponentPiece):
    """A common-use input field."""
    _INTERNAL_INPUT_LOCATOR = (By.TAG_NAME, "input")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        # The internal input piece is important for when our component has a quality overlay because any reference
        # made using something like an id would now refer to the <div> level instead.
        self._internal_input = ComponentPiece(
            locator=self._INTERNAL_INPUT_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            description="The internal <input> element, used primarily for Text Fields, or when the component contains "
                        "a quality overlay.",
            poll_freq=poll_freq)

    def get_placeholder_text(self) -> str:
        """
        Obtain the text in use as a placeholder for this component.

        :returns: The text a user sees within this component when no value has been supplied.
        """
        return self.find().get_attribute("placeholder")

    def get_text(self) -> str:
        """
        Obtain the text contained within this component.

        :returns: The text contents of the component via the `value` attribute.
        """
        self.wait_on_binding(time_to_wait=0.5)
        input_component = self._internal_input if self._needs_to_get_input_element() else self
        return input_component.find().get_attribute("value")

    def placeholder_text_exists(self) -> bool:
        """
        Determine if a component is currently displaying placeholder text.

        :returns: True, if a component is currently displaying placeholder text - False otherwise.
        """
        return self.find().get_attribute("placeholder") is not None

    def set_text(
            self,
            text: Union[float, str],
            release_focus: bool = True,
            binding_wait_time: float = 0) -> None:
        """
        Set the value of this component.

        :param text: The text to type into the field.
        :param release_focus: Dictates whether a blur() event is invoked for the component after typing the supplied
            text.
        :param binding_wait_time: The amount of time (in seconds) to wait after typing the supplied text before allowing
            code to continue.
        """
        text = str(text)
        # strip special characters (ESC, ENTER) while leaving spaces and punctuation
        expected_text = text.encode("ascii", "ignore").decode()
        if text is None or text == '':
            text = ' ' + Keys.BACKSPACE
        current_text = self.get_text()  # Do NOT wait on matching text to be in place
        keys_to_send = ''.join([Keys.ARROW_RIGHT for _ in current_text] +
                               [Keys.BACKSPACE for _ in current_text] +
                               [text])
        self.scroll_to_element()
        input_object = self
        if self._needs_to_get_input_element():
            input_object = self._internal_input
        try:
            input_object.click()
        except ElementClickInterceptedException:
            # quality overlay interference
            ActionChains(driver=self.driver) \
                .move_to_element_with_offset(to_element=input_object.find(), xoffset=5, yoffset=5) \
                .click() \
                .perform()
        input_object.find().send_keys(keys_to_send)
        if release_focus:
            self.release_focus()
        self.wait_on_binding(time_to_wait=binding_wait_time)
        IAAssert.is_equal_to(
            actual_value=self.get_text(),
            expected_value=expected_text,
            failure_msg="Failed to set the value of the input.")

    def _needs_to_get_input_element(self) -> bool:
        """
        Determine if the Component IS the <input>, or if we should instead query for the internal <input>.

        :returns: True, if the current component definition would fail to operate as an <input> - False otherwise.
        """
        return self.find().tag_name not in ['input', 'textarea']

Components/Common/TextInput.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, Optional, List

from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class Pie(BasicPerspectiveComponent):
    """A Perspective Pie Chart Component."""
    LOGGER = "PIE"
    _SECTION_LOCATOR = (By.CSS_SELECTOR, 'g[role="menu-item"]')
    _CHECKBOX_LOCATOR = (By.CSS_SELECTOR, 'g[role="switch"]')
    _CHECKBOX_LABEL_LOCATOR = (By.CSS_SELECTOR, "tspan")
    _TITLE_LOCATOR = (By.CSS_SELECTOR, "svg > g > g > g > g > g > g > text > tspan")
    _LEGEND_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'g[aria-label="Legend"]')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._sections = ComponentPiece(
            locator=self._SECTION_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The actual slices of the Pie.",
            poll_freq=poll_freq)
        self._title = ComponentPiece(
            locator=self._TITLE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The title of the Pie Chart.",
            poll_freq=poll_freq)
        self._legend_container = ComponentPiece(
            locator=self._LEGEND_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The Legend of the Pie Chart.",
            poll_freq=poll_freq)
        self._checkboxes = ComponentPiece(
            locator=self._CHECKBOX_LOCATOR,
            driver=driver,
            parent_locator_list=self._legend_container.locator_list,
            wait_timeout=1,
            description="The checkboxes within the legend of the Pie Chart.",
            poll_freq=poll_freq)

    def get_chart_title(self) -> str:
        """
        Obtain the title of the Pie Chart.

        :returns: The title of the Pie Chart as it is displayed to the user.

        :raises TimeoutException: If the Pie CHart is not displaying a title.
        """
        try:
            return self._title.get_text()
        except TimeoutException as toe:
            raise TimeoutException(msg="No title is present for the Pie Chart.") from toe

    def get_legend_checkbox_count(self) -> int:
        """
        Obtain a count of displayed checkboxes from the legend.

        :returns: A count of all checkboxes displayed as part of legend of the Pie Chart.
        """
        try:
            return len(self._checkboxes.find_all())
        except TimeoutException:
            return 0

    def get_legend_checkbox_labels(self) -> List[str]:
        """
        Obtain all labels for checkboxes in the legend of the Pie Chart.

        :returns: A list which contains the text of all displayed labels belonging to checkboxes in the legend.

        :raises TimeoutException: If no checkboxes (or labels) are displayed,
        """
        # checkboxes have multiple <tspan> children, and we want ONLY the first one.
        try:
            return [
                checkbox.find_element(*self._CHECKBOX_LABEL_LOCATOR).text for checkbox in self._checkboxes.find_all()]
        except TimeoutException as toe:
            raise TimeoutException(msg="No checkbox labels are present.") from toe

    def get_pie_slice_count(self) -> int:
        """
        Obtain a count of displayed pie slices.

        :returns: A count of pie slices displayed to the user.
        """
        try:
            return len(self._sections.find_all())
        except TimeoutException:
            return 0

    def has_title(self) -> bool:
        """
        Determine if the Pie Chart is displaying a title.

        :returns: True, if the Pie Chart is currently displaying a title - False otherwise.
        """
        try:
            return self._title.find() is not None
        except TimeoutException:
            return False

    def legend_is_displayed(self) -> bool:
        """
        Determine if the Pie Chart is displaying a legend.

        :returns: True, if the Pie Chart is currently displaying a legend - False otherwise.
        """
        try:
            return self._legend_container.find().is_displayed()
        except TimeoutException:
            return False

    def pie_slice_labels_are_displayed(self) -> bool:
        """
        Determine if pie slices are displaying labels.

        :returns: True, if pie slices are currently displaying labels - False otherwise.
        """
        # We can't follow the normal pattern here because we specifically need the 0th instance of the 5th child.
        sections = ComponentPiece(
            locator=(By.CSS_SELECTOR, 'g[aria-label="Series"] > g > g:nth-child(5)'),
            driver=self.driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=self.poll_freq)
        try:
            return len(sections.find_all()[0].find_elements(By.CSS_SELECTOR, 'tspan')) > 0
        except TimeoutException:
            return False
        except NoSuchElementException:
            return False

Components/PerspectiveComponents/Charts/Pie.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Union, List, Tuple, Optional

from selenium.common.exceptions import StaleElementReferenceException
from selenium.common.exceptions import TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.Common.Button import CommonButton
from Components.PerspectiveComponents.Common.Checkbox import CommonCheckbox
from Components.PerspectiveComponents.Common.DateRangeSelector import \
    CommonDateRangeSelector, DateRangeSelectorTab, DateRangeSelectorTimeUnit, HistoricalRange, PerspectiveDate
from Components.PerspectiveComponents.Common.Table import Table as CommonTable
from Components.PerspectiveComponents.Common.TagBrowseTree import CommonTagBrowseTree
from Components.PerspectiveComponents.Inputs.TextField import TextField
from Helpers.CSSEnumerations import CSS, CSSPropertyValue
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Helpers.IAExpectedConditions.IAExpectedConditions import TextCondition
from Helpers.IASelenium import IASelenium


class OptionBarButton(Enum):
    """An enumeration of the available Option Bar buttons."""
    PAN_AND_ZOOM = 'panAndZoom'
    X_TRACE = 'xTrace'
    RANGE_BRUSH = 'rangeBrush'
    ANNOTATION = 'annotation'
    FULL_SCREEN = 'fullscreen'
    SETTINGS = 'settings'
    MORE = "more"


class PenControlColumn(Enum):
    """An enumeration of the available Pen Control columns."""
    AVERAGE = "average"
    AXIS = "axis"
    CURRENT_VALUE = "currentValue"
    MAXIMUM = "maximum"
    MINIMUM = "minimum"
    PLOT = "plot"
    X_TRACE = "xTrace"


class RangeBrushColumn(Enum):
    """An enumeration of the available Range Brush columns."""
    AVERAGE = "average"
    DELTA = "delta"
    FIRST = "first"
    LAST = "last"
    LCL = "lcl"
    MAXIMUM = "maximum"
    MINIMUM = "minimum"
    MEDIAN = "median"
    STANDARD_DEVIATION = "standardDeviation"
    SUM = "sum"
    UCL = "ucl"


class Tab(Enum):
    """An enumeration of the available Tabs within the settings panel."""
    AXES = "Axes"
    COLUMNS = "Columns"
    PENS = "Pens"
    PLOTS = "Plots"


class PowerChart(BasicPerspectiveComponent):
    """A Perspective Power Chart Component."""

    class _PowerChartFooter(ComponentPiece):
        _FOOTER_LOCATOR = (By.CSS_SELECTOR, 'div.chart-footer')

        def __init__(
                self,
                parent_locator_list: List[Tuple[By, str]],
                driver: WebDriver,
                description: Optional[str] = None,
                poll_freq: float = 0.5):
            super().__init__(
                locator=self._FOOTER_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description=description,
                poll_freq=poll_freq)

        def is_in_responsive_mode(self) -> bool:
            """
            Determine if the Power Chart is in responsive mode.

            :returns: True, if the Power Chart is currently displaying in a responsive layout - False otherwise.
            """
            try:
                return "mobile" in self.find().get_attribute(name="class")
            except TimeoutException as toe:
                raise TimeoutException(msg="Unable to locate the Power Chart as defined by its locator") from toe

    class _PowerChartGraph(ComponentPiece):
        _LINE_CHART_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.main-chart-container")
        _GRAPHED_LINE_LOCATOR = (By.CSS_SELECTOR, f"g.ia_lineChart > g > g")
        _LINE_PATH_LOCATOR = (By.CSS_SELECTOR, "> path")
        _GRAPH_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "g.chart-row")
        _LABEL_LOCATOR = (By.CSS_SELECTOR, "text.ia_powerChartComponent__timeAxis__values")
        _Y_AXIS_GRID_LOCATOR = (By.CSS_SELECTOR, "line.ia_powerChartComponent__yAxis__grid")
        _X_AXIS_GRID_LOCATOR = (By.CSS_SELECTOR, "line.ia_powerChartComponent__timeAxis__grid")
        _X_AXIS_LOCATOR = (By.CSS_SELECTOR, "line.ia_powerChartComponent__timeAxis__axis")
        _Y_AXIS_LABEL_LOCATOR = (By.CSS_SELECTOR, "text.ia_powerChartComponent__yAxis__label")
        _Y_AXIS_LOCATOR = (By.CSS_SELECTOR, "line.ia_powerChartComponent__yAxis__axis")

        def __init__(
                self,
                parent_locator_list: List[Tuple[By, str]],
                driver: WebDriver,
                description: Optional[str] = None,
                poll_freq: float = 0.5):
            super().__init__(
                parent_locator_list=parent_locator_list,
                driver=driver,
                locator=self._LINE_CHART_CONTAINER_LOCATOR,
                description=description,
                poll_freq=poll_freq)
            self._row_container = ComponentPiece(
                locator=self._GRAPH_CONTAINER_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The container of all graphed lines in the chart.",
                poll_freq=poll_freq)
            self._graphed_line = ComponentPiece(
                locator=self._GRAPHED_LINE_LOCATOR,
                driver=driver,
                parent_locator_list=self._row_container.locator_list,
                description="The actual graphed line.",
                poll_freq=poll_freq)
            self._line_paths = ComponentPiece(
                parent_locator_list=self._graphed_line.locator_list,
                locator=self._LINE_PATH_LOCATOR,
                driver=driver,
                description="The <path> element within the line which describes the drawn path of the line.",
                poll_freq=poll_freq)
            self._time_axis_labels = ComponentPiece(
                locator=self._LABEL_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description="The labels of the time axis, usually found along the bottom of the graphed lines.",
                poll_freq=poll_freq)
            self._y_axis_grid_line = ComponentPiece(
                locator=self._Y_AXIS_GRID_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                poll_freq=poll_freq)
            self._x_axis_grid_line = ComponentPiece(
                locator=self._X_AXIS_GRID_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                poll_freq=poll_freq)
            self._x_axis = ComponentPiece(
                locator=self._X_AXIS_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description="The line which defines the bottom of the graphed area.",
                poll_freq=poll_freq)
            self._y_axis_label = ComponentPiece(
                locator=self._Y_AXIS_LABEL_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description="The labels of the Y axis, which can be found to either the left or right of the graphed "
                            "lines.",
                poll_freq=poll_freq)
            self._y_axis = ComponentPiece(
                locator=self._Y_AXIS_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description="The line which defines either the left or right border of the graph.",
                poll_freq=poll_freq)

        def get_count_of_graphed_pens(self) -> int:
            """
            Obtain a count of pens which are displayed in the chart.

            :returns: A count of pens currently rendered in the chart.
            """
            try:
                # each graphed pen has 2 <path> elements
                return int(len(self._line_paths.find_all(wait_timeout=1)) / 2)
            except TimeoutException:
                return 0

        def get_displayed_time_axis_labels(self) -> List[str]:
            """
            Obtain the labels displayed on the X (time) axis.

            :returns: A list which contains all currently displayed labels from the X (time) axis.

            :raises TimeoutException: If no labels are displayed on the X (time) axis.
            """
            try:
                return [_.text for _ in self._time_axis_labels.find_all()]
            except TimeoutException as toe:
                raise TimeoutException(msg="No labels displayed for the Power Chart") from toe

        def get_drawn_path(self, pen_checkbox_color_as_string: str) -> str:
            """
            Obtain the drawn path of a graphed pen. Useful for determining when a graphed pen has updated.

            :param pen_checkbox_color_as_string: The color used to graph the pen for which you would like the drawn
                path.

            :returns: A string which represents the actual graphed line of a pen.

            :raises IndexError: If no graphed pen has the supplied color.
            :raises TimeoutException: If no pens are graphed.
            """
            try:
                return list(
                    filter(
                        lambda e: e.value_of_css_property(CSS.STROKE.value) == pen_checkbox_color_as_string,
                        self._line_paths.find_all()))[0].get_attribute("d")
            except TimeoutException as toe:
                raise TimeoutException(msg="No pens are currently graphed.") from toe
            except IndexError as ie:
                raise IndexError(f"No pen graphed with a color of '{pen_checkbox_color_as_string}'.") from ie

        def get_x_axis_html_class(self) -> str:
            """
            Obtain the HTML class of the X axis.

            :returns: The class of the X axis as it exists in the DOM.

            :raises TimeoutException: If no X axis is present.
            """
            try:
                return self._x_axis.find().get_attribute(name="class")
            except TimeoutException as toe:
                raise TimeoutException(msg="No X axis present.") from toe

        def get_x_axis_css_property_value(self, property_name: Union[CSSPropertyValue, str]) -> str:
            """
            Obtain the value of a CSS property from the X axis.

            :param property_name: The name of the CSS property you would like the value of.

            :returns: The value of the requested CSS property.

            :raises TimeoutException: If no labels are present on the X (time) axis.
            """
            try:
                return self._time_axis_labels.get_css_property(property_name=property_name)
            except TimeoutException as toe:
                raise TimeoutException(msg="No labels are present for the X (time) axis.") from toe
        
        def get_y_axis_html_class(self) -> str:
            """
            Obtain the HTML class of the Y axis.

            :returns: The class of the Y axis as it exists in the DOM.

            :raises TimeoutException: If no Y axis is present.
            """
            try:
                return self._y_axis_label.find().get_attribute(name="class")
            except TimeoutException as toe:
                raise TimeoutException(msg="No labels present on the Y axis.") from toe

        def get_y_axis_vertical_bar_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
            """
            Obtain a CSS property value from the Y axis.

            :param property_name: The name of the CSS property to retrieve the value of.

            :returns: The value of the requested CSS property from the Y axis.

            :raises TimeoutException: If no Y axis is present.
            """
            try:
                return self._y_axis.get_css_property(property_name=property_name)
            except TimeoutException as toe:
                raise TimeoutException(msg="No Y axis is present.") from toe

        def get_y_axis_label_css_property_value(self, property_name: Union[CSSPropertyValue, str]) -> str:
            """
            Obtain the value of a CSS property from the Y axis.

            :param property_name: The name of the CSS property you would like the value of.

            :returns: The value of the requested CSS property.

            :raises TimeoutException: If no labels are present on the Y axis.
            """
            try:
                return self._y_axis_label.get_css_property(property_name=property_name)
            except TimeoutException as toe:
                raise TimeoutException(msg="No labels present on the Y axis") from toe

        def click_and_drag_chart(self, x_offset: int = 0, y_offset: int = 0) -> None:
            """
            Blindly click within the Power Chart and then drag some number of pixels before releasing the click event.

            :param x_offset: The number of pixels to the right of the upper left corner to click.
            :param y_offset: The number of pixels down from the upper left corner to click.
            """
            ActionChains(driver=self.driver) \
                .click_and_hold(on_element=self.find()) \
                .move_by_offset(xoffset=x_offset, yoffset=y_offset) \
                .release() \
                .perform()
            self.wait_on_binding(time_to_wait=1)

        def get_count_of_x_axis_grid_lines(self, _already_attempted=False) -> int:
            """
            Obtain a count of lines displayed on the X axis.

            :param _already_attempted: Used internally to re-try in the event the Power Chart updates.

            :returns: A count of vertical lines currently displayed on the X axis of the Power Chart.
            """
            try:
                return len(self._x_axis_grid_line.find_all())
            except TimeoutException:
                return 0
            except StaleElementReferenceException as sere:
                if not _already_attempted:
                    return self.get_count_of_x_axis_grid_lines(
                        _already_attempted=True)
                else:
                    raise sere

        def get_count_of_y_axis_grid_lines(self, _already_attempted=False) -> int:
            """
            Obtain a count of lines displayed on the Y axis.

            :param _already_attempted: Used internally to re-try in the event the Power Chart updates.

            :returns: A count of horizontal lines currently displayed on the Y axis of the Power Chart.
            """
            try:
                return len(self._y_axis_grid_line.find_all())
            except TimeoutException:
                return 0
            except StaleElementReferenceException as sere:
                if not _already_attempted:
                    return self.get_count_of_y_axis_grid_lines(
                        _already_attempted=True)
                else:
                    raise sere

        def get_css_property_from_x_axis_grid(self, property_name: Union[CSSPropertyValue, str]) -> str:
            """
            Obtain the value of a CSS property from the grid of the X axis.

            :param property_name: The name of the property you want to value of.

            :returns: The value of the requested CSS property from the X axis grid.

            :raises TimeoutException: If no X axis grid is present.
            """
            try:
                return self._x_axis_grid_line.get_css_property(property_name=property_name)
            except StaleElementReferenceException:
                return self.get_css_property_from_x_axis_grid(property_name=property_name)
            except TimeoutException as toe:
                raise TimeoutException(
                    msg="No grid line was found for the X axis.") from toe

        def get_css_property_from_y_axis_grid(self, property_name: Union[CSSPropertyValue, str]) -> str:
            """
            Obtain the value of a CSS property from the grid of the Y axis.

            :param property_name: The name of the property you want to value of.

            :returns: The value of the requested CSS property from the Y axis grid.

            :raises TimeoutException: If no Y axis grid is present.
            """
            try:
                return self._y_axis_grid_line.get_css_property(property_name=property_name)
            except StaleElementReferenceException:
                return self.get_css_property_from_y_axis_grid(
                    property_name=property_name)
            except TimeoutException as toe:
                raise TimeoutException(
                    msg="No grid line was found for the Y axis.") from toe

        def line_is_displayed_by_color(self, pen_checkbox_color_as_str: str) -> bool:
            """
            Determine if a line is graphed.

            :param pen_checkbox_color_as_str: The color to look for in the graph. This value MUST be supplied in the
                same form as the browser in use would return a color value. It is recommended to avoid supplying
                hard-coded colors and instead supply values retrieved from the browser.

            :returns: True, if any graphed line uses the supplied color - False otherwise.
            """
            try:
                return len(
                    list(
                        filter(
                            lambda e: e.value_of_css_property(CSS.STROKE.value) == pen_checkbox_color_as_str,
                            self._line_paths.find_all()))) == 1
            except TimeoutException:
                return False

        def set_range_brush_basic(self) -> None:
            """
            Applies the range brush to the graph via a short drag event.
            """
            self.click_and_drag_chart(x_offset=100, y_offset=0)

    class _PowerChartHeader(ComponentPiece):
        """
        The header of the Power Chart, where the Option Bar Buttons live, as well as the range selector.
        """
        # TODO remove after IGN-5195
        _FULLSCREEN_BUTTON_ACTIVE_PATH = "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"

        _GENERIC_ICON_STRING = "ia_powerChartComponent__icon__"
        _BUTTON_ACTIVE_CLASS = "ia_powerChartComponent__icon--active"
        _HEADER_LOCATOR = (By.CSS_SELECTOR, 'div.chart-header')
        _FULLSCREEN_BUTTON_ID_SUFFIX = "fullscreen"
        _TAG_BROWSER_BUTTON_LOCATOR = (By.CSS_SELECTOR, "div.tag-browser-icon-and-timerange span.show-tag-browser-icon")
        _TITLE_LOCATOR = (By.ID, "ia_powerChartComponent__title")

        def __init__(
                self,
                parent_locator_list: List[Tuple[By, str]],
                driver: WebDriver,
                poll_freq: float = 0.5):
            super().__init__(
                locator=self._HEADER_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                poll_freq=poll_freq)
            self._tag_browser_button = ComponentPiece(
                locator=self._TAG_BROWSER_BUTTON_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                description="The button used to expand the Tag Browser Tree.",
                poll_freq=poll_freq)
            self._fullscreen_button_path = ComponentPiece(
                locator=(By.CSS_SELECTOR, f"#{self._GENERIC_ICON_STRING}{self._FULLSCREEN_BUTTON_ID_SUFFIX} path"),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                description="The <path> element of the fullscreen button.",
                poll_freq=poll_freq)
            self._date_range_selector = CommonDateRangeSelector(
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The Date Range Selector of the Power Chart. If enabled, this is found in the upper-left "
                            "of the Power Chart.",
                poll_freq=poll_freq)
            self._option_bar_buttons = {}
            self._title = ComponentPiece(
                locator=self._TITLE_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description="The title of the Power Chart. If configured, this will be found on the left side of the "
                            "Power Chart, between the toolbar area of the Power Chart and the graph itself.",
                poll_freq=poll_freq)

        def apply_changes(self) -> None:
            """
            Click the Apply button in the range selector.

            :raises TimeoutException: If the range selector is not present, or if the Apply button is not present.
            """
            self._date_range_selector.apply_changes()

        def button_is_active(self, option_bar_button: OptionBarButton) -> bool:
            """
            Determine if an Option Bar button is active (selected/in-use).

            :param option_bar_button: The Option Bar button to check the status of.

            :returns: True, if the supplied Option Bar button is active - False otherwise.
            """
            try:
                if option_bar_button == OptionBarButton.FULL_SCREEN:
                    # special handling for fullscreen button
                    return self._FULLSCREEN_BUTTON_ACTIVE_PATH \
                        == self._fullscreen_button_path.find().get_attribute(
                            name="d")
                else:
                    return self._BUTTON_ACTIVE_CLASS in self._get_option_bar_button(
                        option_bar_button=option_bar_button).find().get_attribute("class")
            except TimeoutException as toe:
                raise TimeoutException(
                    msg=f"No {option_bar_button.value} button found among the Option Bar buttons.") from toe

        def button_is_displayed(self, option_bar_button: OptionBarButton) -> bool:
            """
            Determine if an Option Bar button is displayed.

            :param option_bar_button: The Option Bar button we will check.

            :returns: True, if the supplied Option Bar button is displayed - False otherwise.
            """
            try:
                return self._get_option_bar_button(option_bar_button=option_bar_button).find().is_displayed()
            except TimeoutException:
                return False

        def click_option_bar_button(self, option_bar_button: OptionBarButton) -> None:
            """
            Click a button located within the Option Bar area.

            :param option_bar_button: The Option Bar button to click.

            :raises TimeoutException: if the supplied button is not present.
            """
            try:
                self._get_option_bar_button(option_bar_button=option_bar_button).click()
            except TimeoutException as toe:
                raise TimeoutException(
                    msg=f"No {option_bar_button.value} button found among the Option Bar buttons.") from toe

        def click_tag_browser_button(self) -> None:
            """
            Click the button which expands or collapses the Tag Browser panel.

            :raises TimeoutException: If the Tag Browse button is not present.
            """
            try:
                self._tag_browser_button.click()
            except TimeoutException as toe:
                raise TimeoutException(msg="The Tag browse button is not present.") from toe

        def date_range_selector_expansion_icon_is_displayed(self) -> bool:
            """
            Determine if the Date Range selector is displayed.

            :returns: True, if the date range selector is currently displayed - False otherwise.
            """
            return self._date_range_selector.date_range_selector_toggle_icon_is_present()

        def get_historical_range(self) -> HistoricalRange:
            """
            Obtain the applied historical range as an object. Expands the range selector if not already open, and then
            closes the range selector if it was not open to start with.

            :returns: The applied historical range as a HistoricalRange object.
            """
            return self._date_range_selector.get_historical_range()

        def get_date_range_message(self) -> str:
            """
            Obtain the range message associated with the Date Range Selector. This value describes the applied range.
            Example: 'Last 8 hours', or a breakdown of the date/times used for the Historical range.

            :returns: The range message associated with the Date Range Selector, if available.

            :raises TimeoutException: If the Power Chart has made the Date Range Selector invisible.
            """
            return self._date_range_selector.get_range_message()

        def get_title_html_class(self) -> str:
            """
            Obtain the html class attribute for the title of the header.

            :returns: The HTML class attribute for the title of the header of the Power Chart.

            :raises TimeoutException: If the title is not present.
            """
            try:
                return self._title.find().get_attribute(name="class")
            except TimeoutException as toe:
                raise TimeoutException(msg="No title is present in the header of the Power Chart.") from toe

        def get_css_property_value_from_title(self, property_name: Union[CSSPropertyValue, str]) -> str:
            """
            Obtain the value of a CSS property from the title of the header.

            :param property_name: The name of the property you want to value of.

            :returns: The value of the supplied CSS property of the title of the header of the Power Chart.

            :raises TimeoutException: If the title is not present.
            """
            try:
                return self._title.get_css_property(property_name=property_name)
            except TimeoutException as toe:
                raise TimeoutException(msg="No title is present in the header of the Power Chart.") from toe

        def hover_over_option_bar_button(self, option_bar_button: OptionBarButton) -> None:
            """
            Hover over a button of the Option Bar.

            :param option_bar_button: The Option Bar button to hover over.

            :raises TimeoutException: If the supplied Option Bar button is not present.
            """
            try:
                IASelenium(driver=self.driver).hover_over_web_element(
                    web_element=self._get_option_bar_button(option_bar_button=option_bar_button).find())
            except TimeoutException as toe:
                raise TimeoutException(msg=f"No {option_bar_button.value} Option Bar button found.") from toe

        def is_in_responsive_mode(self) -> bool:
            """
            Determine if the Power Chart is displaying in responsive mode.

            :returns: True, if the Power Chart is currently rendering in a responsive layout - False otherwise.
            """
            return "mobile" in self.find().get_attribute(name="class")

        def open_date_range_selector_if_not_displayed(self) -> None:
            """
            Open the date range selector if it is not already displayed. No action is taken if the date range selector
            is already displayed.
            """
            self._date_range_selector.open_date_range_popover_or_modal_if_not_already_displayed()

        def select_date_range_selector_tab(self, tab: DateRangeSelectorTab) -> None:
            """
            Select the supplied tab within the date range selector.

            :param tab: The date range selector tab to click/select.

            :raises TimeoutException: If the date range selector is not present.
            """
            self._date_range_selector.select_tab(tab=tab)

        def select_realtime_unit(self, time_unit: DateRangeSelectorTimeUnit) -> None:
            """
            Select a realtime unit from the date range selector.

            :param time_unit: The unit of time to select.

            :raises TimeoutSelection: If the date range selector is not present.
            """
            self._date_range_selector.select_realtime_unit(time_unit=time_unit)

        def set_historical_day(self, numeric_day: Union[int, str]) -> None:
            """
            Select/click a day within the historical range selector. This requires that the date range selector already
            be displayed and the historical tab is already selected.

            :param numeric_day: The numeric day to click within the historical range.

            :raises TimeoutException: If the historical range selector is not present, or if the supplied day is
                invalid.
            """
            self._date_range_selector.select_date(date=PerspectiveDate(day=numeric_day))

        def set_historical_month(self, month: str) -> None:
            """
            Select a month within the historical range selector. This requires that the date range selector already
            be displayed and the historical tab is already selected.

            :param month: The month to select (eg: "January", not 1).

            :raises TimeoutException: If the historical range selector is not present, or if the supplied month is not
                valid.
            """
            self._date_range_selector.select_date(date=PerspectiveDate(month=int(month)))

        def set_historical_year(self, year: Union[int, str]) -> None:
            """
            Select a year from the dropdown in the Historical Range selector.

            :param year: The year to select.

            :raises TimeoutException: If the Historical Range Selector is not present.
            """
            self._date_range_selector.select_date(date=PerspectiveDate(year=int(year)))

        def set_historical_range_and_apply(self, historical_range: HistoricalRange) -> None:
            """
            Open the date range selector, select values for a historical range, and then apply those changes.

            :param historical_range: The historical range settings to apply, within a HistoricalRange object.

            :raises AssertionError: If unsuccessful in applying the provided historical range.
            :raises TimeoutException: If the date range capabilities of the Power Chart are disabled.
            """
            self.open_date_range_selector_if_not_displayed()
            self.select_date_range_selector_tab(tab=DateRangeSelectorTab.HISTORICAL)
            self._date_range_selector.click_clear_in_historical_tab()
            self._date_range_selector.set_historical_range(historical_range=historical_range, apply=True)

        def set_realtime_numeric_value(self, time_value: int) -> None:
            """
            Set the numeric (scalar) value in use by the realtime range.

            :param time_value: The scalar value to use for the realtime range.

            :raises AssertionError: If unsuccessful in applying the supplied time value.
            """
            self._date_range_selector.set_realtime_range(time_value=time_value)

        def tag_browser_button_is_displayed(self) -> bool:
            """
            Determine if the Tag Browser button is displayed.

            :returns: True if the Tag Browser button is currently displayed - False otherwise.
            """
            try:
                return self._tag_browser_button.find().is_displayed()
            except TimeoutException:
                return False

        def _get_option_bar_button(self, option_bar_button: OptionBarButton) -> ComponentPiece:
            """
            Obtain one of the buttons available from the upper-right of the Power Chart.

            :param option_bar_button: The Option Bar button to obtain.
            """
            _button_comp_piece = self._option_bar_buttons.get(option_bar_button.value)
            if not _button_comp_piece:
                _button_comp_piece = ComponentPiece(
                    locator=(By.ID, f"{self._GENERIC_ICON_STRING}{option_bar_button.value}"),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The {option_bar_button.value} button, found in the upper-right of the Power Chart.",
                    poll_freq=self.poll_freq)
                self._option_bar_buttons[option_bar_button.value] = _button_comp_piece
            return _button_comp_piece

    class _PowerChartSettingsPanel(ComponentPiece):
        """
        The settings panel is accessed by clicking the settings button (gear icon) of the Power Chart.
        """
        _SETTINGS_PANEL_LOCATOR = (By.CSS_SELECTOR, "section.ia_powerChartComponent__settings")
        _PEN_ROW_LOCATOR = (By.CSS_SELECTOR, "div.tr")
        _TAB_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.tab-container")
        _TAB_ID_GENERIC_STRING = "ia_powerChartComponent__settings__tab__"
        _CHECKBOX_ACTIVE_PATH = "M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 " \
                                "14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
        _CLOSE_PANEL_ICON_LOCATOR = (By.ID, "ia_powerChartComponent__icon__closeSettings")
        _UPDATE_PEN_NAME_INPUT_FIELD_LOCATOR = (By.ID, "ia_powerChartComponent__settings__addEditPen__name")
        _UPDATE_PEN_PATH_INPUT_FIELD_LOCATOR = (By.ID, "ia_powerChartComponent__settings__addEditPen__dataSource")
        _DONE_BUTTON_LOCATOR = (
            By.CSS_SELECTOR, ".ia_powerChartComponent__settings__tab__content__buttonContainer .ia_button--primary")
        _DELETION_CANCELLATION_BUTTON_LOCATOR = (By.ID, "ia_powerChartComponent__settings__pensTab__deleteCancelButton")
        _DELETION_CONFIRMATION_BUTTON_LOCATOR = (
            By.ID, "ia_powerChartComponent__settings__pensTab__deleteConfirmButton")
        _VISIBILITY_CHECKBOX_LOCATOR = (By.ID, "ia_powerChartComponent__settings__addEditPen__visible")
        _ENABLED_CHECKBOX_LOCATOR = (By.ID, "ia_powerChartComponent__settings__addEditPen__enabled")
        _CHART_SETTINGS_LINK_LOCATOR = (By.LINK_TEXT, "Chart Settings")
        _NORMAL_STATE_STROKE_COLOR_DROPDOWN_LOCATOR = (
            By.ID, "ia_powerChartComponent__settings__addEditPen__displayStylesnormalStrokeColor")
        _SHARED_COLOR_PICKER_INPUT_LOCATOR = (By.CSS_SELECTOR, "div.ia_colorPickerCommon__modal input")
        _ADD_PEN_LINK_LOCATOR = (By.ID, "ia_powerChartComponent__settings__pensTab__addPenLink")
        # this can be updated once IGN-5498 is merged, new data attribute for the edit axis button will be included
        _DEFAULT_AXIS_EDIT_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.tc.ia_table__cell[data-column-id="edit"] svg')
        _GRID_VISIBLE_CHECKBOX_LOCATOR = (By.ID, 'ia_powerChartComponent__settings__addEditAxis__gridVisible')
        _GRID_COLOR_PICKER_DROPDOWN_LOCATOR = (By.ID, 'ia_powerChartComponent__settings__addEditAxis__gridColor')
        _GRID_OPACITY_TF_LOCATOR = (By.ID, 'ia_powerChartComponent__settings__addEditAxis__gridOpacity')
        _GRID_DASH_ARRAY_TF_LOCATOR = (By.ID, 'ia_powerChartComponent__settings__addEditAxis__gridDashArray')

        def __init__(
                self,
                parent_locator_list: List[Tuple[By, str]],
                driver: WebDriver,
                poll_freq: float = 0.5):
            super().__init__(
                parent_locator_list=parent_locator_list,
                driver=driver,
                locator=self._SETTINGS_PANEL_LOCATOR,
                poll_freq=poll_freq)
            self._pen_rows = ComponentPiece(
                locator=self._PEN_ROW_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="Inside the Pens tab of the Settings Panel, the rows of Tag History Pen names.",
                poll_freq=poll_freq)
            self._tab_container = ComponentPiece(
                locator=self._TAB_CONTAINER_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="Inside the Settings Panel, the container which has tabs for various configuration areas.",
                poll_freq=poll_freq)
            self._close_icon = ComponentPiece(
                locator=self._CLOSE_PANEL_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The 'X' in the upper-right of the Settings Panel.",
                poll_freq=poll_freq)
            self._update_pen_name_input_field = ComponentPiece(
                locator=self._UPDATE_PEN_NAME_INPUT_FIELD_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The input used to modify the name of the pen currently being edited in the Settings "
                            "Panel.",
                poll_freq=poll_freq)
            self._update_pen_path_input_field = ComponentPiece(
                locator=self._UPDATE_PEN_PATH_INPUT_FIELD_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The input used to modify the path of the pen currently being edited in the Settings "
                            "Panel.",
                poll_freq=poll_freq)
            self._done_button = ComponentPiece(
                locator=self._DONE_BUTTON_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description="The 'Done' button, found in the bottom right of the Settings Panel while editing a pen",
                poll_freq=poll_freq)
            self._deletion_cancellation_button = ComponentPiece(
                locator=self._DELETION_CANCELLATION_BUTTON_LOCATOR,
                driver=driver,
                parent_locator_list=None,
                description="The 'Cancel' button, which is available after having clicked the trash can icon for a row "
                            "within the Settings Panel.",
                poll_freq=poll_freq)
            self._deletion_confirmation_button = ComponentPiece(
                locator=self._DELETION_CONFIRMATION_BUTTON_LOCATOR,
                driver=driver,
                parent_locator_list=None,
                description="The 'Delete' button, which is available after having clicked the trash can icon for a row "
                            "within the Settings Panel.",
                poll_freq=poll_freq)
            self._visible_checkbox = CommonCheckbox(
                locator=self._VISIBILITY_CHECKBOX_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The checkbox which dictates whether the pen is displayed in the chart.",
                poll_freq=poll_freq)
            self._enabled_checkbox = CommonCheckbox(
                locator=self._ENABLED_CHECKBOX_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The checkbox which dictates whether the pen is hidden from the chart and pen control "
                            "panel.",
                poll_freq=poll_freq)
            self._chart_settings_link = ComponentPiece(
                locator=self._CHART_SETTINGS_LINK_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The breadcrumb within the Settings Panel of the Power Chart, while editing a "
                            "configuration area.",
                poll_freq=poll_freq)
            self._normal_state_stroke_color_dropdown = ComponentPiece(
                locator=self._NORMAL_STATE_STROKE_COLOR_DROPDOWN_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The dropdown/Select used to allow modification for the 'Normal State' style within the "
                            "Settings Panel.",
                poll_freq=poll_freq)
            self._shared_color_modal_input = ComponentPiece(
                locator=self._SHARED_COLOR_PICKER_INPUT_LOCATOR,
                driver=driver,
                parent_locator_list=None,
                description="The color picker text input field shared by any of the color modification dropdowns.",
                poll_freq=poll_freq)
            self._color_picker = ComponentPiece(
                locator=(By.CSS_SELECTOR, "div.saturation-black"),
                driver=driver,
                parent_locator_list=None,
                description="The saturation picker panel itself.",
                poll_freq=poll_freq)
            self._add_pen_link = ComponentPiece(
                locator=self._ADD_PEN_LINK_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The '+ Add Pen' link found beneath the last row in the Pens tab of the Settings Panel.",
                poll_freq=poll_freq)
            self._tab = ComponentPiece(
                locator=(By.ID, f""),  # placeholder, to be replaced within usages.
                driver=driver,
                parent_locator_list=self._tab_container.locator_list,
                description="A specific tab within the Settings Panel - check the declared `id`.",
                poll_freq=poll_freq)
            self._pen_control_column_checkbox = ComponentPiece(
                locator=(By.ID, ""),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                description="The checkboxes within the Columns tab of the Settings Panel which are used to modify "
                            "which columns are displayed in the pen control table.",
                poll_freq=poll_freq)
            self._range_brush_column_checkboxes = {}
            self._pen_visibility_checkboxes = {}
            self._pen_row_visible_checkbox_path = ComponentPiece(
                locator=(By.CSS_SELECTOR, ""),
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The <path> element of the checkbox, which describes the drawn lines of the icon.",
                poll_freq=poll_freq)
            self._tabs = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div[id^="{self._TAB_ID_GENERIC_STRING}"]'),
                driver=driver,
                parent_locator_list=self._tab_container.locator_list,
                description="Any of the tabs visible within the Settings Panel.",
                poll_freq=poll_freq)
            self._active_tab = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div[id^="{self._TAB_ID_GENERIC_STRING}"].active'),
                driver=driver,
                parent_locator_list=self._tab_container.locator_list,
                description="The active tab visible within the Settings Panel.",
                poll_freq=poll_freq)
            self._edit_icons_dict = {}
            self._delete_icons_dict = {}
            self._grid_visible_checkbox = CommonCheckbox(
                locator=self._GRID_VISIBLE_CHECKBOX_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The checkbox which drives whether the grid is visible for the axis currently being "
                            "edited in the Settings Panel.",
                poll_freq=poll_freq)
            self._grid_color_picker_dropdown = ComponentPiece(
                locator=self._GRID_COLOR_PICKER_DROPDOWN_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The dropdown which opens up the color picker for the grid of the axis currently being "
                            "edited in the Settings Panel.",
                poll_freq=poll_freq)
            self._grid_opacity = TextField(
                locator=self._GRID_OPACITY_TF_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The text input for grid opacity of the axis currently being edited in the Settings Panel.",
                poll_freq=poll_freq)
            self._grid_dash_array = TextField(
                locator=self._GRID_DASH_ARRAY_TF_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The text input for grid dash array of the axis currently being edited in the Settings "
                            "Panel.",
                poll_freq=poll_freq)
            self._axis_edit_icon = {}

        def cancel_deletion(self) -> None:
            """
            Click the 'Cancel' button during the deletion process.

            :raises TimeoutException: If the 'Cancel' button is not present.
            """
            self._deletion_cancellation_button.click()

        def click_add_pen_link(self) -> None:
            """
            Click the '+ Add Pen' link. Requires that the Settings panel already be open.

            :raises TimeoutException: If the Settings panel is not already open, or if the Pens tab is not present.
            """
            self._add_pen_link.click()

        def click_chart_settings_breadcrumb_link(self) -> None:
            """
            Click the 'Chart Settings' link within the breadcrumb path at the top of the Settings panel.

            :raises TimeoutException: If the Settings panel is nt already open, or if the Chart Settings breadcrumb
                option is not present. This will occur anytime the Chart Settings tabbed area is already displayed.
            """
            self._chart_settings_link.click()

        def click_done(self) -> None:
            """
            Click the Done button.

            :raises TimeoutException: If the 'Done' button is not present.
            """
            self._done_button.click()

        def click_point_in_color_picker(self) -> None:
            """
            Click the color picker displayed while editing any of the color settings for a pen. Requires that the
            picker already be visible as a result of clicking any of the available color dropdown items. Note that
            Selenium does not properly handle offsets for the color picker modal, so this function has limited use.

            :raises TimeoutException: If the color picker is not present.
            """
            # TODO: Figure out why offsets don't work here
            ActionChains(driver=self.driver)\
                .move_to_element(
                    to_element=self._color_picker.find()
                )\
                .click()\
                .pause(1)\
                .perform()

        def click_normal_state_stroke_color_dropdown(self) -> None:
            """
            Click the dropdown for the stroke color in the 'Normal State' section of the pen.

            :raises TimeoutException: if not currently in the pen editing section of the Settings panel.
            """
            self._normal_state_stroke_color_dropdown.click()

        def click_tab(self, tab: Tab) -> None:
            """
            Click a tab of the Settings panel. Requires that the Settings panel already be open.

            :param tab: The tab to click.

            :raises TimeoutException: If the supplied tab is not present.
            """
            self._tab.set_locator(new_locator=(By.ID, f"{self._TAB_ID_GENERIC_STRING}{tab.value.lower()}"))
            self._tab.click()
            IAAssert.is_true(
                value=self.tab_is_active(tab=tab),
                failure_msg=f"Failed to select the {tab.value} tab.")

        def close_settings_panel(self) -> None:
            """
            Close the Settings panel.

            :raises AssertionError: If unsuccessful in closing the Settings panel.
            """
            if self.is_displayed():
                try:
                    self.click_chart_settings_breadcrumb_link()
                except TimeoutException:
                    pass
                try:
                    self._close_icon.click()
                except TimeoutException:
                    pass
            IAAssert.is_not_true(
                value=self.is_displayed(),
                failure_msg="Failed to close the Settings panel.")

        def confirm_deletion(self) -> None:
            """
            Click the 'Delete' button available during the process of deleting a pen.

            :raises TimeoutException: If the 'Delete' button is not present.
            """
            self._deletion_confirmation_button.click()

        def click_trash_can_icon_for_pen(self, pen_name: str) -> None:
            """
            Click the trash can icon for a specific pen. Note that this only begins the deletion process, and that there
            are further actions to take after this.

            :param pen_name: The name of the pen you would like to begin the deletion process for.

            :raises TimeoutException: If the trash can icon is not present. This might be due to no pen with the
                supplied name being present.
            """
            self._get_delete_icon_by_pen_name(pen_name=pen_name).click()

        def _get_delete_icon_by_pen_name(self, pen_name: str) -> ComponentPiece:
            """
            Obtain the trash can icon from the Tag History Pens tab for the supplied pen.

            :param pen_name: The name of the pen to target.
            """
            _icon = self._delete_icons_dict.get(pen_name)
            if not _icon:
                _icon = ComponentPiece(
                    locator=(
                        By.CSS_SELECTOR,
                        f'svg[id^="ia_powerChartComponent__settings__pensTab__deleteIcon"][data-pen-name="{pen_name}"'),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The trash can icon for the '{pen_name}' pen in the Pens tab of the Settings Panel.",
                    poll_freq=self.poll_freq)
                self._delete_icons_dict[pen_name] = _icon
            return _icon

        def click_pencil_icon_for_pen(self, pen_name: str) -> None:
            """
            Click the pencil icon for a specific pen. Note that this only begins the editing process, and that there
            are further actions to take after this.

            :param pen_name: The name of the pen you would like to begin the editing process for.

            :raises TimeoutException: If the pencil icon is not present. This might be due to no pen with the
                supplied name being present.
            """
            self._get_edit_icon_by_pen_name(pen_name=pen_name).click()

        def _get_edit_icon_by_pen_name(self, pen_name: str) -> ComponentPiece:
            """
            Obtain the pencil icon from the Tag History Pens tab for the supplied pen.

            :param pen_name: The name of the pen to target.
            """
            _icon = self._edit_icons_dict.get(pen_name)
            if not _icon:
                _icon = ComponentPiece(
                    locator=(
                        By.CSS_SELECTOR,
                        f'svg[id^="ia_powerChartComponent__settings__pensTab__editIcon"][data-pen-name="{pen_name}"'),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The pencil icon for the '{pen_name}' pen in the Pens tab of the Settings Panel.",
                    poll_freq=self.poll_freq)
                self._edit_icons_dict[pen_name] = _icon
            return _icon

        def get_current_color_from_picker(self) -> str:
            """
            Obtain the color in use for whichever setting is currently being modified, as a string. Note that as this
            value is coming from a prop it will always be hex.

            :returns: The color currently applied as a hex string.

            :raises TimeoutException: If the color picker and its accompanying inputs are not present.
            """
            return self._shared_color_modal_input.find().get_attribute("value")

        def get_count_of_displayed_pens(self) -> int:
            """
            Obtain a count of how many pens are currently listed in the Pens tab of the Settings panel. Requires that
            the Settings panel already be displayed and the Pens tab be selected.

            :returns: A numeric count of the pens listed in the Pens tab of the Settings Panel.
            """
            try:
                return len(self._pen_rows.find_all())
            except TimeoutException:
                return 0

        def get_displayed_pen_names(self) -> List[str]:
            """
            Obtain a list of all pen names displayed in the Pens tab of the Settings panel. Requires that the Settings
            panel already be displayed and the Pens tab be selected.

            :returns: A list which contains all pen names displayed in the Pens tab of the Settings panel.
            """
            try:
                return [_.text for _ in self._pen_rows.find_all()]
            except TimeoutException:
                return []

        def get_displayed_tab_names(self) -> List[str]:
            """
            Obtain the name sof all tabs listed in the Settings panel. Requires that the Settings panel already be
            displayed.

            :returns: A list which contains the names of all tabs displayed in the Settings panel.

            :raises TimeoutException: If the Settings panel is not already open, or if the Settings panel is not
                currently at the level which displays the tabbed layout.
            """
            return [_.text for _ in self._tabs.find_all()]

        def get_pen_name(self) -> str:
            """
            Obtain the name of the pen as displayed in the Settings panel.

            :returns: The text contents of the name field within the Settings panel.

            :raises TimeoutException: If the Settings panel is not present.
            """
            return self._update_pen_name_input_field.find().get_attribute("value")

        def is_displayed(self) -> bool:
            """
            Determine if the Settings panel is displayed.

            :returns: True, if the Settings panel is currently open/displayed/expanded - False otherwise.
            """
            try:
                return self.find(wait_timeout=1).is_displayed() and "expanded" in self.find().get_attribute("class")
            except TimeoutException:
                return False

        def pen_settings_are_displayed(self) -> bool:
            """
            Determine if the Pen settings panel is displayed.

            :returns: True, if the inputs used to edit a pen are currently displayed - False otherwise.
            """
            try:
                return self._update_pen_name_input_field.find(wait_timeout=1) is not None
            except TimeoutException:
                return False

        def set_pen_control_column_display_state_in_settings_panel(
                self,
                pen_control_column: PenControlColumn,
                should_be_displayed: bool) -> None:
            """
            Set the display state of a column within the pen control table. Requires the Settings panel already be
            displayed.

            :param pen_control_column: The column which will have its state set.
            :param should_be_displayed: If True, the column will be set to display - otherwise the column will be set to
                not be displayed.

            :raises TimeoutException: If the Settings panel is not already displayed.
            """
            self.click_tab(tab=Tab.COLUMNS)
            self._pen_control_column_checkbox.set_locator(
                new_locator=(
                    By.ID, f"ia_powerChartComponent__settings__columnsTab__penControl.{pen_control_column.value}"))
            self._special_checkbox_handling(
                checkbox_component_piece=self._pen_control_column_checkbox, should_be_checked=should_be_displayed)

        def set_range_brush_column_display_state_in_settings_panel(
                self,
                range_brush_column: RangeBrushColumn,
                should_be_displayed: bool) -> None:
            """
            Set the display state of a column within the range brush table. Requires the Settings panel already be
            displayed.

            :param range_brush_column: The column which will have its state set.
            :param should_be_displayed: If True, the column will be set to display - otherwise the column will be set to
                not be displayed.

            :raises TimeoutException: If the Settings panel is not already displayed.
            """
            self.click_tab(tab=Tab.COLUMNS)
            self._special_checkbox_handling(
                checkbox_component_piece=self._get_range_brush_checkbox(range_brush_column=range_brush_column),
                should_be_checked=should_be_displayed)

        def set_display_checkbox_state_by_pen_name(self, pen_name: str, should_be_checked: bool) -> None:
            """
            Set the display state of a pen listed in the Pens tab of the Settings panel. Requires the Settings panel
            already be displayed, and that the pen tab be displayed.

            :param pen_name: The name of the pen which will have its state set.
            :param should_be_checked: If True, the pen will be set to display - otherwise, the pen will be set to not
                display.

            :raises TimeoutException: If the Settings panel is not already displayed.
            """
            # requires special handling due to use of uncommon checkbox.
            if (self._get_pen_visibility_checkbox(
                    pen_name=pen_name).find().get_attribute('data-state') == "checked") != should_be_checked:
                self._get_pen_visibility_checkbox(pen_name=pen_name).click()

        def set_pen_display_state_in_settings_panel(self, should_be_checked: bool) -> None:
            """
            Set the display state of a pen while the pen is being edited. This function expects that a pen is already
            being edited.

            :param should_be_checked: If True, the pen will be set to display - otherwise, the pen will be set to not
                display.

            :raises TimeoutException: If the Settings panel is not already displayed, or if we are not already editing
                a pen.
            """
            self._special_checkbox_handling(
                checkbox_component_piece=self._visible_checkbox, should_be_checked=should_be_checked)

        def set_pen_enabled_state_in_settings_panel(self, should_be_enabled: bool) -> None:
            """
            Set the enabled state of a checkbox. The wording in the Power Chart is actually to "hide" the pen from the
            chart, so we reverse that logic inside this function. Requires that a pen already be in an "editing" state.

            :param should_be_enabled: If True, the pen will NOT be hidden - otherwise we will set the pen to be hidden.

            :raises TimeoutException: If the Settings panel is not already visible, or if a pen is not already being
                edited.
            """
            # This is counter-intuitive, but the checkbox is actually to "hide" the pen - so reverse the logic here.
            self._special_checkbox_handling(
                checkbox_component_piece=self._enabled_checkbox, should_be_checked=not should_be_enabled)

        @classmethod
        def _special_checkbox_handling(cls, checkbox_component_piece: ComponentPiece, should_be_checked: bool) -> None:
            """
            Perform one-off handling of checkboxes within the Power Chart.

            :param checkbox_component_piece: The ComponentPiece (not Checkbox) which defines the checkbox.
            :param should_be_checked: A True value dictates the checkbox should have an active (checked) state.

            :raises TimeoutException: If the checkbox can not be found.
            """
            svg = checkbox_component_piece.find().find_element(By.XPATH, '../*[name()="svg"]')
            if (svg.get_attribute("data-state") == "checked") != should_be_checked:
                svg.click()

        def set_pen_name_field(self, updated_pen_name: str) -> None:
            """
            Set the name of a pen during editing.

            :param updated_pen_name: The desired pen name.

            :raises AssertionError: If unsuccessful in editing the name of the pen.
            :raises TimeoutException: If the Settings panel is not already visible, or if a pen is not already being
                edited.
            """
            current_text = self._update_pen_name_input_field.find().get_attribute("value")
            self._update_pen_name_input_field.click()
            self._update_pen_name_input_field.find().send_keys(
                ''.join(
                    [Keys.ARROW_LEFT for _ in current_text] +
                    [updated_pen_name] +
                    [Keys.DELETE for _ in current_text]))
            IAAssert.is_equal_to(
                actual_value=self._update_pen_name_input_field.find().get_attribute("value"),
                expected_value=updated_pen_name,
                failure_msg="Failed to modify the name of the pen while editing in the Settings panel.")

        def set_pen_path_field(self, updated_pen_path: str) -> None:
            """
            Set the path of a pen during editing.

            :param updated_pen_path: The desired pen path to use.

            :raises AssertionError: If unsuccessful in editing the path of the pen.
            :raises TimeoutException: If the Settings panel is not already visible, or if a pen is not already being
                edited.
            """
            current_text = self._update_pen_path_input_field.find().get_attribute("value")
            self._update_pen_path_input_field.click()
            self._update_pen_path_input_field.find().send_keys(
                ''.join(
                    [Keys.ARROW_LEFT for _ in current_text] +
                    [updated_pen_path] +
                    [Keys.DELETE for _ in current_text]))
            IAAssert.is_equal_to(
                actual_value=self._update_pen_path_input_field.find().get_attribute("value"),
                expected_value=updated_pen_path,
                failure_msg="Failed to modify the path of the pen while editing in the Settings panel.")

        def set_pen_stroke_color(self, hex_desired_color: str) -> None:
            """
            Set the stroke color of a pen during editing.

            :param hex_desired_color: The color to use for the stroke of the pen, in hex format.

            :raises AssertionError: If unsuccessful in editing the stroke color of the pen.
            :raises TimeoutException: If the Settings panel is not already visible, or if a pen is not already being
                edited.
            """
            self._normal_state_stroke_color_dropdown.click()
            current_text = self._shared_color_modal_input.find().get_attribute("value")
            self._shared_color_modal_input.find().send_keys(
                ''.join(
                    [Keys.ARROW_LEFT for _ in current_text] +
                    [hex_desired_color] +
                    [Keys.DELETE for _ in current_text]))
            IAAssert.is_equal_to(
                actual_value=self._shared_color_modal_input.find().get_attribute("value"),
                expected_value=hex_desired_color,
                failure_msg="Failed to modify the stroke of the pen while editing in the Settings panel.")

        def tab_is_active(self, tab: Tab) -> bool:
            """
            Determine if a tab is actively in use.

            :param tab: The tab to check for.

            :returns: True, if the supplied tab is currently the active tab in use - False otherwise.

            raises TimeoutException: If the Settings panel is not already open, or if the Settings panel is not
                currently on the main tabbed layout section.
            """
            return self._active_tab.find().get_attribute(name="data-category") == tab.value.lower()

        def _get_range_brush_checkbox(self, range_brush_column: RangeBrushColumn) -> ComponentPiece:
            """
            Obtain a checkbox from the range brush section of the Columns tab.

            :param range_brush_column: The column for which you would like the checkbox.
            """
            _checkbox = self._range_brush_column_checkboxes.get(range_brush_column.value)
            if not _checkbox:
                _checkbox = ComponentPiece(
                    locator=(
                        By.ID,
                        f"ia_powerChartComponent__settings__columnsTab__rangeSelection.{range_brush_column.value}"),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The {range_brush_column.value} checkbox within the Range Brush section of the "
                                f"Columns tab of the Settings Panel.",
                    poll_freq=self.poll_freq
                )
                self._range_brush_column_checkboxes[range_brush_column.value] = _checkbox
            return _checkbox

        def _get_pen_visibility_checkbox(self, pen_name: str) -> ComponentPiece:
            """
            Obtain the visibility checkbox for a row in the Pens tab of the Settings panel.

            :param pen_name: The name of the pen for which you would like the visibility checkbox.
            """
            _checkbox = self._pen_visibility_checkboxes.get(pen_name)
            if not _checkbox:
                _checkbox = ComponentPiece(
                    locator=(By.CSS_SELECTOR, f'svg[data-pen-name="{pen_name}"]'),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The visibility checkbox to the left of the '{pen_name}' pen within the Pens tab of "
                                f"the Settings Panel,",
                    poll_freq=self.poll_freq)
                self._pen_visibility_checkboxes[pen_name] = _checkbox
            return _checkbox

        @classmethod
        def _click_checkbox_svg(cls, checkbox: ComponentPiece) -> bool:
            """
            Click the <svg> of a checkbox and report back on the success of the click.

            :param checkbox: The ComponentPiece which defines the checkbox.

            :returns: True, if the click event was successful - False otherwise.

            :raises TimeoutException: if the checkbox could not be found.
            """
            try:
                checkbox.find().find_element(By.XPATH, '../*[name()="svg"]').click()
                return True
            except StaleElementReferenceException:
                return False

        def click_pencil_icon_for_axis(self, axis_name: str) -> None:
            """
            Click the pencil (edit) icon for an axis.

            :param axis_name: The name of the axis to begin editing.

            :raises TimeoutException: If no row matching the supplied axis name is present.
            """
            _icon = self._axis_edit_icon.get(axis_name)
            if not _icon:
                _icon = ComponentPiece(
                    locator=(
                        By.CSS_SELECTOR,
                        f'[data-column-id="edit"] div [data-axis-name="{axis_name}"]'),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The pencil icon for the '{axis_name}' axis row within the Axes tab of the Settings "
                                f"Panel.",
                    poll_freq=self.poll_freq)
                self._axis_edit_icon[axis_name] = _icon
            _icon.click()

        def set_grid_visible_checkbox_state(self, should_be_checked: bool) -> None:
            """
            Set the display state of the grid for the axis currently being edited.

            :param should_be_checked: If True, the checkbox will be set to active/checked - otherwise the checkbox will
                be set to inactive/un-checked.

            :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
                edited.
            """
            self._special_checkbox_handling(
                checkbox_component_piece=self._grid_visible_checkbox, should_be_checked=should_be_checked)

        def set_grid_y_axis_color(self, hex_desired_color: str) -> None:
            """
            Set the Y-axis color to be used by the grid for the axis currently being edited.

            :param hex_desired_color: The color (in hex format) to use for the Y-axis of the grid.

            :raises AssertionError: If unsuccessful in modifying the color in use by the Y-axis of the grid.
            :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
                edited.
            """
            self._grid_color_picker_dropdown.click()
            current_text = self._shared_color_modal_input.find().get_attribute("value")
            self._shared_color_modal_input.find().send_keys(
                ''.join(
                    [Keys.ARROW_LEFT for _ in current_text] +
                    [hex_desired_color] +
                    [Keys.DELETE for _ in current_text]))
            IAAssert.is_equal_to(
                actual_value=self._shared_color_modal_input.find().get_attribute("value"),
                expected_value=hex_desired_color,
                failure_msg="Failed to set the color of the Y-axis of the grid while editing an axis.")

        def set_grid_y_axis_opacity(self, value: Union[float, str]) -> None:
            """
            Set the Y-axis opacity to be used by the grid for the axis currently being edited.

            :param value: The desired value (0<=value<=1) to use for the opacity of the Y-axis of the grid.

            :raises AssertionError: If unsuccessful in modifying the opacity in use by the Y-axis of the grid.
            :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
                edited.
            """
            self._grid_opacity.set_text(text=value)
            IAAssert.is_equal_to(
                actual_value=self._grid_opacity.wait_on_text_condition(
                    text_to_compare=value, condition=TextCondition.EQUALS),
                expected_value=value,
                as_type=str,
                failure_msg="Failed to set the opacity of the Y-axis of the grid while editing an axis.")

        def set_grid_y_axis_dash_array(self, value: str) -> None:
            """
            Set the Y-axis dash-array to be used by the grid for the axis currently being edited.

            :param value: The desired value to use for the dash-array of the Y-axis of the grid.

            :raises AssertionError: If unsuccessful in modifying the dash-array in use by the Y-axis of the grid.
            :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
                edited.
            """
            self._grid_dash_array.set_text(text=value)
            IAAssert.is_equal_to(
                actual_value=self._grid_dash_array.wait_on_text_condition(
                    text_to_compare=value, condition=TextCondition.EQUALS),
                expected_value=value,
                as_type=str,
                failure_msg="Failed to set the dash-array of the Y-axis of the grid while editing an axis.")

    class _PowerChartTable(CommonTable):
        """
        The pen control table of the Power Chart.
        """
        _PEN_CHECKBOX_SVG_CHECKED = \
            'M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 ' \
            '14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z'
        _EXPAND_COLLAPSE_ICON_LOCATOR_GENERIC_STRING = "ia_powerChartComponent__penDataDisplay__table__icon"
        _PEN_TABLE_COLLAPSE_ICON_LOCATOR = (
            By.CSS_SELECTOR, f'svg.{_EXPAND_COLLAPSE_ICON_LOCATOR_GENERIC_STRING}.collapse-icon')
        _PEN_TABLE_EXPAND_ICON_LOCATOR = (
            By.CSS_SELECTOR, f'svg.{_EXPAND_COLLAPSE_ICON_LOCATOR_GENERIC_STRING}.expand-icon')
        _DELETE_PEN_ICON_LOCATOR = (By.CSS_SELECTOR, "svg.delete-pen-icon")
        _EDIT_PEN_ICON_LOCATOR = (By.CSS_SELECTOR, "svg.edit-pen-icon")
        _PEN_TABLE_BODY_LOCATOR = (By.CSS_SELECTOR, "div.tb")
        _UNFORMATTED_PEN_VISIBILITY_CHECKBOX_SELECTOR_STRING = 'svg.pen-visibility-checkbox[data-pen-name="{0}"]'
        _TABLE_HEADER_LOCATOR = (By.CSS_SELECTOR, 'div.tr.header')
        _CONFIRM_DELETION_BUTTON_LOCATOR = (
            By.CSS_SELECTOR, "button#ia_powerChartComponent__settings__pensTab__deleteConfirmButton")

        def __init__(
                self,
                parent_locator_list: List[Tuple[By, str]],
                driver: WebDriver,
                poll_freq: float = 0.5):
            super().__init__(
                parent_locator_list=parent_locator_list,
                driver=driver,
                locator=(By.CSS_SELECTOR, 'div.pen-control-container'),
                description="The pen control table found at the bottom of the Power Chart.",
                poll_freq=poll_freq)
            self._collapse_icon = ComponentPiece(
                locator=self._PEN_TABLE_COLLAPSE_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The arrow pointing down - and located in the upper-left of the pen control table - which "
                            "allows for collapsing the pen control table. Present only while the pen control table is "
                            "expanded.",
                poll_freq=poll_freq)
            self._pen_table_body = ComponentPiece(
                locator=self._PEN_TABLE_BODY_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The body of the pen control table.",
                poll_freq=poll_freq)
            self._expand_icon = ComponentPiece(
                locator=self._PEN_TABLE_EXPAND_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The arrow pointing up - and located to the left of all displayed pen names - which "
                            "allows for expanding the pen control table. Present only while the pen control table is "
                            "collapsed.",
                poll_freq=poll_freq)
            self._delete_pen_icon = ComponentPiece(
                locator=self._DELETE_PEN_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The trash can icon visible only while hovering over a pen row in the pen control table.",
                poll_freq=poll_freq)
            self._edit_pen_icon = ComponentPiece(
                locator=self._EDIT_PEN_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The pencil icon visible only while hovering over a pen row in the pen control table.",
                poll_freq=poll_freq)
            self._confirm_deletion_button = ComponentPiece(
                locator=self._CONFIRM_DELETION_BUTTON_LOCATOR,
                driver=driver,
                parent_locator_list=None,
                description="The 'Delete' button visible after having clicked the trash can icon for a row in the "
                            "pen control table.",
                poll_freq=poll_freq)
            self._pen_checkbox = ComponentPiece(
                locator=(By.CSS_SELECTOR, f''),  # placeholder, to be overwritten with local pen name
                driver=self.driver,
                parent_locator_list=self._pen_table_body.locator_list,
                description="The checkbox which drives whether a given pen is displayed in the graph.",
                poll_freq=poll_freq)
            self._pen_control_columns = {}
            self._range_brush_columns = {}
            self._pen_table_expand_collapse_svg = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'svg[class*="{self._EXPAND_COLLAPSE_ICON_LOCATOR_GENERIC_STRING}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                description="The generic expand OR collapse svg definition; this could refer to either.",
                poll_freq=poll_freq)
            self._pen_labels = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'span.pen-name'),
                driver=self.driver,
                parent_locator_list=self._pen_table_body.locator_list,
                description="The labels which contain the names of pens within the pen control table.",
                poll_freq=poll_freq)
            self._pen_overlay = ComponentPiece(
                locator=(By.CSS_SELECTOR, '.overlay-shown'),
                driver=self.driver,
                parent_locator_list=self._pen_table_body.locator_list,
                description="The overlay displayed when a pen is faulted.",
                poll_freq=poll_freq)
            self._pen_control_table_cells_dict = {}

        def click_pen_checkbox_by_name(self, pen_name: str) -> None:
            """
            Click the checkbox for a pen listed in the pen control table.

            :param pen_name: The name of the pen for which you would like to click the accompanying checkbox.

            :raises TimeoutException: If no pen with the supplied name is present.
            """
            self._pen_checkbox.set_locator(
                new_locator=(
                    By.CSS_SELECTOR, self._UNFORMATTED_PEN_VISIBILITY_CHECKBOX_SELECTOR_STRING.format(pen_name)))
            self._pen_checkbox.click()

        def click_pencil_icon_for_pen(self, pen_name: str) -> None:
            """
            Click the pencil icon for a pen in the pen control table.

            :param pen_name: The name of the pen for which you would like to click the accompanying pencil icon.

            :raises IndexError: If no pen with the supplied name could be found.
            """
            pen_label_web_element = list(
                filter(
                    lambda e: e.text == pen_name,
                    self._pen_labels.find_all(
                        wait_timeout=1)))[0]
            IASelenium(
                driver=self.driver).hover_over_web_element(
                web_element=pen_label_web_element)
            self._edit_pen_icon.set_locator(
                new_locator=(By.CSS_SELECTOR, "div.tr.hovering svg.edit-pen-icon"))
            self._edit_pen_icon.click()

        def get_all_pen_names_from_pen_table(self) -> List[str]:
            """
            Obtain the names of all pens displayed in the pen control table.

            :returns: A list which contains the name of every pen displayed in the pen control table.

            :raises TimeoutException: If the pen control table is not present.
            """
            row_count = self.get_count_of_rows()
            pen_names = []
            for i in range(row_count):
                pen_names.append(self.get_row_data_by_row_index(zero_based_row_index=i)['penName'])
            return pen_names

        def get_count_of_pen_fault_overlays(self) -> int:
            """
            Obtain a count of how many pens are currently displaying fault overlays.

            :returns: A count of how many pens are currently displaying fault overlays.
            """
            try:
                return len(self._pen_overlay.find_all())
            except TimeoutException:
                return 0

        def get_column_headers_from_pen_table(self) -> List[str]:
            """
            Obtain the headers of the columns displayed in the pen control table. Ignores the selection column, as it
            contains no header text.

            :returns: A list which contains the header of every column currently displayed in the pen control table.

            :raises TimeoutException: If no pen control table is present.
            """
            return self.get_column_names_from_header()[1:]  # omit selection column

        def get_count_of_available_pens_from_pen_table(self) -> int:
            """
            Obtain a count of all pens listed in the pen control table.

            :returns: A count of every pen displayed in the pen control table.

            :raises TimeoutException: If the pen control table is not present.
            """
            return len(self.get_all_pen_names_from_pen_table())

        def get_pen_checkbox_color(self, pen_name: str) -> str:
            """
            Obtain the color in use for a given pen. This color will always be the same as the line which is graphed.

            :param pen_name: The name of the pen for which you would like the color of the accompanying checkbox.

            :returns: The color in use by the checkbox of a pen.

            :raises TimeoutException: If no pen with the supplied name is present in the pen control table, or if the
                pen control table is not present.
            """
            self._pen_checkbox.set_locator(
                new_locator=(
                    By.CSS_SELECTOR,
                    f'{self._UNFORMATTED_PEN_VISIBILITY_CHECKBOX_SELECTOR_STRING.format(pen_name)} > path'))
            return self._pen_checkbox.get_css_property(property_name=CSS.FILL)

        def pen_checkbox_is_active(self, pen_name: str) -> bool:
            """
            Determine if the checkbox of a pen is active (selected and/or checked are synonymous with active).

            :param pen_name: The name of the pen for which we will check the accompanying checkbox.

            :returns: True, if the checkbox for the specified pen is active/selected/checked.

            :raises TimeoutException: If no pen with the supplied name is present in the pen control table, or if the
                pen control table is not present.
            """
            self._pen_checkbox.set_locator(
                new_locator=(
                    By.CSS_SELECTOR,
                    f'{self._UNFORMATTED_PEN_VISIBILITY_CHECKBOX_SELECTOR_STRING.format(pen_name)} > path'))
            return self._pen_checkbox.find().get_attribute('d') == self._PEN_CHECKBOX_SVG_CHECKED

        def get_row_data_by_row_index(self, zero_based_row_index: int) -> dict:
            """
            Obtain the data of a specified row and return that data as a dictionary, where the keys of the dictionary
            are the column names.

            :param zero_based_row_index: The zero-based index of the row you are targeting.

            :returns: The row data as a dictionary, with the keys of the dictionary being the case-sensitive names of
                the columns.
            """
            row_data_as_dictionary = {}
            for cell in self._get_cell_components_by_row_index(zero_based_row_index=zero_based_row_index).find_all():
                row_data_as_dictionary[cell.get_attribute('data-column-id')] = cell.text
            row_data_as_dictionary.pop('toggle', None)
            return row_data_as_dictionary

        def _get_cell_components_by_row_index(self, zero_based_row_index: int) -> ComponentPiece:
            """
            Obtain a ComponentPiece which describes how to locate the cells of a row of the pen control table.

            :param zero_based_row_index: The zero-based index of the row these 'cells' would belong to.
            """
            cells = self._pen_control_table_cells_dict.get(zero_based_row_index)
            if not cells:
                cells = ComponentPiece(
                    locator=(By.CSS_SELECTOR, f'div.tr[data-row-index="{zero_based_row_index}"] div.tc'),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The cells from the {zero_based_row_index}th row in the pen control table.",
                    poll_freq=self.poll_freq)
                self._pen_control_table_cells_dict[zero_based_row_index] = cells
            return cells

        def pen_control_column_is_displayed(
                self, pen_control_column: Union[PenControlColumn, RangeBrushColumn]) -> bool:
            """
            Determine if a pen control column is displayed.

            :param pen_control_column: The pen control column or range brush column to check for the presence of.

            :returns: True, if the supplied column is displayed - False otherwise.
            """
            try:
                return self._get_pen_control_table_column(pen_control_column=pen_control_column).find(1) is not None
            except TimeoutException:
                return False

        def _get_pen_control_table_column(
                self, pen_control_column: Union[PenControlColumn, RangeBrushColumn]):
            pen_control_column = pen_control_column.value
            _column = self._pen_control_columns.get(pen_control_column)
            if not _column:
                _column = ComponentPiece(
                    locator=(By.CSS_SELECTOR, f'div.thc[data-column-id="{pen_control_column}"]'),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    description=f"The '{pen_control_column}' column from the pen control table of the Power Chart.",
                    poll_freq=self.poll_freq)
                self._pen_control_columns[pen_control_column] = _column
            return _column

        def pen_table_is_expanded(self) -> bool:
            """
            Determine if the pen control table is expanded.

            :returns: True, if the pen control table is currently expanded - False otherwise.
            """
            try:
                return (self.find() is not None) \
                       and ("collapse-icon" in self._pen_table_expand_collapse_svg.find().get_attribute("class"))
            except TimeoutException:
                return False

        def range_brush_column_is_displayed(self, range_brush_column: RangeBrushColumn) -> bool:
            """
            Determine if a range brush column is displayed in the pen control table.

            :param range_brush_column: The range brush column for which we will check the display state.

            :returns: True, if the supplied range brush column is displayed - False otherwise.
            """
            return self.pen_control_column_is_displayed(pen_control_column=range_brush_column)

        def remove_pen_by_name(self, pen_name: str) -> None:
            """
            Remove a pen from the pen control table (and therefore the Power Chart) via the pen control table.

            :param pen_name: The name of the pen you would like to remove.

            :raises AssertionError: If unsuccessful in removing the supplied pen from the Power Chart.
            :raises TimeoutException: If the pen control table is not present.
            """
            try:
                pen_label_web_element = list(filter(
                    lambda e: e.text == pen_name, self._pen_labels.find_all(wait_timeout=1)))[0]
                IASelenium(driver=self.driver).hover_over_web_element(web_element=pen_label_web_element)
                self._delete_pen_icon.set_locator(new_locator=(By.CSS_SELECTOR, "div.tr.hovering svg.delete-pen-icon"))
                self._delete_pen_icon.click()
                self._confirm_deletion_button.click()
            except IndexError:
                # no pen with name in controls
                pass
            IAAssert.does_not_contain(
                iterable=self.get_all_pen_names_from_pen_table(),
                expected_value=pen_name,
                failure_msg=f"The '{pen_name}' pen was still present in the pen control table after we attempted to "
                            f"remove it.")

        def set_pen_visibility_on_pen_table(self, pen_name: str, should_be_visible: bool) -> None:
            """
            Set the display state (visibility) of a pen via the pen control table by interacting with the associated
            checkbox.

            :param pen_name: The name of the pen which you would like to set the display state of.
            :param should_be_visible: If True, the checkbox will be set to active/selected/checked - otherwise the
                checkbox will be set to inactive/unselected/un-checked.

            :raises AssertionError: If unsuccessful in setting the display state of the specified checkbox.
            :raises TimeoutException: if no pen with the supplied name is present, or if the pen control table is not
                present.
            """
            if self.pen_checkbox_is_active(pen_name=pen_name) != should_be_visible:
                self.click_pen_checkbox_by_name(pen_name=pen_name)
            IAAssert.is_equal_to(
                actual_value=self.pen_checkbox_is_active(pen_name=pen_name),
                expected_value=should_be_visible,
                failure_msg=f"Failed to set the display state (visibility) of the '{pen_name}' pen "
                            f"to {should_be_visible}.")

        def toggle_pen_table_display(self) -> None:
            """
            Toggle the pen control table expansion state.

            :raises TimeoutException: If the expand/collapse toggle icon is not present.
            """
            try:
                self._collapse_icon.click()
                self._expand_icon.find(wait_timeout=1)
            except TimeoutException:
                try:
                    self._expand_icon.click()
                    self._collapse_icon.find(wait_timeout=1)
                except TimeoutException as toe:
                    raise TimeoutException(
                        msg=f'Pen table collapse/expand icons could not be located for interaction.') from toe

    class _PowerChartTagBrowser(CommonTagBrowseTree):
        """
        The Tag Browse Tree built into the Power Chart.
        """
        _ADD_TAG_BUTTON_LOCATOR = (By.CSS_SELECTOR, '.ia_button--primary')
        _CLOSE_ICON_LOCATOR = (By.ID, 'ia_powerChartComponent__tagBrowserContainer__closeIcon')
        _TAG_BROWSER_LOCATOR = (By.CSS_SELECTOR, 'div.ia_powerChartComponent__tagBrowserContainer')
        _TAG_BROWSER_OPEN_ICON_LOCATOR = (By.CSS_SELECTOR, 'span.show-tag-browser-icon')
        _RELOAD_ICON_LOCATOR = (By.ID, 'ia_powerChartComponent__tagBrowserContainer__reloadIcon')

        def __init__(
                self,
                parent_locator_list: List[Tuple[By, str]],
                driver: WebDriver,
                poll_freq: float = 0.5):
            super().__init__(
                locator=self._TAG_BROWSER_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                description="The Tag Browse Tree of the Power Chart.",
                poll_freq=poll_freq)
            self._add_tag_button = CommonButton(
                locator=self._ADD_TAG_BUTTON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The 'Add Selected Tags' button at the bottom of the Tag Browse Tree.",
                poll_freq=poll_freq)
            self._close_icon = ComponentPiece(
                locator=self._CLOSE_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The arrow pointing left - and located in the upper-right of the Tag Browse Tree - which "
                            "collapses the Tag Browse Tree of the Power Chart.",
                poll_freq=poll_freq)
            self._reload_icon = ComponentPiece(
                locator=self._RELOAD_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                description="The reload/refresh icon located in the upper-right of the Tag Browse Tree of the Power "
                            "Chart.",
                poll_freq=poll_freq)

        def add_tags_to_chart(self, tag_paths: [str]) -> None:
            """
            Add tags to the Power Chart. Requires the Tag Browse Tree already be expanded. If all specified tags are
            already graphed, no action is taken, and the Tag Browse Tree will remain expanded.

            :param tag_paths: A list of tag paths which describe the tags to add to the Power Chart graph as pens.

            :raises TimeoutException: If any of the supplied paths does not map to a tag, or if the Tag Browse Tree is
                not present/expanded.
            """
            for tag_path in tag_paths:
                tag_name = self._split_item_label_path(tag_path)[-1]
                self._expand_tag_browser_tree_by_path(path=tag_path)
                try:
                    WebDriverWait(driver=self.driver, timeout=5).until(
                        IAec.function_returns_true(
                            custom_function=self._select_tag_to_be_added_later,
                            function_args={"tag_name": tag_name}))
                except TimeoutException as toe:
                    raise TimeoutException(msg=f"No tag found with a name of '{tag_name}'.") from toe
                # wait for Apply button to become enabled
                WebDriverWait(driver=self.driver, timeout=1).until(IAec.function_returns_true(
                    custom_function=self._add_tag_button.is_enabled, function_args={}))
                if self._add_tag_button.is_enabled():
                    # tag might already be displayed in the chart, so we might not need to add it
                    self._add_tag_button.click(binding_wait_time=1)

        def click_reload_icon(self) -> None:
            """
            Click the reload icon of the Tag Browse Tree.

            :raises TimeoutException: If the Tag Browse Tree is not expanded in the Power Chart.
            """
            self._reload_icon.click(binding_wait_time=1)

        def collapse_if_expanded(self) -> None:
            """
            Collapse the Tag Browse Tree if it is currently expanded. If the Tag Browse Tree is already collapsed, then
            no action is taken.
            """
            if self.tag_browser_is_expanded():
                self._close_icon.click()

        def is_displayed(self) -> bool:
            """
            Determine if the Tag Browse Tree is displayed.

            :returns: True, if the Tag Browse Tree is currently displayed - False otherwise.
            """
            return self.tag_browser_is_expanded()

        def is_in_responsive_mode(self) -> bool:
            """
            Determine if the Tag Browse Tree is in responsive mode.

            :returns: True, if the Tag Browse Tree is currently rendering in responsive mode.
            """
            return "mobile" in self.find().get_attribute(name="class")

        def tag_browser_is_expanded(self) -> bool:
            """
            Determine if the Tag Browse Tree is expanded.

            :returns: True, if the tag Browse Tree is currently expanded - False otherwise.
            """
            try:
                return 'expanded' in self.find(wait_timeout=1).get_attribute('class')
            except TimeoutException:
                return False

        def _expand_tag_browser_tree_by_path(self, path: str) -> None:
            """
            Expand the Tag Browse Tree structure based on a supplied tag path.

            :param path: The path of a Tag as a slash-delimited string.
            """
            path_nodes = self._split_item_label_path(path)
            for i in path_nodes:
                try:
                    if self.item_label_exists_in_tree(item_label=i) and \
                            not self.item_is_expanded(item_label=i) \
                            and (path_nodes.index(i) != len(path_nodes)-1):
                        folder_index = self._get_index_of_item_in_tree(item_label=i)
                        self._folder_icons.find_all()[folder_index].click()
                        self.wait_on_binding(time_to_wait=1)
                        WebDriverWait(driver=self.driver, timeout=3, poll_frequency=self.poll_freq).until(
                            IAec.function_returns_true(
                                custom_function=self.item_is_expanded,
                                function_args={'item_label': i}))
                except TimeoutException as toe:
                    raise TimeoutException(
                        msg=f"Tag browser tree might not have expanded as expected.\nCheck that the provided "
                            f"path \'{path}\' is correct.") from toe

        def _select_tag_to_be_added_later(self, tag_name: str) -> bool:
            """
            Select a singular tag for later addition to the Power Chart.

            :param tag_name: The name of the tag to select now, for later addition to the Power Chart graph.

            :returns: True, if the supplied tag name was selected successfully - False otherwise.
            """
            try:
                self.click_item_label(item_label=tag_name)
                return True
            except TimeoutException:
                return False

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._footer = self._PowerChartFooter(
            parent_locator_list=self.locator_list, driver=driver, poll_freq=poll_freq)
        self._graph = self._PowerChartGraph(
            parent_locator_list=self.locator_list, driver=driver, poll_freq=poll_freq)
        self._header = self._PowerChartHeader(
            parent_locator_list=self.locator_list, driver=driver, poll_freq=poll_freq)
        self._pen_control_table = self._PowerChartTable(
            parent_locator_list=self.locator_list, driver=driver, poll_freq=poll_freq)
        self._settings_panel = self._PowerChartSettingsPanel(
            parent_locator_list=self.locator_list, driver=driver, poll_freq=poll_freq)
        self._tag_browser = self._PowerChartTagBrowser(
            parent_locator_list=self.locator_list, driver=driver, poll_freq=poll_freq)

    def add_tags_to_chart(self, tag_paths: [str]) -> None:
        """
        Add tags to the chart to be graphed as pens. This function will expand the Tag Browser if it is not already
        expanded, and will leave the Tag Browser expansion state as it was before this function was invoked.

        :param tag_paths: A list containing the full tag paths of each tag to add.

        :raises TimeoutException: If the Tag Browser button is not present, or if any supplied tag path does not
            identify a Tag to be added.
        """
        _original_state = self._tag_browser.tag_browser_is_expanded()
        if not _original_state:
            self._header.click_tag_browser_button()
        self._tag_browser.add_tags_to_chart(tag_paths=tag_paths)
        self.wait_on_binding(time_to_wait=2)  # wait for tags to be graphed
        if not _original_state:
            self._tag_browser.collapse_if_expanded()

    def button_is_active(self, option_bar_button: OptionBarButton) -> bool:
        """
        Determine if an Option Bar button is active (selected/in-use).

        :param option_bar_button: The Option Bar button to check the status of.

        :returns: True, if the supplied Option Bar button is active - False otherwise.
        """
        return self._header.button_is_active(option_bar_button=option_bar_button)

    def button_is_displayed(self, option_bar_button: OptionBarButton) -> bool:
        """
        Determine if an Option Bar button is displayed.

        :param option_bar_button: The Option Bar button we will check.

        :returns: True, if the supplied Option Bar button is displayed - False otherwise.
        """
        return self._header.button_is_displayed(option_bar_button=option_bar_button)

    def click_add_pen_link_in_settings_panel(self) -> None:
        """
        Click the '+ Add Pen' link. Requires that the Settings panel already be open.

        :raises TimeoutException: If the Settings panel is not already open, or if the Pens tab is not present.
        """
        self._settings_panel.click_add_pen_link()

    def click_and_drag_chart(self, x_offset: int = 0, y_offset: int = 0) -> None:
        """
        Blindly click within the Power Chart and then drag some number of pixels before releasing the click event.

        :param x_offset: The number of pixels to the right of the upper left corner to click.
        :param y_offset: The number of pixels down from the upper left corner to click.
        """
        self._graph.click_and_drag_chart(x_offset=x_offset, y_offset=y_offset)

    def click_cancel_button_during_deletion(self) -> None:
        """
        Click the 'Cancel' button during the deletion process.

        :raises TimeoutException: If the 'Cancel' button is not present.
        """
        self._settings_panel.cancel_deletion()

    def click_chart_settings_link_in_settings_panel(self) -> None:
        """
        Click the 'Chart Settings' link within the breadcrumb path at the top of the Settings panel.

        :raises TimeoutException: If the Settings panel is nt already open, or if the Chart Settings breadcrumb
            option is not present. This will occur anytime the Chart Settings tabbed area is already displayed.
        """
        self._settings_panel.click_chart_settings_breadcrumb_link()

    def click_confirm_button_during_deletion(self) -> None:
        """
        Click the 'Delete' button available during the process of deleting a pen.

        :raises TimeoutException: If the 'Delete' button is not present.
        """
        self._settings_panel.confirm_deletion()

    def click_done_button_in_settings_panel(self) -> None:
        """
        Click the Done button located in the bottom right of the Settings panel.

        :raises TimeoutException: If the 'Done' button is not present.
        """
        self._settings_panel.click_done()

    def click_normal_state_stroke_color_dropdown(self) -> None:
        """
        Click the 'Normal State' stroke color dropdown for the pen which is currently being edited.

        :raises TimeoutException: if not currently in the pen editing section of the Settings panel.
        """
        self._settings_panel.click_normal_state_stroke_color_dropdown()

    def click_option_bar_button(self, option_bar_button: OptionBarButton) -> None:
        """
        Click a button located within the Option Bar area.

        :param option_bar_button: The Option Bar button to click.

        :raises TimeoutException: if the supplied button is not present.
        """
        self._header.click_option_bar_button(option_bar_button=option_bar_button)

    def click_reload_icon_in_tag_browser(self) -> None:
        """
        Click the reload icon of the Tag Browse Tree.

        :raises TimeoutException: If the Tag Browse Tree is not expanded in the Power Chart.
        """
        self._tag_browser.click_reload_icon()

    def click_pencil_icon_for_axis(self, axis_name) -> None:
        """
        Click the pencil (edit) icon for an axis. Requires that the Settings panel already be displayed.

        :param axis_name: The name of the axis to begin editing.

        :raises TimeoutException: If no row matching the supplied axis name is present, or if the Settings panel is not
            displayed.
        """
        self._settings_panel.click_pencil_icon_for_axis(axis_name=axis_name)

    def click_point_in_color_picker(self) -> None:
        """
        Click the color picker displayed while editing any of the color settings for a pen. Requires that the
        picker already be visible as a result of clicking any of the available color dropdown items. Note that
        Selenium does not properly handle offsets for the color picker modal, so this function has limited use.

        :raises TimeoutException: If the color picker is not present.
        """
        self._settings_panel.click_point_in_color_picker()

    def click_tab_of_settings_panel(self, tab: Tab) -> None:
        """
        Click a tab of the Settings panel. Requires that the Settings panel already be open.

        :param tab: The tab to click.

        :raises TimeoutException: If the supplied tab is not present.
        """
        self._settings_panel.click_tab(tab=tab)

    def click_tag_browser_button(self) -> None:
        """
        Click the button which expands or collapses the Tag Browser panel.

        :raises TimeoutException: If the Tag Browse button is not present.
        """
        self._header.click_tag_browser_button()

    def close_settings_panel(self) -> None:
        """
        Close the Settings panel. Takes no action if the Settings panel is already closed.

        :raises AssertionError: If unsuccessful in closing the Settings panel.
        """
        self._settings_panel.close_settings_panel()

    def collapse_tag_browser_if_expanded(self) -> None:
        """
        Collapse the Tag Browse Tree if it is currently expanded. If the Tag Browse Tree is already collapsed, then
        no action is taken.
        """
        self._tag_browser.collapse_if_expanded()

    def date_range_selector_is_displayed(self) -> bool:
        """
        Determine if the Date Range selector is displayed.

        :returns: True, if the date range selector is currently displayed - False otherwise.
        """
        return self._header.date_range_selector_expansion_icon_is_displayed()

    def click_trash_can_icon_for_pen_in_settings_panel(self, pen_name: str) -> None:
        """
        Click the trash can icon for a specific pen. Note that this only begins the deletion process, and that there
        are further actions to take after this.

        :param pen_name: The name of the pen you would like to begin the deletion process for.

        :raises TimeoutException: If the trash can icon is not present. This might be due to no pen with the
            supplied name being present.
        """
        self._settings_panel.click_trash_can_icon_for_pen(pen_name=pen_name)

    def click_pencil_icon_for_pen_in_pen_control_table(self, pen_name: str) -> None:
        """
        Click the pencil icon for a pen in the pen control table.

        :param pen_name: The name of the pen for which you would like to click the accompanying pencil icon.

        :raises IndexError: If no pen with the supplied name could be found.
        """
        self._pen_control_table.click_pencil_icon_for_pen(pen_name=pen_name)

    def click_pencil_icon_for_pen_in_settings_panel(self, pen_name: str) -> None:
        """
        Click the pencil icon for a specific pen. Note that this only begins the editing process, and that there
        are further actions to take after this.

        :param pen_name: The name of the pen you would like to begin the editing process for.

        :raises TimeoutException: If the pencil icon is not present. This might be due to no pen with the
            supplied name being present.
        """
        self._settings_panel.click_pencil_icon_for_pen(pen_name=pen_name)

    def get_all_pen_names_from_pen_control_table(self) -> List[str]:
        """
        Obtain the names of all pens displayed in the pen control table.

        :returns: A list which contains the name of every pen displayed in the pen control table.

        :raises TimeoutException: If the pen control table is not present.
        """
        return self._pen_control_table.get_all_pen_names_from_pen_table()

    def get_column_headers_from_pen_control_table(self) -> List[str]:
        """
        Obtain the headers of the columns displayed in the pen control table. Ignores the selection column, as it
        contains no header text.

        :returns: A list which contains the header of every column currently displayed in the pen control table.

        :raises TimeoutException: If no pen control table is present.
        """
        return self._pen_control_table.get_column_headers_from_pen_table()

    def get_count_of_pen_fault_overlays(self) -> int:
        """
        Obtain a count of how many pens are currently displaying fault overlays.

        :returns: A count of how many pens are currently displaying fault overlays.
        """
        return self._pen_control_table.get_count_of_pen_fault_overlays()

    def get_count_of_pens_from_pen_control_table(self) -> int:
        """
        Obtain a count of all pens listed in the pen control table.

        :returns: A count of every pen displayed in the pen control table.

        :raises TimeoutException: If the pen control table is not present.
        """
        return self._pen_control_table.get_count_of_available_pens_from_pen_table()

    def get_count_of_visible_line_charts(self) -> int:
        """
        Obtain a count of pens which are displayed in the chart.

        :returns: A count of pens currently rendered in the chart.
        """
        return self._graph.get_count_of_graphed_pens()

    def get_css_property_value_from_title_in_header(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of a CSS property from the title of the header.

        :param property_name: The name of the CSS property you would like the value of.

        :returns: The value of the supplied CSS property of the title of the header of the Power Chart.

        :raises TimeoutException: If the title is not present.
        """
        return self._header.get_css_property_value_from_title(property_name=property_name)

    def get_css_property_value_from_x_axis_grid(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of a CSS property from the grid of the X axis.

        :param property_name: The name of the property you want to value of.

        :returns: The value of the requested CSS property from the X axis grid.

        :raises TimeoutException: If no X axis grid is present.
        """
        return self._graph.get_css_property_from_x_axis_grid(property_name=property_name)

    def get_css_property_value_from_y_axis_grid(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of a CSS property from the grid of the Y axis.

        :param property_name: The name of the property you want to value of.

        :returns: The value of the requested CSS property from the Y axis grid.

        :raises TimeoutException: If no Y axis grid is present.
        """
        return self._graph.get_css_property_from_y_axis_grid(property_name=property_name)

    def get_css_property_value_from_y_axis(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain a CSS property value from the Y axis.

        :param property_name: The name of the CSS property to retrieve the value of.

        :returns: The value of the requested CSS property from the Y axis.

        :raises TimeoutException: If no Y axis is present.
        """
        return self._graph.get_y_axis_vertical_bar_css_property(property_name=property_name)

    def get_currently_selected_color_from_color_picker(self) -> str:
        """
        Obtain the color in use for whichever setting is currently being modified, as a string. Note that as this
        value is coming from a prop it will always be hex. Requires that the Settings panel be displayed, and that a pen
        already be in an editing state, and that a color dropdown has already been clicked/activated.

        :returns: The color currently applied as a hex string.

        :raises TimeoutException: If the color picker and its accompanying inputs are not present.
        """
        return self._settings_panel.get_current_color_from_picker()

    def get_displayed_tab_names_from_settings_panel(self) -> List[str]:
        """
        Obtain the name sof all tabs listed in the Settings panel. Requires that the Settings panel already be
        displayed.

        :returns: A list which contains the names of all tabs displayed in the Settings panel, or na empty list if the
            Settings panel is not displayed.
        """
        return self._settings_panel.get_displayed_tab_names() if self.settings_panel_is_displayed() else []

    def get_graph_path(self, pen_name: str) -> str:
        """
        Obtain the drawn path of a graphed pen. Useful for determining when a graphed pen has updated.

        :param pen_name: The name of the pen for which you would like the path of the graphed line.

        :returns: A string which represents the actual graphed line of a pen.

        :raises IndexError: If unable to link the supplied pen name to a graphed line by the color of the pen's
            checkbox.
        :raises TimeoutException: If no pen with the supplied name is present in the pen control table, or if no pens
            are graphed at all.
        """
        return self._graph.get_drawn_path(pen_checkbox_color_as_string=self.get_pen_checkbox_color(pen_name=pen_name))

    def get_historical_range(self) -> HistoricalRange:
        """
        Obtain the applied historical range as an object. Expands the range selector if not already open, and then
        closes the range selector if it was not open to start with. Returns a HistoricalRange object, even if the Power
        Chart is using a realtime range.

        :returns: The historical range as a HistoricalRange object - even if the Power Chart is using a realtime range.
        """
        return self._header.get_historical_range()

    def get_html_class_from_title_in_header(self) -> str:
        """
        Obtain the html class attribute for the title of the header.

        :returns: The HTML class attribute for the title of the header of the Power Chart.

        :raises TimeoutException: If the title is not present.
        """
        return self._header.get_title_html_class()

    def get_html_class_from_x_axis(self) -> str:
        """
        Obtain the HTML class of the X axis.

        :returns: The class of the X axis as it exists in the DOM.

        :raises TimeoutException: If no X axis is present.
        """
        return self._graph.get_x_axis_html_class()

    def get_html_class_from_y_axis(self) -> str:
        """
        Obtain the HTML class of the Y axis.

        :returns: The class of the Y axis as it exists in the DOM.

        :raises TimeoutException: If no Y axis is present.
        """
        return self._graph.get_y_axis_html_class()

    def get_pen_checkbox_color(self, pen_name: str) -> str:
        """
        Obtain the color in use for a given pen. This color will always be the same as the line which is graphed.

        :param pen_name: The name of the checkbox for which you would like the color of the accompanying checkbox.

        :returns: The color in use by the checkbox of a pen.

        :raises TimeoutException: If no pen with the supplied name is present in the pen control table, or if the
            pen control table is not present.
        """
        return self._pen_control_table.get_pen_checkbox_color(pen_name=pen_name)

    def get_pen_name_from_settings_panel(self) -> str:
        """
        Obtain the name of the pen as displayed in the Settings panel.

        :returns: The text contents of the name field within the Settings panel.

        :raises TimeoutException: If the Settings panel is not present.
        """
        return self._settings_panel.get_pen_name()

    def get_realtime_range_time_units(self) -> str:
        """
        Obtain the unit of time in use by the realtime range. Only works if the Power Cart is currently using
        a realtime range.

        :returns: The unit of time in use by the realtime range.

        :raises TimeoutException: If the Power Chart has configured the range selector piece to be invisible.
        """
        return self._header.get_date_range_message().split(" ")[-1]

    def get_realtime_range_scalar_time_value(self) -> int:
        """
        Obtain the scalar amount of time in use by the realtime range. Only works if the Power Cart is currently using
        a realtime range.

        :returns: The scalar time value in use by the realtime range.

        :raises TimeoutException: If the Power Chart has configured the range selector piece to be invisible.
        """
        return int(self._header.get_date_range_message().split(" ")[1])

    def get_text_of_top_level_nodes(self) -> List[str]:
        """
        Obtain the text of all items located at the top-most level of the Tag Browser.
        """
        return self._tag_browser.get_text_of_top_level_items()

    def get_time_axis_labels(self) -> List[str]:
        """
        Obtain the labels displayed on the X (time) axis.

        :returns: A list which contains all currently displayed labels from the X (time) axis.

        :raises TimeoutException: If no labels are displayed on the X (time) axis.
        """
        return self._graph.get_displayed_time_axis_labels()

    def get_time_range_selector_message(self) -> str:
        """
        Obtain the range message associated with the Date Range Selector. This value describes the applied range.
        Example: 'Last 8 hours', or a breakdown of the date/times used for the Historical range.

        :returns: The range message associated with the Date Range Selector, if available.

        :raises TimeoutException: If the Power Chart has made the Date Range Selector invisible.
        """
        return self._header.get_date_range_message()

    def get_x_axis_style_class_property_value(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of a CSS property from the X axis.

        :param property_name: The name of the CSS property you would like the value of.

        :returns: The value of the requested CSS property.

        :raises TimeoutException: If no labels are present on the X (time) axis.
        """
        return self._graph.get_x_axis_css_property_value(property_name=property_name)

    def get_y_axis_label_style_class_property_value(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of a CSS property from the Y axis.

        :param property_name: The name of the CSS property you would like the value of.

        :returns: The value of the requested CSS property.

        :raises TimeoutException: If no labels are present on the Y axis.
        """
        return self._graph.get_y_axis_label_css_property_value(property_name=property_name)

    def hover_over_option_bar_button(self, option_bar_button: OptionBarButton) -> None:
        """
        Hover over a button of the Option Bar.

        :param option_bar_button: The Option Bar button to hover over.

        :raises TimeoutException: If the supplied Option Bar button is not present.
        """
        self._header.hover_over_option_bar_button(option_bar_button=option_bar_button)

    def is_displaying_in_full_screen(self) -> bool:
        """
        Determine if the Power Chart is rendering in a full-screen layout.

        :returns: True, if the Power Chart is currently rendering in a manner which covers the entire viewport - False
            otherwise.

        :raises TimeoutException: If the full-screen button is not present.
        """
        return self._header.button_is_active(
            option_bar_button=OptionBarButton.FULL_SCREEN)

    def is_in_responsive_mode(self) -> bool:
        """
        Determine if the Power Chart is rendering in a mobile-friendly layout.

        :returns: True, if the Tag Browser, Header, and Footer are all reporting as rendering in a mobile-friendly
            layout - False if any of those pieces is reporting otherwise.

        :raises TimeoutException: If the Tag Browser, Header, or Footer are not present.
        """
        return self._tag_browser.is_in_responsive_mode() \
            and self._header.is_in_responsive_mode() \
            and self._footer.is_in_responsive_mode()

    def line_is_displayed_by_color(self, pen_checkbox_color_as_str: str) -> bool:
        """
        Determine if a line is graphed.

        :param pen_checkbox_color_as_str: The color to look for in the graph. This value MUST be supplied in the
            same form as the browser in use would return a color value. It is recommended to avoid supplying
            hard-coded colors and instead supply values retrieved from the browser. Look into use
            of :func:`get_pen_checkbox_color` in order to obtain a color.

        :returns: True, if any graphed line uses the supplied color - False otherwise.
        """
        return self._graph.line_is_displayed_by_color(pen_checkbox_color_as_str=pen_checkbox_color_as_str)

    def line_is_displayed_by_name(self, pen_name: str) -> bool:
        """
        Determine if a line is graphed.

        :param pen_name: The name of the pen to look for in the graph.

        :returns: True, if the supplied pen name has a graphed line - False otherwise.
        """
        try:
            return self._graph.line_is_displayed_by_color(
                pen_checkbox_color_as_str=self._pen_control_table.get_pen_checkbox_color(pen_name=pen_name))
        except TimeoutException:
            return False

    def pen_checkbox_is_active(self, pen_name: str) -> bool:
        """
        Determine if the checkbox of a pen is active (selected and/or checked are synonymous with active).

        :returns: True, if the checkbox for the specified pen is active/selected/checked.

        :raises TimeoutException: If no pen with the supplied name is present in the pen control table, or if the
            pen control table is not present.
        """
        return self._pen_control_table.pen_checkbox_is_active(pen_name=pen_name)

    def pen_control_column_is_displayed(self, pen_control_column: PenControlColumn) -> bool:
        """
        Determine if a pen control column is displayed.

        :param pen_control_column: The pen control column or range brush column to check for the presence of.

        :returns: True, if the supplied column is displayed - False otherwise.
        """
        return self._pen_control_table.pen_control_column_is_displayed(pen_control_column=pen_control_column)

    def pen_settings_are_displayed_in_settings_panel(self) -> bool:
        """
        Determine if the Pen settings panel is displayed.

        :returns: True, if the inputs used to edit a pen are currently displayed - False otherwise.
        """
        return self._settings_panel.pen_settings_are_displayed()

    def pen_table_is_visible(self) -> bool:
        """
        Determine if the pen control table is expanded.

        :returns: True, if the pen control table is currently expanded - False otherwise.
        """
        return self._pen_control_table.pen_table_is_expanded()

    def range_brush_column_is_displayed(self, range_brush_column: RangeBrushColumn) -> bool:
        """
        Determine if a range brush column is displayed in the pen control table.

        :param range_brush_column: The range brush column for which we will check the display state.

        :returns: True, if the supplied range brush column is displayed - False otherwise.
        """
        return self._pen_control_table.range_brush_column_is_displayed(range_brush_column=range_brush_column)

    def remove_pen_by_name(self, pen_name: str) -> None:
        """
        Remove a pen from the pen control table (and therefore the Power Chart) via the pen control table.

        :param pen_name: The name of the pen you would like to remove.

        :raises AssertionError: If unsuccessful in removing the supplied pen from the Power Chart.
        :raises TimeoutException: If the pen control table is not present.
        """
        self._pen_control_table.remove_pen_by_name(pen_name=pen_name)

    def select_historical_range(self, historical_range: HistoricalRange) -> None:
        """
        Apply the values of a HistoricalRange object to the Power Chart. This convenience function handles expansion,
        application, and confirmation of the supplied values.

        :param historical_range: A HistoricalRange object which contains all of the information about the range to be
            selected.

        :raises AssertionError: If unsuccessful in applying the provided range.
        """
        self._header.open_date_range_selector_if_not_displayed()
        self._header.select_date_range_selector_tab(tab=DateRangeSelectorTab.HISTORICAL)
        self._header.set_historical_month(month=historical_range.beginning_month)
        self._header.set_historical_year(year=historical_range.beginning_year)
        self._header.set_historical_day(numeric_day=historical_range.beginning_day)
        self._header.set_historical_month(month=historical_range.ending_month)
        self._header.set_historical_year(year=historical_range.ending_year)
        self._header.set_historical_day(numeric_day=historical_range.ending_day)
        self._header.apply_changes()

    def select_realtime_range(self, time_value: int, time_unit: DateRangeSelectorTimeUnit) -> None:
        """
        Select the scalar value of time and the time unit to use for the realtime range of the Power Chart.

        :param time_value: The scalar amount of time to apply.
        :param time_unit: The unit of time to apply.

        :raises TimeoutException: If the range selector is not present.
        """
        self._header.open_date_range_selector_if_not_displayed()
        self._header.select_date_range_selector_tab(tab=DateRangeSelectorTab.REALTIME)
        self._header.set_realtime_numeric_value(time_value=time_value)
        self._header.select_realtime_unit(time_unit=time_unit)
        self._header.apply_changes()

    def set_display_state_for_pen_in_settings_panel(self, pen_name: str, should_be_displayed: bool) -> None:
        """
        Set the display state of a pen listed in the Pens tab of the Settings panel. Requires the Settings panel
        already be displayed, and that the pen tab be displayed.

        :param pen_name: The name of the pen which will have its state set.
        :param should_be_displayed: If True, the pen will be set to display - otherwise, the pen will be set to not
            display.

        :raises TimeoutException: If the Settings panel is not already displayed.
        """
        self._settings_panel.set_display_checkbox_state_by_pen_name(
            pen_name=pen_name, should_be_checked=should_be_displayed)

    def set_grid_visible_checkbox_state_in_axis_settings(self, should_be_checked: bool) -> None:
        """
        Set the display state of the grid for the axis currently being edited.

        :param should_be_checked: If True, the checkbox will be set to active/checked - otherwise the checkbox will
            be set to inactive/un-checked.

        :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
            edited.
        """
        self._settings_panel.set_grid_visible_checkbox_state(should_be_checked=should_be_checked)

    def set_grid_y_axis_color_in_axis_settings(self, hex_desired_color: str) -> None:
        """
        Set the Y-axis color to be used by the grid for the axis currently being edited.

        :param hex_desired_color: The color (in hex format) to use for the Y-axis of the grid.

        :raises AssertionError: If unsuccessful in modifying the color in use by the Y-axis of the grid.
        :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
            edited.
        """
        self._settings_panel.set_grid_y_axis_color(hex_desired_color=hex_desired_color)

    def set_grid_y_axis_opacity_in_axis_settings(self, value: Union[float, str]) -> None:
        """
        Set the Y-axis opacity to be used by the grid for the axis currently being edited.

        :param value: The desired value (0<=value<=1) to use for the opacity of the Y-axis of the grid.

        :raises AssertionError: If unsuccessful in modifying the opacity in use by the Y-axis of the grid.
        :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
            edited.
        """
        self._settings_panel.set_grid_y_axis_opacity(value=value)

    def set_grid_y_axis_dasharray_in_axis_settings(self, value: str) -> None:
        """
        Set the Y-axis dash-array to be used by the grid for the axis currently being edited.

        :param value: The desired value to use for the dash-array of the Y-axis of the grid.

        :raises AssertionError: If unsuccessful in modifying the dash-array in use by the Y-axis of the grid.
        :raises TimeoutException: If the Settings panel is not already displayed, or an axis is not already being
            edited.
        """
        self._settings_panel.set_grid_y_axis_dash_array(value=value)

    def set_historical_range_and_apply(self, historical_range: HistoricalRange) -> None:
        """
        Open the date range selector, select values for a historical range, and then apply those changes.

        :param historical_range: The historical range settings to apply, within a HistoricalRange object.

        :raises AssertionError: If unsuccessful in applying the provided historical range.
        :raises TimeoutException: If the date range capabilities of the Power Chart are disabled.
        """
        self._header.set_historical_range_and_apply(historical_range=historical_range)

    def set_pen_control_column_display_state_in_settings_panel(
            self, pen_control_column: PenControlColumn, should_be_displayed: bool) -> None:
        """
        Set the display state of a column within the pen control table. Requires the Settings panel already be
        displayed.

        :param pen_control_column: The column which will have its state set.
        :param should_be_displayed: If True, the column will be set to display - otherwise the column will be set to
            not be displayed.

        :raises TimeoutException: If the Settings panel is not already displayed.
        """
        self._settings_panel.set_pen_control_column_display_state_in_settings_panel(
            pen_control_column=pen_control_column, should_be_displayed=should_be_displayed)

    def set_pen_enabled_state_in_settings_panel(self, should_be_enabled: bool) -> None:
        """
        Set the enabled state of a checkbox. The wording in the Power Chart is actually to "hide" the pen from the
        chart, so we reverse that logic inside this function. Requires that a pen already be in an "editing" state.

        :param should_be_enabled: If True, the pen will NOT be hidden - otherwise we will set the pen to be hidden.

        :raises TimeoutException: If the Settings panel is not already visible, or if a pen is not already being
            edited.
        """
        self._settings_panel.set_pen_enabled_state_in_settings_panel(should_be_enabled=should_be_enabled)

    def set_pen_name_while_editing(self, updated_pen_name: str) -> None:
        """
        Set the name of a pen during editing. Requires the Settings panel be displayed and a pen already be in editing
        mode.

        :param updated_pen_name: The desired pen name.

        :raises AssertionError: If unsuccessful in editing the name of the pen.
        :raises TimeoutException: If the Settings panel is not already visible, or if a pen is not already being
            edited.
        """
        self._settings_panel.set_pen_name_field(updated_pen_name=updated_pen_name)

    def set_pen_path_while_editing(self, updated_pen_path: str) -> None:
        """
        Set the path of a pen during editing.

        :param updated_pen_path: The desired pen path to use.

        :raises AssertionError: If unsuccessful in editing the path of the pen.
        :raises TimeoutException: If the Settings panel is not already visible, or if a pen is not already being
            edited.
        """
        self._settings_panel.set_pen_path_field(updated_pen_path=updated_pen_path)

    def set_pen_visibility_in_settings_panel(self, should_be_displayed: bool) -> None:
        """
        Set the display state of a pen while the pen is being edited. This function expects that a pen is already
        being edited in the Settings panel.

        :param should_be_displayed: If True, the pen will be set to display - otherwise, the pen will be set to not
            display.

        :raises TimeoutException: If the Settings panel is not already displayed, or if we are not already editing
            a pen.
        """
        self._settings_panel.set_pen_display_state_in_settings_panel(should_be_checked=should_be_displayed)

    def set_pen_visibility_on_pen_table(self, pen_name: str, should_be_visible: bool) -> None:
        """
        Set the display state (visibility) of a pen via the pen control table by interacting with the associated
        checkbox.

        :param pen_name: The name of the pen which you would like to set the display state of.
        :param should_be_visible: If True, the checkbox will be set to active/selected/checked - otherwise the
            checkbox will be set to inactive/unselected/un-checked.

        :raises AssertionError: If unsuccessful in setting the display state of the specified checkbox.
        :raises TimeoutException: if no pen with the supplied name is present, or if the pen control table is not
            present.
        """
        self._pen_control_table.set_pen_visibility_on_pen_table(pen_name=pen_name, should_be_visible=should_be_visible)

    def set_range_brush_basic(self) -> None:
        """
        Applies the range brush to the graph via a short drag event.
        """
        self._graph.set_range_brush_basic()

    def set_range_brush_column_display_state(
            self, range_brush_column: RangeBrushColumn, should_be_displayed: bool) -> None:
        """
        Set the display state of a column within the range brush table. Requires the Settings panel already be
        displayed.

        :param range_brush_column: The column which will have its state set.
        :param should_be_displayed: If True, the column will be set to display - otherwise the column will be set to
            not be displayed.

        :raises TimeoutException: If the Settings panel is not already displayed.
        """
        self._settings_panel.set_range_brush_column_display_state_in_settings_panel(
            range_brush_column=range_brush_column, should_be_displayed=should_be_displayed)

    def settings_panel_is_displayed(self) -> bool:
        """
        Determine if the Settings panel is displayed.

        :returns: True, if the Settings panel is currently open/displayed/expanded - False otherwise.
        """
        return self._settings_panel.is_displayed()

    def tag_browser_button_is_displayed(self) -> bool:
        """
        Determine if the Tag Browser button is displayed.

        :returns: True if the Tag Browser button is currently displayed - False otherwise.
        """
        return self._header.tag_browser_button_is_displayed()

    def tag_browser_panel_is_displayed(self) -> bool:
        """
        Determine if the Tag Browse Tree is displayed.

        :returns: True, if the Tag Browse Tree is currently displayed - False otherwise.
        """
        return self._tag_browser.is_displayed()

    def toggle_pen_table_display(self) -> None:
        """
        Toggle the pen control table expansion state.

        :raises TimeoutException: If the expand/collapse toggle icon is not present.
        """
        self._pen_control_table.toggle_pen_table_display()

    def x_axis_grid_is_displayed(self) -> bool:
        """
        Determine if grid lines are displayed for the X axis.

        :returns: True, if grid lines are found for the X axis - False otherwise.
        """
        return self._graph.get_count_of_x_axis_grid_lines() > 0

    def y_axis_grid_is_displayed(self) -> bool:
        """
        Determine if grid lines are displayed for the Y axis.

        :returns: True, if grid lines are found for the Y axis - False otherwise.
        """
        return self._graph.get_count_of_y_axis_grid_lines() > 0

Components/PerspectiveComponents/Charts/PowerChart.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class TimeSeriesChart(BasicPerspectiveComponent):
    """A Perspective Time Series Chart."""
    _DATE_TIME_CONTAINER_LOCATOR = (By.CSS_SELECTOR, ".date-time-container:nth-of-type(1)")
    _DATE_CONTAINER_LOCATOR = (By.CSS_SELECTOR, ".date-container")
    _TIME_CONTAINER_LOCATOR = (By.CSS_SELECTOR, ".time-container")
    _TIME_AXIS_LABEL_LOCATOR = (By.CSS_SELECTOR, "text.ia_timeSeriesChartComponent__timeAxis__values")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._date_time_container = ComponentPiece(
            locator=self._DATE_TIME_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The container for the date/time labels.",
            poll_freq=poll_freq)
        self._date_container = ComponentPiece(
            locator=self._DATE_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self._date_time_container.locator_list,
            wait_timeout=1,
            description="The container which contains only the date information.",
            poll_freq=poll_freq)
        self._time_container = ComponentPiece(
            locator=self._TIME_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self._date_time_container.locator_list,
            wait_timeout=1,
            description="The container which contains only the time information.",
            poll_freq=poll_freq)
        self._time_axis_label = ComponentPiece(
            locator=self._TIME_AXIS_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            description="The label which contains the text of the time axis of the Time Series Chart.",
            poll_freq=poll_freq)

    def get_date_range_text(self) -> str:
        """
        Obtain the text which describes the date range of the graphed data.

        :returns: The text visible to a user which describes the date range of the graphed data.

        :raises TimeoutException: If the date is not present.
        """
        return self._date_container.get_text()

    def get_text_of_last_time_meridian(self) -> str:
        """
        Obtain the text of the last displayed time meridian (am/pm) of the chart.

        :returns: The text of the last displayed time meridian.

        :raises TimeoutException: If the time range is not present.
        """
        return self._time_container.get_text().split(" ")[-1]

    def get_time_axis_label(self, zero_based_index: int) -> str:
        """
        Obtain the text of a time axis label as it is displayed to the user.
        
        :param zero_based_index: The zero-based index of the label to obtain.
        
        :returns: The text of a time axis (x axis) label.

        :raises IndexError: If the supplied index is invalid based o the number of labels displayed.
        :raises TimeoutException: If no time axis labels are present.
        """
        return self._time_axis_label.find_all()[zero_based_index].text

Components/PerspectiveComponents/Charts/TimeSeriesChart.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Optional, Union, Tuple, List

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.IASelenium import IASelenium


class RenderType(Enum):
    """An enumeration of available render types for the Perspective XY Chart Component."""
    CANDLESTICK = "candlestick"
    COLUMN = "column"
    LINE = "line"
    STEP_LINE = "step line"


class XYChart(BasicPerspectiveComponent):
    """A Perspective XY Chart Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._bar_coll = {}
        self._series_coll = {}

    def click(self, wait_timeout: Optional[Union[int, float]] = None, binding_wait_time: float = 0) -> None:
        """
        Please do not blindly click the XY Chart; no good will come of it.

        :raises NotImplementedError: Because you shouldn't do it.
        """
        raise NotImplementedError("Please don't blindly click the XYChart.")

    def click_series_label_in_legend_by_text(self, label_text: str, binding_wait_time: float = 0) -> None:
        """
        Click the label of a series within the legend of the XY Chart.

        :param label_text: The text of the label to click. This should be the name of the series.
        :param binding_wait_time: How long to wait after the click before allowing code to continue.

        :raises TimeoutException: If no label with the provided text is present.
        :raises NoSuchElementException: If unable to locate the click target of the label.
        """
        self._get_series_label_from_legend(label_text=label_text).find_element(*(By.TAG_NAME, "rect")).click()
        self.wait_on_binding(time_to_wait=binding_wait_time)

    def get_count_of_bars_in_series(self, series_label: str) -> int:
        """
        Obtain a count of bars displayed for a given series.

        :param series_label: The name of the series for which we will obtain a count of bars.

        :returns: The count of displayed bars for the provided series.
        """
        try:
            return len(self._get_paths_for_series(series_label=series_label).find_all())
        except TimeoutException:
            return 0

    def hover_over_bar_of_series(self, bar_index: int, series_label: str) -> None:
        """
        Hover over a specific bar for a given series.

        :param bar_index: The zero-based index of the bar you would like to hover over.
        :param series_label: The name of the series to target.

        :raises IndexError: If the supplied index is invalid based on the number of displayed bars.
        :raises TimeoutException: If no bars exist for the provided series.
        """
        IASelenium(driver=self.driver).hover_over_web_element(
            web_element=self._get_paths_for_series(series_label=series_label).find_all()[bar_index])

    def series_label_is_displayed_in_legend(self, label_text: str) -> bool:
        """
        Determine if a series is displayed in the legend of the XY Chart.

        :param label_text: The name of the series.

        :returns: True, if the provided series is listed in the legend - False otherwise.
        """
        try:
            return self._get_series_label_from_legend(label_text=label_text).is_displayed()
        except IndexError:
            return False

    def series_is_displayed_in_chart_body(self, series_name: str) -> bool:
        """
        Determine if a series is displayed in the body (graph) of the XY Chart.

        :param series_name: The name of the series to look for in the body of the XY Chart.

        :returns: True, if the provided series is graphed in some manner - False otherwise.
        """
        return self._get_top_level_series_element(series_label=series_name).find().is_displayed()

    def _get_series_label_from_legend(self, label_text: str) -> WebElement:
        """
        Obtain the WebElement which is the label for a series in the legend of the XY Chart.

        :param label_text: The text of the label to query for.

        :returns: A WebElement which is the label of the provided series within the legend of the XY Chart.

        :raises IndexError: If no label with the provided text is present.
        :raises NoSuchElementException: If no labels are found at all.
        """
        return list(
            filter(
                lambda e: e.text == label_text,
                self.driver.find_elements(
                    *ComponentPiece(
                        locator=(By.CSS_SELECTOR, 'g[focusable="true"]'),
                        driver=self.driver,
                        parent_locator_list=self.locator_list).get_full_css_locator())))[0]

    def _get_paths_for_series(self, series_label: str) -> ComponentPiece:
        """
        Obtain the Component Piece which describes the paths for a given series.

        :param series_label: The name of the series we would like the paths for.

        :returns: A Component piece which can be used to describe ALL <paths> for a series.
        """
        bar = self._bar_coll.get(series_label)
        if not bar:
            bar = ComponentPiece(
                locator=(By.CSS_SELECTOR, 'path'),
                driver=self.driver,
                parent_locator_list=self._get_top_level_series_element(series_label=series_label).locator_list,
                description="The <path> elements which define the lines drawn for the series.")
            self._bar_coll[series_label] = bar
        return bar

    def _get_top_level_series_element(self, series_label: str) -> ComponentPiece:
        """
        Obtain a ComponentPiece which describes a series in the body of the XY Chart.

        :param series_label: The name of the series we would like the graph overview of.

        :returns: A Component piece which can be used to describe the series in the body of the XY Chart.
        """
        series = self._series_coll.get(series_label)
        if not series:
            series = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'g[aria-label="{series_label}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                description=f"The label for the '{series_label}' series.")
            self._series_coll[series_label] = series
        return series

Components/PerspectiveComponents/Charts/XYChart.py
inductiveautomation/ignition-automation-tools
from typing import Optional, Tuple, List

from selenium.common.exceptions import TimeoutException, StaleElementReferenceException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec


class CommonCheckbox(ComponentPiece):
    """A Common Checkbox, as might be used within a larger component."""
    _DISABLED_CLASS = "ia_checkbox--disabled"
    _CLICK_TARGET_LOCATOR = (By.CSS_SELECTOR, "label.ia_checkbox")
    _LABEL_TEXT_LOCATOR = (By.CSS_SELECTOR, "label.checkbox-input-label")
    _ICON_LOCATOR = (By.TAG_NAME, "svg")
    _INPUT_LOCATOR = (By.TAG_NAME, "input")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._click_target = ComponentPiece(
            locator=self._CLICK_TARGET_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq,
            description="The actual visible checkbox a user would click.")
        self._text_label = ComponentPiece(
            locator=self._LABEL_TEXT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq,
            description="The actual text a user should see for the Checkbox.")
        self._icon = CommonIcon(
            locator=self._ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq,
            description="The actual text a user should see for the Checkbox.")
        self._input = ComponentPiece(
            locator=self._INPUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def click(self, wait_timeout: Optional[float] = None, binding_wait_time: float = 0) -> None:
        """
        Click the Checkbox.

        If attempting to set the checkbox to a known state, :func:`set_state` is preferred.

        :param wait_timeout: The amount of time you are willing to wait for the checkbox to appear before attempting
            to perform the click. Overrides the component's original wait.
        :param binding_wait_time: The amount of time after the click occurs before allowing code to continue.
        """
        self._click_target.click(wait_timeout=wait_timeout, binding_wait_time=binding_wait_time)

    def get_checkbox_state(self) -> Optional[bool]:
        """
        Obtain the state of the checkbox.

        :returns: True, if checked. False, if un-checked. None, if indeterminate (tri-state).
        """
        return None if self._is_indeterminate() else self.is_checked()

    def get_direction_of_label_text(self) -> str:
        """
        Obtain the direction of the text as a string. This value describes the location of the text relative to the
        Checkbox.
        """
        return self._text_label.find().get_attribute("class").split("--")[-1]

    def get_label_text(self) -> str:
        """Obtain the text of the label of the checkbox."""
        return self._text_label.find().text

    def get_path_of_icon(self) -> str:
        """
        Obtain a slash-delimited path of the icon currently in use for the checkbox.
        """
        return self._icon.get_icon_name()

    def is_checked(self) -> bool:
        """
        Determine if the checkbox is currently 'checked'. Preferred function is :func:`get_checkbox_state` as it handles
        tri-state checkboxes as well.

        :returns: True, if the checkbox is checked - False if the checkbox is un-checked or indeterminate.
        """
        try:
            return self._icon.find().get_attribute("data-state") == "checked"
        except StaleElementReferenceException:
            # check again
            return self.is_checked()

    def is_enabled(self) -> bool:
        """
        Determine if the Checkbox is currently enabled.

        :returns: True, if the Checkbox is enabled - False otherwise.
        """
        try:
            return self.wait.until(IAec.function_returns_true(
                custom_function=self._is_enabled, function_args={}))
        except TimeoutException:
            return False

    def set_state(self, should_be_checked: Optional[bool], binding_wait_time: float = 0) -> None:
        """
        Set the checkbox to a specified state.
        :param binding_wait_time: How long to wait before allowing the code to continue.
        :param should_be_checked: True if the Checkbox should be selected, False if it should not be selected, or None
            if the checkbox is a tri-state checkbox and should be 'indeterminate'.
        """
        self._set_state(should_be_checked=should_be_checked, binding_wait_time=binding_wait_time)

    def _is_enabled(self) -> bool:
        """Determine if the checkbox is enabled."""
        return self._input.find().is_enabled() and (
                self._DISABLED_CLASS not in self._click_target.find().get_attribute("class"))

    def _is_indeterminate(self) -> bool:
        """
        Determine if the Checkbox is currently displaying as 'indeterminate' (tri-state).

        :returns: True, if the Checkbox is currently displaying as indeterminate. False, if the checkbox is checked or
            unchecked.
        """
        return self._icon.find().get_attribute("data-state") == "indeterminate"

    def _set_state(
            self,
            should_be_checked: Optional[bool],
            already_clicked_once: bool = False,
            binding_wait_time: float = 0) -> None:
        """
        Set the checkbox to a specified state.
        :param should_be_checked: True if the Checkbox should be selected, False if it should not be selected, or None
            if the checkbox is a tri-state checkbox and should be 'indeterminate'.
        :param already_clicked_once: Used to prevent possible recursion by preventing additional calls. In theory, we
            should only ever need to call this function twice in a row, in order to swap between the three states.
        """
        if self.get_checkbox_state() != should_be_checked:
            self.click(binding_wait_time=binding_wait_time)
            # could potentially need to click again (checked/unchecked/indeterminate)
            if not already_clicked_once:
                # prevent possible recursion issues
                self._set_state(should_be_checked=should_be_checked, already_clicked_once=True)
        IAAssert.is_equal_to(
            actual_value=self.get_checkbox_state(),
            expected_value=should_be_checked,
            failure_msg="Failed to set the state of a checkbox.")

Components/PerspectiveComponents/Common/Checkbox.py
inductiveautomation/ignition-automation-tools
from typing import Union, Optional

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece


class ComponentModal(ComponentPiece):
    """
    A Perspective Component Modal, unique from the modal in use by Perspective Popups. The Component Modal is often
    used by Dropdowns and interactive menus to display available options.
    """
    _LOCATOR = (By.CSS_SELECTOR, 'div.ia_componentModal')

    def __init__(
            self,
            driver: WebDriver,
            wait_timeout: Union[float, int] = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=self._LOCATOR,
            driver=driver,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

Components/PerspectiveComponents/Common/ComponentModal.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Optional, Union, List, Tuple

from selenium.common.exceptions import TimeoutException, StaleElementReferenceException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.select import Select

from Components.BasicComponent import ComponentPiece
from Components.Common.Button import CommonButton
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Components.PerspectiveComponents.Common.DateTimePicker import CommonDateTimePicker, PerspectiveDate
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.IAAssert import IAAssert


class DateRangeSelectorTab(Enum):
    HISTORICAL = "historical"
    REALTIME = "realtime"


class DateRangeSelectorTimeUnit(Enum):
    DAYS = "days"
    HOURS = "hours"
    MINUTES = "minutes"
    SECONDS = "seconds"
    WEEKS = "weeks"
    YEARS = "years"


class HistoricalRange:

    def __init__(
            self,
            beginning_day: Optional[Union[int, str]] = None,
            beginning_month: Optional[Union[int, str]] = None,
            beginning_year: Optional[Union[int, str]] = None,
            beginning_hours: Optional[Union[int, str]] = None,
            beginning_minutes: Optional[Union[int, str]] = None,
            beginning_am: Optional[bool] = None,
            ending_day: Optional[Union[int, str]] = None,
            ending_month: Optional[Union[int, str]] = None,
            ending_year: Optional[Union[int, str]] = None,
            ending_hours: Optional[Union[int, str]] = None,
            ending_minutes: Optional[Union[int, str]] = None,
            ending_am: Optional[bool] = None):
        self.beginning_day = beginning_day
        self.beginning_month = beginning_month
        self.beginning_year = beginning_year
        self.beginning_hours = beginning_hours
        self.beginning_minutes = beginning_minutes
        self.beginning_am = beginning_am
        self.ending_day = ending_day
        self.ending_month = ending_month
        self.ending_year = ending_year
        self.ending_hours = ending_hours
        self.ending_minutes = ending_minutes
        self.ending_am = ending_am


class CommonDateRangeSelector(ComponentModal):
    """
    The DateRange Selector used in multiple larger components, including the Alarm Journal Table, the Power Chart, and
    During filtering of Table columns which render as a date.

    In some cases the range selector may change between a Historical 'Picker' which allows for specifying a beginning
    date/time and an ending date/time, or a Realtime range selector which allows for specifying a range of time before
    the current time, where a value of '8 Hours' would specify a range of anything between 'now and 8 hours ago'.

    Use of the Historical picker requires the presence of the default chevrons/arrows/spinners.
    """

    class _HistoricalPicker(CommonDateTimePicker):
        """
        The Historical Picker piece of the Range Selector. Interaction with this component with Selenium requires use
        of the chevrons/arrows/spinners; this class will not work if those pieces are removed through CSS styling.
        """

        _CLEAR_HISTORICAL_ICON_LOCATOR = (By.CSS_SELECTOR, "div.clear-icon")
        _AM_PM_SELECTS_LOCATOR = (By.CSS_SELECTOR, "div.timePickerAmPmPicker select")
        _APPLY_BUTTON_LOCATOR = (By.CSS_SELECTOR, f"button.{CommonButton.PRIMARY_CLASS}")
        _CANCEL_BUTTON_LOCATOR = (By.CSS_SELECTOR, f"button.{CommonButton.SECONDARY_CLASS}")
        _ACTIVE_DAYS_LOCATOR = (By.CSS_SELECTOR, "div.dayTile.active")  # active means "this month", not "enabled"
        _END_TIME_INPUT_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.timeInputContainer[data-end-time="true"]')
        _START_TIME_INPUT_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.timeInputContainer[data-start-time="true"]')
        _TIME_PICKER_RANGE_INFO_TEXT_LOCATOR = (By.CSS_SELECTOR, "div.ia_dateRangeTimePicker__rangeInfo")
        _SELECTED_START_AND_END_DATES_DATE_RANGE_LOCATOR = (By.CSS_SELECTOR, "div.node")
        _BEGINNING_INDEX = 0
        _ENDING_INDEX = 1

        def __init__(
                self,
                driver: WebDriver,
                description: Optional[str] = None,
                poll_freq: float = 0.5):
            super().__init__(
                locator=(By.CSS_SELECTOR, 'div[class*="iaDateRange"]'),
                driver=driver,
                parent_locator_list=ComponentModal(driver=driver).locator_list,
                description=description,
                poll_freq=poll_freq)
            self._next_month_chevron = ComponentPiece(
                locator=self._NEXT_MONTH_CHEVRON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._previous_month_chevron = ComponentPiece(
                locator=self._PREVIOUS_MONTH_CHEVRON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._clear_link = ComponentPiece(
                locator=self._CLEAR_RANGE_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._month_dropdown = ComponentPiece(
                locator=self._MONTH_SELECT_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._year_dropdown = ComponentPiece(
                locator=self._YEAR_SELECT_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._active_days = ComponentPiece(
                locator=self._ACTIVE_DAYS_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._clear_icon = ComponentPiece(
                locator=self._CLEAR_HISTORICAL_ICON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list)
            self._start_time_input_container = ComponentPiece(
                locator=self._START_TIME_INPUT_CONTAINER_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
            self._start_time_hour_input = ComponentPiece(
                locator=self._HOURS_INPUT_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_minute_input = ComponentPiece(
                locator=self._MINUTES_INPUT_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_second_input = ComponentPiece(
                locator=self._SECONDS_INPUT_LOCATOR,
                driver=driver,
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_next_hour_spinner = ComponentPiece(
                locator=self._NEXT_HOUR_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_next_minute_spinner = ComponentPiece(
                locator=self._NEXT_MINUTE_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_next_second_spinner = ComponentPiece(
                locator=self._NEXT_SECOND_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_previous_hour_spinner = ComponentPiece(
                locator=self._PREVIOUS_HOUR_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_previous_minute_spinner = ComponentPiece(
                locator=self._PREVIOUS_MINUTE_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_previous_second_spinner = ComponentPiece(
                locator=self._PREVIOUS_SECOND_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._start_time_input_container.locator_list)
            self._start_time_am_pm_select = ComponentPiece(
                locator=self._AM_PM_SELECTS_LOCATOR,
                driver=driver,
                parent_locator_list=self._start_time_input_container.locator_list,
                wait_timeout=1)
            self._end_time_input_container = ComponentPiece(
                locator=self._END_TIME_INPUT_CONTAINER_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
            self._end_time_hour_input = ComponentPiece(
                locator=self._HOURS_INPUT_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_minute_input = ComponentPiece(
                locator=self._MINUTES_INPUT_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_second_input = ComponentPiece(
                locator=self._SECONDS_INPUT_LOCATOR,
                driver=driver,
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_next_hour_spinner = ComponentPiece(
                locator=self._NEXT_HOUR_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_next_minute_spinner = ComponentPiece(
                locator=self._NEXT_MINUTE_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_next_second_spinner = ComponentPiece(
                locator=self._NEXT_SECOND_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_previous_hour_spinner = ComponentPiece(
                locator=self._PREVIOUS_HOUR_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_previous_minute_spinner = ComponentPiece(
                locator=self._PREVIOUS_MINUTE_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_previous_second_spinner = ComponentPiece(
                locator=self._PREVIOUS_SECOND_SPINNER_LOCATOR, 
                driver=driver, 
                parent_locator_list=self._end_time_input_container.locator_list)
            self._end_time_am_pm_select = ComponentPiece(
                locator=self._AM_PM_SELECTS_LOCATOR,
                driver=driver,
                parent_locator_list=self._end_time_input_container.locator_list,
                wait_timeout=1,
                poll_freq=poll_freq)
            self._time_picker_range_info_text = ComponentPiece(
                locator=self._TIME_PICKER_RANGE_INFO_TEXT_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._selected_start_and_end_dates_calendar_values = ComponentPiece(
                locator=self._SELECTED_START_AND_END_DATES_DATE_RANGE_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)

        def click_clear(self) -> None:
            """
            Click the 'clear' link in the Historical tab.

            :raises TimeoutException: If the clear link is not present. Verify you are using the Historical tab and not
                the Realtime range tab.
            """
            self._clear_link.click(wait_timeout=0.5)

        def click_historical_day(self, numeric_day: Union[int, str]) -> None:
            """
            Click a numeric day of the current month.

            :param numeric_day: The number of the day to click.

            :raises AssertionError: If the specified day was not selected.
            :raises TimeoutException: If the clear link is not present. Verify you are using the Historical tab and not
                the Realtime range tab.
            """
            self.select_date(date=PerspectiveDate(day=numeric_day))
            IAAssert.contains(
                iterable=[int(_) for _ in self.get_selected_days()],
                expected_value=int(numeric_day),
                failure_msg=f"Failed to select '{numeric_day}' as part of a Historical range.")

        def day_is_node_of_range(self, numeric_day: Union[int, str]) -> bool:
            """
            Determine if the specified day is the beginning or end day of the current Historical range.

            :param numeric_day: The day which is to be checked as a terminus of the range.

            :returns: True, if the supplied numeric_day is either the beginning day or the ending day of the current
                Historic range. False, if the numeric_day is within the current range or is not a part of the range.
            """
            return "node" in self._get_day_tile(numeric_day=numeric_day).find().get_attribute("class")

        def day_is_within_current_range(self, numeric_day: Union[int, str]) -> bool:
            """
            Determine if the supplied day is the beginning day, ending day, or any day in-between those days for the
            currently selected Historical range.

            :param numeric_day: The day which is to be checked for inclusion in the range.

            :returns: True, if the supplied day falls anywhere within the current Historical range - False otherwise.
            """
            return self.day_is_node_of_range(numeric_day=numeric_day) or \
                "range" in self._get_day_tile(numeric_day=numeric_day).find().get_attribute("class")
                
        def end_time_hour_input_is_displayed(self) -> bool:
            """
            Determine if the hours input is displayed for the 'ending' time.

            :returns: True, if the hours input is displayed for the ending time of the range - False otherwise.

            :raises TimeoutException: If the hours input is not present.
            """
            return self._end_time_hour_input.find().is_displayed()
                
        def end_time_minute_input_is_displayed(self) -> bool:
            """
            Determine if the minutes input is displayed for the 'ending' time.

            :returns: True, if the minutes input is displayed for the ending time of the range - False otherwise.

            :raises TimeoutException: If the minutes input is not present.
            """
            return self._end_time_minute_input.find().is_displayed()
                
        def end_time_second_input_is_displayed(self) -> bool:
            """
            Determine if the seconds input is displayed for the 'ending' time.

            Note: Date Range Selector does not support seconds as of 8.1.22, so this will always throw a
            TimeoutException.

            :returns: True, if the seconds input is displayed for the ending time of the range - False otherwise.

            :raises TimeoutException: If the seconds input is not present.
            """
            return self._end_time_second_input.find().is_displayed()
                
        def end_time_hour_input_is_enabled(self) -> bool:
            """
            Determine if the hours input is enabled for the 'ending' time.

            :returns: True, if the hours input is enabled for the ending time of the range - False otherwise.

            :raises TimeoutException: If the hours input is not present.
            """
            return self._end_time_hour_input.find().is_enabled()
                
        def end_time_minute_input_is_enabled(self) -> bool:
            """
            Determine if the minutes input is enabled for the 'ending' time.

            :returns: True, if the minutes input is enabled for the ending time of the range - False otherwise.

            :raises TimeoutException: If the minutes input is not present.
            """
            return self._end_time_minute_input.find().is_enabled()
                
        def end_time_second_input_is_enabled(self) -> bool:
            """
            Determine if the seconds input is enabled for the 'ending' time.

            Note: Date Range Selector does not support seconds as of 8.1.22, so this will always throw a
            TimeoutException.

            :returns: True, if the seconds input is enabled for the ending time of the range - False otherwise.

            :raises TimeoutException: If the seconds input is not present.
            """
            return self._end_time_second_input.find().is_enabled()

        def get_current_range(self) -> Optional[HistoricalRange]:
            """
            Obtain the current Historical range.

            Currently only supports ranges within a single month, and formats which use 12-hour times.

            :returns: The currently applied Historical Range if one is fully applied, or None if a valid Historical
                Range is not currently applied or if the range begins and ends in different months.
            """
            try:
                _active_days = self._active_days.find_all()
                beginning_day = _active_days[0].text
                ending_day = _active_days[-1].text
                month = self.get_selected_month()
                year = self.get_selected_year()
                return HistoricalRange(
                    beginning_day=beginning_day,
                    beginning_month=month,
                    beginning_year=year,
                    beginning_hours=self._start_time_hour_input.get_text(),
                    beginning_minutes=self._start_time_minute_input.get_text(),
                    beginning_am=Select(webelement=self._start_time_am_pm_select.find()).first_selected_option == "am",
                    ending_day=ending_day,
                    ending_month=month,
                    ending_year=year,
                    ending_hours=self._end_time_hour_input.get_text(),
                    ending_minutes=self._end_time_minute_input.get_text(),
                    ending_am=Select(webelement=self._end_time_am_pm_select.find()).first_selected_option == "am")
            except IndexError:
                # possible if only one day has been selected, or if our range spans more than one month
                return None

        def get_selected_start_and_end_days_from_date_range_selector(self) -> List[int]:
            """
            Obtain the currently selected beginning and end days of the Historical range.

            Note: This only returns days of the current calendar month, so ranges which span months will only return
            one day.

            :returns: The beginning and end days of the current Historical range, where the
                first element in the list is the start day and the second element is the end day.
            """
            try:
                return [int(_.text) for _ in self._selected_start_and_end_dates_calendar_values.find_all()]
            except TimeoutException:
                return []

        def get_time_picker_range_info_text(self) -> str:
            """
            Obtain the start and end dates of the currently applied Historical range as the user would see them.

            :returns: The displayed formatted date range (sans time) as the user sees it.
            """
            return self._time_picker_range_info_text.get_text()

        def is_displayed(self) -> bool:
            """
            Determine if the Historical range picker is currently displayed to the user.

            :returns: True, if the picker is present - False otherwise.
            """
            try:
                return self.find(wait_timeout=0.5) is not None
            except TimeoutException:
                return False

        def set_end_time(self, date: PerspectiveDate) -> None:
            """
            Set the ending time to use for the Historical range.

            :param date: A PerspectiveDate object from which we will only use the time aspects.

            :raises AssertionError: If any of the end time values do not match what is supplied after attempting to
                apply them.
            """
            am_pm_found = False
            if date.hours24 and self._end_time_hour_input.find().is_enabled():
                try:
                    # this picker determines our input approach (12 vs 24 hour)
                    am_pm_found = self._end_time_am_pm_select.find() is not None
                    target_value = date.hours12
                except TimeoutException:
                    # 24-hour format
                    am_pm_found = False
                    target_value = date.hours24
                self._set_end_hours(target_value=target_value)
            if date.minutes and self._end_time_minute_input.find().is_enabled():
                self._set_end_minutes(target_value=date.minutes)
            if am_pm_found:
                Select(webelement=self._end_time_am_pm_select.find()).select_by_value(value=date.am_pm)
            # assert new settings were applied
            if date.hours24 is not None:
                IAAssert.is_equal_to(
                    actual_value=self._end_time_hour_input.find().get_attribute("value"),
                    expected_value=date.hours12 if am_pm_found else date.hours24,
                    as_type=int,
                    failure_msg="Failed to set the hours for the end time.")
            if date.minutes is not None:
                IAAssert.is_equal_to(
                    actual_value=self._end_time_minute_input.find().get_attribute("value"),
                    expected_value=date.minutes,
                    as_type=int,
                    failure_msg="Failed to set the minutes for the end time.")
            if date.am_pm and date.am_pm is not None:
                IAAssert.is_equal_to(
                    actual_value=Select(webelement=self._end_time_am_pm_select.find()).first_selected_option.text.upper(),
                    expected_value=date.am_pm.upper(),
                    as_type=str,
                    failure_msg="Failed to set AM/PM for the end time.")

        def set_range(self, historical_range: HistoricalRange) -> None:
            """
            Set the Historical range by specifying all time pieces of both the beginning and end dates.

            :param historical_range: A HistoricalRange object containing information about both the start date/time and
                end date/time for the desired range.

            :raises AssertionError: If unsuccessful in applying any of the range values.
            """
            _beginning_hours = 0
            _ending_hours = 0
            if historical_range.beginning_hours is not None:
                _beginning_hours = int(historical_range.beginning_hours)
            if not historical_range.beginning_am:
                _beginning_hours += 12
            if historical_range.ending_hours is not None:
                _ending_hours = int(historical_range.ending_hours)
            if not historical_range.ending_am:
                _ending_hours += 12
            beginning_date = PerspectiveDate(
                year=historical_range.beginning_year,
                month=historical_range.beginning_month,
                day=historical_range.beginning_day,
                hours24=_beginning_hours,
                minutes=historical_range.beginning_minutes)
            ending_date = PerspectiveDate(
                year=historical_range.ending_year,
                month=historical_range.ending_month,
                day=historical_range.ending_day,
                hours24=_ending_hours,
                minutes=historical_range.ending_minutes)
            self.select_date_only_not_time(date=beginning_date)
            self.select_date_only_not_time(date=ending_date)
            self.set_start_time(date=beginning_date)
            self.set_end_time(date=ending_date)

        def set_start_time(self, date: PerspectiveDate) -> None:
            """
            Set the starting time to use for the Historical range.

            :param date: A PerspectiveDate object from which we will only use the time aspects.

            :raises AssertionError: If any of the end time values do not match what is supplied after attempting to
                apply them.
            """
            am_pm_found = False
            if date.hours24 and self._start_time_hour_input.find().is_enabled():
                try:
                    # this picker determines our input approach (12 vs 24 hour)
                    am_pm_found = self._start_time_am_pm_select.find() is not None
                    target_value = date.hours12
                except TimeoutException:
                    # 24-hour format
                    am_pm_found = False
                    target_value = date.hours24
                self._set_start_hours(target_value=target_value)
            if date.minutes and self._start_time_minute_input.find().is_enabled():
                self._set_start_minutes(target_value=date.minutes)
            if am_pm_found:
                Select(webelement=self._start_time_am_pm_select.find()).select_by_value(value=date.am_pm)
            # assert new settings were applied
            if date.hours24 is not None:
                IAAssert.is_equal_to(
                    actual_value=self._start_time_hour_input.find().get_attribute("value"),
                    expected_value=date.hours12 if am_pm_found else date.hours24,
                    as_type=int,
                    failure_msg="Failed to set the hours for the start time.")
            if date.minutes is not None:
                IAAssert.is_equal_to(
                    actual_value=self._start_time_minute_input.find().get_attribute("value"),
                    expected_value=date.minutes,
                    as_type=int,
                    failure_msg="Failed to set the minutes for the start time.")
            if am_pm_found and date.am_pm is not None:
                IAAssert.is_equal_to(
                    actual_value=Select(webelement=self._start_time_am_pm_select.find()).first_selected_option.text.upper(),
                    expected_value=date.am_pm.upper(),
                    as_type=str,
                    failure_msg="Failed to set AM/PM for the start time.")
                
        def start_time_hour_input_is_displayed(self) -> bool:
            """
            Determine if the hours input for the starting time is displayed.

            :returns: True, if the hours input of the starting time is displayed - False otherwise.

            :raises TimeoutException: If the hours input of the starting time is not present.
            """
            return self._start_time_hour_input.find().is_displayed()
                
        def start_time_minute_input_is_displayed(self) -> bool:
            """
            Determine if the minutes input for the starting time is displayed.

            :returns: True, if the minutes input of the starting time is displayed - False otherwise.

            :raises TimeoutException: If the minutes input of the starting time is not present.
            """
            return self._start_time_minute_input.find().is_displayed()
                
        def start_time_second_input_is_displayed(self) -> bool:
            """
            Determine if the seconds input for the starting time is displayed.

            :returns: True, if the seconds input of the starting time is displayed - False otherwise.

            :raises TimeoutException: If the seconds input of the starting time is not present.
            """
            return self._start_time_second_input.find().is_displayed()
                
        def start_time_hour_input_is_enabled(self) -> bool:
            """
            Determine if the hours input for the starting time is enabled.

            :returns: True, if the hours input of the starting time is enabled - False otherwise.

            :raises TimeoutException: If the hours input of the starting time is not present.
            """
            return self._start_time_hour_input.find().is_enabled()
                
        def start_time_minute_input_is_enabled(self) -> bool:
            """
            Determine if the minutes input for the starting time is enabled.

            :returns: True, if the minutes input of the starting time is enabled - False otherwise.

            :raises TimeoutException: If the minutes input of the starting time is not present.
            """
            return self._start_time_minute_input.find().is_enabled()
                
        def start_time_second_input_is_enabled(self) -> bool:
            """
            Determine if the seconds input for the starting time is enabled.

            :returns: True, if the seconds input of the starting time is enabled - False otherwise.

            :raises TimeoutException: If the seconds input of the starting time is not present.
            """
            return self._start_time_second_input.find().is_enabled()
                
        def _set_end_hours(self, target_value: int) -> None:
            """
            Set the ending hours of the Historical range by clicking the chevrons/arrows.

            :param target_value: The desired hours to apply as part of the ending date.

            :raises AssertionError: If unsuccessful in applying the supplied target_value.
            """
            current_hours = int(self._end_time_hour_input.find().get_attribute("value"))
            if target_value != current_hours:
                while current_hours < target_value:
                    self._end_time_next_hour_spinner.click()
                    current_hours = int(self._end_time_hour_input.find().get_attribute("value"))
                while current_hours > target_value:
                    self._end_time_previous_hour_spinner.click()
                    current_hours = int(self._end_time_hour_input.find().get_attribute("value"))
            IAAssert.is_equal_to(
                actual_value=current_hours,
                expected_value=target_value,
                as_type=int,
                failure_msg=f"failed to set the ending hours ({target_value}) for the range.")
                    
        def _set_end_minutes(self, target_value: int) -> None:
            """
            Set the ending minutes of the Historical range by clicking the chevrons/arrows.

            :param target_value: The desired minutes to apply as part of the ending date.

            :raises AssertionError: If unsuccessful in applying the supplied target_value.
            """
            current_minutes = int(self._end_time_minute_input.find().get_attribute("value"))
            if target_value != current_minutes:
                while current_minutes < target_value:
                    self._end_time_next_minute_spinner.click()
                    current_minutes = int(self._end_time_minute_input.find().get_attribute("value"))
                while current_minutes > target_value:
                    self._end_time_previous_minute_spinner.click()
                    current_minutes = int(self._end_time_minute_input.find().get_attribute("value"))
            IAAssert.is_equal_to(
                actual_value=current_minutes,
                expected_value=target_value,
                as_type=int,
                failure_msg=f"failed to set the ending minutes ({target_value}) for the range.")
                
        def _set_start_hours(self, target_value: int) -> None:
            """
            Set the starting hours of the Historical range by clicking the chevrons/arrows.

            :param target_value: The desired hours to apply as part of the starting date.

            :raises AssertionError: If unsuccessful in applying the supplied target_value.
            """
            current_hours = int(self._start_time_hour_input.find().get_attribute("value"))
            if target_value != current_hours:
                while current_hours < target_value:
                    self._start_time_next_hour_spinner.click()
                    current_hours = int(self._start_time_hour_input.find().get_attribute("value"))
                while current_hours > target_value:
                    self._start_time_previous_hour_spinner.click()
                    current_hours = int(self._start_time_hour_input.find().get_attribute("value"))
            IAAssert.is_equal_to(
                actual_value=current_hours,
                expected_value=target_value,
                as_type=int,
                failure_msg=f"failed to set the starting hours ({target_value}) for the range.")
                    
        def _set_start_minutes(self, target_value: int) -> None:
            """
            Set the starting minutes of the Historical range by clicking the chevrons/arrows.

            :param target_value: The desired minutes to apply as part of the starting date.

            :raises AssertionError: If unsuccessful in applying the supplied target_value.
            """
            current_minutes = int(self._start_time_minute_input.find().get_attribute("value"))
            if target_value != current_minutes:
                while current_minutes < target_value:
                    self._start_time_next_minute_spinner.click()
                    current_minutes = int(self._start_time_minute_input.find().get_attribute("value"))
                while current_minutes > target_value:
                    self._start_time_previous_minute_spinner.click()
                    current_minutes = int(self._start_time_minute_input.find().get_attribute("value"))
            IAAssert.is_equal_to(
                actual_value=current_minutes,
                expected_value=target_value,
                as_type=int,
                failure_msg=f"failed to set the starting minutes ({target_value}) for the range.")

    class _RealtimeRange(ComponentModal):
        """The Realtime range piece of the Range Selector, comprised of a simple input and a Dropdown/Select."""
        _REALTIME_NUMERIC_INPUT_LOCATOR = (By.CSS_SELECTOR, 'input.realtimeContentInput')
        _REALTIME_SELECT_LOCATOR = (By.CSS_SELECTOR, 'div.realtimeUnitSelector select')
        _REALTIME_RANGE_MESSAGE_LABEL_LOCATOR = (By.CSS_SELECTOR, 'div.realtimeMessage')

        def __init__(
                self,
                driver: WebDriver,
                description: Optional[str] = None,
                poll_freq: float = 0.5):
            super().__init__(
                driver=driver,
                description=description,
                poll_freq=poll_freq)
            self._input = ComponentPiece(
                locator=self._REALTIME_NUMERIC_INPUT_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._unit_selector = ComponentPiece(
                locator=self._REALTIME_SELECT_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._message_label = ComponentPiece(
                locator=self._REALTIME_RANGE_MESSAGE_LABEL_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq
            )

        def select_realtime_unit(self, time_unit: DateRangeSelectorTimeUnit) -> None:
            """
            Select which time unit to apply as part of the Realtime range.

            :param time_unit: An enumeration of the available options to select from.

            :raises AssertionError: If unsuccessful in applying the supplied time unit.
            """
            Select(webelement=self._unit_selector.find()).select_by_value(value=time_unit.value)
            IAAssert.is_equal_to(
                actual_value=Select(webelement=self._unit_selector.find()).first_selected_option.text.upper(),
                expected_value=time_unit.value.upper(),
                as_type=str,
                failure_msg=f"Failed to apply {time_unit.value} as the time unit fr the Realtime range.")

        def set_time_value(self, time_value: int) -> None:
            """
            Set the numeric range to be used with the time unit.

            :param time_value: The amount of the time unit you desire to apply as the Realtime range.

            :raises AssertionError: If unsuccessful in applying the supplied time value.
            """
            original = self._input.find().get_attribute("value")
            text_to_set = "".join([Keys.ARROW_RIGHT * len(original), Keys.BACKSPACE * len(original), str(time_value)])
            self._input.click()
            self._input.find().send_keys(text_to_set)
            self._message_label.click(binding_wait_time=0.25)  # force the loss of focus on the input
            IAAssert.is_equal_to(
                actual_value=self._input.find().get_attribute('value'),
                expected_value=time_value,
                as_type=str,
                failure_msg="Failed to set the time value of the Realtime range.")

    ACTIVE_TAB_CLASS = "isActiveTab"
    _TOGGLE_ICON_LOCATOR = (By.CSS_SELECTOR, "svg.iaCalendarIcon")
    _MESSAGE_LOCATOR = (By.CSS_SELECTOR, '[data-message]')
    _RANGE_MESSAGE_LOCATOR = (By.CSS_SELECTOR, '[data-range-message]')
    # modal/popover locators
    _APPLY_BUTTON_LOCATOR = (By.CSS_SELECTOR, f'button.{CommonButton.PRIMARY_CLASS}')
    _CANCEL_BUTTON_LOCATOR = (By.CSS_SELECTOR, f'button.{CommonButton.SECONDARY_CLASS}')
    _POPOVER_CLOSE_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.closeIcon svg')
    _HISTORICAL_TAB_LOCATOR = (By.CSS_SELECTOR, 'div.historicalTab')
    _REALTIME_TAB_LOCATOR = (By.CSS_SELECTOR, 'div.realtimeTab')
    _FOOTER_LOCATOR = (By.CSS_SELECTOR, "div.realtimeOrHistoricalRangeFooter")
    _HEADER_LOCATOR = (By.CSS_SELECTOR, 'div.realtimeOrHistoricalRangeSelectorModalHeader')
    _TAB_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.realtimeOrHistoricalRangeTabContainer')
    _GENERIC_TAB_LOCATOR = (By.CSS_SELECTOR, 'div.ia_realtimeOrHistoricalRange__tabContainer__tab')
    _HISTORICAL_PICKER_LOCATOR = (By.CSS_SELECTOR, 'div.iaDateRangeTimePicker')

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(driver=driver, description=description)
        # toggle icon is actually part of the Power Chart component - not the Date Range Selector/modal
        self._toggle_icon = CommonIcon(
            locator=self._TOGGLE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            poll_freq=poll_freq)
        self._message = ComponentPiece(
            locator=self._MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            poll_freq=poll_freq)
        self._range_message = ComponentPiece(
            locator=self._RANGE_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            poll_freq=poll_freq)
        # modal content
        self._footer = ComponentPiece(
            locator=self._FOOTER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._header = ComponentPiece(
            locator=self._HEADER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._tab_container = ComponentPiece(
            locator=self._TAB_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._apply_button = CommonButton(
            locator=self._APPLY_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._footer.locator_list,
            poll_freq=poll_freq)
        self._cancel_button = CommonButton(
            locator=self._CANCEL_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._footer.locator_list,
            poll_freq=poll_freq)
        self._close_icon = CommonIcon(
            locator=self._POPOVER_CLOSE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._header.locator_list,
            poll_freq=poll_freq)
        self._historical_tab = ComponentPiece(
            locator=self._HISTORICAL_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self._tab_container.locator_list,
            poll_freq=poll_freq)
        self._realtime_tab = ComponentPiece(
            locator=self._REALTIME_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self._tab_container.locator_list,
            poll_freq=poll_freq)
        self._generic_tabs = ComponentPiece(
            locator=self._GENERIC_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self._tab_container.locator_list,
            poll_freq=poll_freq)
        self._historical_picker = self._HistoricalPicker(driver=driver, poll_freq=poll_freq)
        self._realtime_range = self._RealtimeRange(driver=driver, poll_freq=poll_freq)

    def apply_button_is_enabled(self) -> bool:
        """
        Determine if the Apply button is enabled.

        :returns: True, if the Apply button is enabled - False otherwise.

        :raises TimeoutException: If the Apply button is not present.
        """
        return self._apply_button.is_enabled()

    def apply_changes(self) -> None:
        """
        Click the Apply button.

        :raises TimeoutException: If the Apply button is not present.
        """
        self._apply_button.click()

    def cancel_changes(self) -> None:
        """
        Click the Cancel button.

        :raises TimeoutException: If the Cancel button is not present.
        """
        self._cancel_button.click()

    def click_clear_in_historical_tab(self) -> None:
        """
        Click the 'Clear' link in the Historical Picker.

        :raises TimeoutException: If the Historical Picker is not present.
        """
        self._historical_picker.click_clear()

    def click_historical_day(self, numeric_day: Union[int, str]) -> None:
        """
        Click the supplied numeric day in the Historical Picker.

        :param numeric_day: The day you would like to click.

        :raises AssertionError: If unsuccessful in clicking the supplied day.
        :raises TimeoutException: If the Historical Picker is not present.
        """
        self._historical_picker.click_historical_day(numeric_day=numeric_day)

    def close_date_range_modal_or_popover_if_displayed(self) -> None:
        """
        Close the Date Range Selector if it is already expanded. Takes no action if the Date Range Selector is not
        expanded.
        """
        if self.modal_or_popover_is_displayed():
            self._close_icon.click()

    def date_range_selector_toggle_icon_is_present(self) -> bool:
        """
        Determine if the icon used to expand the DateRangeSelector is present.

        :returns: True, if the icon is present - False otherwise.
        """
        try:
            return self._toggle_icon.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    def day_is_node_of_range(self, numeric_day: Union[int, str]) -> bool:
        """
        Determine if the supplied numeric day is either the start or end day of the current Historical range.

        :returns: True, if the supplied numeric_day is either the starting or ending day of the current range. False, if
            the numeric_day is simply within the range or not a part of the range at all.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the Realtime
            range instead of the Historical range.
        """
        return self._historical_picker.day_is_node_of_range(numeric_day=numeric_day)

    def day_is_within_historical_range(self, numeric_day: Union[int, str]) -> bool:
        """
        Determine if the supplied day falls anywhere within the current historical range.

        :param numeric_day: The number of the day to check for validity.

        :returns: True, if the supplied numeric day is the starting day, ending day or any day between those two days -
            False otherwise.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the Realtime
            range instead of the Historical range.
        """
        return self._historical_picker.day_is_within_current_range(numeric_day=numeric_day)
        
    def end_time_hour_input_is_enabled(self) -> bool:
        """
        Determine if the hours input of the ending time is enabled.

        :returns: True, if the hours input of the ending time for the Historical range is enabled - False otherwise.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the Realtime
            range instead of the Historical range.
        """
        return self._historical_picker.end_time_hour_input_is_enabled()
        
    def end_time_minute_input_is_enabled(self) -> bool:
        """
        Determine if the minutes input of the ending time is enabled.

        :returns: True, if the minutes input of the ending time for the Historical range is enabled - False otherwise.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the Realtime
            range instead of the Historical range.
        """
        return self._historical_picker.end_time_minute_input_is_enabled()
        
    def end_time_second_input_is_enabled(self) -> bool:
        """
        Determine if the seconds input of the ending time is enabled.

        Note: Date Range Selector does not support seconds as of 8.1.22, so this will always throw a TimeoutException.

        :returns: True, if the seconds input of the ending time for the Historical range is enabled - False otherwise.

        :raises TimeoutException: If the Historical picker is not present or the seconds input is not present; likely
            to happen if dealing with the Realtime range instead of the Historical range.
        """
        return self._historical_picker.end_time_second_input_is_enabled()

    def get_enabled_days(self) -> List[int]:
        """
        Obtain a list of all days in the current month which allow for selection.

        :returns: All days in the current month which a user may click as part of the range selection.

        :raises TimeoutException: If the historical picker is not present; likely to happen if dealing with the
            Realtime range instead of the Historical range.
        """
        return self._historical_picker.get_enabled_days()

    def get_historical_range(self) -> HistoricalRange:
        """
        Obtain all pieces of information pertaining to the Historical Range.

        :returns: An object containing information about the currently applied Historical Range - even if the Component
            is using a realtime range.
        """
        opened_to_start_with = self.modal_or_popover_is_displayed()
        if not opened_to_start_with:
            self.open_date_range_popover_or_modal_if_not_already_displayed()
            self.select_tab(tab=DateRangeSelectorTab.HISTORICAL)
        current_historical_range = self._historical_picker.get_current_range()
        if not opened_to_start_with:
            self.close_date_range_modal_or_popover_if_displayed()
        return current_historical_range

    def get_historical_text_picker_range_info_text(self) -> str:
        """
        Obtain the historical range as the formatted dates the user would see.

        :returns: A formatted string representation of the dates currently applied as the Historical Range.
        """
        opened_to_start_with = self.modal_or_popover_is_displayed()
        if not opened_to_start_with:
            self.open_date_range_popover_or_modal_if_not_already_displayed()
            self.select_tab(tab=DateRangeSelectorTab.HISTORICAL)
        time_picker_range_info_text = self._historical_picker.get_time_picker_range_info_text()
        if not opened_to_start_with:
            self.close_date_range_modal_or_popover_if_displayed()
        return time_picker_range_info_text

    def get_list_of_available_months_from_historical_picker(self) -> List[str]:
        """
        Obtain all available months from the Historical Range picker.

        :returns: All enabled months available for selection in the historical picker.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the
            Realtime range instead of the Historical range.
        """
        return self._historical_picker.get_list_of_available_months()

    def get_message(self) -> str:
        """
        Obtain the message related to the Date Range Selector. Currently only used for the Alarm Journal Table.
        Example: 'X alarm events'

        :returns: The message associated with the Date Range Selector, if available.

        :raises TimeoutException: If not using the Alarm Journal Table, or if the Alarm Journal Table currently has
            disabled the toolbar which houses the Date Range Selector.
        """
        return self._message.get_text()

    def get_range_message(self) -> str:
        """
        Obtain the range message associated with the Date Range Selector. This value describes the applied range.
        Example: 'Last 8 hours', or a breakdown of the date/times used for the Historical range.

        :returns: The range message associated with the Date Range Selector, if available.

        :raises TimeoutException: If the range message is not present.
        """
        return self._range_message.get_text()

    def get_selected_start_and_end_days_from_date_range_selector(self) -> List[int]:
        """
        Obtain the currently selected beginning and end days of the Historical range.

        Note: This only returns days of the current calendar month, so ranges which span months will only return
        one day.

        :returns: The beginning and end days of the current Historical range, where the
            first element in the list is the start day and the second element is the end day.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the
            Realtime range instead of the Historical range.
        """
        return self._historical_picker.get_selected_start_and_end_days_from_date_range_selector()

    def get_text_of_tabs(self) -> List[str]:
        """
        Obtain the text of the Realtime and Historical tabs, as a user would see them. Useful for verifying
        localization.

        :returns: A list of strings which represent the text displayed in the Realtime and Historical tabs, with
            Realtime as the 0th element, and Historical as the 1th element.

        :raises TimeoutException: If the Date Range Selector is not expanded.
        """
        return [_.text for _ in self._generic_tabs.find_all()]

    def historical_date_range_is_displayed(self) -> bool:
        """
        Determine if the Historical range picker is currently displayed to the user.

        :returns: True, if the picker is present - False otherwise.
        """
        return self._historical_picker.is_displayed()

    def hour_input_is_displayed(self) -> bool:
        """
        Determine if any hour input is visible.

        :returns: True if any hour input field is displayed.
        """
        return self._historical_picker.hour_input_is_visible()

    def hour_input_is_enabled(self) -> bool:
        """
        Determine if any hour input is enabled.

        :returns: True if any hour input field is enabled.
        """
        return self._historical_picker.hour_input_is_enabled()

    def minute_input_is_displayed(self) -> bool:
        """
        Determine if any minute input is visible.

        :returns: True if any minute input field is displayed.
        """
        return self._historical_picker.minute_input_is_visible()

    def minute_input_is_enabled(self) -> bool:
        """
        Determine if any minute input is enabled.

        :returns: True if any minute input field is enabled.
        """
        return self._historical_picker.minute_input_is_enabled()

    def modal_or_popover_is_displayed(self) -> bool:
        """
        Determine if the Date Range Selector is expanded.

        :returns: True, if the Date Range Selector is expanded as either a modal or 'popover' - False otherwise.
        """
        try:
            return self.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    def open_date_range_popover_or_modal_if_not_already_displayed(self) -> None:
        """
        Expand the Date Range Selector if it is not already expanded.

        :raises TimeoutException: If the icon used to expand the Date Range Selector is not present.
        """
        if not self.modal_or_popover_is_displayed():
            self._toggle_icon.click()

    def seconds_input_is_enabled(self) -> bool:
        """
        Determine if any seconds input is enabled.

        :returns: True, is any seconds input is enabled - False otherwise.
        """
        return self._historical_picker.seconds_input_is_enabled()

    def seconds_input_is_displayed(self) -> bool:
        """
        Determine if any seconds input is visible.

        :returns: True, is any seconds input is visible - False otherwise.
        """
        return self._historical_picker.seconds_input_is_displayed()

    def select_historical_month_from_dropdown(self, month: Union[int, str]) -> None:
        """
        Select a month from the Historical range picker.

        :param month: The name or 1-indexed numeric representation of a month to select (where 1 == January).

        :raises AssertionError: If unsuccessful in applying the supplied month.
        """
        self._historical_picker.select_month(month=month)

    def select_realtime_unit(self, time_unit: DateRangeSelectorTimeUnit) -> None:
        """
        Select a unit of time from the Realtime range selector.

        :param time_unit: The unit of time to select from the selector.

        :raises AssertionError: If unsuccessful in applying the supplied time unit.
        """
        self._realtime_range.select_realtime_unit(time_unit=time_unit)

    def select_date(self, date: PerspectiveDate) -> None:
        """
        Select a date in the picker. This function should only be used when interacting with the picker used as part of
        filtering a date-type Table column. Usage while interacting with an Alarm Journal Table or Power Chart is
        unsupported; please use :func:`set_historical_range_and_apply`

        :param date: The date to select in the Range Selector.

        :raises AssertionError: If unsuccessful in applying the supplied date.
        """
        self._historical_picker.select_date(date=date)

    def select_tab(self, tab: DateRangeSelectorTab) -> None:
        """
        Select either the Realtime or Historical tab within the Date Range Selector.

        :param tab: The tab you would like to click.

        :raises TimeoutException: If the supplied tab is not present.
        """
        _tab_comp = self._historical_tab if tab == DateRangeSelectorTab.HISTORICAL else self._realtime_tab
        try:
            _tab_comp.click(binding_wait_time=1)
        except StaleElementReferenceException:
            self.select_tab(tab=tab)

    def set_historical_range(self, historical_range: HistoricalRange, apply: Optional[bool] = None) -> None:
        """
        Set the historical range and click the Apply button.

        :param historical_range: The Historical Range object which defines the starting and ending dates to apply.
        :param apply: If True, the supplied historical range will be applied. If False, the supplied Historical Range
            will be cancelled upon completion. If None, no action will be taken after completion, leaving the Date
            Range Selector open.

        :raises AssertionError: If unsuccessful in applying the supplied historical range.
        :raises TimeoutException: If the Apply button or the picker were not present.
        """
        self._historical_picker.set_range(historical_range=historical_range)
        if apply:
            self._apply_button.click()
        elif apply is not None:  # False
            self._cancel_button.click()
        else:  # take no action at all
            pass

    def set_realtime_range(self, time_value: int) -> None:
        """
        Set the numeric range to be used with the time unit.

        :param time_value: The scalar amount of time you desire to apply as the Realtime range.

        :raises AssertionError: If unsuccessful in applying the supplied time value.
        """
        self._realtime_range.set_time_value(
            time_value=time_value)
        
    def start_time_hour_input_is_enabled(self) -> bool:
        """
        Determine if the starting time hour input is enabled.

        :returns: True, if the hours input for the starting date of the range is enabled - False otherwise..

        :raises TimeoutException: If the picker is not present.
        """
        return self._historical_picker.start_time_hour_input_is_enabled()
        
    def start_time_minute_input_is_enabled(self) -> bool:
        """
        Determine if the starting time minute input is enabled.

        :returns: True, if the minutes input for the starting date of the range is enabled - False otherwise..

        :raises TimeoutException: If the picker is not present.
        """
        return self._historical_picker.start_time_minute_input_is_enabled()
        
    def start_time_second_input_is_enabled(self) -> bool:
        """
        Determine if the starting time second input is enabled.

        :returns: True, if the seconds input for the starting date of the range is enabled - False otherwise..

        :raises TimeoutException: If the picker is not present.
        """
        return self._historical_picker.start_time_second_input_is_enabled()

    def tab_is_active(self, tab: DateRangeSelectorTab) -> bool:
        """
        Determine if the supplied tab is currently the active tab.

        :param tab: The tab which will have its active status queried.

        :returns: True, if the supplied tab is active - False otherwise.

        :raises TimeoutException: If the Date Range Selector is not present.
        """
        _tab_component = self._historical_tab if tab == DateRangeSelectorTab.HISTORICAL else self._realtime_tab
        return self.ACTIVE_TAB_CLASS in _tab_component.find().get_attribute("class")

Components/PerspectiveComponents/Common/DateRangeSelector.py
inductiveautomation/ignition-automation-tools
import calendar
from time import sleep
from typing import Optional, Union, Tuple, List

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.select import Select

from Components.BasicComponent import ComponentPiece
from Components.Common.TextInput import CommonTextInput
from Helpers.IAAssert import IAAssert


class PerspectiveDate:
    """
    Perspective inputs use a unique take on dates, and formatting applied to these inputs can result in some values
    making no sense. Is a supplied time of 11:12:13 AM or PM? Should users specify a month by name or index? If index,
    is the month zero-indexed or one-indexed? By forcing interaction with these components to use a uniform class, we
    alleviate many pain points and a lot of confusion.
    """

    def __init__(
            self,
            year: Optional[int] = None,
            month: Optional[Union[int, str]] = None,
            day: Optional[int] = None,
            hours24: Optional[int] = None,
            minutes: Optional[int] = None,
            seconds: Optional[int] = None):
        """
        Create a date-type object Perspective can actually do something with.
        :param year: The numeric year of this date.
        :param month: accepts int or str (1, or "January"), but will be coerced into an int.
        :param day: The numeric day you'd like to select.
        :param hours24: The hours you'd like to select in 24-hour time. The am/pm value will be derived from this
            value, as will the hours12 value.
        :param minutes: The numeric minute of this date/time object.
        :param seconds: The numeric seconds of this date/time object.
        """
        self.year = year
        self._set_month(month=month)  # accept int/str but refine to int
        self.day = day
        self.hours24 = hours24 % 24 if hours24 else hours24
        self._set_hours12()  # calculation to be made
        self._set_am_pm()  # calculation to be made
        self.minutes = minutes
        self.seconds = seconds

    def __str__(self) -> str:
        """Provide a human-readable string representation of this object. Fill missing pieces with "X"."""
        filler = "X"
        return f"{self.month or filler}/{self.day or filler}/{self.year or filler} " \
               f"{self.hours24 or 2 * filler}:{self.minutes or 2 * filler}:{self.seconds or 2 * filler} (24-hour time)"

    def get_new_date_with_offset(
            self,
            year_offset: Optional[int] = None,
            month_offset: Optional[int] = None,
            day_offset: Optional[int] = None,
            hours24_offset: Optional[int] = None,
            minutes_offset: Optional[int] = None,
            seconds_offset: Optional[int] = None):
        """
        Obtain a new date with aspects of the current ('base') date modified by some value.

        :param year_offset: The count of years to add to the base date.
        :param month_offset: The count of months to add to the base date.
        :param day_offset: The count of days to add to the base date.
        :param hours24_offset: The count of hours to add to the base date. Must be less than 24. If larger than 24,
            combine with day_offset.
        :param minutes_offset: The count of minutes to add to the base date. Must be between -60 and 60. If the absolute
            value would be larger than 60, combine with hours24_offset.
        :param seconds_offset: The count of seconds to add to the base date. Must be between -60 and 60. If the absolute
            value would be larger than 60, combine with minutes_offset.

        :returns: A new Perspective Date object offset by some values from a base Perspective Date.

        :raises AssertionError: if the 'base' date being modified does not contain a value for some aspect which has
            been specified for modification, or if day offset values which would result in a day of a new month are
            supplied.
        """
        new_year = self.year
        new_month = self.month
        new_day = self.day
        new_hours24 = self.hours24
        new_minutes = self.minutes
        new_seconds = self.seconds
        if year_offset:
            IAAssert.is_true(
                value=self.year is not None,
                failure_msg="The supplied date had no year to offset from.")
            new_year = self.year + year_offset
        if month_offset:
            IAAssert.is_true(
                value=self.month is not None,
                failure_msg="The supplied date had no month to offset from.")
            new_month = self.month + month_offset
            if new_month < 1:
                new_month = 12 + (new_month % 12)
        if day_offset:
            IAAssert.is_true(
                value=self.day is not None,
                failure_msg="The supplied date had no day to offset from.")
            IAAssert.is_greater_than(
                left_value=day_offset + self.day,
                right_value=0,
                failure_msg="Unable to calculate day with negative offset which would result in new day being "
                            "calculated from end of month because we have no way of knowing what month is being "
                            "interacted with.")
            IAAssert.is_less_than_or_equal_to(
                left_value=day_offset + self.day,
                right_value=31,
                failure_msg="There can only be up to 31 days in a month.")
            new_day = self.day + day_offset
            # no special handling for new day because assertions prevent next/previous month checking
        if hours24_offset:
            new_hours24 = self.hours24 + hours24_offset
            if new_hours24 < 0:
                new_hours24 = 24 - (new_hours24 % 24)
            IAAssert.is_true(
                value=self.hours24 is not None,
                failure_msg="The supplied date had no hours to offset from.")
            IAAssert.is_less_than(
                left_value=abs(hours24_offset),
                right_value=24,
                failure_msg="There are only 0-23:59 hours in a day; please modify the day offset for offsets greater "
                            "than 24 hours.")
        if minutes_offset:
            IAAssert.is_true(
                value=self.minutes is not None,
                failure_msg="The supplied date had no minutes to offset from.")
            IAAssert.is_less_than(
                left_value=abs(minutes_offset),
                right_value=60,
                failure_msg="There are only 0-59 minutes in an hour; please modify the hour offset instead for "
                            "offsets greater than 60 minutes.")
            new_minutes = self.minutes + minutes_offset
            if new_minutes < 0:
                new_minutes = 60 - (new_minutes % 60)
        if seconds_offset:
            IAAssert.is_true(
                value=self.seconds is not None,
                failure_msg="The supplied date had no seconds to offset from.")
            IAAssert.is_less_than(
                left_value=abs(seconds_offset),
                right_value=60,
                failure_msg="There are only 0-59 seconds in a minute; please modify the minute offset instead for "
                            "offsets greater than 60 seconds.")
            new_seconds = self.seconds + seconds_offset
            if new_seconds < 0:
                new_seconds = 60 - (new_seconds % 60)
        return PerspectiveDate(
            year=new_year,
            month=new_month,
            day=new_day,
            hours24=new_hours24,
            minutes=new_minutes,
            seconds=new_seconds
        )

    def _set_am_pm(self) -> None:
        """
        Apply the 12 hour meridiem to use based on the 24-hour supplied value.
        """
        if self.hours24:
            self.am_pm = "am" if self.hours24 < 12 else "pm"
        else:
            self.am_pm = None

    def _set_hours12(self) -> None:
        """
        Apply a 12-hour (am/pm) value for instances where we might need to use a 12-hour format.
        """
        if self.hours24 is not None:
            self.hours12 = self.hours24 - 12 if self.hours24 > 12 else self.hours24
        else:
            self.hours12 = None

    def _set_month(self, month: Union[int, str]) -> None:
        """
        Determine and apply a numeric month based on whether the user supplied the month as a number or a string.
        """
        try:
            self.month = int(month)
        except ValueError:
            # string
            month_dict = {v: k for k, v in enumerate(calendar.month_name)}
            self.month = month_dict[month]
        except TypeError:
            # None
            self.month = month


class CommonDateTimePicker(ComponentPiece):
    """
    A common DateTime Picker component piece, used in not only the Perspective DateTime Input/Picker components, but
    also instances like the Alarm table date range selectors.
    """
    _NEXT_MONTH_CHEVRON_LOCATOR = (By.CSS_SELECTOR, 'div[data-next-month="true"]')
    _PREVIOUS_MONTH_CHEVRON_LOCATOR = (By.CSS_SELECTOR, 'div[data-prev-month="true"]')
    _CLEAR_RANGE_LOCATOR = (By.CSS_SELECTOR, 'a[class*="__clearRange"]')
    _MONTH_SELECT_LOCATOR = (By.CSS_SELECTOR, 'div.monthSelector select')
    _MONTH_OPTIONS_LOCATOR = f'{_MONTH_SELECT_LOCATOR[1]} option'
    _YEAR_SELECT_LOCATOR = (By.CSS_SELECTOR, 'div.yearSelector select')
    _NEXT_HOUR_SPINNER_LOCATOR = (By.CSS_SELECTOR, 'button[data-spinner-next-hour="true"]')
    _NEXT_MINUTE_SPINNER_LOCATOR = (By.CSS_SELECTOR, 'button[data-spinner-next-minute="true"]')
    _NEXT_SECOND_SPINNER_LOCATOR = (By.CSS_SELECTOR, 'button[data-spinner-next-second="true"]')
    _PREVIOUS_HOUR_SPINNER_LOCATOR = (By.CSS_SELECTOR, 'button[data-spinner-prev-hour="true"]')
    _PREVIOUS_MINUTE_SPINNER_LOCATOR = (By.CSS_SELECTOR, 'button[data-spinner-prev-minute="true"]')
    _PREVIOUS_SECOND_SPINNER_LOCATOR = (By.CSS_SELECTOR, 'button[data-spinner-prev-second="true"]')
    _CALENDAR_LOCATOR = (By.CSS_SELECTOR, "div.calendar")
    _HOURS_INPUT_LOCATOR = (By.CSS_SELECTOR, "input.hours")
    _MINUTES_INPUT_LOCATOR = (By.CSS_SELECTOR, "input.minutes")
    _SECONDS_INPUT_LOCATOR = (By.CSS_SELECTOR, "input.seconds")
    _AM_PM_PICKER_LOCATOR = (By.CSS_SELECTOR, "div.timePickerAmPmPicker select")
    # the "filler" class is attached to days outside the current month
    _GENERIC_DAY_LOCATOR = (By.CSS_SELECTOR, f'div[class*="dayTile"]:not([class*="filler"])')  # must be this format
    # disabled days are still "active" because they're in the current month, but they are also "out of range"; enabled
    # days will have none of these three class (including "filler").
    _ENABLED_DAY_LOCATOR = (
        By.CSS_SELECTOR, f'{_GENERIC_DAY_LOCATOR[1]}:not([class*="outOfRange"])')
    _SELECTED_DAYS_SELECTOR = (By.CSS_SELECTOR, "div.node")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: List[Tuple[By, str]],
            wait_timeout: float = 1,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._next_month_chevron = ComponentPiece(
            locator=self._NEXT_MONTH_CHEVRON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._previous_month_chevron = ComponentPiece(
            locator=self._PREVIOUS_MONTH_CHEVRON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._clear_link = ComponentPiece(
            locator=self._CLEAR_RANGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._month_dropdown = ComponentPiece(
            locator=self._MONTH_SELECT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._year_dropdown = ComponentPiece(
            locator=self._YEAR_SELECT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._calendar_container = ComponentPiece(
            locator=self._CALENDAR_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._day_tiles_dict = {}
        self._enabled_days = ComponentPiece(
            locator=self._ENABLED_DAY_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
        self.next_hour_chevron = ComponentPiece(
            locator=self._NEXT_HOUR_SPINNER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self.previous_hour_chevron = ComponentPiece(
            locator=self._PREVIOUS_HOUR_SPINNER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self.next_minute_chevron = ComponentPiece(
            locator=self._NEXT_MINUTE_SPINNER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self.next_second_chevron = ComponentPiece(
            locator=self._NEXT_SECOND_SPINNER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self.previous_minute_chevron = ComponentPiece(
            locator=self._PREVIOUS_MINUTE_SPINNER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._hours_input = CommonTextInput(
            locator=self._HOURS_INPUT_LOCATOR, driver=driver, parent_locator_list=parent_locator_list, wait_timeout=1)
        self._minutes_input = CommonTextInput(
            locator=self._MINUTES_INPUT_LOCATOR, driver=driver, parent_locator_list=parent_locator_list, wait_timeout=1)
        self._seconds_input = CommonTextInput(
            locator=self._SECONDS_INPUT_LOCATOR, driver=driver, parent_locator_list=parent_locator_list, wait_timeout=1)
        self._am_pm_picker = ComponentPiece(
            locator=self._AM_PM_PICKER_LOCATOR, driver=driver, parent_locator_list=parent_locator_list, wait_timeout=1)
        self._selected_days = ComponentPiece(
            locator=self._SELECTED_DAYS_SELECTOR, driver=driver, parent_locator_list=self.locator_list)

    def click_next_month(self) -> None:
        """Click the next month chevron/arrow."""
        self._next_month_chevron.click()

    def click_previous_month(self) -> None:
        """Click the previous month chevron/arrow."""
        self._previous_month_chevron.click()

    def get_am_pm(self) -> str:
        """
        Obtain the current meridiem from the picker.

        :returns: A value of 'am', 'pm'. A value of None will be returned if no AM/PM picker is present.
        """
        return self._am_pm_picker.find().get_attribute("value")

    def get_enabled_days(self) -> List[int]:
        """
        Obtain a list of all enabled days for the current month.

        :returns: A numeric list of all days for the current month which are enabled.
        :raises TimeoutException: If the Historical picker is not present.
        """
        return [int(_.text) for _ in self._enabled_days.find_all()]

    def get_hours(self) -> int:
        """
        Obtain the current numeric hour selection.

        :raises TimeoutException: If the current format is preventing the display of the hours input.
        """
        return int(self._hours_input.find().get_attribute("value"))

    def get_list_of_available_months(self) -> List[str]:
        """
        Obtain a list of names of the months available for selection in this picker.

        :returns: a list of string names of months which are currently enabled.
        """
        all_options = Select(webelement=self._month_dropdown.find()).options
        enabled_options = list(filter(lambda e: e.is_enabled(), all_options))
        return [option.text for option in enabled_options]

    def get_list_of_available_years(self) -> List[int]:
        """
        Obtain a list of all the years available for selection in this picker.

        :returns: A list of integers which make up the years available for selection in the picker.
        """
        all_options = Select(webelement=self._year_dropdown.find()).options
        enabled_options = list(filter(lambda e: e.is_enabled(), all_options))
        return [int(option.text) for option in enabled_options]

    def get_minutes(self) -> int:
        """
        Obtain the current minute value in use by the picker.

        :raises TimeoutException: If the current format is preventing the display of the minutes input.
        """
        return int(self._minutes_input.find().get_attribute("value"))

    def get_selected_days(self) -> List[int]:
        """
        Obtain a list of all the days currently selected within the picker. For ranges, this includes all days between
        the start and end of the range.

        :returns: A list of numeric days which display as selected in the picker.
        """
        try:
            return [int(_.text) for _ in self._selected_days.find_all()]
        except TimeoutException:
            return []

    def get_seconds(self) -> int:
        """
        Obtain the current seconds value of the picker.

        :raises TimeoutException: If the picker is rendering in a format that does not allow for specifying seconds.
        """
        return int(self._seconds_input.find().get_attribute("value"))

    def get_selected_month(self) -> str:
        """
        Obtain the name of the month currently selected for the picker.

        :returns: The full name of the currently selected month.
        """
        return Select(self._month_dropdown.find()).first_selected_option.get_attribute("value")

    def get_selected_year(self) -> int:
        """Obtain the currently selected year of the picker."""
        return int(Select(self._year_dropdown.find()).first_selected_option.text)

    def hour_input_is_enabled(self) -> bool:
        """
        Determine if the hour input field is currently enabled.

        :returns: True, if the hour input is enabled - False otherwise.
        """
        try:
            return self._hours_input.find().is_enabled()
        except TimeoutException:
            return False

    def hour_input_is_visible(self) -> bool:
        """
        Determine if the hour input field is currently visible.

        :returns: True, if the hour input is displayed - False otherwise.
        """
        try:
            return self._hours_input.find().is_displayed()
        except TimeoutException:
            return False

    def minute_input_is_enabled(self) -> bool:
        """
        Determine if the minute input field is currently enabled.

        :returns: True, if the minute input is enabled - False otherwise.
        """
        try:
            return self._minutes_input.find().is_enabled()
        except TimeoutException:
            return False

    def minute_input_is_visible(self) -> bool:
        """
        Determine if the minute input field is currently visible.

        :returns: True, if the minute input is visible - False otherwise.
        """
        try:
            return self._minutes_input.find().is_displayed()
        except TimeoutException:
            return False

    def seconds_input_is_enabled(self) -> bool:
        """
        Determine if the seconds input field is currently enabled.

        :returns: True, if the seconds input is enabled - False otherwise.
        """
        try:
            return self._seconds_input.find().is_enabled()
        except TimeoutException:
            return False

    def seconds_input_is_displayed(self) -> bool:
        """
        Determine if the seconds input field is currently visible.

        :returns: True, if the seconds input is visible - False otherwise.
        """
        try:
            return self._seconds_input.find().is_displayed()
        except TimeoutException:
            return False

    def select_date(self, date: PerspectiveDate) -> None:
        """
        Select a date in the picker.

        :param date: The date to select, as an object.

        :raises AssertionError: If unsuccessful in applying the supplied date.
        """
        # date must be selected BEFORE time
        self.select_date_only_not_time(date=date)
        self.select_time_only_not_date(date=date)

    def select_date_only_not_time(self, date: PerspectiveDate) -> None:
        """
        Select only a date (year, month, day), while ignoring any time values.

        :param date: The date to select, as an object. The time values of this object will be ignored.

        :raises AssertionError: If unsuccessful in applying any piece if the date.
        """
        if date.year is not None:
            self.select_year(year=date.year)
        if date.month is not None:
            self.select_month(month=date.month)
        if date.day is not None:
            self._click_day(numeric_day=date.day)

    def select_month(self, month: Union[int, str]) -> None:
        """
        Select a month by using the chevrons/arrows. Selenium has issues with the dropdowns when they are opened in
        Popups, so we avoid their use at all.

        :param month: The month to select. This may be a numeric representation or the string name of the month.

        :raises AssertionError: If the picker could not apply the month.
        """
        try:
            month = int(month)
        except ValueError:
            month_dict = {v: k for k, v in enumerate(calendar.month_name)}
            month = month_dict[month]
        selected_month_value = self._month_value()
        count = 0
        while selected_month_value != month and count < 12:
            self._next_month_chevron.find().click() if selected_month_value < month \
                else self._previous_month_chevron.find().click()
            selected_month_value = self._month_value()
            count += 1
        IAAssert.is_equal_to(
            actual_value=selected_month_value,
            expected_value=month,
            as_type=str,
            failure_msg=f"Failed to set the month to a different 1-indexed value ({month}).")

    def select_time_only_not_date(self, date: PerspectiveDate) -> None:
        """
        Select only a time (hours, minutes, seconds), while ignoring any date values.

        :param date: The date to select, as an object. Only the time piece of the date object will be used.

        :raises AssertionError: If unable to select the supplied time.
        """
        # seconds
        seconds = date.seconds
        if seconds is not None:
            current_seconds = int(self.get_seconds())
            if self._seconds_input.find().is_enabled():
                target = self.previous_minute_chevron if int(seconds) < int(current_seconds) else \
                    self.next_second_chevron
                while current_seconds != int(seconds):
                    target.click(binding_wait_time=0.5)
                    new_seconds = int(self.get_seconds())
                    if new_seconds != current_seconds:
                        current_seconds = new_seconds
                    else:
                        break
            IAAssert.is_equal_to(
                actual_value=current_seconds,
                expected_value=seconds,
                as_type=int,
                failure_msg=f"Failed to set the seconds to a different value ({seconds}).")
        # minutes
        minutes = date.minutes
        if minutes is not None:
            current_minutes = int(self.get_minutes())
            if self._minutes_input.find().is_enabled():
                target = self.previous_minute_chevron if int(minutes) < int(current_minutes) else \
                    self.next_minute_chevron
                while current_minutes != int(minutes):
                    target.click(binding_wait_time=0.5)
                    new_minutes = int(self.get_minutes())
                    if new_minutes != current_minutes:
                        current_minutes = new_minutes
                    else:
                        break
            IAAssert.is_equal_to(
                actual_value=current_minutes,
                expected_value=minutes,
                as_type=int,
                failure_msg=f"Failed to set the minutes to a different value ({minutes}).")
        # hours AND am/pm
        if date.am_pm:  # implies any hours are supplied
            try:
                if date.hours12 is not None and self._hours_input.find().is_enabled():
                    self._set_hours(date.hours12)
                self._am_pm_picker.find().click()
                Select(webelement=self._am_pm_picker.find()).select_by_visible_text(text=date.am_pm.upper())
                # TODO: There is an issue with Selenium/FF where the date time picker is collapsed
                #  after selecting am/pm. With that being the case, the following assertions will fail.
                #
                # IAAssert.is_equal_to(
                #     actual_value=self.get_hours(),
                #     expected_value=date.hours12,
                #     as_type=str,
                #     failure_msg=f"Failed to set the hours to a different value ({date.hours12}).")
                # IAAssert.is_equal_to(
                #     actual_value=Select(webelement=self._am_pm_picker.find()).first_selected_option.text.upper(),
                #     expected_value=date.am_pm.upper(),
                #     as_type=str,
                #     failure_msg="Failed to select am/pm.")
            except TimeoutException:
                # AM/PM Select might not be present, depending on locale
                if date.hours24 is not None:
                    self._set_hours(date.hours24)
                IAAssert.is_equal_to(
                    actual_value=self.get_hours(),
                    expected_value=date.hours24,
                    as_type=str,
                    failure_msg=f"Failed to set the hours to a different value ({date.hours24}).")

    def select_year(self, year: Union[int, str]) -> None:
        """
        Select a year by using the chevrons/arrows. Selenium has issues with the dropdowns when they are opened in
        Popups, so we avoid their use at all.

        :param year: The year to select.

        :raises AssertionError: If the picker could not apply the year.
        """
        max_distance = 120
        count = 0
        selected_year = Select(self._year_dropdown.find()).first_selected_option.text
        year = str(year)
        while selected_year != year and count < max_distance:
            self._next_month_chevron.find().click() if selected_year < year \
                else self._previous_month_chevron.find().click()
            selected_year = Select(self._year_dropdown.find()).first_selected_option.text
            count += 1
        IAAssert.is_equal_to(
            actual_value=selected_year,
            expected_value=year,
            as_type=int,
            failure_msg=f"Failed to set the year to a different value ({year}).")

    def set_time(self, date: PerspectiveDate) -> None:
        """
        Set the time in use by the picker.

        :param date: The date object which contains the time values we will use.

        :raises TimeoutException: If the picker is using a format which does not allow for some unit of the supplied
            time (eg: seconds are supplied, but a format which does not support seconds is in use).
        """
        self.select_time_only_not_date(date=date)

    def _click_day(self, numeric_day: Union[int, str]) -> None:
        """
        Click a specific day tile.

        :param numeric_day: The number fo the day we will click.

        :raises TimeoutException: If no such day exists for the given month.
        """
        # unable to safely assert here because DateTime Inputs will collapse
        self._get_day_tile(numeric_day=numeric_day).click()

    def _get_day_tile(self, numeric_day: Union[int, str]) -> ComponentPiece:
        """
        Obtain a specific day tile.

        :param numeric_day: the 'text' of the day to obtain as a number.
        """
        numeric_day = str(numeric_day)
        tile_component_piece = self._day_tiles_dict.get(numeric_day)
        if not tile_component_piece:
            tile_component_piece = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'>div[data-day="{numeric_day}"]:not([class*="filler"])'),
                driver=self.driver,
                parent_locator_list=self._calendar_container.locator_list,
                poll_freq=self.poll_freq)
            self._day_tiles_dict[numeric_day] = tile_component_piece
        return tile_component_piece

    def _month_value(self) -> int:
        """Obtain the value of the month dropdown, which stores months as numbers."""
        return int(self.driver.execute_script('return arguments[0].value;', self._month_dropdown.find()))

    def _set_hours(self, hours: int) -> None:
        """
        Set the hours in use by the input by clicking the up/down chevrons/arrows.

        :param hours: The hour to select.

        :raises AssertionError: If the supplied hour could not be applied.
        """
        current_hours = int(self.get_hours())
        target = self.previous_hour_chevron if int(hours) < current_hours else self.next_hour_chevron
        while current_hours != int(hours):
            target.click()
            sleep(0.5)
            new_hours = int(self.get_hours())
            if new_hours != current_hours:
                current_hours = new_hours
            else:
                break

Components/PerspectiveComponents/Common/DateTimePicker.py
inductiveautomation/ignition-automation-tools
import json
from json import JSONDecodeError
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Helpers.Point import Point


class CommonDropdown(ComponentPiece):
    """
    A common-use Dropdown, used by the Dropdown component a well as numerous larger components which provide the ability
    to select options form a list.
    """
    COMMON_CLASS = "iaDropdownCommon"
    ACTIVE_CLASS = "ia_dropdown--active"
    _FOCUSED_CLASS = "ia_dropdown--focused"
    _DISABLED_CLASS = 'ia_dropdown__option--disabled'
    _EXPAND_ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.iaDropdownCommon_expandIcon')
    _OPTIONS_CONTAINER = (By.CSS_SELECTOR, 'div.iaDropdownCommon_options')
    _AVAILABLE_OPTION_LOCATOR = (By.CSS_SELECTOR, 'a.iaDropdownCommon_option:not(.ia_dropdown__option__noResults)')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._expand_icon = ComponentPiece(
            locator=self._EXPAND_ICON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._options_modal = ComponentModal(
            driver=self.driver,
            poll_freq=poll_freq)
        self._options_container = ComponentPiece(
            locator=self._OPTIONS_CONTAINER,
            driver=self.driver,
            parent_locator_list=self._options_modal.locator_list,
            poll_freq=poll_freq)
        self._available_options = ComponentPiece(
            locator=self._AVAILABLE_OPTION_LOCATOR,
            driver=driver,
            parent_locator_list=self._options_container.locator_list,
            poll_freq=poll_freq)
        self._option_elements = {}

    def collapse_if_expanded(self) -> None:
        """
        Collapse the dropdown if it is already expanded. No action is taken if the dropdown is currently collapsed.
        """
        if self.is_expanded():
            self.wait_on_binding(0.5)
            # counter-intuitive, but the expand and collapse icons share the same locator
            self.toggle_expansion()

    def get_width_and_height_of_first_available_option(self) -> Tuple[str, str]:
        """
        Obtain the width and height of the first available option within the Dropdown.

        :returns: A Tuple with two elements, where the 0th element is the width (in pixels) and the 1th element is the
        height (in pixels) as strings.

        :raises TimeoutException: If no options are currently displayed.
        """
        return self._available_options.get_computed_width(include_units=False), \
            self._available_options.get_computed_height(include_units=False)

    def expand_if_collapsed(self) -> None:
        """
        Expand the dropdown if it is collapsed. No action is taken if the dropdown is currently expanded.
        """
        if not self.is_expanded():
            self.toggle_expansion()

    def get_available_options(self) -> List[str]:
        """
        Expand the dropdown (if necessary), collect all displayed options, then collapse the dropdown before returning
        the displayed options as a list.

        :returns: A list where each item is an option displayed within the Dropdown when expanded.
        """
        needs_to_collapse = not self.is_expanded()
        self.expand_if_collapsed()
        try:
            options = [option.text for option in self._available_options.find_all(wait_timeout=0.5)]
        except TimeoutException:
            options = []
        if needs_to_collapse:
            self.collapse_if_expanded()
        return options

    def get_height_of_option_modal(self, include_units: bool = False) -> str:
        """
        Obtain the height of the modal which contains the options of the Dropdown. Requires that the Dropdown be
        expanded first.

        :param include_units: Dictates whether the returned value includes units of measurement.

        :returns: The height of the modal which contains the options of the Dropdown, as a string.

        :raises TimeoutException: If the modal is not present - most likely because the Dropdown was not expanded first.
        """
        return self._options_modal.get_computed_height(include_units=include_units)

    def get_origin_of_options_modal(self) -> Point:
        """
        Obtain the location of the upper-left corner of the modal which contains the options of the Dropdown. Requires
        that the Dropdown be expanded to display its options.

        :returns: A two-dimensional point which represents the location of the upper-left corner of the modal.

        :raises TimeoutException: If the modal is not present - most likely because the Dropdown was not expanded first.
        """
        return self._options_modal.get_origin()

    def get_selected_options_as_list(self) -> List[str]:
        """
        Obtain a list of all options which are displayed as currently selected by the Dropdown.

        :returns: A list, where each element of the list is an option currently selected within the Dropdown.
        """
        selected_labels = self.find().get_attribute("data-selected-labels")  # multi-select dropdowns
        if selected_labels is None:  # single-select handling
            return [self.find().get_attribute('data-label')]
        try:
            if selected_labels and "[" in selected_labels:
                selected_labels = json.loads(selected_labels)
            else:
                selected_labels = [json.loads(selected_labels)]
        except JSONDecodeError:
            return []
        return selected_labels

    def get_termination_of_options_modal(self) -> Point:
        """
        Obtain the location of the bottom-right corner of the modal which contains the options of the Dropdown. Requires
        that the Dropdown be expanded to display its options.

        :returns: A two-dimensional point which represents the location of the bottom-right corner of the modal.

        :raises TimeoutException: If the modal is not present - most likely because the Dropdown was not expanded first.
        """
        return self._options_modal.get_termination()

    def is_collapsed(self) -> bool:
        """
        Determine if the Dropdown is currently collapsed.

        :returns: True, if the Dropdown is currently collapsed.
        """
        try:
            return self.wait.until(IAec.function_returns_false(custom_function=self._is_expanded, function_args={}))
        except TimeoutException:
            return False

    def is_expanded(self) -> bool:
        """
        Determine if the Dropdown is currently expanded.

        :returns: True, if the Dropdown is currently expanded.
        """
        try:
            return self.wait.until(IAec.function_returns_true(custom_function=self._is_expanded, function_args={}))
        except TimeoutException:
            return False

    def option_is_disabled(self, option_text: str) -> bool:
        """
        Determine if an option of the Dropdown is currently disabled.

        :param option_text: The exact text of the option we will verify as being disabled.

        :returns: True, if the supplied option is currently disabled and therefore not available for selection.

        :raises TimeoutException: If the supplied option is not present.
        """
        self.expand_if_collapsed()
        option_disabled = self._DISABLED_CLASS in self._get_option(
            option_text=option_text).find().get_attribute("class")
        self.collapse_if_expanded()
        return option_disabled

    def option_is_visible_within_option_modal(self, option_text: str) -> bool:
        """
        Determine if an option is visible within the modal which displays options.

        :param option_text: The text of the option you wish to verify is contained within the modal.

        :returns: True, if the supplied option is present and is rendered within the boundaries of the modal - False if
            the option is not displayed at all, or if the option is rendering even partially outside the bounds of the
            modal - including options which must be scrolled to.
        """
        modal = ComponentModal(driver=self.driver)
        try:
            visible = self._get_option(option_text=option_text).find().is_displayed()
        except TimeoutException:  # option not even found
            return False
        if not visible:
            # don't bother checking anything else
            return False
        option_origin = self._get_option(option_text=option_text).get_origin()
        option_termination = self._get_option(option_text=option_text).get_termination()
        modal_origin = modal.get_origin()
        modal_termination = modal.get_termination()
        left_within_modal = option_origin.X >= modal_origin.X
        top_within_modal = option_origin.Y >= modal_origin.Y
        right_within_modal = option_termination.X <= modal_termination.X
        bottom_within_modal = option_termination.Y <= modal_termination.Y
        return visible and left_within_modal and top_within_modal and right_within_modal and bottom_within_modal

    def select_option_by_text_if_not_selected(self, option_text: str, binding_wait_time: float = 0.5) -> None:
        """
        Select an option from the Dropdown if it is not already selected. No action is taken if the supplied option is
        already selected.

        :param option_text: The exact text of the option to select.
        :param binding_wait_time: The amount of time (in seconds) to wait after selecting the option before allowing
            code to continue. Ignored if the option is already selected.

        :raises TimeoutException: If the specified option is not present.
        :raises AssertionError: If unsuccessful in selecting the specified option.
        """
        if option_text not in self.get_selected_options_as_list():
            self.scroll_to_element()
            self.expand_if_collapsed()
            try:
                self._get_option(option_text=option_text).click(wait_timeout=1, binding_wait_time=binding_wait_time)
            except TimeoutException as toe:
                raise TimeoutException(msg=f"Failed to locate element with text of \"{option_text}\".") from toe
        assert option_text in self.get_selected_options_as_list(), f"Failed to select option: '{option_text}'."

    def toggle_expansion(self) -> None:
        """
        Expands the Dropdown if it is collapsed, or collapses the dropdown if it is expanded.
        """
        self._expand_icon.click()

    def _is_expanded(self) -> bool:
        """
        Determine if the Dropdown is currently expanded (without waiting for the Dropdown to become expanded).

        :returns: True, if the Dropdown is currently expanded - False otherwise.
        """
        clazz = self.find().get_attribute("class")
        try:
            return (self.ACTIVE_CLASS in clazz) and (self._options_container.find(wait_timeout=0) is not None)
        except TimeoutException:
            return False

    def _get_option(self, option_text: str) -> ComponentPiece:
        """
        Obtain the ComponentPiece which defines an option within the modal.

        :param option_text: The exact text of the option.

        :returns: A ComponentPiece which defines the option of the Dropdown as it will be located within the modal
            during expansion.
        """
        option_element = self._option_elements.get(option_text)
        if not option_element:
            option_element = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'a[data-label="{option_text}"]'),
                driver=self.driver,
                parent_locator_list=self._options_container.locator_list,
                poll_freq=self.poll_freq)
            self._option_elements[option_text] = option_element
        return option_element

Components/PerspectiveComponents/Common/Dropdown.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, Optional, List

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Helpers.CSSEnumerations import CSS


class CommonIcon(ComponentPiece):
    """An Icon which could be used anywhere within Perspective, including within other components."""
    # This list will grow over time, until Dev takes this on as a unit test for their Icon strip-and-zip
    _KNOWN_EXTRANEOUS_D_ATTRIBUTE_LIST = [
        'M24 24H0V0h24v24z',
        'M0,0h24v24H0V0z',
        'M0,0h24 M24,24H0'
    ]

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._children = ComponentPiece(
            locator=(By.CSS_SELECTOR, "g > *"),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._bad_borders = [
            ComponentPiece(
                locator=(By.CSS_SELECTOR, f'path[d="{bad_border}"]'),
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq) for bad_border in self._KNOWN_EXTRANEOUS_D_ATTRIBUTE_LIST
        ]
        self._internal_g = ComponentPiece(
            locator=(By.CSS_SELECTOR, "g > g"),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def get_fill_color(self) -> str:
        """
        Obtain the fill in use by the Icon. In the event any children (internal <g> elements) of the Icon have a
        different fill, return that value instead.

        :returns: The fill color in use by the Icon. As a way to verify any potential children of the icon are
            displaying the same expected color, this function will return any mismatched child color instead.
        """
        local_fill = self.get_css_property(property_name=CSS.FILL)
        try:
            for child in self._children.find_all(wait_timeout=1):
                child_fill = child.value_of_css_property("fill")  # WebElement (WE), so have to use WE function
                if child_fill != local_fill and child_fill != 'none':  # ignore 'none' because we have 0 control over it
                    return child_fill
        except TimeoutException:
            pass
        return local_fill

    def get_icon_name(self) -> str:
        """
        Obtain the library and id of the Icon as a slash-delimited string.

        Example: material/perm_identity denotes the 'perm_identity' svg from the 'material' library.

        :returns: The library and id of the svg in use by the Icon Component as a slash-delimited string.

        raises: ValueError if the internal <g> element claims to be using a different id than the top-level
        component declares.
        """
        library_and_name = self.find().get_attribute("data-icon")
        name_in_use = self._internal_g.find().get_attribute("id")
        if name_in_use != library_and_name.split("/")[1]:
            raise ValueError(f"The icon declared a library:name combo of {library_and_name} but is actually "
                             f"using {name_in_use} within the <g> element.")
        return library_and_name

    def get_stroke_color(self) -> str:
        """
        Obtain the stroke in use by the Icon. In the event any children (internal <g> elements) of the Icon have a
        different stroke, return that value instead.

        :returns: The stroke color in use by the Icon. As a way to verify any potential children of the icon are
            displaying the same expected color, this function will return any mismatched child color instead.
        """
        local_stroke = self.get_css_property(property_name=CSS.STROKE)
        try:
            for child in self._children.find_all(wait_timeout=1):
                child_stroke = child.value_of_css_property("stroke")  # WebElement (WE), so have to use WE function
                if child_stroke != local_stroke:
                    return child_stroke
        except TimeoutException:
            pass
        return local_stroke

    def get_viewbox_as_dict(self) -> dict:
        """
        Get the viewbox of the Icon as a Python dictionary.

        returns: A dictionary with the following keys: 'x', 'y', 'width', and 'height'.
        """
        viewbox_pieces = self.driver.execute_script(
            'return arguments[0].getAttribute("viewBox")', self.find()).split(" ")
        viewbox_as_dict = dict()
        viewbox_as_dict['x'] = viewbox_pieces[0]
        viewbox_as_dict['y'] = viewbox_pieces[1]
        viewbox_as_dict['width'] = viewbox_pieces[2]
        viewbox_as_dict['height'] = viewbox_pieces[3]
        return viewbox_as_dict

    def has_extraneous_border(self) -> bool:
        """
        Determine if this Icon has any unexpected border. Used internally to verify packaged Icons have no
        borders which would interfere with styling.

        :returns: True, if any internal border is found for the Icon - False otherwise.
        """
        for bad_border in self._bad_borders:
            try:
                return bad_border.find(wait_timeout=0) is not None
            except TimeoutException:
                pass
        return False

    def is_rendered(self) -> bool:
        """
        Determine if this Icon is actually rendered/displayed.

        :returns: True, if rendered - False otherwise.
        """
        try:
            return self._internal_g.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

Components/PerspectiveComponents/Common/Icon.py
inductiveautomation/ignition-automation-tools
from typing import List, Tuple
from typing import Optional

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.TablePieces.Body import Body
from Components.PerspectiveComponents.Common.TablePieces.HeaderAndFooter import Header
from Helpers.Point import Point


class Table(ComponentPiece):
    """A Common Table Component, containing ONLY content which would be found in ALL the following components:

    - Table
    - Alarm Journal Table
    - Alarm Status Table
    - Power Chart
    """

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._body = Body(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._header = Header(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)

    def descending_sort_order_is_active_for_column(self, column_id: str) -> bool:
        """
        Determine if the column is displaying in descending order.

        :returns: True, if the column is currently sorted in descending order - False otherwise.

        :raises TimeoutException: If no header is present.
        """
        return self._header.descending_sort_is_active(column_id=column_id)

    def get_cell_data_by_column_index(self, row_index: int, column_index: int) -> str:
        """
        Obtain the data of a specific cell within the Table.

        :param row_index: The zero-based index of the row the cell belongs to.
        :param column_index: The zero-based index of the column the cell belongs to.

        :returns: The data of the specified cell, as a string.

        :raises TimeoutException: If no rows are displayed within the Table, or if the supplied indexes are invalid
            based on the data of the Table.
        """
        return self._body.get_cell_data_by_row_index_column_index(
            zero_based_row_index=row_index, zero_based_column_index=column_index)

    def get_cell_data_by_column_id(self, row_index: int, column_id: str) -> str:
        """
        Obtain the data of a specific cell within the Table.

        :param row_index: The zero-based index of the row the cell belongs to.
        :param column_id: The id of the column the cell belongs to. Note that column ids are direct mappings to the
            keys of the data of the Table, and may not match the name of the column.

        :returns: The data of the specified cell, as a string.

        :raises TimeoutException: If no rows are displayed within the Table, or if the supplied index/id values are
            invalid based on the data of the Table.
        """
        return self._body.get_cell_data_by_row_index_column_id(
            zero_based_row_index=row_index, column_id=column_id)

    def get_cell_origin(self, row_index: int, column_index: int) -> Point:
        """
        Obtain a two-dimensional point in space which represents the top-left corner of the cell.

        :param row_index: The zero-based index of the row the cell belongs to.
        :param column_index: The zero-based index of the column the cell belongs to.

        :returns: The top-left corner of the cell as a two-dimensional point.

        :raises TimeoutException: If no rows are displayed within the Table, or if the supplied indexes are invalid
            based on the data of the Table.
        """
        return self._body.get_cell_origin(zero_based_row_index=row_index, zero_based_column_index=column_index)

    def get_cell_termination(self, row_index: int, column_index: int) -> Point:
        """
        Obtain a two-dimensional point in space which represents the bottom-right corner of the cell.

        :param row_index: The zero-based index of the row the cell belongs to.
        :param column_index: The zero-based index of the column the cell belongs to.

        :returns: The bottom-right corner of the cell as a two-dimensional point.

        :raises TimeoutException: If no rows are displayed within the Table, or if the supplied indexes are invalid
            based on the data of the Table.
        """
        return self._body.get_cell_termination(
            zero_based_row_index=row_index, zero_based_column_index=column_index)

    def get_column_count_from_body(self) -> int:
        """
        Obtain a count of columns from the BODY of the Table.

        :raises TimeoutException: If no rows are present in the body of the Table.
        """
        return self._body.get_column_count()

    def get_column_count_from_header(self) -> int:
        """
        Obtain a count of columns from the HEADER of the Table.

        :raises TimeoutException: If no columns are present in the header of the Table.
        """
        return self._header.get_count_of_columns()

    def get_column_index(self, column_id: str) -> int:
        """
        Obtain the index of a column, based on its id.

        :param column_id: The id of the column. Note that column ids are direct mappings to the keys of the data of the
            Table, and may not match the name of the column.

        :raises TimeoutException: If no column exists with the supplied id.
        """
        return self._header.get_column_index(
            column_id=column_id)

    def get_column_names_from_header(self) -> List[str]:
        """
        Obtain the column names displayed in the Header.

        :returns: All currently displayed column names from the header. Note that column names are not the same as
            column ids.

        :raises TimeoutException: If no header is present, or if no rows are displayed in the Table.
        """
        return self._header.get_column_names()

    def get_column_width(self, column_id: str, include_units: bool = False) -> str:
        """
        Obtain the width of a column.

        :param column_id: The id of the column from which you'd like the width.
        :param include_units: Dictates whether the returned value should include units of measurement (almost always
            'px').

        :returns: The width of the column as a string.

        :raises TimeoutException: If no header is present, or if no rows are displayed, or if no column exists with the
            supplied id.
        """
        return self._header.get_column_width(
            column_id=column_id, include_units=include_units)

    def get_column_max_width(self, column_id: str, include_units: bool = False) -> str:
        """
        Obtain the maximum width allowed to a column.

        :param column_id: The id of the column from which you'd like the max-width value.
        :param include_units: Dictates whether the returned value should include units of measurement (almost always
            'px').

        :raises TimeoutException: If no header is present, or if no rows are displayed, or if no column exists with the
            supplied id.
        """
        return self._header.get_column_max_width(
            column_id=column_id, include_units=include_units)

    def get_computed_height_of_body(self, include_units: bool = False) -> str:
        """
        Get the computed height of the component. Must return as a string because of the possibility of included units.

        :param include_units: Include the units of height (typically "px") if True, otherwise return only the numeric
        value (as a str).

        :returns: The height of the body of the Table as a string. May contain units based on include_units kwarg.

        :raises TimeoutException: If the component is not found in the DOM.
        """
        return self._body.get_computed_height(include_units=include_units)

    def get_count_of_header_columns(self) -> int:
        """Obtain a count of columns form the header."""
        return self._header.get_count_of_columns()

    def get_count_of_row_groups(self) -> int:
        """
        Obtain a count of row groups. Row groups are distinct from rows, in that row GROUPS can contain multiple rows
        and potentially subviews. They are a way of grouping row content which belongs together into one container.

        :returns: A numeric count of row groups currently displayed within the Table.
        """
        return self._body.get_row_group_count()

    def get_count_of_rows(
            self, include_expanded_subviews_in_count: bool = False, expected_count: Optional[int] = None) -> int:
        """
        Obtain a count of rows currently displayed in the Table.

        :param include_expanded_subviews_in_count: If True, subviews which are currently expanded will be counted as a
            row. If False, subviews will not be included in the returned count.
        :param expected_count: If supplied, we will attempt to wait until the supplied count is displayed before
            returning.

        :returns: A numeric count of rows currently displayed in the Table. This count may optionally contain expanded
            subviews.
        """
        return self._body.get_row_count(
            include_expanded_subviews_in_count=include_expanded_subviews_in_count,
            expected_count=expected_count)

    def get_empty_message_text(self) -> str:
        """
        Obtain the text of the message which is clarifying the Table contains no data.

        :returns: The text of the message displayed in the Table which informs users there is no data.

        :raises TimeoutException: If the Table is not currently informing the user there is no data.
        """
        return self._body.get_empty_message_text()

    def get_origin_of_column_in_header(self, column_index: int) -> Point:
        """
        Obtain the origin of a column in the Header.

        :param column_index: The zero-based index of the column.

        :returns: A two-dimensional point which represents the top-left corner of a cell in the Header.

        :raises TimeoutException: If no header is present, or if the Table contains no data.
        """
        return self._header.get_origin_of_column(column_index=column_index)

    def get_row_data(self, row_index: int) -> dict:
        """
        Obtain the data from all cells in a row.

        :param row_index: The zero-based index of the row from which you would like data.

        :returns: The data of a row as a dictionary, where the keys of the dictionary match the keys of the underlying
            data (column ids - not names).

        :raises TimeoutException: If no row exists with the supplied index.
        """
        return self._body.get_row_data_by_row_index(zero_based_row_index=row_index)

    def get_row_data_by_column_value(self, column_id: str, known_value: str) -> List[dict]:
        """
        Obtain the data from all cells in all rows which contain some known value within a column.

        :param column_id: The id of the column which contains your known value.
        :param known_value: A value in the supplied column which identifies the corresponding row as the target.

        :returns: The data of all rows which contains the known_value as a value in the column specified by the
            supplied column_id.

        :raises TimeoutException: If no rows are displayed in the Table.
        :raises KeyError: If no column exists with the supplied id.
        """
        return self._body.get_row_data_by_column_value(column_id=column_id, column_value=known_value)

    def get_row_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of rows within the Table.

        :param include_units: Dictates whether the returned value should include units of measurement.

        :returns: The width of the rows of the Table as a string.

        :raises TimeoutException: If no rows are displayed in the Table.
        """
        return self._body.get_row_width(include_units=include_units)

    def get_row_index_of_first_row_with_value_in_column(self, column_id: str, known_value: str) -> Optional[int]:
        """
        Obtain the index of the row which contains a supplied value in a specified column.

        :param column_id: The id of the column which contains the known value.
        :param known_value: A value which identifies which row of the Table we would like the index from.

        :returns: The zero-based index of the first row which contains the supplied value within the specified column,
            or None if no row contains the supplied value in the specified column.

        :raises TimeoutException: If no rows are displayed in the Table
        """
        return self._body.get_row_index_of_first_row_with_value_in_column(
            column_id_with_known_value=column_id, known_value=known_value)

    def get_sort_order_number_of_column(self, column_id: str) -> Optional[int]:
        """
        Obtain the sort order of a column, if it has one.

        :param column_id: The id of the column which we want to sort order of.

        :returns: The sort order of the specified column, or None if the column is not sorted or is the only sorted
            column.
        """
        return self._header.get_sort_order_number_of_column(column_id=column_id)

    def get_termination_of_column_in_header(self, column_index: int) -> Point:
        """
        Obtain a two-dimensional point which represents the bottom-right of a cell in the Header.

        :param column_index: The zero-based index of the column.

        :returns: A two-dimensional point which represents the bottom-right corner of a cell in the Header.

        :raises TimeoutException: If no column exists with the supplied index, or if the Table contains no data.
        """
        return self._header.get_termination_of_column(column_index=column_index)

    def header_is_present(self) -> bool:
        """
        Determine if the Header is currently displayed within the Table.

        :returns: True, if the Header is currently displayed - False otherwise.
        """
        return self._header.header_is_present()

    def hover_over_column_header(self, column_index: int) -> None:
        """
        Hover over the header cell of a column.

        :param column_index: The zero-based index of the column for which you would like to hover over the Header cell.

        :raises TimeoutException: If no column exists with the supplied index, or if the Table contains no data.
        """
        self._header.hover_over_column_in_header(column_index=column_index)

Components/PerspectiveComponents/Common/Table.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple, Union

from selenium.common.exceptions import TimeoutException, StaleElementReferenceException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Helpers.Point import Point


class Body(ComponentPiece):
    """The body of a table, which contains information and interactions for rows and cells."""
    BODY_CELL_CSS = 'div.tc'
    BODY_ROW_CSS = 'div.ia_table__body__row'
    ROW_GROUP_CSS = 'div.tr-group'
    _EMPTY_MESSAGE_LOCATOR = (By.CSS_SELECTOR, 'div.emptyMessage')

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=(By.CSS_SELECTOR, "div.tb"),
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._empty_message = ComponentPiece(
            locator=self._EMPTY_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._row_groups = ComponentPiece(
            locator=(By.CSS_SELECTOR, self.ROW_GROUP_CSS),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._rows = ComponentPiece(
            locator=(By.CSS_SELECTOR, self.BODY_ROW_CSS),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._cells = ComponentPiece(
            locator=(By.CSS_SELECTOR, self.BODY_CELL_CSS),
            driver=driver,
            parent_locator_list=self._rows.locator_list,
            poll_freq=poll_freq)

    def get_cell_data_by_row_index_column_index(
            self,
            zero_based_row_index: Union[int, str],
            zero_based_column_index: Union[int, str]) -> str:
        """
        Get the String data of a cell specified by a zero-based row index and a zero-based column index.

        :param zero_based_row_index: The zero-based index of the row you are targeting.
        :param zero_based_column_index: The zero-based index of the column you are targeting.

        :returns: The displayed contents of the specified cell.

        :raises TimeoutException: If no cell exists with the provided indices.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="{zero_based_row_index}"] '
                                     f'{self.BODY_ROW_CSS} '
                                     f'{self.BODY_CELL_CSS}[data-column-index="{zero_based_column_index}"]')
        return ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).get_text()

    def get_cell_data_by_row_index_column_id(self, zero_based_row_index: Union[int, str], column_id: str) -> str:
        """
        Get the String data of a cell specified by a zero-based row index and a CASE-SENSITIVE column name.

        :param zero_based_row_index: The zero-based index of the row you are targeting.
        :param column_id: The CASE-SENSITIVE id of the column you are targeting. Note that the id may not be the name
            of the column as the id is a direct mapping against the keys of the data in use.

        :returns: The displayed contents of the specified cell.

        :raises TimeoutException: If no cell exists with the provided row index in the supplied column.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="{zero_based_row_index}"] '
                                     f'{self.BODY_ROW_CSS} {self.BODY_CELL_CSS}[data-column-id="{column_id}"]')
        return ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).get_text()

    def get_cell_origin(self, zero_based_row_index: int, zero_based_column_index: int) -> Point:
        """
        Obtain the origin (top-left corner) position of a cell.

        :param zero_based_row_index: The zero-based index of the row you are targeting.
        :param zero_based_column_index: The zero-based index of the column you are targeting.

        :returns: A two-dimensional point in the DOM which represents the top-left corner of the cell.

        :raises TimeoutException: If no cell exists with the provided indices.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="{zero_based_row_index}"] '
                                     f'{self.BODY_ROW_CSS} {self.BODY_CELL_CSS}'
                                     f'[data-column-index="{zero_based_column_index}"]')
        return ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).get_origin()

    def get_cell_termination(self, zero_based_row_index: int, zero_based_column_index: int) -> Point:
        """
        Obtain the termination (bottom-right corner) position of a cell.

        :param zero_based_row_index: The zero-based index of the row you are targeting.
        :param zero_based_column_index: The zero-based index of the column you are targeting.

        :returns: A two-dimensional point in the DOM which represents the bottom-right corner of the cell.

        :raises TimeoutException: If no cell exists with the provided indices.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="{zero_based_row_index}"] '
                                     f'{self.BODY_ROW_CSS} {self.BODY_CELL_CSS}'
                                     f'[data-column-index="{zero_based_column_index}"]')
        return ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).get_termination()

    def get_column_count(self) -> int:
        """
        Obtain the count of cells in the first row of the body of the Table.

        :returns: The count of cells in the first row of the Table.

        :raises TimeoutException: If no first row is present in the Table.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="0"] '
                                     f'{self.BODY_ROW_CSS} {self.BODY_CELL_CSS}')
        return len(ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).find_all())

    def get_column_width(self, column_name: str, include_units: bool = False) -> str:
        """
        Obtain the width of a column.

        :param column_name: The name of the column of which we are retrieving the width.
        :param include_units: A value of True specifies we should include the units of measurement (almost always
            'px'), while a value of False specifies we should return only the numeric value (as a string).

        :returns: The width of the specified column as a string, because it might have units attached.

        :raises TimeoutException: If the specified column does not exist, or if the Table has no rows displayed.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="0"] '
                                     f'{self.BODY_ROW_CSS} {self.BODY_CELL_CSS}[data-row-id="{column_name}"')
        return ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).get_computed_width(include_units=include_units)

    def get_empty_message_text(self) -> str:
        """
        Obtain the message displayed while the table has no content.

        :raises TimeoutException: If no empty message is found, most likely because the Table has content.
        """
        return self._empty_message.get_text().strip()

    def get_height_of_row_by_index(self, zero_based_row_index: int, include_units: bool = False) -> str:
        """
        Obtain the height of a row.

        :param zero_based_row_index: The zero-based index of the row you are targeting.
        :param include_units: A value of True specifies we should include the units of measurement (almost always
            'px'), while a value of False specifies we should return only the numeric value (as a string).

        :returns: The height of the specified row as a string, because it might have units attached.

        :raises TimeoutException: If the specified row index does not exist.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="{zero_based_row_index}"] '
                                     f'{self.BODY_ROW_CSS}')
        return ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).get_computed_height(include_units=include_units)

    def get_row_index_of_first_row_with_value_in_column(
            self, column_id_with_known_value: str, known_value: str) -> Optional[int]:
        """
        Obtain the index of the first row which contains s specified value in a know column.

        :param column_id_with_known_value: The case-sensitive column ID which of the column that you want to search.
            Note that this may not always be the name of the column, as this attribute is mapped to the data key which
            drives the column.
        :param known_value: The case-sensitive value you are searching for within the column.

        :returns: The zero-based index of the first row which contains the specified known_value in the specified
            column, or None if no row contained the value.
        """
        _locator = (By.CSS_SELECTOR, f'{self.BODY_CELL_CSS}[data-column-id="{column_id_with_known_value}"]')
        _cells = ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).find_all()
        for cell in _cells:
            if cell.text == str(known_value):
                return int(cell.get_attribute("data-row-index"))
        return None

    def get_row_count(
            self, include_expanded_subviews_in_count: bool = False, expected_count: Optional[int] = None) -> int:
        """
        Obtain a count of rows in the Table.

        :param include_expanded_subviews_in_count: A True value will include any expanded subviews as a row, while a
            value of False will only return a count of rows which reflects the native data.
        :param expected_count: If supplied, the function will wait some short period of time until this number of rows
            appears.

        :returns: A count of rows in the table, or possibly a count of rows which includes expanded subviews.
        """
        try:
            return int(self.wait.until(IAec.function_returns_true(
                custom_function=self._get_row_count,
                function_args={
                    "include_expanded_subviews_in_count": include_expanded_subviews_in_count,
                    "expected_count": expected_count
                }
            )))
        except TimeoutException:
            return 0

    def _get_row_count(self, include_expanded_subviews_in_count: bool, expected_count: Optional[int]) -> int:
        """
        Obtain a count of rows in the Table.

        :param include_expanded_subviews_in_count: If True, we will include expanded subviews as part of the row count.
            If False, expanded subviews will be omitted from the count, making the returned value a true reflection of
            the underlying data.
        :param expected_count: A count we expect the Table to have, and we will wait on reporting until this value is
            found, or some period of time has elapsed.
        """
        try:
            rows = self._rows.find_all(wait_timeout=1)
            if not include_expanded_subviews_in_count:
                rows = [row for row in rows if "subview" not in row.get_attribute("class")]
            if expected_count is not None:
                expected_count = int(expected_count)
                if len(rows) == expected_count:
                    return expected_count
                else:
                    return False
            return len(rows)
        except StaleElementReferenceException:
            return False
        except TimeoutException:
            return False

    def get_row_data_by_row_index(self, zero_based_row_index: int) -> dict:
        """
        Obtain the data of a specified row and return that data as a dictionary, where the keys of the dictionary
        are the column ids.

        :param zero_based_row_index: The zero-based index of the row you are targeting.

        :returns: The row data as a dictionary, with the keys of the dictionary being the case-sensitive
            ids of the columns.
        """
        _locator = (By.CSS_SELECTOR, f'{self.ROW_GROUP_CSS}[data-row-index="{zero_based_row_index}"] '
                                     f'{self.BODY_ROW_CSS} {self.BODY_CELL_CSS}')
        cell_web_element_list = ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).find_all()
        row_data_as_dictionary = {}
        for cell in cell_web_element_list:
            row_data_as_dictionary[cell.get_attribute('data-column-id')] = cell.text
        row_data_as_dictionary.pop('expandSubview', None)  # remove the expandSubview column from the dict
        return row_data_as_dictionary

    def get_row_data_by_column_value(self, column_value: str, column_id: str) -> List[dict]:
        """
        Obtain the row data for all rows which contain a known value in a specified column.

        :param column_value: The known value within a column which will be used to determine what rows are returned.
        :param column_id: The id of the column you expect to contain the supplied value.

        :returns: The row data for every row which contains the supplied value in the specified column.

        :raises TimeoutException: If no rows are found.
        :raises KeyError: If the supplied column_id value does not match any displayed column.
        """
        rows_with_column_value = []
        for i in range(self.get_row_count()):
            row_dict = self.get_row_data_by_row_index(zero_based_row_index=i)
            if row_dict[column_id] == str(column_value):
                rows_with_column_value.append(row_dict)
        return rows_with_column_value

    def get_row_group_count(self) -> int:
        """
        Obtain the count of row groups present in the table

        :returns: int - The count of row groups.
        """
        try:
            return len(self._row_groups.find_all())
        except TimeoutException:
            return 0

    def get_row_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of a row.

        :param include_units: If True, the returned value will include the units (almost always 'px'). If False, the
            returned value will be the numeric representation as a string.

        :raises TimeoutException: If no rows are present in the Table.
        """
        return self._rows.get_computed_width(include_units=include_units)

Components/PerspectiveComponents/Common/TablePieces/Body.py
inductiveautomation/ignition-automation-tools
from typing import List, Tuple, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Components.BasicComponent import ComponentPiece
from Components.Common.TextInput import CommonTextInput
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions.IAExpectedConditions import TextCondition


class Filter(ComponentPiece):
    """
    The text filter available above the header of Table components.
    """
    _FILTER_LOCATOR = (By.CSS_SELECTOR, '.filterCommon input')
    _RESULTS_LABEL_LOCATOR = (By.CSS_SELECTOR, "div.filterResults")

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: List[Tuple[By, str]] = None,
            wait_timeout: float = 1,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=(By.CSS_SELECTOR, 'div[class*="Filter"]'),
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._filter_input = CommonTextInput(
            locator=self._FILTER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        self._results_label = ComponentPiece(
            locator=self._RESULTS_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)

    def get_results_text(self) -> str:
        """
        Obtain the text of the label which provides feedback on the results of the filter.

        :raises TimeoutException: If the feedback filter is not present.
        """
        return self._results_label.get_text()

    def is_present(self) -> bool:
        """
        Determine if the filter for the table exists.

        :returns: True, if the filter for the table exists - False otherwise. This function makes no claims about
            visibility.
        """
        try:
            return self._filter_input.find() is not None
        except TimeoutException:
            return False

    def results_text_content_is_displayed(self) -> bool:
        """
        Determine if information about filtered results is present.

        :returns: True, if a breakdown regarding filtered results is displayed.
        """
        try:
            return len(self.get_results_text()) > 0
        except TimeoutException:
            return False

    def set_filter_text(self, text: str, binding_wait_time: float = 0) -> None:
        """
        Set the text filter to contain some text.

        :param text: The text you want to supply to the filter of the Table.
        :param binding_wait_time: how long (in seconds) after applying the text to the filter you would like to wait
            before allowing code to continue.

        :raises TimeoutException: If the filter input is not present.
        :raises AssertionError: If the supplied text is not successfully applied to the filter.
        """
        if self._filter_input.get_text() != text:
            self.wait.until(ec.element_to_be_clickable(self._filter_input.get_full_css_locator()))
            text_accounting_for_empty_str = Keys.SPACE + Keys.BACK_SPACE + Keys.ENTER if text == '' else text
            self._filter_input.set_text(
                text=text_accounting_for_empty_str,
                binding_wait_time=binding_wait_time)
        IAAssert.is_equal_to(
            actual_value=self._filter_input.wait_on_text_condition(
                text_to_compare=text, condition=TextCondition.EQUALS),
            expected_value=text,
            as_type=str,
            failure_msg="We failed to apply the supplied text to the filter of the Table.")

Components/PerspectiveComponents/Common/TablePieces/Filter.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple
from typing import Union

from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.Common.Button import CommonButton
from Components.Common.TextInput import CommonTextInput
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Components.PerspectiveComponents.Common.DateRangeSelector \
    import CommonDateRangeSelector, HistoricalRange, PerspectiveDate
from Components.PerspectiveComponents.Common.Dropdown import CommonDropdown
from Helpers.CSSEnumerations import CSS
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions.IAExpectedConditions import TextCondition
from Helpers.Point import Point

_COMMON_CELL_LOCATOR = (By.CSS_SELECTOR, "div.tc")


class FilterModal(ComponentModal):
    """
    The modal available while filtering Table columns. Currently only available to the Table Component.

    This class will not be able to raise any assertion errors when attempting to apply filters because we can't
    possibly know about the expected results of whatever is applied. The Table class utilizing this FilterModal might
    be able to make some assertions regarding application and removal.
    """
    _INPUT_FIELD_CLASS = "ia_inputField"
    _CONDITION_DROPDOWN_LOCATOR = (By.CSS_SELECTOR, f"div.{CommonDropdown.COMMON_CLASS}")
    _BASIC_VALUE_INPUT_LOCATOR = (By.CSS_SELECTOR, f"input.{_INPUT_FIELD_CLASS}")
    _REMOVE_FILTER_SPAN_LOCATOR = (By.CSS_SELECTOR, "span.dataTypeFilter__actionsContainer__removeAction")
    _APPLY_BUTTON_LOCATOR = (By.CSS_SELECTOR, "button.dataTypeFilter__actionsContainer__applyAction")
    _MAXIMUM_RANGE_INPUT_LOCATOR = (By.CSS_SELECTOR, f'{_BASIC_VALUE_INPUT_LOCATOR[1]}[name="max"]')
    _MINIMUM_RANGE_INPUT_LOCATOR = (By.CSS_SELECTOR, f'{_BASIC_VALUE_INPUT_LOCATOR[1]}[name="min"]')
    _DATE_RANGE_PICKER_LOCATOR = (By.CSS_SELECTOR, "div.iaDateRangeTimePicker")

    def __init__(self, driver: WebDriver, description: Optional[str] = None):
        super().__init__(driver=driver, wait_timeout=1, description=description)
        self._condition_dd = CommonDropdown(
            locator=self._CONDITION_DROPDOWN_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1)
        self._basic_value_input = CommonTextInput(
            locator=self._BASIC_VALUE_INPUT_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
        self._remove_filter_link = ComponentPiece(
            locator=self._REMOVE_FILTER_SPAN_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
        self._apply_button = CommonButton(
            locator=self._APPLY_BUTTON_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
        self._min_value_input = CommonTextInput(
            locator=self._MINIMUM_RANGE_INPUT_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
        self._max_value_input = CommonTextInput(
            locator=self._MAXIMUM_RANGE_INPUT_LOCATOR, driver=driver, parent_locator_list=self.locator_list)
        self._date_range_picker = CommonDateRangeSelector(driver=driver)

    def apply_button_is_enabled(self) -> bool:
        """
        Determine if the Apply button is enabled in the modal.

        :returns: True, if the Apply button is enabled - False otherwise.

        :raises TimeoutException: if the Apply button is not present.
        """
        return self._apply_button.find().is_enabled()

    def click_apply_button(self) -> None:
        """
        Click the Apply button in the filter modal.

        :raises TimeoutException: If the Apply button is not present.
        """
        self._apply_button.click()

    def click_remove_filter(self) -> None:
        """
        Click The 'Remove Filter' link in the filter modal.

        :raises TimeoutException: If the 'Remove Filter' link is not present.
        """
        self._remove_filter_link.scroll_to_element(align_to_top=True)
        self._remove_filter_link.click()
        
    def end_time_hours_input_is_enabled(self) -> bool:
        """
        Determine if the input for hours for the ending date is currently enabled.
        
        :returns: True, if the input which drives the hours of the ending date is currently enabled - False otherwise.
        
        :raises TimeoutException: If the hours input for the ending date is not present.
        """
        return self._date_range_picker.end_time_hour_input_is_enabled()
    
    def end_time_minutes_input_is_enabled(self) -> bool:
        """
        Determine if the input for minutes for the ending date is currently enabled.
        
        :returns: True, if the input which drives the minutes of the ending date is currently enabled - False otherwise.
        
        :raises TimeoutException: If the minutes input for the ending date is not present.
        """
        return self._date_range_picker.end_time_minute_input_is_enabled()
    
    def end_time_seconds_input_is_enabled(self) -> bool:
        """
        Determine if the input for seconds for the ending date is currently enabled.
        
        :returns: True, if the input which drives the seconds of the ending date is currently enabled - False otherwise.
        
        :raises TimeoutException: If the seconds input for the ending date is not present.
        """
        return self._date_range_picker.end_time_second_input_is_enabled()

    def get_available_conditions(self) -> List[str]:
        """
        Obtain all options available to the user in the condition dropdown.

        :returns: A list which contains the text of every option in the dropdown of available conditions.

        :raises TimeoutException: If the dropdown which contains available conditions is not present.
        """
        return self._condition_dd.get_available_options()

    def get_enabled_days_from_picker(self) -> List[int]:
        """
        Obtain all days available for selection in the current month.

        :returns: A list with the numbers of all available days within the current month.

        :raises TimeoutException: If the date picker (calendar) is not present.
        """
        return self._date_range_picker.get_enabled_days()

    def get_remove_filter_link_text(self) -> str:
        """
        Obtain the text of the link which would remove the filter for the column. Useful for checking translations.

        :returns: The text of the 'Remove Filter' link, after any potential translation.

        :raises TimeoutException: If the link which removes the current filter is not present.
        """
        return self._remove_filter_link.get_text()

    def get_apply_button_text(self) -> str:
        """
        Obtain the text of the Apply button. Useful for checking translations.

        :returns: The text of the Apply button, after any potential translation.

        :raises TimeoutException: If the Apply button is not present.
        """
        return self._apply_button.get_text()

    def get_current_selected_condition(self) -> Optional[str]:
        """
        Obtain the current filter condition selection.

        :returns: The text of the currently selected filter condition, or None if no selection has been selected yet.
        """
        try:
            return self._condition_dd.get_selected_options_as_list()[0]
        except IndexError:
            return None
    
    def hour_input_is_displayed(self) -> bool:
        """
        Determine if the hours input is displayed. This should not be used when dealing with a time condition of 
        "between", because multiple hour inputs might be present.
        
        :returns: True, if any hour input is displayed.
        """
        try:
            return self._date_range_picker.hour_input_is_displayed()
        except TimeoutException:
            return False
    
    def hour_input_is_enabled(self) -> bool:
        """
        Determine if the hours input is enabled. This should not be used when dealing with a time condition of 
        "between", because multiple hour inputs might be present.
        
        :returns: True, if the hour input field is enabled.
        """
        try:
            return self._date_range_picker.hour_input_is_enabled()
        except TimeoutException:
            return False

    def is_displayed(self) -> bool:
        """
        Determine if the filter modal is currently displayed.

        :returns: True, if the filter modal is currently displayed - False otherwise.
        """
        try:
            return self._condition_dd.find().is_displayed()
        except TimeoutException:
            return False
    
    def minute_input_is_displayed(self) -> bool:
        """
        Determine if the minutes input is displayed. This should not be used when dealing with a time condition of 
        "between", because multiple minute inputs might be present.
        
        :returns: True, if any minute input is displayed.
        """
        try:
            return self._date_range_picker.minute_input_is_displayed()
        except TimeoutException:
            return False
    
    def minute_input_is_enabled(self) -> bool:
        """
        Determine if the minutes input is enabled. This should not be used when dealing with a time condition of 
        "between", because multiple minute inputs might be present.
        
        :returns: True, if the minute input field is enabled.
        """
        try:
            return self._date_range_picker.minute_input_is_enabled()
        except TimeoutException:
            return False

    def seconds_input_is_displayed(self) -> bool:
        """
        Determine if the seconds input is displayed. Distinct from :func:`end_time_seconds_input_is_displayed`, which is
        used for date ranges.

        :returns: True, if the seconds input is displayed - False otherwise.

        :raises TimeoutException: If the seconds input is not present.
        """
        return self._date_range_picker.seconds_input_is_displayed()

    def seconds_input_is_enabled(self) -> bool:
        """
        Determine if the seconds input is enabled. Distinct from :func:`end_time_seconds_input_is_enabled`, which is
        used for date ranges.

        :returns: True, if the seconds input is enabled - False otherwise.

        :raises TimeoutException: If the seconds input is not present.
        """
        return self._date_range_picker.seconds_input_is_enabled()

    def select_date_and_apply(self, date: PerspectiveDate, apply: bool = True) -> None:
        """
        Select a date, and potentially apply that date.

        :param date: The date to select in the filter modal.
        :param apply: If True, click the apply button. If False, take no action after selecting the date.

        :raises TimeoutException: If the date picker (calendar) is not present, or if the Apply button is not present.
        """
        self._date_range_picker.select_date(date=date)
        if apply:
            self._apply_button.click()

    def select_range(self, historical_range: HistoricalRange) -> None:
        """
        Apply a historical range (start AND end date) to the filter modal and then click the Apply button.

        :param historical_range: An object containing the start and end dates to be applied to the filter modal.

        :raises TimeoutException: If the date picker (calendar) is not present, or if the Apply button is not present.
        """
        self._date_range_picker.set_historical_range(historical_range=historical_range, apply=True)

    def set_condition(self, condition: str) -> None:
        """
        Select a condition from the dropdown.

        :param condition: The text of the condition to select.

        :raises TimeoutException: If the condition dropdown is not present.
        :raises AssertionError: If we fail to select the supplied condition.
        """
        self._condition_dd.select_option_by_text_if_not_selected(option_text=condition)
        IAAssert.is_equal_to(
            actual_value=self._condition_dd.get_selected_options_as_list()[0],
            expected_value=condition,
            failure_msg=f"We failed to select the '{condition}' option in the condition dropdown of the filter modal.")

    def set_maximum_range_value(self, text: Union[int, str]) -> None:
        """
        Set the maximum value in use by the filter.

        :param text: The maximum value the filter should use.

        :raises TimeoutException: if the maximum value input field is not present.
        :raises AssertionError: If we fail to set the input to the supplied value.
        """
        self._max_value_input.set_text(text=text)
        IAAssert.is_equal_to(
            actual_value=self._max_value_input.wait_on_text_condition(
                text_to_compare=text, condition=TextCondition.EQUALS),
            expected_value=text,
            as_type=str,
            failure_msg="We failed to set the maximum value within the filter modal.")

    def set_minimum_range_value(self, text: Union[int, str]) -> None:
        """
        Set the minimum value in use by the filter.

        :param text: The minimum value the filter should use.

        :raises TimeoutException: if the minimum value input field is not present.
        :raises AssertionError: If we fail to set the input to the supplied value.
        """
        self._min_value_input.set_text(text=text)
        IAAssert.is_equal_to(
            actual_value=self._min_value_input.wait_on_text_condition(
                text_to_compare=text, condition=TextCondition.EQUALS),
            expected_value=text,
            as_type=str,
            failure_msg="We failed to set the minimum value within the filter modal.")

    def set_value_for_condition(self, text: Union[int, str]) -> None:
        """
        Set the value for the condition. This function should be sued for condition which do not include ranges of any
        type.

        :param text: The value of the condition.

        :raises TimeoutException: if the value input field is not present.
        :raises AssertionError: If we fail to set the input to the supplied value.
        """
        self._basic_value_input.set_text(text=text, binding_wait_time=1)
        IAAssert.is_equal_to(
            actual_value=self._basic_value_input.wait_on_text_condition(
                text_to_compare=text, condition=TextCondition.EQUALS),
            expected_value=text,
            as_type=str,
            failure_msg="We failed to set the condition value within the filter modal.")
        
    def start_time_hours_input_is_enabled(self) -> bool:
        """
        Determine if the hours input of the start time is enabled.
        
        :returns: True, if the hours input of the start time is enabled - False otherwise.
        
        :raises TimeoutException: If the hours input is not present.
        """
        return self._date_range_picker.start_time_hour_input_is_enabled()
        
    def start_time_minutes_input_is_enabled(self) -> bool:
        """
        Determine if the minutes input of the start time is enabled.
        
        :returns: True, if the minutes input of the start time is enabled - False otherwise.
        
        :raises TimeoutException: If the minutes input is not present.
        """
        return self._date_range_picker.start_time_minute_input_is_enabled()
        
    def start_time_seconds_input_is_enabled(self) -> bool:
        """
        Determine if the seconds input of the start time is enabled.
        
        :returns: True, if the seconds input of the start time is enabled - False otherwise.
        
        :raises TimeoutException: If the seconds input is not present.
        """
        return self._date_range_picker.start_time_second_input_is_enabled()

    def value_input_is_enabled(self) -> bool:
        """
        Determine if the basic value input field in the filter modal is enabled.

        :returns: True, if the value input is enabled - False otherwise.

        :raises TimeoutException: If the value input field is not present.
        """
        return self._basic_value_input.find().is_enabled()


class Footer(ComponentPiece):
    """
    The Footer of a table, as distinct from the Header. Headers are far more likely to be used so default to checking
    those.
    """
    _FOOTER_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.tf-container")
    _FOOTER_ROW_LOCATOR = (By.CSS_SELECTOR, "div.tr.footer")

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 1,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=self._FOOTER_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._footer_rows = ComponentPiece(
            locator=self._FOOTER_ROW_LOCATOR, driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._footer_cells = ComponentPiece(
            locator=_COMMON_CELL_LOCATOR,
            driver=driver,
            parent_locator_list=self._footer_rows.locator_list,
            poll_freq=poll_freq)

    def get_column_names(self) -> List[str]:
        """
        Obtain all column names displayed in the Footer.

        :returns: A list which contains all column names displayed in the Footer.

        :raises TimeoutException: If no Footer is present.
        """
        return [_.text for _ in self._footer_cells.find_all()]

    def footer_is_present(self) -> bool:
        """
        Determine if the Table is currently displaying a Footer.

        :returns: True, if the Table is currently displaying a Footer - False otherwise.
        """
        try:
            return self.find().is_displayed()
        except TimeoutException:
            return False


class Header(ComponentPiece):
    _HEADER_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.th-container")
    _HEADER_ROW_LOCATOR = (By.CSS_SELECTOR, "div.tr.header")

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 1,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=self._HEADER_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._header_rows = ComponentPiece(
            locator=self._HEADER_ROW_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._header_cells = ComponentPiece(
            locator=_COMMON_CELL_LOCATOR,
            driver=driver,
            parent_locator_list=self._header_rows.locator_list,
            poll_freq=poll_freq)
        self.__header_cell = {}
        self._filter_cells = {}
        self._header_cell_dict = {}

    def descending_sort_is_active(self, column_id: str) -> Optional[bool]:
        """
        Determine if the current sort order of the column is 'descending'.

        :returns: True, if the sort order is descending. False, if the sort or is ascending. None, if no sort order is
            applied to the column.

        :raises TimeoutException: If no column exists with the specified column id.
        """
        try:
            descending_arrow = self._get_header_cell_by_id(
                column_id=column_id).find().find_element(By.CSS_SELECTOR, 'div.sort-down')
            return 'active' in descending_arrow.get_attribute('class')
        except NoSuchElementException:
            return None

    def get_column_ids(self) -> List[str]:
        """
        Obtain the ids of all columns in the Table. Column ids map directly to the keys of the underlying data of the
        Table.

        :returns: A list which contains the ids of all columns in the Table.
        """
        try:
            # don't return the "select" column, AND remove any potential sorting value for the column
            return [_.get_attribute('data-column-id') for _ in self._header_cells.find_all() if _.text != ""]
        except TimeoutException:
            return []

    def get_column_index(self, column_id: str) -> int:
        """
        Obtain the index of a column based off of the id of the column. Note that the id is derived from the underlying
        data of the table, and is not necessarily the displayed name of the column.

        :returns: The zero-based display index of the column with the supplied id.

        :raises TimeoutException: If the supplied column id does not match any column in the Table.
        """
        return int(self._get_header_cell_by_id(column_id=column_id).find().get_attribute("data-column-index"))

    def get_column_names(self) -> List[str]:
        """
        Obtain the names of all columns in the Table. Column names are for display purposes ONLY, and are not a
        guaranteed way to reference columns. Be wary of functions which require a column id.

        :returns: A list which contains the names of all columns in the Table.
        """
        try:
            # don't return the "select" column, AND remove any potential sorting value for the column
            return [str(_.text).splitlines()[0] for _ in self._header_cells.find_all() if _.text != ""]
        except TimeoutException:
            return []

    def get_column_max_width(self, column_id: str, include_units: bool = False) -> str:
        """
        Obtain the max width available to a column.

        :param column_id: The id of the column for which you would like the max width.
        :param include_units: Dictates whether the returned value contains the units of measurement (almost
            always 'px').

        :raises TimeoutException: If no column exists with the specified id.
        """
        try:
            header = self._get_header_cell_by_id(column_id=column_id)
            width = header.get_css_property(property_name=CSS.MAX_WIDTH)
            return width if include_units else width.split("px")[0]
        except IndexError:
            return "-1"

    def get_column_width(self, column_id: str, include_units: bool = False) -> str:
        """
        Obtain the width of a column.

        :param column_id: The id of the column for which you would like the width.
        :param include_units: Dictates whether the returned value contains the units of measurement (almost
            always 'px').

        :raises TimeoutException: If no column exists with the specified id.
        """
        return self._get_header_cell_by_id(
            column_id=column_id).get_computed_width(include_units=include_units)

    def get_count_of_columns(self) -> int:
        """
        Obtain a count of all displayed columns.

        :raises TimeoutException: If no header is displayed, or if the Table has no data.
        """
        return len(self._header_cells.find_all())

    def get_origin_of_column(self, column_index: int) -> Point:
        """
        Obtain the positioning information of the beginning of a column.

        :param column_index: The numeric index of the column you would like positional data for.

        :returns: A two-dimensional point which represents the top-left corner of the header cell for the specified
            column.

        :raises TimeoutException: If no column exists with the specified index.
        """
        return self._get_header_cell_by_index(column_index=column_index).get_origin()

    def get_sort_order_number_of_column(self, column_id: str) -> Optional[int]:
        """
        Obtain the sort order of a column.

        :param column_id: The id of the column for which you would like the sort order.

        :returns: The sort order of the column as a number, or None if no sorting is applied.

        :raises TimeoutException: If no column exists with the specified id.
        """
        try:
            return int(self._get_header_cell_by_id(
                column_id=column_id).find().find_element(
                By.CSS_SELECTOR,
                'div.sort-order-mini').text)
        except NoSuchElementException:
            return None

    def get_termination_of_column(self, column_index: int) -> Point:
        """
        Obtain the positioning information of the ending of a column.

        :param column_index: The numeric index of the column you would like positional data for.

        :returns: A two-dimensional point which represents the bottom-right corner of the header cell for the specified
            column.

        :raises TimeoutException: If no column exists with the specified index.
        """
        return self._get_header_cell_by_index(column_index=column_index).get_termination()

    def header_is_present(self) -> bool:
        """
        Determine if a header is currently displayed for the Table.

        :returns: True, if a header is currently displayed for the Table - False otherwise.
        """
        try:
            return self.find().is_displayed()
        except TimeoutException:
            return False

    def hover_over_column_in_header(self, column_index: int) -> None:
        """
        Hover over the header cell of a column of the Table.

        :param column_index: The zero-based index of the column of which you would like to hover over the header cell.

        :raises TimeoutException: If no column exists with the specified index.
        """
        self._get_header_cell_by_index(column_index=column_index).hover()

    def _get_header_cell_by_index(self, column_index: int) -> ComponentPiece:
        """
        Obtain the header cell (as a ComponentPiece) of the column with the specified index.
        """
        header_cell = self._header_cell_dict.get(column_index)
        if not header_cell:
            header_cell = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{_COMMON_CELL_LOCATOR[1]}[data-column-index="{column_index}"]'),
                driver=self.driver,
                parent_locator_list=self._header_rows.locator_list,
                poll_freq=self.poll_freq)
            self._header_cell_dict[column_index] = header_cell
        return header_cell

    def _get_header_cell_by_id(self, column_id: str) -> ComponentPiece:
        """
        Obtain the header cell (as a ComponentPiece) of the column with the specified column id.
        """
        header_cell = self._header_cell_dict.get(column_id)
        if not header_cell:
            header_cell = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{_COMMON_CELL_LOCATOR[1]}[data-column-id="{column_id}"]'),
                driver=self.driver,
                parent_locator_list=self._header_rows.locator_list,
                poll_freq=self.poll_freq)
            self._header_cell_dict[column_id] = header_cell
        return header_cell

Components/PerspectiveComponents/Common/TablePieces/HeaderAndFooter.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import List, Optional, Tuple, Union

from selenium.common.exceptions import TimeoutException, StaleElementReferenceException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.select import Select
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import ComponentPiece
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec


class Pager(ComponentPiece):

    class Location(Enum):
        BOTTOM = "bottom"
        TOP = "top"

    """The pager available to most Table components."""
    _ACTIVE_PAGE_CSS_LOCATOR = (By.CSS_SELECTOR, 'div.page.active')
    _ACTUAL_PAGE_JUMP_INPUT = (By.CSS_SELECTOR, 'div.jump input')
    _FIRST_PAGE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.first')
    _LAST_PAGE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.last')
    _NEXT_PAGE_CHEVRON_LOCATOR = (By.CSS_SELECTOR, 'div.next')
    _PAGER_LOCATOR = (By.CSS_SELECTOR, 'div.pager-container')
    _PAGE_JUMP_LOCATOR = (By.CSS_SELECTOR, 'div.jump input')
    _PREVIOUS_PAGE_CHEVRON_LOCATOR = (By.CSS_SELECTOR, 'div.prev')
    _SELECT_SIZE_OPTIONS_LOCATOR = (By.CSS_SELECTOR, 'div.size-options select')
    _DISPLAYED_PAGE_NUMBERS_CSS_LOCATOR = (By.CSS_SELECTOR, 'div.center div.page')
    _ACTIVE_PAGE_NUMBER_LOCATOR = (By.CSS_SELECTOR, "div.ia_pager__page--active")

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=self._PAGER_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            description=description,
            poll_freq=poll_freq)
        self._active_page = ComponentPiece(
            locator=self._ACTIVE_PAGE_CSS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._page_jump_input = ComponentPiece(
            locator=self._PAGE_JUMP_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._first_page_button = ComponentPiece(
            locator=self._FIRST_PAGE_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._last_page_button = ComponentPiece(
            locator=self._LAST_PAGE_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._previous_page_chevron = ComponentPiece(
            locator=self._PREVIOUS_PAGE_CHEVRON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._next_page_chevron = ComponentPiece(
            locator=self._NEXT_PAGE_CHEVRON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._row_count_select = ComponentPiece(
            locator=self._SELECT_SIZE_OPTIONS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._page_numbers = ComponentPiece(
            locator=self._DISPLAYED_PAGE_NUMBERS_CSS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._active_page_number = ComponentPiece(
            locator=self._ACTIVE_PAGE_NUMBER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def click_first_page_button(self) -> None:
        """
        Click the option in the Page which would take a user to the 'first' page.

        :raises TimeoutException: If the 'First' page option is not present in the Pager.
        :raises AssertionError: If clicking the 'First' page button does result in the Table displaying the first page.
        """
        self._first_page_button.click(binding_wait_time=0.5)
        if len(self.get_all_listed_pages()) > 0:
            IAAssert.is_equal_to(
                actual_value=self.get_active_page(),
                expected_value=1,
                as_type=int,
                failure_msg="We failed to get back to the first page of the Table.")

    def click_last_page_button(self) -> None:
        """
        Click the option in the Page which would take a user to the 'last' page.

        :raises TimeoutException: If the 'Last' page option is not present in the Pager.
        """
        self._last_page_button.click(binding_wait_time=0.5)
        # unable to assert destination page number because of the dynamic length of data.

    def click_next_page_chevron(self, binding_wait_time: float = 0.5) -> None:
        """
        Click the chevron which would take the user to the next page.

        :param binding_wait_time: The amount of time (in seconds) after clicking the next page chevron before allowing
            code to continue.

        :raises TimeoutException: If the 'next' chevron does not exist, or if no page numbers exist in the Pager.
        """
        original_page_number = self.get_active_page()
        self._next_page_chevron.click(binding_wait_time=binding_wait_time)
        IAAssert.is_equal_to(
            actual_value=self.get_active_page(),
            expected_value=original_page_number + 1,
            as_type=int,
            failure_msg="We failed to get to the next page after clicking the next page chevron.")

    def click_page_number(self, desired_page: Union[int, str]) -> None:
        """
        Click a page number within the Pager.

        :param desired_page: The page number you wish to click.

        :raises TimeoutException: If no page numbers are present in the Pager.
        :raises IndexError: If the specified page number is not present in the Pager.
        :raises AssertionError: If the Table does not end up displaying the specified page number.
        """
        list(filter(lambda e: e.text == str(desired_page), self._page_numbers.find_all()))[0].click()
        IAAssert.is_equal_to(
            actual_value=self.get_active_page(),
            expected_value=desired_page,
            as_type=int,
            failure_msg=f"After clicking the option for page {desired_page}, we failed to display that page.")

    def click_previous_page_chevron(self, binding_wait_time: float = 0.5) -> None:
        """
        Click the chevron which would take the user to the previous page.

        :param binding_wait_time: The amount of time (in seconds) after clicking the previous page chevron before
            allowing code to continue.

        :raises TimeoutException: If the 'previous' chevron does not exist.
        """
        original_page_number = self.get_active_page()
        self._previous_page_chevron.click(binding_wait_time=binding_wait_time)
        IAAssert.is_equal_to(
            actual_value=self.get_active_page(),
            expected_value=original_page_number - 1,
            as_type=int,
            failure_msg="We failed to get to the previous page after clicking the previous page chevron.")

    def first_page_button_is_displayed(self) -> bool:
        """
        Determine if the 'First' page option is displayed in the Pager.

        :returns: True, if a user can see the 'First' page option in the Pager.
        """
        try:
            return self._first_page_button.find().is_displayed()
        except TimeoutException:
            return False

    def first_page_button_is_enabled(self) -> bool:
        """
        Determine if the 'First' page option is enabled in the Pager.

        :returns: True, if the 'First' page option in the Pager is enabled.

        :raises TimeoutException: If the 'First' page option is not present in the Pager.
        """
        return 'disabled' not in self._first_page_button.find().get_attribute("class")

    def get_active_page(self) -> int:
        """
        Obtain the number of the currently displayed page of the Table.

        :returns: The number of the currently displayed page.

        :raises TimeoutException: If no page numbers are present.
        """
        return int(self._active_page.get_text())

    def get_all_listed_pages(self) -> List[int]:
        """
        Obtain all displayed page numbers.

        :returns: All page numbers available for clicking in the Pager, including the current (disabled) page.
        """
        try:
            return [int(_.text) for _ in self._page_numbers.find_all()]
        except TimeoutException:
            return []

    def get_all_row_display_options(self) -> List[str]:
        """
        Obtain all selection options form the dropdown which controls how many rows may be displayed in the Table.

        :returns: A list of strings, where each entry is an option available for selection to drive the count of rows
            displayed by the Table.

        :raises TimeoutException: If the row count dropdown is not present.
        """
        return [_.text for _ in Select(webelement=self._row_count_select.find()).options]

    def get_last_displayed_page_number(self) -> int:
        """
        Obtain the number of the last page available in the Pager. This value may not be the count of Pages of data,
        but is the last number a user sees displayed.

        Example: A Table may have 40 pages, but the pager may only allow for quick navigation to pages 1, 2, 3, 4, or 5
        before then displaying the 'Last' page option, or a 'next set of pages' option.

        :returns: The last visible page number available for clicking in the Pager.

        :raises TimeoutException: If no pages numbers are present.
        """
        return int(self._page_numbers.find_all()[-1].text)

    def get_selected_row_count_option_from_dropdown(self) -> int:
        """
        Obtain the currently selected VALUE from the dropdown which dictates how many rows are displayed.

        :returns: The currently selected count of rows which should be displayed. If "25 rows" is selected, this will
            return 25.

        :raises TimeoutException: If the row count dropdown is not present. This dropdown/select piece is only present
            while the Table is wider than 700px.
        """
        return int(Select(webelement=self._row_count_select.find()).first_selected_option.text.split(" ")[0])

    def jump_input_is_displayed(self) -> bool:
        """
        Determine if the input which allows for a user to type a page number is displayed.

        :returns: True, if the user is able to type a page number into the Pager - False otherwise.
        """
        try:
            return self._page_jump_input.find().is_displayed()
        except TimeoutException:
            return False

    def jump_to_page(self, page_to_jump_to: Union[int, str], binding_wait_time: float = 1) -> None:
        """
        Use the page jump input field to go to a specific page in the Table.

        :param page_to_jump_to: The page of the Table to go to.
        :param binding_wait_time: The amount of time (in seconds) to wait after supplying the page number before
            allowing code to continue.

        :raises TimeoutException: If the page jump input is not present.
        :raises AssertionError: If the Table does not end up on the supplied page.
        """
        self._page_jump_input.find().click()
        self.driver.execute_script('arguments[0].value = ""', self._page_jump_input.find())
        self._page_jump_input.find().send_keys(str(page_to_jump_to) + Keys.ENTER)
        self._page_jump_input.wait_on_binding(binding_wait_time)
        # we can only manage the following assertion if page numbers are listed
        if len(self.get_all_listed_pages()) > 0:
            IAAssert.is_equal_to(
                actual_value=self.get_active_page(),
                expected_value=page_to_jump_to,
                as_type=int,
                failure_msg=f"Failed to jump to the specified page ({page_to_jump_to}).")

    def last_page_button_is_displayed(self) -> bool:
        """
        Determine if the 'Last' page option is displayed in the Pager.

        :returns: True, if a user can see the 'Last' page option in the Pager.
        """
        try:
            return self._last_page_button.find().is_displayed()
        except TimeoutException:
            return False

    def last_page_button_is_enabled(self) -> bool:
        """
        Determine if the 'Last' page option is enabled in the Pager.

        :returns: True, if the 'Last' page option in the Pager is enabled.

        :raises TimeoutException: If the 'Last' page option is not present in the Pager.
        """
        try:
            return WebDriverWait(self.driver, 2).until(IAec.function_returns_true(
                custom_function=self._last_page_button_is_enabled,
                function_args={}))
        except TimeoutException:
            return False

    def next_page_chevron_is_enabled(self) -> bool:
        """
        Determine if the next page chevron is enabled.

        :returns: True, if the next page chevron is enabled - False otherwise.
        """
        # .is_enabled() does not work here because it's just a <div>
        return "disabled" not in self._next_page_chevron.find().get_attribute("class")

    def is_displayed(self, location: Location = Location.BOTTOM) -> bool:
        """
        Determine if the specified Pager is displayed within the Table.

        :returns: True, if the Pager is visible to the user - False otherwise.
        """
        try:
            # use the parent locator list instead of our own locator list so that we don't duplicate the pager locator
            # used in our own init step.
            return ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{self._PAGER_LOCATOR[1]}.{location.value}'),
                driver=self.driver,
                parent_locator_list=self._parent_locator_list,
                wait_timeout=0,
                poll_freq=self.poll_freq).find().is_displayed()
        except TimeoutException:
            return False

    def is_hidden(self) -> bool:
        """
        Determine if the Pager is currently hidden.

        :returns: True, if the Pager exists but is currently out of sight of the user - False otherwise.

        :raises TimeoutException: If the Pager is not present at all.
        """
        return self.is_present() and 'hide' in self.find().get_attribute('class')

    def is_present(self) -> bool:
        """
        Determine if the specified Pager exists within the Table. Note that this does not reflect the VISIBILITY of the
        Pager.

        :returns: True, if the Pager exists - False otherwise.
        """
        try:
            return self.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    def page_number_is_displayed(self, desired_page: Union[int, str]) -> bool:
        """
        Determine if a specific page number is currently displayed.

        :param desired_page: The number of the page for which we will verify display status.

        :returns: True, if the supplied page number is currently displayed - False otherwise.

        :raises TimeoutException: If no page numbers are displayed in the Pager.
        """
        return str(desired_page) in [_.text for _ in self._page_numbers.find_all()]

    def previous_page_chevron_is_enabled(self) -> bool:
        """
        Determine if the previous page chevron is enabled.

        :returns: True, if the previous page chevron is enabled - False otherwise.

        :raises TimeoutException: If the previous page chevron is not present.
        """
        return "disabled" not in self._previous_page_chevron.find().get_attribute("class")

    def row_select_dropdown_is_displayed(self) -> bool:
        """
        Determine if the dropdown which allows for specifying a row count to display is displayed.

        :returns: True, if the row count dropdown is displayed - False otherwise.
        """
        try:
            return self._row_count_select.find().is_displayed()
        except (TimeoutException, StaleElementReferenceException):
            return False

    def set_displayed_row_count(self, count_of_rows: Union[int, str], attempted=False) -> None:
        """
        Set the number of displayed rows in the Table. The count of rows must be a valid value from the
        dropdown (Select) in the Pager.

        :param count_of_rows: The desired count of rows (this must be one of the available values in the dropdown).
        :param attempted: Has the function been tried already, if False and counts do not match try to set the dropdown
            value once more.

        :raises TimeoutException: If the row count dropdown is not present.
        :raises AssertionError: If unable to set the row count dropdown to the supplied row count.
        """
        Select(webelement=self._row_count_select.find()).select_by_value(str(count_of_rows))
        self.wait_on_binding(time_to_wait=1)
        if str(count_of_rows) != str(self.get_selected_row_count_option_from_dropdown()):
            if not attempted:
                self.set_displayed_row_count(count_of_rows, attempted=True)
            else:
                # already tried once...
                pass
        IAAssert.is_equal_to(
            actual_value=self.get_selected_row_count_option_from_dropdown(),
            expected_value=count_of_rows,
            as_type=int,
            failure_msg="We failed to specify a count of rows to select.")

    def _last_page_button_is_enabled(self) -> bool:
        """Determine if the 'Last' page button is enabled."""
        return 'disabled' not in self._last_page_button.find().get_attribute('class')

Components/PerspectiveComponents/Common/TablePieces/Pager.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Components.PerspectiveComponents.Common.Tree import CommonTree


class CommonTagBrowseTree(CommonTree, BasicPerspectiveComponent):
    _TAG_BROWSER_NO_RESULTS_FOUND_LOCATOR = (By.CSS_SELECTOR, 'span.ia_tagBrowser__noResultsDisplay__message')
    _TAG_BROWSER_RELOAD_ICON_LOCATOR = (By.CSS_SELECTOR, '.reload-icon svg')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonTree.__init__(
            self, locator=locator, driver=driver, parent_locator_list=parent_locator_list, poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._no_results = ComponentPiece(
            locator=self._TAG_BROWSER_NO_RESULTS_FOUND_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._reload_icon = CommonIcon(
            locator=self._TAG_BROWSER_RELOAD_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def click_refresh_icon(self, binding_wait_time=5) -> None:
        """
        Click the refresh icon of the Tag Browse Tree.

        :raises TimeoutException: If the refresh icon is not present.
        """
        self._reload_icon.click(binding_wait_time=binding_wait_time)

    def get_refresh_icon_path(self) -> str:
        """
        Get a slash-delimited path of the refresh icon.

        :returns: The path in use by the svg in a slash-delimited format.
        """
        return self._reload_icon.get_icon_name()

    def no_results_message_is_displayed(self, wait_timeout=5) -> bool:
        """
        Determine if the Tag Browse Tree is currently displaying a message conveying that no results are found for the
        configured provider.

        :param wait_timeout: The amount of time (in seconds) to wait for the message to appear.

        :returns: True, if the message is displayed - False otherwise.
        """
        try:
            return self._no_results.find(wait_timeout=wait_timeout).is_displayed()
        except TimeoutException:
            return False

    def refresh_icon_is_displayed(self, wait_timeout=5) -> bool:
        """
        Determine if the refresh icon is currently displayed.

        :param wait_timeout: The amount of time (in seconds) to wait for the icon to appear.

        :returns: True, if the icon is displayed - False otherwise.
        """
        try:
            return self._reload_icon.find(wait_timeout=wait_timeout).is_displayed()
        except TimeoutException:
            return False

Components/PerspectiveComponents/Common/TagBrowseTree.py
inductiveautomation/ignition-automation-tools
import sys
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon


class CommonTree(ComponentPiece):
    """
    A Tree of items, as is found as part of the Tree Component, the Tag Browse Tree, and the Power Chart.

    Selenium is limited in how it locates items for interaction, and so if two visible nodes share the same label text
    the first (top-most) item is the one which will be interacted with.

    https://youtrack.ia.local/issue/IGN-7575 exists to move the data-label attribute up higher, which would allow
    for us to target specific items which have duplicated text by item path instead of zero-index.
    """
    _EXPANDED_ICON_CLASS = "ia_treeComponent__expandIcon--expanded"
    _FOLDER_EXPAND_ICON_LOCATOR = (
        By.CSS_SELECTOR, 'svg.expand-icon.ia_treeComponent__expandIcon')
    _LABEL_LOCATOR = (
        By.CSS_SELECTOR, 'div.label-wrapper-text')
    _TOP_LEVEL_ITEMS_LOCATOR = (By.CSS_SELECTOR, 'div.tree>div>div>div.tree-row')
    _ITEM_CLASS = "tree-item"
    _TREE_ITEM_LOCATOR = (By.CSS_SELECTOR, f".{_ITEM_CLASS}")

    def __init__(
            self,
            driver: WebDriver,
            locator: Tuple[By, str],
            parent_locator_list: Optional[List] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._folder_icons = ComponentPiece(
            locator=self._FOLDER_EXPAND_ICON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._expanded_folders = ComponentPiece(
            locator=(By.CSS_SELECTOR, f'div[class*="expanded"] div.text-scroll'),
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._top_level_items = ComponentPiece(
            locator=self._TOP_LEVEL_ITEMS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._tree_items = ComponentPiece(
            locator=self._TREE_ITEM_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._items = {}
        self._item_icons = {}
        self._expand_collapse_icons = {}
        self._labels = {}

    def click_expansion_icon_for_item(self, item_label: str, binding_wait_time: int = 0) -> None:
        """
        Click the expand icon for an item regardless of expansion state. If you are attempting to set the expansion
        state, you should use :func:`set_expansion_state_for_item`

        :param item_label: The text of the item for which we will click the expansion icon.
        :param binding_wait_time: The amount of time (in seconds) to wait after the click event before allowing code to
            continue.

        :raises TimeoutException: If no icon exists or if no item with the supplied label exists.
        """
        self._get_expansion_icon(item_label=item_label).click(binding_wait_time=binding_wait_time)

    def click_item_label(self, item_label: str, binding_wait_time: float = 0) -> None:
        """
        Click the label of an item.

        :param item_label: The text of the item you would like to click.
        :param binding_wait_time: The amount of time (in seconds) to wait after the click event before allowing code to
            continue.

        :raises TimeoutException: If no item with the supplied text is available to be clicked. This could either mean
            no item with that text exists, or the target item could be inside an item which has not yet been expanded.
        """
        label = self._labels.get(item_label)
        if not label:
            label = ComponentPiece(
                locator=self._LABEL_LOCATOR,
                driver=self.driver,
                parent_locator_list=self._get_item_by_label(item_label=item_label).locator_list,
                poll_freq=self.poll_freq)
            self._labels[item_label] = label
        label.click(binding_wait_time=binding_wait_time)

    def expansion_icon_is_present_for_item(self, item_label: str) -> bool:
        """
        Determine if an item is currently displaying an expansion icon.

        :returns: True, if an expansion icon is displayed for the first item with the supplied text. False, if an
            expansion icon is not displayed, or if no item with the supplied text exists.
        """
        try:
            return ComponentPiece(
                locator=(By.CSS_SELECTOR, "g"),
                driver=self.driver,
                parent_locator_list=self._get_expansion_icon(item_label=item_label).locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq).find().is_displayed()
        except TimeoutException:
            return False

    def get_fill_color_of_expansion_icon_for_item(self, item_label: str) -> str:
        """
        Obtain the fill color of the expansion icon for an item.

        :returns: The fill color of the expansion icon for the first item with the supplied text as a string. Note that
            different browsers may return this string in different formats (RGB vs hex).

        :raises TimeoutException: If no expansion icon exists for the item, or if no item with the supplied text exists.
        """
        return self._get_expansion_icon(item_label=item_label).get_fill_color()

    def get_fill_color_of_icon_for_item(self, item_label: str) -> str:
        """
        Obtain the fill color of the icon for an item.

        :returns: The fill color of the icon for the first item with the supplied text as a string. Note that
            different browsers may return this string in different formats (RGB vs hex).

        :raises TimeoutException: If no icon exists for the item, or if no item with the supplied text exists.
        """
        return self._get_item_icon(item_label=item_label).get_fill_color()

    def get_path_of_expansion_icon_for_item(self, item_label: str) -> str:
        """
        Obtain the path of the expansion icon in use by an item.

        :returns: A slash-delimited string representing the path of the svg in use.

        :raises TimeoutException: If no expansion icon exists for the item, or if no item with the supplied text exists.
        """
        return self._get_expansion_icon(item_label=item_label).get_icon_name()

    def get_path_of_icon_for_item(self, item_label: str) -> str:
        """
        Obtain the path of the icon in use by an item.

        :returns: A slash-delimited string representing the path of the svg in use.

        :raises TimeoutException: If no icon exists for the item, or if no item with the supplied text exists.
        """
        return self._get_item_icon(item_label=item_label).get_icon_name()

    def get_text_of_top_level_items(self) -> List[str]:
        """
        Obtain the text of all items located at the top-most level.
        """
        try:
            return [_.text for _ in self._top_level_items.find_all()]
        except TimeoutException:
            return []

    def get_text_of_all_items_in_tree(self, wait_timeout: float = 5) -> List[str]:
        """
        Obtain a list which contains the text of all displayed items in the Tree.

        :param wait_timeout: How long to wait (in seconds) for any tree items to appear.
        """
        try:
            return [item.text for item in self._tree_items.find_all(wait_timeout=wait_timeout)]
        except TimeoutException:
            return []

    def multi_select_items(
            self, item_labels: List[str], inclusive_multi_selection: bool = True, wait_timeout: float = 5) -> None:
        """
        Select multiple items within the Tree. Note that any selections previous to this function could be lost.

        :param item_labels: A list of item labels to select.
        :param inclusive_multi_selection: Dictates whether we are holding Shift (True) or CMD/Ctrl (False) while making
            the selections.
        :param wait_timeout: The amount of time to wait for each item to become available before attempting selection.

        :raises TimeoutException: If no item exists with text matching any individual entry from the items_list.
        """
        actions = ActionChains(self.driver)
        if inclusive_multi_selection:
            mod_key = Keys.SHIFT
        else:
            mod_key = Keys.COMMAND if sys.platform.startswith('darwin') else Keys.CONTROL

        actions.key_down(mod_key)
        piece_list = [self._get_item_by_label(item_label=_, wait_timeout=wait_timeout) for _ in item_labels]
        for piece in piece_list:
            actions.click(on_element=piece.find()).pause(1)
        actions.key_up(mod_key)
        actions.perform()
        self.wait_on_binding(1)

    def item_label_exists_in_tree(self, item_label: str, wait_timeout: int = 5) -> bool:
        """
        Determine if an item exists based on its label text.

        :param item_label: The label text of the item whose existence you wish to verify.
        :param wait_timeout: The amount of time (in seconds) you are willing to wait for the item to appear.

        :returns: True, if any item matches the supplied text - False otherwise.
        """
        try:
            return self._get_item_by_label(item_label=item_label, wait_timeout=wait_timeout).find() is not None
        except TimeoutException:
            return False

    def item_is_expanded(self, item_label: str) -> bool:
        """
        Determine if an item is expanded.

        :param item_label: The label text of the item we will check the expanded status of.

        :raises TimeoutException: If the item we check does not have an expansion icon. This likely means that the
            specified item is a terminal node and may not be expanded.
        """
        try:
            return self._EXPANDED_ICON_CLASS in self._get_expansion_icon(
                item_label=item_label).find().get_attribute(name="class")
        except TimeoutException:
            return False

    def node_icon_is_present_for_item(self, item_label: str) -> bool:
        """
        Determine if an item is currently displaying an icon which would convey whether the item is a
        directory/folder or a terminal node.

        :param item_label: The text of the item we will check for a node icon.

        :returns: True, if an icon which would convey the type of the item is displayed for the first item with the
            supplied text. False, if an icon is not displayed, or if no item with the supplied text exists.
        """
        try:
            return ComponentPiece(
                locator=(By.CSS_SELECTOR, "g"),
                driver=self.driver,
                parent_locator_list=self._get_item_icon(item_label=item_label).locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq).find().is_displayed()
        except TimeoutException:
            return False

    def select_item_in_tree(self, item_label: str, wait_timeout: int = 5, binding_wait_time: int = 1) -> None:
        """
        Select a single item in the Tree.

        :param item_label: The label text of the item to click.
        :param wait_timeout: The amount of time (in seconds) to wait for the item to appear.
        :param binding_wait_time: The amount of time (in seconds) after clicking the item to wait before allowing code
            to continue.

        :raises TimeoutException: If no item with the supplied text exists.
        """
        self._get_item_by_label(
            item_label=item_label, wait_timeout=wait_timeout).click(binding_wait_time=binding_wait_time)

    def set_expansion_state_for_item(self, item_label: str, should_be_expanded: bool) -> None:
        """
        Set an item to be expanded or collapsed.

        :param item_label: The label text of the item to expand or collapse.
        :param should_be_expanded: A True value specifies the item should be expanded, while a False value specifies the
            item should be collapsed.

        :raises TimeoutException: If no item with the supplied text exists, or if the specified item has no expansion
            icon.
        """
        if self.item_is_expanded(item_label=item_label) != should_be_expanded:
            try:
                self._get_expansion_icon(item_label=item_label).click()
            except TimeoutException as toe:
                toe.msg = f"Node with label of '{item_label}' does not contain an expand/collapse icon."
                raise toe

    def _get_expansion_icon(self, item_label: str) -> CommonIcon:
        """Obtain the icon which conveys the expansion status of the item."""
        icon = self._expand_collapse_icons.get(item_label)
        if not icon:
            icon = CommonIcon(
                locator=(By.CSS_SELECTOR, "svg.expand-icon"),
                driver=self.driver,
                parent_locator_list=self._get_item_by_label(item_label=item_label).locator_list,
                poll_freq=self.poll_freq)
            self._expand_collapse_icons[item_label] = icon
        return icon

    def _get_item_icon(self, item_label: str) -> CommonIcon:
        """Obtain the icon which conveys whether the item is a directory/folder or a terminal node."""
        icon = self._item_icons.get(item_label)
        if not icon:
            icon = CommonIcon(
                locator=(By.CSS_SELECTOR, "svg.node-icon"),
                driver=self.driver,
                parent_locator_list=self._get_item_by_label(item_label=item_label).locator_list,
                poll_freq=self.poll_freq)
            self._item_icons[item_label] = icon
        return icon

    def _get_index_of_item_in_tree(self, item_label: str) -> int:
        """Get the zero-indexed position among all items for the label with the supplied text."""
        return [_.text for _ in self._tree_items.find_all()].index(item_label)

    def _get_item_by_label(self, item_label: str, wait_timeout: float = 5) -> ComponentPiece:
        """Obtain the first item with the supplied text."""
        item = self._items.get(item_label)
        if not item:
            item = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{self._TREE_ITEM_LOCATOR[1]}[data-label="{item_label}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                wait_timeout=wait_timeout,
                poll_freq=self.poll_freq)
            self._items[item_label] = item
        return item

    @classmethod
    def _split_item_label_path(cls, slash_delimited_label_path: str) -> List[str]:
        """Obtain the pieces of a string after having split the string on a slas ('/')."""
        return slash_delimited_label_path.split('/')

Components/PerspectiveComponents/Common/Tree.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, List, Optional

from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class Column(BasicPerspectiveComponent):
    """A Perspective Column Container."""
    _ROW_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.inner-container")
    _ROW_LOCATOR = (By.CSS_SELECTOR, f"{_ROW_CONTAINER_LOCATOR[1]} > div.row")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._rows = ComponentPiece(
            locator=self._ROW_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=2,
            poll_freq=poll_freq)
        self._indexed_rows_dict = {}

    def element_with_locator_is_in_row_by_index(
            self, element_locator: Tuple[By, str], zero_based_row_index: int) -> bool:
        """
        Determine if the supplied locator exists within a specified row.

        :param element_locator: The Tuple locator of the element to search for in the specified row.
        :param zero_based_row_index: The zero-based index of the row we will search for the provided locator.

        :returns: True, if the supplied locator is found within the specified row - False otherwise.
        """
        try:
            return self._get_row_by_index(index=zero_based_row_index).find().find_element(*element_locator) is not None
        except NoSuchElementException:
            return False
        except TimeoutException:
            return False

    def get_count_of_web_elements_in_row_by_locator(self, zero_based_row_index: int, locator: Tuple[By, str]) -> int:
        """
        Obtain a count of the number of instances which match the supplied locator within a specified row.

        :param zero_based_row_index: The zero-based index of the row within which we will search for the supplied
            locator.
        :param locator: The Tuple locator we will search for within the specified row.

        :returns: The number of times the supplied locator is found within the specified row.

        :raises TimeoutException: If the supplied row index is not present.
        """
        try:
            # allow TimeoutException to be raised if row does not exist.
            return len(self._get_row_by_index(index=zero_based_row_index).find().find_elements(*locator))
        except NoSuchElementException:
            # return 0 if no element with locator is found.
            return 0

    def get_row_of_component_by_locator(self, element_locator: Tuple[By, str]) -> Optional[int]:
        """
        Obtain the row index of the row which contains the supplied locator.

        :param element_locator: The Tuple locator which describes an element located within the Column Container.

        :returns: The zero-based index of the row which contains the supplied locator, or None if no row contains the
            locator.
        """
        for row in self._rows.find_all():
            try:
                if row.find_element(*element_locator) is not None:
                    return int(row.get_attribute("data-row-index"))
            except NoSuchElementException:
                pass
        return None

    def _get_row_by_index(self, index: int) -> ComponentPiece:
        """
        Obtain a Component Piece which defines a singular row within the Column Container.

        :returns: A ComponentPiece which defines a singular row within the Column Container.
        """
        row = self._indexed_rows_dict.get(index)
        if not row:
            row = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{self._ROW_CONTAINER_LOCATOR[1]} div[data-row-index="{index}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                wait_timeout=2,
                poll_freq=self.poll_freq)
            self._indexed_rows_dict[index] = row
        return row

Components/PerspectiveComponents/Containers/Column.py
inductiveautomation/ignition-automation-tools
from Components.BasicComponent import BasicPerspectiveComponent


class Coordinate(BasicPerspectiveComponent):
    pass

Components/PerspectiveComponents/Containers/Coordinate.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.CSSEnumerations import CSS


class Orientation(Enum):
    """An enumeration of the available orientations of the Split Container."""
    HORIZONTAL = 'horizontal'
    VERTICAL = 'vertical'


class Split(BasicPerspectiveComponent):
    """A Perspective Split Container."""
    _CONTENT_WRAPPER_LOCATOR = (By.CSS_SELECTOR, 'div.split-container')
    _FIRST_PANE_LOCATOR = (By.CSS_SELECTOR, 'div.split-pane')
    _SPLIT_HANDLE_LOCATOR = (By.CSS_SELECTOR, 'div.split-handle')
    _INVISIBLE_HANDLE_LOCATOR = (By.CSS_SELECTOR, 'div.split-handle-invis')
    _HANDLE_ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.drag-icon')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._content_wrapper = ComponentPiece(
            locator=self._CONTENT_WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._split_handle = ComponentPiece(
            locator=self._SPLIT_HANDLE_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._content_wrapper.locator_list,
            poll_freq=poll_freq)
        self._split_handle_invisible = ComponentPiece(
            locator=self._INVISIBLE_HANDLE_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._content_wrapper.locator_list,
            poll_freq=poll_freq)
        self._first_pane = ComponentPiece(
            locator=self._FIRST_PANE_LOCATOR,
            driver=driver,
            parent_locator_list=self._content_wrapper.locator_list,
            poll_freq=poll_freq)
        self._handle_icon = CommonIcon(
            locator=self._HANDLE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._split_handle.locator_list,
            poll_freq=poll_freq)

    def click_and_hold_split_handle(self, pause_after_click: float = 5) -> None:
        """
        Click the handle of the Split Container, and hold it for an indefinite amount of time. NOTE: THIS FUNCTION DOES
        NOT RELEASE THE CLICK.

        :param pause_after_click: The amount of time after making the click before allowing code to continue.
        """
        ActionChains(self.driver).click_and_hold(self._split_handle.find()).pause(seconds=pause_after_click).perform()

    def drag_split_handle_to_pixel_value(self, desired_position_in_pixels: float) -> None:
        """
        Drag the handle of the Split Container from its current position to the specified pixel. Pixels outside the
        bounds of the Split Container are valid, though the Split Container should stop updating the handle position
        once the cursor leaves the bounds of the Container. The drag will always be done to the specified pixel based
        on the orientation of the Split Container; vertical orientations will always drag the handle up/down, and
        horizontal orientations will always drag the handle left/right.

        :param desired_position_in_pixels: The pixel to which we should drag the handle of the Split Container.
        """
        if self.has_orientation(orientation=Orientation.VERTICAL):
            x_step = 0
            y_step = 1
            first_pane_height = float(self._first_pane.get_computed_height())
            handle_height = float(self._split_handle.get_computed_height())
            current_pixel_position = first_pane_height + (handle_height / 2)
        else:
            x_step = 1
            y_step = 0
            first_pane_width = float(self._first_pane.get_computed_width())
            handle_width = float(self._split_handle.get_computed_width())
            current_pixel_position = first_pane_width + (handle_width/2)
        pixel_count = (float(desired_position_in_pixels) - current_pixel_position)
        ActionChains(self.driver)\
            .click_and_hold(self._split_handle.find())\
            .pause(seconds=5)\
            .move_by_offset((x_step * pixel_count), (y_step * pixel_count))\
            .release()\
            .perform()

    def drag_split_handle_to_percentage_value(self, desired_percentage: float) -> None:
        """
        Drag the handle of the Split Container to a percentage of the available space of the Split Container.

        :param desired_percentage: The numeric percentage to which we should drag the handle of the Split Container.
        """
        if self.has_orientation(orientation=Orientation.VERTICAL):
            x_step = 0
            y_step = 1
            first_pane_height = float(self._first_pane.get_computed_height())
            handle_height = float(self._split_handle.get_computed_height())
            container_height = float(self.get_computed_height())
            current_percent = (first_pane_height + (handle_height / 2.0))
            target_percent = (desired_percentage / 100.0) * container_height
        else:
            x_step = 1
            y_step = 0
            first_pane_width = float(self._first_pane.get_computed_width())
            handle_width = float(self._split_handle.get_computed_width())
            container_width = float(self.get_computed_width())
            current_percent = (first_pane_width + (handle_width / 2.0))
            target_percent = (desired_percentage / 100.0) * container_width
        pixel_count = target_percent - current_percent
        ActionChains(self.driver)\
            .click_and_hold(self._split_handle.find()) \
            .pause(seconds=5)\
            .move_by_offset((x_step*pixel_count), (y_step*pixel_count))\
            .release()\
            .perform()

    def get_split_handle_height(self, include_units: bool = False) -> str:
        """
        Obtain the height of the handle of the Split Container.

        :param include_units: Dictates whether the returned height includes units of measurement.

        :returns: The height of the handle of the Split Container, potentially with units.
        """
        try:
            return self._split_handle.get_computed_height(include_units=include_units)
        except TimeoutException:
            return self._split_handle_invisible.get_computed_height(include_units=include_units)

    def get_split_handle_position_within_container(self) -> float:
        """
        Obtain the pixel location of the CENTER of the handle within the Split Container. Note that this pixel location
        is relative to the upper-left corner of the Split Container - not the Viewport.

        :returns: The pixel location of the CENTER of the handle of the Split Container, as a relative position within
            the Split Container.
        """
        if self.has_orientation(orientation=Orientation.HORIZONTAL):
            return float(self._first_pane.get_css_property(property_name=CSS.WIDTH).split("p")[0]) + \
                   (float(self.get_split_handle_width()) / 2)
        else:
            return float(self._first_pane.get_css_property(property_name=CSS.HEIGHT).split("p")[0]) + \
                   (float(self.get_split_handle_height()) / 2)

    def get_split_handle_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of the handle of the Split Container.

        :param include_units: Dictates whether the returned width includes units of measurement.

        :returns: The width of the handle of the Split Container, potentially with units.
        """
        try:
            return self._split_handle.get_computed_width(include_units=include_units)
        except TimeoutException:
            return self._split_handle_invisible.get_computed_width(include_units=include_units)

    def has_orientation(self, orientation: Orientation) -> bool:
        """
        Determine if the Split Container has the specified orientation.

        :param orientation: The orientation we will attempt to verify.

        :returns: True, of the Split Container has the specified orientation - False otherwise.
        """
        return f'split-container-{orientation.value}' in self._content_wrapper.find().get_attribute('class')

    def release_split_handle_click(self) -> None:
        """
        Release any held click on the Split Container.
        """
        ActionChains(self.driver).release().perform()

    def split_handle_is_active(self) -> bool:
        """
        Determine if the handle of the Split Container is currently rendered as being in an active state.

        :returns: True, if the handle of the Split Container is rendered as 'active'.
        """
        return 'isActive' in self._split_handle.find().get_attribute('class')

    def split_handle_is_draggable(self) -> bool:
        """
        Determine if the handle is displaying as enabled/draggable.

        :returns: True, if the handle is displaying as enabled/draggable - False otherwise.
        """
        return 'split-handle-undraggable' not in self._split_handle.find().get_attribute('class')

    def split_handle_is_visible(self) -> bool:
        """
        Determine if the handle of the Split Container is visible to a user.

        :returns: True, if a user is able to see the handle of the Split Container - False otherwise.
        """
        try:
            return self._split_handle.find().is_displayed()
        except TimeoutException:
            return not self._split_handle_invisible.find().is_displayed()

Components/PerspectiveComponents/Containers/Split.py
inductiveautomation/ignition-automation-tools
from typing import Union, List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS


class Tab(BasicPerspectiveComponent):
    """A Perspective Tab Container."""
    _TAB_CSS_STRING = 'div.tab-menu-item'
    _ACTIVE_TAB_CLASS = 'tab-active'
    _ACTIVE_TAB_LOCATOR = (By.CSS_SELECTOR, f"{_TAB_CSS_STRING}.{_ACTIVE_TAB_CLASS}")
    _CLASSIC_MODE_CLASS = 'menu-classic'
    _MODERN_MODE_CLASS = 'menu-modern'
    _VIEW_PARENT_CLASS = 'view-parent'
    _TAB_DISABLED_CLASS = 'tab-disabled'
    _TAB_LOCATOR = (By.CSS_SELECTOR, f'{_TAB_CSS_STRING} span')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._tabs = ComponentPiece(
            locator=self._TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        self._tabs_by_index = {}
        self._active_tab = ComponentPiece(
            locator=self._ACTIVE_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            poll_freq=poll_freq)

    def click_tab_by_index(self, index: Union[int, str]) -> None:
        """
        Click a tab based on its index.

        :param index: The zero-based index of the tab you would like to click.

        :raises TimeoutException: If the supplied index is not present.
        """
        self._get_tab_by_index(index=index).click(binding_wait_time=0.5)

    def _get_tab_by_index(self, index: int) -> ComponentPiece:
        """
        Obtain a ComponentPiece which defines a tab of the container by its index.

        :param index: The zero-based index of the tab this ComponentPiece will define.
        """
        index = int(index)
        tab_element = self._tabs_by_index.get(index)
        if not tab_element:
            tab_element = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div[data-index="{index}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq)
            self._tabs_by_index[index] = tab_element
        return tab_element

    def get_active_tab_index(self) -> int:
        """
        Obtain the index of the currently active tab.

        :returns: The zero-based index of the tab which is currently active for the container.
        """
        return int(self._active_tab.find().get_attribute("data-index"))

    def get_count_of_tabs(self) -> int:
        """
        Obtain a count of tabs currently present for the container.

        :returns: The count of tabs which belong to this container.
        """
        try:
            return len(self._tabs.find_all(wait_timeout=1))
        except TimeoutException:
            return 0

    def click_tab_by_text(self, text: str) -> None:
        """
        Click a tab for this container based on the text of the tab. The supplied text must be an exact match of all
        text contained within the tab. No action is taken if no tab contains the supplied text.

        :param text: The exact text contained within the tab to click.
        """
        for tab in self._tabs.find_all():
            if tab.text == text:
                tab.click()

    def tab_is_rendering_view(self, index: int) -> bool:
        """
        Determine if the specified tab is rendering as a View.

        :param index: The zero-based index of the tab to check.

        :returns: True, if the specified tab is rendering as a View - False otherwise.
        """
        return self._VIEW_PARENT_CLASS in self._get_tab_by_index(index=index).find().get_attribute("class")

    def tab_is_disabled(self, index: int) -> bool:
        """
        Determine if a tab is currently disabled.

        :param index: The zero-based index of the tab to check.

        :returns: True, if the specified tab is disabled - False otherwise.
        """
        return self._TAB_DISABLED_CLASS in self._get_tab_by_index(index=index).find().get_attribute("class")

    def get_tab_computed_width(self, index: int, include_units: bool = False) -> str:
        """
        Obtain the width of a tab which belongs to this container.

        :param index: The zero-based index of the tab to check.
        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The width of the specified tab, potentially with units of measurement.
        """
        return self._get_tab_by_index(index=index).get_computed_width(include_units=include_units)

    def get_tab_background_color(self, index: int) -> str:
        """
        Obtain the background color in use for a tab of this container. Note that different browsers may return this
        color value in different formats (RGB vs hex).

        :param index: The zero-based index of the tab from which you would like the background color.

        :returns: The background color in use for the specified tab as a string.
        """
        return self._get_tab_by_index(index=index).get_css_property(property_name=CSS.BACKGROUND_COLOR)

    def get_tab_container_computed_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of the container which contains all of the tabs which belongs to this container.

        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The width of the container which contains all of the tabs, potentially with units of measurement.
        """
        return self.get_computed_width(include_units=include_units)

    def get_names_of_tabs(self) -> List[str]:
        """
        Obtain the names of all tabs of this container.

        :returns: A list, where each string element is the text displayed within the tab.
        """
        return [tab.text for tab in self._tabs.find_all()]

    def tabs_are_in_classic_mode(self) -> bool:
        """
        Determine if tabs are rendering in 'classic' mode.

        :returns: True, if all tabs are rendering in the 'classic' mode - False if even one of the tabs is not rendering
            in the 'classic' mode.
        """
        for tab in self._tabs.find_all():
            if self._CLASSIC_MODE_CLASS not in tab.get_attribute("class"):
                return False
        return True

    def tabs_are_in_modern_mode(self) -> bool:
        """
        Determine if tabs are rendering in 'modern' mode.

        :returns: True, if all tabs are rendering in the 'modern' mode - False if even one of the tabs is not rendering
            in the 'modern' mode.
        """
        for tab in self._tabs.find_all():
            if self._MODERN_MODE_CLASS not in tab.get_attribute("class"):
                return False
        return True

Components/PerspectiveComponents/Containers/Tab.py
inductiveautomation/ignition-automation-tools
from typing import List, Union, Optional, Tuple

from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException, TimeoutException, \
    ElementNotInteractableException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.Checkbox import CommonCheckbox
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Components.PerspectiveComponents.Common.DateRangeSelector import \
    CommonDateRangeSelector, DateRangeSelectorTab, DateRangeSelectorTimeUnit, HistoricalRange
from Components.PerspectiveComponents.Common.Table import Table
from Components.PerspectiveComponents.Common.TablePieces.Body import Body
from Components.PerspectiveComponents.Common.TablePieces.Filter import Filter
from Components.PerspectiveComponents.Common.TablePieces.HeaderAndFooter import Header
from Components.PerspectiveComponents.Common.TablePieces.Pager import Pager
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Helpers.IASelenium import IASelenium
from Helpers.Ignition.Alarm import AlarmPriority, AlarmState, AlarmStatusTableAlarmState
from Helpers.Point import Point


class AlarmTableColumn:
    """
    An Alarm Table Column definition, which contains insight into both the name of the column, as well as the id
    used to designate that column in the DOM.
    """
    data_column_id = None
    name = None
    
    def __init__(self, data_column_id: str, name: str):
        self.data_column_id = data_column_id
        self.name = name


class StandardAlarmTableColumns:
    """
    Standard columns available to both the Alarm Status Table and Alarm Journal Table.
    """
    ACK_NOTES = AlarmTableColumn(data_column_id='ackNotes', name='Ack Notes')
    ACK_USER = AlarmTableColumn(data_column_id='ackUser', name='Ack User')
    ACK_PIPELINE = AlarmTableColumn(data_column_id='ackPipeline', name='Ack Pipeline')
    ACK_TIME = AlarmTableColumn(data_column_id='ackTime', name='Ack Time')
    ACTIVE_DURATION = AlarmTableColumn(data_column_id='activeDuration', name='Active Duration')
    ACTIVE_TIME = AlarmTableColumn(data_column_id='activeTime', name='Active Time')
    CLEAR_PIPELINE = AlarmTableColumn(data_column_id='clearPipeline', name='Clear Pipeline')
    CLEAR_TIME = AlarmTableColumn(data_column_id='clearTime', name='Clear Time')
    DEADBAND = AlarmTableColumn(data_column_id='deadband', name='Deadband')
    DISPLAY_PATH = AlarmTableColumn(data_column_id='displayPath', name='Display Path')
    EVENT_ID = AlarmTableColumn(data_column_id='eventId', name='Event Id')
    EVENT_STATE = AlarmTableColumn(data_column_id='eventState', name='Event State')
    EVENT_TIME = AlarmTableColumn(data_column_id='eventTime', name='Event Time')
    EVENT_VALUE = AlarmTableColumn(data_column_id='eventValue', name='Event Value')
    EXPIRES = AlarmTableColumn(data_column_id='expires', name='Expires')
    IS_ACKED = AlarmTableColumn(data_column_id='isAcked', name='Is Acked')
    IS_ACTIVE = AlarmTableColumn(data_column_id='isActive', name='Is Active')
    IS_CLEAR = AlarmTableColumn(data_column_id='isClear', name='Is Clear')
    IS_SYSTEM_EVENT = AlarmTableColumn(data_column_id='isSystemEvent', name='Is System Event')
    LABEL = AlarmTableColumn(data_column_id='label', name='Label')
    NAME = AlarmTableColumn(data_column_id='name', name='Name')
    PRIORITY = AlarmTableColumn(data_column_id='priority', name='Priority')
    SHELVED_BY = AlarmTableColumn(data_column_id='shelvedBy', name='Shelved By')
    SOURCE = AlarmTableColumn(data_column_id='source', name='Source')
    SOURCE_PATH = AlarmTableColumn(data_column_id='sourcePath', name='Source Path')
    STATE = AlarmTableColumn(data_column_id='state', name='State')
    NOTES = AlarmTableColumn(data_column_id='notes', name='Notes')
    UNACK_DURATION = AlarmTableColumn(data_column_id='unackDuration', name='Unack Duration')

    def get_all_permanent_columns(self) -> List[AlarmTableColumn]:
        """
        Obtain all columns which are always available for configuration within the Alarm Tables.
        """
        return [
            self.ACK_NOTES,
            self.ACK_USER,
            self.ACK_PIPELINE,
            self.ACK_TIME,
            self.ACTIVE_DURATION,
            self.ACTIVE_TIME,
            self.CLEAR_PIPELINE,
            self.CLEAR_TIME,
            self.DEADBAND,
            self.DISPLAY_PATH,
            self.EVENT_ID,
            self.EVENT_STATE,
            self.EVENT_TIME,
            self.EVENT_VALUE,
            self.EXPIRES,
            self.IS_ACKED,
            self.IS_ACTIVE,
            self.IS_CLEAR,
            self.IS_SYSTEM_EVENT,
            self.LABEL,
            self.NAME,
            self.PRIORITY,
            self.SHELVED_BY,
            self.SOURCE,
            self.SOURCE_PATH,
            self.STATE,
            self.NOTES,
            self.UNACK_DURATION
        ]


class AssociatedDataAlarmTableColumn(AlarmTableColumn):
    """A Column which may be configured for appearance, but only exists due to the custom configuration of an alarm."""
    
    def __init__(self, associated_data_column_name: str):
        """
        :param associated_data_column_name: The case-sensitive name of the Associated Data field.
        """
        super().__init__(data_column_id=associated_data_column_name, name=associated_data_column_name)
        

class _AlarmTable(Table, BasicPerspectiveComponent):
    """A common definition of an Alarm Table in Perspective.

    Important terminology:
    Prefilter: Conditional filters - like State and Priority - which may be applied to the table in order to filter
        the displayed results to only those alarm entries which match at least one of the applied conditions.
    """

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: List[Tuple[By, str]],
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        Table.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq
        )
        self._alarm_table_toolbar = _AlarmTableToolbar(
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._alarm_table_body = _AlarmTableBody(
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._alarm_table_filter = _AlarmTableFilter(
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._alarm_table_header = _AlarmTableHeader(
            driver=driver,
            parent_locator_list=parent_locator_list,
            poll_freq=poll_freq)
        self._pager = Pager(
            driver=driver, 
            parent_locator_list=self.locator_list, 
            poll_freq=poll_freq)

    def apply_filter_string(self, filter_string: str, wait_for_filtered_results: float = 2) -> None:
        """
        Expand the text filter located at the top of the Alarm Table, and then type a supplied string into the input.

        :param filter_string: The string to type into the filter input.
        :param wait_for_filtered_results: The amount of time (in seconds) to wait for the filtered result count to
            change before allowing code to continue.

        :raises TimeoutException: If the text filter is not present.
        """
        self._alarm_table_filter.set_filter_text(text=filter_string, binding_wait_time=wait_for_filtered_results)

    def apply_prefilters(
            self,
            prefilter_list: List[Union[AlarmState, AlarmPriority]],
            close_modal_after: bool = True) -> None:
        """
        Expand the popover/modal which contains the Alarm States and Priorities, and then apply the supplied
            States/Priorities to the Alarm table.

        :param prefilter_list: A list of the Alarm States and/or Alarm Priorities you would like to have applied as
            a condition for display of an alarm.
        :param close_modal_after: Dictates whether the popover/modal which contains the states and priorities is
            closed after the selections are made.

        :raises AssertionError: If unsuccessful in applying all supplied States/Priorities.
        :raises TimeoutException: If the icon required to expand the conditional prefilter popover/modal is not present.
        """
        self.open_prefilter_popover()
        self._alarm_table_toolbar.apply_prefilters(
            prefilter_list=prefilter_list)
        if close_modal_after:
            self.close_any_open_popover_or_modal()
        self._alarm_table_toolbar.wait_on_binding(time_to_wait=1)

    def apply_show_all_events_prefilter(self) -> None:
        """
        Expand the Prefilter modal, select the 'Show All' option under 'Events' (NOT PRIORITY), and then close the
        modal.

        :raises TimeoutException: If the Prefilter icon is not present in the toolbar of the Alarm Table.
        """
        if not self._alarm_table_toolbar.prefilter_popover_or_modal_is_displayed():
            self._alarm_table_toolbar.click_prefilter_icon()
        self._alarm_table_toolbar.apply_show_all_events_prefilter()
        self._alarm_table_toolbar.click_popover_or_modal_close_icon()

    def cards_are_displayed(self) -> bool:
        """
        Determine if the Alarm Table is displaying cards as part of a responsive layout.

        :returns: True, if the Alarm Table is currently displaying cards for each alarm entry instead of rows False
            otherwise.
        """
        return self._alarm_table_body.cards_are_displayed()

    def click(self, **kwargs) -> None:
        """:raises NotimplementedError: Because there's no reason you should blindly click the Alarm Table."""
        raise NotImplementedError('Please do not blindly click the Alarm Table.')

    def click_column_configuration_icon(self) -> None:
        """
        Click the icon which opens the Column Configuration modal.

        :raises TimeoutException: If the Column Configuration (gear) icon is not present.
        """
        self._alarm_table_toolbar.click_column_configuration_icon()

    def click_details_for_row(
            self,
            identifying_text: str,
            column_with_identifying_text: AlarmTableColumn) -> None:
        """
        Click the Details hover icon for a row based on unique text in a column.

        :param identifying_text: The text used to identify the row we will click the Details hover icon for.
        :param column_with_identifying_text: The Alarm Table Column which contains the identifying text.

        :raises TimeoutException: If no row contains the identifying text in the specified column, or if the Details
            hover icon never becomes visible.
        """
        self._alarm_table_body.click_details_for_row(
            column_text=identifying_text, column_with_identifying_text=column_with_identifying_text)
        
    def click_first_page_button(self) -> None:
        """
        Within the Pager, click the option which has text of 'First'. This does not mean the '1' option.

        :raises TimeoutException: If the 'First' option is not present.
        :raises AssertionError: If unsuccessful in getting the Alarm Table to page 1.
        """
        self._pager.click_first_page_button()
        IAAssert.is_equal_to(
            actual_value=self.get_active_page(),
            expected_value=1,
            as_type=int,
            failure_msg="Failed to get the Alarm Table to page 1 by clicking the 'First' option in the Pager.")

    def click_prefilter_icon(self) -> None:
        """
        Click the icon which opens the Conditional Prefilter popover/modal.

        :raises TimeoutException: If the Prefilter icon is not present.
        """
        self._alarm_table_toolbar.click_prefilter_icon()
        
    def click_last_page_button(self) -> None:
        """
        Within the Pager of the Alarm Table, click the 'Last' option.

        :raises TimeoutException: If the 'Last' option is not available.
        """
        self._pager.click_last_page_button()
        IAAssert.is_not_true(
            value=self.last_page_button_is_enabled(),
            failure_msg="Failed to get the Alarm Table to the last page by clicking the 'Last' option within the "
                        "Pager.")

    def click_gear_icon(self) -> None:
        """
        Click the icon which opens the Column Configuration modal. This function is just an 'alias', and redirects to
        :func:`click_column_configuration_icon`

        :raises TimeoutException: If the Column Configuration (gear) icon is not present.
        """
        self.click_column_configuration_icon()
        
    def click_next_page_chevron(self, binding_wait_time: float = 0) -> None:
        """
        Within the Pager, click the '>' option in order to go to the next page.

        :param binding_wait_time: How long to wait (in seconds) after clicking the '>' option before allowing code to
            continue.

        :raises TimeoutException: If the '>' option is not present.
        :raises AssertionError: If unsuccessful in advancing the Alarm Table to the next page.
        """
        original_page_number = self.get_active_page()
        self._pager.find().click()
        self._pager.click_next_page_chevron(binding_wait_time=binding_wait_time)
        IAAssert.is_equal_to(
            actual_value=self.get_active_page(),
            expected_value=original_page_number + 1,
            failure_msg="Failed to increment the page of the Alarm Table by clicking the '>' option within the Pager; "
                        "the '>' option may have been disabled.")
        
    def click_page_number(self, page_number: int) -> None:
        """
        Within the Pager of the Alarm Table, click a page number.

        :raises TimeoutException: If the supplied page number is not present.
        :raises AssertionError: If unsuccessful in setting the Alarm Table to display the supplied page.
        """
        self._pager.click_page_number(desired_page=page_number)
        IAAssert.is_equal_to(
            actual_value=self.get_active_page(),
            expected_value=page_number,
            as_type=int,
            failure_msg=f"Failed to set the Alarm Table to display page {page_number} by clicking that page number "
                        f"within the Pager.")
        
    def click_previous_page_chevron(self, binding_wait_time: float = 0) -> None:
        """
        Within the Pager, click the '<' option in order to go to the previous page.

        :param binding_wait_time: How long to wait (in seconds) after clicking the '<' option before allowing code to
            continue.

        :raises TimeoutException: If the '<' option is not present.
        :raises AssertionError: If unsuccessful in advancing the Alarm Table to the previous page.
        """
        original_page_number = self.get_active_page()
        self._pager.click_previous_page_chevron(binding_wait_time=binding_wait_time)
        IAAssert.is_equal_to(
            actual_value=self.get_active_page(),
            expected_value=original_page_number - 1,
            failure_msg="Failed to decrement the page of the Alarm Table by clicking the '<' option within the Pager; "
                        "the '<' option may have been disabled.")

    def click_remove_all_prefilters_button(self) -> None:
        """
        Click the 'Remove All' button in order to remove all Conditional Prefilters currently applied to the Alarm
        Table.

        :raises TimeoutException: If the 'Remove All' button is not present.
        :raises AssertionError: If unsuccessful in removing all applied Prefilters.
        """
        self._alarm_table_toolbar.click_remove_all_prefilters_button()
        IAAssert.is_equal_to(
            actual_value=self.get_count_of_applied_prefilters(),
            expected_value=0,
            failure_msg="Failed to remove all applied Prefilters by clicking the 'Remove All' button.")

    def click_show_all_for_events(self) -> None:
        """
        Click the 'Show All' option for the EVENT category.

        :raises TimeoutException: If the Prefilter popover/modal is not currently displayed.
        :raises IndexError: If the EVENT category is not present.
        """
        self._alarm_table_toolbar.click_show_all_for_events()

    def click_show_all_for_priorities(self) -> None:
        """
        Click the 'Show All' option for the PRIORITY category.

        :raises TimeoutException: If the Prefilter popover/modal is not currently displayed.
        :raises IndexError: If the PRIORITY category is not present.
        """
        self._alarm_table_toolbar.click_show_all_for_priorities()

    def close_any_open_popover_or_modal(self) -> None:
        """
        Close any open popover or modal.

        :raises TimeoutException: If no popover or modal is open.
        :raises AssertionError: If unsuccessful in closing all open popovers or modals.
        """
        self._alarm_table_toolbar.click_popover_or_modal_close_icon()
        IAAssert.is_not_true(
            value=self.column_configuration_popover_or_modal_is_open(),
            failure_msg="The Column Configuration popover/modal is still open after our attempt to close all open "
                        "popovers and/or modals.")
        IAAssert.is_not_true(
            value=self.prefilter_popover_or_modal_is_open(),
            failure_msg="The Prefilter popover/modal is still open after our attempt to close all open popovers "
                        "and/or modals.")
        IAAssert.is_not_true(
            value=self.details_modal_is_open(),
            failure_msg="The Details modal is still open after our attempt to close all open popovers and/or modals.")

    def collapse_text_filter(self) -> None:
        """
        Collapse the text filter of the Alarm Table. No action is taken if the filter is already collapsed.

        :raises AssertionError: If unsuccessful in collapsing the text filter.
        """
        self._alarm_table_filter.collapse_text_filter()

    def column_configuration_is_enabled(self) -> bool:
        """
        Determine if the user is able to access the Column Configuration (gear) icon.

        :returns: True, if the Column Configuration (gear) icon is present - False otherwise.
        """
        return self._alarm_table_toolbar.column_configuration_icon_is_present()

    def column_configuration_popover_or_modal_is_open(self) -> bool:
        """
        Determine if the Column Configuration popover/modal is open.

        :returns: True, if the Column Configuration popover/modal is open - False otherwise.
        """
        return self._alarm_table_toolbar.column_configuration_popover_or_modal_is_open()

    def column_is_present_in_column_configuration_popover(self, column: AlarmTableColumn) -> bool:
        """
        Determine if a column is present as a menu item in the Column Configuration popover/modal. Requires that the
        Column Configuration popover/modal already be open.

        :param column: The column to verify the presence of within the Column Configuration popover/modal.

        :returns: True, if the supplied column's name is present as an option in the Column Configuration
            popover/modal - False otherwise.

        :raises TimeoutException: If the Column Configuration popover/modal is not already open.
        """
        return self._alarm_table_toolbar.column_is_present_in_column_configuration_popover(column=column)

    def details_are_enabled(self) -> bool:
        """
        Determine if the Alarm Table allows for viewing details of an alarm entry.

        :returns: True, if after hovering over an alarm entry within the Alarm Table the Details hover icon appears
            - False otherwise.
        """
        return self._alarm_table_body.details_are_enabled()

    def details_modal_is_open(self) -> bool:
        """
        Determine if the modal which contains details about an Alarm entry is open.

        :returns: True, if a modal is open which contains information about the details of an Alarm - False otherwise.
        """
        return self._alarm_table_body.details_modal_is_open()

    def dismiss_prefilter_pill(self, prefilter: Union[AlarmState, AlarmPriority]) -> None:
        """
        Remove an applied Prefilter from being used as a condition for an Alarm to be displayed.

        :param prefilter: The applied State or Priority you would like to remove from use as a conditional filter.

        :raises TimeoutException: If the supplied prefilter is not already applied.
        :raises AssertionError: If unsuccessful in removing the supplied prefilter.
        """
        self._alarm_table_toolbar.dismiss_prefilter_pill(
            prefilter=prefilter)
        IAAssert.does_not_contain(
            iterable=self.get_text_of_applied_prefilters(),
            expected_value=prefilter.name,
            failure_msg=f"Failed to remove the {prefilter.name} prefilter from the applied filters.")

    def filtered_results_are_displayed(self) -> bool:
        """
        Determine if the applied filters are resulting in a displayed count of matching results.

        :returns: True, if the applied filters result in the display of a count of matching results - False otherwise.
        """
        return self._alarm_table_toolbar.filtered_results_are_displayed()
    
    def first_page_button_is_enabled(self) -> bool:
        """
        Determine if the Pager has a 'First' option which is enabled. This does not include the '1' option.

        :returns: True, if there is an enabled 'First' option within the Pager of the Alarm Table.
        """
        return self._pager.first_page_button_is_enabled()

    def get_notes_from_details_modal(self) -> str:
        """
        In an open Details modal, click the Notes tab and obtain the Notes from panel. Requires that the Details modal
        already be open.

        :returns: The notes supplied for the Alarm within the Notes tab.

        :raises TimeoutException: If the Details modal is not already open.
        """
        return self._alarm_table_body.get_notes_from_details_modal()

    def get_all_prefilter_options(self) -> List[str]:
        """
        Open the Prefilter popover/modal, obtain all available prefilters which could be selected by a user, and close
        the open modal.

        :returns: A list, where each entry is string representing an available Prefilter a user could select. These
            include the 'Show All' option, which will appear once for States and once for Priority.

        :raises TimeoutException: If the Prefilter icon is not present.
        :raises AssertionError: If unsuccessful in closing out the Prefilter modal.
        """
        try:
            if not self.prefilter_popover_or_modal_is_open():
                self.click_prefilter_icon()
            return self._alarm_table_toolbar.get_all_prefilter_options()
        finally:
            self.close_any_open_popover_or_modal()
            IAAssert.is_not_true(
                value=self.prefilter_popover_or_modal_is_open(),
                failure_msg="Failed to close the Prefilter popover/modal after retrieving available Prefilters.")

    def get_card_field_names(self) -> List[str]:
        """
        Obtain the field names listed within the displayed cards of the Alarm Table. Requires the Alarm table already
        be in a responsive layout, where it is rendering row content as cards.

        :returns: A list, where each item is the name of a field in the displayed cards, where the fields could be
            interpreted as being equivalent to a column from a row.

        :raises TimeoutException: If the Alarm Table is not in a responsive layout.
        """
        return self._alarm_table_body.get_card_column_names()

    def get_color_of_first_row_which_matches_criteria(self, text_criteria: str, column: AlarmTableColumn) -> str:
        """
        Obtain the color in use for the background of the first row which meets the criteria set forth within arguments.
        Note that this color may come back in different formats (RGB vs hex) depending on the type of browser being
        used.

        :param text_criteria: The 'unique' text found in the specified column.
        :param column: The Alarm table Column which contains the supplied unique text.

        :returns: The color in use for the first row which has the supplied text in the specified column.

        :raises TimeoutException: If no row contains the supplied text in the specified column.
        """
        return self._alarm_table_body.get_color_of_first_row_which_matches_criteria(
            text_criteria=text_criteria, column=column)

    def get_column_index(self, alarm_table_column: AlarmTableColumn) -> int:
        """
        Obtain the index of the specified column. Note that the Alarm Status Table has a selection column which is
        always the 0th column.

        :param alarm_table_column: The Alarm Table Column of which you would like the index.

        :returns: The index of the specified row.

        :raises TimeoutException: If the specified row is not present in the Alarm Table.
        """
        return self._alarm_table_header.get_column_index(column_id=alarm_table_column.data_column_id)

    def get_column_width(self, alarm_table_column: AlarmTableColumn, include_units: bool = False) -> str:
        """
        Obtain the width of the specified column.

        :param alarm_table_column: The Alarm Table Column you would like the width of.
        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The width of the specified column, with the option of units of measurement.

        :raises TimeoutException: If the requested column is not present in the Alarm Table.
        """
        # Tables expect each column id to be the name, whereas Alarm Tables construct a camel-cased id.
        return super().get_column_width(column_id=alarm_table_column.data_column_id, include_units=include_units)

    def get_column_value_from_first_row_with_text_in_column(
            self,
            desired_column: AlarmTableColumn,
            sibling_column_text: str,
            sibling_column: AlarmTableColumn) -> str:
        """
        Obtain the value of a column within a row based on the value present in a sibling column.

        :param desired_column: The column you want a value from.
        :param sibling_column_text: The (string) value present in a sibling column of the same row.
        :param sibling_column: The sibling column which contains the supplied known text.

        :returns: The string value of the cell identified by the specified column within the row which has the supplied
            text in the specified sibling column.

        :raises TimeoutException: If no sibling cell has the supplied text in the specified column, or if either of the
            columns is not present in the Alarm Table.
        """
        self._alarm_table_body.wait_until_column_is_present(column=desired_column)
        return self._alarm_table_body.get_cell_data_by_other_cell_text(
            desired_column=desired_column, sibling_cell_text=sibling_column_text, sibling_column=sibling_column)

    def get_column_value_by_row_index(self, row_index: int, column: AlarmTableColumn) -> str:
        """
        Obtain the string value of the cell identified by the intersection of the provided row index and Alarm Table
        Column.

        :param row_index: The zero-based row index which has the desired cell.
        :param column: The Alarm Table Column under which the desired cell resides.

        :returns: The string value of the cell identified by the intersection of the supplied row index and column.

        :raises TimeoutException: If the row index is not displayed, or the supplied column is not present in the Alarm
            Table.
        """
        return self._alarm_table_body.get_column_value_by_row_index(row_index=row_index, column=column)

    def get_count_of_applied_prefilters(self) -> int:
        """
        Obtain a count of prefilters applied to the Alarm Table.

        :returns: The number of Prefilters currently applied to the Alarm Table.
        """
        try:
            return len(self.get_text_of_applied_prefilters())
        except TimeoutException:
            return 0

    def get_count_of_displayed_rows(self) -> int:
        """
        Obtain a count of rows visually displayed within the Alarm Table. This is not a count of total rows - this is
        a count of rows the user can see on the current page, including after scrolling.

        :returns: The count of rows displayed on the current page of the Alarm Table.
        """
        # alarm tables have no concept of subview, so explicitly exclude subviews from the count
        return self._alarm_table_body.get_row_count(include_expanded_subviews_in_count=False)

    def get_count_of_results_matching_text_filter(self) -> int:
        """
        Obtain a count of how many results match all applied filtering.

        :returns: A count of results which match all applied filters.
        """
        return self._alarm_table_toolbar.get_count_of_results_matching_text_filter()
    
    def get_active_page(self) -> int:
        """
        Obtain the number of the page the user is currently viewing.

        :returns: The page number the user is currently viewing.

        :raises TimeoutException: If the Alarm Table does not have an enabled Pager.
        """
        return self._pager.get_active_page()

    def get_last_displayed_page_number(self) -> int:
        """
        Obtain the number of the last page number displayed to the user within the Pager. This may not be the last page
        a user could get to.

        :returns: The current last displayed page number within the Pager.

        :raises TimeoutException: If the Alarm Table does not have an enabled Pager.
        """
        return self._pager.get_last_displayed_page_number()

    def get_empty_message_text(self) -> str:
        """
        Obtain the message displayed to the user when the Alarm Table has no results which match the applied filters.

        :returns: The text displayed to the user when the Alarm Table has no results which match the applied filters.

        :raises TimeoutException: If the message is not present.
        """
        return self._alarm_table_body.get_empty_message_text()

    def get_field_from_details_modal(self, field_name: str) -> str:
        """
        Obtain the value of a field within the Details modal. Requires that the Details modal already be open.

        :param field_name: The case-sensitive field to retrieve from the Details modal.

        :returns: The text value of the requested field.

        :raises NoSuchElementException: If the value for the field is missing.
        :raises NullPointerException: If the supplied field does not exist.
        :raises TimeoutException: If the Details modal is not present.
        """
        return self._alarm_table_body.get_field_from_details_modal(field_name=field_name)

    def get_origin_of_remove_all_prefilters_button(self) -> Point:
        """
        Obtain the location of the upper-left corner of the 'Remove All' button.

        :returns: A two-dimensional point which represents the location of the upper-left corner of the 'Remove All'
            button.

        :raises TimeoutException: If the 'Remove All' button is not present.
        """
        return self._alarm_table_toolbar.get_origin_of_remove_all_prefilters_button()

    def get_text_of_alarm_events_label(self) -> str:
        """
        Obtain the full text of the filtered results label.

        :returns: The full text of the label which describes the count of filtered results.

        :raises TimeoutException: If no filtering is applied.
        """
        return self._alarm_table_toolbar.get_text_of_filter_results()

    def get_text_of_all_columns_in_popover(self) -> List[str]:
        """
        Obtain all columns available for display from within the column configuration popover/modal.

        :returns: A list, where each item is the text of a column which is available for display in the Alarm Table.

        :raises TimeoutException: If the column configuration popover/modal is not already open.
        """
        return self._alarm_table_toolbar.get_text_of_all_columns_in_popover()

    def get_text_of_applied_filters_label(self) -> str:
        """
        Obtain the text of the label preceding the applied conditional filters, which usually appears as 'FILTERS (X):'
        (after any translations have been applied).

        :returns: The text of the label which describes how many conditional filters are currently applied.

        :raises TimeoutException: If no conditional filters are currently applied.
        """
        return self._alarm_table_toolbar.get_text_of_applied_filter_count_label()

    def get_text_of_applied_prefilters(self) -> List[str]:
        """
        Obtain the text of all applied State and Priority conditional filters.

        :returns: A list which contains the text of all applied conditional (State/Priority) filters. An empty list
            implies no prefilters are applied.
        """
        return self._alarm_table_toolbar.get_text_of_applied_prefilters()

    def get_text_of_remove_all_prefilters_button(self) -> str:
        """
        Obtain the text of the 'Remove All' button (after any translations have been applied).

        :returns: The text in use by the 'Remove All' button.

        :raises TimeoutException: If the 'Remove All' button is not currently in place.
        """
        return self._alarm_table_toolbar.get_text_of_remove_all_prefilters_button()

    def get_text_of_filter_results(self) -> str:
        """
        Obtain the full text of the filtered results label.

        :returns: The full text of the label which describes the count of filtered results.

        :raises TimeoutException: If no filtering is applied.
        """
        return self._alarm_table_toolbar.get_text_of_filter_results()
    
    def jump_to_page(self, page_number: int, binding_wait_time: float = 1) -> None:
        """
        Use the page jump input field to go to a specific page in the Table.

        :param page_number: The page of the Table to go to.
        :param binding_wait_time: The amount of time (in seconds) to wait after supplying the page number before
            allowing code to continue.

        :raises TimeoutException: If the page jump input is not present.
        :raises AssertionError: If the Table does not end up on the supplied page.
        """
        self._pager.jump_to_page(page_to_jump_to=page_number, binding_wait_time=binding_wait_time)

    def results_label_is_displayed(self) -> bool:
        """
        Determine if filtering of any type is in place on the Alarm Table.

        :returns: True, if any conditional or text filtering is currently applied to the Alarm Table.
        """
        return self._alarm_table_toolbar.filtered_results_are_displayed()

    def get_width_of_remove_all_prefilters_button(self, include_units: bool = False) -> str:
        """
        Obtain the width of the 'Remove All' button, with or without units.

        :param include_units: If True, the returned value will contain units of measurement, otherwise the returned
            value will be only the numeric value.

        :returns: The width of the 'Remove All' button, and potentially units.

        :raises TimeoutException: If the 'Remove All' button is not currently in place.
        """
        return self._alarm_table_toolbar.get_width_of_remove_all_prefilters_button(include_units=include_units)

    def is_in_responsive_layout(self) -> bool:
        """
        Determine if the Alarm Table is in a responsive (card) layout.

        :returns: True, if the Alarm Table is currently rendering in a responsive layout - False otherwise.
        """
        return self._alarm_table_body.is_in_responsive_layout()
    
    def last_page_button_is_enabled(self) -> bool:
        """
        Determine if the 'Last' page option is enabled in the Pager.

        :returns: True, if the 'Last' page option in the Pager is enabled.

        :raises TimeoutException: If the 'Last' page option is not present in the Pager.
        """
        return self._pager.last_page_button_is_enabled()
    
    def next_page_chevron_is_enabled(self) -> bool:
        """
        Determine if the next page chevron is enabled.

        :returns: True, if the next page chevron is enabled - False otherwise.
        """
        return self._pager.next_page_chevron_is_enabled()

    def open_prefilter_popover(self) -> None:
        """
        Open the popover/modal which contains the States and Priorities available to apply as conditional filters.

        :raises TimeoutException: If the icon required to expand the popover/modal is not present.
        """
        if not self._alarm_table_toolbar.prefilter_popover_or_modal_is_displayed():
            self._alarm_table_toolbar.click_prefilter_icon()
            
    def pager_is_hidden(self) -> bool:
        """
        Determine if the Pager exists but is currently hidden.

        :returns: True, if the Pager exists in the DOM but is currently out of sight of the user.

        :raises TimeoutException: If the Pager is not present at all.
        """
        return self._pager.is_hidden()
            
    def pager_is_present(self) -> bool:
        """
        Determine if the Pager exists within the Table. Note that this does not reflect the VISIBILITY of the
        Pager.

        :returns: True, if the Pager exists, False otherwise.
        """
        return self._pager.is_present()

    def prefilter_is_applied(self, prefilter: Union[AlarmState, AlarmPriority]) -> bool:
        """
        Determine if a Prefilter is currently applied as a conditional prefilter.

        :param prefilter: The prefilter to verify as a current conditional prefilter.

        :returns: True, if the supplied Prefilter is currently applied as a conditional filter - False otherwise.
        """
        return self._alarm_table_toolbar.prefilter_is_applied(
            prefilter=prefilter)

    def prefilter_popover_or_modal_is_open(self) -> bool:
        """
        Determine if the State and Priority conditional filter popover/modal is displayed.

        :returns: True, if the State/Priority popover/modal is currently displayed - False otherwise.
        """
        return self._alarm_table_toolbar.prefilter_popover_or_modal_is_displayed()

    def prefiltering_is_enabled(self) -> bool:
        """
        Determine if the Alarm Table allows for applying States and/or Priorities as conditional prefilters to determine
        the display state of Alarms.

        :returns: True, if the user has access to the icon which would allow for opening the State and Priority
            conditional prefilter popover/modal.
        """
        return self._alarm_table_toolbar.prefiltering_icon_is_displayed()

    def previous_page_chevron_is_enabled(self) -> bool:
        """
        Determine if the previous page chevron is enabled.

        :returns: True, if the previous page chevron is enabled - False otherwise.

        :raises TimeoutException: If the previous page chevron is not present.
        """
        return self._pager.previous_page_chevron_is_enabled()

    def set_displayed_row_count(self, desired_row_count: int) -> None:
        """
        Set the number of displayed rows in the Table. The count of rows must be a valid value from the
        dropdown (Select) in the Pager.

        :param desired_row_count: The desired count of rows (this must be one of the available values in the dropdown).

        :raises TimeoutException: If the row count dropdown is not present.
        :raises AssertionError: If unable to set the row count dropdown to the supplied row count.
        """
        self._pager.set_displayed_row_count(count_of_rows=desired_row_count)

    def set_display_state_of_alarm_table_columns(
            self,
            alarm_table_columns: List[AlarmTableColumn],
            should_be_displayed: bool) -> None:
        """
        Open the column configuration popover/modal and set the display state of multiple Alarm Table Columns.

        :param alarm_table_columns: A list of Alarm Table Columns for which we will set the display state of each.
        :param should_be_displayed: If True, the supplied columns will be set as an active selection. If False the
            supplied columns will be set as inactive selections, removing them from display.
        """
        self._alarm_table_toolbar.set_display_state_of_known_alarm_columns(
            list_of_known_alarm_columns=alarm_table_columns, should_be_displayed=should_be_displayed)

    def text_filtering_is_enabled(self) -> bool:
        """
        Determine if text filtering is enabled for the Alarm Table.

        :returns: True, if the icon (magnifying glass) which would expand the text filter is present in the DOM - False
            otherwise.
        """
        return self._alarm_table_filter.text_filtering_is_enabled()

    def toolbar_is_displayed(self) -> bool:
        """
        Determine if the Alarm Table is currently displayed.

        :returns: True, if the Alarm Table is currently displayed - False otherwise.
        """
        return self._alarm_table_toolbar.is_displayed()


class _AlarmTableBody(Body):
    """The Body of an Alarm Table."""
    _ROW_INDEX_ATTRIBUTE = 'data-row-index'
    _COL_INDEX_ATTRIBUTE = 'data-column-index'
    _COLUMN_HEADERS_CELLS_LOCATOR = (By.CSS_SELECTOR, 'div.th div.tr.header .tc')
    _COLUMN_HEADER_CHECKBOX_LOCATOR = (By.CSS_SELECTOR, 'label.ia_checkbox svg')
    _ALARM_TABLE_ROWS_LOCATOR = (By.CSS_SELECTOR, '.tr.alarmTableBodyRow')
    _ROW_SELECTION_OVERLAY_LOCATOR = (By.CSS_SELECTOR, 'div.rowSelectedOverlay')
    _CARD_LOCATOR = (By.CSS_SELECTOR, 'div.tc div.tr')
    _DETAILS_LOCATOR = (By.CSS_SELECTOR, "div.alarmRowActionOverlay")
    _DETAILS_MODAL_LOCATOR = (By.CSS_SELECTOR, 'div.alarmDetailsModal')
    _DETAILS_MODAL_NOTES_TAB_LOCATOR = (By.CSS_SELECTOR, "div.notesTab")
    _DETAILS_MODAL_PROPERTIES_TAB_LOCATOR = (By.CSS_SELECTOR, "div.detailsTab")
    _DETAILS_MODAL_ALARM_NOTES_LOCATOR = (By.CSS_SELECTOR, "div.alarmNotesContainer textarea")
    _EMPTY_MESSAGE_LOCATOR = (
        By.CSS_SELECTOR, "div.alarmTableEmptyMessage")  # Different locator than the "common" table

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: List[Tuple[By, str]],
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        # OVERWRITE inherited piece
        self._rows = ComponentPiece(
            locator=self._ALARM_TABLE_ROWS_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._cards = ComponentPiece(
            locator=self._CARD_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._cells_of_column_dict = {}
        self._card_labels_dict = {}
        self._details_hover_piece = ComponentPiece(
            locator=self._DETAILS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._details_modal = ComponentPiece(
            locator=self._DETAILS_MODAL_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            poll_freq=poll_freq)
        self._details_modal_rows = ComponentPiece(
            locator=(By.CSS_SELECTOR, "div.alarmPropertiesCategoryTableRow"),
            driver=self.driver,
            parent_locator_list=self._details_modal.locator_list,
            poll_freq=poll_freq)
        self._properties_tab_in_details_modal = ComponentPiece(
            locator=self._DETAILS_MODAL_PROPERTIES_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self._details_modal.locator_list,
            poll_freq=poll_freq)
        self._notes_tab_in_details_modal = ComponentPiece(
            locator=self._DETAILS_MODAL_NOTES_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self._details_modal.locator_list,
            poll_freq=poll_freq)
        self._notes_field = ComponentPiece(
            locator=self._DETAILS_MODAL_ALARM_NOTES_LOCATOR,
            driver=driver,
            parent_locator_list=self._details_modal.locator_list,
            poll_freq=poll_freq)
        self._selected_rows = ComponentPiece(
            locator=self._ROW_SELECTION_OVERLAY_LOCATOR,
            driver=driver,
            parent_locator_list=self._rows.locator_list,
            poll_freq=poll_freq)
        self._empty_message = ComponentPiece(
            locator=self._EMPTY_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def cards_are_displayed(self) -> bool:
        """
        Determine if cards are displayed within the Alarm Table, as opposed to rows.

        :returns: True, if the Alarm Table is rendering results in a 'card' layout - False otherwise.
        """
        try:
            return self.get_row_count() > 0 and len(self._cards.find_all()) > 0
        except TimeoutException:
            return False

    def click_details_for_row(
            self,
            column_text: str,
            column_with_identifying_text: AlarmTableColumn) -> None:
        """
        Click the Details hover icon for the first row with a supplied value in a specified column.

        :param column_text: The text present in the specified column. This will be used to identify the row we will be
            hovering over before clicking the Details hover icon.
        :param column_with_identifying_text: The Alarm Table Column which contains the supplied text.

        :raises TimeoutException: If no row contains the supplied text in the specified column.
        """
        try:
            self.wait.until(IAec.function_returns_true(
                custom_function=self._click_details_for_row_with_text_in_column,
                function_args={"text_in_column": column_text, "column": column_with_identifying_text}))
        except TimeoutException as toe:
            raise TimeoutException(
                msg=f"Unable to click details element for row with text {column_text} in the "
                    f"{column_with_identifying_text.name} column.") from toe

    def details_are_enabled(self) -> bool:
        """
        Determine if the ability to open a Details popover/modal is enabled for the Alarm Table.

        :returns: True, if a user sees a Details hover icon when hovering over thw rows of the Alarm Table - False
            otherwise.
        """
        IASelenium(driver=self.driver).hover_over_web_element(web_element=self._rows.find_all()[0])
        try:
            return self._details_hover_piece.find() is not None
        except TimeoutException:
            return False

    def details_modal_is_open(self) -> bool:
        """
        Determine if the popover/modal which displays details for an Alarm is currently open.

        :returns: True, if the Details popover/modal is currently open - False otherwise.
        """
        try:
            return self._details_modal.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    def get_card_column_names(self) -> List[str]:
        """
        Obtain a list of all displayed 'column' names displayed in the cards of the Alarm Table. Requires the Table
        already be displaying in a responsive (card) layout.

        :returns: A list where each element is the name of a column displayed as a field of the card.

        :raises TimeoutException: If the Alarm Table is not already displaying in a responsive (card) layout.
        """
        return [_.text for _ in self._get_card_labels_by_row_index(zero_based_row_index=0).find_all()]

    def get_cell_data_by_other_cell_text(
            self,
            desired_column: AlarmTableColumn,
            sibling_cell_text: str,
            sibling_column: AlarmTableColumn) -> str:
        """
        Obtain the value of a cell based on information from a sibling cell in the same row.

        :param desired_column: The column from which you would like the value.
        :param sibling_cell_text: The text present in the supplied sibling column.
        :param sibling_column: The sibling column which contains the known value.

        :returns: The value of the desired column.

        :raises KeyError: If the desired column is not currently displayed.
        :raises TimeoutException: If the specified sibling column does not contain the supplied text in any row, or if
            the desired column is not displayed.
        """
        try:
            data_row_id = self._get_complex_cell(
                cell_text=sibling_cell_text,
                column=sibling_column).find().find_element(*(By.XPATH, "../../..")).get_attribute("data-row-id")
            return self._get_row_data_as_dict(
                alarm_event_id=data_row_id)[desired_column.data_column_id]
        except StaleElementReferenceException:
            return self.get_cell_data_by_other_cell_text(
                sibling_cell_text=sibling_cell_text, desired_column=desired_column, sibling_column=sibling_column)

    def get_color_of_first_row_which_matches_criteria(self, text_criteria: str, column: AlarmTableColumn) -> str:
        """
        Obtain the color of the first row which contains some supplied text in a specific column. Note that different
        browsers may return this value in different formats (RGB vs hex).

        :param text_criteria: The text which must be present in the supplied Alarm Table Column.
        :param column: The Alarm Table Column which must have the supplied text.

        :returns: The color (as a string) of the first row which contains the supplied text in the specified column.

        :raises: TimeoutException: If no row contains the supplied text in the specified column.
        """
        return self._get_complex_cell(
            cell_text=text_criteria, column=column).find().value_of_css_property('background-color')

    def get_column_value_by_row_index(self, row_index: int, column: AlarmTableColumn) -> str:
        """
        Obtain the value of a specified column within a specified row.

        :param row_index: The zero-based index of the row.
        :param column: The Alarm Table Column from which you would like the value.

        :returns: The value of the cell at the intersection of the supplied row index and column.

        :raises TimeoutException: If the row index is not currently displayed, or if the supplied column is not
            displayed.
        """
        return self._get_complex_cell(column=column, row_index=row_index).find().text

    def get_empty_message_text(self) -> str:
        """
        Obtain the message displayed to the user when the Alarm Table has no results which match the applied filters.

        :returns: The text displayed to the user when the Alarm Table has no results which match the applied filters.

        :raises TimeoutException: If the message is not present.
        """
        # This uses a different locator than the "common" table
        return self._empty_message.get_text().strip()

    def get_field_from_details_modal(self, field_name: str) -> str:
        """
        Obtain the value of a field within the Details modal. Requires that the Details modal already be open.

        :param field_name: The case-sensitive field to retrieve from the Details modal.

        :returns: The text value of the requested field.

        :raises NoSuchElementException: If the value for the field is missing.
        :raises NullPointerException: If the supplied field does not exist.
        """
        try:
            return self._get_details_modal_row(
                label_text=field_name).find_element(*(By.CSS_SELECTOR, "div.alarmPropertiesCategoryTableProp")).text
        except StaleElementReferenceException:
            return self.get_field_from_details_modal(field_name=field_name)

    def get_notes_from_details_modal(self) -> str:
        """
        Click the Notes tab and obtain the Ack Notes from the Details popover/modal. Requires the Details popover/modal
        already be open.

        :returns: The text visible in the Ack Notes field for the Alarm which is currently displaying a Details
            popover/modal.

        :raises TimeoutException: If the Details popover/modal is not already open.
        """
        self._notes_tab_in_details_modal.click(wait_timeout=1)
        return self._notes_field.find(wait_timeout=1).text

    def get_selection_state_for_rows(self, known_value: str, column_for_value: AlarmTableColumn) -> List[bool]:
        """
        Obtain the selection state of all rows which have a known value within a specified column.

        :param known_value: A value known to be present in the specified column.
        :param column_for_value: The column which contains the supplied value.

        :returns: A list, where each element in the list is a boolean value which represents the selection state of a
            row which had the known value within the specified column.

        :raises TimeoutException: If no rows contain the supplied value in the specified column.
        :raises AssertionError: If no rows contain the supplied value in the specified column.
        """
        indices = []
        for cell_web_element in self._get_complex_cell(column=column_for_value, cell_text=known_value).find_all():
            indices.append(
                int(cell_web_element.find_element(*(By.XPATH, "../..")).get_attribute(self._ROW_INDEX_ATTRIBUTE)))
        IAAssert.is_greater_than(
            left_value=len(indices),
            right_value=0,
            failure_msg=f"No rows contained {known_value} in the {column_for_value.name} column.")
        return self.wait.until(IAec.function_returns_true(
            custom_function=self._get_selection_state_for_rows,
            function_args={
                "indices": indices
            }
        ))

    def is_in_responsive_layout(self) -> bool:
        """
        Determine if the Alarm Table is in a responsive (card) layout.

        :returns: True, if the Alarm Table is currently rendering in a responsive layout - False otherwise.
        """
        return 'tableIsResponsive' in self.find().get_attribute("class")

    def wait_until_column_is_present(self, column: AlarmTableColumn) -> None:
        """
        Wait until the specified column is displayed within the Alarm Table.

        :param column: The Alarm Table Column to wait upon the appearance of.

        :raises TimeoutException: If the column never becomes visible.
        """
        try:
            self.wait.until(IAec.function_returns_true(
                custom_function=self._column_is_present, function_args={"column": column}))
        except TimeoutException as toe:
            raise TimeoutException(msg=f"Missing column in Alarm Table: {column.data_column_id}.") from toe

    def _click_details_for_row_with_text_in_column(self, text_in_column: str, column: AlarmTableColumn) -> bool:
        """
        Click the Details hover icon for a row with the supplied text in the specified column, and then report back
        on the success of the opening of the modal.

        :param text_in_column: The text present in the supplied column.
        :param column: The Alarm Table Column which contains the supplied text.

        :returns: True, if the click resulted in the opening of the Details modal - False if unable to locate the
            desired row based on the supplied information, or if the Details modal did not open.
        """
        try:
            self._get_complex_cell(cell_text=text_in_column, column=column).hover()
            self._details_hover_piece.click(wait_timeout=5, binding_wait_time=1)
            self.wait.until(IAec.function_returns_true(
                custom_function=self.details_modal_is_open,
                function_args={}))
            return True
        except (StaleElementReferenceException, TimeoutException):
            return False

    def _column_is_present(self, column: AlarmTableColumn) -> bool:
        """
        Determine if a column is present in the Alarm Table.

        :param column: The Alarm Table Column which we will verify is currently present.

        :returns: True, if any cells can be found for the supplied Alarm Table Column - False otherwise.
        """
        try:
            return len(self._get_cells_of_column(column=column).find_all()) > 0
        except TimeoutException:
            return False

    def _get_card_labels_by_row_index(self, zero_based_row_index: Union[int, str]) -> ComponentPiece:
        """
        Obtain a ComponentPiece which defines the columns available within the card which represents an Alarm row while
        in a responsive layout.

        :param zero_based_row_index: The zero-based index of the row the card belongs to.
        """
        _label_pieces = self._card_labels_dict.get(zero_based_row_index)
        if not _label_pieces:
            locator = (By.CSS_SELECTOR, 'div.tc[data-column-id="label"]')
            _label_pieces = ComponentPiece(
                locator=locator,
                driver=self.driver,
                parent_locator_list=self._get_row_component_by_row_index(
                    zero_based_row_index=zero_based_row_index).locator_list,
                poll_freq=self.poll_freq)
            self._card_labels_dict[zero_based_row_index] = _label_pieces
        return _label_pieces

    def _get_cells_of_column(self, column: AlarmTableColumn) -> ComponentPiece:
        """
        Obtain a ComponentPiece which defines AL cells which belong to an Alarm Table Column.

        :param column: The Alarm Table Column under which al of the cells should reside.
        """
        column_id = column.data_column_id
        _cells_of_column = self._cells_of_column_dict.get(column_id)
        if not _cells_of_column:
            locator = (By.CSS_SELECTOR, f'div.tc.ia_table__cell[data-column-id="{column_id}"]')
            _cells_of_column = ComponentPiece(
                locator=locator,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq)
            self._cells_of_column_dict[column_id] = _cells_of_column
        return _cells_of_column

    def _get_complex_cell(
            self, column: AlarmTableColumn, row_index: int = 0, cell_text: Optional[str] = None) -> ComponentPiece:
        """
        Obtain a specific cell within the Table, based on information known about the cell.

        :param column: The Alarm Table Column this cell belongs to.
        :param row_index: The zero-based index of the row which contains the cell.
        :param cell_text: The known text of the cell.

        :returns: A ComponentPiece which defines the cell described by the supplied parameters.
        """
        column_id = column.data_column_id
        identifying_tuple = (column_id, row_index, cell_text)
        _cells_of_column = self._cells_of_column_dict.get(identifying_tuple)
        if not _cells_of_column:
            row_index_piece = f'[{self._ROW_INDEX_ATTRIBUTE}="{row_index}"]' if row_index else ""
            # leading space is important here
            cell_text_piece = f' div[title="{cell_text}"]' if cell_text else ""
            locator = (By.CSS_SELECTOR, f'div[data-column-id="{column_id}"]{row_index_piece}{cell_text_piece}')
            _cells_of_column = ComponentPiece(
                locator=locator,
                driver=self.driver,
                parent_locator_list=self._rows.locator_list,
                poll_freq=self.poll_freq)
            self._cells_of_column_dict[identifying_tuple] = _cells_of_column
        return _cells_of_column

    def _get_details_modal_row(self, label_text: str) -> Optional[WebElement]:
        """
        Obtain the WebElement which represents the key:value pairing of Alarm properties inside the Details modal.

        :param label_text: The case-sensitive field name which identifies the row to be returned.

        :returns: The WebElement which contains the field and value pieces based on the label text, or None if no field
            matched the supplied text.
        """
        for row in self._details_modal_rows.find_all():
            if label_text == row.find_element(*(By.CSS_SELECTOR, "div.alarmPropertiesCategoryTableLabel")).text:
                return row
        return None

    def _get_row_component_by_alarm_event_id(self, alarm_event_id: str) -> ComponentPiece:
        """
        Obtain a ComponentPiece which defines a row.

        :param alarm_event_id: The Event ID of the alarm you would like the row of.
        """
        return ComponentPiece(
            locator=(By.CSS_SELECTOR, f'{self._ALARM_TABLE_ROWS_LOCATOR[1]}[data-row-id="{alarm_event_id}"]'),
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq)

    def _get_row_component_by_row_index(self, zero_based_row_index: Union[int, str]) -> ComponentPiece:
        """
        Obtain a ComponentPiece which defines a row.

        :param zero_based_row_index: The zero-based index of the alarm you would like the row of.
        """
        return ComponentPiece(
            locator=(By.CSS_SELECTOR, f'{self._ALARM_TABLE_ROWS_LOCATOR[1]}[data-row-index="{zero_based_row_index}"]'),
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq)

    def _get_row_data_as_dict(self, alarm_event_id: str) -> dict:
        """
        Obtain the contents of the cells of the row where the keys of the returned dictionary are case-sensitive
            matches to the IDs of the columns.

        :param alarm_event_id: The Event ID which belongs to the row you would like returned.

        :returns: A dictionary where the keys are case-sensitive matches to the columns of the Alarm Table.

        :raises TimeoutException: If the supplied Alarm Event ID does not match any displayed row.
        """
        row_data = {}
        _row = self._get_row_component_by_alarm_event_id(
            alarm_event_id=alarm_event_id)
        try:
            cells = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'>div'),
                driver=self.driver,
                parent_locator_list=_row.locator_list,
                wait_timeout=0,
                poll_freq=self.poll_freq).find_all()
            for cell in cells:
                row_data[cell.get_attribute("data-column-id")] = cell.text
        except TimeoutException:
            # column not displayed
            pass
        return row_data

    def _get_selection_state_for_rows(self, indices: List[Union[int, str]]) -> Union[List[bool], bool]:
        """
        Return a list which contains the selection state of all rows with the supplied indices, or False if the Alarm
        Table updates while determining the state of the rows.

        :param indices: A list of zero-based indices for rows from which you want information about the selection state.
            These values may be integers or numeric strings.

        :returns: A list which describes the selection state for each row with one of the supplied indices, or False if
            the Alarm Table updates while determining the selection state of the entire list.

        :raises AssertionError: If the length of the returned list does not match the length of the list of requested
            indices.
        :raises ValueError: If any of the supplied indices is invalid.
        """
        try:
            indices = [int(_) for _ in indices]
            selection_state_list = []
            row_elements = self._rows.find_all()
            for row in row_elements:
                if int(row.get_attribute(self._ROW_INDEX_ATTRIBUTE)) in indices:
                    try:
                        row.find_element(*self._ROW_SELECTION_OVERLAY_LOCATOR)
                        selection_state_list.append(True)
                    except NoSuchElementException:
                        selection_state_list.append(False)
            # If it gets through all the row elements without an SERE then we return the list otherwise try again
            IAAssert.is_equal_to(
                actual_value=len(selection_state_list),
                expected_value=len(indices),
                failure_msg="Our requested list of indices contained an index which fell outside of the listed indices "
                            "of the page.")
            return selection_state_list
        except StaleElementReferenceException:
            return False


class _AlarmTableFilter(Filter):
    """
    The Alarm Table Filter, where text filtering and conditional filtering based on State and/or Priority occurs.
    """
    _FILTER_ICON_LOCATOR = (By.CSS_SELECTOR, "div.alarmFilterToggle")
    _CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.toggleableAlarmFilter')
    _COLLAPSE_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.closeFilterToggle svg')

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 1,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._filter_icon = ComponentPiece(
            locator=self._FILTER_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            poll_freq=poll_freq)
        self._container = ComponentPiece(
            locator=self._CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=parent_locator_list,
            poll_freq=poll_freq)
        self._collapse_icon = ComponentPiece(
            locator=self._COLLAPSE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._container.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)

    def collapse_text_filter(self) -> None:
        """
        Collapse the text filter. No action is taken if the text filter is already collapsed.

        :raises AssertionError: If unsuccessful in collapsing the text filter.
        """
        if self.filter_is_expanded():
            try:
                self._collapse_icon.click(binding_wait_time=1)
            except ElementNotInteractableException:
                pass
            except TimeoutException:
                pass
        IAAssert.is_not_true(
            value=self.filter_is_expanded(),
            failure_msg="The text filter was still expanded after an attempt to collapse it.")

    def expand_filter(self) -> None:
        """
        Expand the text filter located at the top of the Alarm Table if it is not already expanded. No action is taken
        if the filter is already expanded.

        :raises AssertionError: If unsuccessful in collapsing the text filter.
        """
        if not self.filter_is_expanded():
            try:
                self._filter_icon.click(wait_timeout=1)
                self.filter_is_expanded()  # glorified wait for the animation of the filter expansion
            except ElementNotInteractableException as enie:
                # This error is sometimes thrown even though the click registers.
                if not self.filter_is_expanded():
                    raise enie
                else:
                    pass
            except TimeoutException as toe:
                if not self.filter_is_expanded():
                    raise toe
                else:
                    pass
        IAAssert.is_true(
            value=self.filter_is_expanded(),
            failure_msg="The text filter was not expanded after attempting to expand it.")

    def filter_is_expanded(self) -> bool:
        """
        Determine if the text filter is expanded.

        :returns: True, if the text filter is expanded - False otherwise.
        """
        try:
            expanded = WebDriverWait(
                driver=self.driver,
                timeout=1,
                poll_frequency=self.poll_freq).until(
                IAec.function_returns_true(custom_function=self._filter_is_expanded, function_args={}))
            if expanded:
                # wait for any animation to complete
                self.wait_on_binding(time_to_wait=self.wait_timeout)
            return expanded
        except TimeoutException:
            return False

    def _filter_is_expanded(self) -> bool:
        """
        Determine if the text filter is expanded.

        :returns: True, if the text filter is expanded - False otherwise.
        """
        return "isExpanded" in self._container.find(wait_timeout=0.5).get_attribute("class")

    def set_filter_text(self, text: str, binding_wait_time: float = 2) -> None:
        """
        Expand the text filter before typing the supplied text into the filter input.

        :param text: The text to type into the filter.
        :param binding_wait_time: How long (in seconds) to wait after typing the supplied text before allowing code to
            continue.

        :raises AssertionError: If unsuccessful in applying the supplied text to the filter.
        :raises TimeoutException: If the icon to expand the filter is not present.
        """
        self.expand_filter()
        super().set_filter_text(text=text, binding_wait_time=binding_wait_time)

    def text_filtering_is_enabled(self) -> bool:
        """
        Determine if text filtering is enabled for the Alarm Table.

        :returns: True, if the icon (magnifying glass) which would expand the text filter is present in the DOM - False
            otherwise.
        """
        try:
            return self._filter_icon.find(wait_timeout=1) is not None
        except TimeoutException:
            return False


class _AlarmTableHeader(Header):
    """The Header of the Alarm Table."""
    pass


class _AlarmTableMenuItem(ComponentPiece):
    """
    Used as both Config modal items in both tables AND Shelve times of the AST
    """
    _MENU_ITEM_LOCATOR = (By.CSS_SELECTOR, 'div.alarmTableMenuItem')

    def __init__(
            self,
            driver: WebDriver,
            text: str,
            description: Optional[str] = None):
        super().__init__(
            locator=(By.CSS_SELECTOR, f'{self._MENU_ITEM_LOCATOR[1]}[data-label="{text}"]'),
            driver=driver,
            parent_locator_list=ComponentModal(driver=driver).locator_list,
            wait_timeout=1,
            description=description)


class _AlarmTableToolbar(ComponentPiece):
    """
    The toolbar of the Alarm Table, where the filtering mechanisms and date range selector reside.
    """
    _PREFILTER_TOGGLE_ICON_LOCATOR = (By.ID, 'preFilterToggle')
    _PREFILTER_MENU_ITEM_LOCATOR = (By.CSS_SELECTOR, 'div.preFilterMenuItem')
    _FILTER_RESULT_COUNT_LOCATOR = (By.CSS_SELECTOR, 'div.toolbarFilterResultsBar')
    _REMOVE_ALL_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.ia_alarmTableComponent__toolbar__preFilter__removeAll')
    _POPOVER_OR_MODAL_CLOSE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div[class*="CloseIcon"] svg')
    _TEXT_FILTER_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.alarmFilterToggle svg')
    _TEXT_FILTER_CLOSE_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.closeFilterToggle svg')
    _TEXT_FILTER_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.toggleableAlarmFilter')
    _GEAR_LOCATOR = (By.ID, 'configToggle')
    _CONFIG_COLUMN_ITEM_LOCATOR = (By.CSS_SELECTOR, 'div.alarmTableConfigColumnItem')
    _COLUMN_CONFIG_MENU_HEADER_LOCATOR = (By.CSS_SELECTOR, 'div.alarmTableConfigMenu')
    _COLUMN_ITEM_LOCATOR = (By.CSS_SELECTOR, 'div.alarmTableConfigColumnItem')
    _PREFILTER_MENU_LOCATOR = (By.CSS_SELECTOR, "div.alarmPreFilterMenu")
    _PREFILTER_PILLS_LOCATOR = (By.CSS_SELECTOR, "div.alarmPreFilterPill")
    _PRE_FILTER_REMOVE_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.alarmPreFilterPillClose svg')
    _FILTER_RESULTS_LOCATOR = (By.CSS_SELECTOR, 'div.toolbarFilterResultsBar')
    _COUNT_OF_APPLIED_FILTERS_LABEL_LOCATOR = (By.CSS_SELECTOR, 'div.alarmPreFilterCount')

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: List[Tuple[By, str]],
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=(By.CSS_SELECTOR, "div.alarmTableToolbar"),
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        # Non-component locator lists
        self._filter_label_result_count = ComponentPiece(
            locator=self._FILTER_RESULT_COUNT_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._applied_filter_count_label = ComponentPiece(
            locator=self._COUNT_OF_APPLIED_FILTERS_LABEL_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._remove_all_button = ComponentPiece(
            locator=self._REMOVE_ALL_BUTTON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._prefilter_toggle_icon = ComponentPiece(
            locator=self._PREFILTER_TOGGLE_ICON_LOCATOR,
            driver=self.driver,
            parent_locator_list=parent_locator_list,
            poll_freq=poll_freq)
        self._text_filter_container = ComponentPiece(
            locator=self._TEXT_FILTER_CONTAINER_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._text_filter_icon = ComponentPiece(
            locator=self._TEXT_FILTER_ICON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._text_filter_close_icon = ComponentPiece(
            locator=self._TEXT_FILTER_CLOSE_ICON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._column_configuration_icon = ComponentPiece(
            locator=self._GEAR_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._prefilter_pills = ComponentPiece(
            locator=self._PREFILTER_PILLS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._filtered_results_label = ComponentPiece(
            locator=self._FILTER_RESULTS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._filter = Filter(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        # These are not children of the table component and instead is a child of the root.
        # So we shouldn't copy the existing locator list
        self._component_modal = ComponentModal(driver=driver)
        self._prefilter_menu_item = ComponentPiece(
            locator=self._PREFILTER_MENU_ITEM_LOCATOR,
            driver=self.driver,
            poll_freq=poll_freq)
        self._column_items = ComponentPiece(locator=self._COLUMN_ITEM_LOCATOR, driver=self.driver, poll_freq=poll_freq)
        self._popover_or_modal_close_icon = ComponentPiece(
            locator=self._POPOVER_OR_MODAL_CLOSE_BUTTON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._component_modal.locator_list,
            poll_freq=poll_freq)
        self._prefilter_popover_or_modal = ComponentPiece(
            locator=self._PREFILTER_MENU_LOCATOR,
            driver=driver,
            parent_locator_list=self._component_modal.locator_list,
            poll_freq=poll_freq)
        self._config_popover_or_modal_header = ComponentPiece(
            locator=self._COLUMN_CONFIG_MENU_HEADER_LOCATOR,
            driver=driver,
            parent_locator_list=self._component_modal.locator_list,
            poll_freq=poll_freq)
        self._menu_items = {}
        self._menu_item_checkboxes = {}
        self._already_attempted_to_click_icon = False

    def apply_prefilters(
            self, prefilter_list: List[Union[AlarmState, AlarmPriority, AlarmStatusTableAlarmState]]) -> None:
        """
        Set the conditional display of an alarm to require it have at least one of the supplied States or Priorities.

        :param prefilter_list: A list of States and/or Priorities of which any alarm must satisfy at least one
            requirement to be displayed.

        :raises AssertionError: If unsuccessful in applying all supplied States/Priorities. Will also be raised if
            application of all prefilters in list results in all states or priorities being applied, as the requested
            state or priority will not be present as a prefilter.
        """
        prefilter_list_as_strings = [_.value for _ in prefilter_list]
        try:
            self.wait.until(IAec.function_returns_true(
                custom_function=self._apply_prefilters,
                function_args={
                    'prefilter_list_as_strings': prefilter_list_as_strings
                }))
        except TimeoutException:
            # Continue on, and let the assertion raise the issue instead.
            pass
        priority_leading_string = ": "  # technically "Priority: ", but translations will impact this
        applied_state_and_priority_filter_displayed_text = self.get_text_of_applied_prefilters()
        actual_states_and_priorities = []
        for prefilter in applied_state_and_priority_filter_displayed_text:
            actual_states_and_priorities.append(
                prefilter if priority_leading_string not in prefilter else prefilter.split(priority_leading_string)[-1])
        for state_or_priority in prefilter_list_as_strings:
            IAAssert.contains(
                iterable=actual_states_and_priorities,
                expected_value=state_or_priority,
                failure_msg=f"Failed to apply {state_or_priority} as a conditional filter.")

    def apply_show_all_events_prefilter(self) -> None:
        """
        Select the 'Show All' option for the STATES category of filters.

        :raises AssertionError: If unsuccessful in applying the 'Show All' option.
        """
        self.wait.until(IAec.function_returns_true(
            custom_function=self._apply_prefilters,
            function_args={
                'prefilter_list_as_strings': ["Show All"]
            }))
        all_applied_states_and_priorities = self.get_text_of_applied_prefilters()
        for state in [_.value for _ in AlarmState]:
            IAAssert.does_not_contain(
                iterable=all_applied_states_and_priorities,
                expected_value=state,
                failure_msg="Failed to remove any applied Alarm State filtering after selecting 'Show All' for States.")

    def click_prefilter_icon(self) -> None:
        """
        Click the icon which opens the State and Priority conditional prefilter popover/modal.

        :raises TimeoutException: If the icon required to open the State and Priority conditional prefilter
            popover/modal is not present.
        """
        self._prefilter_toggle_icon.find().click()

    def click_remove_all_prefilters_button(self) -> None:
        """
        Click the 'Remove All' button which removes all applied prefilters.

        :raises TimeoutException: If the 'Remove All' button is not present.
        """
        self._remove_all_button.click(wait_timeout=2)

    def column_configuration_icon_is_present(self) -> bool:
        """
        Determine if the gear icon which would open the Column Configuration popover/modal is present.

        :returns: True, if the gear icon is present - False otherwise.
        """
        try:
            return self._column_configuration_icon.find() is not None
        except TimeoutException:
            return False

    def column_configuration_popover_or_modal_is_open(self) -> bool:
        """
        Determine if the popover/modal which is used to configure which columns are displayed in the Alarm Table is
        open.

        :returns: True, if the popover/modal is currently displayed - False otherwise.
        """
        try:
            return self._config_popover_or_modal_header.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    def column_is_present_in_column_configuration_popover(self, column: AlarmTableColumn) -> bool:
        """
        Determine if the supplied Column is displayed in the column configuration popover/modal. Requires the
        popover/modal already be opened.

        :param column: The Alarm Table Column to verify the presence of.

        :raises TimeoutException: If the Column Configuration popover/modal is not open.
        """
        return column.name in [_.text for _ in self._column_items.find_all()]

    def _apply_prefilters(self, prefilter_list_as_strings: List[str]) -> bool:
        """
        Attempt to set the state of supplied labels (menu items) to True, and report back on the success of the entire
        list.

        :param prefilter_list_as_strings: A lost of labels (menu items) as STRINGS, where each label will be set to
            'selected' and applied as a conditional filter.

        :returns: True, if the entire list of labels was set to be included as a filtering condition - False otherwise.
        """
        try:
            for menu_item_label in prefilter_list_as_strings:
                self._set_selection_state_of_menu_item(menu_item_label=menu_item_label, should_be_displayed=True)
            return True
        except TimeoutException:
            return False

    def click_column_configuration_icon(self) -> None:
        """
        Click the icon to expand the Column Configuration popover/modal.

        :raises TimeoutException: If the icon is not present.
        """
        self._column_configuration_icon.scroll_to_element()
        self._column_configuration_icon.click()

    def click_popover_or_modal_close_icon(self) -> None:
        """
        Close any displayed popover or modal.

        :raises StaleElementReferenceException: If after trying twice we were unable to click the 'close' icon of the
            popover/modal.
        """
        try:
            self._popover_or_modal_close_icon.click(wait_timeout=2)
            self._already_attempted_to_click_icon = False  # on success, make sure we reset our check
        except TimeoutException:
            pass  # nothing to close
        except StaleElementReferenceException as sere:
            if not self._already_attempted_to_click_icon:
                self._already_attempted_to_click_icon = True
                self.click_popover_or_modal_close_icon()
            else:
                raise sere

    def click_show_all_for_events(self) -> None:
        """
        Click the 'Show All' option for the EVENT category.

        :raises TimeoutException: If the Prefilter popover/modal is not currently displayed.
        :raises IndexError: If the EVENT category is not present.
        """
        self._get_menu_item(menu_item_label="Show All").find_all()[0].click()

    def click_show_all_for_priorities(self) -> None:
        """
        Click the 'Show All' option for the PRIORITY category.

        :raises TimeoutException: If the Prefilter popover/modal is not currently displayed.
        :raises IndexError: If the PRIORITY category is not present.
        """
        self._get_menu_item(menu_item_label="Show All").find_all()[1].click()

    def collapse_text_filter_if_expanded(self) -> None:
        """
        Collapse the text filter if it is currently expanded. No action is taken if the text filter is not expanded.

        :raises TimeoutException: If the collapse icon within the text filter does not become clickable.
        """
        if self.text_filter_is_expanded():
            self.wait.until(ec.element_to_be_clickable(
                self._text_filter_close_icon.get_full_css_locator())).click()
            self.wait_on_binding(time_to_wait=1)

    def dismiss_prefilter_pill(
            self, prefilter: Union[AlarmState, AlarmPriority, AlarmStatusTableAlarmState]) -> None:
        """
        Remove the supplied Prefilter as a conditional filter if it is applied. No action is taken if the supplied
        Prefilter is not currently applied as a conditional filter.

        :param prefilter: The Alarm State to be removed as a conditional filter.

        :raises TimeoutException: If no Alarm States or Priorities are currently applied as conditional filters.
        """
        prefilters = self._prefilter_pills.find_all()
        for i in range(len(prefilters)):
            if prefilter.value.lower() in prefilters[i].text.lower():
                prefilters[i].find_element(*self._PRE_FILTER_REMOVE_ICON_LOCATOR).click()
                break
        IAAssert.does_not_contain(
            iterable=[prefilter.text.lower() for prefilter in self._prefilter_pills.find_all()],
            expected_value=prefilter.value.lower(),
            failure_msg=f"Failed to remove the {prefilter.value} State as a conditional filter by clicking the "
                        f"associated pill in the Alarm Table.")

    def expand_text_filter_if_collapsed(self) -> None:
        """
        Expand the text filter of the Alarm Table if it is not already expanded. No action is taken if the text filter
        is already expanded.

        :raises AssertionError: If unsuccessful in expanding the text filter of the Alarm Table.
        :raises TimeoutException: If the magnifying glass icon is not present, or does not become clickable.
        """
        if not self.text_filter_is_expanded():
            self.wait.until(ec.element_to_be_clickable(
                self._text_filter_icon.get_full_css_locator()
            )).click()
            try:
                self.wait.until(IAec.function_returns_true(
                    custom_function=self.text_filter_is_expanded,
                    function_args={}
                ))
            except TimeoutException:
                # ignore TimeoutException and raise AssertionError instead.
                pass
            self.wait_on_binding(time_to_wait=1)
        IAAssert.is_true(
            value=self.text_filter_is_expanded(),
            failure_msg="Failed to expand the text filter of the Alarm Table.")

    def filtered_results_are_displayed(self) -> bool:
        """
        Determine if filtering of any type is in place on the Alarm Table.

        :returns: True, if any conditional or text filtering is currently applied to the Alarm Table.
        """
        try:
            return self._filtered_results_label.find(wait_timeout=1) is not None
        except TimeoutException:
            return False

    def get_all_prefilter_options(self) -> List[str]:
        """
        Obtain the text of all available State or Priority conditional prefilters. Requires that the conditional
        prefilter popover/modal which contains States and Priorities is already opened.

        :returns: A list, where each entry in the list is the text of a displayed State or Priority that could be
            applied as a conditional prefilter.

        :raises TimeoutException: If the conditional prefilter popover/modal is not already open.
        """
        try:
            return [_.text for _ in self._prefilter_menu_item.find_all()]
        except StaleElementReferenceException:
            return self.get_all_prefilter_options()

    def get_origin_of_remove_all_prefilters_button(self) -> Point:
        """
        Obtain the location of the upper-left corner of the 'Remove All' button.

        :returns: A two-dimensional point which represents the location of the upper-left corner of the 'Remove All'
            button.

        :raises TimeoutException: If the 'Remove All' button is not present.
        """
        return self._remove_all_button.get_origin()

    def get_text_of_all_columns_in_popover(self) -> List[str]:
        """
        Obtain all columns available for display from within the column configuration popover/modal.

        :returns: A list, where each item is the text of a column which is available for display in the Alarm Table.

        :raises TimeoutException: If the column configuration popover/modal is not already open.
        """
        try:
            return [_.text for _ in self._column_items.find_all()]
        except StaleElementReferenceException:
            return self.get_text_of_all_columns_in_popover()

    def get_text_of_applied_filter_count_label(self) -> str:
        """
        Obtain the text of the label preceding the applied conditional filters, which usually appears as 'FILTERS (X):'
        (after any translations have been applied).

        :returns: The text of the label which describes how many conditional filters are currently applied.

        :raises TimeoutException: If no conditional filters are currently applied.
        """
        return self._applied_filter_count_label.get_text()

    def get_text_of_applied_prefilters(self) -> List[str]:
        """
        Obtain the text of all applied State and Priority conditional filters.

        :returns: A list which contains the text of all applied conditional (State/Priority) filters. An empty list
            implies no prefilters are applied.
        """
        try:
            return [prefilter_pill.text for prefilter_pill in self._prefilter_pills.find_all()]
        except TimeoutException:
            return []

    def get_text_of_remove_all_prefilters_button(self) -> str:
        """
        Obtain the text of the 'Remove All' button (after any translations have been applied).

        :returns: The text in use by the 'Remove All' button.

        :raises TimeoutException: If the 'Remove All' button is not currently in place.
        """
        return self._remove_all_button.find().text

    def get_width_of_remove_all_prefilters_button(self, include_units: bool = False) -> str:
        """
        Obtain the width of the 'Remove All' button, with or without units.

        :param include_units: If True, the returned value will contain units of measurement, otherwise the returned
            value will be only the numeric value.

        :returns: The width of the 'Remove All' button, and potentially units.

        :raises TimeoutException: If the 'Remove All' button is not currently in place.
        """
        return self._remove_all_button.get_computed_width(include_units=include_units)

    def is_displayed(self) -> bool:
        """
        Determine if the Alarm Table is currently displayed.

        :returns: True, if the Alarm Table is currently displayed - False otherwise.
        """
        try:
            return self.find(wait_timeout=0.5).is_displayed()
        except TimeoutException:
            return False

    def prefilter_popover_or_modal_is_displayed(self) -> bool:
        """
        Determine if the State and Priority conditional filter popover/modal is displayed.

        :returns: True, if the State/Priority popover/modal is currently displayed - False otherwise.
        """
        try:
            return self._prefilter_popover_or_modal.find(wait_timeout=0.5).is_displayed()
        except TimeoutException:
            return False

    def prefilter_is_applied(self, prefilter: Union[AlarmState, AlarmPriority]) -> bool:
        """
        Determine if a Prefilter is currently applied as a conditional prefilter.

        :param prefilter: The prefilter to verify as a current conditional prefilter.

        :returns: True, if the supplied Prefilter is currently applied as a conditional filter - False otherwise.
        """
        try:
            return prefilter.value in [_.text for _ in self._prefilter_pills.find_all()]
        except StaleElementReferenceException:
            return self.prefilter_is_applied(
                prefilter=prefilter)
        except TimeoutException:
            return False

    def prefiltering_icon_is_displayed(self) -> bool:
        """
        Determine if the Alarm Table allows for applying States and/or Priorities as conditional prefilters to determine
        the display state of Alarms.

        :returns: True, if the user has access to the icon which would allow for opening the State and Priority
            conditional prefilter popover/modal.
        """
        try:
            return self._prefilter_toggle_icon.find().is_displayed()
        except TimeoutException:
            return False

    def get_count_of_results_matching_text_filter(self) -> int:
        """
        Obtain the count of results the Alarm Table believes match all applied filters.

        :returns: The displayed count of results of the Alarm Table which match all applied filters.

        :raises TimeoutException: If no filtering is applied.
        """
        return int(self.get_text_of_filter_results().split(' result')[0])

    def get_text_of_filter_results(self) -> str:
        """
        Obtain the full text of the filtered results label.

        :returns: The full text of the label which describes the count of filtered results.

        :raises TimeoutException: If no filtering is applied.
        """
        return self._filter_label_result_count.find().text

    def set_display_state_of_known_alarm_columns(
            self,
            list_of_known_alarm_columns: List[AlarmTableColumn],
            should_be_displayed: bool) -> None:
        """
        Open the column configuration popover/modal and set the display state for a list of Alarm Table Columns.

        :param list_of_known_alarm_columns: A list where each entry is an Alarm Table Column.
        :param should_be_displayed: If True, each entry will be set as an active selection within the column
            configuration popover/modal. If False, the supplied columns will be set as inactive.
        """
        if not self.column_configuration_popover_or_modal_is_open():
            self._column_configuration_icon.click()
        try:
            self.wait.until(IAec.function_returns_true(
                custom_function=self._set_selection_state_of_columns,
                function_args={
                    "list_of_known_alarm_columns": list_of_known_alarm_columns,
                    "should_be_displayed": should_be_displayed
                }
            ))
        except TimeoutException:
            # no action taken
            pass
        self.click_popover_or_modal_close_icon()

    def show_all_hidden_columns(self) -> None:
        """
        Request that all available columns be displayed in the Alarm Table.
        """
        if not self.column_configuration_popover_or_modal_is_open():
            self.click_column_configuration_icon()
        # bypass normal menu item handling because this option never has any indication it is active.
        self._get_menu_item(menu_item_label="Show All Hidden Columns").click()
        self.click_popover_or_modal_close_icon()

    def text_filter_is_expanded(self) -> bool:
        """Determine if the text filter of the Alarm Table is currently expanded."""
        return 'isExpanded' in self._text_filter_container.find().get_attribute('class')

    def _set_selection_state_of_columns(
            self,
            list_of_known_alarm_columns: List[AlarmTableColumn],
            should_be_displayed: bool) -> bool:
        """
        Set the selection state for a set of columns. Omitting a column from this list will result in no action on the
        omitted column. Report back if any column had its display state modified. Requires that the column configuration
        popover/modal already be open.

        :param list_of_known_alarm_columns: A list which contains all of the columns you would like to set the state of.
        :param should_be_displayed: Dictates whether the column should be an active selection, making it visible to the
            user.

        :returns: True, if any column had its display state changed - False otherwise.
        """
        did_click = False
        for column_name in [column.name for column in list_of_known_alarm_columns]:
            try:
                checkbox = self._get_column_menu_item_checkbox(
                    menu_item_label=column_name)
                if (self._CHECKBOX_CHECKED not in checkbox.find().get_attribute(
                        "class")) == should_be_displayed:
                    self._get_menu_item(
                        menu_item_label=column_name).click(
                        binding_wait_time=0.5)
                    did_click = True
            except TimeoutException:
                pass  # no checkbox with given name
        return not did_click

    def _set_selection_state_of_menu_item(self, menu_item_label: str, should_be_displayed: bool) -> None:
        """
        Set the selection state of any item in a popover/modal (State, Priority, or Column), except for the
        'Show All Columns' item.

        :param menu_item_label: The text of the item for which we will set the state.
        :param should_be_displayed: If True, we will set the supplied item to be an active selection. If False, we will
            set the itme to be an inactive selection.

        :raises TimeoutException: If no item with the supplied text could be found.
        """
        menu_item = self._get_menu_item(menu_item_label=menu_item_label)
        if ("isActive" in menu_item.find().find_element(
                By.CSS_SELECTOR, "div").get_attribute("class")) != should_be_displayed:
            menu_item.click(binding_wait_time=0.5)

    def _get_menu_item(self, menu_item_label: str) -> _AlarmTableMenuItem:
        """
        Obtain a menu item with the supplied text.

        :param menu_item_label: The text of the menu item.
        """
        menu_item = self._menu_items.get(menu_item_label)
        if not menu_item:
            menu_item = _AlarmTableMenuItem(driver=self.driver, text=menu_item_label)
            self._menu_items[menu_item_label] = menu_item
        return menu_item

    def _get_column_menu_item_checkbox(self, menu_item_label: str) -> ComponentPiece:
        """
        Get the checkbox associated with a menu item.

        :param menu_item_label: The text of the menu item from which you would like the checkbox.

        :returns: The Component Piece which defines the checkbox for a column menu item within the Column Configuration
            popover/modal.
        """
        checkbox = self._menu_item_checkboxes.get(menu_item_label)
        if not checkbox:
            checkbox = ComponentPiece(
                locator=(By.TAG_NAME, 'svg'),
                driver=self.driver,
                parent_locator_list=self._get_menu_item(menu_item_label=menu_item_label).locator_list,
                wait_timeout=1)
            self._menu_item_checkboxes[menu_item_label] = checkbox
        return checkbox
    

class AlarmJournalTable(_AlarmTable):
    """A Perspective Alarm Journal Component."""

    class _AlarmJournalTableDateRangeSelector(CommonDateRangeSelector):
        """The Date Range Selector of an Alarm Journal Table."""
        def __init__(
                self,
                driver: WebDriver,
                parent_locator_list: Optional[List] = None,
                description: Optional[str] = None,
                poll_freq: float = 0.5):
            super().__init__(
                driver=driver,
                parent_locator_list=parent_locator_list,
                description=description,
                poll_freq=poll_freq)

        def get_count_of_events_within_current_range(self) -> int:
            """
            Obtain a count of events which fall within the applied date range (whether Realtime or Historical).

            :returns: The count of Alarm entries which match all applied filters and which fall within the currently
                applied date range.
            """
            msg = self.get_message()
            return 0 if "alarm events" not in msg else int(msg.split(" ")[0])

    class _AlarmJournalTableFooter(ComponentPiece):
        """The footer of the Alarm Journal Table."""
        _FOOTER_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.alarmTableFoot")
        _VIEW_INSTANCES_FOR_ALARM_BUTTON_LOCATOR = (By.CSS_SELECTOR, "button.viewInstancesButton.ofAlarmButton")
        _VIEW_INSTANCES_FOR_SOURCE_PATH_BUTTON_LOCATOR = (By.CSS_SELECTOR, "button.viewInstancesButton.ofAlarmButton")

        def __init__(
                self,
                driver: WebDriver,
                parent_locator_list: Optional[List[Tuple[By, str]]] = None,
                wait_timeout: float = 3,
                poll_freq: float = 0.5):
            super().__init__(
                locator=self._FOOTER_CONTAINER_LOCATOR,
                driver=driver,
                parent_locator_list=parent_locator_list,
                wait_timeout=wait_timeout,
                poll_freq=poll_freq)
            self._for_alarm_button = ComponentPiece(
                locator=self._VIEW_INSTANCES_FOR_ALARM_BUTTON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._for_source_path_button = ComponentPiece(
                locator=self._VIEW_INSTANCES_FOR_SOURCE_PATH_BUTTON_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)

        def click_view_instances_for_this_alarm(self) -> None:
            """
            Click the 'Alarm' button which allows for a user to view instances of the currently-selected alarm.

            :raises TimeoutException: If the 'Alarm' button is not present. This is likely a result of no alarm being
                selected.
            """
            self._for_alarm_button.click()

        def click_view_instances_for_this_source_path(self) -> None:
            """
            Click the 'Source Path' button which allows for a user to view instances of the currently-selected alarm.

            :raises TimeoutException: If the 'Source Path' button is not present. This is likely a result of no alarm
                being selected.
            """
            self._for_source_path_button.click()

        def footer_is_expanded(self) -> bool:
            """
            Determine if the footer of the Alarm Journal Table is expanded.

            :returns: True, if the panel which contains the 'Alarm' and 'Source Path' buttons is expanded - False
                otherwise.
            """
            return "show" in self.find().get_attribute("class")

    def __init__(
            self,
            locator,
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: int = 5,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        self._date_range_selector = self._AlarmJournalTableDateRangeSelector(
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._ajt_footer = self._AlarmJournalTableFooter(
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def apply_button_is_enabled(self) -> bool:
        """
        Determine if the 'Apply' button is enabled within the Date Range Selector.

        :returns: True, if the 'Apply' button is enabled within the Date Range Selector.

        :raises TimeoutException: If the Apply button is not present.
        """
        return self._date_range_selector.apply_button_is_enabled()

    def cancel_changes_to_date_range(self) -> None:
        """
        Click the Cancel button in the Date Range Selector.

        :raises TimeoutException: If the Cancel button is not present.
        """
        self._date_range_selector.cancel_changes()

    def click(self, **kwargs) -> None:
        """
        :raises NotImplementedError: Because there's no reason to blindly click an Alarm Journal Table.
        """
        raise NotImplementedError("Please do not click the Alarm Journal Table.")

    def click_apply_button_in_date_range_selector(self) -> None:
        """
        Click the Apply button within the Date Range Selector.

        :raises TimeoutException: If the Apply button is not present.
        """
        self._date_range_selector.apply_changes()

    def click_clear_in_historical_tab(self) -> None:
        """
        Click the 'Clear' link in the Historical Picker of the Date Range Selector.

        :raises TimeoutException: If the Historical Picker is not present.
        """
        self._date_range_selector.click_clear_in_historical_tab()

    def click_historical_day(self, numeric_day: Union[int, str]) -> None:
        """
        Click the supplied numeric day in the Historical Picker of the Date Range Selector.

        :param numeric_day: The day you would like to click.

        :raises AssertionError: If unsuccessful in clicking the supplied day.
        :raises TimeoutException: If the Historical Picker is not present.
        """
        self._date_range_selector.click_historical_day(numeric_day=numeric_day)

    def click_realtime_or_historical_tab(self, tab: DateRangeSelectorTab) -> None:
        """
        Select either the Realtime or Historical tab within the Date Range Selector.

        :param tab: The tab you would like to click.

        :raises TimeoutException: If the supplied tab is not present.
        """
        self._date_range_selector.select_tab(tab=tab)

    def click_view_instances_for_this_alarm(self) -> None:
        """
        Click the 'Alarm' button which allows for a user to view instances of the currently-selected alarm.

        :raises TimeoutException: If the 'Alarm' button is not present. This is likely a result of no alarm being
            selected.
        """
        self._ajt_footer.click_view_instances_for_this_alarm()

    def click_view_instances_for_this_source_path(self) -> None:
        """
        Click the 'Source Path' button which allows for a user to view instances of the currently-selected alarm.

        :raises TimeoutException: If the 'Source Path' button is not present. This is likely a result of no alarm
            being selected.
        """
        self._ajt_footer.click_view_instances_for_this_source_path()

    def close_date_range_popover(self) -> None:
        """
        Close the Date Range Selector if it is already expanded. Takes no action if the Date Range Selector is not
        expanded.
        """
        self._date_range_selector.close_date_range_modal_or_popover_if_displayed()

    def date_range_selector_toggle_icon_is_present(self) -> bool:
        """
        Determine if the icon used to expand the DateRangeSelector is present.

        :returns: True, if the icon is present - False otherwise.
        """
        return self._date_range_selector.date_range_selector_toggle_icon_is_present()

    def date_range_tab_is_active(self, tab: DateRangeSelectorTab) -> bool:
        """
        Determine if the supplied tab is currently the active tab.

        :param tab: The tab which will have its active status queried.

        :returns: True, if the supplied tab is active - False otherwise.

        :raises TimeoutException: If the Date Range Selector is not present.
        """
        return self._date_range_selector.tab_is_active(tab=tab)

    def day_is_node_of_range(self, numeric_day: Union[int, str]) -> bool:
        """
        Determine if the supplied numeric day is either the start or end day of the current Historical range.

        :param numeric_day: The number of the day to verify as a terminal node of the range.

        :returns: True, if the supplied numeric_day is either the starting or ending day of the current range. False, if
            the numeric_day is simply within the range or not a part of the range at all.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the Realtime
            range instead of the Historical range.
        """
        return self._date_range_selector.day_is_node_of_range(numeric_day=numeric_day)

    def day_is_within_historical_range(self, numeric_day: Union[int, str]) -> bool:
        """
        Determine if the supplied day falls anywhere within the current historical range.

        :param numeric_day: The number of the day to check for validity.

        :returns: True, if the supplied numeric day is the starting day, ending day or any day between those two days -
            False otherwise.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the Realtime
            range instead of the Historical range.
        """
        return self._date_range_selector.day_is_within_historical_range(numeric_day=numeric_day)

    def get_alarm_event_text(self) -> str:
        """
        Obtain the message related to the Date Range Selector. Currently only used for the Alarm Journal Table.
        Example: 'X alarm events'

        :returns: The message associated with the Date Range Selector, if available.

        :raises TimeoutException: If not using the Alarm Journal Table, or if the Alarm Journal Table currently has
            disabled the toolbar which houses the Date Range Selector.
        """
        return self._date_range_selector.get_message()

    def get_count_of_events_within_current_range(self) -> int:
        """
        Obtain a count of events which fall within the applied date range (whether Realtime or Historical).

        :returns: The count of Alarm entries which match all applied filters and which fall within the currently
            applied date range.
        """
        return self._date_range_selector.get_count_of_events_within_current_range()

    def get_current_range_text(self) -> str:
        """
        Obtain the range message associated with the Date Range Selector. This value describes the applied range.
        Example: 'Last 8 hours', or a breakdown of the date/times used for the Historical range.

        :returns: The range message associated with the Date Range Selector, if available.

        :raises TimeoutException: If the range message is not present.
        """
        return self._date_range_selector.get_range_message()

    def get_historical_range(self) -> HistoricalRange:
        """
        Obtain all pieces of information pertaining to the Historical Range.

        :returns: An object containing information about the currently applied Historical Range - even if the Component
            is using a realtime range.
        """
        return self._date_range_selector.get_historical_range()

    def get_historical_text_picker_range_info_text(self) -> str:
        """
        Obtain the historical range as the formatted dates the user would see.

        :returns: A formatted string representation of the dates currently applied as the Historical Range.
        """
        return self._date_range_selector.get_historical_text_picker_range_info_text()

    def get_list_of_available_months_from_date_range_selector(self) -> List[str]:
        """
        Open the Date Range Selector and obtain a list of months available for selection, before finally closing the
        date range popover/modal.

        :returns: A list of months which a user is able to select within the Date Range Selector.

        :raises TimeoutException: If the icon to open the Date Range Selector is not present.
        """
        try:
            self._date_range_selector.open_date_range_popover_or_modal_if_not_already_displayed()
            self._date_range_selector.select_tab(tab=DateRangeSelectorTab.HISTORICAL)
            return self._date_range_selector.get_list_of_available_months_from_historical_picker()
        finally:
            self._date_range_selector.close_date_range_modal_or_popover_if_displayed()

    def get_selected_start_and_end_days_from_date_range_selector(self) -> List[int]:
        """
        Obtain the currently selected beginning and end days of the Historical range.

        Note: This only returns days of the current calendar month, so ranges which span months will only return
        one day.

        :returns: The beginning and end days of the current Historical range, where the
            first element in the list is the start day and the second element is the end day.

        :raises TimeoutException: If the Historical picker is not present; likely to happen if dealing with the
            Realtime range instead of the Historical range.
        """
        return self._date_range_selector.get_selected_start_and_end_days_from_date_range_selector()

    def get_text_of_range_tabs(self) -> List[str]:
        """
        Obtain the text of the Realtime and Historical tabs, as a user would see them. Useful for verifying
        localization.

        :returns: A list of strings which represent the text displayed in the Realtime and Historical tabs, with
            Realtime as the 0th element, and Historical as the 1th element.

        :raises TimeoutException: If the Date Range Selector is not expanded.
        """
        return self._date_range_selector.get_text_of_tabs()

    def open_date_range_popover(self) -> None:
        """
        Expand the Date Range Selector if it is not already expanded.

        :raises TimeoutException: If the icon used to expand the Date Range Selector is not present.
        """
        self._date_range_selector.open_date_range_popover_or_modal_if_not_already_displayed()

    def select_realtime_unit(self, time_unit: DateRangeSelectorTimeUnit) -> None:
        """
        Select a unit of time from the Realtime range selector.

        :param time_unit: The unit of time to select from the selector.

        :raises AssertionError: If unsuccessful in applying the supplied time unit.
        """
        self._date_range_selector.select_realtime_unit(time_unit=time_unit)

    def set_historical_range(self, historical_range: HistoricalRange, apply: bool = True) -> None:
        """
        Set the historical range and click the Apply button based on additional parameters.

        :param historical_range: The Historical Range object which defines the starting and ending dates to apply.
        :param apply: If True, the supplied historical range will be applied. If False, the supplied Historical Range
            will be cancelled upon completion. If None, no action will be taken after completion, leaving the Date
            Range Selector open.

        :raises AssertionError: If unsuccessful in applying the supplied historical range.
        :raises TimeoutException: If the Apply button or the picker were not present.
        """
        self._date_range_selector.set_historical_range(historical_range=historical_range, apply=apply)

    def set_realtime_numeric_value(self, time_value: int) -> None:
        """
        Set the numeric range to be used with the time unit.

        :param time_value: The scalar amount of time you desire to apply as the Realtime range.

        :raises AssertionError: If unsuccessful in applying the supplied time value.
        """
        self._date_range_selector.set_realtime_range(time_value=time_value)


class AlarmStatusTable(_AlarmTable):
    """A Perspective Alarm Status Table Component."""
    _NOTES_MODAL_LOCATOR = (By.CSS_SELECTOR, '#alarmNotesModal')
    _NOTES_MODAL_ACK_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button.alarmNotesModalOkayButton')
    _NOTES_MODAL_CANCEL_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button.alarmNotesModalCancelButton')
    _NOTES_MODAL_TEXT_AREA_LOCATOR = (By.CSS_SELECTOR, 'div.alarmNotesContainer textarea')
    _NOTES_MODAL_CLOSE_LOCATOR = (By.CSS_SELECTOR, 'div.alarmNotesClose svg')
    _NOTES_MODAL_TITLE_LOCATOR = (By.CSS_SELECTOR, 'div.alarmNotesTitle')
    _SHELVE_TIMES_LOCATOR = (By.CSS_SELECTOR, 'div.shelveTimes div.alarmTableMenuItem')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator,
            driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._alarm_table_toolbar = self._AlarmStatusTableToolbar(
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._alarm_table_body = self._AlarmStatusTableBody(
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._alarm_table_footer = self._AlarmStatusTableFooter(
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._alarm_status_table_header = self._AlarmStatusTableHeader(
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        # The following are for the ack modal which are children of the root
        self._notes_modal = ComponentPiece(locator=self._NOTES_MODAL_LOCATOR, driver=self.driver, poll_freq=poll_freq)
        self._notes_modal_ack_button = ComponentPiece(
            locator=self._NOTES_MODAL_ACK_BUTTON_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._notes_modal.locator_list,
            poll_freq=poll_freq)
        self._notes_modal_cancel_button = ComponentPiece(
            driver=driver,
            locator=self._NOTES_MODAL_CANCEL_BUTTON_LOCATOR,
            parent_locator_list=self._notes_modal.locator_list,
            poll_freq=poll_freq)
        self._notes_modal_close = ComponentPiece(
            locator=self._NOTES_MODAL_CLOSE_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._notes_modal.locator_list,
            poll_freq=poll_freq)
        self._notes_modal_text_area = ComponentPiece(
            locator=self._NOTES_MODAL_TEXT_AREA_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._notes_modal.locator_list,
            poll_freq=poll_freq)
        self._notes_modal_title = ComponentPiece(
            locator=self._NOTES_MODAL_TITLE_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._notes_modal.locator_list,
            poll_freq=poll_freq)
        # The Shelve time selector is its own modal too
        self._shelve_times = ComponentPiece(locator=self._SHELVE_TIMES_LOCATOR, driver=self.driver, poll_freq=poll_freq)

    def click(self, wait_timeout=None, binding_wait_time: float = 0) -> None:
        """:raises NotImplementedError: Because the Alarm Status Table should not be blindly clicked."""
        raise NotImplementedError("Please do not blindly click the Alarm Status Table.")

    def acknowledge_button_is_displayed(self) -> bool:
        """
        Determine if the 'Acknowledge' Button is displayed for the selected alarm.

        :returns: True, if the 'Acknowledge' button is displayed - False otherwise.
        """
        return self._alarm_table_footer.acknowledge_button_is_displayed()

    def acknowledge_button_is_enabled(self) -> bool:
        """
        Determine if the 'Acknowledge' button is enabled in the footer of the Alarm Status Table.

        :returns: True, if the 'Acknowledge' button is currently enabled - False otherwise.

        :raises TimeoutException: If the 'Acknowledge' button is not present in the Footer of the Alarm Status
            Table.
        """
        return self._alarm_table_footer.acknowledge_button_is_enabled()

    def apply_prefilters(
            self,
            prefilter_list: List[Union[AlarmStatusTableAlarmState, AlarmPriority]],
            close_modal_after: bool = True) -> None:
        """
        Expand the popover/modal which contains the Alarm States and Priorities for the Alarm Status Table, and then
        apply the supplied States/Priorities to the Alarm table.

        :param prefilter_list: A list of the Alarm States and/or Alarm Priorities you would like to have applied as
            a condition for display of an alarm.
        :param close_modal_after: Dictates whether the popover/modal which contains the states and priorities is
            closed after the selections are made.

        :raises AssertionError: If unsuccessful in applying all supplied States/Priorities.
        :raises TimeoutException: If the icon required to expand the conditional prefilter popover/modal is not present.
        """
        # special requirement of AlarmStatusTableAlarmState due to unique nature of Alarm Status Table prefilters.
        self.open_prefilter_popover()
        self._alarm_table_toolbar.apply_prefilters(
            prefilter_list=prefilter_list)
        if close_modal_after:
            self.close_any_open_popover_or_modal()
        self._alarm_table_toolbar.wait_on_binding(time_to_wait=1)

    def click_shelve_button(self) -> None:
        """
        Click the 'Shelve' button in the Footer of the Alarm Status Table.

        :raises TimeoutException: If the 'Shelve' button is not present, or if unsuccessful in clicking the button.
        """
        return self._alarm_table_footer.click_shelve_button()

    def dismiss_prefilter_pill(self, prefilter: Union[AlarmStatusTableAlarmState, AlarmPriority]) -> None:
        """
        Remove the supplied Prefilter as a conditional filter if it is applied. No action is taken if the supplied
        Prefilter is not currently applied as a conditional filter.

        :param prefilter: The Prefilter to be removed as a conditional filter.

        :raises TimeoutException: If no Alarm States or Priorities are currently applied as conditional filters.
        """
        self._alarm_table_toolbar.dismiss_prefilter_pill(
            prefilter=prefilter)

    def select_shelve_time(self, shelve_time_option_text: str, wait_for_shelve: bool = True) -> None:
        """
        Select a period of time for which to shelve an Alarm.

        :param shelve_time_option_text: The string label of the option to select as the period of time to shelve the
            selected alarm(s).
        :param wait_for_shelve: If True, wait for the animation associated with shelving an Alarm to complete before
            continuing.

        :raises TimeoutException: If the supplied shelve time is not an option, or if the Shelve button has not already
            been clicked.
        """
        self.wait.until(IAec.function_returns_true(
            custom_function=self._select_shelve_time_and_report_success_of_selection,
            function_args={
                "shelve_time": shelve_time_option_text
            }
        ))
        if wait_for_shelve:
            # Waits until the footer is gone
            self._alarm_table_footer.acknowledge_button_is_not_displayed()

    def shelve_time_is_displayed(self, shelve_time_option_text: str) -> bool:
        """
        Determine if the supplied shelve time text is a displayed option in the dropdown.

        :param shelve_time_option_text: The text to search for as an option of the shelve time dropdown.

        :returns: True, if the supplied text is an option of the shelve time dropdown - False otherwise.
        """
        return self._alarm_table_footer.shelve_time_is_displayed(shelve_time=shelve_time_option_text)

    def _select_shelve_time_and_report_success_of_selection(self, shelve_time: str) -> bool:
        """
        Select a shelve time from the dropdown and report on the success of the selection.

        :param shelve_time: The option to select from the shelve time dropdown.

        :returns: True, if the option was successfully selected - False otherwise.
        """
        try:
            self._alarm_table_footer.click_shelve_time(shelve_time=shelve_time)
            return True
        except TimeoutException:
            return False

    def set_row_selection_state_for_first_matching_row(
            self, known_value: str, column_for_value: AlarmTableColumn, should_be_selected: bool) -> None:
        """
        Set the selection state for a row which has a known value in a known column.

        :param known_value: The value known to be in the target row, within the specified column.
        :param column_for_value: The Alarm Table Column known to contain the supplied known value.
        :param should_be_selected: Dictates the selection state to be applied to the row described by the supplied
            value and column.

        :raises TimeoutException: If selection is disabled for the Alarm Status Table, or if no row contains the
            known value in the specified column, or if the specified colum is not present.
        """
        self._alarm_table_body.set_row_selection_state_for_first_matching_row(known_value=known_value,
                                                                              column_for_value=column_for_value,
                                                                              should_be_selected=should_be_selected)

    def set_row_selection_state_for_all_matching_rows(
            self, known_value: str, column_for_value: AlarmTableColumn, should_be_selected: bool) -> None:
        """
        Set the selection state for all rows which have a known value in a known column.

        :param known_value: The value known to be in the target rows, within the specified column.
        :param column_for_value: The Alarm Table Column known to contain the supplied known value.
        :param should_be_selected: Dictates the selection state to be applied to the rows described by the supplied
            value and column.

        :raises TimeoutException: If unsuccessful in applying the requested selection state to all supplied indices.
        """
        self._alarm_table_body.set_row_selection_state_for_all_matching_rows(known_value=known_value,
                                                                             column_for_value=column_for_value,
                                                                             should_be_selected=should_be_selected)

    def get_selection_state_for_rows(self, known_value: str, column_for_value: AlarmTableColumn) -> List[bool]:
        """
        Obtain the selection state of all rows which have a known value within a specified column.

        :param known_value: A value known to be present in the specified column.
        :param column_for_value: The column which contains the supplied value.

        :returns: A list, where each element in the list is a boolean value which represents the selection state of a
            row which had the known value within the specified column.

        :raises TimeoutException: If no rows contain the supplied value in the specified column.
        :raises AssertionError: If no rows contain the supplied value in the specified column.
        """
        return self._alarm_table_body.get_selection_state_for_rows(known_value=known_value,
                                                                   column_for_value=column_for_value)

    def get_count_of_selected_rows(self) -> int:
        """
        Obtain a count of all rows which are currently selected in the Alarm Status Table.

        :returns: A count which represents the number of currently selected rows in the Alarm Status Table.
        """
        return self._alarm_table_body.get_count_of_selected_rows()

    def get_count_of_alarm_with_expected_value(
            self,
            desired_column: AlarmTableColumn,
            desired_column_value_list: Optional[List[str]],
            known_column: AlarmTableColumn,
            known_column_value: str) -> int:
        """
        Obtain a count of all rows which contain a known value in a specific column.

        :param desired_column: The Alarm Table Column you want information from.
        :param desired_column_value_list: Potential values you want to filter for within the desired column.
        :param known_column: The Alarm Table Column you know contains text which will be used to target rows of the
            Alarm Status Table.
        :param known_column_value: The value known to exist in a column. This value is what determines which rows
            will be considered matches.

        :returns: A count of all rows which contain a known value within a known column, and which also (optionally)
            contain a set of values in a second column.
        """
        return len(self._alarm_table_body
                   .get_alarm_column_data_with_known_value(desired_column=desired_column,
                                                           desired_column_value_list=desired_column_value_list,
                                                           known_column=known_column,
                                                           known_column_value=known_column_value))

    def get_alarm_column_data_with_known_value(
            self,
            desired_column: AlarmTableColumn,
            desired_column_value_list: Optional[List[str]],
            known_column: AlarmTableColumn,
            known_column_value: str) -> List[dict]:
        """
        Obtain information about a desired column for all rows which contain a known value in a known column.

        :param desired_column: The Alarm Table Column you want information from.
        :param desired_column_value_list: Potential values you want to filter for within the desired column.
        :param known_column: The Alarm Table Column you know contains text which will be used to target rows of the
            Alarm Status Table.
        :param known_column_value: The value known to exist in a column. This value is what determines which rows
            will be considered matches.
        """
        return self._alarm_table_body\
            .get_alarm_column_data_with_known_value(desired_column=desired_column,
                                                    desired_column_value_list=desired_column_value_list,
                                                    known_column=known_column,
                                                    known_column_value=known_column_value)

    def get_row_count(self) -> int:
        """
        Obtain a count of rows in the Table.

        :returns: A count of rows in the Alarm Status Table.
        """
        return self._alarm_table_body.get_row_count(include_expanded_subviews_in_count=False)  # no subviews in AST

    def get_select_all_checkbox_state(self) -> Optional[bool]:
        """
        Obtain the state of the "select-all" checkbox.

        :returns: True, if checked. False, if un-checked. None, if some number of rows less than the total number
            of rows on the page is currently selected.

        :raises TimeoutException: If the checkbox which drives the selection of all rows is not present.
        """
        return self._alarm_status_table_header.get_select_all_checkbox_state()

    def get_sort_order_number_of_column(self, column: AlarmTableColumn) -> Optional[int]:
        """
        Obtain the numeric sort order of a given Alarm table Column.

        :param column: The Alarm Table Column from which you would like the sort order.

        :returns: The sort order of the Alarm Table Column as a number, or None if no sorting is applied.

        :raises TimeoutException: If the specified Alarm Table Column is not present.
        """
        return self._alarm_status_table_header.get_sort_order_number_of_column(column=column)

    def sort_order_of_column_is_descending(self, column: AlarmTableColumn) -> Optional[bool]:
        """
        Determine if the sort applied to a given Alarm Table Column is in descending order.

        :param column: The Alarm Table Column which will have it sort order checked.

        :returns: True, if the sort applied to the supplied Alarm Table Column is in descending order. False
            if the order is ascending. None if no sorting is applied to the Alarm Table Column.

        :raises TimeoutException: If the specified column has no sorting applied, or if the column is not present.
        """
        try:
            return self._alarm_status_table_header.descending_sort_is_active(column=column)
        except TimeoutException:
            return False

    def click_acknowledge_button_and_report_success_of_click(self) -> bool:
        """
        Click the Acknowledge button and report back the success of the click.

        :returns: True, if the 'Acknowledge' button was successfully clicked - False otherwise.
        """
        return self._alarm_table_footer.click_acknowledge_button_and_report_success_of_click()

    def click_active_alarms_tab(self) -> None:
        """
        Click the ACTIVE tab within the Alarm Status Table. This function will also wait for any transition
        between the tabs to complete.

        :raises AssertionError: If unsuccessful in getting to the ACTIVE tab of the Alarm Status Table.
        """
        return self._alarm_table_toolbar.click_active_alarms_tab()

    def click_shelved_alarms_tab(self, time_to_wait: float = 0.5) -> None:
        """
        Click the SHELVED tab within the Alarm Status Table. This function will also wait for any transition
        between the tabs to complete.

        :raises AssertionError: If unsuccessful in getting to the SHELVED tab of the Alarm Status Table.
        """
        return self._alarm_table_toolbar.click_shelved_alarms_tab(
            transition_wait_time=time_to_wait)

    def active_tab_exists(self) -> bool:
        """
        Determine if the ACTIVE tab is present in the Alarm Status Table.

        :returns: True, if the ACTIVE tab is present - False otherwise.
        """
        return self._alarm_table_toolbar.active_tab_exists()

    def shelved_tab_exists(self) -> bool:
        """
        Determine if the SHELVED tab is present in the Alarm Status Table.

        :returns: True, if the SHELVED tab is present - False otherwise.
        """
        return self._alarm_table_toolbar.shelved_tab_exists()

    def get_count_of_shelved_alarms_from_toolbar(self, expected_count: Optional[int] = None) -> int:
        """
        Obtain a count of shelved alarms from the SHELVED tab of the Alarm Status Table.

        :param expected_count: The count of Alarms you expect to be shelved. If supplied, this function will attempt
            to wait until this value is the actual count before returning a value.

        :returns: The number of Alarms declared as being shelved by the SHELVED tab.

        :raises TimeoutException: If the tabs of the Alarm Status Table are not present.
        :raises IndexError: If the SHELVED tab is not present.
        """
        return self._alarm_table_toolbar.get_count_of_shelved_alarms(
            expected_count=expected_count)

    def get_count_of_active_alarms_from_toolbar(self) -> int:
        """
        Obtain a count of Alarms which match applied filters and which are active.

        :returns: The count of alarms declared by the ACTIVE tab which match all applied PREFILTERS and which are
            active. Text filtering does not affect this count.
        """
        return self._alarm_table_toolbar.get_count_of_active_alarms()

    def get_confirmation_text_of_shelved_alarms(self, expected_shelve_message: str) -> Optional[str]:
        """
        Obtain the message displayed as a confirmation that an alarm has been shelved. Requires that an Alarm have
        been shelved immediately before invocation.

        :param expected_shelve_message: The message you expect to be displayed as a confirmation. This function will
            wait some period of time or until the expected message is displayed before returning the displayed
            message.

        :returns: The confirmation message displayed when a user shelves an alarm, or None. A None value indicates
            the message never took on the expected text.
        """
        return self._alarm_table_footer.get_shelved_alarm_confirmation_message(
            expected_shelve_message=expected_shelve_message)

    def show_all_hidden_columns(self) -> None:
        """
        Request that the Alarm Table display all available columns.
        """
        # unable to perform assertion here because this function is used across the ACTIVE/SHELVED tabs.
        self._alarm_table_toolbar.show_all_hidden_columns()

    def click_unshelve_button(self) -> None:
        """
        Click the 'Unshelve' button available in the Footer of the Alarm Status Table while interacting with alarms
        in the 'Shelved' tab.

        :raises TimeoutException: If the 'Unshelve' button is not present.
        """
        self._alarm_table_footer.click_unshelve_button()

    def set_select_all_checkbox(self, should_be_selected: bool) -> None:
        """
        Set the state of the "select-all" checkbox.

        :param should_be_selected: If True, the checkbox will be set to checked. If False, the checkbox will be set
            to an un-checked state.

        :raises TimeoutException: If the checkbox which drives the selection of all rows is not present.
        :raises AssertionError: If unsuccessful in setting the state of the "select-all" checkbox.
        """
        return self._alarm_status_table_header.set_select_all_checkbox_state(should_be_checked=should_be_selected)

    def notes_modal_is_displayed(self) -> bool:
        """
        Determine if the Acknowledgement Notes modal is displayed.

        :returns: True, if the modal which allows a user to supply acknowledgement notes to an alarm undergoing
            acknowledgement is displayed -False otherwise.
        """
        try:
            return self._notes_modal.find(wait_timeout=3).is_displayed()
        except TimeoutException:
            return False

    def set_acknowledgement_notes(self, notes: str) -> None:
        """
        Set the acknowledgement notes of an alarm undergoing acknowledgement. Requires the Acknowledgement Notes modal
        already be displayed.

        :param notes: The notes to be applied to the alarm.

        :raises TimeoutException: If the Acknowledgement Notes modal is not currently displayed.
        """
        self.wait.until(IAec.function_returns_true(
            custom_function=self._set_acknowledgement_notes,
            function_args={
                'notes': notes
            }
        ))

    def _set_acknowledgement_notes(self, notes: str) -> bool:
        """
        Set the acknowledgement notes of an alarm undergoing acknowledgement. Requires the Acknowledgement Notes modal
        already be displayed.

        :param notes: The notes to be applied to the alarm.

        :returns: True, if successful in identifying the input and sending the text content - False otherwise.
        """
        try:
            text_area = self._notes_modal_text_area.find()
            text_area.clear()
            text_area.send_keys(notes)
            return True
        except StaleElementReferenceException:
            return False

    def click_ack_alarms(self) -> None:
        """
        Click the 'Ack Alarm(s)' button in the bottom right of the Acknowledgement Notes modal.

        :raises TimeoutException: If the 'Ack Alarm(s)' button is not present.
        """
        self._notes_modal_ack_button.click()
        self._alarm_table_footer.acknowledge_button_is_not_displayed()

    def select_all_checkbox_is_enabled(self) -> bool:
        """
        Determine if the "select-all" checkbox of the Alarm Status Table is enabled.

        :returns: True, if the "select-all" checkbox is currently enabled - False otherwise.
        """
        return self._alarm_status_table_header.select_all_checkbox_is_enabled()

    class _AlarmStatusTableToolbar(_AlarmTableToolbar):
        """
        The Toolbar at the top of the Alarm Status Table, unique as it has tabs for ACTIVE and SHELVED alarms.
        """
        _TABS_LOCATOR = (By.CSS_SELECTOR, 'div.toolbarTabContainer>div.tab')
        _ACTIVE_TABS_LOCATOR = (By.CSS_SELECTOR, 'div.toolbarTabContainer>div.tab.active')
        _ALARM_COUNT_LOCATOR = (By.CSS_SELECTOR, 'div.count')

        def __init__(
                self,
                driver: WebDriver,
                parent_locator_list: List[Tuple[By, str]],
                wait_timeout: float = 5,
                poll_freq: float = 0.5):
            super().__init__(
                driver=driver,
                parent_locator_list=parent_locator_list,
                wait_timeout=wait_timeout,
                poll_freq=poll_freq)
            # Non-component locator lists
            self.active_tab = ComponentPiece(
                locator=self._ACTIVE_TABS_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            # Note tabs/alarm_count return multiple components. One for each tab.
            # Without an ID we just have to remember to use the right index
            self.tabs = ComponentPiece(
                locator=self._TABS_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self.alarm_count = ComponentPiece(
                locator=self._ALARM_COUNT_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.tabs.locator_list,
                poll_freq=poll_freq)

        def click_active_alarms_tab(self) -> None:
            """
            Click the ACTIVE tab within the Alarm Status Table. This function will also wait for any transition
            between the tabs to complete.

            :raises AssertionError: If unsuccessful in getting to the ACTIVE tab of the Alarm Status Table.
            """
            self.tabs.find_all()[0].click()
            self.tabs.wait_on_binding(time_to_wait=0.5)
            try:
                self.wait.until(IAec.function_returns_true(
                    custom_function=self._tab_is_active,
                    function_args={
                        'tab': 'ACTIVE'
                    }
                ))
            except TimeoutException:
                raise AssertionError("Failed to display the ACTIVE tab of the Alarm Status Table.")

        def click_shelved_alarms_tab(self, transition_wait_time: float = 0.5) -> None:
            """
            Click the ACTIVE tab within the Alarm Status Table. This function will also wait for any transition
            between the tabs to complete.

            :param transition_wait_time: The amount of time (in seconds) to wait after clicking the SHELVED tab before
                we attempt to verify it has become the active tab in use.

            :raises AssertionError: If unsuccessful in getting to the ACTIVE tab of the Alarm Status Table.
            """
            self.tabs.find_all()[1].click()
            self.tabs.wait_on_binding(time_to_wait=transition_wait_time)
            try:
                self.wait.until(IAec.function_returns_true(
                    custom_function=self._tab_is_active,
                    function_args={
                        'tab': 'SHELVED'
                    }
                ))
            except TimeoutException:
                raise AssertionError("Failed to display the SHELVED tab of the Alarm Status Table.")

        def active_tab_exists(self) -> bool:
            """
            Determine if the ACTIVE tab is present in the Alarm Status Table.

            :returns: True, if the ACTIVE tab is present - False otherwise.
            """
            return 'ACTIVE' in self.tabs.find_all()[0].text

        def shelved_tab_exists(self) -> bool:
            """
            Determine if the SHELVED tab is present in the Alarm Status Table.

            :returns: True, if the SHELVED tab is present - False otherwise.
            """
            return 'SHELVED' in self.tabs.find_all()[1].text

        def get_count_of_shelved_alarms(
                self, expected_count: Optional[int] = None, time_to_wait: float = 3) -> int:
            """
            Obtain a count of shelved alarms.

            :param expected_count: The count of Alarms you expect to be shelved. If supplied, this function will attempt
                to wait until this value is the actual count before returning a value.
            :param time_to_wait: The amount of time (in seconds) you are willing to wait for the count of shelved alarms
                to match your supplied value.

            :returns: The number of Alarms declared as being shelved by the SHELVED tab.

            :raises TimeoutException: If the tabs of the Alarm Status Table are not present.
            :raises IndexError: If the SHELVED tab is not present.
            """
            if expected_count is not None:
                try:
                    WebDriverWait(driver=self.driver, timeout=time_to_wait).until(IAec.function_returns_true(
                        custom_function=self._shelved_alarm_count_matches_expectation,
                        function_args={'expected_count': expected_count}))
                    return expected_count
                except TimeoutException:
                    return self._get_count_of_shelved_alarms()
            else:
                return self._get_count_of_shelved_alarms()

        def _shelved_alarm_count_matches_expectation(self, expected_count: int) -> bool:
            """Determine if the displayed count of shelved alarms matches some expected value."""
            return self._get_count_of_shelved_alarms() == expected_count

        def _tab_is_active(self, tab: str) -> bool:
            """
            Determine if a tab is the current active tab.

            :param tab: The case-insensitive tab to verify as the currently active tab ('active', or 'shelved').

            :returns: True, if the supplied tab is currently the active tab - False otherwise.

            :raises TimeoutException: If the tabs of the Alarm Status Table are not present.
            """
            try:
                return tab.upper() in self.active_tab.find().text
            except StaleElementReferenceException:
                return self._tab_is_active(tab=tab)

        def _get_count_of_shelved_alarms(self) -> int:
            """Obtain a count of Alarms declared as shelved by the SHELVED tab."""
            return int(self.alarm_count.find_all()[1].text)

        def get_count_of_active_alarms(self) -> int:
            """
            Obtain a count of Alarms which match applied filters and which are active.

            :returns: The count of alarms declared by the ACTIVE tab which match all applied PREFILTERS and which are
                active. Text filtering does not affect this count.
            """
            return int(self.alarm_count.find_all()[0].text)

    class _AlarmStatusTableFooter(ComponentPiece):
        """
        The Footer of the Alarm Status Table, where the buttons for acknowledging and/or Shelving alarms reside.
        """
        _ACKNOWLEDGE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button.ackAlarmsButton')
        _UNSHELVE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button.unshelveAlarmsButton')
        _SHELVE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button.shelveAlarmsButton')
        _SHELVE_CONFIRMATION_MESSAGE_LOCATOR = (By.CSS_SELECTOR, "div.text")

        def __init__(
                self,
                driver: WebDriver,
                parent_locator_list: List[Tuple[By, str]],
                wait_timeout: float = 5,
                poll_freq: float = 0.5):
            super().__init__(
                locator=(By.CSS_SELECTOR, "div.alarmTableFoot"),
                driver=driver,
                parent_locator_list=parent_locator_list,
                wait_timeout=wait_timeout,
                poll_freq=poll_freq)
            # Non-component locator lists
            self.acknowledge_button = ComponentPiece(
                locator=self._ACKNOWLEDGE_BUTTON_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self.unshelve_button = ComponentPiece(
                locator=self._UNSHELVE_BUTTON_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self.shelve_button = ComponentPiece(
                locator=self._SHELVE_BUTTON_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._shelve_time_coll = {}
            self.shelve_confirmation_message = ComponentPiece(
                locator=self._SHELVE_CONFIRMATION_MESSAGE_LOCATOR,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)

        def acknowledge_button_is_displayed(self) -> bool:
            """
            Determine if the Acknowledge button is displayed in the Footer of the Alarm Status Table. This function
            will wait a short period of time until the 'Acknowledge' button is displayed before returning.

            :returns: True, if the 'Acknowledge' button is displayed - False otherwise.
            """
            try:
                self.wait.until(IAec.function_returns_true(
                    custom_function=self._button_displayed,
                    function_args={
                        "local_element_object": self.acknowledge_button
                    }
                ))
                return True
            except TimeoutException:
                return False

        def acknowledge_button_is_not_displayed(self) -> bool:
            """
            Determine if the Acknowledge button is NOT displayed in the Footer of the Alarm Status Table. This function
            will wait a short period of time until the 'Acknowledge' button is no longer displayed before returning.

            :returns: True, if the 'Acknowledge' button is NOT displayed - False otherwise.
            """
            try:
                self.wait.until(IAec.function_returns_false(
                    custom_function=self._button_displayed,
                    function_args={
                        "local_element_object": self.acknowledge_button
                    }
                ))
                return True
            except TimeoutException:
                return False

        @staticmethod
        def _button_displayed(local_element_object: ComponentPiece) -> bool:
            """
            Determine the display state of a button (ComponentPiece).

            :param local_element_object: The ComponentPiece which defines the button to check.

            :returns: True, if the supplied ComponentPiece is displayed - False otherwise.
            """
            try:
                return local_element_object.find().is_displayed()
            except TimeoutException:
                return False

        def acknowledge_button_is_enabled(self) -> bool:
            """
            Determine if the 'Acknowledge' button is enabled.

            :returns: True, if the 'Acknowledge' button is currently enabled - False otherwise.

            :raises TimeoutException: If the 'Acknowledge' button is not present in the Footer of the Alarm Status
                Table.
            """
            return self.acknowledge_button.find().is_enabled()

        def click_acknowledge_button_and_report_success_of_click(self) -> bool:
            """
            Click the Acknowledge button and report back the success of the click.

            :returns: True, if the 'Acknowledge' button was successfully clicked - False otherwise.
            """
            if self.acknowledge_button_is_displayed():
                self.acknowledge_button.find().click()
                return True
            else:
                return False

        def click_shelve_button(self) -> None:
            """
            Click the 'Shelve' button in the Footer of the Alarm Status Table.

            :raises TimeoutException: If the 'Shelve' button is not present, or if unsuccessful in clicking the button.
            """
            self.wait.until(IAec.function_returns_true(
                custom_function=self._click_shelve_button_and_report_success_of_click,
                function_args={}
            ))

        def click_shelve_time(self, shelve_time: str) -> None:
            """
            Select an amount of time to shelve an alarm. Requires the 'Shelve' button have already been clicked.

            :param shelve_time: The option available within the dropdown opened on click of the 'Shelve' button which
                describes how long you would like to shelve the Alarm for.

            :raises TimeoutException: If the supplied shelve time is not present, or if the dropdown containing shelve
                time options is not present. This is most likely to occur if the 'Shelve' button was not clicked
                beforehand.
            """
            self._get_shelve_time(shelve_time=shelve_time).click()

        def click_unshelve_button(self) -> None:
            """
            Click the 'Unshelve' button available in the Footer of the Alarm Status Table while interacting with alarms
            in the 'Shelved' tab.

            :raises TimeoutException: If the 'Unshelve' button is not present.
            """
            self.wait_on_binding(time_to_wait=1)  # wait on the animation of the button
            self.wait.until(IAec.function_returns_true(
                custom_function=self._click_unshelve_button_and_report_success_of_click,
                function_args={}
            ))

        def get_shelved_alarm_confirmation_message(self, expected_shelve_message: str) -> Optional[str]:
            """
            Obtain the message displayed as a confirmation that an alarm has been shelved. Requires that an Alarm have
            been shelved immediately before invocation.

            :param expected_shelve_message: The message you expect to be displayed as a confirmation. This function will
                wait some period of time or until the expected message is displayed before returning the displayed
                message.

            :returns: The confirmation message displayed when a user shelves an alarm, or None. A None value indicates
                the message never took on the expected text.
            """
            try:
                self.wait.until(
                    IAec.function_returns_true(
                        custom_function=self._alarm_confirmation_message_has_expected_text,
                        function_args={
                            "expected_shelve_message": expected_shelve_message
                        }
                    ))
                return expected_shelve_message  # don't re-query, just return the matched text
            except TimeoutException:
                # comparison never became an exact match
                return None

        def _alarm_confirmation_message_has_expected_text(self, expected_shelve_message: str) -> bool:
            """
            Determine if the displayed confirmation message is an exact match to the supplied expected text. We are
            unable to return the exact text because the displayed text can at times be an empty string.

            :param expected_shelve_message: The message you expect to be displayed as the confirmation message.

            :returns: True, if the supplied expected text is an exact match to what is displayed - False if there is a
                discrepancy, or if the confirmation message has not yet appeared.
            """
            try:
                return self.shelve_confirmation_message.find(wait_timeout=0).text == str(expected_shelve_message)
            except TimeoutException:
                return False

        def shelve_time_is_displayed(self, shelve_time: str) -> bool:
            """
            Determine if the available periods to shelve an alarm are displayed.

            :param shelve_time: We will verify the presence of this string option.

            :returns: True, if the periods of time to shelve an Alarm are displayed - False otherwise.
            """
            try:
                return self._get_shelve_time(shelve_time=shelve_time).find().is_displayed()
            except TimeoutException:
                return False

        def _click_shelve_button_and_report_success_of_click(self) -> bool:
            """
            Wait for the 'Shelve' button to become clickable before then clicking the button.

            :returns: True, if the click was successful - False otherwise.
            """
            try:
                self.shelve_button.find(wait_timeout=0.5).click()
                return True
            except ElementNotInteractableException:
                return False

        def _click_unshelve_button_and_report_success_of_click(self) -> bool:
            """
            Wait for the 'Unshelve' button to become clickable before then clicking the button.

            :returns: True, if the click was successful - False otherwise.
            """
            try:
                self.unshelve_button.find(wait_timeout=0.5).click()
                return True
            except ElementNotInteractableException:
                return False
            except TimeoutException:
                return False

        def _get_shelve_time(self, shelve_time: str) -> ComponentPiece:
            """
            Obtain the ComponentPiece which defines an available period of time to shelve an Alarm.

            :param shelve_time: The text of the shelve time option this Component Piece will define.

            :returns: A ComponentPiece which may be used to click a period of time to shelve an alarm.
            """
            shelve_time = str(shelve_time)
            item = self._shelve_time_coll.get(shelve_time)
            if not item:
                item = _AlarmTableMenuItem(driver=self.driver, text=shelve_time)
                self._shelve_time_coll[shelve_time] = item
            return item

    class _AlarmStatusTableBody(_AlarmTableBody):
        """
        The Body of the Alarm Status Table, unique in that the Alarm Status Table actually has a column designated to
        allow for selection of rows.
        """

        def __init__(
                self,
                driver: WebDriver,
                parent_locator_list: List[Tuple[By, str]],
                wait_timeout: int = 5,
                poll_freq: float = 0.5):
            super().__init__(
                driver=driver,
                parent_locator_list=parent_locator_list,
                wait_timeout=wait_timeout,
                poll_freq=poll_freq)
            self._table_cells = ComponentPiece(
                locator=(By.CSS_SELECTOR, 'div.tc.ia_table__cell'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=poll_freq)
            self._cell_by_columns_objects_dict = {}

        def set_row_selection_state_for_first_matching_row(
                self, known_value: str, column_for_value: AlarmTableColumn, should_be_selected: bool) -> None:
            """
            Set the selection state for a row which has a known value in a known column.

            :param known_value: The value known to be in the target row, within the specified column.
            :param column_for_value: The Alarm Table Column known to contain the supplied known value.
            :param should_be_selected: Dictates the selection state to be applied to the row described by the supplied
                value and column.

            :raises TimeoutException: If selection is disabled for the Alarm Status Table, or if no row contains the
                known value in the specified column, or if the specified colum is not present.
            """
            # Shouldn't have to worry about SERE here because there are no interactions
            for cell in self._get_cell_object_by_column_object(
                    alarm_table_column=column_for_value).find_all():
                if cell.text == known_value:
                    self.wait.until(IAec.function_returns_true(
                        custom_function=self._set_selection_state_for_rows,
                        function_args={
                            'indices': [int(cell.get_attribute(self._ROW_INDEX_ATTRIBUTE))],
                            'should_be_selected': should_be_selected
                        }
                    ))
                    break

        def set_row_selection_state_for_all_matching_rows(
                self, known_value: str, column_for_value: AlarmTableColumn, should_be_selected: bool) -> None:
            """
            Set the selection state for all rows which have a known value in a known column.

            :param known_value: The value known to be in the target rows, within the specified column.
            :param column_for_value: The Alarm Table Column known to contain the supplied known value.
            :param should_be_selected: Dictates the selection state to be applied to the rows described by the supplied
                value and column.

            :raises TimeoutException: If unsuccessful in applying the requested selection state to all supplied indices.
            """
            indices = []
            for cell in self._get_cell_object_by_column_object(
                    alarm_table_column=column_for_value).find_all():
                if cell.text == known_value:
                    indices.append(cell.get_attribute(self._ROW_INDEX_ATTRIBUTE))
            self.wait.until(IAec.function_returns_true(
                custom_function=self._set_selection_state_for_rows,
                function_args={
                    'indices': indices,
                    'should_be_selected': should_be_selected
                }
            ))

        def _set_selection_state_for_rows(self, indices: List[int], should_be_selected: bool) -> bool:
            """
            Set the uniform selection state for multiple rows based on their indices.

            :param indices: A list of zero-based indices which will have their selection state set.
            :param should_be_selected: Specifies the active state the checkbox for each row should reflect.

            :returns: True, if any click event occurs for any of the supplied rows - False if no action is taken.
            """
            try:
                did_click = False
                for row in self._rows.find_all():
                    if int(row.get_attribute(self._ROW_INDEX_ATTRIBUTE)) in indices:
                        checkbox_svg_element = row.find_element(*self._CHECKBOX_LOCATOR)
                        if (self._CHECKBOX_CHECKED in checkbox_svg_element.get_attribute('class')) \
                                != should_be_selected:
                            # Click the row instead of the checkbox because of the dock handler
                            IASelenium(self.driver).scroll_to_element(row, align_to_top=False).click()
                            did_click = True
                return not did_click
            except StaleElementReferenceException:
                return False

        def _get_cell_object_by_column_object(self, alarm_table_column: AlarmTableColumn) -> ComponentPiece:
            """
            Obtain a ComponentPiece which defines all cells of the DOM which reside within the supplied Alarm Table
            Column.

            :param alarm_table_column: The Alarm Table Column within which the desired cell resides.

            :returns: A ComponentPiece which defines all cells within the specified column.
            """
            column_id = alarm_table_column.data_column_id
            cell_object = self._cell_by_columns_objects_dict.get(column_id)
            if not cell_object:
                cell_object = ComponentPiece(
                    locator=(By.CSS_SELECTOR, f'div.tc[data-column-id="{column_id}"]'),
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    poll_freq=self.poll_freq)
                self._cell_by_columns_objects_dict[column_id] = cell_object
            return cell_object

        def get_count_of_selected_rows(self) -> int:
            """
            Obtain a count of all rows which are currently selected in the Alarm Status Table.

            :returns: A count which represents the number of currently selected rows in the Alarm Status Table.
            """
            try:
                return len(self._selected_rows.find_all(wait_timeout=0.5))
            except TimeoutException:
                return 0

        # Until IGN-5222 is completed, we will have to assume the table is unchanged between known/desired queries
        def get_alarm_column_data_with_known_value(
                self,
                desired_column: AlarmTableColumn,
                desired_column_value_list: Optional[List[str]],
                known_column: AlarmTableColumn,
                known_column_value: str) -> List[dict]:
            """
            Obtain information about a desired column for all rows which contain a known value in a known column.

            :param desired_column: The Alarm Table Column you want information from.
            :param desired_column_value_list: Potential values you want to filter for within the desired column.
            :param known_column: The Alarm Table Column you know contains text which will be used to target rows of the
                Alarm Status Table.
            :param known_column_value: The value known to exist in a column. This value is what determines which rows
                will be considered matches.
            """
            local_known_cells_object = self._get_cell_object_by_column_object(known_column)
            local_desired_cells_object = self._get_cell_object_by_column_object(desired_column)
            values = []
            try:
                known_cell_elements = local_known_cells_object.find_all(wait_timeout=3)
            except TimeoutException:
                known_cell_elements = []
            try:
                desired_cell_elements = local_desired_cells_object.find_all(wait_timeout=3)
            except TimeoutException:
                desired_cell_elements = []
            for index in range(len(known_cell_elements)):
                if known_cell_elements[index].text == known_column_value and \
                        (desired_column_value_list is None or
                         desired_cell_elements[index].text in desired_column_value_list):
                    values.append(
                        {
                            known_column.data_column_id: known_cell_elements[index].text,
                            desired_column.data_column_id: desired_cell_elements[index].text,
                            self._ROW_INDEX_ATTRIBUTE: desired_cell_elements[index].get_attribute(
                                self._ROW_INDEX_ATTRIBUTE)
                        })
            return values

    class _AlarmStatusTableHeader(_AlarmTableHeader):
        """
        The header of the Alarm Status Table, unique in that it contains a checkbox to drive the selection state of all
        displayed rows.
        """
        _SELECT_ALL_CHECKBOX_LOCATOR = (By.CSS_SELECTOR, 'div.thc[data-column-id="select"]')

        def __init__(self, driver: WebDriver, parent_locator_list: List[Tuple[By, str]], poll_freq: float = 0.5):
            super().__init__(
                driver=driver,
                parent_locator_list=parent_locator_list,
                wait_timeout=1,
                poll_freq=poll_freq)
            self._select_all_checkbox = CommonCheckbox(
                locator=self._SELECT_ALL_CHECKBOX_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                wait_timeout=1,
                poll_freq=poll_freq)

        def get_select_all_checkbox_state(self) -> Optional[bool]:
            """
            Obtain the state of the "select-all" checkbox.

            :returns: True, if checked. False, if un-checked. None, if some number of rows less than the total number
                of rows on the page is currently selected.

            :raises TimeoutException: If the checkbox which drives the selection of all rows is not present.
            """
            return self._select_all_checkbox.get_checkbox_state()

        def get_sort_order_number_of_column(self, column: AlarmTableColumn) -> Optional[int]:
            """
            Obtain the numeric sort order of a given Alarm table Column.

            :param column: The Alarm Table Column from which you would like the sort order.

            :returns: The sort order of the Alarm Table Column as a number, or None if no sorting is applied.

            :raises TimeoutException: If the specified Alarm Table Column is not present.
            """
            return super().get_sort_order_number_of_column(column_id=column.data_column_id)

        def set_select_all_checkbox_state(self, should_be_checked: bool) -> None:
            """
            Set the state of the "select-all" checkbox.

            :param should_be_checked: If True, the checkbox will be set to checked. If False, the checkbox will be set
                to an un-checked state.

            :raises TimeoutException: If the checkbox which drives the selection of all rows is not present.
            :raises AssertionError: If unsuccessful in setting the state of the "select-all" checkbox.
            """
            self._select_all_checkbox.scroll_to_element(align_to_top=True)
            self._select_all_checkbox.set_state(should_be_checked=should_be_checked, binding_wait_time=0.5)
            IAAssert.is_equal_to(
                actual_value=self.get_select_all_checkbox_state(),
                expected_value=should_be_checked,
                failure_msg="Failed to set the state of the 'select-all' checkbox of the Alarm Status Table.")

        def select_all_checkbox_is_enabled(self) -> bool:
            """
            Determine if the "select-all" checkbox of the Alarm Status Table is enabled.

            :returns: True, if the "select-all" checkbox is currently enabled - False otherwise.
            """
            return self._select_all_checkbox.is_enabled()

        def descending_sort_is_active(self, column: AlarmTableColumn) -> Optional[bool]:
            """
            Determine if the sort applied to a given Alarm Table Column is in descending order.

            :param column: The Alarm Table Column which will have it sort order checked.

            :returns: True, if the sort applied to the supplied Alarm Table Column is in descending order. False
                if the order is ascending. None if no sorting is applied to the Alarm Table Column.

            :raises TimeoutException: If the specified column has no sorting applied, or if the column is not present.
            """
            return super().descending_sort_is_active(column_id=column.data_column_id)

Components/PerspectiveComponents/Displays/AlarmTable.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class Audio(BasicPerspectiveComponent):
    """A Perspective Audio Component."""
    _AUDIO_LOCATOR = (By.TAG_NAME, "audio")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            description=description,
            poll_freq=poll_freq)
        self._audio = ComponentPiece(
            locator=self._AUDIO_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def get_source(self) -> str:
        """
        Obtain the path of the file currently in use by the Audio Component.

        :returns: The path of the file currently in use by the component, as a slash-delimited string.

        :raises TimeoutException: If unable to locate the internal <audio> element.
        """
        try:
            return self._audio.find().get_attribute("src")
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the inner <audio> piece of the Audio Component.") from toe

    def is_displayed(self) -> bool:
        """
        Determine if the Audio Component is currently displayed. The Audio Component has a complicated set of display
        logic, and so even if the Audio Component is displayed, it may not be visible. Double-check expected states
        by using the :func:`is_visible` function.

        :returns: True, if the component is currently displayed - False otherwise.

        :raises TimeoutException: If unable to locate the internal <audio> element.
        """
        try:
            return self._audio.find().is_displayed()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the inner <audio> piece of the Audio Component.") from toe

    def is_visible(self) -> bool:
        """
        Determine if the Audio Component is currently visible. The Audio Component has a complicated set of display
        logic, and so even if the Audio Component is visible, it may not be displayed. Double-check expected states
        by using the :func:`is_displayed` function.

        :returns: True, if the component is currently visible - False otherwise.

        :raises TimeoutException: If unable to locate the Audio Component.
        """
        try:
            return self.get_css_property(property_name="visibility") != "hidden"
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the Audio Component as defined by its locator.") from toe

Components/PerspectiveComponents/Displays/Audio.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class Barcode(BasicPerspectiveComponent):
    """A Perspective Barcode Component."""
    _INNER_WRAPPER_LOCATOR = (By.CSS_SELECTOR, 'div.barcode')
    _VALUE_LOCATOR = (By.CSS_SELECTOR, 'div.barcode-value')
    _CANVAS_LOCATOR = (By.CSS_SELECTOR, 'canvas')
    _ERROR_STATE_LOCATOR = (By.CSS_SELECTOR, 'div.barcode-error')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._barcode_value = ComponentPiece(
            locator=self._VALUE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._barcode_wrapper = ComponentPiece(
            locator=self._INNER_WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._barcode_canvas = ComponentPiece(
            locator=self._CANVAS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._barcode_error_state = ComponentPiece(
            locator=self._ERROR_STATE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def barcode_is_displaying_value(self) -> bool:
        """
        Determine if the Barcode Component is currently displaying a value.

        :returns: True, if the Barcode Component is currently displaying any value - False otherwise.
        """
        try:
            return self._barcode_value.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def get_barcode_value(self) -> str:
        """
        Obtain the displayed text of the Barcode Component.

        :returns: The text currently displayed by the Barcode component.

        :raises TimeoutException: If no value is displayed.
        """
        try:
            return self._barcode_value.get_text()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the value container of the Barcode Component.") from toe

    def is_in_error_state(self) -> bool:
        """
        Determine if the Barcode Component is currently in an error state.

        :returns: True, if the Barcode Component is currently in an error state - False otherwise.
        """
        try:
            return self._barcode_error_state.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def get_error_message(self) -> str:
        """
        Obtain the error message displayed in the Barcode Component.

        :returns: The displayed text of the error message currently displayed in the Barcode Component.
        """
        try:
            return self._barcode_error_state.get_text()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the error state of the Barcode Component.") from toe

    def value_displayed_below(self) -> bool:
        """
        Determine if the value of the Barcode Component is currently displayed below the canvas (visual barcode).

        :returns: True, if the text is below the barcode.

        :raises TimeoutException: If unable to locate the value or canvas.
        """
        try:
            return self._barcode_value.get_origin().Y > self._barcode_canvas.get_origin().Y
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate either the value container or the barcode canvas.") from toe

    def get_barcode_as_data_url(self) -> str:
        """
        Obtain the shape of the Barcode as a string. Useful only for validating differences in values.

        :returns: A gibberish string which represents the shape of the displayed barcode as a string.
        """
        try:
            return self.driver.execute_script('return arguments[0].toDataURL()', self._barcode_canvas.find())
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the canvas of the barcode Component.") from toe

Components/PerspectiveComponents/Displays/Barcode.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS, CSSPropertyValue


class CylindricalTank(BasicPerspectiveComponent):
    """A Perspective Cylindrical Tank Component."""
    _TANK_LOCATOR = (By.CSS_SELECTOR, 'path.ia_cylindricalTankComponent__tank')
    _LIQUID_LOCATOR = (By.CSS_SELECTOR, 'path.ia_cylindricalTankComponent__liquid')
    _VALUE_DISPLAY_LOCATOR = (By.CSS_SELECTOR, 'text.ia_cylindricalTankComponent__valueDisplay')
    _POTENTIAL_SYMBOLS_TO_OMIT_FROM_NUMERIC_VALUES = ["$", "%"]

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._tank = ComponentPiece(locator=self._TANK_LOCATOR, driver=driver, wait_timeout=0, poll_freq=poll_freq)
        self._tank_liquid = ComponentPiece(
            locator=self._LIQUID_LOCATOR,
            driver=driver,
            wait_timeout=0,
            poll_freq=poll_freq)
        self._tank_value = ComponentPiece(
            locator=self._VALUE_DISPLAY_LOCATOR,
            driver=driver,
            wait_timeout=0,
            poll_freq=poll_freq)

    def get_liquid_property(self, property_name: CSSPropertyValue) -> str:
        """
        Obtain the value of some CSS property from the liquid within the Cylindrical Tank.

        :param property_name: The CSS property from which you would like the value.

        :returns: The value of the supplied CSS property as is in use by the liquid of the Cylindrical Tank.

        :raises TimeoutException: If the liquid of the Cylindrical Tank is not present.
        """
        try:
            return self._tank_liquid.get_css_property(property_name=property_name)
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the liquid of the Cylindrical Tank") from toe

    def get_tank_color(self) -> str:
        """
        Obtain the color in use for the actual tank portion of the Cylindrical Tank.

        :returns: The color in use for the container portion of the Cylindrical Tank as a string. Note that different
            browsers may return this value in different formats (RGB vs hex).

        :raises TimeoutException: If the tank of the Cylindrical Tank is not present.
        """
        try:
            return self._tank.get_css_property(property_name=CSS.FILL)
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the tank portion of the Cylindrical Tank") from toe

    def get_text(self) -> str:
        """
        Obtain the displayed text of the Cylindrical Tank, including any displayed units.

        :returns: The displayed value and units of the Cylindrical Tank, as a string.
        """
        return self._tank_value.get_text()

    def value_is_displayed(self) -> bool:
        """
        Determine if the Cylindrical Tank is displaying a value and/or units.

        :returns True, if the Cylindrical Tank is currently displaying a value and/or units - False otherwise.
        """
        try:
            return self._tank_value.find() is not None
        except TimeoutException:
            return False

Components/PerspectiveComponents/Displays/CylindricalTank.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.Common.Button import CommonButton
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Helpers.IAAssert import IAAssert
from Helpers.IASelenium import IASelenium
from Helpers.Point import Point
from Pages.Perspective.Widget import Widget


class Dashboard(BasicPerspectiveComponent):
    """
    A Perspective Dashboard Component.

    The Dashboard requires specialized information about its internal widgets in order to perform some interactions.
    """
    _GRID_LOCATOR = (By.CSS_SELECTOR, 'div.gridCommon__grid')
    _GRID_CELL_LOCATOR = (By.CSS_SELECTOR, 'div.grid-cell.dashboard-cell')
    _EDIT_TOGGLE_LOCATOR = (By.CSS_SELECTOR, 'div.dashboard-run-edit-toggle')
    _ADD_OR_REMOVE_CANCEL_LOCATOR = (By.CSS_SELECTOR, f'button.{CommonButton.SECONDARY_CLASS}')
    _ADD_OR_REMOVE_CONFIRM_LOCATOR = (By.CSS_SELECTOR, f'button.{CommonButton.PRIMARY_CLASS}')
    _REMOVE_WIDGET_MODAL_LOCATOR = (By.CSS_SELECTOR, 'div.removeWidgetModalContent')
    _ADD_WIDGET_MODAL_LOCATOR = (By.CSS_SELECTOR, 'div.addWidgetsModalContent')
    _ADD_WIDGET_MODAL_CATEGORY_LOCATOR = (By.CSS_SELECTOR, 'div.addWidgetCategory')  # TitleBar
    _ADD_WIDGET_MODAL_TITLE_LOCATOR = (By.CSS_SELECTOR, 'div.widgetMenuItemTitle')
    _WIDGET_LOCATOR = (By.CSS_SELECTOR, 'div.dashboard-widget')
    _DELETE_LOCATOR = (By.CSS_SELECTOR, 'div.widget-control-delete')
    _EAST_RESIZE_HANDLE_LOCATOR = (By.CSS_SELECTOR, 'div.widget-resize-handle.east div')
    _NORTH_RESIZE_HANDLE_LOCATOR = (By.CSS_SELECTOR, 'div.widget-resize-handle.north div')
    _SOUTH_RESIZE_HANDLE_LOCATOR = (By.CSS_SELECTOR, 'div.widget-resize-handle.south div')
    _WEST_RESIZE_HANDLE_LOCATOR = (By.CSS_SELECTOR, 'div.widget-resize-handle.west div')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._grid = ComponentPiece(
            locator=self._GRID_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._grid_cell = ComponentPiece(
            locator=self._GRID_CELL_LOCATOR,
            driver=driver,
            parent_locator_list=self._grid.locator_list,
            poll_freq=poll_freq)
        self._edit_toggle = ComponentPiece(
            locator=self._EDIT_TOGGLE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._shared_modal = ComponentModal(driver=driver, wait_timeout=2, poll_freq=poll_freq)
        self._add_remove_cancel_button = CommonButton(
            locator=self._ADD_OR_REMOVE_CANCEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._shared_modal.locator_list,
            poll_freq=poll_freq)
        self._add_remove_confirm_button = CommonButton(
            locator=self._ADD_OR_REMOVE_CONFIRM_LOCATOR,
            driver=driver,
            parent_locator_list=self._shared_modal.locator_list,
            poll_freq=poll_freq)
        self._remove_widget_modal = ComponentPiece(
            locator=self._REMOVE_WIDGET_MODAL_LOCATOR,
            driver=driver,
            parent_locator_list=self._shared_modal.locator_list,
            wait_timeout=2,
            poll_freq=poll_freq)
        self._add_widget_modal = ComponentPiece(
            locator=self._ADD_WIDGET_MODAL_LOCATOR,
            driver=driver,
            parent_locator_list=self._shared_modal.locator_list,
            wait_timeout=2,
            poll_freq=poll_freq)
        self._add_widget_categories = ComponentPiece(
            locator=self._ADD_WIDGET_MODAL_CATEGORY_LOCATOR,
            driver=driver,
            parent_locator_list=self._add_widget_modal.locator_list,
            wait_timeout=2,
            poll_freq=poll_freq)
        self._add_widget_modal_title = ComponentPiece(
            locator=self._ADD_WIDGET_MODAL_TITLE_LOCATOR,
            driver=driver,
            parent_locator_list=self._add_widget_modal.locator_list,
            poll_freq=poll_freq)
        self._widget = ComponentPiece(
            locator=self._WIDGET_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=2,
            poll_freq=poll_freq)
        self._delete_widget = ComponentPiece(
            locator=self._DELETE_LOCATOR,
            driver=driver,
            parent_locator_list=self._widget.locator_list,
            poll_freq=poll_freq)
        self._grid_cell_collection = {}
        self._east_resize_handle = ComponentPiece(
            locator=self._EAST_RESIZE_HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._north_resize_handle = ComponentPiece(
            locator=self._NORTH_RESIZE_HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._south_resize_handle = ComponentPiece(
            locator=self._SOUTH_RESIZE_HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._west_resize_handle = ComponentPiece(
            locator=self._WEST_RESIZE_HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def add_button_is_enabled(self) -> bool:
        """
        Determine if the Add button in the "Add Widget" modal is enabled.

        :returns: True, if the "add" button within the "Add Widget" modal is enabled - False otherwise.

        :raises TimeoutException: If the "Add Widget" modal is not present.
        """
        try:
            return self._add_remove_confirm_button.find().is_enabled()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the 'Add' button in the Add Widget modal.") from toe

    def add_widget_modal_is_displayed(self) -> bool:
        """
        Determine if the 'Add Widget' modal is currently displayed.

        :returns: True, if the Add Widget modal is displayed - False otherwise.
        """
        try:
            return self._add_widget_modal.find().is_displayed()
        except TimeoutException:
            return False

    def any_modal_is_displayed(self) -> bool:
        """
        Determine if ANY modal is displayed within the Dashboard.

        :returns: True, if any modal is currently visible within the Dashboard.
        """
        try:
            return self._shared_modal.find() is not None
        except TimeoutException:
            return False

    def cancel_modal(self) -> None:
        """
        Close any displayed modal by clicking the "Cancel" button.

        :raises TimeoutException: If the "Cancel" button is not present.
        """
        try:
            self._add_remove_cancel_button.click()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the 'Cancel' button in the modal.") from toe

    def category_is_expanded(self, category: str) -> bool:
        """
        Determine if a category within the "Add Widget" modal is expanded.

        :param category: The name of the category to check for expansion.

        :returns: True, if the supplied category is expanded - False otherwise.

        :raises IndexError: If no category is present with the supplied case-sensitive name.
        :raises TimeoutException: If no categories are found at all.
        """
        clazz = self._get_category(category=category).find_element(*(By.TAG_NAME, "div")).get_attribute("class")
        return "expanded" in clazz

    def click_add_button(self) -> None:
        """
        Click the "Add" button within the "Add Widget" modal.

        :raises TimeoutException: If the "Add Widget" modal is not present.
        """
        try:
            self._add_remove_confirm_button.click()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate the 'Add' button in the Add Widget modal.") from toe

    def click_edit_toggle(self) -> None:
        """
        Click the edit toggle, regardless of current state.

        :raises TimeoutException: If the edit toggle is not present.
        """
        try:
            self.hover()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate a Dashboard with the defined locator.") from toe
        try:
            self._edit_toggle.hover()
            self._edit_toggle.wait_on_binding(time_to_wait=1)
            self._edit_toggle.click(binding_wait_time=1)
        except TimeoutException as toe:
            raise TimeoutException(
                msg="The edit toggle of the Dashboard was not present. Make sure the toggle is turned on "
                    "in props.") from toe

    def click_grid_cell(self, one_based_column_index: int, one_based_row_index: int) -> None:
        """
        Click a cell within the grid of the Dashboard during editing.

        :param one_based_column_index: The one-based index of the column to target.
        :param one_based_row_index: The one-based index of the row to target.

        :raises TimeoutException: If the supplied indices do not define a cell which is present.
        """
        # firefox reasons
        try:
            IASelenium(driver=self.driver).simple_click(
                web_element=self._get_grid_cell(
                    one_based_column_index=one_based_column_index, one_based_row_index=one_based_row_index).find())
        except TimeoutException as toe:
            raise TimeoutException(
                msg="Unable to locate a grid cell with the supplied indices in the Dashboard.") from toe

    def click_widget_name(self, widget_name: str) -> None:
        """
        Click a widget in the "Add Widget" modal.

        :param widget_name: The case-sensitive name of the widget to click.

        :raises IndexError: If no widget exists with the supplied name.
        :raises TimeoutException: If the "Add Widget" modal is not present.
        """
        try:
            list(filter(lambda e: e.text == widget_name, self._add_widget_modal_title.find_all()))[0].click()
        except IndexError as ie:
            raise IndexError(f"No widget found with a name of '{widget_name}'.") from ie
        except TimeoutException as toe:
            raise TimeoutException("No widgets found at all in the Add Widget modal.") from toe

    def collapse_category(self, category: str) -> None:
        """
        Collapse a category within the "Add Widget" modal.

        :param category: The case-sensitive name of the category to collapse.

        :raises IndexError: If no category exists with the supplied name.
        :raises TimeoutException: If no categories exist at all.
        """
        if self.category_is_expanded(category=category):
            self._get_category(category=category).click()
        IAAssert.is_not_true(
            value=self.category_is_expanded(category=category),
            failure_msg=f"Failed to collapse the '{category}' category.")

    def confirm_remove_widget(self, binding_wait_time: int = 1) -> None:
        """
        Click the "Confirm" button visible while attempting to remove a widget from the Dashboard.

        :param binding_wait_time: How long to wait (in seconds) after clicking before allowing code to continue.

        :raises TimeoutException: If the "Confirm" button is not present.
        """
        try:
            self._add_remove_confirm_button.click(binding_wait_time=binding_wait_time)
        except TimeoutException as toe:
            raise TimeoutException(
                msg="Unable to locate the 'Remove' button in the confirmation modal while deleting a widget.") from toe

    def dashboard_is_in_editing_mode(self) -> bool:
        """
        Determine if the Dashboard is currently in "editing" mode.

        :returns: True, while a user may edit widget layout - False otherwise.
        """
        try:
            return "isEditing" in self.find().get_attribute("class")
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate a Dashboard component with the supplied locator.") from toe

    def delete_active_widget(self) -> None:
        """
        Go through the deletion process for the currently selected widget.

        :raises TimeoutException: If the Dashboard is not in editing mode, or if no widget is selected/active.
        """
        self._delete_active_widget()

    def delete_all_widgets(self) -> None:
        """
        Delete all widgets currently placed in the Dashboard.

        :raises AssertionError: If unsuccessful in deleting all widgets.
        :raises TimeoutException: If no widgets are currently placed, or if not in editing mode.
        """
        while self.get_count_of_widgets() > 0:
            self._widget.click()  # get first - which one does not matter
            self._delete_active_widget()
        IAAssert.is_equal_to(
            actual_value=self.get_count_of_widgets(),
            expected_value=0,
            failure_msg="Failed to delete all widgets.")

    def delete_widget(self, widget: Widget) -> None:
        """
        Delete a specific widget.

        :param widget: The Widget object which contains information regarding the structure of your widget.

        :raises AssertionError: If unsuccessful in removing the supplied widget.
        :raises TimeoutException: If the supplied widget is not present, or if the Dashboard is not in editing mode.
        """
        widget.select_during_edit_mode()
        self._delete_active_widget()
        IAAssert.is_not_true(
            value=widget.is_currently_placed_on_dashboard(),
            failure_msg=f"Failed to remove the '{widget.name}' widget.")

    def drag_widget_to_column_row_and_release(
            self,
            one_based_column_index: int,
            one_based_row_index: int,
            widget: Widget) -> None:
        """
        Click within a widget and drag it to a specified cell, before then releasing the click.

        :param one_based_column_index: The one-based index of the column we will drag the widget to.
        :param one_based_row_index: The one-based index of the row we will drag the widget to.
        :param widget: The Widget object which contains information about the widget we will be dragging.

        :raises TimeoutException: If the Widget is not present, or if the Dashboard is not in editing mode, or if the
            supplied cell indices are not valid.
        """
        cell_x_offset = int(float(self.get_cell_width())) // 2
        cell_y_offset = int(float(self.get_cell_height())) // 2
        # click-and-hold the widget in the center of the 1st (upper-left) cell, move the mouse to the center of the
        # supplied cell, and then release the held click.
        ActionChains(
            driver=self.driver) \
            .move_to_element_with_offset(
            to_element=widget.get_widget(),
            xoffset=cell_x_offset,
            yoffset=cell_y_offset) \
            .click_and_hold() \
            .move_to_element_with_offset(
            to_element=self._get_grid_cell(
                one_based_column_index,
                one_based_row_index).find(),
            xoffset=int(float(self.get_cell_width())),
            yoffset=int(float(self.get_cell_height()))) \
            .release() \
            .perform()

    def enter_editing_mode(self) -> None:
        """
        Click the mode toggle of the Dashboard if not already in editing mode.

        :raises TimeoutException: If the editing toggle is not present.
        """
        if not self.dashboard_is_in_editing_mode():
            self.click_edit_toggle()
        IAAssert.is_true(
            value=self.dashboard_is_in_editing_mode(),
            failure_msg="Failed to enter editing mode.")

    def enter_run_mode(self) -> None:
        """
        Click the mode toggle of the Dashboard if in editing mode.

        :raises TimeoutException: If the editing toggle is not present.
        """
        if self.dashboard_is_in_editing_mode():
            self.click_edit_toggle()
        IAAssert.is_not_true(
            value=self.dashboard_is_in_editing_mode(),
            failure_msg="Failed to enter run mode.")

    def expand_category(self, category: str) -> None:
        """
        Expand a category in the "Add Widget" modal.

        :param category: The case-sensitive name of the category to expand.

        :raises AssertionError: If unsuccessful in expanding the supplied category.
        :raises IndexError: If the supplied category does not exist.
        """
        if not self.category_is_expanded(category=category):
            self._get_category(category=category).click()
        IAAssert.is_true(
            value=self.category_is_expanded(category=category),
            failure_msg=f"Failed to expand the '{category}' category.")

    def get_cell_height(self, include_units: bool = False) -> str:
        """
        Obtain the height of the cells within the grid of the Dashboard.

        :param include_units: Dictates whether the returned value will include units of measurement.

        :returns: The height of the cells of the Dashboard grid, as a string.
        """
        try:
            return self._grid_cell.get_computed_height(include_units=include_units)
        except TimeoutException as toe:
            raise TimeoutException("Unable to locate the grid within the Dashboard.") from toe

    def get_cell_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of the cells within the grid of the Dashboard.

        :param include_units: Dictates whether the returned value will include units of measurement.

        :returns: The width of the cells of the Dashboard grid, as a string.
        """
        try:
            return self._grid_cell.get_computed_width(include_units=include_units)
        except TimeoutException as toe:
            raise TimeoutException("Unable to locate the grid within the Dashboard.") from toe

    def get_column_gutter_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of the gap between columns in the Dashboard.

        :param include_units: Dictates whether the returned value will include units of measurement.

        :returns: The width of the gap between columns of the Dashboard, as a string.
        """
        try:
            gap = self._grid.find().value_of_css_property(property_name="gap")
        except TimeoutException as toe:
            raise TimeoutException("Unable to locate the grid within the Dashboard.") from toe
        if not include_units:
            return gap.split("px")[0]
        else:
            return gap

    def get_count_of_columns(self) -> int:
        """
        Obtain a count of how many columns are configured for the Dashboard.

        :returns: The number of columns available within the Dashboard, regardless of mode.
        """
        try:
            return int(self._grid.find().value_of_css_property(
                property_name="grid-template-columns").split("repeat(")[1].split(",")[0])
        except TimeoutException as toe:
            raise TimeoutException("Unable to locate the grid within the Dashboard.") from toe

    def get_count_of_rows(self) -> int:
        """
        Obtain a count of how many rows are configured for the Dashboard.

        :returns: The number of rows available within the Dashboard, regardless of mode.
        """
        try:
            return int(self._grid.find().value_of_css_property(
                property_name="grid-template-rows").split("repeat(")[1].split(",")[0])
        except TimeoutException as toe:
            raise TimeoutException("Unable to locate the grid within the Dashboard.") from toe

    def get_count_of_widgets(self) -> int:
        """
        Obtain a count of how many widgets are currently displaying inside the Dashboard.
        """
        try:
            return len(self._widget.find_all())
        except TimeoutException:
            return 0

    def get_displayed_widget_categories(self) -> List[str]:
        """
        Obtain the text of all displayed categories.

        :returns: A list which contains the text of all displayed categories.

        :raises TimeoutException: If no categories are found.
        """
        try:
            return [_.text.split("\n")[0] for _ in self._add_widget_categories.find_all()]
        except TimeoutException as toe:
            raise TimeoutException(
                msg="No categories found in the Add Widget modal, or the Add Widget modal itself was missing.") from toe

    def get_list_of_widget_names_in_category(self, category: str) -> List[str]:
        """
        Obtain the text of all widgets listed under a specific category.

        :param category: The category from which you would like the listed widget names.

        :returns: A list which contains all of the widgets listed under the supplied category.

        :raises IndexError: If the supplied category does not exist.
        :raises NoSuchElementException: If no widgets are listed under the supplied category.
        """
        try:
            return [_.text for _ in
                    self._get_category(category=category).find_elements(*self._ADD_WIDGET_MODAL_TITLE_LOCATOR)]
        except NoSuchElementException as nsee:
            raise NoSuchElementException(msg=f"No widgets listed under the {category} category.") from nsee

    def get_origin_of_cell(self, one_based_column_index: int, one_based_row_index: int) -> Point:
        """
        Obtain the origin of a cell from within the grid of the Dashboard.

        :param one_based_column_index: The one-based index of the column of the target cell.
        :param one_based_row_index: The one-based index of the row of the target cell.

        :returns: A two-dimensional point which represents the position of the upper-left corner of the cell described
            by the provided indices.
        """
        try:
            return self._get_grid_cell(
                one_based_column_index=one_based_column_index, one_based_row_index=one_based_row_index).get_origin()
        except TimeoutException as toe:
            raise TimeoutException(
                msg=f"No cell found with a column index of {one_based_column_index} and a row index of "
                    f"{one_based_row_index}.") from toe

    def remove_widget_confirmation_modal_is_displayed(self) -> bool:
        """
        Determine if the modal to remove or cancel widget removal is displayed.

        :returns: True, if the user can see a modal with a "Cancel" and "Remove" button while attempting to remove a
            widget - False otherwise.
        """
        try:
            return self._remove_widget_modal.find().is_displayed()
        except TimeoutException:
            return False

    def resize_bottom_of_widget(self, one_based_row_target: int, widget: Widget) -> None:
        """
        Resize a widget by using the bottom resize handle. Potential for failure if the supplied index is not
        possible due to size constraints of the Widget.

        :param one_based_row_target: The one-based index of the row you would like the widget to end in.
        :param widget: The Widget object which contains information about your widget.

        :raises AssertionError: If unsuccessful in resizing the widget.
        :raises TimeoutException: If the Dashboard is not in editing mode, or if the supplied index is invalid.
        """
        row_difference = int(one_based_row_target) - (widget.get_next_available_cell_as_column_row()[1] - 1)
        if row_difference != 0:
            widget.select_during_edit_mode()
            closest_column = widget.get_starting_column() + widget.get_column_span() // 2
            self._resize_widget(
                handle=self._south_resize_handle,
                one_based_target_column_index=closest_column,
                one_based_target_row_index=one_based_row_target)
        # Adjust for one-based indexing in the assertion.
        IAAssert.is_equal_to(
            actual_value=widget.get_starting_row() + widget.get_row_span() - 1,
            expected_value=one_based_row_target,
            failure_msg=f"Failed to resize the widget via the bottom handle.")

    def resize_left_of_widget(self, one_based_column_target: int, widget: Widget) -> None:
        """
        Resize a widget by using the left side resize handle. Potential for failure if the supplied index is not
        possible due to size constraints of the Widget.

        :param one_based_column_target: The one-based index of the column you would like the widget to begin in.
        :param widget: The Widget object which contains information about your widget.

        :raises AssertionError: If unsuccessful in resizing the widget.
        :raises TimeoutException: If the Dashboard is not in editing mode, or if the supplied index is invalid.
        """
        column_difference = int(one_based_column_target) - (widget.get_next_available_cell_as_column_row()[0] - 1)
        if column_difference != 0:
            widget.select_during_edit_mode()
            closest_row = widget.get_starting_row() + widget.get_row_span() // 2
            self._resize_widget(
                handle=self._west_resize_handle,
                one_based_target_column_index=one_based_column_target,
                one_based_target_row_index=closest_row)
        IAAssert.is_equal_to(
            actual_value=widget.get_starting_column(),
            expected_value=one_based_column_target,
            failure_msg="Failed to resize the widget via the left handle.")

    def resize_right_of_widget(self, one_based_column_target: int, widget: Widget) -> None:
        """
        Resize a widget by using the right side resize handle. Potential for failure if the supplied index is not
        possible due to size constraints of the Widget.

        :param one_based_column_target: The one-based index of the column you would like the widget to end in.
        :param widget: The Widget object which contains information about your widget.

        :raises AssertionError: If unsuccessful in resizing the widget.
        :raises TimeoutException: If the Dashboard is not in editing mode, or if the supplied index is invalid.
        """
        column_difference = int(one_based_column_target) - (widget.get_next_available_cell_as_column_row()[0] - 1)
        if column_difference != 0:
            widget.select_during_edit_mode()
            closest_row = widget.get_starting_row() + widget.get_row_span() // 2
            self._resize_widget(
                handle=self._east_resize_handle,
                one_based_target_column_index=one_based_column_target,
                one_based_target_row_index=closest_row)
        # Adjust for one-based indexing in the assertion.
        IAAssert.is_equal_to(
            actual_value=widget.get_starting_column() + widget.get_column_span() - 1,
            expected_value=one_based_column_target,
            failure_msg="Failed to resize the widget via the right resize handle.")

    def resize_top_of_widget(self, one_based_row_target: int, widget: Widget) -> None:
        """
        Resize a widget by using the top resize handle. Potential for failure if the supplied index is not possible
        due to size constraints of the Widget.

        :param one_based_row_target: The one-based index of the row you would like the widget to start in.
        :param widget: The Widget object which contains information about your widget.

        :raises AssertionError: If unsuccessful in resizing the widget.
        """
        row_difference = int(one_based_row_target) - (widget.get_next_available_cell_as_column_row()[1] - 1)
        if row_difference != 0:
            widget.select_during_edit_mode()
            closest_column = widget.get_starting_column() + widget.get_column_span() // 2
            self._resize_widget(
                handle=self._north_resize_handle,
                one_based_target_column_index=closest_column,
                one_based_target_row_index=one_based_row_target)
        IAAssert.is_equal_to(
            actual_value=widget.get_starting_row(),
            expected_value=one_based_row_target,
            failure_msg="Failed to resize the widget via the top handle.")

    def _delete_active_widget(self) -> None:
        """
        Click the "X" for the currently selected widget, and then confirm the deletion.

        :raises TimeoutException: If no widget is selected/active, or if unable to locate the confirmation button in
            the resulting confirmation modal.
        """
        try:
            self._delete_widget.click()
        except TimeoutException as toe:
            raise TimeoutException(
                msg="There was no X available for the active widget, or no widget was selected/active.") from toe
        self.confirm_remove_widget(binding_wait_time=1)

    def _get_category(self, category: str) -> WebElement:
        """
        Obtain the underlying WebElement of a category based on the text of that category.

        :raises IndexError: If no category with the supplied name could be found.
        :raises TimeoutException: If no categories are found at all within the Add Widget modal.
        """
        try:
            return list(
                filter(
                    lambda e: e.text.split("\n")[0] == category, self._add_widget_categories.find_all()))[0]
        except IndexError as ie:
            raise IndexError(f"Unable to locate any category with a name of '{category}'.") from ie
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate any categories in the Add Widget modal.") from toe

    def _get_grid_cell(self, one_based_column_index: int, one_based_row_index: int) -> ComponentPiece:
        """
        Obtain the ComponentPiece which defines a singular cell within the grid of the Dashboard.

        :param one_based_column_index: The one-based index of the target column.
        :param one_based_row_index: The one-based index of the target row.

        :returns: A single cell of the grid displayed while in editing mode.
        """
        css_selector = f'div.grid-cell.dashboard-cell[data-column="{one_based_column_index}"]' \
                       f'[data-row="{one_based_row_index}"]'
        grid_cell = self._grid_cell_collection.get(css_selector)
        if not grid_cell:
            grid_cell = ComponentPiece(
                locator=(By.CSS_SELECTOR, css_selector),
                driver=self.driver,
                parent_locator_list=self._grid.locator_list,
                poll_freq=self.poll_freq)
            self._grid_cell_collection[css_selector] = grid_cell
        return grid_cell

    def _resize_widget(
            self,
            handle: ComponentPiece,
            one_based_target_column_index: int,
            one_based_target_row_index: int) -> None:
        """
        Resize a widget.

        :param handle: The ComponentPiece which identifies the handle we will be dragging.
        :param one_based_target_column_index: The one-based index of the column we will be dragging the handle to.
        :param one_based_target_row_index: The one-based index of the row we will be dragging the handle to.
        """
        try:
            ActionChains(driver=self.driver).drag_and_drop(
                source=handle.find(),
                target=self._get_grid_cell(
                    one_based_column_index=one_based_target_column_index,
                    one_based_row_index=one_based_target_row_index).find()).perform()
        except TimeoutException as toe:
            raise TimeoutException(
                msg="Unable to locate either the resize handle or the destination cell within the grid while resizing "
                    "a widget.") from toe

Components/PerspectiveComponents/Displays/Dashboard.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.Dropdown import CommonDropdown
from Components.PerspectiveComponents.Inputs.Button import Button


class Direction(Enum):
    LEFT = 'left'
    RIGHT = 'right'


class EquipmentSchedule(BasicPerspectiveComponent):
    _PRIMARY_HEADER_LOCATOR = (By.CSS_SELECTOR, '.time-range-label')
    _SECONDARY_HEADER_LOCATOR = (By.CSS_SELECTOR, 'div#grouping-header-cells div > span')
    _TERTIARY_HEADER_LOCATOR = (By.CSS_SELECTOR, 'div#header-cells div > p')
    _ZOOM_LEVEL_LOCATOR = (By.CSS_SELECTOR, '.button-group .ia_dropdown')
    _DELETE_BUTTON_LOCATOR = (By.CSS_SELECTOR, '.delete')
    _INCREMENT_BUTTON_LOCATOR = (By.CSS_SELECTOR, '.increment')
    _DECREMENT_BUTTON_LOCATOR = (By.CSS_SELECTOR, '.decrement')
    _ITEMS_LOCATOR = (By.CSS_SELECTOR, 'div.itemList div > span')
    _EVENTS_LOCATOR = (By.CSS_SELECTOR, 'div.scheduled-event')
    _BREAK_EVENTS_LOCATOR = (By.CSS_SELECTOR, '.ia_equipmentScheduleComponent__breakEvent')
    _DOWNTIME_EVENTS_LOCATOR = (By.CSS_SELECTOR, '.ia_equipmentScheduleComponent__downtimeEvent')
    _OVERLAPPING_EVENTS_LOCATOR = (By.CSS_SELECTOR, '.overlapping-event')
    _VISIBLE_OVERLAPPING_EVENTS_LOCATOR = (By.CSS_SELECTOR, '.overlapping-visible-event div > p')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._primary_header = ComponentPiece(locator=self._PRIMARY_HEADER_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._secondary_header = ComponentPiece(
            locator=self._SECONDARY_HEADER_LOCATOR,
            driver=driver,
            poll_freq=poll_freq)
        self._tertiary_header = ComponentPiece(
            locator=self._TERTIARY_HEADER_LOCATOR,
            driver=driver,
            poll_freq=poll_freq)
        self._zoom_level = CommonDropdown(locator=self._ZOOM_LEVEL_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._delete_button = Button(locator=self._DELETE_BUTTON_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._increment_button = Button(locator=self._INCREMENT_BUTTON_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._decrement_button = Button(locator=self._DECREMENT_BUTTON_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._items = ComponentPiece(locator=self._ITEMS_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._events = ComponentPiece(locator=self._EVENTS_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._break_events = ComponentPiece(locator=self._BREAK_EVENTS_LOCATOR, driver=driver, poll_freq=poll_freq)
        self._downtime_events = ComponentPiece(
            locator=self._DOWNTIME_EVENTS_LOCATOR,
            driver=driver,
            poll_freq=poll_freq)
        self._overlapping_events = ComponentPiece(
            locator=self._OVERLAPPING_EVENTS_LOCATOR,
            driver=driver,
            poll_freq=poll_freq)
        self._visible_overlapping_events = ComponentPiece(
            locator=self._VISIBLE_OVERLAPPING_EVENTS_LOCATOR,
            driver=driver,
            poll_freq=poll_freq)
        self._internal_pieces = {}

    def click_increment_zoom_level(self) -> None:
        """
        Increment the zoom level of the Equipment Schedule by clicking the built-in UI.
        Method scrolls to the zoom level element because increment button is out of frame with FireFox automation.
        """
        self._zoom_level.scroll_to_element()
        self._increment_button.click(binding_wait_time=1)

    def click_decrement_zoom_level(self) -> None:
        """
        Decrement the zoom level of the Equipment Schedule by clicking the built-in UI.
        Method scrolls to the zoom level element because decrement button is out of frame with FireFox automation.
        """
        self._zoom_level.scroll_to_element()
        self._decrement_button.click(binding_wait_time=1)

    def click_delete_event_button(self) -> None:
        """
        Click the delete button.

        :raises TimeoutException: If the Delete button is configured to not be allowed, and is therefore not present.

        Method scrolls to zoom level element because delete button is out of frame with Firefox automation.
        """
        self._zoom_level.scroll_to_element()
        self._delete_button.click(binding_wait_time=1)

    def delete_button_is_displayed(self) -> bool:
        """
        Determine if the Delete button is currently displayed.

        :returns: True, if the Delete button is currently displayed - False otherwise.
        """
        try:
            return self._delete_button.find().is_displayed()
        except TimeoutException:
            return False

    def get_count_of_break_events(self) -> int:
        """
        Obtain a count of "break" events from the Equipment Schedule Component.

        :returns: A count of events which are classified as a break.
        """
        try:
            return len(self._break_events.find_all())
        except TimeoutException:
            return 0

    def get_count_of_downtime_events(self) -> int:
        """
        Obtain a count of "downtime" events from the Equipment Schedule Component.

        :returns: A count of events which are classified as downtime.
        """
        try:
            return len(self._downtime_events.find_all())
        except TimeoutException:
            return 0

    def get_count_of_overlapping_events(self) -> int:
        """
        Obtain a count of "overlapping" events from the Equipment Schedule Component.

        :returns: A count of events which are overlapping other events.
        """
        try:
            return len(self._overlapping_events.find_all())
        except TimeoutException:
            return 0

    def get_count_of_scheduled_events(self) -> int:
        """
        Obtain a count of all events displayed in the Equipment Schedule Component.

        :returns: A count of all events displayed in the Equipment Schedule Component, regardless of type.
        """
        try:
            return len(self._events.find_all())
        except TimeoutException:
            return 0

    def get_text_of_primary_header(self) -> str:
        """
        Obtain the text of the Primary Header.

        :returns: The text of the primary.

        :raises TimeoutException: If the Primary Header is not Present.
        """
        return self._primary_header.get_text()

    def get_text_of_secondary_headers(self) -> List[str]:
        """
        Obtain the text of all currently displayed secondary headers.

        :returns: A list which contains the text of every displayed secondary header.

        :raises TimeoutException: If no secondary headers are present.
        """
        return [_.text for _ in self._secondary_header.find_all()]

    def get_text_of_tertiary_headers(self) -> List[str]:
        """
        Obtain the text of all currently displayed tertiary headers.

        :returns: A list which contains the text of every displayed tertiary header.

        :raises TimeoutException: If no tertiary headers are present.
        """
        return [_.text for _ in self._tertiary_header.find_all()]

    def get_text_of_items(self) -> List[str]:
        """
        Obtain the text of every configured item which is currently displayed.

        :returns: A list which contains the text of every displayed item configured for the Equipment Schedule
            Component.

        :raises TimeoutException: If no items are present.
        """
        return [_.text for _ in self._items.find_all()]

    def get_text_of_scheduled_events(self) -> List[str]:
        """
        Obtain the text of every displayed scheduled event for the Equipment Schedule Component.

        :returns: A list which contains the text of every displayed event.

        :raises TimeoutException: If no events are present.
        """
        return [_.text for _ in self._events.find_all()]

    def get_text_of_visible_overlapping_events(self) -> List[str]:
        """
        Obtain the text of every overlapping scheduled event for the Equipment Schedule Component.

        :returns: A list which contains the text of every overlapping displayed event.

        :raises TimeoutException: If no overlapping events are present.
        """
        return [_.text for _ in self._visible_overlapping_events.find_all()]

    def add_event(self, row_index: int, column_index: int, count_of_columns_to_span: int) -> None:
        """
        Add an event to the Equipment Schedule Component. Presumes the supplied row and column indices do not already
        have an event present.

        :param row_index: The zero-based index for the row we will target while adding the new event.
        :param column_index: The zero-based index for the column we will target while adding the new event.
        :param count_of_columns_to_span: The count of columns the new event should span.

        :raises ClickInterceptedException: If the supplied indices identify a cell which already has an event in place.
        """
        cell_element = self._get_internal_piece(
            locator=(By.CSS_SELECTOR, f'[data-column="{column_index}"][data-row="{row_index}"]'))
        cell_element.scroll_to_element(align_to_top=True)
        offset = int(cell_element.get_computed_width(include_units=False))
        ActionChains(driver=self.driver) \
            .click_and_hold(on_element=cell_element.find()) \
            .pause(2) \
            .move_by_offset(xoffset=offset * count_of_columns_to_span, yoffset=0) \
            .release() \
            .perform()
        cell_element.wait_on_binding(1)

    def move_event(self, item_id: str, event_id: str, count_of_columns_to_drag: int) -> None:
        """
        Move an existing event within the Equipment Schedule Component.

        :param item_id: The id of the item which contains the event. This is not necessarily the name of the item.
        :param event_id: The id of the event which will be moved.
        :param count_of_columns_to_drag: The count of columns to drag the event. Negative values will drag the event to
            the left, and positive values will drag the event to the right.

        :raises TimeoutException: If no event with the specified ids is present.
        """
        event_element = self._get_internal_piece(
            locator=(By.CSS_SELECTOR, f'[data-itemid="{item_id}"][data-eventid="{event_id}"]'))
        event_element.scroll_to_element(align_to_top=True)
        offset = int(float(event_element.get_computed_width(include_units=False)))
        ActionChains(driver=self.driver) \
            .click_and_hold(on_element=event_element.find()) \
            .move_by_offset(xoffset=offset * count_of_columns_to_drag, yoffset=0) \
            .release() \
            .perform()
        event_element.wait_on_binding(1)

    def resize_event(self, item_id: str, event_id: str, count_of_columns_to_drag: int, direction: Direction) -> None:
        """
        Resize an existing event within the Equipment Schedule Component.

        :param item_id: The id of the item which contains the event. This is not necessarily the name of the item.
        :param event_id: The id of the event which will be moved.
        :param count_of_columns_to_drag: The count of columns to drag the event. Negative values will drag the event
            resize handle to the left, and positive values will drag the event resize handle to the right.
        :param direction: Dictates whether we are resizing the left or the right of the event.

        :raises TimeoutException: If no event with the specified ids is present.
        """
        self.select_event(item_id=item_id, event_id=event_id)
        event_element = self._get_internal_piece(
            locator=(By.CSS_SELECTOR, f'[data-itemid="{item_id}"][data-eventid="{event_id}"]'))
        offset = int(float(event_element.get_computed_width(include_units=False)))  # we need the width of the event
        event_resize_element = self._get_internal_piece(
            locator=(
                By.CSS_SELECTOR,
                f'[data-itemid-eventid="{item_id}_{event_id}"][data-direction="{direction.value}"]'))
        event_resize_element.scroll_to_element(align_to_top=True)
        ActionChains(driver=self.driver) \
            .click_and_hold(on_element=event_resize_element.find()) \
            .pause(2) \
            .move_by_offset(xoffset=offset * count_of_columns_to_drag, yoffset=0) \
            .release() \
            .perform()
        event_resize_element.wait_on_binding(1)

    def event_is_resizable(self, item_id: str, event_id: str, direction: Direction) -> bool:
        """
        Determine if an event is resizable on a given side.

        :param item_id: The id of the item which contains the event. This is not necessarily the name of the item.
        :param event_id: The id of the event which will checked.
        :param direction: Dictates which side of the event we are checking for the ability to be resized.

        :returns: True, if the specified event may be resized on the specified side - False otherwise.

        :raises TimeoutException: If no event with the specified ids is present.
        """
        try:
            event = self._get_internal_piece(
                locator=(
                    By.CSS_SELECTOR,
                    f'[data-itemid-eventid="{item_id}_{event_id}"][data-direction="{direction.value}"]'))
            return event.find() is not None
        except TimeoutException:
            return False

    def select_event(self, item_id: str, event_id: str) -> None:
        """
        Select an event by clicking it.

        :param item_id: The id of the item which contains the event. This is not necessarily the name of the item.
        :param event_id: The id of the event which will selected/clicked.

        :raises TimeoutException: If no event with the specified ids is present.
        """
        self._get_internal_piece(
            locator=(
                By.CSS_SELECTOR,
                f'[data-itemid="{item_id}"][data-eventid="{event_id}"]')).click(binding_wait_time=1)

    def set_zoom_level(self, text: str):
        self._zoom_level.select_option_by_text_if_not_selected(option_text=text, binding_wait_time=1)

    def _get_internal_piece(self, locator: Tuple[By, str]) -> ComponentPiece:
        """
        Obtain some internal piece of the Equipment Schedule (almost always an event) via the locator which uniquely
        identifies the internal piece.

        :returns: The ComponentPiece identified by the provided locator.
        """
        event = self._internal_pieces.get(locator)
        if not event:
            event = ComponentPiece(
                locator=locator,
                driver=self.driver,
                parent_locator_list=self.locator_list)
            self._internal_pieces[locator] = event
        return event

Components/PerspectiveComponents/Displays/EquipmentSchedule.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class GoogleMap(BasicPerspectiveComponent):
    """A Perspective Google Map Component."""
    _DISMISS_OVERLAY_BUTTON_LOCATOR = (By.CLASS_NAME, 'dismissButton')
    _ZOOM_OUT_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button[title="Zoom out"]')
    _ZOOM_IN_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button[title="Zoom in"]')
    _FULL_SCREEN_BUTTON_LOCATOR = (By.CLASS_NAME, 'fullscreen-control')
    _TILT_MAP_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button[title="Tilt map"]')
    _ROTATE_TILT_MAP_CLOCKWISE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button[title="Rotate map clockwise"]')
    _ROTATE_TILT_MAP_COUNTERCLOCKWISE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button[title="Rotate map counterclockwise"]')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._dismiss_overlay_button = ComponentPiece(
            locator=self._DISMISS_OVERLAY_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._zoom_out_button = ComponentPiece(
            locator=self._ZOOM_OUT_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._zoom_in_button = ComponentPiece(
            locator=self._ZOOM_IN_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._full_screen_button = ComponentPiece(
            locator=self._FULL_SCREEN_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._tilt_map_button = ComponentPiece(
            locator=self._TILT_MAP_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._rotate_tilt_map_clockwise_button = ComponentPiece(
            locator=self._ROTATE_TILT_MAP_CLOCKWISE_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._rotate_tilt_map_counterclockwise_button = ComponentPiece(
            locator=self._ROTATE_TILT_MAP_COUNTERCLOCKWISE_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)

    def click_dismiss_overlay_button(self) -> None:
        """
        Clicks the dismiss overlay button found when no API key is applied.
        """
        self._dismiss_overlay_button.find().click()

    def click_full_screen_button(self) -> None:
        """
        Clicks the toggle full screen button.
        """
        self._full_screen_button.find().click()

    def click_zoom_out_button(self) -> None:
        """
        Clicks the zoom out button.
        """
        self._zoom_out_button.find().click()

    def click_zoom_in_button(self) -> None:
        """
        Clicks the zoom in button.
        """
        self._zoom_in_button.find().click()

    def click_tilt_map_button(self) -> None:
        """
        Clicks the button to toggle tilt mode, which enables the rotate buttons for supported areas.
        """
        self._tilt_map_button.find().click()

    def click_rotate_tilt_map_clockwise_button(self) -> None:
        """
        Clicks the rotate clockwise button (only visible when tilt is enabled).
        """
        self._rotate_tilt_map_clockwise_button.find().click()

    def click_rotate_tilt_map_counterclockwise_button(self) -> None:
        """
        Clicks the rotate counterclockwise button (only visible when tilt is enabled).
        """
        self._rotate_tilt_map_counterclockwise_button.find().click()

Components/PerspectiveComponents/Displays/GoogleMap.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.Icon import CommonIcon


class Icon(BasicPerspectiveComponent, CommonIcon):
    """A Perspective Icon component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonIcon.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

Components/PerspectiveComponents/Displays/Icon.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple, Union

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS, CSSPropertyValue


class Image(BasicPerspectiveComponent):
    """A Perspective Image Component."""
    _INTERNAL_IMAGE_LOCATOR = (By.TAG_NAME, "img")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._internal_img = ComponentPiece(
            locator=self._INTERNAL_IMAGE_LOCATOR,
            parent_locator_list=self.locator_list,
            driver=driver,
            poll_freq=poll_freq)

    def get_source(self) -> str:
        """
        Obtain the source (src) in use by the Image Component. This function works unless the component has no applied
        source. In that instance, Chrome and Firefox will return a blank String, whereas only Edge of all browsers will
        return the correct expected value, which is the url of the current page.

        :returns: The source (src) of the <img> element.
        """
        return self._internal_img.find().get_attribute('src')

    def get_alt_text(self) -> str:
        """
        Obtain the Alt Text (alt) in use by the Image Component. This function will return an empty string ("") if no
        alt text is configured to the Image component.

        :returns: The Alt Text (alt HTML attribute) of the <img> element.
        """
        return self._internal_img.find().get_attribute('alt')

    def get_css_property_value_of_internal_image(self, css_property: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of the specified CSS property for the internal image reference.

        :param css_property: Supply the name of the CSS property that will be obtained.

        :returns: The style property value of the <img> element.
        """
        return self._internal_img.get_css_property(property_name=css_property)

    def get_height_of_internal_image(self, include_units: bool = False) -> str:
        """
        Obtain the height value of the internal image (<img> element).

        :param include_units: If set to True, the units of the height value will be supplied to the value. Otherwise,
            only the numeric value will be given.

        :returns: The height value of the internal image (<img> element).
        """
        return self._internal_img.get_computed_height(include_units=include_units)

    def get_width_of_internal_image(self, include_units: bool = False) -> str:
        """
        Obtain the width value of the internal image (<img> element).

        :param include_units: If set to True, the units of the width value will be supplied to the value. Otherwise,
            only the numeric value will be given.

        :returns: The width value of the internal image (<img> element).
        """
        return self._internal_img.get_computed_width(include_units=include_units)

    def is_scroll_enabled(self) -> bool:
        """
        Obtain the value of the scroll property in the Image component. The Image component's overflow value
        determines if scroll is enabled or not. When overflow is set to auto, scrolling is enabled. Otherwise, the
        value of overflow is set to hidden.

        :returns: A value of True when overflow is set to "auto"; False otherwise.
        """
        return self.get_css_property(property_name=CSS.OVERFLOW) == 'auto'


Components/PerspectiveComponents/Displays/Image.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, Optional, List

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class InlineFrame(BasicPerspectiveComponent):
    """A Perspective Inline Frame (iframe) Component"""
    _IFRAME_LOCATOR = (By.TAG_NAME, "iframe")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._iframe = ComponentPiece(locator=self._IFRAME_LOCATOR, driver=driver, poll_freq=poll_freq)

    def get_source(self) -> str:
        """
        Obtain the source in use by the Inline Frame component. The source is essentially the URl of the website
        embedded into the iframe.

        :returns: The URl of the website embedded into the Inline Frame Component.
        """
        return self._iframe.find().get_attribute('src')

Components/PerspectiveComponents/Displays/InlineFrame.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Optional, List, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS


class LEDDisplay(BasicPerspectiveComponent):
    """A Perspective LED Display Component."""
    _DIGIT_LOCATOR = (By.TAG_NAME, "g")
    _BACKGROUND_RECT_LOCATOR = (By.TAG_NAME, "rect.ia_ledComponent__background")

    class Segments(Enum):
        """Available segment count configurations."""
        SEGMENTS_14 = "14 segment"
        SEGMENTS_7 = "7 segment"

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._digit = ComponentPiece(
            locator=self._DIGIT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._background_rect = ComponentPiece(
            locator=self._BACKGROUND_RECT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._digit_coll = {}

    def get_active_diode_color(self) -> str:
        """
        Obtain the color in use by active segments/diodes. This function works as long as the 0th index (from the right)
        is not a '.'.

        :returns: The active diode color as a string. Note that different browsers may return this value in different
            formats (RGB vs hex).

        :raises TimeoutException: If the LED Display is not currently displaying any value.
        """
        return ComponentPiece(
            locator=(By.TAG_NAME, "use.ia_ledComponent__diode--on"),
            driver=self.driver,
            parent_locator_list=self._get_digit_by_index(zero_based_index_from_right=0).locator_list,
            wait_timeout=1,
            poll_freq=self.poll_freq).get_css_property(property_name=CSS.FILL)

    def get_background_color(self) -> str:
        """
        Obtain the color in use by the background.

        :returns: The background color as a string. Note that different browsers may return this value in different
            formats (RGB vs hex).
        """
        return self._background_rect.get_css_property(property_name=CSS.FILL)

    def get_character_at_index(self, zero_based_index_from_right: int) -> str:
        """
        Obtain the character displayed at a given index.

        :param zero_based_index_from_right: The zero-based index of the value FROM THE RIGHT for which you would like
            to obtain the displayed character.

        :returns: The character at the supplied index, as a string.

        :raises TimeoutException: If the supplied index is invalid, based on the length of the displayed value.
        """
        return self._get_digit_by_index(
            zero_based_index_from_right=zero_based_index_from_right).find().get_attribute(name="data-char")

    def get_inactive_diode_color(self) -> str:
        """
        Obtain the color in use by inactive segments/diodes. This function works as long as the 0th index (from the
        right) is not a '.'.

        :returns: The inactive diode color as a string. Note that different browsers may return this value in different
            formats (RGB vs hex).

        :raises TimeoutException: If the LED Display is not currently displaying any value.
        """
        return ComponentPiece(
            locator=(By.TAG_NAME, "use.ia_ledComponent__diode--off"),
            driver=self.driver,
            parent_locator_list=self._get_digit_by_index(zero_based_index_from_right=0).locator_list,
            wait_timeout=1,
            poll_freq=self.poll_freq).get_css_property(property_name=CSS.FILL)

    def get_text(self) -> str:
        """
        Obtain the text of the LED Display.

        :return: The text of the LEDDisplay.
        """
        return self._get_display_value_as_text()

    def is_fourteen_segment_display(self) -> bool:
        """
        Determine if the LED Display is currently rendering in a 14-segment layout (as opposed to 7-segment layout).

        :returns: True, if the LED Display is in 14 segment mode - False otherwise.
        """
        try:
            return len(ComponentPiece(
                locator=(By.TAG_NAME, "use"),
                driver=self.driver,
                parent_locator_list=self._get_digit_by_index(zero_based_index_from_right=0).locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq).find_all()) > 7
        except TimeoutException:
            # If we fail to find the expected fourteen-segment svgs, we should at least verify the LED exists.
            return self.find() is None

    def _display_has_text(self, expected_text: str, trim: bool = False) -> bool:
        """
        Determine if the LED Display is currently displaying the supplied expected_text.

        :param expected_text: The text you expect the LED Display to be displaying.
        :param trim: If True, we will ignore any leading whitespace.

        :returns: True, if the currently displayed value is an exact match for the expected_text - False otherwise.
        """
        return str(self._get_display_value_as_text(trim=trim)) == str(expected_text)

    def _get_display_value_as_text(self, trim: bool = False) -> str:
        """
        Obtain the displayed value of the LED Display.

        :param trim: If True, we will remove any leading spaces from the displayed value.

        :returns: The displayed value of the LED Display.

        :raises TimeoutException: If no value is currently displayed for the LED Display.
        """
        displayed_text = []
        try:
            all_digits = self._digit.find_all()
        except TimeoutException:
            all_digits = []
        for i in range(len(all_digits)):
            displayed_text.insert(
                0,
                self._get_digit_by_index(zero_based_index_from_right=i).find().get_attribute(name="data-char"))
        displayed_text = ''.join(displayed_text)
        if trim:
            displayed_text = displayed_text.lstrip(" ")
        return displayed_text

    def _get_digit_by_index(self, zero_based_index_from_right: int) -> ComponentPiece:
        """
        Obtain a specific character from the LED Display by its index.

        :param zero_based_index_from_right: The zero-based index of the desired character FROM THE RIGHT.
        """
        digit = self._digit_coll.get(zero_based_index_from_right)
        if not digit:
            digit = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'svg[data-index="{zero_based_index_from_right}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq)
            self._digit_coll[zero_based_index_from_right] = digit
        return digit

Components/PerspectiveComponents/Displays/LEDDisplay.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent


class Label(BasicPerspectiveComponent):
    """A Perspective Label Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

Components/PerspectiveComponents/Displays/Label.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, Optional, List

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class LinearScale(BasicPerspectiveComponent):
    """A Perspective Linear Scale Component."""
    _TRANSFORM_ELEMENT_LOCATOR = (By.CSS_SELECTOR, 'svg > g')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq
        )
        self._transform_element = ComponentPiece(
            locator=self._TRANSFORM_ELEMENT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)

    def is_horizontal(self) -> bool:
        """
        Determine if the Linear Scale is currently in a horizontal layout.

        :returns: True, if the Linear Scale is currently rendering in a horizontal manner - False otherwise.
        """
        return 'rotate(90)' in self._transform_element.find().get_attribute('transform')

Components/PerspectiveComponents/Displays/LinearScale.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class Map(BasicPerspectiveComponent):
    """A Perspective Map Component."""
    _MARKER_CSS = 'div.leaflet-marker-icon.leaflet-interactive'
    _POPUP_CSS = 'div.leaflet-popup'
    _VECTOR_CSS = 'path.leaflet-interactive'
    _ZOOM_IN_BUTTON_LOCATOR = (By.CSS_SELECTOR, '.leaflet-control-zoom-in')
    _ZOOM_OUT_BUTTON_LOCATOR = (By.CSS_SELECTOR, '.leaflet-control-zoom-out')
    _CONTAINER_VIEW_TOOLTIP_PANE_LOCATOR = (By.CSS_SELECTOR, 'div>div>div.view')
    _TOOLTIP_PANE_LOCATOR = (By.CSS_SELECTOR, 'div.leaflet-tooltip-pane')
    _DISPLAYED_VIEW_LOCATOR = (By.CSS_SELECTOR, 'div.view-marker')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._map_marker = ComponentPiece(
            locator=(By.CSS_SELECTOR, self._MARKER_CSS),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._map_vector = ComponentPiece(
            locator=(By.CSS_SELECTOR, self._VECTOR_CSS),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._map_tooltip_pane = ComponentPiece(
            locator=self._TOOLTIP_PANE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._map_container_from_tooltip_pane = ComponentPiece(
            locator=self._CONTAINER_VIEW_TOOLTIP_PANE_LOCATOR,
            driver=driver,
            parent_locator_list=self._map_tooltip_pane.locator_list,
            poll_freq=poll_freq)
        self._map_displayed_view = ComponentPiece(
            locator=self._DISPLAYED_VIEW_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._map_zoom_in = ComponentPiece(
            locator=self._ZOOM_IN_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._map_zoom_out = ComponentPiece(
            locator=self._ZOOM_OUT_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def click(self, **kwargs) -> None:
        """
        :raises NotImplementError: Please do not blindly click the Map.
        """
        raise NotImplementedError

    def click_marker_by_expected_index(self, marker_index: int) -> None:
        """
        Click a Map marker.

        :param marker_index: The zero-based index of the marker you would like to click.

        :raises TimeoutException: If no markers are present.
        :raises IndexError: If the supplied index is invalid, based on the number of markers found.
        """
        self._map_marker.find_all()[int(marker_index)].click()

    def click_vector_by_expected_index(self, vector_index: int) -> None:
        """
        Click a Map vector.

        :param vector_index: The zero-based index of the vector you would like to click.

        :raises TimeoutException: If no vectors are present.
        :raises IndexError: If the supplied index is invalid, based on the number of vectors found.
        """
        self._map_vector.find_all()[int(vector_index)].click()

    def click_zoom_in_button(self) -> None:
        """
        Click the built-in zoom-in button of the Map.

        :raises TimeoutException: If the built-in zoom-in button is not present.
        """
        self._map_zoom_in.click(binding_wait_time=1)

    def click_zoom_out_button(self) -> None:
        """
        Click the built-in zoom-out button of the Map.

        :raises TimeoutException: If the built-in zoom-out button is not present.
        """
        self._map_zoom_out.click(binding_wait_time=1)

    def get_height_of_popup_wrapper(self, include_units: bool = False) -> str:
        """
        Obtain the height of the container which houses the View inside the internal Popup which was opened by clicking
        on a marker or vector.

        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The height of the open Popup as a string value.
        """
        return self._map_container_from_tooltip_pane.get_computed_height(include_units=include_units)

    def get_height_of_tooltip_view_container(self, include_units: bool = False) -> str:
        """
        Obtain the height of the internal tooltip displayed while hovering over a marker or vector.

        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The height of the displayed tooltip as a string value.
        """
        return self._map_container_from_tooltip_pane.get_computed_height(include_units=include_units)

    def get_height_of_view_wrapper_div(self, include_units: bool = False) -> str:
        """
        Obtain the height of the internal Popup which was opened by clicking on a marker or vector.

        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The height of the open Popup as a string value.
        """
        return self._map_displayed_view.get_computed_height(include_units=include_units)

    def get_width_of_popup_wrapper(self, include_units: bool = False) -> str:
        """
        Obtain the width of the container which houses the View inside the internal Popup which was opened by clicking
        on a marker or vector.

        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The width of the open Popup as a string value.
        """
        return self._map_container_from_tooltip_pane.get_computed_width(include_units=include_units)

    def get_width_of_tooltip_view_container(self, include_units: bool = False) -> str:
        """
        Obtain the width of the internal tooltip displayed while hovering over a marker or vector.

        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The width of the displayed tooltip as a string value.
        """
        return self._map_container_from_tooltip_pane.get_computed_width(include_units=include_units)

    def get_width_of_view_wrapper_div(self, include_units: bool = False) -> str:
        """
        Obtain the width of the internal Popup which was opened by clicking on a marker or vector.

        :param include_units: Dictates whether the returned value contains units of measurement.

        :returns: The width of the open Popup as a string value.
        """
        return self._map_displayed_view.get_computed_width(include_units=include_units)

    def hover_over_map_marker_by_expected_index(self, index: int) -> None:
        """
        Hover over a map marker in the Map.

        :param index: The zero-based index of the marker to hover over.

        :raises TimeoutException: If no markers are present.
        :raises IndexError: If the supplied index is invalid, based on the number of markers found.
        """
        ActionChains(self.driver).move_to_element(self._map_marker.find_all()[index]).perform()

Components/PerspectiveComponents/Displays/Map.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class Markdown(BasicPerspectiveComponent):
    _TEXT_CONTAINER_LOCATOR = (By.TAG_NAME, "p")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._text_container = ComponentPiece(
            locator=self._TEXT_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def get_text(self) -> str:
        """
        Obtain the text of the Markdown Component.

        :returns: The text of the Markdown Component.
        """
        return self._text_container.get_text()

Components/PerspectiveComponents/Displays/Markdown.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Optional, List, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS


class Mode(Enum):
    """The modes available to the Progress Component."""
    DETERMINATE = "determinate"
    INDETERMINATE = "indeterminate"


class Progress(BasicPerspectiveComponent):
    """A Perspective Progress Component."""
    _TRACK_CLASS = 'ia_progressBar__track'
    _DETERMINATE_CLASS = f'{_TRACK_CLASS}--determinate'
    _INDETERMINATE_CLASS = f'{_TRACK_CLASS}--indeterminate'
    _VALUE_DISPLAY_LOCATOR = (By.CSS_SELECTOR, "span.ia_progressBar__displayValue")
    _BAR_LOCATOR = (By.CSS_SELECTOR, "div.ia_progressBar__bar")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._track = ComponentPiece(
            locator=(By.CSS_SELECTOR, "div"), driver=driver, parent_locator_list=self.locator_list)
        self._bar = ComponentPiece(
            locator=self._BAR_LOCATOR, driver=driver, parent_locator_list=self._track.locator_list)
        self._value_display = ComponentPiece(
            locator=self._VALUE_DISPLAY_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def get_bar_color(self) -> str:
        """
        Obtain the color of the bar piece of the Progress Component.

        :returns: The color of the bar of the Progress Component, as a string. Note that different browsers may return
            this color in different formats (RGB vs hex).
        """
        return self._bar.get_css_property(property_name=CSS.BACKGROUND_COLOR)

    def get_displayed_percentage(self) -> float:
        """
        Obtain the current visual percentage of the Progress Component. This is not a check against the Label, but is an
        actual calculation of the volume of the bar of the Progress Component.

        :returns: The visual percentage of the Progress Component, as a user would see it.
        """
        progress_origin_x = float(self.get_origin().X)
        progress_width = float(self.get_computed_width())
        return ((float(self._bar.get_termination().X) - progress_origin_x) * 100) / progress_width

    def get_max(self) -> float:
        """
        Obtain the current maximum value the Progress Component may display.

        :returns: The 'max' attribute of the Progress Bar.
        """
        return float(self._track.find().get_attribute('data-max'))

    def get_min(self):
        """
        Obtain the current minimum value the Progress Component may display.

        :returns: The 'min' attribute of the Progress Bar.
        """
        return float(self._track.find().get_attribute('data-min'))

    def get_text(self) -> Optional[str]:
        """
        Obtain the displayed label text of the Progress Component.

        :returns: The text of the label for the Progress Component, or None if the component is not configured to
            display a value.
        """
        try:
            return self._value_display.get_text()
        except TimeoutException:
            return None

    def get_track_color(self) -> str:
        """
        Obtain the color of the track piece of the Progress Component.

        :returns: The color of the track of the Progress Component, as a string. Note that different browsers may return
            this color in different formats (RGB vs hex).
        """
        return self._track.get_css_property(property_name=CSS.BACKGROUND_COLOR)

    def get_value(self) -> float:
        """
        Obtain the value of the Progress Component.

        :returns: The 'value' attribute of the Progress Bar.
        """
        return float(self._track.find().get_attribute('data-value'))

    def is_indeterminate_progress_bar(self) -> bool:
        """
        Determine if the Progress Component is currently displaying as indeterminate.

        :returns: True if the Progress Bar is in "indeterminate" mode, False otherwise.
        """
        variant = self._track.find().get_attribute('data-variant')
        return variant is not None and variant == Mode.INDETERMINATE.value

Components/PerspectiveComponents/Displays/Progress.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import List, Optional, Tuple, Union

from selenium.common.exceptions import StaleElementReferenceException, NoSuchElementException, TimeoutException, \
    ElementClickInterceptedException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.DateRangeSelector import HistoricalRange
from Components.PerspectiveComponents.Common.DateTimePicker import PerspectiveDate
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Components.PerspectiveComponents.Common.Table import Table as CommonTable
from Components.PerspectiveComponents.Common.TablePieces.Body import Body
from Components.PerspectiveComponents.Common.TablePieces.Filter import Filter
from Components.PerspectiveComponents.Common.TablePieces.HeaderAndFooter import Header, FilterModal, Footer
from Components.PerspectiveComponents.Common.TablePieces.Pager import Pager
from Helpers.CSSEnumerations import CSS
from Helpers.IAAssert import IAAssert
from Helpers.IASelenium import IASelenium
from Helpers.Point import Point


class ColumnConfigurations:
    """Enums relating to ways a column can be configured/rendered, and also runtime settings like column filtering."""
    class Render(Enum):
        """Available settings for how a column may be rendered."""
        AUTO = "auto"
        NUMBER = "number"
        DATE = "date"
        BOOLEAN = "boolean"
        STRING = "string"
        VIEW = "view"

    class Filter:
        """Column filtering settings, including conditions and filter icon visibility"""
        class Condition(Enum):
            """
            This is a complete collection of conditions, but not all conditions are applicable based on the column type.
            """
            BETWEEN = "between"  # number, NOT date
            BETWEEN_DATES = "between dates"
            BETWEEN_DATE_TIMES = "between date times"
            CONTAINS = "contains"
            DATE_EQUALS = "date equals"
            DATE_TIME_EQUALS = "date time equals"
            EARLIER_THAN_DATE = "earlier than date"
            EARLIER_THAN_DATE_TIME = "earlier than date time"
            ENDS_WITH = "ends with"  # string
            EQUALS = "equals"  # string or number, NOT date
            FALSE = "false"
            GREATER_THAN = "greater than"
            GREATER_THAN_OR_EQUAL_TO = "greater than or equal to"
            LATER_THAN_DATE = "later than date"
            LATER_THAN_DATE_TIME = "later than date time"
            LESS_THAN = "less than"
            LESS_THAN_OR_EQUAL_TO = "less than or equal to"
            STARTS_WITH = "starts with"  # string
            TRUE = "true"

        class Visible(Enum):
            """Settings for specifying the appearance of the column filter icon in the header."""
            ALWAYS = "always"
            ON_HOVER = "on-hover"
            NEVER = "never"


class _TableBody(Body):
    _ROW_GROUP_LOCATOR = (By.CSS_SELECTOR, "div.tr-group")
    _ROW_LOCATOR = (By.CSS_SELECTOR, "div:not(.subview).tr")  # Not compatible with Workstation until JXBrowser upgrade
    _CELL_LOCATOR = (By.CSS_SELECTOR, "div.tc")
    _SELECTED_CELL_LOCATOR = (By.CSS_SELECTOR, "div.t-selected")
    _ROOT_SELECTION_LOCATOR = (By.CSS_SELECTOR, f"{_SELECTED_CELL_LOCATOR}.root-selected")
    _COPY_OPTION_LOCATOR = (By.CSS_SELECTOR, 'div.copy')
    _SUBVIEW_ARROW_CSS = 'div.tc-expand[data-column-id="expandSubview"] div.expand-subview'
    _EMPTY_MESSAGE_LOCATOR = (By.CSS_SELECTOR, '.empty-message')
    _EMPTY_MESSAGE_ICON_LOCATOR = (By.CSS_SELECTOR, '.empty-icon svg')

    def __init__(self, driver: WebDriver, parent_locator_list: Optional[List] = None, poll_freq: float = 0.5):
        super().__init__(driver=driver, parent_locator_list=parent_locator_list, poll_freq=poll_freq)
        self._copy_option = ComponentPiece(
            locator=self._COPY_OPTION_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._empty_message = ComponentPiece(
            locator=self._EMPTY_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=0.5,
            poll_freq=poll_freq)
        self._empty_icon = CommonIcon(
            locator=self._EMPTY_MESSAGE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=0.5,
            poll_freq=poll_freq)

    def cell_is_root_selection(self, row_index: int, column_index: int) -> bool:
        """
        Determine if the cell of a Table is the root of the current selection.

        :param row_index: The zero-based index of the row we will check.
        :param column_index: The zero-based index of the column we will check.

        :returns: True, if the resultant cell is the root of the current selection of the Table.

        :raises TimeoutException: If the supplied row and column indices are invalid.
        """
        _locator = (By.CSS_SELECTOR, f'{self._ROW_LOCATOR}[data-row-index="{row_index}"] '
                                     f'{self._CELL_LOCATOR}[data-column-index="{column_index}"]')
        try:
            return ComponentPiece(
                locator=_locator,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq).find().find_element(*self._ROOT_SELECTION_LOCATOR) is not None
        except StaleElementReferenceException:
            return self.cell_is_root_selection(row_index=row_index, column_index=column_index)
        except NoSuchElementException:
            return False

    def cell_is_selected(self, row_index: int, column_index: int) -> bool:
        """
        Determine if a specified cell is currently part of the active selection of the Table.

        :param row_index: The zero-based index of the row we will check.
        :param column_index: The zero-based index of the column we will check.

        :returns: True, if the resultant cell is part of the current selection of the Table.

        :raises TimeoutException: If the supplied row and column indices are invalid.
        """
        _locator = (By.CSS_SELECTOR, f'{self.__get_row_css_by_row_index(row_index=row_index)} '
                                     f'{self.__get_column_css_by_index(column_index=column_index)}')
        try:
            return ComponentPiece(
                locator=_locator,
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq).find().find_element(*self._SELECTED_CELL_LOCATOR) is not None
        except StaleElementReferenceException:
            return self.cell_is_root_selection(row_index=row_index, column_index=column_index)
        except NoSuchElementException:
            return False

    def click_copy_option(self) -> None:
        """
        Click the "Copy" option of the displayed context menu. Note: the context menu the copy option belongs to must be
        triggered/displayed before invoking this function.

        :raises TimeoutException: If the context menu which contains the copy option is not present.
        """
        self._copy_option.click(wait_timeout=1)

    def collapse_subview_by_row_index(self, row_index: int) -> None:
        """
        Collapse an expanded subview. Takes no action if the subview is already collapsed.

        :param row_index: The zero-based index of the row which might have an expanded subview.

        :raises TimeoutException: if no row exists with the supplied index.
        """
        if self.subview_is_expanded(row_index=row_index):
            locator = (By.CSS_SELECTOR, self.__get_subview_css_by_row_index(row_index=row_index))
            subview_arrow = ComponentPiece(locator=locator, driver=self.driver, parent_locator_list=self.locator_list)
            subview_arrow.click(binding_wait_time=0.5)

    def contains_subviews(self) -> bool:
        """
        Determine if the Table contains subviews. This function does not reflect the current display state of subviews
        - only their presence.

        :returns: True, if the Table contains Subviews - False otherwise.
        """
        try:
            return ComponentPiece(
                locator=(By.CSS_SELECTOR, self.__get_subview_css_by_row_index(row_index=0)),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq).find() is not None
        except TimeoutException:
            return False

    def copy_option_is_displayed(self) -> bool:
        """
        Determine if the "Copy" option is currently displayed in a context menu of the Table.

        :returns: True, if the "Copy" option is currently displayed in a context menu - False otherwise.
        """
        try:
            return self._copy_option.find() is not None
        except TimeoutException:
            return False

    def copy_option_is_enabled(self) -> bool:
        """
        Determine if the "Copy" option of the context menu is currently enabled.

        :returns: True, if the "Copy" option of the Table's context menu is currently enabled - False otherwise.

        :raises TimeoutException: If the "Copy" option is not present in the context menu, or the context menu is not
            present.
        """
        return 'disabled' not in self._copy_option.find().get_attribute("class")

    def double_click_cell(self, row_index: int, column_index: int, binding_wait_time: float = 0) -> None:
        """
        Double-click a cell of the Table.

        :param row_index: The zero-based index of the row containing the target cell.
        :param column_index: The zero-based index of the column containing the target cell.
        :param binding_wait_time: The amount of time (in seconds) to wait before allowing code to continue.

        :raises TimeoutException: If the supplied indices do not define a cell of the Table.
        """
        _locator = (By.CSS_SELECTOR, f'{self.__get_row_css_by_row_index(row_index=row_index)} '
                                     f'{self.__get_column_css_by_index(column_index=column_index)}')
        ComponentPiece(
            locator=_locator,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq).double_click(binding_wait_time=binding_wait_time)

    def expand_subview_by_row_index(self, row_index: int) -> None:
        """
        Expand the subview of a row of the Table.

        :param row_index: The zero-based index of the row which contains the subview to be expanded.

        :raises TimeoutException: If no row exists with the supplied index.
        :raises AssertionError: If the interaction does not result in the subview being expanded.
        """
        if not self.subview_is_expanded(row_index=row_index):
            ComponentPiece(
                locator=(By.CSS_SELECTOR, self.__get_subview_css_by_row_index(row_index=row_index)),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq)\
                .click(binding_wait_time=0.5)
        IAAssert.is_true(
            value=self.subview_is_expanded(row_index=row_index),
            failure_msg=f"Failed to expand a subview for row {row_index}.")

    def get_count_of_selected_rows(self) -> int:
        """
        Obtain a count of the rows which are at least partially selected.

        :returns: A count of rows where at least one cell of the row is currently selected.
        """
        return self._get_count_of_selected_rows()

    def get_dimensions_of_copy_option(self) -> Tuple[str, str]:
        """
        Obtain the width and height of the "Copy" option of the Table context menu.

        :raises TimeoutException: If the context menu of the Table is not present.
        """
        return self._copy_option.get_css_property(property_name=CSS.WIDTH).split('px')[0], \
            self._copy_option.get_css_property(property_name=CSS.HEIGHT).split('px')[0]

    def get_empty_message(self) -> str:
        """
        Obtain the text of the message displayed when the Table contains no data.

        :returns: The message displayed by the Table when there is no backing data.

        :raises TimeoutException: If the message is not present.
        """
        return self._empty_message.get_text()

    def get_empty_message_text_color(self) -> str:
        """
        Obtain the color in use for the message displayed when the Table contains no backing data.

        :returns: The color (as a string) of the message which is displayed when the Table contains no data.

        :raises TimeoutException: If the message is not present.
        """
        return self._empty_message.get_css_property(property_name=CSS.COLOR)

    def get_empty_message_icon_color(self) -> str:
        """
        Obtain the color in use for the icon displayed when the Table contains no backing data.

        :returns: The color (as a string) of the icon which is displayed when the Table contains no data.

        :raises TimeoutException: If the message is not present.
        """
        return self._empty_icon.get_fill_color()

    def get_empty_message_icon_name(self) -> str:
        """
        Obtain the slash-delimited path of the icon displayed when the Table contains no backing data.

        :returns: The path of the icon which is displayed when the Table contains no data as a slash-delimited string.

        :raises TimeoutException: If the message is not present.
        """
        return self._empty_icon.get_icon_name()

    def inclusive_select_cells_by_indices(
            self, list_of_row_column_index_tuples: List[Tuple[Union[int, str], Union[int, str]]]) -> None:
        """
        Select all content between a set of cells. Shift will be held during this event.

        :param list_of_row_column_index_tuples: A list of tuples where each tuple contains the zero-based index of the
            target row as the 0th element and the zero-based index of the target column as the 1th element.

        :raises TimeoutException: If any of the supplied tuples fail to define a valid cell.
        """
        cell_web_element_list = []
        tuple_row_index_location = 0
        tuple_column_index_location = 1
        for i in range(len(list_of_row_column_index_tuples)):
            _row_index = list_of_row_column_index_tuples[i][tuple_row_index_location]
            _column_index = list_of_row_column_index_tuples[i][tuple_column_index_location]
            _locator = (
                By.CSS_SELECTOR,
                f'{self.__get_row_css_by_row_index(row_index=_row_index)} '
                f'{self.__get_column_css_by_index(column_index=_column_index)}')
            cell_web_element_list.append(
                ComponentPiece(
                    locator=_locator,
                    driver=self.driver,
                    parent_locator_list=self.locator_list,
                    poll_freq=self.poll_freq).find())
            if len(cell_web_element_list) == 1:
                cell_web_element_list[0].click()
            else:
                IASelenium(driver=self.driver).inclusive_multi_select_elements(web_element_list=cell_web_element_list)

    def row_contains_subview(self, row_index: int) -> bool:
        """
        Determine if a row contains a subview.

        :param row_index: The zero-based index of the row to check.

        :returns: True, if the supplied row contains a subview. False, if the row does not contain a subview, or if the
            row does not exist.
        """
        try:
            return ComponentPiece(
                locator=(By.CSS_SELECTOR, self.__get_subview_css_by_row_index(row_index=row_index)),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq).find() is not None
        except TimeoutException:
            return False

    def row_is_completely_selected(self, row_index: int) -> bool:
        """
        Determine if every cell of a row is currently selected.

        :param row_index: The zero-based index of the row to check.

        :returns: True, if every cel within the specified row is currently selected - False otherwise.
        """
        for i in range(self.get_column_count()):
            if not self.cell_is_selected(row_index=row_index, column_index=i):
                return False
        return True

    def rows_are_completely_selected(self, list_of_row_indices: List[Union[int, str]]) -> bool:
        """
        Determine if a set of rows is completely selected.

        :param list_of_row_indices: A list which contains the zero-based indices of al rows to check.

        :returns: True, if every row specified in the list is completely selected - False otherwise.

        :raises TimeoutException: If any of the specified rows does not exist.
        """
        for i in range(len(list_of_row_indices)):
            if not self.row_is_completely_selected(row_index=i):
                return False
        return True

    def scroll_to_row(self, row_index: int, align_to_top: bool = False) -> None:
        """
        Scroll the Table so that the specified row is visible.

        :param row_index: The zero-based index of the row we will scroll to.
        :param align_to_top: If True, we will try to align the row to the Top of the viewport. If False, we will attempt
            to align the row to the bottom of the viewport.

        :raises TimeoutException: If no row exists with the supplied index.
        """
        row_index = int(row_index)
        rows = ComponentPiece(
            locator=self._ROW_GROUP_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq)
        min_index = min([int(row.get_attribute('data-row-index')) for row in rows.find_all()])
        max_index = max([int(row.get_attribute('data-row-index')) for row in rows.find_all()])
        if min_index > row_index:
            self._scroll_to_row(row_index=min_index, align_to_top=False)
            self.scroll_to_row(row_index=row_index, align_to_top=align_to_top)
        elif max_index < row_index:
            self._scroll_to_row(row_index=max_index, align_to_top=True)
            self.scroll_to_row(row_index=row_index, align_to_top=align_to_top)
        else:
            self._scroll_to_row(row_index=row_index, align_to_top=align_to_top)

    def subview_is_expanded(self, row_index: int) -> bool:
        """
        Determine if a subview is expanded for a given row. Only works properly in tables which have virtualization
        disabled, or if the row is already in view.

        :param row_index: The zero-based index of the row to check.
        """
        try:
            return "open" in ComponentPiece(
                locator=(By.CSS_SELECTOR, self.__get_subview_css_by_row_index(row_index=row_index)),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq).find(wait_timeout=1).get_attribute("class")
        except TimeoutException:
            return False

    def _get_count_of_selected_rows(self) -> int:
        """
        Obtain a count of rows where at least one cell of the row is selected.
        """
        try:
            count = 0
            for row in self._rows.find_all():
                try:
                    row.find_element(*self._SELECTED_CELL_LOCATOR)
                    count += 1
                except NoSuchElementException:
                    pass
            return count
        except StaleElementReferenceException:
            return self.get_count_of_selected_rows()

    def _scroll_to_row(self, row_index: int, align_to_top: bool = False) -> None:
        """
        Scroll the Table so that the specified row is visible.

        :param row_index: The zero-based index of the row we will scroll to.
        :param align_to_top: If True, we will try to align the row to the Top of the viewport. If False, we will attempt
            to align the row to the bottom of the viewport.

        :raises TimeoutException: If no row exists with the supplied index.
        """
        row = ComponentPiece(
            locator=(By.CSS_SELECTOR, self.__get_row_css_by_row_index(row_index=row_index)),
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=self.poll_freq)
        row.scroll_to_element(align_to_top=align_to_top)
        row.wait_on_binding()

    def __get_column_css_by_index(self, column_index: Union[int, str]) -> str:
        """Obtain the CSS which defines a column based on its index."""
        return f'{self._CELL_LOCATOR[1]}[data-column-index="{column_index}"]'

    def __get_row_css_by_row_index(self, row_index: Union[int, str]) -> str:
        """Obtain the CSS which defines a row based on its index."""
        return f'{self._ROW_GROUP_LOCATOR[1]}[data-row-index="{row_index}"] {self._ROW_LOCATOR[1]}'

    def __get_subview_css_by_row_index(self, row_index: Union[int, str]) -> str:
        """Obtain the CSS which defines a subview based on the index of the row which contains it."""
        return f'{self.__get_row_css_by_row_index(row_index=row_index)} {self._SUBVIEW_ARROW_CSS}'


class _TableHeader(Header):
    _ACTIVE_FILTER_CLASS = "ia_table__head__header__filter--active"
    _HEADER_GROUP_LOCATOR = (By.CSS_SELECTOR, "div.tr-group")

    def __init__(
            self,
            driver: WebDriver,
            parent_locator_list: List[Tuple[By, str]],
            wait_timeout: float = 1,
            poll_freq: float = 0.5):
        super().__init__(
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        self._filter_modal = FilterModal(driver=driver)
        self._header_row_groups = {}
        self._header_row_group_cells = {}

    def click_filter_for_column(self, column_index: int) -> None:
        """
        Click the filter icon for a given column.

        :param column_index: The numeric index of the column which contains the filter icon we will click.

        :raises TimeoutException: If the supplied column is not configured to allow for filtering.
        """
        self._get_filter_by_column_index(column_index=column_index).click()

    def column_has_active_filter_applied(self, column_index: int) -> bool:
        """
        Determine if a column has a filter currently applied.

        :param column_index: The numeric index of the column to check for an applied filter.

        :returns: True, if the column with the provided index has a filter applied - False otherwise.
        """
        try:
            return self._ACTIVE_FILTER_CLASS in self._get_filter_icon_by_column_index(
                column_index=column_index).find().get_attribute("class")
        except TimeoutException:
            return False

    def filter_icon_is_displayed_for_column(self, column_index: int) -> bool:
        """
        Determine if the supplied column is currently displaying a filter icon.

        :param column_index: The zero-based index of the column to check for a filter icon in the Header.

        :returns: True, if the column is currently displaying an icon. False, if no icon is currently displayed. If
            receiving a return of False but expecting True, verify the column is not configured to only display the
            icon on-hover.
        """
        try:
            return self._get_filter_by_column_index(column_index=column_index).find().is_displayed()
        except TimeoutException:
            return False

    def get_count_of_header_row_groups(self) -> int:
        """
        Obtain a count of how many Header Row Groups are present in the Header of the Table.

        :returns: A count of Header Row Groups.
        """
        try:
            return len(self._get_header_row_group(zero_based_header_row_group_index=None).find_all())
        except TimeoutException:
            return 0

    def get_labels_from_header_row_group(self, zero_based_header_row_group_index: Union[int, str]) -> List[str]:
        """
        Obtain the labels of all cells within the specified Header Row Group.

        :param zero_based_header_row_group_index: The 0-based index of the Header Group Row you would like the labels
            of.

        :returns: A list, where each entry is the text of a label within the specified Header Row Group.
        """
        try:
            return [_.text for _ in self._get_header_row_group(
                zero_based_header_row_group_index=zero_based_header_row_group_index).find_all()]
        except TimeoutException:
            return []

    def get_origin_of_header_row_group_cell(
            self, label_text: str, zero_based_header_row_group_index: Union[int, str] = 0) -> Point:
        """
        Obtain a two-dimensional point in the viewport which represents the upper-left corner of the Header Row Group
        cell defined by the supplied arguments.

        :param label_text: The text of the cell you want the origin of.
        :param zero_based_header_row_group_index: The 0-based index of the Header Row Group you expect this cell to
            belong to.

        :returns: The location of the upper-left corner of the Header Group Row cell defined by the supplied arguments.
        """
        return self._get_cell_from_header_row_group(
            zero_based_header_row_group_index=zero_based_header_row_group_index, label=label_text).get_origin()

    def get_termination_of_header_row_group_cell(
            self, label_text: str, zero_based_header_row_group_index: Union[int, str] = 0) -> Point:
        """
        Obtain a two-dimensional point in the viewport which represents the bottom-right corner of the Header Row Group
        cell defined by the supplied arguments.

        :param label_text: The text of the cell you want the origin of.
        :param zero_based_header_row_group_index: The 0-based index of the Header Row Group you expect this cell to
            belong to.

        :returns: The location of the bottom-right corner of the Header Group Row cell defined by the supplied
            arguments.
        """
        return self._get_cell_from_header_row_group(
            zero_based_header_row_group_index=zero_based_header_row_group_index, label=label_text).get_termination()

    def set_column_filter_condition(self, condition: ColumnConfigurations.Filter.Condition) -> None:
        """
        Set the current filtering condition of a column within the open filtering modal.

        :param condition: The condition to use.

        :raises TimeoutException: If the column filtering modal is not present.
        :raises AssertionError: If the interaction does not result in the condition being selected.
        """
        self._filter_modal.set_condition(condition=condition.value)

    def _get_filter_by_column_index(self, column_index: int) -> ComponentPiece:
        """
        Obtain the filter piece of the column with the specified index.
        """
        filter_comp = self._filter_cells.get(column_index)
        if not filter_comp:
            filter_comp = ComponentPiece(
                locator=(By.CSS_SELECTOR, "div.filter-button svg"),
                driver=self.driver,
                parent_locator_list=self._get_header_cell_by_index(column_index=column_index).locator_list,
                wait_timeout=1)
            self._filter_cells[column_index] = filter_comp
        return filter_comp

    def _get_filter_icon_by_column_index(self, column_index: Union[int, str]) -> ComponentPiece:
        """
        Obtain the icon of the filter for a column by providing the column index.
        """
        return ComponentPiece(
            locator=(By.CSS_SELECTOR, "path.iaFilterIcon"),
            driver=self.driver,
            parent_locator_list=self._get_filter_by_column_index(column_index=column_index).locator_list)

    def _get_header_row_group(
            self,
            zero_based_header_row_group_index: Optional[Union[int, str]] = 0) -> ComponentPiece:
        """
        Obtain a Header Row Group of varying specificity, based on the supplied arguments.

        :param zero_based_header_row_group_index: The 0-based index of the Header Row Group. If None is supplied, then
            all row groups will be matched.

        :returns: A ComponentPiece which defines the Header Group entry with varying levels of specificity; the more
            information supplied, the more specific the locator will be.
        """
        index = str(zero_based_header_row_group_index) \
            if zero_based_header_row_group_index is not None else zero_based_header_row_group_index
        header_row_group = self._header_row_groups.get(index)
        if not header_row_group:
            row_index_piece = f'[data-row-index="{index}"]' if index is not None else ''
            header_row_group = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{self._HEADER_GROUP_LOCATOR[1]}{row_index_piece}'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                description=f"The {zero_based_header_row_group_index}th row of Header Row Groups.",
                poll_freq=self.poll_freq)
            self._header_row_groups[index] = header_row_group
        return header_row_group

    def _get_cell_from_header_row_group(
            self,
            label: str,
            zero_based_header_row_group_index: Optional[Union[int, str]] = 0):
        index = str(zero_based_header_row_group_index) \
            if zero_based_header_row_group_index is not None else zero_based_header_row_group_index
        cell = self._header_row_group_cells.get((index, label))
        if not cell:
            cell = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div.tc[data-label="{label}"]'),
                driver=self.driver,
                parent_locator_list=self._get_header_row_group(
                    zero_based_header_row_group_index=zero_based_header_row_group_index).locator_list,
                description=f"A Header Row Group cell in the {zero_based_header_row_group_index}th row, with a "
                            f"label of '{label}'.",
                poll_freq=self.poll_freq)
            self._header_row_group_cells[(index, label)] = cell
        return cell


class Table(CommonTable, BasicPerspectiveComponent):
    """
    A Perspective Table Component.
    """
    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonTable.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        self._body = _TableBody(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._filter = Filter(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._filter_modal = FilterModal(driver=driver)
        self._footer = Footer(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._header = _TableHeader(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._pager = Pager(driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)

    def apply_column_filter(self) -> None:
        """
        Apply the current configuration of the filter for the column.

        :raises TimeoutException: If the filter modal and/or Apply button within the modal is not present.
        """
        self._filter_modal.click_apply_button()

    def cell_is_root_selection(self, row_index: int, column_index: int) -> bool:
        """
        Determine if the cell defined by the supplied indices is the root of the current selection.

        :param row_index: The zero-based index of the target row.
        :param column_index: The zero-based index of the target column.

        :returns: True, if the resultant cell is the root of the current selection - False otherwise.

        :raises TimeoutException: If no cell is defined by the supplied indices.
        """
        return self._body.cell_is_root_selection(row_index=row_index, column_index=column_index)

    def cell_is_selected(self, row_index: int, column_index: int) -> bool:
        """
        Determine if a specified cell is currently part of the active selection of the Table.

        :param row_index: The zero-based index of the row we will check.
        :param column_index: The zero-based index of the column we will check.

        :returns: True, if the resultant cell is part of the current selection of the Table.

        :raises TimeoutException: If the supplied row and column indices are invalid.
        """
        return self._body.cell_is_selected(row_index=row_index, column_index=column_index)

    def click(self, wait_timeout=None, binding_wait_time: float = 0) -> None:
        """
        This method is only here to override the inherited method and prevent any generic clicking of the Table.

        :raises NotImplementedError: Please don't blindly click the Table...
        """
        raise NotImplementedError

    def click_copy_option(self) -> None:
        """
        Click the "Copy" option of the displayed context menu. Note: the context menu the copy option belongs to must be
        triggered/displayed before invoking this function.

        :raises TimeoutException: If the context menu which contains the copy option is not present.
        """
        self._body.click_copy_option()

    def click_filter_for_column(self, column_index: int) -> None:
        """
        Click the filter icon for a given column.

        :param column_index: The numeric index of the column which contains the filter icon we will click.

        :raises TimeoutException: If the supplied column is not configured to allow for filtering.
        """
        self._header.click_filter_for_column(column_index=column_index)

    def click_first_page_button(self) -> None:
        """
        Click the option in the Page which would take a user to the 'first' page.

        :raises TimeoutException: If the 'First' page option is not present in the Pager.
        :raises AssertionError: If clicking the 'First' page button does result in the Table displaying the first page.
        """
        self._pager.click_first_page_button()

    def click_last_page_button(self) -> None:
        """
        Click the option in the Page which would take a user to the 'last' page.

        :raises TimeoutException: If the 'Last' page option is not present in the Pager.
        """
        self._pager.click_last_page_button()

    def click_next_page_chevron(self) -> None:
        """
        Click the chevron which would take the user to the next page.

        :raises TimeoutException: If the 'next' chevron does not exist, or if no page numbers exist in the Pager.
        """
        self._pager.click_next_page_chevron()

    def click_page_number(self, page_number: Union[int, str]) -> None:
        """
        Click a page number within the Pager.

        :param page_number: The page number you wish to click.

        :raises TimeoutException: If no page numbers are present in the Pager.
        :raises IndexError: If the specified page number is not present in the Pager.
        :raises AssertionError: If the Table does not end up displaying the specified page number.
        """
        self._pager.click_page_number(desired_page=page_number)

    def click_previous_page_chevron(self) -> None:
        """
        Click the chevron which would take the user to the previous page.

        :raises TimeoutException: If the 'previous' chevron does not exist.
        """
        self._pager.click_previous_page_chevron()

    def collapse_subview_by_row_index(self, row_index: int) -> None:
        """
        Collapse an expanded subview. Takes no action if the subview is already collapsed.

        :param row_index: The zero-based index of the row which might have an expanded subview.

        :raises TimeoutException: if no row exists with the supplied index.
        """
        self._body.collapse_subview_by_row_index(row_index=row_index)

    def column_filter_modal_apply_button_is_enabled(self) -> bool:
        """
        Determine if the Apply button is enabled in the modal.

        :returns: True, if the Apply button is enabled - False otherwise.

        :raises TimeoutException: if the Apply button is not present.
        """
        return self._filter_modal.apply_button_is_enabled()

    def column_filter_modal_is_displayed(self) -> bool:
        """
        Determine if the filter modal is currently displayed.

        :returns: True, if the filter modal is currently displayed - False otherwise.
        """
        return self._filter_modal.is_displayed()

    def column_filter_modal_value_input_is_enabled(self) -> bool:
        """
        Determine if the basic value input field in the filter modal is enabled.

        :returns: True, if the value input is enabled - False otherwise.

        :raises TimeoutException: If the value input field is not present.
        """
        return self._filter_modal.value_input_is_enabled()

    def contains_sub_views(self) -> bool:
        """
        Determine if the Table contains subviews. This function does not reflect the current display state of subviews
        - only their presence.

        :returns: True, if the Table contains Subviews - False otherwise.
        """
        return self._body.contains_subviews()

    def copy_option_is_displayed(self) -> bool:
        """
        Determine if the "Copy" option is currently displayed in a context menu of the Table.

        :returns: True, if the "Copy" option is currently displayed in a context menu - False otherwise.
        """
        return self._body.copy_option_is_displayed()

    def copy_option_is_enabled(self) -> bool:
        """
        Determine if the "Copy" option of the context menu is currently enabled.

        :returns: True, if the "Copy" option of the Table's context menu is currently enabled - False otherwise.

        :raises TimeoutException: If the "Copy" option is not present in the context menu, or the context menu is not
            present.
        """
        return self._body.copy_option_is_enabled()

    def double_click_cell_in_body(self, row_index: int, column_index: int) -> None:
        """
        Double-click a cell in the body of the Table.

        :param row_index: The zero-based index of the row containing the target cell.
        :param column_index: The zero-based index of the column containing the target cell.

        :raises TimeoutException: If the supplied indices do not define a cell of the Table.
        """
        self._body.double_click_cell(row_index=row_index, column_index=column_index)

    def end_time_hours_input_is_enabled_in_filtering_modal(self) -> bool:
        """
        Determine if the input for hours for the ending date is currently enabled.

        :returns: True, if the input which drives the hours of the ending date is currently enabled - False otherwise.

        :raises TimeoutException: If the hours input for the ending date is not present.
        """
        return self._filter_modal.end_time_hours_input_is_enabled()

    def end_time_minutes_input_is_enabled_in_filtering_modal(self) -> bool:
        """
        Determine if the input for minutes for the ending date is currently enabled.

        :returns: True, if the input which drives the minutes of the ending date is currently enabled - False otherwise.

        :raises TimeoutException: If the minutes input for the ending date is not present.
        """
        return self._filter_modal.end_time_minutes_input_is_enabled()

    def end_time_seconds_input_is_enabled_in_filtering_modal(self) -> bool:
        """
        Determine if the input for seconds for the ending date is currently enabled.

        :returns: True, if the input which drives the seconds of the ending date is currently enabled - False otherwise.

        :raises TimeoutException: If the seconds input for the ending date is not present.
        """
        return self._filter_modal.end_time_seconds_input_is_enabled()

    def entire_row_is_selected(self, row_index: int) -> bool:
        """
        Determine if every cell in a row is selected.

        :returns: True if every cell in the specified row is selected - False otherwise.
        """
        return self._body.row_is_completely_selected(row_index=row_index)

    def expand_subview_by_row_index(self, row_index: int) -> None:
        """
        Expand the subview of a row of the Table.

        :param row_index: The zero-based index of the row which contains the subview to be expanded.

        :raises TimeoutException: If no row exists with the supplied index.
        :raises AssertionError: If the interaction does not result in the subview being expanded.
        """
        self._body.expand_subview_by_row_index(row_index=row_index)

    def column_filter_icon_is_displayed_for_column(self, column_index: int) -> bool:
        """
        Determine if the supplied column is currently displaying a filter icon.

        :param column_index: The zero-based index of the column to check for a filter icon in the Header.

        :returns: True, if the column is currently displaying an icon. False, if no icon is currently displayed. If
            receiving a return of False but expecting True, verify the column is not configured to only display the
            icon on-hover.
        """
        return self._header.filter_icon_is_displayed_for_column(column_index=column_index)

    def filter_is_present(self) -> bool:
        """
        Determine if the filter for the table exists.

        :returns: True, if the filter for the table exists - False otherwise. This function makes no claims about
            visibility.
        """
        return self._filter.is_present()

    def first_page_button_is_displayed(self) -> bool:
        """
        Determine if the 'First' page option is displayed in the Pager.

        :returns: True, if a user can see the 'First' page option in the Pager.
        """
        return self._pager.first_page_button_is_displayed()

    def first_page_button_is_enabled(self) -> bool:
        """
        Determine if the 'First' page option is enabled in the Pager.

        :returns: True, if the 'First' page option in the Pager is enabled.

        :raises TimeoutException: If the 'First' page option is not present in the Pager.
        """
        return self._pager.first_page_button_is_enabled()

    def footer_is_present(self) -> bool:
        """
        Determine if the Table is currently displaying a Footer.

        :returns: True, if the Table is currently displaying a Footer - False otherwise.
        """
        return self._footer.footer_is_present()

    def get_active_page(self) -> int:
        """
        Obtain the number of the currently displayed page of the Table.

        :returns: The number of the currently displayed page.

        :raises TimeoutException: If no page numbers are present.
        """
        return self._pager.get_active_page()

    def get_all_listed_pages(self) -> List[int]:
        """
        Obtain all displayed page numbers.

        :returns: All page numbers available for clicking in the Pager, including the current (disabled) page.
        """
        return self._pager.get_all_listed_pages()

    def get_all_row_display_options(self) -> List[str]:
        """
        Obtain all selection options form the dropdown which controls how many rows may be displayed in the Table.

        :returns: A list of strings, where each entry is an option available for selection to drive the count of rows
            displayed by the Table.

        :raises TimeoutException: If the row count dropdown is not present.
        """
        return self._pager.get_all_row_display_options()

    def get_available_column_filter_conditions(self, column_index: int) -> List[str]:
        """
        Obtain all options available to the user in the condition dropdown.

        :returns: A list which contains the text of every option in the dropdown of available conditions.

        :raises TimeoutException: If the dropdown which contains available conditions is not present.
        """
        if not self.column_filter_modal_is_displayed():
            self.click_filter_for_column(column_index=column_index)
        return self._filter_modal.get_available_conditions()

    def get_text_of_remove_filter_and_apply_button(self) -> Tuple[str, str]:
        """
        Obtain the text of the Remove Filter link and the Apply Button.

        :returns: A tuple, where the 0th element is the text of the Remove Filter link, and the 1th element is the text
            of the Apply button.

        :raises TimeoutException: If either the Remove Filter link or the Apply button is not present.
        """
        return (
            self._filter_modal.get_remove_filter_link_text(),
            self._filter_modal.get_apply_button_text()
        )

    def get_dimensions_of_copy_option(self) -> Tuple[str, str]:
        """
        Obtain the width and height of the "Copy" option of the Table context menu.

        :returns: A tuple, where the 0th element is the width of the copy option and the 1th element is the height.

        :raises TimeoutException: If the context menu of the Table is not present.
        """
        return self._body.get_dimensions_of_copy_option()

    def get_displayed_row_data_as_a_list_of_dictionaries(
            self, start_row_index: Optional[int] = None, end_row_index: Optional[int] = None) -> List[dict]:
        """
        Obtain the data of a subset of rows.

        :param start_row_index: The zero-based index of the row which defines the beginning of the subset. If not
            supplied, we will begin with the 0th row.
        :param end_row_index: The zero-based index of the row which defines the end of the subset. if not supplied,
            we will end with the last row.

        :returns: A list of dictionaries, where the keys of each dictionary match the columns ids.

        :raises IndexError: If the supplied row indices are invalid based on the data.
        """
        return [
            self.get_row_data_by_row_index(row_index=row_index)
            for row_index in range(self.get_count_of_rows())
        ][start_row_index:end_row_index]

    def get_empty_message(self) -> str:
        """
        Obtain the text of the message displayed when the Table contains no data.

        :returns: The message displayed by the Table when there is no backing data.

        :raises TimeoutException: If the message is not present.
        """
        return self._body.get_empty_message()

    def get_empty_message_text_color(self) -> str:
        """
        Obtain the color in use for the message displayed when the Table contains no backing data.

        :returns: The color (as a string) of the message which is displayed when the Table contains no data.

        :raises TimeoutException: If the message is not present.
        """
        return self._body.get_empty_message_text_color()

    def get_empty_message_icon_name(self) -> str:
        """
        Obtain the slash-delimited path of the icon displayed when the Table contains no backing data.

        :returns: The path of the icon which is displayed when the Table contains no data as a slash-delimited string.

        :raises TimeoutException: If the message is not present.
        """
        return self._body.get_empty_message_icon_name()

    def get_enabled_days_from_column_filter_picker(self) -> List[int]:
        """
        Obtain all days available for selection in the current month.

        :returns: A list with the numbers of all available days within the current month.

        :raises TimeoutException: If the date picker (calendar) is not present.
        """
        return self._filter_modal.get_enabled_days_from_picker()

    def get_height_of_row(self, row_index: int, include_units: bool = False) -> str:
        """
        Obtain the height of a row.

        :param row_index: The zero-based index of the row you are targeting.
        :param include_units: A value of True specifies we should include the units of measurement (almost always
            'px'), while a value of False specifies we should return only the numeric value (as a string).

        :returns: The height of the specified row as a string, because it might have units attached.

        :raises TimeoutException: If the specified row index does not exist.
        """
        return self._body.get_height_of_row_by_index(
            zero_based_row_index=row_index, include_units=include_units)

    def get_labels_from_header_row_group(self, zero_based_header_row_group_index: Union[int, str] = 0) -> List[str]:
        """
        Obtain the labels of all cells within the specified Header Row Group.

        :param zero_based_header_row_group_index: The 0-based index of the Header Group Row you would like the labels
            of.

        :returns: A list, where each entry is the text of a label within the specified Header Row Group.
        """
        return self._header.get_labels_from_header_row_group(
            zero_based_header_row_group_index=zero_based_header_row_group_index)

    def get_last_displayed_page_number(self) -> int:
        """
        Obtain the number of the last page available in the Pager. This value may not be the count of Pages of data,
        but is the last number a user sees displayed.

        Example: A Table may have 40 pages, but the pager may only allow for quick navigation to pages 1, 2, 3, 4, or 5
        before then displaying the 'Last' page option, or a 'next set of pages' option.

        :returns: The last visible page number available for clicking in the Pager.

        :raises TimeoutException: If no pages numbers are present.
        """
        return self._pager.get_last_displayed_page_number()

    def get_list_of_displayed_column_names_from_header(self) -> List[str]:
        """
        :returns: A list of Strings which contains all the column names from the header.
        """
        return self.get_column_names_from_header()

    def get_origin_of_cell_in_header_rwo_group(
            self,
            label_text: str,
            zero_based_header_row_group_index: Union[int, str] = 0) -> Point:
        """
        Obtain a two-dimensional point in the viewport which represents the upper-left corner of the Header Row Group
        cell defined by the supplied arguments.

        :param label_text: The text of the cell you want the origin of.
        :param zero_based_header_row_group_index: The 0-based index of the Header Row Group you expect this cell to
            belong to.

        :returns: The location of the upper-left corner of the Header Group Row cell defined by the supplied arguments.
        """
        return self._header.get_origin_of_header_row_group_cell(
            label_text=label_text, zero_based_header_row_group_index=zero_based_header_row_group_index)

    def get_row_group_count(self) -> int:
        """
        Obtain a count of row groups. Row groups are distinct from rows, in that row GROUPS can contain multiple rows
        and potentially subviews. They are a way of grouping row content which belongs together into one container.

        :returns: A numeric count of row groups currently displayed within the Table.
        """
        return self._body.get_row_group_count()

    def get_row_data_by_row_index(self, row_index: int) -> dict:
        """
        Get the data from a specified row as a dictionary, with the column names as the keys.
        :param row_index: This argument is cast as an int, and is used to determine what row we
        should obtain data from.

        :returns: dict - The data of the row packaged as a dictionary, where they keys are the column ids.
        """
        return self._body.get_row_data_by_row_index(zero_based_row_index=row_index)

    def get_cell_data_by_row_index_column_index(self, row_index: int, column_index: int) -> str:
        """
        Get the data of a specific cell in the table. NOTE: this always returns a str, due to how the DOM stores the
        information.

        :param row_index: The zero-based index of the ROW you are targeting.
        :param column_index: The zero-based index of the COLUMN you are targeting.

        :returns: The data of the cell as a string.

        :raises TimeoutException: If no cell exists with the provided indices.
        """
        return self._body.get_cell_data_by_row_index_column_index(
            zero_based_row_index=row_index, zero_based_column_index=column_index)

    def get_cell_data_by_row_index_column_id(self, row_index: int, column_id: str) -> str:
        """
        Get the data of a specific cell in the table by supplying the index of the row and the CASE-SENSITIVE
        id of the column. NOTE: this always returns a str, due to how the DOM stores the information.

        :param row_index: The zero-based index of the ROW you are targeting.
        :param column_id: The case-sensitive id of the column you are targeting.

        :returns: The data of the cell as a string.

        :raises TimeoutException: If no cell exists with the provided row index in the supplied column.
        """
        return self._body.get_cell_data_by_row_index_column_id(
            zero_based_row_index=row_index, column_id=column_id)

    def get_column_names_from_footer(self) -> List[str]:
        """
        Obtain all column names displayed in the Footer.

        :returns: A list which contains all column names displayed in the Footer.

        :raises TimeoutException: If no Footer is present.
        """
        return self._footer.get_column_names()

    def get_count_of_header_row_groups(self) -> int:
        """
        Obtain a count of how many Header Row Groups are present in the Header of the Table.

        :returns: A count of Header Row Groups.
        """
        return self._header.get_count_of_header_row_groups()

    def get_count_of_selected_rows(self) -> int:
        """
        Get a count of rows for which every cell in the row is selected.

        :returns: The count of rows for which every cell in the row is selected.
        """
        return self._body.get_count_of_selected_rows()

    def get_active_page_from_listings(self) -> int:
        """
        Obtain the value text of the "active" page as conveyed by the clickable page links in the pager.

        :returns: The number of the active page link.
        """
        return self._pager.get_active_page()

    def get_displayed_rows_selection(self) -> int:
        """
        Obtain the value of the row display dropdown from the pager.

        :returns: The value of the row display dropdown in the Pager.
        """
        return self._pager.get_selected_row_count_option_from_dropdown()

    def get_selected_row_count_option(self) -> int:
        """
        Obtain the currently selected VALUE from the dropdown which dictates how many rows are displayed.

        :returns: The currently selected count of rows which should be displayed. If "25 rows" is selected, this will
            return 25.

        :raises TimeoutException: If the row count dropdown is not present. This dropdown/select piece is only present
            while the Table is wider than 700px.
        """
        return self._pager.get_selected_row_count_option_from_dropdown()

    def get_termination_of_cell_in_row_group_header(
            self, label_text: str, zero_based_header_row_group_index: Union[int, str] = 0) -> Point:
        """
        Obtain a two-dimensional point in the viewport which represents the bottom-right corner of the Header Row Group
        cell defined by the supplied arguments.

        :param label_text: The text of the cell you want the origin of.
        :param zero_based_header_row_group_index: The 0-based index of the Header Row Group you expect this cell to
            belong to.

        :returns: The location of the bottom-right corner of the Header Group Row cell defined by the supplied
            arguments.
        """
        return self._header.get_termination_of_header_row_group_cell(
            label_text=label_text, zero_based_header_row_group_index=zero_based_header_row_group_index)

    def get_text_of_filter_results(self) -> str:
        """
        Obtain the text of the label which provides feedback on the results of the filter.

        :returns: A description of how many results match the applied filter string ("X result(s)").

        :raises TimeoutException: If the feedback filter is not present.
        """
        return self._filter.get_results_text()

    def go_to_page(self, page_number: int) -> None:
        """
        Use the page jump input field to go to a specific page in the Table.

        :param page_number: The page of the Table to go to.

        :raises TimeoutException: If the page jump input is not present.
        :raises AssertionError: If the Table does not end up on the supplied page.
        """
        self._pager.jump_to_page(page_to_jump_to=page_number)

    def hour_input_is_displayed_in_filter_modal(self) -> bool:
        """
        Determine if the hours input is displayed. This should not be used when dealing with a time condition of
        "between", because multiple hour inputs might be present.

        :returns: True, if any hour input is displayed.
        """
        return self._filter_modal.hour_input_is_displayed()

    def hour_input_is_enabled_in_filter_modal(self) -> bool:
        """
        Determine if the hours input is enabled. This should not be used when dealing with a time condition of
        "between", because multiple hour inputs might be present.

        :returns: True, if the hour input field is enabled.
        """
        return self._filter_modal.hour_input_is_enabled()

    def jump_input_is_displayed(self) -> bool:
        """
        Determine if the input which allows for a user to type a page number is displayed.

        :returns: True, if the user is able to type a page number into the Pager - False otherwise.
        """
        return self._pager.jump_input_is_displayed()

    def jump_to_page(self, page_number: int) -> None:
        """
        Use the page jump input field to go to a specific page in the Table.

        :param page_number: The page of the Table to go to.

        :raises TimeoutException: If the page jump input is not present.
        :raises AssertionError: If the Table does not end up on the supplied page.
        """
        self._pager.jump_to_page(page_to_jump_to=page_number)

    def last_page_button_is_displayed(self) -> bool:
        """
        Determine if the 'Last' page option is displayed in the Pager.

        :returns: True, if a user can see the 'Last' page option in the Pager.
        """
        return self._pager.last_page_button_is_displayed()

    def last_page_button_is_enabled(self) -> bool:
        """
        Determine if the 'Last' page option is enabled in the Pager.

        :returns: True, if the 'Last' page option in the Pager is enabled.

        :raises TimeoutException: If the 'Last' page option is not present in the Pager.
        """
        return self._pager.last_page_button_is_enabled()

    def minute_input_is_displayed_in_filter_modal(self) -> bool:
        """
        Determine if the minutes input is displayed. This should not be used when dealing with a time condition of
        "between", because multiple minute inputs might be present.

        :returns: True, if any minute input is displayed.
        """
        return self._filter_modal.minute_input_is_displayed()

    def minute_input_is_enabled_in_filter_modal(self) -> bool:
        """
        Determine if the minutes input is enabled. This should not be used when dealing with a time condition of
        "between", because multiple minute inputs might be present.

        :returns: True, if the minute input field is enabled.
        """
        return self._filter_modal.minute_input_is_enabled()

    def next_page_chevron_is_enabled(self) -> bool:
        """
        Determine if the next page chevron is enabled.

        :returns: True, if the next page chevron is enabled - False otherwise.
        """
        return self._pager.next_page_chevron_is_enabled()

    def page_number_is_displayed(self, page_number: int) -> bool:
        """
        Determine if a specific page number is currently displayed.

        :param page_number: The number of the page for which we will verify display status.

        :returns: True, if the supplied page number is currently displayed - False otherwise.

        :raises TimeoutException: If no page numbers are displayed in the Pager.
        """
        return self._pager.page_number_is_displayed(desired_page=page_number)

    def pager_is_displayed(self, location: Pager.Location = Pager.Location.BOTTOM) -> bool:
        """
        Determine if the specified pager is displayed.

        :returns: True, if the specified pager is displayed - False otherwise.
        """
        return self._pager.is_displayed(location=location)

    def previous_page_chevron_is_enabled(self) -> bool:
        """
        Determine if the previous page chevron is enabled.

        :returns: True, if the previous page chevron is enabled - False otherwise.

        :raises TimeoutException: If the previous page chevron is not present.
        """
        return self._pager.previous_page_chevron_is_enabled()

    def remove_column_filter(self, column_index: int) -> None:
        """
        Remove the applied filter for a column.

        :param column_index: The zero-based index of the column for which we wil remove the applied filter.

        :raises TimeoutException: If the column is not configured to have any filtering done.
        :raises AssertionError: If unable to successfully remove any filter from the column.
        """
        if not self.column_filter_modal_is_displayed():
            self.hover_over_column_header(column_index=column_index)
            self.click_filter_for_column(column_index=column_index)
        try:
            self._filter_modal.click_remove_filter()
        except ElementClickInterceptedException:
            selenium = IASelenium(driver=self.driver)
            original_height = selenium.get_inner_height()
            selenium.set_inner_window_dimensions(inner_height=float(original_height) + 50)
            try:
                self._filter_modal.click_remove_filter()
            finally:
                selenium.set_inner_window_dimensions(inner_height=original_height)
        if not self.column_filter_modal_is_displayed():
            self.hover_over_column_header(column_index=column_index)
        IAAssert.is_not_true(
            value=self._header.column_has_active_filter_applied(column_index=column_index),
            failure_msg=f"Failed to remove column filter for column {column_index}.")

    def results_label_is_displayed(self) -> bool:
        """
        Determine if information about filtered results is present.

        :returns: True, if a breakdown regarding filtered results is displayed.
        """
        return self._filter.results_text_content_is_displayed()

    def row_contains_subview(self, row_index: int) -> bool:
        """
        Determine if a row contains a subview.

        :param row_index: The zero-based index of the row to check.

        :returns: True, if the supplied row contains a subview. False, if the row does not contain a subview, or if the
            row does not exist.
        """
        return self._body.row_contains_subview(row_index=row_index)

    def rows_are_completely_selected(self, list_of_row_indices: List) -> bool:
        """
        Determine if a set of rows is completely selected.

        :param list_of_row_indices: A list which contains the zero-based indices of al rows to check.

        :returns: True, if every row specified in the list is completely selected - False otherwise.

        :raises TimeoutException: If any of the specified rows does not exist.
        """
        return self._body.rows_are_completely_selected(list_of_row_indices=list_of_row_indices)

    def row_select_dropdown_is_displayed(self) -> bool:
        """
        Determine if the dropdown which allows for specifying a row count to display is displayed.

        :returns: True, if the row count dropdown is displayed - False otherwise.
        """
        return self._pager.row_select_dropdown_is_displayed()

    def scroll_to_row(self, row_index: int, align_to_top: bool = False) -> None:
        """
        Scroll the Table so that the specified row is visible.

        :param row_index: The zero-based index of the row we will scroll to.
        :param align_to_top: If True, we will try to align the row to the Top of the viewport. If False, we will attempt
            to align the row to the bottom of the viewport.

        :raises TimeoutException: If no row exists with the supplied index.
        """
        return self._body.scroll_to_row(row_index=row_index, align_to_top=align_to_top)

    def seconds_input_is_enabled_in_modal(self) -> bool:
        """
        Determine if the seconds input is enabled. Distinct from :func:`end_time_seconds_input_is_enabled`, which is
        used for date ranges.

        :returns: True, if the seconds input is enabled - False otherwise.

        :raises TimeoutException: If the seconds input is not present.
        """
        return self._filter_modal.seconds_input_is_enabled()

    def seconds_input_is_displayed_in_modal(self) -> bool:
        """
        Determine if the seconds input is displayed. Distinct from :func:`end_time_seconds_input_is_displayed`, which is
        used for date ranges.

        :returns: True, if the seconds input is displayed - False otherwise.

        :raises TimeoutException: If the seconds input is not present.
        """
        return self._filter_modal.seconds_input_is_displayed()

    def select_cells_by_indices(self, list_of_row_column_index_tuples: List[Tuple[int, int]]) -> None:
        """
        Select a single cell or multiple cells from the table.

        Each tuple in the list should consist of two elements, where the 0th element is the zero-based row index,
        and the 1th element is the zero-based column index.

        :param list_of_row_column_index_tuples: A list of tuples which are the row index and the column index.

        :raises TimeoutException: If any of the tuples fail to define a cell of the Table.
        """
        self._body.inclusive_select_cells_by_indices(list_of_row_column_index_tuples=list_of_row_column_index_tuples)
        self.wait_on_binding(time_to_wait=0.5)

    def select_rows_to_display(self, count_of_rows: int) -> None:
        """
        This method expects a numeric value (either int or str), without the additional " rows" portion of the dropdown.
        For example: If you want 25 rows displayed, supply 25, but not "25 rows".

        :param count_of_rows: The numeric value of rows you would like displayed.
        """
        self._pager.set_displayed_row_count(count_of_rows=count_of_rows)

    def set_column_filter_condition(self, condition: ColumnConfigurations.Filter.Condition) -> None:
        """
        Select a condition from the dropdown.

        :param condition: The condition to select.

        :raises TimeoutException: If the condition dropdown is not present.
        :raises AssertionError: If we fail to select the supplied condition.
        """
        self._filter_modal.set_condition(condition=condition.value)

    def set_column_filter_date(self, date: PerspectiveDate, apply: bool = True) -> None:
        """
        Select a date, and potentially apply that date.

        :param date: The date to select in the filter modal.
        :param apply: If True, click the apply button. If False, take no action after selecting the date.

        :raises TimeoutException: If the date picker (calendar) is not present, or if the Apply button is not present.
        """
        self._filter_modal.select_date_and_apply(date=date, apply=apply)

    def set_column_filter_max_value(self, text: str) -> None:
        """
        Set the maximum value in use by the filter.

        :param text: The maximum value the filter should use.

        :raises TimeoutException: if the maximum value input field is not present.
        :raises AssertionError: If we fail to set the input to the supplied value.
        """
        self._filter_modal.set_maximum_range_value(text=text)

    def set_column_filter_min_value(self, text: str) -> None:
        """
        Set the minimum value in use by the filter.

        :param text: The minimum value the filter should use.

        :raises TimeoutException: if the minimum value input field is not present.
        :raises AssertionError: If we fail to set the input to the supplied value.
        """
        self._filter_modal.set_minimum_range_value(text=text)

    def set_column_filter_range(self, date_range: HistoricalRange) -> None:
        """
        Apply a historical range (start AND end date) to the filter modal and then click the Apply button.

        :param date_range: An object containing the start and end dates to be applied to the filter modal.

        :raises TimeoutException: If the date picker (calendar) is not present, or if the Apply button is not present.
        """
        self._filter_modal.select_range(historical_range=date_range)

    def set_column_filter_value(self, text: str) -> None:
        """
        Set the value for the condition. This function should be sued for condition which do not include ranges of any
        type.

        :param text: The value of the condition.

        :raises TimeoutException: if the value input field is not present.
        :raises AssertionError: If we fail to set the input to the supplied value.
        """
        self._filter_modal.set_value_for_condition(text=text)

    def set_displayed_row_count(self, desired_row_count: int) -> None:
        """
        Set the number of displayed rows in the Table. The count of rows must be a valid value from the
        dropdown (Select) in the Pager.

        :param desired_row_count: The desired count of rows (this must be one of the available values in the dropdown).

        :raises TimeoutException: If the row count dropdown is not present.
        :raises AssertionError: If unable to set the row count dropdown to the supplied row count.
        """
        self._pager.set_displayed_row_count(count_of_rows=desired_row_count)

    def set_filter_text(self, text: str) -> None:
        """
        Set the text filter to contain some text.

        :param text: The text you want to supply to the filter of the Table.

        :raises TimeoutException: If the filter input is not present.
        :raises AssertionError: If the supplied text is not successfully applied to the filter.
        """
        self._filter.set_filter_text(text=text)

    def start_time_hours_input_is_enabled(self) -> bool:
        """
        Determine if the hours input of the start time is enabled.

        :returns: True, if the hours input of the start time is enabled - False otherwise.

        :raises TimeoutException: If the hours input is not present.
        """
        return self._filter_modal.start_time_hours_input_is_enabled()

    def start_time_minutes_input_is_enabled(self) -> bool:
        """
        Determine if the minutes input of the start time is enabled.

        :returns: True, if the minutes input of the start time is enabled - False otherwise.

        :raises TimeoutException: If the minutes input is not present.
        """
        return self._filter_modal.start_time_minutes_input_is_enabled()

    def start_time_seconds_input_is_enabled(self) -> bool:
        """
        Determine if the seconds input of the start time is enabled.

        :returns: True, if the seconds input of the start time is enabled - False otherwise.

        :raises TimeoutException: If the seconds input is not present.
        """
        return self._filter_modal.start_time_seconds_input_is_enabled()

    def subview_is_expanded(self, row_index: int) -> bool:
        """
        Determine if a subview is expanded for a given row. Only works properly in tables which have virtualization
        disabled, or if the row is already in view.

        :returns: True, if the row with the supplied index is displaying an expanded subview - False otherwise.

        :param row_index: The zero-based index of the row to check.
        """
        return self._body.subview_is_expanded(row_index=row_index)

Components/PerspectiveComponents/Displays/Table.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.TagBrowseTree import CommonTagBrowseTree


class TagBrowseTree(CommonTagBrowseTree, BasicPerspectiveComponent):
    """A Perspective Tag Browse Tree Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonTagBrowseTree.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)

    def multi_select_tags(self, tag_name_list: [str], inclusive_multi_selection: bool, wait_timeout: int = 5) -> None:
        """
        Select multiple tags within the Tag Browse Tree.

        :param tag_name_list: A list of tag names to be selected. If multiple tags with the same name are visible, only
            the first of each such tag will be selected.
        :param inclusive_multi_selection: A True value specifies that all tags between the supplied tags should also be
            selected, and so SHIFT will be held. A False value specifies that ONLY the supplied tags should be selected,
            and so CONTROL will be held.
        :param wait_timeout: The amount of time to wait (in seconds) for each tag to become visible.

        :raises TimeoutException: If any of the supplied tags did not exist.
        """
        self.multi_select_items(
            item_labels=tag_name_list, inclusive_multi_selection=inclusive_multi_selection, wait_timeout=wait_timeout)

    def select_tag_from_tree(self, tag_name: str, wait_timeout: int = 5) -> None:
        """
        Select a tag from the Tag Browse Tree.

        :param tag_name: The name of the tag to select. If multiple tags with the same name are displayed, the first
            tag with this name will be selected.
        :param wait_timeout: The amount of time (in seconds) to wait for the supplied tag to appear.

        :raises TimeoutException: If no such tag exists.
        """
        self.select_item_in_tree(item_label=tag_name, wait_timeout=wait_timeout)

Components/PerspectiveComponents/Displays/TagBrowseTree.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple, Union

from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.color import Color

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS
from Helpers.IAExpectedConditions import IAExpectedConditions


class Thermometer(BasicPerspectiveComponent):
    """A Perspective Thermometer Component."""
    _GLASS_LOCATOR = (By.CSS_SELECTOR, 'g > path.ia_thermometerComponent__glass')
    _INTERVAL_LOCATOR = (By.CSS_SELECTOR, 'line.ia_thermometerComponent__interval')
    _LABELS_LOCATOR = (By.TAG_NAME, 'text')
    _LIQUID_LOCATOR = (By.CSS_SELECTOR, 'g > path.ia_thermometerComponent__liquid')
    _STROKE_LOCATOR = (By.CSS_SELECTOR, 'g')
    _TICK_LABEL_LOCATOR = (By.CSS_SELECTOR, 'text.ia_thermometerComponent__tickLabel')
    _TICK_LOCATOR = (By.CSS_SELECTOR, 'line.ia_thermometerComponent__tick')
    _UNIT_LABEL_LOCATOR = (By.CSS_SELECTOR, 'text.ia_thermometerComponent__unit')
    _VALUE_LABEL_LOCATOR = (By.CSS_SELECTOR, 'text.ia_thermometerComponent__valueDisplay')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._glass = ComponentPiece(
            locator=self._GLASS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._interval = ComponentPiece(
            locator=self._INTERVAL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._labels = ComponentPiece(
            locator=self._LABELS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._liquid = ComponentPiece(
            locator=self._LIQUID_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._stroke = ComponentPiece(
            locator=self._STROKE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._tick = ComponentPiece(
            locator=self._TICK_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._tick_label = ComponentPiece(
            locator=self._TICK_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._unit_label = ComponentPiece(
            locator=self._UNIT_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._value_label = ComponentPiece(
            locator=self._VALUE_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def get_all_interval_colors(self, wait_timeout: float = 0) -> List[str]:
        """
        Obtain a list of all interval colors as strings.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: A list containing the color of all intervals of the thermometer as strings. Note that different
            browsers may return these values in different formats (RGB vs hex).

        :raises TimeoutException: If no intervals are found.
        """
        return [
            Color.from_string(interval.value_of_css_property(CSS.STROKE.value)).hex
            for interval in self._interval.find_all(wait_timeout=wait_timeout)
        ]

    def get_all_tick_colors(self, wait_timeout: float = 0) -> List[str]:
        """
        Obtain a list of all tick colors as strings.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: A list containing the color of all ticks of the thermometer as strings. Note that different
            browsers may return these values in different formats (RGB vs hex).

        :raises TimeoutException: If no ticks are found.
        """
        return [
            Color.from_string(tick.value_of_css_property(CSS.STROKE.value)).hex
            for tick in self._tick.find_all(wait_timeout=wait_timeout)
        ]

    def get_all_tick_label_colors(self, wait_timeout: float = 0) -> List[str]:
        """
        Obtain a list of all tick label colors as strings.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: A list containing the color of all tick labels of the thermometer as strings. Note that different
            browsers may return these values in different formats (RGB vs hex).

        :raises TimeoutException: If no tick labels are found.
        """
        return [
            Color.from_string(tick_label.value_of_css_property(CSS.FILL.value)).hex
            for tick_label in self._tick_label.find_all(wait_timeout=wait_timeout)
        ]

    def get_color_of_labels(self, label_index=0) -> str:
        """
        Obtain the color in use for a specific label.

        :param label_index: The zero-based index of the label you would like the color of. These labels include the
            units label, all tick labels, and the value label, and so the count of labels is dependent on the
            configuration of the Thermometer.

        :raises TimeoutException: If no labels are found.
        :raises IndexError: If the supplied index is invalid based on the count of labels found.
        """
        return Color.from_string(
            self._labels.find_all()[label_index].value_of_css_property(CSS.COLOR.value)
        ).hex

    def get_displayed_value(self) -> float:
        """
        Obtain the displayed value of the Thermometer.

        :raises TimeoutException: If the Thermometer is not currently displaying a value label.
        """
        return float(self._value_label.get_text())

    def get_displayed_value_font_size(self) -> str:
        """
        Obtain the font size of the value label.

        :raises TimeoutException: If the Thermometer is not currently displaying a value label.
        """
        return self._value_label.find().value_of_css_property(CSS.FONT_SIZE.value)

    def get_glass_color(self, wait_timeout: float = 0) -> str:
        """
        Obtain the color of the glass of the Thermometer as a string.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: The color of the Thermometer's glass as a string. Note that different
            browsers may return these values in different formats (RGB vs hex).

        :raises TimeoutException: In the event the glass of the Thermometer could not be located.
        """
        return Color.from_string(
            self._glass.find(wait_timeout=wait_timeout).value_of_css_property(CSS.STROKE.value)
        ).hex

    def get_list_of_levels(self) -> List[str]:
        """
        Obtain the text of all tick labels.

        :returns: All tick labels as strings.

        :raises TimeoutException: If no tick labels are found.
        """
        return self.wait.until(
            IAExpectedConditions.function_returns_true(
            custom_function=self._get_list_of_levels,
            function_args={}
        ))

    def get_mercury_color(self, wait_timeout: float = 0) -> str:
        """
        Obtain the color (fill) of the mercury within the Thermometer.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: The mercury color (fill) for the Thermometer as a string.  Note that different
            browsers may return these values in different formats (RGB vs hex).

        :raises TimeoutException: In the event no mercury could be located.
        """
        return Color.from_string(
            self._liquid.find(wait_timeout=wait_timeout).value_of_css_property(CSS.FILL.value)
        ).hex

    def get_count_of_intervals(self, wait_timeout: float = 0) -> int:
        """
        Obtain a count of currently displayed tick labels.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: A count of interval displayed for the Thermometer.
        """
        try:
            return len(self._interval.find_all(wait_timeout=wait_timeout))
        except TimeoutException:
            return 0

    def get_stroke_width_of_glass(self) -> float:
        """
        Obtain the width of the glass of the Thermometer.

        :raises TimeoutException: In the event the glass could not be located.
        """
        return float(self._stroke.find().value_of_css_property(
            CSS.STROKE_WIDTH.value).split("px")[0])

    def get_temperature_font_color(self) -> str:
        """
        Obtain the font color in use for the value label.

        :returns: The color in use for the value label as a string. Note that different
            browsers may return these values in different formats (RGB vs hex).

        :raises TimeoutException: If the value label is not currently displayed.
        """
        return Color.from_string(self._value_label.find().value_of_css_property(CSS.COLOR.value)).hex

    def get_unit_type(self,  wait_timeout: float = 0) -> str:
        """
        Obtain the units in use for the Thermometer.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: The units displayed in the unit label.

        :raises TimeoutException: If the units label is not currently displayed.
        """
        return self._unit_label.find(wait_timeout=wait_timeout).text[1:]

    def get_unit_color(self, wait_timeout: float = 0) -> str:
        """
        Obtain the font color in use for the units label.

        :param wait_timeout: The amount of time (in seconds) to wait for the Thermometer to appear before returning.

        :returns: The color in use for the units label as a string. Note that different
            browsers may return these values in different formats (RGB vs hex).

        :raises TimeoutException: If the units label is not currently displayed.
        """
        return Color.from_string(
            self._unit_label.find(wait_timeout=wait_timeout).value_of_css_property(CSS.FILL.value)
        ).hex

    def _get_list_of_levels(self) -> Union[List[str], bool]:
        """Obtain the text of all tick labels."""
        try:
            all_labels = self._labels.find_all()
            levels = []
            for label in all_labels:
                # this is less than ideal, but we're checking that we are only using the labels
                # on the side of the thermometer.
                if label.get_attribute('x') == all_labels[0].get_attribute('x'):
                    levels.append(label.text)
            return levels
        except StaleElementReferenceException:
            # doesn't raise the exception - it just returns that it failed to get the values
            return False

Components/PerspectiveComponents/Displays/Thermometer.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.PerspectiveComponents.Common.Tree import CommonTree
from Helpers.IASelenium import IASelenium


class Tree(CommonTree, BasicPerspectiveComponent):
    """A Perspective Tree Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonTree.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._items_by_path = {}

    def item_exists_in_tree(self, item_path: str, item_label: str, wait_timeout: int = 2) -> bool:
        """
        Determine if an item exists in the Tree.

        :param item_path: The zero-indexed path of the item. A value of 0/0/1 translates to 'The 1th grandchild of the
            0th child of the 0th top-level node.'
        :param item_label: The label text of the item to verify the presence of.
        :param wait_timeout: The amount of time (in seconds) to wait for the item with the supplied path to appear.

        :returns: True, if an item with the supplied text exists at the specified location - False otherwise.
        """
        try:
            return self._get_item_by_path(
                path=item_path, wait_timeout=wait_timeout).get_text() == item_label
        except TimeoutException:
            return False

    def right_click(self, **kwargs) -> None:
        """
        Right-click the Tree. As individual items do not allow for their own events, this click will always target the
        0th top-level item (if one exists).
        """
        try:
            IASelenium(driver=self.driver).right_click(
                web_element=self._get_item_by_path(path="0", wait_timeout=1).find())
        except TimeoutException:
            super().right_click()

    def _get_item_by_path(self, path: str, wait_timeout: int = 2) -> ComponentPiece:
        """Obtain a specific item based on its zero-indexed path."""
        item_component = self._items_by_path.get(path)
        if not item_component:
            item_component = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div[data-item-path="{path}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                wait_timeout=wait_timeout,
                poll_freq=self.poll_freq)
            self._items_by_path[path] = item_component
        return item_component

Components/PerspectiveComponents/Displays/Tree.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import List, Optional, Tuple, Union

from selenium.common.exceptions import TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Helpers.CSSEnumerations import CSS


class VideoPlayer(BasicPerspectiveComponent):
    """
    A Perspective Video Player Component.

    Note that unlike some other components, the Video Player does not raise AssertionErrors in the event it fails to
    apply some sort of setting. This is because other setting values on other components might drive behaviors and
    logic for a project, whereas setting values on the Video Player is highly likely to only affect an end-user.
    """
    _ERROR_MESSAGE_LOCATOR = (By.CSS_SELECTOR, "span.error-message")
    # central panel
    _PAUSED_OVERLAY_LOCATOR = (By.CSS_SELECTOR, "div.ia_videoPlayerComponent__videoOverlay--paused")
    _CENTRAL_PLAY_BUTTON_LOCATOR = (By.CSS_SELECTOR, "div.central-container.play span")
    _VIDEO_PANEL_LOCATOR = (By.CSS_SELECTOR, "video-element-wrapper")
    # control container
    _CONTROL_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.control-container")
    _BASE_CONTROL_LOCATOR = By.CSS_SELECTOR, "div.base-control"
    _PLAY_BUTTON_LOCATOR = (By.CSS_SELECTOR, "div.ia_videoPlayerComponent__controlContainer__playControl")
    _PAUSE_CLASS = "ia_videoPlayerComponent__controlContainer__playControl--playing"
    _PAUSE_BUTTON_LOCATOR = (By.CSS_SELECTOR, f"div.{_PAUSE_CLASS}")
    _SEEK_BAR_LOCATOR = (By.CSS_SELECTOR, "div.ia_videoPlayerComponent__controlContainer__seekBar")
    _SEEK_BAR_HANDLE_LOCATOR = (By.CSS_SELECTOR, "span.slider-handle")
    _ELAPSED_TIME_LOCATOR = (By.CSS_SELECTOR, "span.current-time")
    _DURATION_TIME_LOCATOR = (By.CSS_SELECTOR, "span.duration")
    _VOLUME_CONTROL_ICON_LOCATOR = (By.CSS_SELECTOR, "div.ia_videoPlayerComponent__controlContainer__volumeControl svg")
    _RATE_CONTROL_ICON_LOCATOR = (By.CSS_SELECTOR, "div.ia_videoPlayerComponent__controlContainer__playRateControl svg")
    _FULL_SCREEN_ICON_LOCATOR = (
        By.CSS_SELECTOR, "div.ia_videoPlayerComponent__controlContainer__fullscreenControl svg")
    _CONTROL_POPOVER_LOCATOR = (By.CSS_SELECTOR, "div.ia_videoPlayerComponent__controlPopupContainer")
    _VOLUME_MODAL_SLIDER_HANDLE_LOCATOR = (
        By.CSS_SELECTOR, _SEEK_BAR_HANDLE_LOCATOR[1])
    _VOLUME_PERCENT_LOCATOR = (By.CSS_SELECTOR, "div.slider-active-background")
    _PLAY_RATE_LIST_OPTIONS_LOCATOR = (By.CSS_SELECTOR, 'ul.generic-select-list li')
    _SELECTED_PLAY_RATE_OPTION = (By.CSS_SELECTOR, ".selected")

    class PlayRate(Enum):
        """An enumeration of available options for play-rate."""
        ONE_QUARTER = "0.25"
        ONE_HALF = "0.5"
        NORMAL = "Normal"
        ONE_AND_ONE_QUARTER = "1.25"
        ONE_AND_ONE_HALF = "1.5"
        DOUBLE = "2"
        QUINTUPLE = "5"
        DECUPLE = "10"

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._error_message = ComponentPiece(
            locator=self._ERROR_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._central_panel = ComponentPiece(
            locator=self._VIDEO_PANEL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._control_container = ComponentPiece(
            locator=self._CONTROL_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._base_controls = ComponentPiece(
            locator=self._BASE_CONTROL_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._central_play_button = ComponentPiece(
            locator=self._CENTRAL_PLAY_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._central_panel.locator_list,
            poll_freq=poll_freq)
        self._pause_button = ComponentPiece(
            locator=self._PAUSE_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._pause_icon = ComponentPiece(
            locator=(By.TAG_NAME, "svg"),
            driver=driver,
            parent_locator_list=self._pause_button.locator_list,
            poll_freq=poll_freq)
        self._play_button = ComponentPiece(
            locator=self._PLAY_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._play_icon = ComponentPiece(
            locator=(By.TAG_NAME, "svg"),
            driver=driver,
            parent_locator_list=self._play_button.locator_list,
            poll_freq=poll_freq)
        self._seek_bar = ComponentPiece(
            locator=self._SEEK_BAR_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._seek_handle = ComponentPiece(
            locator=self._SEEK_BAR_HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self._seek_bar.locator_list,
            poll_freq=poll_freq)
        self._elapsed_time = ComponentPiece(
            locator=self._ELAPSED_TIME_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._duration_time = ComponentPiece(
            locator=self._DURATION_TIME_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._volume_icon = ComponentPiece(
            locator=self._VOLUME_CONTROL_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._play_rate_icon = ComponentPiece(
            locator=self._RATE_CONTROL_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._fullscreen_icon = ComponentPiece(
            locator=self._FULL_SCREEN_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_container.locator_list,
            poll_freq=poll_freq)
        self._control_popover = ComponentPiece(
            locator=self._CONTROL_POPOVER_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            poll_freq=poll_freq)
        self._volume_slider_handle = ComponentPiece(
            locator=self._VOLUME_MODAL_SLIDER_HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_popover.locator_list,
            poll_freq=poll_freq)
        self._volume_percent = ComponentPiece(
            locator=self._VOLUME_PERCENT_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_popover.locator_list,
            poll_freq=poll_freq)
        self._play_rate_options = ComponentPiece(
            locator=self._PLAY_RATE_LIST_OPTIONS_LOCATOR,
            driver=driver,
            parent_locator_list=self._control_popover.locator_list,
            poll_freq=poll_freq)
        self._selected_play_rate = ComponentPiece(
            locator=self._SELECTED_PLAY_RATE_OPTION,
            driver=driver,
            parent_locator_list=self._control_popover.locator_list,
            poll_freq=poll_freq)

    def all_controls_are_disabled(self) -> bool:
        """
        Determine if all base controls for the Video Player are currently disabled.

        :returns: True, if all controls are currently disabled - False otherwise.

        :raises TimeoutException: If the base controls could not be found.
        """
        all_base_controls = self._base_controls.find_all()
        for elem in all_base_controls:
            if 'disabled' not in elem.get_attribute("class"):
                return False
        return True

    def any_error_message_is_displayed(self) -> bool:
        """
        Determine if any error message is currently displayed.

        :returns: True, if any error message is displayed - False otherwise.
        """
        try:
            return self._error_message.find(1) is not None
        except TimeoutException:
            return False

    def central_play_button_is_displayed(self) -> bool:
        """
        Determine if the central play button is currently displayed.

        :returns: True, if the play button within the video panel is displayed - False otherwise.
        """
        try:
            return self._central_play_button.find(0.5) is not None
        except TimeoutException:
            return False

    def click_central_play_button_if_displayed(self) -> None:
        """
        Click the central play button if it is currently displayed, otherwise do nothing.
        """
        if self.central_play_button_is_displayed():
            self._central_play_button.click()

    def click_central_play_button(self) -> None:
        """
        Click the central play button located in the video panel.

        :raises TimeoutException: If the central play button is not found.
        """
        self._central_play_button.click()

    def click_fullscreen_icon(self) -> None:
        """
        Click the full-screen icon.

        :raises TimeoutException: If the full-screen icon is not present.
        """
        self._fullscreen_icon.click()

    def click_pause(self) -> None:
        """
        Click the pause option in the bottom-left of the Video Player.

        :raises TimeoutException: If the pause option is not present. Expected while the Video Player is already paused.
        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        self._pause_icon.click()

    def click_play(self) -> None:
        """
        Click the play option in the bottom-left of the Video Player.

        :raises TimeoutException: If the play option is not present. Expected while the Video Player is already playing.
        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        self._play_icon.click()

    def click_play_rate_icon(self) -> None:
        """
        Click the play-rate icon (gauge) located in the controls panel.

        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        self._play_rate_icon.click()

    def click_volume_icon(self) -> None:
        """
        Click the volume icon (speaker) located in the controls panel.

        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        self._volume_icon.click(binding_wait_time=0.25)

    def controls_are_displayed(self) -> bool:
        """
        Determine if the controls panel is currently displayed.

        :returns: True, if the controls panel which contains the play/pause options, scrubber, volume, play-rate, and
            full-screen options is displayed.
        """
        try:
            # verify the bottom of the controls panel is 0 relative to the bottom of its parent (the Video Player)
            bottom = self._control_container.get_css_property(property_name=CSS.BOTTOM)
            return (bottom is not None) and (len(bottom.split('px')) > 1) and (int(float(bottom.split('px')[0])) == 0)
        except TimeoutException:
            return False

    def drag_seek_handle_to_time(self, time_as_hour_minute_second_string: str) -> None:
        """
        Drag the seek handle (scrubber) to a specific colon-delimited time. Currently only supports changes in seconds.
        This function takes longer the larger the Video Player is because it drags one pixel at a time.

        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        desired_seconds = time_as_hour_minute_second_string.split(':')[-1]
        current_seconds = self.get_elapsed_time().split(':')[-1]
        directional_modifier = 1 if desired_seconds > current_seconds else -1
        value_needs_adjustment = desired_seconds != current_seconds
        if value_needs_adjustment:
            ActionChains(driver=self.driver).click_and_hold(on_element=self._seek_handle.find()).perform()
        if desired_seconds < current_seconds:
            while desired_seconds < current_seconds:
                ActionChains(driver=self.driver).move_by_offset(xoffset=directional_modifier, yoffset=0).perform()
                current_seconds = self.get_elapsed_time().split(':')[-1]
        elif desired_seconds > current_seconds:
            while desired_seconds > current_seconds:
                ActionChains(driver=self.driver).move_by_offset(xoffset=directional_modifier, yoffset=0).perform()
                current_seconds = self.get_elapsed_time().split(':')[-1]
        if value_needs_adjustment:
            ActionChains(driver=self.driver).release().perform()

    def get_current_play_rate(self) -> str:
        """
        Obtain the current play rate of the Video Player.

        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        if not self._play_rate_options_displayed():
            self._play_rate_icon.click()
        try:
            return self._selected_play_rate.get_text()
        finally:
            self._play_rate_icon.click()

    def get_current_volume(self) -> int:
        """
        Obtain the current volume of the Video Player.

        :returns: The current volume of the Video Player as an integer value.

        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        # When working on QA-1563, we found that .get_css_property() always returns px value instead of %
        # This is on the Selenium side so this method remains using .get_attribute('style') for it to work
        modal_displayed_originally = self._volume_modal_is_displayed()
        if not modal_displayed_originally:
            self._volume_icon.click()
        try:
            return int(self._volume_percent.find(wait_timeout=1).get_attribute("style").split(' ')[-1].split('%')[0])
        finally:
            if not modal_displayed_originally:
                self.click_volume_icon()

    def get_duration(self) -> str:
        """
        Obtain the duration (length) of the currently-loaded video.

        :returns: The duration of the current video as a colon-delimited string. The structure of this string may change
            depending on the length of the video. For example, videos less than one hour long only return minutes and
            seconds (MM:SS).
        """
        return self._duration_time.get_text()

    def get_duration_in_seconds(self) -> int:
        """
        Obtain the duration of the video in a measurement of seconds.

        :returns: The duration of the video in seconds, instead of HH:MM:SS.
        """
        return VideoPlayer._convert_time_to_seconds(time_as_str=self.get_duration())

    def get_elapsed_time(self) -> str:
        """
        Obtain the elapsed time of the video.

        :returns: The elapsed time of the current video as a colon-delimited string. The structure of this string may
            change depending on the length of the video. For example, videos less than one hour long only return minutes
            and seconds (MM:SS).
        """
        return self._elapsed_time.find().text

    def get_elapsed_time_in_seconds(self) -> int:
        """
        Obtain the elapsed time of the video in a measurement of seconds.

        :returns: The elapsed time of the video in seconds, instead of HH:MM:SS.
        """
        return VideoPlayer._convert_time_to_seconds(time_as_str=self.get_elapsed_time())

    def pause_icon_is_displayed(self) -> bool:
        """
        Determine if the pause icon is currently displayed.

        :returns: True, if the pause icon is currently displayed in the control panel. False if the pause icon is not
            currently displayed - expected while the video is currently paused.
        """
        try:
            return self._pause_button.find() is not None
        except TimeoutException:
            return False

    def play_icon_is_displayed(self) -> bool:
        """
        Determine if the play icon is currently displayed.

        :returns: True, if the play icon is currently displayed in the control panel. False if the play icon is not
            currently displayed - expected while the video is currently playing.
        """
        try:
            return self._PAUSE_CLASS not in self._play_button.find().get_attribute("class")
        except TimeoutException:
            return False

    def set_play_rate(self, desired_rate: PlayRate) -> None:
        """
        Set the play rate of the Video Player.

        :param desired_rate: The desired rate as an enumeration.

        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        """
        if desired_rate.value != self.get_current_play_rate():
            if not self._play_rate_options_displayed():
                self._play_rate_icon.click()
                self._select_play_rate(desired_rate=desired_rate)
                self._play_rate_icon.click()

    def set_volume(self, desired_volume: Union[int, str]) -> None:
        """
        Set the volume of the Video Player.

        :param desired_volume: The value (0<=desired_value<=100) to which the volume will be set.

        :raises ElementClickInterceptedException: If the base controls are configured to hide during times of
            inactivity.
        :raises ValueError: If the value is invalid.
        """
        desired_volume = int(desired_volume)
        if (0 > desired_volume) or (desired_volume > 100):
            raise ValueError("Volume only accepts values between 0 and 100.")
        if desired_volume != self.get_current_volume():
            self._set_volume(desired_volume)

    def _play_rate_options_displayed(self) -> bool:
        """Determine if the play-rate options are displayed."""
        return len(self._play_rate_options.find_all(wait_timeout=0.5)) > 0

    def _select_play_rate(self, desired_rate: PlayRate) -> None:
        """Set the play rate of the Video Player."""
        options = self._play_rate_options.find_all()
        for option in options:
            if option.text == desired_rate.value:
                option.click()
                break

    def _set_volume(self, desired_volume: int) -> None:
        """Set the volume of the Video Player."""
        desired_volume = int(desired_volume)
        current_volume = self.get_current_volume()
        directional_modifier = 1 if current_volume > desired_volume else -1
        value_needs_adjustment = current_volume != desired_volume
        if value_needs_adjustment:
            self.click_volume_icon()
            ActionChains(driver=self.driver).click_and_hold(on_element=self._volume_slider_handle.find()).perform()
        while current_volume != desired_volume:
            ActionChains(driver=self.driver).move_by_offset(xoffset=0, yoffset=directional_modifier).perform()
            current_volume = int(self._volume_percent.find(
                wait_timeout=1).get_attribute("style").split(' ')[-1].split('%')[0])
        if value_needs_adjustment:
            ActionChains(driver=self.driver).release().perform()
        if self._volume_modal_is_displayed():
            self.click_volume_icon()

    def _volume_modal_is_displayed(self) -> bool:
        """Determine if the volume modal is displayed."""
        try:
            return self._volume_slider_handle.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    @staticmethod
    def _convert_time_to_seconds(time_as_str: str) -> int:
        """Convert a colon-delimited time string to a count of seconds."""
        if len(time_as_str.split(":")) == 2:
            # ensure the presence of hours in the string
            time_as_str = "00:" + time_as_str
        ftr = [3600, 60, 1]  # seconds in hour, seconds in minute, seconds in second
        return sum([a * b for a, b in zip(ftr, map(int, time_as_str.split(':')))])

Components/PerspectiveComponents/Displays/VideoPlayer.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, Optional, List

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon


class Accordion(BasicPerspectiveComponent):
    """A Perspective Accordion Component."""
    _GENERIC_ITEM_HEADER_LOCATOR = (By.CSS_SELECTOR, 'div.ia_accordionComponent__header')
    _ITEM_HEADER_ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.ia_accordionComponent__header__chevron')
    _ITEM_HEADER_TEXT_LOCATOR = (By.CSS_SELECTOR, 'div.ia_accordionComponent__header__text')
    _ITEM_HEADER_VIEW_LOCATOR = (By.CSS_SELECTOR, 'div.ia_accordionComponent__header__view')
    _BODY_CONTENT_MARGIN_IN_PIXELS = 16
    _BODY_BORDER = 1  # Body has border on bottom only

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._headers = ComponentPiece(
            locator=self._GENERIC_ITEM_HEADER_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list)
        self._body_collection = {}
        self._header_collection = {}
        self._header_icon_collection = {}
        self._header_text_collection = {}

    def click_body(self, index: int) -> None:
        """
        Click the body of an Accordion panel.

        :param index: The zero-based index of the panel for which you would like to click the body.

        :raises TimeoutException: If the supplied index is invalid.
        :raises ClickInterceptedException: If the targeted body is not already expanded.
        """
        self._get_body_by_index(index=index).click()

    def click_header(self, index: int) -> None:
        """
        Click the header of an Accordion panel.

        :param index: The zero-based index of the header you would like to click.

        :raises TimeoutException: If the supplied index is invalid.
        """
        self._get_header_by_index(index=index).click()

    def click_header_icon(self, index: int) -> None:
        """
        Click the icon located in the header of an Accordion panel.

        :param index: The zero-based index of the header for which you would like to click the icon.

        :raises TimeoutException: If the supplied index is invalid.
        """
        self._get_header_icon_by_index(index=index).click()

    def click_header_text(self, index: int) -> None:
        """
        Click the text of an Accordion header.

        :param index: The zero-based index of the header for which you would like to click the text.

        :raises TimeoutException: If the supplied index is invalid.
        """
        self._get_header_text_component_by_index(index=index).click()

    def get_available_body_height(self, index: int) -> float:
        """
        Obtain the available height (in pixels) for an Accordion panel's body.

        :param index: The zero-based index of the panel for which you would like to obtain the available height for the
            body.

        :returns: The height (in pixels) which a panel's body has available for display.

        :raises TimeoutException: If the supplied index is invalid.
        """
        # there is no top border on body, so only subtract bottom border
        return (float(self.get_body_height(index=index))
                - (self._BODY_CONTENT_MARGIN_IN_PIXELS * 2)) - Accordion._BODY_BORDER

    def get_available_body_width(self, index: int) -> float:
        """
        Obtain the available width (in pixels) for an Accordion panel's body.

        :param index: The zero-based index of the panel for which you would like to obtain the available width for the
            body.

        :returns: The width (in pixels) which a panel's body has available for display.

        :raises TimeoutException: If the supplied index is invalid.
        """
        # there is no left/right border on body
        return self.get_body_width(index=index) - (self._BODY_CONTENT_MARGIN_IN_PIXELS * 2)

    def get_body_height(self, index: int) -> float:
        """
        Obtain the height (in pixels) of a body panel. Note this may not be the height of a rendered View.

        :param index: The zero-based index of the panel for which you would like to obtain the height for the body
            panel.

        :returns: The height (in pixels) of a body panel.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return float(self._get_body_by_index(index=index).get_computed_height())

    def get_body_width(self, index: int):
        """
        Obtain the width (in pixels) of a body panel. Note this may not be the width of a rendered View.

        :param index: The zero-based index of the panel for which you would like to obtain the width for the body panel.

        :returns: The width (in pixels) of a body panel.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return float(self._get_body_by_index(index=index).get_computed_width())

    def get_count_of_headers(self) -> int:
        """
        Obtain a count of headers.
        """
        return len(self._headers.find_all())

    def get_header_height(self, index: int) -> float:
        """
        Obtain the height of a header.

        :param index: The zero-based index of the header for which you would like the height.

        :returns: The height (in pixels) of the header.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return float(self._get_header_by_index(index=index).get_computed_height())

    def get_header_icon_fill_color(self, index: int) -> str:
        """
        Obtain the fill in use by the icon of a header.

        :param index: The zero-based index of the header from which you would like to obtain the fill color for the
            contained icon.

        :returns: The fill color of the icon as a string. Note that this color might report as RGB or hex based on
            the browser in use.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return self._get_header_icon_by_index(index=index).get_fill_color()

    def get_header_icon_name(self, index: int) -> str:
        """
        Obtain a slash-delimited path for the icon in use for the header.

        :param index: The zero-based index of the header from which you would like to obtain the path for the
            contained icon.

        :returns: The slash-delimited path of the icon.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return self._get_header_icon_by_index(index=index).get_icon_name()

    def get_header_icon_height(self, index: int) -> float:
        """
        Obtain the height of the icon in use by a header.

        :param index: The zero-based index of the header from which you would like to obtain the height of the
            contained icon.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return float(self._get_header_icon_by_index(index=index).get_computed_height())

    def get_header_icon_width(self, index: int) -> float:
        """
        Obtain the width of the icon in use by a header.

        :param index: The zero-based index of the header from which you would like to obtain the width of the
            contained icon.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return float(self._get_header_icon_by_index(index=index).get_computed_width())

    def get_header_text(self, index: int) -> str:
        """
        Obtain the text contained within the header.

        :param index: The zero-based index of the header from which you would like the text.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return self._get_header_text_component_by_index(index=index).get_text()

    def get_header_width(self, index: int) -> float:
        """
        Obtain the width of a header.

        :param index: The zero-based index of the header for which you would like the width.

        :returns: The width (in pixels) of the header.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return float(self._get_header_by_index(index=index).get_computed_width())

    def header_is_enabled(self, index: int) -> bool:
        """
        Determine if a header is enabled.

        :param index: The zero-bsed index of the header for which you would like to determine the enabled state.

        :returns: True, if the header is enabled - False otherwise.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return '--disabled' not in self._get_header_by_index(index=index).find().get_attribute('class')

    def header_is_in_reverse_render_mode(self, index: int) -> bool:
        """
        Determine if the header is configured to display in reverse.

        :param index: The zero-based index of the header to check for reversal.

        :returns: True, if the icon of the header is displaying to the right of the header content.

        :raises TimeoutException: If the supplied index is invalid.
        """
        icon_x = float(self._get_header_icon_by_index(index=index).get_origin().X)
        content_x = float(self._get_header_content_div(index=index).get_origin().X)
        return icon_x > content_x

    def header_is_in_text_render_mode(self, index: int):
        """
        Determine if the header is displaying only text.

        :param index: The zero-based index of the header to check.

        :returns: True, if the header is displaying in text mode. False, if the header is rendering as a View.
        """
        try:
            return self._get_header_text_component_by_index(index=index).find() is not None
        except TimeoutException:
            return False

    def item_is_expanded(self, index: int) -> bool:
        """
        Determine if an item of the accordion is expanded.

        :param index: The zero-based index of the item to check for expansion.

        :returns: True, if the item is expanded - False otherwise.

        :raises TimeoutException: If the supplied index is invalid.
        """
        return '--collapsed' not in self._get_body_by_index(index=index).find().get_attribute('class')

    def _get_body_by_index(self, index: int) -> ComponentPiece:
        """Obtain the body of an item."""
        body = self._body_collection.get(index)
        if not body:
            body = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div.ia_accordionComponent__body[data-index="{index}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list)
            self._body_collection[index] = body
        return body

    def _get_header_by_index(self, index: int) -> ComponentPiece:
        """Obtain the header of an item."""
        header = self._header_collection.get(index)
        if not header:
            header = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{self._GENERIC_ITEM_HEADER_LOCATOR[1]}[data-index="{index}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list)
            self._header_collection[index] = header
        return header

    def _get_header_content_div(self, index: int) -> ComponentPiece:
        """
        Obtain the content div located inside a header.
        """
        if self.header_is_in_text_render_mode(index=index):
            locator = self._ITEM_HEADER_TEXT_LOCATOR
        else:
            locator = self._ITEM_HEADER_VIEW_LOCATOR
        return ComponentPiece(
            locator=locator,
            driver=self.driver,
            parent_locator_list=self._headers.locator_list,
            poll_freq=self.poll_freq)

    def _get_header_icon_by_index(self, index: int) -> CommonIcon:
        """
        Obtain an icon from inside a header.
        """
        icon = self._header_icon_collection.get(index)
        if not icon:
            icon = CommonIcon(
                locator=self._ITEM_HEADER_ICON_LOCATOR,
                driver=self.driver,
                parent_locator_list=self._get_header_by_index(index=index).locator_list)
            self._header_icon_collection[index] = icon
        return icon

    def _get_header_text_component_by_index(self, index: int) -> ComponentPiece:
        """Obtain the text component from within a header."""
        header_text = self._header_text_collection.get(index)
        if not header_text:
            header_text = ComponentPiece(
                locator=self._ITEM_HEADER_TEXT_LOCATOR,
                driver=self.driver,
                parent_locator_list=self._get_header_by_index(index=index).locator_list,
                wait_timeout=2)
            self._header_text_collection[index] = header_text
        return header_text

Components/PerspectiveComponents/Embedding/Accordion.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple, Union
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.CSSEnumerations import CSS, CSSPropertyValue


class Carousel(BasicPerspectiveComponent):
    """
    A Perspective Carousel Component.

    Note that many functions contained within this class are reliant on the presence of dots to determine the indices of
    active slides.
    """
    _ARROW_LOCATOR = (By.CSS_SELECTOR, "a.arrow")
    _DOT_LOCATOR = (By.CSS_SELECTOR, "a.dot")
    _ACTIVE_DOT_LOCATOR = (By.CSS_SELECTOR, f"{_DOT_LOCATOR[1]}.active")
    _VIEW_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.view-container:not(.leading-clone):not(.trailing-clone)')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._arrows = ComponentPiece(
            locator=self._ARROW_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._dot_coll = {}
        self._dot_icon_coll = {}
        self._dots = ComponentPiece(
            locator=self._DOT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._active_dot = ComponentPiece(
            locator=self._ACTIVE_DOT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._view_containers = ComponentPiece(
            locator=self._VIEW_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)

    def arrows_are_displayed(self) -> bool:
        """
        Determine if the Carousel is displaying arrows to the left and right of the slides.

        :returns: True, if arrows are displayed - False otherwise.
        """
        try:
            return self._arrows.find() is not None
        except TimeoutException:
            return False

    def click_dot_by_index(self, zero_based_index: int, wait_for_transition: bool = True) -> None:
        """
        Click a zero-indexed dot of the Carousel. The click is made regardless of active/inactive status.

        :param zero_based_index: The index of the dot to click.
        :param wait_for_transition: Dictates whether the code should wait for any animation to complete before
            continuing.

        :raises TimeoutException: If no dots are available to click or the supplied index is invalid.
        """
        current_index = self.get_current_index_from_dots()
        self._get_dot(
            zero_based_index=zero_based_index).click()
        if wait_for_transition:
            try:
                # We identify dots based on their active/inactive classes. By waiting for the inactive dot to disappear,
                # we are essentially waiting for the transition to have been completed and the active dot to have
                # appeared.
                self.wait.until(
                    ec.invisibility_of_element_located(
                        self._get_dot(
                            zero_based_index=current_index, must_be_active=False).get_full_css_locator()))
            except TimeoutException:
                pass

    def click_next_arrow(self, transition_speed: int = 1) -> None:
        """
        Click the 'next' arrow in order to transition to the next slide.

        :param transition_speed: The amount of time to wait while the Carousel goes through its transition. Synonymous
            with binding_wait_time.

        :raises TimeoutException: If no arrows are found.
        :raises IndexError: If only 1 arrow is found? Should never happen unless a user hides the next arrow through
            theming.
        """
        try:
            self.wait.until(IAec.function_returns_true(
                custom_function=self.next_arrow_is_enabled,
                function_args={}))
        except TimeoutException:
            pass
        # We have to perform a wait due to a FireFox issue not clicking the next arrow button.
        self.wait_on_binding(time_to_wait=0.25)
        self._arrows.find_all()[1].click()
        self._arrows.wait_on_binding(time_to_wait=transition_speed)

    def click_previous_arrow(self, transition_speed: int = 1) -> None:
        """
        Click the 'previous' arrow in order to transition to the previous slide.

        :param transition_speed: The amount of time to wait while the Carousel goes through its transition. Synonymous
            with binding_wait_time.

        :raises TimeoutException: If no arrows are found.
        :raises IndexError: If only 1 arrow is found? Should never happen unless a user hides the previous arrow through
            theming.
        """
        try:
            self.wait.until(IAec.function_returns_true(
                custom_function=self.previous_arrow_is_enabled,
                function_args={}))
        except TimeoutException:
            pass
        # We have to perform a wait due to a FireFox issue not clicking the previous arrow button.
        self.wait_on_binding(time_to_wait=0.25)
        self._arrows.find_all()[0].click()
        self._arrows.wait_on_binding(time_to_wait=transition_speed)

    def get_index_of_next_slide(self, current_index: Optional[int] = None) -> int:
        """
        Obtain the zero_based_index of the next slide. If current_index is supplied and is the last slide, or if
        current_index is not supplied and the Carousel is displaying the last slide, 0 will be returned.

        :param current_index: If None, we will return the next index based on the current display state of the Carousel.
            If not None, we will return the next index based on the provided index. This is most often used when
            dealing with what could potentially be the 'last' slide while attempting to determine if the 'next' slide
            would be the 'first' slide.

        :raises TimeoutException: If no dots are available from which to determine the indices.
        """
        if current_index is None:
            current_index = self.get_current_index_from_dots()
        current_index = int(current_index)
        slide_count = self.get_count_of_dots()
        if slide_count > 1:
            return (current_index + 1) % slide_count
        else:
            return current_index

    def determine_index_of_previous_slide(self, current_index: Optional[int] = None) -> int:
        """
        Obtain the zero_based_index of the previous slide. If current_index is supplied and is the first slide, or if
        current_index is not supplied and the Carousel is displaying the first slide, the index of the final slide will
        be returned.

        :param current_index: If None, we will return the previous index based on the current display state of the
            Carousel. If not None, we will return the previous index based on the provided index. This is most often
            used when dealing with what could potentially be the 'first' slide while attempting to determine if the
            'previous' slide would be the 'last' slide.

        :raises TimeoutException: If no dots are available from which to determine the indices.
        """
        if current_index is None:
            current_index = self.get_current_index_from_dots()
        current_index = int(current_index)
        slide_count = self.get_count_of_dots()
        if slide_count > 1:
            expected = current_index - 1
            return expected if expected >= 0 else slide_count - 1
        else:
            return current_index

    def dots_are_present(self) -> bool:
        """
        Determine if the Carousel is currently displaying the dots which convey which slide is active.

        :returns: True, if any dots are displayed - False otherwise.
        """
        try:
            return self._dots.find() is not None
        except TimeoutException:
            return False

    def fade_is_enabled(self) -> bool:
        """
        Determine if the Carousel is currently configured to fade during transitions.

        :returns: True, if the Carousel would fade in and out during slide transitions - False otherwise.
        """
        return 'opacity' == self.get_css_property(property_name=CSS.TRANSITION)

    def get_current_slide_display_area_height(self, include_units: bool = False) -> str:
        """
        Obtain the current height available for slides to display in.
        """
        return self._view_containers.get_computed_height(include_units=include_units)

    def get_current_slide_display_area_width(self, include_units: bool = False) -> str:
        """
        Obtain the current width available for slides to display in.
        """
        return self._view_containers.get_computed_width(include_units=include_units)

    def get_current_slide_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of some CSS property of the container of the current slide. Note that this is not a property
        of the rendered View.

        :param property_name: The name of the CSS property for which you would like the value.
        """
        return self._view_containers.get_css_property(property_name=property_name)

    def get_count_of_dots(self) -> int:
        """
        Obtain the count of rendered dots of the Carousel.
        """
        try:
            return len(self._dots.find_all(wait_timeout=0))
        except TimeoutException:
            return 0

    def get_count_of_slides(self) -> int:
        """
        Obtain the count of slides within the Carousel. This is not a count of how many slides are currently displayed.
        """
        return len(self._view_containers.find_all())

    def get_current_index_from_dots(self) -> int:
        """
        Obtain the index of the currently displayed slide from the dots of the Carousel.
        """
        return int(self._active_dot.find().get_attribute("data-index"))

    def get_dot_icon_css_property_by_name(
            self, zero_based_index: int, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain a CSS property value from the icon of a Carousel dot.

        :param zero_based_index: The index of the dot from which you would like the CSS property value.
        :param property_name: The name of the CSS property of which you would like the value.

        :raises TimeoutException: If no dots are found, or if the provided index is not valid.
        """
        return self._get_dot_icon(
            zero_based_index=zero_based_index).get_css_property(property_name=property_name)

    def get_dot_icon_path(self, zero_based_index: int) -> str:
        """
        Obtain a slash-delimited path for the icon in use by a Carousel dot.

        :raises TimeoutException: If no dots are found, or if the provided index is not valid.
        """
        return self._get_dot_icon(
            zero_based_index=zero_based_index).get_icon_name()

    def next_arrow_is_enabled(self) -> bool:
        """
        Determine if the 'next' arrow is currently enabled.

        :returns: True, if the 'next' arrow is enabled - False otherwise.

        :raises TimeoutException: If no arrows are found.
        :raises IndexError: If only 1 arrow is found? Should never happen unless a user hides the next arrow through
            theming.
        """
        return "disabled" not in self._arrows.find_all()[1].get_attribute("class")

    def pause_by_hovering(self, binding_wait_time: float = 1) -> None:
        """
        Attempt to pause the Carousel by hovering the mouse over the Component. Note that this function only has any
        effect if the Carousel is configured to pause while a user hovers over it.

        :param binding_wait_time: How long (in seconds) to wait after hovering over the component before allowing code
            to continue.
        """
        self.hover()
        self.wait_on_binding(time_to_wait=binding_wait_time)

    def pause_by_hovering_on_dots(self, binding_wait_time: float = 1) -> None:
        """
        Attempt to pause the Carousel by hovering the mouse over the dots of the Carousel. Note that this function only
        has any effect if the Carousel is configured to pause while a user hovers over the dots of the Carousel.

        :param binding_wait_time: How long (in seconds) to wait after hovering over the component before allowing code
            to continue.
        """
        self._dots.hover()
        self._dots.wait_on_binding(time_to_wait=binding_wait_time)

    def previous_arrow_is_enabled(self) -> bool:
        """
        Determine if the 'previous' arrow of the Carousel is currently enabled.

        :returns: True, if the 'previous' arrow is currently enabled - False otherwise.

        :raises TimeoutException: If no arrows are found.
        :raises IndexError: If only 1 arrow is found? Should never happen unless a user hides the previous arrow through
            theming.
        """
        return "disabled" not in self._arrows.find_all()[0].get_attribute("class")

    def wait_for_next_slide(self, original_index: int, time_to_wait: float = 10) -> int:
        """
        Wait for the Carousel to transition to the next slide.

        :param original_index: The zero-based index you are waiting for the Carousel to transition away from.
        :param time_to_wait: The amount of time (in seconds) you are willing to wait for the Carousel to arrive at the
            next index after original_index.

        :returns: The index the Carousel is currently rendering after having waited for the transition from the
            original_index.

        :raises TimeoutException: If no dots are found.
        """
        try:
            self._get_dot(
                zero_based_index=self.get_index_of_next_slide(
                    current_index=original_index), must_be_active=True).find(wait_timeout=time_to_wait)
        except TimeoutException:
            pass
        return self.get_current_index_from_dots()

    def wait_for_previous_slide(self, original_index: int, time_to_wait: float = 10) -> int:
        """
        Wait for the Carousel to transition to the previous slide.

        :param original_index: The zero-based index you are waiting for the Carousel to transition away from.
        :param time_to_wait: The amount of time (in seconds) you are willing to wait for the Carousel to arrive at the
            previous index from original_index.

        :returns: The index the Carousel is currently rendering after having waited for the transition from the
            original_index.

        :raises TimeoutException: If no dots are found.
        """
        try:
            self._get_dot(
                zero_based_index=self.determine_index_of_previous_slide(
                    current_index=original_index), must_be_active=True).find(wait_timeout=time_to_wait)
        except TimeoutException:
            pass
        return self.get_current_index_from_dots()

    def _get_dot(self, zero_based_index: int, must_be_active: bool = False) -> ComponentPiece:
        """
        Obtain a ComponentPiece which describes a dot of the Carousel.

        :param zero_based_index: The index of the Carousel dot.
        :param must_be_active: Dictates whether the described dot is an active or inactive dot.
        """
        dot = self._dot_coll.get((zero_based_index, must_be_active))
        if not dot:
            locator = self._ACTIVE_DOT_LOCATOR[1] if must_be_active else self._DOT_LOCATOR[1]
            dot = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{locator}[data-index="{zero_based_index}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq)
            self._dot_coll[(zero_based_index, must_be_active)] = dot
        return dot

    def _get_dot_icon(self, zero_based_index: int) -> CommonIcon:
        """
        Obtain a CommonIcon contained within a Carousel dot.

        :param zero_based_index: The index of the Carousel dot this icon lives in.
        """
        icon = self._dot_icon_coll.get(zero_based_index)
        if not icon:
            icon = CommonIcon(
                locator=(By.CSS_SELECTOR, "svg"),
                driver=self.driver,
                parent_locator_list=self._get_dot(
                    zero_based_index=zero_based_index).locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq)
            self._dot_icon_coll[zero_based_index] = icon
        return icon

Components/PerspectiveComponents/Embedding/Carousel.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, List, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS


class EmbeddedView(BasicPerspectiveComponent):
    """A Perspective Embedded View Component."""
    _PRIMARY_MESSAGE_LOCATOR = (By.CSS_SELECTOR, 'div.message-primary')
    _SECONDARY_MESSAGE_LOCATOR = (By.CSS_SELECTOR, 'div.message-secondary')
    _STATE_DISPLAY_LOCATOR = (By.CSS_SELECTOR, "div.view-state-display")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._primary_message = ComponentPiece(
            locator=self._PRIMARY_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._secondary_message = ComponentPiece(
            locator=self._SECONDARY_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._state_display = ComponentPiece(
            locator=self._STATE_DISPLAY_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)

    def get_primary_message(self) -> str:
        """
        Obtain the primary message of the Embedded View. This is the message seen when a View is not properly
        configured.

        :raises TimeoutException: If a View is properly configured - and therefore not displaying a config error message
            - or the Embedded View is not found.
        """
        return self._primary_message.get_text()

    def get_secondary_message(self) -> str:
        """
        Obtain the secondary message of the Embedded View. This is the message seen when a View is not properly
        configured.

        :raises TimeoutException: If a View is properly configured - and therefore not displaying a config error message
            - or the Embedded View is not found.
        """
        return self._secondary_message.get_text()

    def get_state_display_background(self) -> str:
        """
        Obtain the background color of the Embedded View. Usually only useful when a View is not properly configured.
        """
        return self._state_display.get_css_property(property_name=CSS.BACKGROUND_COLOR)

    def view_path_is_invalid(self) -> bool:
        """
        Determine if the currently applied View path is INVALID.

        :returns: True, if the currently applied View path is invalid - False if the currently applied View path is
            valid.
        """
        try:
            self._primary_message.find()
            self._secondary_message.find()
            return True
        except TimeoutException:
            return False

    def view_path_is_blank(self) -> bool:
        """
        Determine if the currently applied View path is an empty string. This function is non-conclusive, as it only
        verifies the Embedded View contains no text. There is still a possibility that the embedded View could be valid
        and rendered, but contain no text.

        :returns: True, if the Embedded View contains no text content - False otherwise.
        """
        return self.get_text() == ''

Components/PerspectiveComponents/Embedding/EmbeddedView.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, List, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class FlexRepeater(BasicPerspectiveComponent):
    """
    A Perspective Flex Repeater Component.
    """
    _DIRECT_CHILD_LOCATOR = (By.CSS_SELECTOR, '>div')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            description=description,
            poll_freq=poll_freq)
        self._children = ComponentPiece(
            locator=self._DIRECT_CHILD_LOCATOR, driver=driver, parent_locator_list=self.locator_list)

    def get_count_of_instances(self) -> int:
        """Obtain a count of instances within the Flex Repeater."""
        try:
            return len(self._children.find_all())
        except TimeoutException:
            return 0

Components/PerspectiveComponents/Embedding/FlexRepeater.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS


class ViewCanvas(BasicPerspectiveComponent):
    """
    A Perspective View Canvas Component.

    Functions for this class only provide insight into the View Canvas, and not the Views instanced by the View Canvas.
    """
    _CHILD_VIEW_LOCATOR = (By.CSS_SELECTOR, "> div")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._children = ComponentPiece(
            locator=self._CHILD_VIEW_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)

    def get_count_of_instanced_views(self) -> int:
        """
        Obtain a count of the Views instanced by this View Canvas.
        """
        try:
            return len(self._children.find_all())
        except TimeoutException:
            return 0

    def get_transition_duration(self) -> str:
        """
        Obtain the transition duration for the View Canvas.

        :raises TimeoutException: If no Views are currently instanced by this View Canvas.
        """
        return self._children.get_css_property(property_name=CSS.TRANSITION_DURATION)

    def get_transition_timing_function(self) -> str:
        """
        Obtain the transition timing function for the View Canvas.

        :raises TimeoutException: If no Views are currently instanced by this View Canvas.
        """
        return self._children.get_css_property(property_name=CSS.TRANSITION_TIMING_FUNCTION)

Components/PerspectiveComponents/Embedding/ViewCanvas.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class BarcodeScanner(BasicPerspectiveComponent):
    """A Perspective Barcode Scanner Component."""
    _LABEL_LOCATOR = (By.CSS_SELECTOR, 'li')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._label = ComponentPiece(locator=self._LABEL_LOCATOR, driver=driver, wait_timeout=1, poll_freq=poll_freq)

    def is_displaying_value(self) -> bool:
        """
        Determine if the component is displaying any value.

        :returns: True, if any text content is displayed by the component - False otherwise.
        """
        try:
            return self._label.find().is_displayed() and (len(self._label.get_text()) > 0)
        except TimeoutException:
            return False

    def get_text(self, **kwargs) -> Optional[str]:
        """
        Retrieve text content that is displaying in the component.

        :returns: The text content that is displayed by the component. If no text is displayed by the component, it
            will return a 'None'.
        """
        try:
            return self._label.get_text()
        except TimeoutException:
            return None

Components/PerspectiveComponents/Inputs/BarcodeScanner.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, List, Optional

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent
from Components.Common.Button import CommonButton


class Button(CommonButton, BasicPerspectiveComponent):
    """A Perspective Button Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonButton.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

Components/PerspectiveComponents/Inputs/Button.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.Checkbox import CommonCheckbox


class Checkbox(CommonCheckbox, BasicPerspectiveComponent):
    """A Perspective Checkbox Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonCheckbox.__init__(
            self, locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self, locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

Components/PerspectiveComponents/Inputs/Checkbox.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple, Union

from selenium.common.exceptions import TimeoutException, NoSuchAttributeException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.select import Select

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Components.PerspectiveComponents.Common.DateTimePicker import CommonDateTimePicker, PerspectiveDate


class DateTimePicker(CommonDateTimePicker, BasicPerspectiveComponent):
    """A Perspective DateTime Picker - distinct from the DateTime Input."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonDateTimePicker.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

    def get_selected_day(self) -> Optional[int]:
        """
        Obtain the currently selected day of the DateTime Picker.

        :returns: The currently selected day of the DateTime Picker, or None if no day is selected.
        """
        try:
            # use inherited function which in this case should only return a list with one day.
            return int(self.get_selected_days()[0])
        except IndexError:
            return None

    def is_in_date_time_mode(self) -> bool:
        """
        Determine if the DateTime Picker is currently displaying the hours/minutes inputs.

        :returns: True, if the DateTime Picker is currently displaying the hours and minutes input fields - False
            otherwise.
        """
        try:
            return self._hours_input.find(wait_timeout=0) is not None and \
                self._minutes_input.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def is_seconds_input_displayed(self) -> bool:
        """
        Determine if the DateTime Picker is currently displaying the input for seconds.

        :returns: True, if the seconds input field is displayed - False otherwise.
        """
        try:
            return self._seconds_input.find(0) is not None
        except TimeoutException:
            return False


class DateTimeInput(BasicPerspectiveComponent):
    """A Perspective DateTime Input - distinct from the DateTime Picker."""
    _TIME_CLASS = 'iaTimePickerInput--wrapper'
    _PICKER_LOCATOR = (By.CSS_SELECTOR, "div.ia_dateTimePicker")
    _INPUT_LOCATOR = (By.TAG_NAME, "input")
    _LOCAL_HOURS_INPUT_LOCATOR = (By.CSS_SELECTOR, "")
    _LOCAL_MINUTES_INPUT_LOCATOR = (By.CSS_SELECTOR, "")
    _LOCAL_AM_PM_SELECT_LOCATOR = (By.CSS_SELECTOR, "")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        # picker is in a modal
        self._picker = DateTimePicker(
            locator=self._PICKER_LOCATOR,
            driver=driver,
            parent_locator_list=ComponentModal(
                driver=driver).locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        self._input = ComponentPiece(
            locator=self._INPUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._local_hours_input = ComponentPiece(
            locator=self._LOCAL_HOURS_INPUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._local_minutes_input = ComponentPiece(
            locator=self._LOCAL_MINUTES_INPUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._local_am_pm_select = ComponentPiece(
            locator=self._LOCAL_AM_PM_SELECT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def click_next_month(self) -> None:
        """
        Click the next month chevron/arrow.
        """
        self.expand()
        self._picker.click_next_month()

    def click_previous_month(self) -> None:
        """
        Click the previous month chevron/arrow.
        """
        self.expand()
        self._picker.click_previous_month()

    def expand(self) -> None:
        """
        Expands the DateTime Input so that the DateTime Picker is displayed.
        """
        if not self.is_expanded() and not self.is_in_time_mode():
            self._input.click(binding_wait_time=0.5)

    def get_available_months_from_dropdown(self) -> List[str]:
        """
        Return the months available for selection as their full string names (ie: 'January' instead of 1 or 0 or 'Jan').

        :returns: A list of the available months as strings.
        """
        self._expand_and_then_collapse_when_done()
        return self._picker.get_list_of_available_months()

    def get_available_years_from_dropdown(self) -> List[int]:
        self._expand_and_then_collapse_when_done()
        return self._picker.get_list_of_available_years()

    def get_am_pm(self) -> str:
        """
        Obtain the current meridiem (AM or PM).

        :returns: 'AM' or 'PM'

        :raises TimeoutException: if the DateTime Input is using a format which does not support AM/PM.
        """
        if self.is_in_time_mode():
            return self._get_am_pm_from_local_select()
        else:
            self._expand_and_then_collapse_when_done()
            return self._get_am_pm()

    def get_hours(self) -> int:
        """
        Obtain the hours in use by the DateTime Input.
        """
        if self.is_in_time_mode():
            return self._get_hours_from_local_hours_input()
        else:
            self._expand_and_then_collapse_when_done()
            return self._get_hours()

    def get_minutes(self) -> int:
        """
        Obtain the minutes in use by the DateTime Input.
        """
        if self.is_in_time_mode():
            return self._get_minutes_from_local_minutes_input()
        else:
            self._expand_and_then_collapse_when_done()
            return self._get_minutes()

    def get_placeholder(self) -> Optional[str]:
        """
        Obtain the placeholder text currently displayed in the DateTime Input.

        :returns: The currently displayed placeholder text. A value of None means the component already has a date
            selected and so no placeholder text is being displayed.
        """
        self._collapse()
        try:
            return self._input.find().get_attribute(name="placeholder")
        except NoSuchAttributeException:
            return None

    def get_selected_days(self) -> Optional[List[int]]:
        """
        Obtain a list of selected days.

        :returns: A list of all days currently selected. For ranges, this includes all days between the first and last.
            A value of None means no days are selected.
        """
        self._expand_and_then_collapse_when_done()
        return self._picker.get_selected_days()

    def get_selected_month(self) -> str:
        """
        Obtains the month currently in use for the DateTime Input.
        """
        self._expand_and_then_collapse_when_done()
        return self.get_selected_month()

    def get_selected_year(self) -> int:
        """
        Obtain the currently selected year of the DateTime Input.
        """
        self._expand_and_then_collapse_when_done()
        return self.get_selected_year()

    def get_text(self) -> str:
        """
        The DateTime Input component does not emit text, so we return the 'value' attribute.
        """
        return self.get_value_from_input()

    def get_value_from_input(self) -> str:
        """
        Obtain the formatted string value of the DateTime Input.

        :returns: A formatted string which represents he currently selected date/time of the DateTime Input.
        """
        self._collapse()
        return self._input.find().get_attribute(name="value")

    def has_placeholder(self) -> bool:
        """
        Determine if the DateTime Input is currently displaying placeholder text.

        :returns: True, if placeholder text is displayed - False otherwise.
        """
        try:
            potential_placeholder = self.get_placeholder()
            return potential_placeholder is not None and len(potential_placeholder) > 0
        except NoSuchAttributeException:
            return False

    def is_enabled(self) -> bool:
        """
        Determine if the DateTime Input is currently enabled.

        :returns: True, if currently enabled - False otherwise.
        """
        return self._local_hours_input.find().is_enabled() \
            if self.is_in_time_mode() else self._input.find().is_enabled()

    def is_expanded(self) -> bool:
        """
        Determine if the DateTime Input is currently displaying its 'picker'.

        :returns: True, if the picker is displayed
        """
        try:
            return self._picker.find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    def is_in_date_mode(self) -> bool:
        """
        Determine if the DateTime Input is currently configured to only allow for selecting year/month/day.

        :returns: True, if a user would only be able to select the year, month, and day. False if the user would be able
            to select any piece of time (hour, minute, or second).
        """
        return (not self.is_in_time_mode()) and (not self._is_in_date_time_mode())

    def is_in_date_time_mode(self) -> bool:
        """
        Determine if a user would be able to select the year, month, day and ANY piece of time for a date.

        :returns: True, if a user can select a year, month, day and ANY piece of time (hours, minutes, or seconds).
        """
        return (not self.is_in_time_mode()) and self._is_in_date_time_mode()

    def is_in_time_mode(self) -> bool:
        """
        Determine if the DateTime Input only allows for selecting time values.

        :returns: True, if only time values are available for modification. False if a user would be able to select the
            year, month, or day.
        """
        return self._TIME_CLASS in self.find().find_elements(By.XPATH, 'child::*')[0].get_attribute('class')

    def select_date(self, date: PerspectiveDate, collapse_when_done: bool = True) -> None:
        """
        Select a date within the DateTime Input

        :param date: The date to select.
        :param collapse_when_done: If True, the picker will be collapsed when done. If False, the picker will be left
            expanded.
        """
        self.expand()
        self._picker.select_date_only_not_time(date=date)
        if date.hours12 or date.hours24 or date.minutes or date.seconds:
            # re-expand after potentially selecting a day
            self.expand()
            self._picker.select_time_only_not_date(date=date)
            if not self.is_expanded():
                self.click()
            self._picker.select_date_only_not_time(date=PerspectiveDate(day=date.day))
        if collapse_when_done:
            self._collapse()

    def select_month(self, month: Union[int, str]) -> None:
        """
        Select a month within the DateTime Input.

        :param month: The month to select by clicking the previous/next chevrons/arrows.
        """
        self.expand()
        self._picker.select_month(month=month)

    def select_year(self, year: Union[int, str]) -> None:
        """
        Select a year within the DateTime Input.

        :param year: The four-digit numeric year you would like to select by clicking the previous/next chevrons/arrows.
        """
        self.expand()
        self._picker.select_year(year=year)

    def _collapse(self) -> None:
        """
        Collapse the DateTime Picker. Takes no action if the picker modal is not displayed.
        """
        if self.is_expanded():
            self.click()

    def _get_am_pm(self) -> Optional[str]:
        """
        Obtain the current meridiem.

        :returns: 'am' or 'pm' while the DateTime Input is using a 12-hour format.

        :raises TimeoutException: If the format of the input is such that the AM/PM picker is not present.
        """
        self._expand_and_then_collapse_when_done()
        return self._picker.get_am_pm()

    def _get_hours(self) -> int:
        """
        Obtain the currently selected hours of the DateTime Input.

        :raises TimeoutException: If the format of the input is such that the hours input is not present.
        """
        self._expand_and_then_collapse_when_done()
        return self._picker.get_hours()

    def _get_minutes(self) -> int:
        """
        Obtain the currently selected minutes of the DateTime Input.

        :raises TimeoutException: If the format of the input is such that the minutes input is not present.
        """
        self._expand_and_then_collapse_when_done()
        return self._picker.get_minutes()

    def _is_in_date_time_mode(self) -> bool:
        """
        Determine if the DateTime Input is currently displaying the ability to select years, months, days and any
        piece of time.

        :returns: True, if the user may apply year, month, day, and at least one from among hours, minutes, or
            seconds. False, if the user may not select any piece of time, or does not have the ability to apply year,
            month or day.
        """
        self._expand_and_then_collapse_when_done()
        return self._picker.is_in_date_time_mode()

    def _collapse_during_teardown_if_was_not_expanded(self) -> None:
        """
        Convenience function to deterministically collapse the expanded picker only if it was not open to begin with.
        """
        expanded_originally = self.is_expanded()
        self.expand()
        yield
        if not expanded_originally:
            self._collapse()

    def _expand_and_then_collapse_when_done(self) -> None:
        """
        Convenience function to first expand the picker, and then force collapse after task completion.
        """
        self.expand()
        self._collapse_during_teardown_if_was_not_expanded()

    def _get_am_pm_from_local_select(self) -> str:
        """
        Obtain the current meridiem ('am' or 'pm').

        :returns: 'AM' or 'PM'

        :raises TimeoutException: If using a time format which does not support AM/PM.
        """
        return Select(webelement=self._local_am_pm_select.find()).all_selected_options[0].text

    def _get_hours_from_local_hours_input(self) -> int:
        """
        Obtain the hours currently applied to the DateTime Input while in 'time' mode.

        :raises TimeoutException: If the DateTime Input is not in 'time' mode.
        """
        return int(self._local_hours_input.find().get_attribute(name="value"))

    def _get_minutes_from_local_minutes_input(self) -> int:
        """
        Obtain the minutes currently applied to the DateTime Input while in 'time' mode.

        :raises TimeoutException: If the DateTime Input is not in 'time' mode.
        """
        return int(self._local_minutes_input.find().get_attribute(name="value"))

Components/PerspectiveComponents/Inputs/DateTime.py
inductiveautomation/ignition-automation-tools
import json
from json import JSONDecodeError
from typing import List, Optional, Union, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.PerspectiveComponents.Common.Dropdown import CommonDropdown
from Helpers.CSSEnumerations import CSSPropertyValue
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Helpers.Point import Point


class Dropdown(CommonDropdown, BasicPerspectiveComponent):
    _ALL_ACTIVE_DROPDOWNS_LOCATOR = (By.CSS_SELECTOR, f"div.{CommonDropdown.ACTIVE_CLASS}")
    _PILL_CLASS = 'ia_dropdown__valuePill'
    _VALUE_PILL_LOCATOR = (By.CSS_SELECTOR, 'div.ia_dropdown__valuePill__value')
    _FOCUSED_OPTION_CLASS = 'ia_dropdown__option--focused'
    _PLACEHOLDER_LOCATOR = (By.CSS_SELECTOR, 'div.iaDropdownCommon_placeholder-container div')
    _CLEAR_ALL_OPTIONS_SVG_LOCATOR = (By.CSS_SELECTOR, 'a.iaDropdownCommon_clear_value')
    _VALUE_CONTAINER_LOCATOR = (By.CSS_SELECTOR, "div.iaDropdownCommon_value-container")
    _SEARCH_INPUT_LOCATOR = (By.CSS_SELECTOR, 'input.iaDropdownCommon_search')
    _SELECTED_OPTION_LOCATOR = (By.CSS_SELECTOR, '> div')
    _COMMON_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.iaDropdownCommon_container')
    _NO_RESULTS_MODAL_LOCATOR = (By.CSS_SELECTOR, 'a.iaDropdownCommon_option.ia_dropdown__option__noResults')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 4,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        CommonDropdown.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,  # only needs to be done once
            poll_freq=poll_freq
        )
        self._placeholder = ComponentPiece(
            locator=self._PLACEHOLDER_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._clear_all_options = ComponentPiece(
            locator=self._CLEAR_ALL_OPTIONS_SVG_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._displayed_selected_options = {}
        self._search_input = ComponentPiece(
            locator=self._SEARCH_INPUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._value_container = ComponentPiece(
            locator=self._VALUE_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._selected_options = ComponentPiece(
            locator=self._SELECTED_OPTION_LOCATOR,
            driver=driver,
            parent_locator_list=self._value_container.locator_list,
            poll_freq=poll_freq)
        self._all_active_dropdowns = ComponentPiece(
            locator=self._ALL_ACTIVE_DROPDOWNS_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            poll_freq=poll_freq)
        self._common_container = ComponentPiece(
            locator=self._COMMON_CONTAINER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._value_pill = ComponentPiece(
            locator=self._VALUE_PILL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._no_results_modal = ComponentPiece(
            locator=self._NO_RESULTS_MODAL_LOCATOR,
            driver=driver,
            parent_locator_list=self._options_container.locator_list,
            poll_freq=poll_freq)

    def clear_all_selections(self) -> None:
        """
        Click the 'X' which would remove all selected options.

        :raises TimeoutException: If the clear all selected options 'X' is not present.
        """
        self._clear_all_options.click(wait_timeout=1)

    def clear_selected_option(self, option_text: str) -> None:
        """
        Clear an individual selected option. Available only for multi-select dropdowns.

        :raises TimeoutException: if not using a multi-select Dropdown component, or if no option with matching text
            is currently selected.
        """
        option_clear_link = self._displayed_selected_options.get(option_text)
        if not option_clear_link:
            option_clear_link = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div[data-label="{option_text}"] a'),
                driver=self.driver,
                parent_locator_list=self._value_container.locator_list,
                poll_freq=self.poll_freq)
            self._displayed_selected_options[option_text] = option_clear_link
        option_clear_link.click()

    def collapse_if_expanded(self) -> None:
        """
        Collapse the Dropdown if it is currently active. Does NOT remove focus from the Dropdown.
        """
        if self.is_expanded():
            self.toggle_expansion()

    def get_common_container_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """Obtain a CSS property value from the main container of the Dropdown."""
        return self._common_container.get_css_property(property_name=property_name)

    def get_count_of_active_dropdowns(self) -> int:
        """Obtain a count of how many Dropdowns are currently displaying options for selection."""
        try:
            return len(self._all_active_dropdowns.find_all(wait_timeout=1))
        except TimeoutException:
            return 0

    def get_count_of_displayed_options(self) -> int:
        """
        Obtain a count of how many options are currently displayed for selection.

        :returns: A count of options currently available for selection. This includes options which must be scrolled to.
        """
        try:
            return len(self._available_options.find_all())
        except TimeoutException:
            return 0

    def get_displayed_text(self) -> str:
        """
        Obtain the displayed text of the Dropdown.

        :returns: All text of the Dropdown - including all selected options - as a single string.
        """
        return self._value_container.get_text()

    def get_options_modal_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain a CSS property value of the options modal.

        :returns: a specific CSS property value of the options modal, distinct from the main Dropdown container.
        """
        try:
            self.expand_if_collapsed()
            return self._available_options.get_css_property(property_name=property_name)
        finally:
            self.collapse_if_expanded()

    def get_no_results_modal_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain a CSS property value of the 'No Results' modal.

        :returns: a specific CSS property value of the options modal, distinct from the main Dropdown container.

        :raises TimeoutException: If the 'No Results' modal is not displayed.
        """
        try:
            self.expand_if_collapsed()
            return self._no_results_modal.get_css_property(property_name=property_name)
        finally:
            self.collapse_if_expanded()

    def get_origin_of_search_input(self) -> Point:
        """
        Obtain the origin of the search input within the Dropdown.

        :raises TimeoutException: If the Dropdown does not allow for searching of options.
        """
        return self._search_input.get_origin()

    def get_origin_of_value_pills(self) -> List[Point]:
        """
        Obtain the origins of all option value pills currently displayed for the multi-select Dropdown.

        :raises TimeoutException: If no options are currently selected, or if using a single-select Dropdown.
        """
        return [Point(x=vp.rect["x"], y=vp.rect["y"]) for vp in self._value_pill.find_all()]

    def get_origins_of_selected_options(self) -> List[Point]:
        """
        Obtain the origins of all selected option wrapper <div> elements. Distinct from the value 'pills'. This function
        works for all Dropdowns.

        :raises TimeoutException: If no options are selected.
        """
        return [
            Point(x=option_elem.rect["x"], y=option_elem.rect["y"])
            for option_elem in self._selected_options.find_all()
        ]

    def get_placeholder_text(self) -> str:
        """
        Obtain the placeholder text currently displayed in the Dropdown.

        :raises TimeoutException: If no placeholder text is displayed.
        """
        return self._placeholder.get_text()

    def get_search_input_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain a CSS property value of the search <input> element. This element is only present while the Dropdown is
        active.

        :returns: a specific CSS property value of the search input.

        :raises TimeoutException: If the search input is not present.
        """
        return self._search_input.get_css_property(property_name=property_name)

    def get_selected_options_as_list(self) -> List[str]:
        """
        Obtain all selected options as a list of strings.

        :returns: A list, where each item is the text of an option which is currently selected.
        """
        selected_labels = self.find().get_attribute("data-selected-labels")
        try:
            if selected_labels and "[" in selected_labels:
                selected_labels = json.loads(selected_labels)
            elif selected_labels == '""':
                selected_labels = []
            else:
                selected_labels = [json.loads(selected_labels)]
        except JSONDecodeError:
            return []
        return selected_labels

    def get_width_and_height_of_first_available_option(self) -> Tuple[str, str]:
        """
        Obtain the width and height of the first available option in the options modal as a Tuple where the first
        element is the width, and the second element is the height.

        :returns: A tuple, where the 0th element is the width of the first available option of the Dropdown, and the 1th
            element is the height.
        """
        try:
            self.expand_if_collapsed()
            return self._available_options.get_computed_width(include_units=False), \
                self._available_options.get_computed_height(include_units=False)
        finally:
            self.collapse_if_expanded()

    def insert_custom_option(self, custom_option_text: str) -> None:
        """
        Insert a custom option into the Dropdown.

        :param custom_option_text: The text of the option you would like to supply. This will also be used as the value
            of the option.

        :raises TimeoutException: If the Dropdown does not allow for custom options.
        """
        try:
            self.expand_if_collapsed()
            self._search_input.find().send_keys(custom_option_text)
            # The "create" option actually has a data-label attribute of "Create", regardless of the option text
            self._get_option(option_text=f"Create: {custom_option_text}").click()
        finally:
            self.collapse_if_expanded()

    def option_has_focus(self, option_text: str) -> bool:
        """
        Determine if the supplied option currently has focus.

        :param option_text: The text of the option to check.

        :returns: True, if the option has focus, False otherwise.
        """
        try:
            return self.wait.until(
                IAec.function_returns_true(
                    custom_function=self._option_has_focus,
                    function_args={"option_text": option_text}))
        except TimeoutException:
            return False

    def placeholder_text_is_displayed(self) -> bool:
        """Determine if placeholder text is currently displayed."""
        try:
            return len(self._placeholder.find(wait_timeout=1).text) > 0
        except TimeoutException:
            return False

    def select_option_by_text_if_not_selected(self, option_text: str, binding_wait_time: float = 0.5) -> None:
        """
        Select an option. If the option is already selected, no action is taken.

        Full Perspective Dropdowns often have many options and need assistance limiting the displayed list,
        so this function will attempt to filter the displayed options if the desired option is not readily seen.

        :param option_text: The text of the option you would like to select.
        :param binding_wait_time: The amount of time to wait after selecting an option before allowing the code to
            continue.

        :raises TimeoutException: If no option with the supplied text exists as an option.
        """
        if option_text not in self.get_selected_options_as_list():
            self.scroll_to_element()
            self.expand_if_collapsed()
            if not self.option_is_visible_within_option_modal(option_text=option_text):
                # Selenium occasionally has issues when scrolling the options modal, so attempt to filter the displayed
                # options first
                try:
                    self.set_search_text(search_text=option_text)
                except TimeoutException:
                    # filtering the displayed options didn't work, so our only option is to scroll and hope it works
                    self._get_option(option_text=option_text).scroll_to_element()
            try:
                self._get_option(option_text=option_text).click(wait_timeout=1, binding_wait_time=binding_wait_time)
            except TimeoutException as toe:
                toe.msg = f"Failed to locate element with text of \"{option_text}\"."
                raise toe
            finally:
                self.collapse_if_expanded()

    def selected_options_are_in_pills(self) -> bool:
        """
        Determine if the selected options are in 'pills'. This concept is unique to multi-select Dropdowns.

        :raises TimeoutException: If no options are currently selected.
        """
        # CommonDropdown does not allow for multi-selection, so no pills
        return self._PILL_CLASS in self._selected_options.find().get_attribute("class").split(" ")

    def set_search_text(self, search_text: str) -> None:
        """
        Set the search text of the Dropdown.

        :param search_text: The text to supply to the Dropdown in an attempt to filter the displayed options.

        :raises TimeoutException: If the Dropdown does not allow for searching.
        """
        # Searching not allowed in CommonDropdown
        self.click(binding_wait_time=0.5)
        self._search_input.find().send_keys(search_text)

    def _option_has_focus(self, option_text: str) -> bool:
        """
        Determine if an option in the options modal has focus.

        :param option_text: The text of the option to check.

        :raises TimeoutException: If no options are currently displayed, or no option with the supplied text exists.
        """
        return self._FOCUSED_OPTION_CLASS in self._get_option(
            option_text=option_text).find(wait_timeout=0).get_attribute("class")

Components/PerspectiveComponents/Inputs/Dropdown.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.Common.FileUpload import FileUpload as CommonFileUpload
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Components.PerspectiveComponents.Common.Icon import CommonIcon


class FileUpload(BasicPerspectiveComponent, CommonFileUpload):
    """
    A Perspective File Upload Component.

    The File Upload can render at one of three sizes based on its dimensions: small, medium, and large. Many functions
    in this class will not work while the File Upload is in the small layout because although we are able to expand
    the modal which has the information those functions would need, we are unable to then remove the modal without
    the risk of unintentionally interacting with another component.

    Also, some functions require the File upload component to be in a specific size. Those functions denote the
    required size.
    """
    _SPAN_LOCATOR = (By.CSS_SELECTOR, "span.display-message")
    _PRIMARY_SVG_LOCATOR = (By.CSS_SELECTOR, 'svg[class^="ia_fileUploadComponent--mobile"]')
    _SUPPORTED_FILE_TYPES_WRAPPER_LOCATOR = (By.CSS_SELECTOR, "div.file-types-supported")
    _SUPPORTED_FILE_TYPES_ICON_LOCATOR = (By.CSS_SELECTOR, ".ia_fileUploadComponent__supportedFileTypes__icon")
    _TOOLTIP_LOCATOR = (By.CSS_SELECTOR, "div.component-tooltip")
    _PRIMARY_MESSAGE_LOCATOR = (By.CSS_SELECTOR, "span.primary-message")
    _SECONDARY_MESSAGE_LOCATOR = (By.CSS_SELECTOR, "span.secondary-message")
    _PAGINATION_WRAPPER_LOCATOR = (By.CSS_SELECTOR, "div.pagination-wrapper")
    _FILE_SUMMARY_WRAPPER = (By.CSS_SELECTOR, "div.file-summary")
    _FILE_MESSAGE_LOCATOR = (By.CSS_SELECTOR, "span.file-message")
    _NEXT_PAGE_LINK_LOCATOR = (By.CSS_SELECTOR, "a.next")
    _PREVIOUS_PAGE_LINK_LOCATOR = (By.CSS_SELECTOR, "a.previous")
    _CLEAR_UPLOADS_LINK = (By.CSS_SELECTOR, "div.clear-icon a")
    _FILE_WRAPPER_LOCATOR = (By.CSS_SELECTOR, "div.file-wrapper")
    _FILE_NAME_LOCATOR = (By.CSS_SELECTOR, "div.file-name")
    _FILE_RESULT_LOCATOR = (By.TAG_NAME, "span")
    _SUCCESS_MESSAGE_LOCATOR = (By.CSS_SELECTOR, "div.success-message")
    _ERROR_LOCATOR = (By.CSS_SELECTOR, "div.error")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._display_message = ComponentPiece(
            locator=self._SPAN_LOCATOR, driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._primary_icon = CommonIcon(
            locator=self._PRIMARY_SVG_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._supported_file_types_icon = CommonIcon(
            locator=self._SUPPORTED_FILE_TYPES_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._tooltip = ComponentPiece(
            locator=self._TOOLTIP_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            poll_freq=poll_freq)
        self._supported_file_types_text_wrapper = ComponentPiece(
            locator=self._SUPPORTED_FILE_TYPES_WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._primary_message = ComponentPiece(
            locator=self._PRIMARY_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self._supported_file_types_text_wrapper.locator_list,
            poll_freq=poll_freq)
        self._secondary_message = ComponentPiece(
            locator=self._SECONDARY_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self._supported_file_types_text_wrapper.locator_list,
            poll_freq=poll_freq)
        self._pagination_wrapper = ComponentPiece(
            locator=self._PAGINATION_WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self.__file_summary_wrapper = ComponentPiece(
            locator=self._FILE_SUMMARY_WRAPPER,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._file_message = ComponentPiece(
            locator=self._FILE_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self._pagination_wrapper.locator_list,
            poll_freq=poll_freq)
        self._next_page_link = ComponentPiece(
            locator=self._NEXT_PAGE_LINK_LOCATOR,
            driver=driver,
            parent_locator_list=self._pagination_wrapper.locator_list,
            poll_freq=poll_freq)
        self._previous_page_link = ComponentPiece(
            locator=self._PREVIOUS_PAGE_LINK_LOCATOR,
            driver=driver,
            parent_locator_list=self._pagination_wrapper.locator_list,
            poll_freq=poll_freq)
        self.__file_wrapper = ComponentPiece(
            locator=self._FILE_WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._file_name = ComponentPiece(
            locator=self._FILE_NAME_LOCATOR,
            driver=driver,
            parent_locator_list=self.__file_wrapper.locator_list,
            poll_freq=poll_freq)
        self._file_result = ComponentPiece(
            locator=self._FILE_RESULT_LOCATOR,
            driver=driver,
            parent_locator_list=self.__file_wrapper.locator_list,
            poll_freq=poll_freq)
        self._modal = ComponentModal(driver=driver, poll_freq=poll_freq)

    def all_extensions_allowed(self) -> bool:
        """
        Determine if all extensions are currently allowed for the File Upload. Does not work for File Uploads which use
        the "small" layout, because we would need to expand the modal - but there is no good way to then remove the
        modal.

        :returns: True, if there are no file extension restrictions in place for the component. False if any
            restrictions are in place.

        :raises TypeError: If the File Upload is in the "small" layout. This error is raised because there could
            actually be restrictions in place, but we are unable to locate them.
        """
        if self.is_using_small_layout():
            raise ValueError("Unable to verify file restrictions while the File Upload is using the 'small' layout.")
        return not self.is_displaying_hover_info_icon()

    def clear_uploads(self, max_number_of_attempts: int = 10) -> None:
        """
        Reset the File Upload Component back to its default state after having uploaded files.

        :param max_number_of_attempts: How many times you are willing to click the next page arrow in order to reach
            the upload summary before giving up.

        :raises TimeoutException: If there were no uploads to clear, or if we failed to reach the summary of uploads
            after clicking next page arrow as many times as specified in max_number_of_attempts.
        """
        attempts = 0
        while not self.summary_panel_is_visible() and attempts < max_number_of_attempts:
            self.click_next_page()
            attempts += 1
        self._get_clear_uploads_link().click(wait_timeout=1)

    def click_next_page(self) -> None:
        """
        Click the next page arrow in the upload summary pagination area.

        :raises TimeoutException: If already on the last page, or if the summary is not present.
        """
        self._next_page_link.click(binding_wait_time=1)

    def click_previous_page(self) -> None:
        """
        Click the previous page arrow in the upload summary pagination area.

        :raises TimeoutException: If already on the first page, or if the summary is not present.
        """
        self._previous_page_link.click(binding_wait_time=1)

    def _expose_file_input_element(self) -> None:
        """
        While using the small layout, the required <input> element is not actually in the DOM, so we must click the
        component in order to expose the <input>.
        """
        if self.is_using_small_layout():
            self.click()

    def extension_is_allowed(self, extension: str) -> bool:
        """
        Determine if the supplied case-insensitive extension is listed as being restricted.

        :returns: True, if the supplied case-insensitive extension is currently restricted - False otherwise.
        """
        all_extensions_allowed = self.all_extensions_allowed()
        if not all_extensions_allowed:
            extension = extension.replace(".", "").upper()
            if self.is_displaying_hover_info_icon():
                return extension in self.get_information_hover_text()
            else:
                return extension in self._secondary_message.get_text()
        return all_extensions_allowed

    def get_current_page_index(self) -> int:
        """
        Obtain the page index of the upload summary.

        :returns: -1 if on the Summary (last) page, otherwise the current page of the summary pagination.
        """
        try:
            return int(self._file_message.get_text().split(" ")[0])
        except IndexError:
            return -1

    def get_display_message(self) -> str:
        """
        Obtain the message displayed while in the default configuration.

        :raises TimeoutException: If the component is not in the default or reset configuration.
        """
        return self._display_message.get_text()

    def get_filename_for_current_page(self) -> str:
        """
        Obtain the name of the file being summarized on this page of the summary.

        :raises TimeoutException: If on the Summary page or if the summary pagination panel is not present.
        """
        return self._file_name.get_text()

    def get_icon_color(self) -> str:
        """
        Obtain the color of the icon as a string

        :raises TimeoutException: If the component is not in the 'small' layout.
        """
        return self._primary_icon.get_fill_color()

    def get_icon_name(self) -> str:
        """
        Obtain a slash delimited path of the icon (someLibrary/iconId).

        :raises TimeoutException: If the component is not in the 'small' layout.
        """
        return self._primary_icon.get_icon_name()

    def get_information_hover_text(self) -> str:
        """
        Obtain the text a user would see when they hover over the information icon. Requires the File Upload be in the
        'medium' layout.

        :raises TimeoutException: If the File Upload is not in the 'medium' layout, or if no information icon is
            displayed. The information icon is only displayed while in the medium layout AND file extension restrictions
            are in place for the component.
        """
        self.hover_over_info_icon()
        return self._tooltip.get_text()

    def get_result_for_current_page(self) -> str:
        """
        Obtain the result message for the current page within the upload summary pagination area.

        :raises TimeoutException: If on the Summary page or if no pagination area is present.
        """
        return self._file_result.get_text()

    def get_total_count_of_results(self) -> int:
        """
        Obtain a count of how many files were uploaded during the last event.

        :raises TimeoutException: If the component is not currently displaying a summary of the upload.
        """
        on_summary_page = self.get_current_page_index() == -1
        if on_summary_page:
            self.click_previous_page()
            self.wait_on_binding(time_to_wait=0.5)
        count_of_results = int(self._file_message.get_text().split(" ")[-1])
        if on_summary_page:
            self.click_next_page()
        return count_of_results

    def get_warning_text(self) -> str:
        """
        Obtain the warning text displayed as part of the upload event.

        :raises TimeoutException: If no warning is currently displayed.
        """
        try:
            return self._get_file_upload_error().find(wait_timeout=0.5).text
        except TimeoutException:
            return ""

    def hover_over_info_icon(self) -> None:
        """
        Hover over the information icon of the component. Requires the File Upload be in the 'medium' layout, and also
        requires that file extension restrictions be in place.

        :raises TimeoutException: If the component is not in the 'medium' layout, or if no file extension restrictions
        are in place for the component.
        """
        self._supported_file_types_icon.hover()

    def is_using_small_layout(self) -> bool:
        """Determine if the component is currently rendering in the 'small' layout."""
        try:
            return self._primary_icon.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def is_displaying_hover_info_icon(self, wait_timeout: int = 1) -> bool:
        """
        Determine if the component is currently displaying the information icon.

        :returns: True, if in the 'medium' layout AND file extension restrictions are in place for the component. False
            if rendering in the 'small' or 'large' layout OR no file extension restrictions are in place.
        """
        try:
            return self._supported_file_types_icon.find(wait_timeout=wait_timeout) is not None
        except TimeoutException:
            return False

    def is_displaying_results(self) -> bool:
        """
        Determine if the upload summary results pagination area is currently displayed.

        :returns: True, if the pagination area is displayed - False otherwise.
        """
        try:
            return self._pagination_wrapper.find() is not None
        except TimeoutException:
            return False

    def navigate_to_file_summary_by_index(self, desired_index: int) -> None:
        """
        Within the upload summary pagination area, navigate to a specific page index. To navigate to the Summary panel,
        supply a value of -1.

        :raises TimeoutException: If the pagination panel is not currently displayed.
        """
        if self.summary_panel_is_visible():
            self.click_previous_page()
        current_index = self.get_current_page_index()
        # stop if we reach the desired page or the summary page
        while current_index != desired_index and current_index != -1:
            self._previous_page_link.click(binding_wait_time=0.5) if current_index > desired_index \
                else self._next_page_link.click(binding_wait_time=0.5)
            current_index = self.get_current_page_index()

    def next_page_link_is_enabled(self) -> bool:
        """
        Determine if the 'next page' arrow (<a>) is enabled.

        :raises TimeoutException: If the pagination panel is not currently present.
        """
        return "disabled" not in self._next_page_link.find().get_attribute("class")

    def previous_page_link_is_enabled(self) -> bool:
        """
        Determine if the 'previous page' arrow (<a>) is enabled.

        :raises TimeoutException: If the pagination panel is not currently present.
        """
        return "disabled" not in self._previous_page_link.find().get_attribute("class")

    def small_layout_modal_is_displayed(self) -> bool:
        """Determine if the 'Browse' button modal is displayed while using the 'small' layout."""
        try:
            return ComponentPiece(
                locator=self._SPAN_LOCATOR,
                driver=self.driver,
                wait_timeout=1,
                parent_locator_list=self._modal.locator_list).find() is not None
        except TimeoutException:
            return False

    def summary_panel_is_visible(self) -> bool:
        """Determine if the Summary panel is visible in the upload summary pagination panel."""
        try:
            return "Summary" in self._file_message.find(wait_timeout=0.5).text
        except TimeoutException:
            return False

    def upload_file_by_path(self, normalized_file_path: str) -> None:
        """
        Upload an individual file by supplying a normalized file path.

        :param normalized_file_path: An OS-agnostic string file path.
        """
        self.upload_files(normalized_file_path_list=[normalized_file_path])

    def upload_files(self, normalized_file_path_list: List[str]) -> None:
        """
        Upload a list of files by supplying normalized file paths.

        :param normalized_file_path_list: A list of OS-agnostic string file paths.
        """
        self.driver.execute_script('arguments[0].style.display="block";', self._find_file_input())
        self._find_file_input().send_keys('\n'.join(normalized_file_path_list))
        self.driver.execute_script('arguments[0].style.display="none";', self._find_file_input())

    def upload_was_successful(self) -> bool:
        """
        Determine if the previous upload attempt was successful.

        :returns: True, if the event was successful. False if ANY file encountered an issue or if the component
            is not displaying an upload summary.
        """
        # small file upload renders success in modal/popover format.
        parent_locator_list = self._modal.locator_list if self.is_using_small_layout() else self.locator_list
        try:
            return ComponentPiece(
                locator=self._SUCCESS_MESSAGE_LOCATOR,
                driver=self.driver,
                parent_locator_list=parent_locator_list,
                poll_freq=self.poll_freq).find(wait_timeout=5).text == 'Upload Successful!'
        except TimeoutException:
            return False

    def warning_is_displayed(self) -> bool:
        """
        Determine if a warning is currently displayed for the component.

        :returns: True, if a warning is currently displayed - False otherwise.
        """
        try:
            return self._get_file_upload_error().find(wait_timeout=0.5) is not None
        except TimeoutException:
            return False

    def _find_file_input(self) -> WebElement:
        """
        Obtain the necessary <input> element so that we can use it to upload files.

        :raises TimeoutException: If unable to locate the required WebElement.
        """
        self._expose_file_input_element()
        # small file upload renders inputs in modal/popover format.
        return ComponentPiece(
            locator=self._INPUT_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._modal.locator_list if self.is_using_small_layout() else self.locator_list,
            poll_freq=self.poll_freq).find(wait_timeout=2)

    def _get_file_upload_error(self) -> ComponentPiece:
        """
        Obtain the error ComponentPiece.
        """
        return ComponentPiece(
            locator=self._ERROR_LOCATOR,
            driver=self.driver,
            parent_locator_list=self._modal.locator_list if self.is_using_small_layout() else self.locator_list,
            poll_freq=self.poll_freq)

    def _get_clear_uploads_link(self) -> ComponentPiece:
        """
        Obtain the clear uploads link (circular refresh icon) as a ComponentPiece.
        """
        return ComponentPiece(
            locator=self._CLEAR_UPLOADS_LINK,
            driver=self.driver,
            parent_locator_list=self._modal.locator_list if self.is_using_small_layout() else self.locator_list,
            poll_freq=self.poll_freq)

Components/PerspectiveComponents/Inputs/FileUpload.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Union, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.color import Color

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.Common.Button import CommonButton
from Helpers.CSSEnumerations import CSSPropertyValue, CSS
from Helpers.IAAssert import IAAssert


class MultiStateButton(BasicPerspectiveComponent):
    """A Perspective Multi-State Button Component."""
    _GENERAL_BUTTON_LOCATOR = (By.TAG_NAME, "button")
    _DEFAULT_INACTIVE_BACKGROUND_COLOR = '#FAFAFA'

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._generic_button = ComponentPiece(
            locator=self._GENERAL_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._buttons = {}

    def click(self, **kwargs) -> None:
        """
        Multi-State Buttons should not be blindly clicked because the 0th button would always receive the click.

        :raises NotImplementedError:
        """
        raise NotImplementedError

    def click_button_by_text(self, button_text: str, wait_timeout: float = 0.5, binding_wait_time: float = 0.5) -> None:
        """
        Click one of the Multi-State Buttons by text. If multiple buttons share the same text only the first match will
        be clicked.

        :param button_text: The text of the button you would like to click.
        :param wait_timeout: The amount of time to wait until a button with the specified text becomes visible.
        :param binding_wait_time: The amount of time after the click event to wait before allowing code to continue.

        :raises TimeoutException: If no button with the specified text is found.
        """
        self._get_button_by_text(button_text=button_text).click(
            wait_timeout=wait_timeout, binding_wait_time=binding_wait_time)

    def get_css_property_value_of_button_by_text(
            self,
            button_text: str,
            property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain the value of a CSS property for a button of the Multi-State Button based on the text of the button.

        :param button_text: The text of the button from which you would like a CSS property value
        :param property_name: The CSS property value you would like to obtain.

        :raises TimeoutException: If no button with the specified text is found.
        """
        return self._get_button_by_text(button_text=button_text).get_css_property(property_name=property_name)

    def get_button_count(self, wait_timeout: float = 0.5) -> int:
        """Obtain a count of all states contained within this Multi-State Button."""
        return len(self._generic_button.find_all(wait_timeout=wait_timeout))

    def get_button_gap(self) -> float:
        """Obtain the current gap between each button."""
        return float(self._generic_button.get_css_property(
            property_name=CSS.MARGIN_BOTTOM if self.is_column_oriented() else CSS.MARGIN_RIGHT).split("px")[0])

    def get_current_indicator_button_text(self) -> Optional[str]:
        """
        Get the text of the button which is currently displaying as selected/active. This function assumes that all
        buttons in the Multi-State Button are using their default appearance. If any button is NOT using the default
        appearance, it is possible that button text would be erroneously returned.
        """
        for button in self._generic_button.find_all():
            try:
                IAAssert.is_equal_to(
                    actual_value=button.value_of_css_property(
                        CSS.BACKGROUND_COLOR.value),
                    expected_value=self._DEFAULT_INACTIVE_BACKGROUND_COLOR,
                    failure_msg="Button does not have default background color, so it must be selected.",
                    as_type=Color)
            except AssertionError:
                return button.text
        return None

    def hover_over_button_by_text(self, button_text: str) -> None:
        """
        Hover over the button of the Multi-State Button which has the supplied text.

        :raises TimeoutException: If no button matches the supplied text.
        """
        self._get_button_by_text(button_text=button_text).hover()

    def is_column_oriented(self) -> bool:
        """
        Determine if the Multi-State Button is rendered in a column layout, as opposed to a row.

        :returns: True if the Multi-State Button is rendered as a column.
        """
        return self._generic_button.find().get_attribute(name="data-button-orientation") == "column"

    def _get_button_by_text(self, button_text: str) -> CommonButton:
        """Obtain the underlying CommonButton component from the Multi-State Button based on its text."""
        button = self._buttons.get(button_text)
        if not button:
            button = CommonButton(
                locator=(By.CSS_SELECTOR, f'{self._GENERAL_BUTTON_LOCATOR[1]}[data-label="{button_text}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                poll_freq=self.poll_freq)
            self._buttons[button_text] = button
        return button

Components/PerspectiveComponents/Inputs/MultiStateButton.py
inductiveautomation/ignition-automation-tools
from typing import Optional, Union, List, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.Common.Button import CommonButton
from Components.Common.TextInput import CommonTextInput
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions.IAExpectedConditions import TextCondition
from Helpers.IASelenium import IASelenium
from Helpers.Point import Point


def _remove_commas(value: Union[float, str]) -> str:
    """
    Without any formatting modifications to a default Numeric Entry Field, Numeric Entry Fields display commas as part
    of their text. This of course wreaks havoc when doing comparisons against expected values, so remove any commas from
    all values. This requires that we convert/cast the value to a string.

    :param value: The value which will have all commas removed.

    :return: The supplied value with all commas removed, as a string.
    """
    return str(value).replace(",", "")


def _zero_out(expected_value: Union[float, str]) -> Union[float, str]:
    """
    When users supply an empty string, Numeric Entry Fields fall back to 0

    :param expected_value: The original value expected to be displayed.

    :return: 0, if the supplied value for the Numeric Entry Field is an empty string - otherwise the current value.
    """
    return 0 if expected_value == "" else expected_value


class ButtonNEF(BasicPerspectiveComponent, CommonTextInput):
    """A Button-variant Perspective Numeric Entry Field, as distinct from the Direct or Protected variants."""
    _APPLY_BUTTON_LOCATOR = (By.CSS_SELECTOR, "button.ia_button--primary")
    _CANCEL_BUTTON_LOCATOR = (By.CSS_SELECTOR, "button.ia_button--secondary")
    _MODAL_INPUT_LOCATOR = (By.TAG_NAME, "input")  # within modal

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        CommonTextInput.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._edit_icon = ComponentPiece(
            locator=(By.TAG_NAME, "a"),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._modal = ComponentModal(
            driver=driver,
            wait_timeout=1,
            poll_freq=poll_freq)  # no parent
        self._apply_button = CommonButton(
            locator=self._APPLY_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._modal.locator_list,
            poll_freq=poll_freq)
        self._cancel_button = CommonButton(
            locator=self._CANCEL_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._modal.locator_list,
            poll_freq=poll_freq)
        self._modal_input = CommonTextInput(
            locator=self._MODAL_INPUT_LOCATOR,
            driver=driver,
            parent_locator_list=self._modal.locator_list,
            poll_freq=poll_freq)

    def click_apply(self) -> None:
        """
        Click the Apply button in the entry modal.

        :raises TimeoutException: If the Apply Button (and/or entry modal) is not present.
        """
        self._apply_button.click()

    def click_cancel(self) -> None:
        """
        Click the Cancel button in the entry modal.

        :raises TimeoutException: If the Cancel Button (and/or entry modal) is not present.
        """
        self._cancel_button.click()

    def click_edit_icon(self) -> None:
        """Click the icon used to edit the value of the Numeric Entry Field."""
        return self._edit_icon.click()

    def get_modal_origin(self) -> Point:
        """
        Obtain the origin (top-left corner) of the entry modal.

        :returns: A Point which contains the x,y coordinates of the top-left of the entry modal.

        :raises TimeoutException: if the entry modal is not displayed.
        """
        return self._modal.get_origin()

    def modal_is_displayed(self) -> bool:
        """
        Determine if the entry modal is currently displayed.

        :returns: True, if the entry modal is displayed - False otherwise.
        """
        try:
            return self._modal.find() is not None
        except TimeoutException:
            return False

    def set_text(
            self,
            text: Union[float, str],
            apply_cancel_no_action: Optional[bool] = True,
            binding_wait_time: float = 0.5) -> None:
        """
        Convenience function to set the text of the Numeric Entry Field. Handles the opening of the entry modal and also
        applies, cancels, or takes no action as directed via arguments.

        :param text: The value which you would like to apply to the Numeric Entry Field.
        :param apply_cancel_no_action: If True, apply changes. If False, cancel changes after typing supplied text. If
            None, take no action - leaving the entry modal displayed without any changes committed.
        :param binding_wait_time: The amount of time to wait after applying or cancelling changes. Ignored if no action
            is to be taken after sending text.

        :raises AssertionError: If application or cancellation is specified, and the final displayed (and formatted)
            value of the Numeric Entry Field does not match the supplied text.
        """
        self.click_edit_icon()
        self._set_text(text=text)
        if apply_cancel_no_action is not None:
            self.click_apply() if apply_cancel_no_action else self.click_cancel()
            self._modal_input.wait_on_binding(time_to_wait=binding_wait_time)
            # assertion needs to go here as it does not make sense to assert if the value has not yet been committed.
            IAAssert.is_equal_to(
                actual_value=_remove_commas(
                    value=self.wait_on_text_condition(
                        text_to_compare=text, condition=TextCondition.EQUALS)),
                expected_value=_remove_commas(value=_zero_out(expected_value=text)),
                failure_msg="Failed to set the text of the Button-mode Numeric Entry Field.",
                as_type=str)

    def _set_text(self, text: Union[float, str]) -> None:
        """
        Set the text of the Numeric Entry Field by interacting with the already-opened entry modal.

        :param text: The text to apply to the input of the entry modal.
        """
        self._modal_input.click()
        self.driver.execute_script('arguments[0].value = ""', self._modal_input.find())
        self._modal_input.find().send_keys(text)


class DirectNEF(BasicPerspectiveComponent, CommonTextInput):
    """A Direct-variant Perspective Numeric Entry Field, as distinct from the Button or Protected variants."""

    def __init__(
            self,
            locator,
            driver: WebDriver,
            parent_locator_list: Optional[List] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator, 
            driver=driver, 
            parent_locator_list=parent_locator_list, 
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        CommonTextInput.__init__(
            self,
            locator=locator, 
            driver=driver, 
            parent_locator_list=parent_locator_list, 
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)

    def get_manual_entry_text(self) -> str:
        """
        Obtain the text of the Numeric Entry Field as a user sees it while typing. Distinct from get_text as this
        function will return an un-formatted value.

        Example: A user might type '14', but formatting for the component could display the committed value as '14.0'.
        This function will return the "typed" value of '14' instead of the formatted '14.0' value.

        :returns: The un-formatted value of the Numeric Entry Field.
        """
        if self.find().tag_name == "input":
            return self.find().get_attribute("value")
        else:
            self._internal_input.click()
            text = self._internal_input.find().get_attribute("value")
            self._internal_input.find().send_keys(Keys.ENTER)  # ENTER to avoid closing any popup
            return text

    def set_text(
            self,
            text: Union[float, int, str],
            release_focus: bool = True,
            binding_wait_time: float = 1) -> None:
        """
        Set the text of the Direct Numeric Entry Field.

        :param text: The value which you would like to apply to the Numeric Entry Field.
        :param release_focus: Specifies whether to commit the text after typing.
        :param binding_wait_time: The amount of time to wait after applying or cancelling changes. Ignored if no action
            is to be taken after sending text.

        :raises AssertionError: If the final displayed (and formatted) value of the Numeric Entry Field does not match
            the supplied text after releasing focus.
        """
        self._internal_input.click()
        if self._internal_input.find().get_attribute('readonly') is not None:
            self._internal_input.click()
        self._internal_input.find().send_keys(Keys.BACK_SPACE + str(text) + (Keys.ENTER if release_focus else ''))
        self.wait_on_binding(time_to_wait=binding_wait_time)
        if release_focus:
            IAAssert.is_equal_to(
                actual_value=_remove_commas(
                    value=self.wait_on_text_condition(
                        text_to_compare=text, condition=TextCondition.EQUALS)),
                expected_value=_remove_commas(value=_zero_out(expected_value=text)),
                failure_msg="Failed to apply the supplied text to the Direct-variant Numeric Entry Field.",
                as_type=str)


class ProtectedNEF(DirectNEF):
    """
    A Protected-variant Numeric Entry Field Component.

    Protected NEFs change their structure during interaction, so unique locators are very important. A Protected NEF
    without a unique ID is likely to fail the interaction process.
    """

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

    def set_text(
            self,
            text: Union[float, int, str],
            release_focus: bool = True,
            binding_wait_time: float = 1) -> None:
        """
        Pass-through function to set the text of the Protected Numeric Entry Field via double-click.

        :param text: The value which you would like to apply to the Numeric Entry Field.
        :param release_focus: Specifies whether to commit the text after typing.
        :param binding_wait_time: The amount of time to wait after applying or cancelling changes. Ignored if no action
            is to be taken after sending text.

        :raises AssertionError: If the final displayed (and formatted) value of the Numeric Entry Field does not match
             the supplied text.
        """
        self.set_text_via_double_click(
            text=text,
            release_focus=release_focus,
            binding_wait_time=binding_wait_time)

    def set_text_via_double_click(
            self,
            text: Union[float, int, str],
            release_focus: bool = True,
            binding_wait_time: float = 1) -> None:
        """
        Set the text of the Protected Numeric Entry Field via double-click.

        :param text: The value which you would like to apply to the Numeric Entry Field.
        :param release_focus: Specifies whether to commit the text after typing.
        :param binding_wait_time: The amount of time to wait after applying or cancelling changes. Ignored if no action
            is to be taken after sending text.

        :raises AssertionError: If the final displayed (and formatted) value of the Numeric Entry Field does not match
             the supplied text.
        """
        IASelenium(driver=self.driver).double_click(web_element=self.find())
        self._set_text(text=str(text), release_focus=release_focus)
        self.wait_on_binding(time_to_wait=binding_wait_time)
        if release_focus:
            IAAssert.is_equal_to(
                actual_value=_remove_commas(
                    value=self.wait_on_text_condition(
                        text_to_compare=text, condition=TextCondition.EQUALS)),
                expected_value=_remove_commas(value=_zero_out(expected_value=text)),
                failure_msg="Failed to apply the supplied text to the Protected-variant Numeric Entry Field.",
                as_type=str)

    def set_text_via_long_press(
            self,
            text: str,
            release_focus: bool = True,
            binding_wait_time: float = 1) -> None:
        """
        Set the text of the Protected Numeric Entry Field via a long-press.

        :param text: The value which you would like to apply to the Numeric Entry Field.
        :param release_focus: Specifies whether to commit the text after typing.
        :param binding_wait_time: The amount of time to wait after applying or cancelling changes. Ignored if no action
            is to be taken after sending text.

        :raises AssertionError: If the final displayed (and formatted) value of the Numeric Entry Field does not match
             the supplied text after releasing focus.
        """
        IASelenium(driver=self.driver).long_click(web_element=self.find())
        self._set_text(text=str(text), release_focus=release_focus)
        self.wait_on_binding(time_to_wait=binding_wait_time)
        if release_focus:
            IAAssert.is_equal_to(
                actual_value=_remove_commas(
                    value=self.wait_on_text_condition(
                        text_to_compare=text, condition=TextCondition.EQUALS)),
                expected_value=_remove_commas(value=_zero_out(expected_value=text)),
                failure_msg="Failed to apply the supplied text to the Protected-variant Numeric Entry Field while "
                            "activating the input via a long-press interaction.",
                as_type=str)

    def _set_text(self, text: str, release_focus: bool = False) -> None:
        """
        Set the text of the Protected Numeric Entry Field after interaction has been triggered via a long-press.

        :param text: The text to supply to the Numeric Entry Field
        :param release_focus: Specifies whether to commit the typed value.
        """
        self.driver.execute_script('arguments[0].value = ""', self.find())
        self._internal_input.find().send_keys(str(text) + (Keys.ENTER if release_focus else ''))

Components/PerspectiveComponents/Inputs/NumericEntryField.py
inductiveautomation/ignition-automation-tools
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Components.PerspectiveComponents.Inputs.Button import Button


class OneShotButton(Button):
    """A Perspective One-Shot Button Component"""
    _CANCEL_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button[data-button-type="cancel"]')
    _CONFIRM_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button[data-button-type="confirm"]')
    _MODAL_MESSAGE_LOCATOR = (By.CSS_SELECTOR, "div.ia_oneShotButtonComponent__confirmModal__message")
    _ICON_LOCATOR = (By.TAG_NAME, "svg")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._modal = ComponentModal(driver=driver, poll_freq=poll_freq)
        self._cancel_button = Button(
            locator=self._CANCEL_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._modal.locator_list,
            poll_freq=poll_freq)
        self._confirm_button = Button(
            locator=self._CONFIRM_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._modal.locator_list,
            poll_freq=poll_freq)
        self._modal_message = ComponentPiece(
            locator=self._MODAL_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self._modal.locator_list,
            poll_freq=poll_freq)
        self._local_icon = CommonIcon(
            locator=self._ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def click_cancel_in_modal(self) -> None:
        """
        Click the Cancel button, available when confirmation is enabled.

        :raises TimeoutException: If the Cancel button is not present.
        """
        self._cancel_button.click()

    def click_confirm_in_modal(self) -> None:
        """
        Click the Confirm button, available when confirmation is enabled.

        :raises TimeoutException: If the Confirm button is not present.
        """
        self._confirm_button.click()

    def confirmation_modal_is_displayed(self) -> bool:
        """
        Determine if the confirmation modal is currently displayed.

        :returns: True, if the confirmation modal is present - False otherwise.
        """
        try:
            return self._modal.find(wait_timeout=0.5) is not None and self._confirm_button.find(0.5) is not None
        except TimeoutException:
            return False

Components/PerspectiveComponents/Inputs/OneShotButton.py
inductiveautomation/ignition-automation-tools
from typing import Optional, Union, List, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent
from Components.Common.TextInput import CommonTextInput
from Components.PerspectiveComponents.Common.Icon import CommonIcon


class PasswordField(BasicPerspectiveComponent):
    """A Perspective Password Field Component."""
    _SHOW_PASSWORD_ICON_LOCATOR = (By.CSS_SELECTOR, 'a.ia_passwordFieldComponent__visibilityLink')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._internal_input = CommonTextInput(
            locator=(By.TAG_NAME, "input"),
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=2,
            poll_freq=poll_freq)
        self._icon = CommonIcon(
            locator=self._SHOW_PASSWORD_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)

    def display_password(self, should_be_displayed: bool) -> None:
        """
        Activate the visibility toggle for the field so that the text is displayed.

        :raises TimeoutException: If the ability to toggle visibility is not enabled for the component.
        """
        if self.password_is_displayed() != should_be_displayed:
            self._icon.click()

    def get_displayed_text(self) -> str:
        """
        Get the human-readable text from the Password Field. Note that due rules applied to the underlying HTML element
        this function should only ever return an empty string.

        :returns: An empty string if the component is working as expected, because the 'visible' text is not actually
            present in the DOM.
        """
        # We must NOT use TextField's get_text() method as it actually pulls the value - as opposed
        # to the visible text. That being said, even grabbing the WebElement's text should only ever
        # give us an empty String for the Password Field.
        return self._internal_input.find().text

    def get_placeholder_text(self) -> str:
        """
        Obtain the placeholder text of the component. Note that this value is returned even when the Password Field is
        not currently displaying the placeholder text.

        :returns: The text the Password Field would use as placeholder text, regardless of whether or not this text is
            currently being used.
        """
        return self._internal_input.find().get_attribute("placeholder")

    def password_is_displayed(self) -> bool:
        """
        Determine if the content supplied as a password is currently being displayed to the user.

        :returns: True, if the user may currently see the password content as text - False if the user is seeing the
            masked (asterisks) content.
        """
        return self._internal_input.find().get_attribute("type") == "text"

    def placeholder_text_exists(self) -> bool:
        """
        Determine if placeholder text is currently configured for the Password Field. Does not reflect current use.

        :returns: True, if placeholder text is configured, regardless of current use - False if not placeholder text is
            configured.
        """
        return self.get_placeholder_text() is not None

    def set_text(
            self,
            text: Union[int, float, str],
            release_focus: bool = True,
            binding_wait_time: float = 0) -> None:
        """
        Set the text content of the Password Field.

        :param text: The text content you would like to set the Password Field to contain.
        :param release_focus: Dictates whether the ENTER Key is supplied as a means to commit the value.
        :param binding_wait_time: The amount of time to wait after applying the supplied text before we allow code to
            continue.
        """
        self._internal_input.find().clear()
        text = text + Keys.ENTER if release_focus else text
        self._internal_input.find().send_keys(text)
        self.wait_on_binding(time_to_wait=binding_wait_time)

    def show_password_icon_is_visible(self) -> bool:
        """
        Determine if the Password Field is currently displaying the icon used to toggle content visibility.

        :returns: True, if the icon is visible - False otherwise.
        """
        return self._icon.is_rendered() and self._icon.find().is_displayed()

Components/PerspectiveComponents/Inputs/PasswordField.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Tuple, List, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon


class RadioGroup(BasicPerspectiveComponent):
    """
    A Perspective Radio Group Component. A Radio Group component typically contains multiple buttons. Interaction
    preference is via the text of each button, though value may also be supplied.
    """
    _INTERNAL_RADIO_GROUP_LABEL_TEXT_LOCATOR = (By.CSS_SELECTOR, "label.ia_radioGroupComponent_text")
    _WRAPPER_CLASS = "ia_radioGroupComponent_radioWrapper"
    _RADIO_GROUP_BUTTON_VALUE_LOCATOR = (By.TAG_NAME, "input")
    _RADIO_GROUP_FORM_LOCATOR = (By.CSS_SELECTOR, "form")

    class Orientation(Enum):
        COLUMN = "column"
        ROW = "row"

    class TextPosition(Enum):
        BOTTOM = "bottom"
        LEFT = "left"
        RIGHT = "right"
        TOP = "top"

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._radio_group_form = ComponentPiece(
            locator=self._RADIO_GROUP_FORM_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1)
        self._internal_radio_group_label_text = ComponentPiece(
            locator=self._INTERNAL_RADIO_GROUP_LABEL_TEXT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1)
        self._radio_group_button_value = ComponentPiece(
            locator=self._RADIO_GROUP_BUTTON_VALUE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1)
        self._radio_wrapper_coll = {}
        self._input_coll = {}
        self._icon_coll = {}
        self._label_coll = {}

    def click_radio_button(
            self, button_text: str = None, button_value: str = None, binding_wait_time: float = 2) -> None:
        """
        Click a Radio Button by supplying either the text of the Radio button or the value of the Radio button. The
        actual click will occur on the label for the Radio button.

        :param button_text: The text of the Radio button you would like to click. If multiple radio buttons have the
            same exact text the first match will be clicked. Takes precedence over button_value.
        :param button_value: The value of the Radio button you would like to click.
        :param binding_wait_time: The amount of time after any click event before we allow code to continue.

        :raises TimeoutException: If no Radio button with the provided text or value is present.
        """
        self._get_label(button_text=button_text, button_value=button_value).click(binding_wait_time=binding_wait_time)

    def get_count_of_radio_buttons_in_group(self) -> int:
        """Get a count of buttons within the Radio Group."""
        return len(self._radio_group_button_value.find_all())

    def has_orientation(self, orientation: Orientation) -> bool:
        """
        Determine if the Radio Group currently has the supplied orientation.

        :param orientation: The orientation you hope to verify the Radio Group is currently using.
        """
        return f"radio-form-{orientation.value}" in self._radio_group_form.find().get_attribute('class')

    def has_text_position(self, expected_text_position: TextPosition) -> bool:
        """
        Determine if the labels of the Radio Group currently have the supplied relative positioning.

        :param expected_text_position: The position relative to the 'button' you hope to verify the text labels
            currently have.
        """
        return f"ia_radioGroupComponent_text--{expected_text_position.value}" in \
            self._internal_radio_group_label_text.find().get_attribute('class')

    def option_is_selected(self, button_text: str = None, button_value: str = None) -> bool:
        """
        Determine if the Radio button identified by the text or value is currently selected.

        :param button_text: The text of the Radio button for which you would like to check the selection state. If
            multiple radio buttons have the same exact text the first match will have its selection state checked.
            Takes precedence over button_value.
        :param button_value: The value of the Radio button for which you would like to check the selection state.

        :returns: True, if a Radio button with text matching the supplied button_text is currently selected, or - if
            button_text was not supplied - if a Radio button matching the supplied button_value is selected.
        """
        try:
            return self._get_input(button_text=button_text, button_value=button_value).find().is_selected()
        except TimeoutException:
            return False

    def _get_icon(self, button_text: str = None, button_value: str = None) -> CommonIcon:
        """
        Obtain the CommonIcon in use for the button of the Radio Group.

        :param button_text: The text of the Radio button from which you'd like the icon. If multiple radio buttons have
            the same exact text the first match will be selected. Takes precedence over button_value.
        :param button_value: The value of the Radio button from which you'd like the icon.

        :returns: CommonIcon
        """
        attr_text = button_text if button_text else button_value
        _icon = self._icon_coll.get(attr_text)
        if not _icon:
            _icon = CommonIcon(
                locator=(By.TAG_NAME, "svg"),
                driver=self.driver,
                parent_locator_list=self._get_radio_wrapper(
                    button_text=button_text,
                    button_value=button_value).locator_list)
            self._icon_coll[attr_text] = _icon
        return _icon

    def _get_input(self, button_text: str = None, button_value: str = None) -> ComponentPiece:
        """
        Obtain the HTML input in use for the button of the Radio Group as a ComponentPiece.

        :param button_text: The text of the Radio button from which you'd like the input. If multiple radio buttons have
            the same exact text the first match will be selected. Takes precedence over button_value.
        :param button_value: The value of the Radio button from which you'd like the input.

        :returns: ComponentPiece
        """
        attr_text = button_text if button_text else button_value
        _input = self._input_coll.get(attr_text)
        if not _input:
            _input = ComponentPiece(
                locator=(By.TAG_NAME, "input"),
                driver=self.driver,
                parent_locator_list=self._get_radio_wrapper(
                    button_text=button_text,
                    button_value=button_value).locator_list)
            self._input_coll[attr_text] = _input
        return _input

    def _get_label(self, button_text: str = None, button_value: str = None) -> ComponentPiece:
        """
        Obtain the label in use for the button of the Radio Group as a ComponentPiece.

        :param button_text: The text of the Radio button from which you'd like the label. If multiple radio buttons have
            the same exact text the first match will be selected. Takes precedence over button_value.
        :param button_value: The value of the Radio button from which you'd like the label.

        :returns: ComponentPiece
        """
        attr_text = button_text if button_text else button_value
        label = self._label_coll.get(attr_text)
        if not label:
            label = ComponentPiece(
                locator=(By.TAG_NAME, "label"),
                driver=self.driver,
                parent_locator_list=self._get_radio_wrapper(
                    button_text=button_text,
                    button_value=button_value).locator_list)
            self._label_coll[attr_text] = label
        return label

    def _get_radio_wrapper(self, button_text: str = None, button_value: str = None) -> ComponentPiece:
        """
        Obtain the wrapper in use for the button of the Radio Group as a ComponentPiece. This wrapper contains the
        input, icon, and label for the 'button'.

        :param button_text: The text of the Radio button from which you'd like the wrapper. If multiple radio buttons
            have the same exact text the first match will be selected. Takes precedence over button_value.
        :param button_value: The value of the Radio button from which you'd like the wrapper.

        :returns: ComponentPiece
        """
        assert button_text is not None or button_value is not None, \
            "You must supply either the text or value of the button"
        attr_text = button_text if button_text else button_value
        wrapper = self._radio_wrapper_coll.get(attr_text)
        if not wrapper:
            locator_piece = f'[data-{"label" if button_text else "value"}="{attr_text}"]'
            wrapper = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'div.{self._WRAPPER_CLASS}{locator_piece}'),
                driver=self.driver,
                parent_locator_list=self.locator_list)
            self._radio_wrapper_coll[attr_text] = wrapper
        return wrapper

Components/PerspectiveComponents/Inputs/RadioGroup.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Tuple, List, Optional

from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Components.PerspectiveComponents.Inputs.Button import Button


class Orientation(Enum):
    """
    Orientation describes the relative positioning of the action bar of the Signature Pad in relation to the canvas
    of the Component, where the action bar contains the Clear and Submit buttons.
    """
    TOP = "column-reverse"
    BOTTOM = "column"
    RIGHT = "row"
    LEFT = "row-reverse"


class SignaturePad(BasicPerspectiveComponent):
    """A Perspective Signature Pad Component."""
    _SIG_PAD_LOCATOR = (By.CSS_SELECTOR, 'div.ia-signature-pad-canvas-wrapper')
    _SIG_PAD_BAR_LOCATOR = (By.CSS_SELECTOR, 'div.ia-signature-pad-action-bar')
    _CLEAR_BUTTON_LOCATOR = (By.CSS_SELECTOR, f'button[data-button-type="clear"]')
    _SUBMIT_BUTTON_LOCATOR = (By.CSS_SELECTOR, f'button[data-button-type="submit"]')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._action_bar = ComponentPiece(
            locator=self._SIG_PAD_BAR_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._clear_button = Button(
            locator=self._CLEAR_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._action_bar.locator_list,
            poll_freq=poll_freq)
        self._submit_button = Button(
            locator=self._SUBMIT_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._action_bar.locator_list,
            poll_freq=poll_freq)
        self._signature_pad = ComponentPiece(
            locator=self._SIG_PAD_LOCATOR, driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)

    def action_bar_has_orientation(self, expected_orientation: Orientation) -> bool:
        """
        Determine the orientation of the action bar of the Signature Pad.

        :returns: True, if the action bar has the supplied Orientation - False otherwise.
        """
        return expected_orientation.value.lower() == self.find().value_of_css_property("flex-direction").lower()

    def clear_button_is_enabled(self) -> bool:
        """
        Determine if the Clear button is currently enabled for the Signature Pad.

        :returns: True, if the Clear button is enabled - False otherwise.
        """
        return self._clear_button.is_enabled()

    def clear_button_is_primary(self) -> bool:
        """
        Determine if the Clear button is currently declaring as a Primary button type.

        :returns: True, if the Clear button is a Primary button - False otherwise.

        :raises TimeoutException: If the Clear button is not currently displayed.
        """
        return self._clear_button.is_primary()

    def clear_button_is_secondary(self) -> bool:
        """
        Determine if the Clear button is currently declaring as a Secondary button type.

        :returns: True, if the Clear button is a Secondary button - False otherwise.

        :raises TimeoutException: If the Clear button is not currently displayed.
        """
        return self._clear_button.is_secondary()

    def click_clear_button(self) -> None:
        """
        Click the Clear button.

        :raises TimeoutException: If the Clear button is not currently displayed.
        """
        self._clear_button.click()

    def click_submit_button(self) -> None:
        """
        Click the Submit button.

        :raises TimeoutException: If the Clear button is not currently displayed.
        """
        self._submit_button.click()

    def draw_on_canvas(self) -> None:
        """
        Make a small mark on the canvas of the Signature Pad. Useful for mocking a signature.
        """
        ActionChains(driver=self.driver)\
            .click_and_hold(on_element=self._signature_pad.find())\
            .move_by_offset(xoffset=10, yoffset=10)\
            .release()\
            .perform()

    def get_text_of_clear_button(self) -> str:
        """
        Obtain the text currently displayed for the Clear button.

        :raises TimeoutException: If the Clear button is not currently displayed.
        """
        return self._clear_button.get_text()

    def get_text_of_submit_button(self) -> str:
        """
        Obtain the text currently displayed for the Submit button.

        :raises TimeoutException: If the Submit button is not currently displayed.
        """
        return self._submit_button.get_text()

    def submit_button_is_enabled(self) -> bool:
        """
        Determine if the Submit button is currently enabled.
        """
        return self._submit_button.is_enabled()

    def submit_button_is_primary(self) -> bool:
        """
        Determine if the Submit button is currently declaring as a Primary button type.

        :raises TimeoutException: If the Submit button is not currently displayed.
        """
        return self._submit_button.is_primary()

    def submit_button_is_secondary(self) -> bool:
        """
        Determine if the Submit button is currently declaring as a Secondary button type.

        :raises TimeoutException: If the Submit button is not currently displayed.
        """
        return self._submit_button.is_secondary()

Components/PerspectiveComponents/Inputs/SignaturePad.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, List, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent


class Slider(BasicPerspectiveComponent):
    """A Perspective Slider Component."""
    _WRAPPER_LOCATOR = (By.CSS_SELECTOR, 'div.slider')
    _LABEL_LOCATOR = (By.CSS_SELECTOR, 'span.slider-mark-text')
    _HANDLE_LOCATOR = (By.CSS_SELECTOR, 'div[role="slider"]')
    _RAIL_LOCATOR = (By.CSS_SELECTOR, 'div.slider-rail')
    _TRACK_LOCATOR = (By.CSS_SELECTOR, 'div.slider-track')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 3,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._wrapper = ComponentPiece(
            locator=self._WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._rail = ComponentPiece(
            locator=self._RAIL_LOCATOR,
            driver=driver,
            parent_locator_list=self._wrapper.locator_list,
            poll_freq=poll_freq)
        self._track = ComponentPiece(
            locator=self._TRACK_LOCATOR,
            driver=driver,
            parent_locator_list=self._wrapper.locator_list,
            poll_freq=poll_freq)
        self._handle = ComponentPiece(
            locator=self._HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self._wrapper.locator_list,
            poll_freq=poll_freq)
        self._labels = ComponentPiece(
            locator=self._LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._wrapper.locator_list,
            poll_freq=poll_freq)

    def click(self, wait_timeout: int = 1, binding_wait_time: float = 0.5) -> None:
        """
        :raises NotImplementedError: Clicking a Slider serves no purpose and risks setting the Slider to an unknown
            value. Please use click_handle instead.
        """
        raise NotImplementedError("Please do not blindly click the Slider.")

    def click_handle(self, binding_wait_time: float = 0.5) -> None:
        """
        Click the handle of the Slider. Note that this has the potential to change the value of the slider because
        different browser drivers click in different locations within the handle.
        """
        self._handle.click(binding_wait_time=binding_wait_time)

    def drag_to_percent_of_axis(self, percent: float) -> None:
        """
        Drag the Slider to a percent of its axis. Typically only useful for stepped Sliders, where interstitial values
        may not be reached via traditional means anyway.
        """
        potential_distance = self.get_computed_height() if self.is_vertically_oriented() else self.get_computed_width()
        current_percent = float(self.get_current_value()) / float(self.get_max()) * 100.0
        percent = percent - current_percent
        pixels_to_drag = int(
            round(
                float(
                    potential_distance) * float(
                    percent) / 100.0,
                0))
        self._drag_by_pixel_count(
            pixel_count=pixels_to_drag)

    def drag_to_value(self, value: int, max_attempts: int = 150) -> None:
        """
        Drag the handle of the Slider to a specific value.

        :param value: The value you would like to have the Slider reflect.
        :param max_attempts: The count of times we will drag the Slider by 1 pixel before giving up. This argument
            acts as a safeguard against infinite loops when a value may not be reached for any reason. For sliders which
            span many pixels, you may need to provide higher values here.


        :raises ValueError: If the Slider is not successfully set to the supplied value.
        """
        value = int(value)
        x_step = 1
        y_step = 0
        if self.is_vertically_oriented():
            x_step = abs(x_step - 1)
            y_step = abs(y_step - 1)

        attempts = 0
        if int(self.get_current_value()) < value:
            ActionChains(driver=self.driver).click_and_hold(on_element=self._handle.find()).perform()
            while (int(self.get_current_value()) < value) \
                    and (int(self.get_max()) > int(self.get_current_value())) \
                    and (attempts < max_attempts):
                attempts += 1
                ActionChains(driver=self.driver).move_by_offset(x_step, (y_step * -1.0)).perform()
            ActionChains(driver=self.driver).release().perform()
        elif int(self.get_current_value()) > value:
            ActionChains(driver=self.driver).click_and_hold(on_element=self._handle.find()).perform()
            while (int(self.get_current_value()) > value) \
                    and (int(self.get_min()) < int(self.get_current_value())) \
                    and (attempts < max_attempts):
                attempts += 1
                ActionChains(driver=self.driver).move_by_offset((x_step * -1.0), y_step).perform()
            ActionChains(driver=self.driver).release().perform()
        if self.get_current_value() != value:
            raise ValueError(
                f"Failed to set the Slider to a value of {value} after trying up to {max_attempts} times.")

    def get_current_value(self) -> int:
        """Obtain the current value of the Slider."""
        return int(float(self._handle.find().get_attribute("aria-valuenow")))

    def get_handle_color(self) -> str:
        """Obtain the current color of the Slider handle as a string."""
        return self._handle.find().value_of_css_property('border-bottom-color')

    def get_labels_as_list(self) -> List[str]:
        """Obtain the labels currently displayed by the Slider. An empty list denotes no labels are displayed."""
        try:
            return [_.text for _ in self._labels.find_all()]
        except TimeoutException:
            return []

    def get_max(self) -> int:
        """Obtain the maximum value the Slider may be set to."""
        return int(float(self._handle.find().get_attribute("aria-valuemax")))

    def get_min(self) -> int:
        """Obtain the minimum value the Slider may be set to."""
        return int(float(self._handle.find().get_attribute("aria-valuemin")))

    def get_track_color(self) -> str:
        """Obtain the current color of the Slider track as a string."""
        return self._track.find().value_of_css_property('background-color')

    def is_displaying_labels(self) -> bool:
        """Determine if the Slider is currently displaying any labels."""
        return len(self.get_labels_as_list()) > 0

    def is_enabled(self) -> bool:
        """Determine if the Slider is currently enabled."""
        return self._handle.find().get_attribute("aria-disabled") == "false"

    def is_vertically_oriented(self) -> bool:
        """
        Determine if the Slider is vertically oriented. A vertical orientation means the Slider handle would be dragged
        up and down instead of left-to-right.
        """
        return 'slider-vertical' in self._wrapper.find().get_attribute("class")

    def _drag_by_pixel_count(self, pixel_count: int) -> None:
        """
        Drag the Slider handle by some number of pixels.
        """
        x_step = 0 if self.is_vertically_oriented() else pixel_count
        y_step = pixel_count if self.is_vertically_oriented() else 0
        ActionChains(driver=self.driver)\
            .click_and_hold(on_element=self._handle.find())\
            .move_by_offset(xoffset=x_step, yoffset=y_step)\
            .release()\
            .perform()

Components/PerspectiveComponents/Inputs/Slider.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent
from Components.Common.TextInput import CommonTextInput


class TextArea(BasicPerspectiveComponent, CommonTextInput):
    """A Perspective Text Area Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        CommonTextInput.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

    def _needs_to_get_input_element(self):
        return False

Components/PerspectiveComponents/Inputs/TextArea.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent
from Components.Common.TextInput import CommonTextInput


class TextField(BasicPerspectiveComponent, CommonTextInput):
    """A Perspective Text Field Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        BasicPerspectiveComponent.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        CommonTextInput.__init__(
            self,
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)

Components/PerspectiveComponents/Inputs/TextField.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, List, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import ComponentPiece, BasicPerspectiveComponent
from Helpers.CSSEnumerations import CSS
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec


class ToggleSwitch(BasicPerspectiveComponent):
    """
    A Perspective Toggle Switch Component.

    The preferred term for referencing the state of the Toggle Switch is "active", instead of "on", "selected", or
    "True".
    """
    _TS_THUMB_LOCATOR = (By.CSS_SELECTOR, 'div.ia_toggleSwitch__thumb')
    _TS_TRACK_LOCATOR = (By.CSS_SELECTOR, 'div.ia_toggleSwitch__track')
    _TS_SELECTED_CLASS = 'ia_toggleSwitch__thumb--selected'
    _TS_DISABLED_CLASS = 'ia_toggleSwitch__thumb--disabled'

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 10,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self.ts_thumb = ComponentPiece(
            locator=self._TS_THUMB_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            description="The circle piece of the Toggle Switch, which slides back and forth within the track.",
            poll_freq=poll_freq)
        self.ts_track = ComponentPiece(
            locator=self._TS_TRACK_LOCATOR,
            driver=self.driver,
            parent_locator_list=self.locator_list,
            description="The horizontal oval in which the 'thumb' of the Toggle Switch slides back-and-forth.",
            poll_freq=poll_freq)

    def set_switch(self, should_be_active: bool = True, binding_wait_time: float = 0.5) -> None:
        """
        Set the state of the Toggle Switch. Takes no action if the Toggle Switch already has the specified state.

        :param should_be_active: The desired state of the Toggle Switch.
        :param binding_wait_time: The amount of time to wait after any click action is taken before continuing.

        :raises AssertionError: If unsuccessful in applying the desired state to the Toggle Switch.
        """
        if not self.has_state(expected_state=should_be_active):
            self.click(binding_wait_time=binding_wait_time)
        IAAssert.is_true(
            value=self.has_state(expected_state=should_be_active),
            failure_msg=f"Failed to set the state of a Toggle Switch to {should_be_active}.")

    def get_track_color(self) -> str:
        """
        Obtain the color of the track as a string. Note that different browsers may return this color in different
        formats (RGB vs hex).

        :returns: The color of the track of the Toggle Switch as a string.
        """
        return self.ts_track.get_css_property(property_name=CSS.BACKGROUND_COLOR)

    def has_state(self, expected_state: bool, wait_timeout: float = 1) -> bool:
        """
        Determine whether the Toggle Switch has the supplied state. Preferable to is_active because has_state works
        for both active/inactive states.

        :returns: True, if the Toggle Switch has the specified state - False otherwise.
        """
        try:
            if not expected_state:  # False
                # We are forced to wait a moment here, because Toggle Switches enter the DOM as inactive; attempts to
                # verify if they are False too early will almost always return True because the front-end has not caught
                # up to the backend. This is most prevalent when dealing with a Toggle Switch immediately after it has
                # entered the DOM, like when Popups are opened.
                self.wait_on_binding(0.25)
            return WebDriverWait(driver=self.driver, timeout=wait_timeout).until(
                IAec.function_returns_true(
                    custom_function=self._has_state,
                    function_args={'expected_state': expected_state}))
        except TimeoutException:
            return False

    def is_active(self, wait_timeout: float = 1) -> bool:
        """
        Determine whether the Toggle Switch is currently active (on, True, selected) while waiting up to the specified
        amount of time for the Toggle Switch to become active before reporting. Does not work for waiting until a Toggle
        Switch is inactive. Preferable to use :func:`has_state`.

        :returns: True, if the Toggle Switch becomes active before the specified wait period elapses - False otherwise.
        """
        try:
            return WebDriverWait(
                driver=self.driver, timeout=wait_timeout, poll_frequency=self.poll_freq).until(
                IAec.function_returns_true(
                    custom_function=self._is_active,
                    function_args={}))
        except TimeoutException:
            return False

    def is_enabled(self) -> bool:
        """Determine if the Toggle Switch is currently enabled."""
        return self._TS_DISABLED_CLASS not in self.ts_thumb.find().get_attribute('class')

    def _is_active(self) -> bool:
        """Determine if the Toggle Switch is currently active (on/selected/True/active)."""
        return self._TS_SELECTED_CLASS in self.ts_thumb.find(wait_timeout=0).get_attribute('class')

    def _has_state(self, expected_state: bool) -> bool:
        """Determine whether the Toggle Switch has the supplied state."""
        return self._is_active() == expected_state

Components/PerspectiveComponents/Inputs/ToggleSwitch.py
inductiveautomation/ignition-automation-tools
from typing import Optional, List, Tuple

from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Components.PerspectiveComponents.Common.ComponentModal import ComponentModal
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.IASelenium import IASelenium


class HorizontalMenu(BasicPerspectiveComponent):
    """A Perspective Horizontal Menu Component."""
    _MENU_ITEM_LOCATOR = (By.CSS_SELECTOR, "div.horizontal-menu-menu-item")
    _CHILD_MENU_ITEM_LOCATOR = (By.CSS_SELECTOR, '.horizontal-menu-menu-item span')
    _SIDE_SCROLL_AREA_LOCATOR = (By.CSS_SELECTOR, 'div.side-nav')
    _SIDE_SCROLL_CLICKABLE_AREA_LOCATOR = (By.CSS_SELECTOR, "span.icon-wrapper")

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 5,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._menu_items = ComponentPiece(
            locator=self._MENU_ITEM_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._child_item_modal = ComponentModal(driver=driver)
        self._child_menu_items = ComponentPiece(
            locator=self._CHILD_MENU_ITEM_LOCATOR,
            driver=driver,
            parent_locator_list=self._child_item_modal.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._side_scroll_areas = ComponentPiece(
            locator=self._SIDE_SCROLL_AREA_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._side_scroll_clickable_area = ComponentPiece(
            locator=self._SIDE_SCROLL_AREA_LOCATOR,
            driver=driver,
            parent_locator_list=self._side_scroll_areas.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._menu_item_coll = {}
        self._child_item_coll = {}
        self._top_level_menu_item_icon_coll = {}

    def base_item_has_icon(self, text_of_base_item: str) -> bool:
        """
        Determine whether an option at the top-most level is displaying an icon.

        :returns: True, if an icon is displayed - False otherwise.
        """
        try:
            return self._get_top_level_item_icon(
                text_of_base_item=text_of_base_item).find().is_displayed()
        except TimeoutException:
            return False

    def base_item_is_visible(self, text_of_base_item: str) -> bool:
        """
        Determine whether an option with the supplied text is present at the top-most level.

        :returns: True, if an option with the supplied text is present at the top-most level - False otherwise.
        """
        try:
            return self._get_top_level_menu_item(
                text_of_base_item=text_of_base_item).find().is_displayed()
        except TimeoutException:
            return False

    def can_scroll_left(self) -> bool:
        """
        Determine if the Horizontal Menu currently allows for scrolling to the left.
        """
        try:
            return 'shown' in self._side_scroll_areas.find_all()[0].get_attribute('class')
        except TimeoutException:
            return False

    def can_scroll_right(self) -> bool:
        """
        Determine if the Horizontal Menu currently allows for scrolling to the right.
        """
        try:
            return 'shown' in self._side_scroll_areas.find_all()[1].get_attribute('class')
        except TimeoutException:
            return False

    def child_item_has_icon(self, text_of_child_item: str) -> bool:
        """
        Determine whether an item at any child level with the supplied text is displaying an icon.

        :returns: True, if a child item with the supplied text is displaying an icon  - False otherwise.
        """
        try:
            return self._get_child_item_icon(
                text_of_child_item=text_of_child_item).find() is not None
        except TimeoutException:
            return False

    def child_item_is_visible_by_text(self, text_of_child_item: str) -> bool:
        """
        Determine if a child item with the supplied text is displayed.

        :returns: True, if a child item is displayed which has the exact supplied text - False otherwise.
        """
        try:
            return self._get_child_menu_item(
                text_of_child_item=text_of_child_item).find().is_displayed()
        except TimeoutException:
            return False

    def child_items_are_visible(self) -> bool:
        """
        Determine if any child options are visible, meaning a top-level option with children was clicked. Note that
        due to the nature of the children this would also report True if any other modal is open on the
        screen - including Popups, Docked Views, or child items of other Horizontal Menus.

        :returns: True, if child items (or any modal) is found - False otherwise.
        """
        try:
            return len(self._child_menu_items.find_all()) > 0
        except TimeoutException:
            return False

    def click_visible_base_item(self, text_of_base_item: str, binding_wait_time: float = 0) -> None:
        """
        Click a base-level item by text.

        :raises NoSuchElementException: If unable to locate any top-level item with the supplied text.
        """
        while (not self.base_item_is_visible(
                text_of_base_item=text_of_base_item)) \
                and (self.is_compressed()) \
                and self.can_scroll_right():
            self.short_scroll(scroll_right=True)
        while (not self.base_item_is_visible(
                text_of_base_item=text_of_base_item)) \
                and (self.is_compressed()) \
                and self.can_scroll_left():
            self.short_scroll(scroll_right=False)
        if not self.base_item_is_visible(
                text_of_base_item=text_of_base_item):
            raise NoSuchElementException(f'No item with text of \'{text_of_base_item}\' could be found')
        self._get_top_level_menu_item(
            text_of_base_item=text_of_base_item).click(binding_wait_time=binding_wait_time)

    def click_visible_child_item(self, text_of_child_item: str) -> None:
        """
        Click a base-level item by text.

        :raises TimeoutException: If unable to locate any child item with the supplied text.
        """
        self._get_child_menu_item(
            text_of_child_item=text_of_child_item).click()

    def get_icon_id_from_base_item(self, text_of_base_item: str) -> str:
        """
        Obtain the library and id of the icon in use by the base-level item with the supplied text as a slash-delimited
        string.
        """
        return self._get_top_level_item_icon(
            text_of_base_item=text_of_base_item).get_icon_name()

    def get_icon_id_from_child_item(self, text_of_child_item: str) -> str:
        """
        Obtain the library and id of the icon in use by the child item with the supplied text as a slash-delimited
        string.
        """
        return self._get_child_item_icon(
            text_of_child_item=text_of_child_item).get_icon_name()

    def get_list_of_base_level_items(self) -> List[str]:
        """
        Obtain the text for all base-level items.
        """
        return [item.text for item in self._menu_items.find_all()]

    def is_compressed(self) -> bool:
        """
        Determine if the Horizontal Menu currently displays that a user may scroll either left or right.

        :returns: True, if a user may currently scroll the Horizontal Menu left, right, or both - False if a user
            may not scroll the Horizontal Menu in either direction.
        """
        try:
            return self.can_scroll_right() or self.can_scroll_left()
        except TimeoutException:
            return False

    def scroll_to_max(self, scroll_right: bool) -> None:
        """
        Scroll the Horizontal Menu as far as it can scroll (left/right).

        :param scroll_right: If True, the Horizontal Menu will be scrolled right. If False, the Horizontal Menu will
            be scrolled left.
        """
        can_scroll_in_direction = self.can_scroll_right if scroll_right else self.can_scroll_left()
        while can_scroll_in_direction():
            self.short_scroll(scroll_right=scroll_right)

    def short_scroll(self, scroll_right: bool) -> None:
        """
        Scroll the Horizontal Menu (left/right) a small amount. Useful when searching for a desired top-level item.

        :param scroll_right: If True, the Horizontal Menu will be scrolled right. If False, the Horizontal Menu
            will be scrolled left.
        """
        side_nav_to_click = self._side_scroll_clickable_area.find_all()[1 if scroll_right else 0]
        IASelenium(driver=self.driver).long_click(web_element=side_nav_to_click)

    def _get_child_menu_item(self, text_of_child_item: str) -> ComponentPiece:
        """
        Retrieve a child item Component, or construct a new one if it doesn't exist already.
        """
        item = self._child_item_coll.get(text_of_child_item)
        if not item:
            # Here, we're using the Component Modal as the direct parent WITHOUT including a reference to the actual
            # component, even though the modal is typically a CHILD of the component. There are edge cases
            # (fullscreen mode) where this is not always the case, so we can't force the longer locator.
            item = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'.horizontal-menu-menu-item[data-label="{text_of_child_item}"]'),
                driver=self.driver,
                parent_locator_list=self._child_item_modal.locator_list,
                wait_timeout=2,
                poll_freq=self.poll_freq)
            self._child_item_coll[text_of_child_item] = item
        return item

    def _get_top_level_menu_item(self, text_of_base_item: str) -> ComponentPiece:
        """
        Retrieve a top-level item Component, or construct a new one if it doesn't exist already.
        """
        item = self._menu_item_coll.get(text_of_base_item)
        if not item:
            item = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{self._MENU_ITEM_LOCATOR[1]}[data-label="{text_of_base_item}"]'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                wait_timeout=2,
                poll_freq=self.poll_freq)
            self._menu_item_coll[text_of_base_item] = item
        return item

    def _get_child_item_icon(self, text_of_child_item: str) -> CommonIcon:
        """
        Retrieve a child item ICON Component, or construct a new one if it doesn't exist already.
        """
        icon = self._top_level_menu_item_icon_coll.get(text_of_child_item)
        if not icon:
            icon = CommonIcon(
                locator=(By.CSS_SELECTOR, 'svg'),
                driver=self.driver,
                parent_locator_list=self._get_child_menu_item(
                    text_of_child_item=text_of_child_item).locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq)
            self._top_level_menu_item_icon_coll[text_of_child_item] = icon
        return icon

    def _get_top_level_item_icon(self, text_of_base_item: str) -> CommonIcon:
        """
        Retrieve a top-level item ICON Component, or construct a new one if it doesn't exist already.
        """
        icon = self._top_level_menu_item_icon_coll.get(text_of_base_item)
        if not icon:
            icon = CommonIcon(
                locator=(By.CSS_SELECTOR, 'svg'),
                driver=self.driver,
                parent_locator_list=self._get_top_level_menu_item(
                    text_of_base_item=text_of_base_item).locator_list,
                wait_timeout=1,
                poll_freq=self.poll_freq)
            self._top_level_menu_item_icon_coll[text_of_base_item] = icon
        return icon

Components/PerspectiveComponents/Navigation/HorizontalMenu.py
inductiveautomation/ignition-automation-tools
from typing import Optional, Tuple, List

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece


class Link(BasicPerspectiveComponent):
    """A Perspective Link Component."""

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: int = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._anchor = ComponentPiece(
            locator=(By.TAG_NAME, "a"),
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def get_link_href(self) -> str:
        """
        Obtain the destination URL of the Link component. Returned values which begin with a slash are Perspective
        page URLs which would be appended to the Gateway address (including port) to fashion a full URL, though
        the actual navigation is done internally instead of through an HTTP GET request.
        """
        return self._anchor.find().get_attribute('href')

    def get_target(self) -> str:
        """
        Obtain the browser target of the link.

        _blank - The destination specified within the href attribute will be opened in a new browser tab.
        All other values would open in the same browser tab.
        """
        return self._anchor.find().get_attribute('target')

Components/PerspectiveComponents/Navigation/Link.py
inductiveautomation/ignition-automation-tools
from typing import Union, Optional, List, Tuple

from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec


class ReportViewer(BasicPerspectiveComponent):
    """A Perspective Report Viewer Component."""
    _REPORT_CONTENTS_LOCATOR = (By.CSS_SELECTOR, 'div.react-pdf__Page__textContent')
    _ZOOM_LEVEL_LOCATOR = (By.CSS_SELECTOR, 'div.iaSelectCommon select')
    _FIRST_PAGE_LOCATOR = (By.CSS_SELECTOR, 'div.first-action')
    _PREVIOUS_PAGE_LOCATOR = (By.CSS_SELECTOR, 'div.previous-action')
    _NEXT_PAGE_LOCATOR = (By.CSS_SELECTOR, 'div.next-action')
    _LAST_PAGE_LOCATOR = (By.CSS_SELECTOR, 'div.last-action')
    _CURRENT_PAGE_LOCATOR = (By.CSS_SELECTOR, 'div.page-display input')
    _TOTAL_PAGE_LOCATOR = (By.CSS_SELECTOR, 'div.page-display span.page-count')
    _DOWNLOAD_LOCATOR = (By.CSS_SELECTOR, 'a.download-action')
    _TAB_OPEN_LOCATOR = (By.CSS_SELECTOR, 'a.open-in-tab-action')
    _COMPONENT_MESSAGE_LOCATOR = (By.CSS_SELECTOR, 'div.message-primary')
    _SPINNER_LOCATOR = (By.CSS_SELECTOR, 'svg.loading-spinner')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]] = None,
            wait_timeout: float = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._contents = ComponentPiece(
            locator=self._REPORT_CONTENTS_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._zoom_level_select = ComponentPiece(
            locator=self._ZOOM_LEVEL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._first_page_action = ComponentPiece(
            locator=self._FIRST_PAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._previous_page_action = ComponentPiece(
            locator=self._PREVIOUS_PAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._next_page_action = ComponentPiece(
            locator=self._NEXT_PAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._last_page_action = ComponentPiece(
            locator=self._LAST_PAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._current_page_action = ComponentPiece(
            locator=self._CURRENT_PAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._total_pages_label = ComponentPiece(
            locator=self._TOTAL_PAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._download_link = ComponentPiece(
            locator=self._DOWNLOAD_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._open_in_new_tab_link = ComponentPiece(
            locator=self._TAB_OPEN_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._message = ComponentPiece(
            locator=self._COMPONENT_MESSAGE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)
        self._loading_spinner = ComponentPiece(
            locator=self._SPINNER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=1,
            poll_freq=poll_freq)

    def click_first_page_button(self) -> None:
        """Click the 1st page button in the Report Viewer."""
        self._click_page_action_when_enabled(action=self._first_page_action)

    def click_previous_page_button(self) -> None:
        """Click the previous page button in the Report Viewer."""
        self._click_page_action_when_enabled(action=self._previous_page_action)

    def click_next_page_button(self) -> None:
        """Click the next page button in the Report Viewer."""
        self._click_page_action_when_enabled(action=self._next_page_action)

    def click_last_page_button(self) -> None:
        """Click the last page button in the Report Viewer."""
        self._click_page_action_when_enabled(action=self._last_page_action)

    def click_download_button(self) -> None:
        """
        Click the download button in the Report Viewer.

        :raises TimeoutException: If the Report Viewer could not be found, or if the ability to download is not
        enabled for the Report Viewer in use.
        """
        self._download_link.click()

    def download_button_is_displayed(self) -> bool:
        """
        Determine the visibility state of the download button of the Report Viewer.

        :returns: True, if the button which allows for downloading the Report is displayed - False otherwise.
        """
        try:
            return self._download_link.find().is_displayed()
        except TimeoutException:
            return False

    def tab_open_button_is_displayed(self) -> bool:
        """
        Determine the visibility state of the open-in-new-tab button of the Report Viewer.

        :returns: True, if the button which allows for opening the Report in a new tab is displayed - False otherwise.
        """
        try:
            return self._open_in_new_tab_link.find().is_displayed()
        except TimeoutException:
            return False

    def click_tab_open_button(self) -> None:
        """Click the open-in-new-tab button."""
        self._open_in_new_tab_link.scroll_to_element()
        self._open_in_new_tab_link.click()

    def input_page_number(self, page_number: Union[int, str]) -> None:
        """
        Type a page number for the Report Viewer to display.

        :param page_number: The page you would like to type into the Report Viewer in order to view the supplied page.
        """
        self._current_page_action.click(binding_wait_time=0.5)
        self._current_page_action.find().clear()
        self._current_page_action.find().send_keys(str(page_number))

    def get_page_number(self) -> int:
        """
        Obtain the page number currently being displayed by the report.

        :returns: The number of the page currently displayed to the user.
        """
        return int(self._current_page_action.find().get_attribute('value'))

    def get_page_count(self) -> int:
        """
        Obtain a count of how many pages the displayed report contains.

        :returns: A count of pages contained within the report.
        """
        return int(float(self._total_pages_label.get_text().split(' ')[-1]))

    def get_displayed_report_contents(self, _attempted_already=False) -> Optional[str]:
        """
        Obtain the text content of the report. This function will retry one time in the event of a failure.

        :param _attempted_already: Used internal to this function in an effort to retry in the event the Report Viewer
            updates during our first attempt. Should always be supplied as False.

        :raises StaleElementReferenceException: If the function has encountered a StaleElementReferenceException
            twice; such an exception can be encountered if the report is updated during querying.
        """
        try:
            WebDriverWait(driver=self.driver, timeout=5).until(
                IAec.function_returns_true(
                    custom_function=self._report_content_is_not_empty,
                    function_args={}))
            spans = WebDriverWait(driver=self.driver, timeout=3).until(
                ec.presence_of_element_located(self._REPORT_CONTENTS_LOCATOR)).find_elements(*(By.CSS_SELECTOR, 'span'))
            return " ".join([span.text for span in spans])
        except TimeoutException:
            return None
        except StaleElementReferenceException as sere:
            if not _attempted_already:
                return self.get_displayed_report_contents(_attempted_already=True)
            else:
                raise sere

    def get_component_message(self) -> str:
        """
        Obtain the text of any Component Error message.

        :raises TimeoutException: If no Component Error message is found.
        """
        self.spinner_appeared()
        self.spinner_was_removed(wait_timeout=5)
        return self._message.find(wait_timeout=3).text

    def spinner_appeared(self, wait_timeout: float = 2) -> bool:
        """
        Wait until the loading spinner has appeared.

        :param wait_timeout: How long to wait for the spinner to appear.

        :returns: True, once the spinner has appeared - False, if the spinner never appeared.
        """
        try:
            return WebDriverWait(driver=self.driver, timeout=wait_timeout).until(
                IAec.function_returns_true(
                    custom_function=self._spinner_appeared,
                    function_args={}))
        except TimeoutException:
            return False

    def spinner_was_removed(self, wait_timeout: float = 2) -> bool:
        """
        Wait until the loading spinner has been removed.

        :param wait_timeout: How long to wait for the spinner to be removed.

        :returns: True, once the spinner has been removed - False, if the spinner remains in place.
        """
        try:
            return WebDriverWait(driver=self.driver, timeout=wait_timeout).until(
                IAec.function_returns_true(
                    custom_function=self._spinner_disappeared,
                    function_args={}))
        except TimeoutException:
            return False

    def _click_page_action_when_enabled(self, action: ComponentPiece) -> None:
        """
        Wait for a Report Viewer page action to become enabled before clicking it.

        :raises TimeoutException: If the page action (button) never becomes enabled.
        """
        WebDriverWait(driver=self.driver, timeout=3).until(IAec.function_returns_true(
            custom_function=action.find().is_enabled,
            function_args={}))
        action.click()

    def _spinner_disappeared(self) -> bool:
        """
        Determine if the spinner has been removed.

        :returns: True, if the spinner is no longer present - False if the spinner remains in place.
        """
        try:
            return self._loading_spinner.find(wait_timeout=0) is None
        except TimeoutException:
            return True

    def _spinner_appeared(self) -> bool:
        """
        Determine if the spinner is displayed.

        :returns: True, if the spinner is displayed - False otherwise.
        """
        try:
            return self._loading_spinner.find(wait_timeout=0).is_displayed()
        except TimeoutException:
            return False

    def _report_content_is_not_empty(self, wait_timeout: float = 3) -> bool:
        """
        Waits until the Report Viewer is displaying any content other than the spinner.

        :param wait_timeout: The amount of time to wait for the Report Viewer to display any report content.

        :returns: True if the Report Viewer is displaying any content before the specified wait period has lapsed -
            False otherwise.
        """
        try:
            report_contents = WebDriverWait(driver=self.driver, timeout=wait_timeout).until(
                ec.presence_of_element_located(
                    self._REPORT_CONTENTS_LOCATOR)).get_attribute('innerHTML')
            return report_contents is not None and len(report_contents) > 0
        except TimeoutException:
            return False

Components/PerspectiveComponents/Reporting/ReportViewer.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import List, Optional, Tuple

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS


class Appearance(Enum):
    AUTO = "auto"
    PNID = "p&id"
    MIMIC = "mimic"
    SIMPLE = "simple"


class Directional(Enum):
    RIGHT = "right"
    LEFT = "left"
    TOP = "top"
    BOTTOM = "bottom"


class Orientation(Enum):
    """If testing a Vessel, note that the Vessel has its own Orientation options."""
    RIGHT = "right"
    LEFT = "left"
    TOP = "top"
    BOTTOM = "bottom"


class State(Enum):
    DEFAULT = "default"
    RUNNING = "running"
    FAULTED = "faulted"
    STOPPED = "stopped"


class TextLabelLocation(Enum):
    RIGHT = "right"
    LEFT = "left"
    TOP = "top"
    BOTTOM = "bottom"
    HIDDEN = "hidden"


class ValueLabelLocation(Enum):
    RIGHT = "right"
    LEFT = "left"
    TOP = "top"
    BOTTOM = "bottom"
    HIDDEN = "hidden"


class SensorTextLabelLocation(Enum):
    RIGHT = "right"
    LEFT = "left"
    TOP = "top"
    BOTTOM = "bottom"
    HIDDEN = "hidden"
    INSIDE = "inside"


class SensorValueLabelLocation(Enum):
    RIGHT = "right"
    LEFT = "left"
    TOP = "top"
    BOTTOM = "bottom"
    HIDDEN = "hidden"
    INSIDE = "inside"


class CommonSymbol(BasicPerspectiveComponent):
    """
    When examining classes, you'll often see an odd pattern: "symbol_symbol_descriptor". This is a result of the Pump
    having multiple variants. A motor, for example, will ALWAYS be "motor_motor_descriptor", but a Pump could be
    "pump_centrifugal_descriptor" or "pump_vacuum_descriptor"
    """
    GENERIC_IA_SYMBOL_CLASS = "ia_symbolComponent"
    WRAPPER_LOCATOR = (By.CSS_SELECTOR, "div.ia_symbolComponent__wrapper")
    MAIN_SVG_LOCATOR = (By.CSS_SELECTOR, f"{WRAPPER_LOCATOR[1]} > svg")
    _LABEL_TEXT_LOCATOR = (By.CSS_SELECTOR, 'div[data-display-type="label"]')
    _VALUE_TEXT_LOCATOR = (By.CSS_SELECTOR, 'div[data-display-type="value"]')

    def __init__(
            self,
            locator: Tuple[By, str],
            driver: WebDriver,
            parent_locator_list: Optional[List[Tuple[By, str]]],
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=locator,
            driver=driver,
            parent_locator_list=parent_locator_list,
            description=description,
            poll_freq=poll_freq)
        self._main_svg = ComponentPiece(
            locator=self.MAIN_SVG_LOCATOR, driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._label_text = ComponentPiece(
            locator=self._LABEL_TEXT_LOCATOR, driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)
        self._value_text = ComponentPiece(
            locator=self._VALUE_TEXT_LOCATOR, driver=driver, parent_locator_list=self.locator_list, poll_freq=poll_freq)

    def get_label_text(self) -> str:
        """Get the text of the label of the Symbol. Distinct from the actual value of the Symbol."""
        return self._label_text.get_text()

    def get_value_text(self) -> str:
        """Get the value of the Symbol (including units). Distinct from label text of the Symbol."""
        return self._value_text.get_text()

    def symbol_has_appearance(self, appearance: Appearance) -> bool:
        """
        Determines whether the Symbol is currently using the supplied Appearance.

        returns: True, if the supplied Appearance is currently in use, False otherwise. Supplying an Appearance of
            `Auto` will always result in False.
        """
        return f"{self.GENERIC_IA_SYMBOL_CLASS}--{appearance.value}" in self.get_wrapper_classes()

    def symbol_has_orientation(self, orientation: Orientation) -> bool:
        """
        Determine if the supplied orientation is currently in use by the Symbol.

        returns: True, if the orientation is currently in use by the Symbol - False otherwise.
        """
        for html_class in self.get_wrapper_classes():
            if "orientation" in html_class:
                return orientation.value in html_class
        return False  # orientation not found

    def symbol_has_state(self, state: State) -> bool:
        """
        Determine if the supplied state is currently in use by the Symbol.

        returns: True, if the state is currently in use by the Symbol - False otherwise.
        """
        return f"{self.GENERIC_IA_SYMBOL_CLASS}--{state.value}" in self.get_wrapper_classes()

    def text_label_has_location(self, location: TextLabelLocation) -> bool:
        """
        Determine if the text label of the Symbol currently has the supplied location.

        returns: True, if the text label of the component has the supplied location, False otherwise.
        """
        _main_symbol_width = int(float(self._main_svg.get_computed_width(include_units=False)))
        _main_symbol_height = int(float(self._main_svg.get_computed_height(include_units=False)))
        _main_symbol_origin = self._main_svg.get_origin()
        _main_symbol_x = int(float(_main_symbol_origin.X))
        _main_symbol_y = int(float(_main_symbol_origin.Y))
        try:
            _text_label_width = int(float(self._label_text.get_computed_width(include_units=False)))
            _text_label_height = int(float(self._label_text.get_computed_height(include_units=False)))
            _text_label_origin = self._label_text.get_origin()
            _text_label_x = int(float(_text_label_origin.X))
            _text_label_y = int(float(_text_label_origin.Y))
            if location == TextLabelLocation.TOP:
                return _text_label_y + _text_label_height <= _main_symbol_y
            elif location == TextLabelLocation.BOTTOM:
                return _text_label_y >= _main_symbol_y + _main_symbol_height
            elif location == TextLabelLocation.LEFT:
                return _text_label_x + _text_label_width <= _main_symbol_x
            elif location == TextLabelLocation.RIGHT:
                return _text_label_x >= _main_symbol_x + _main_symbol_width
            else:
                raise ValueError(f"Invalid location: {location}")
        except TimeoutException:
            """
            The label was correctly "hidden", and therefore not found, and therefore we want to return True.
            Truth-wise our response is equivalent to returning whether the supplied location was HIDDEN.
            """
            return location == TextLabelLocation.HIDDEN

    def value_label_has_location(self, location: ValueLabelLocation) -> bool:
        """
        Determine if the value label of the Symbol currently has the supplied location.

        returns: True, if the value label of the component has the supplied location, False otherwise.
        """
        _main_symbol_width = int(float(self._main_svg.get_computed_width(include_units=False)))
        _main_symbol_height = int(float(self._main_svg.get_computed_height(include_units=False)))
        _main_symbol_origin = self._main_svg.get_origin()
        _main_symbol_x = int(float(_main_symbol_origin.X))
        _main_symbol_y = int(float(_main_symbol_origin.Y))
        try:
            _text_value_width = int(float(self._value_text.get_computed_width(include_units=False)))
            _text_value_height = int(float(self._value_text.get_computed_height(include_units=False)))
            _text_value_origin = self._value_text.get_origin()
            _text_value_x = int(float(_text_value_origin.X))
            _text_value_y = int(float(_text_value_origin.Y))
            if location == ValueLabelLocation.TOP:
                return _text_value_y + _text_value_height <= _main_symbol_y
            elif location == ValueLabelLocation.BOTTOM:
                return _text_value_y >= _main_symbol_y + _main_symbol_height
            elif location == ValueLabelLocation.LEFT:
                return _text_value_x + _text_value_width <= _main_symbol_x
            elif location == ValueLabelLocation.RIGHT:
                return _text_value_x >= _main_symbol_x + _main_symbol_width
            else:
                raise ValueError(f"Invalid location: {location}")
        except TimeoutException:
            """
            The label was correctly "hidden", and therefore not found, and therefore we want to return True.
            Truth-wise our response is equivalent to returning whether the supplied location was HIDDEN.
            """
            return location == ValueLabelLocation.HIDDEN

    def get_wrapper_classes(self) -> List[str]:
        """
        Obtain a list of every HTML class currently applied to the main Symbol. Used internally for determining
        which Appearance and/or State a Symbol has.
        """
        return self._main_svg.find().get_attribute(name="class").split(" ")


class _SymbolWithFeet(CommonSymbol):
    """
    Some Symbols contain feet as part of their appearance.
    """

    class Feet(Enum):
        """
        Feet are not available to all Symbols.
        """
        RIGHT = "right"
        LEFT = "left"
        TOP = "top"
        BOTTOM = "bottom"
        NONE = "none"

    def feet_have_display_state(self, feet_display_state: Feet) -> bool:
        """
        Determine the display state of the feet of a Motor Symbol.

        returns: True, if the feet currently have the supplied display state, False otherwise.
        """
        html_classes = self.get_wrapper_classes()
        feet_have_expected_html_class = False
        for html_class in html_classes:
            if "feet" in html_class:
                feet_have_expected_html_class = feet_display_state.value == html_class.split("-")[-1]
        return feet_have_expected_html_class or feet_display_state == Motor.Feet.NONE


class Motor(_SymbolWithFeet):
    """A Perspective Motor Symbol."""
    def __init__(self, locator, driver: WebDriver, parent_locator_list: Optional[List] = None, poll_freq: float = 0.5):
        super().__init__(locator=locator, driver=driver, parent_locator_list=parent_locator_list, poll_freq=poll_freq)


class Pump(_SymbolWithFeet):
    """A Perspective Pump Symbol."""

    class Variant(Enum):
        """
        Pumps have variant appearances which must be accounted for.
        """
        CENTRIFUGAL = "centrifugal"
        VACUUM = "vacuum"

    def __init__(self, locator, driver: WebDriver, parent_locator_list: Optional[List] = None, poll_freq: float = 0.5):
        super().__init__(locator=locator, driver=driver, parent_locator_list=parent_locator_list, poll_freq=poll_freq)

    def pump_is_variant(self, variant: Variant) -> bool:
        """
        Determine if the Pump currently displays as the supplied variant.

        returns: True, if the Pump is currently the supplied variant - False otherwise.
        """
        return f"{self.GENERIC_IA_SYMBOL_CLASS}-pump-{variant.value}" in self.get_wrapper_classes()


class Sensor(CommonSymbol):
    """A Perspective Sensor Symbol."""
    _SENSOR_BODY_LOCATOR = (By.CSS_SELECTOR, "g.sensor_sensor_body")

    def __init__(self, locator, driver: WebDriver, parent_locator_list: Optional[List] = None, poll_freq: float = 0.5):
        super().__init__(locator=locator, driver=driver, parent_locator_list=parent_locator_list, poll_freq=poll_freq)
        self._body = ComponentPiece(
            locator=self._SENSOR_BODY_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def text_label_has_location(self, location: SensorTextLabelLocation) -> bool:
        """
        Determine if the text label of the Symbol currently has the supplied location.

        returns: True, if the text label of the component has the supplied location, False otherwise.
        """
        sensor_body_origin = self._body.get_origin()
        sensor_body_x = int(float(sensor_body_origin.X))
        sensor_body_y = int(float(sensor_body_origin.Y))
        sensor_body_width = int(float(self._body.get_computed_width(include_units=False)))
        sensor_body_height = int(float(self._body.get_computed_height(include_units=False)))
        try:
            text_label_origin = self._label_text.get_origin()
            text_label_x = int(float(text_label_origin.X))
            text_label_y = int(float(text_label_origin.Y))
            text_label_height = int(float(self._label_text.get_computed_height(include_units=False)))
            text_label_width = int(float(self._label_text.get_computed_width(include_units=False)))
            if location == SensorTextLabelLocation.TOP:
                return text_label_y + text_label_height <= sensor_body_y
            elif location == SensorTextLabelLocation.BOTTOM:
                return text_label_y >= sensor_body_y + sensor_body_height
            elif location == SensorTextLabelLocation.LEFT:
                return text_label_x + text_label_width <= sensor_body_x
            elif location == SensorTextLabelLocation.RIGHT:
                return text_label_x >= sensor_body_x + sensor_body_width
            elif location == SensorTextLabelLocation.INSIDE:
                return (sensor_body_x < text_label_x < (sensor_body_x + sensor_body_width)) \
                       and (sensor_body_y < text_label_y < (sensor_body_y + sensor_body_height))
            else:
                raise ValueError(f"Invalid location: {location}")
        except TimeoutException:
            """
            The text label was correctly "hidden", and therefore not found, and therefore we want to return True.
            Truth-wise our response is equivalent to returning whether the supplied location was HIDDEN.
            """
            return location == TextLabelLocation.HIDDEN

    def value_label_has_location(self, location: SensorValueLabelLocation) -> bool:
        """
        Determine if the value label of the Sensor currently has the supplied location.

        returns: True, if the value label of the Sensor has the supplied location, False otherwise.
        """
        sensor_body_origin = self._body.get_origin()
        sensor_body_x = int(float(sensor_body_origin.X))
        sensor_body_y = int(float(sensor_body_origin.Y))
        sensor_body_width = int(float(self._body.get_computed_width(include_units=False)))
        sensor_body_height = int(float(self._body.get_computed_height(include_units=False)))
        try:
            value_label_origin = self._value_text.get_origin()
            value_label_x = int(float(value_label_origin.X))
            value_label_y = int(float(value_label_origin.Y))
            value_label_width = int(float(self._value_text.get_computed_width(include_units=False)))
            value_label_height = int(float(self._value_text.get_computed_height(include_units=False)))
            if location == SensorValueLabelLocation.TOP:
                return value_label_y + value_label_height <= sensor_body_y
            elif location == SensorValueLabelLocation.BOTTOM:
                return value_label_y >= sensor_body_y + sensor_body_height
            elif location == SensorValueLabelLocation.LEFT:
                return value_label_x + value_label_width <= sensor_body_x
            elif location == SensorValueLabelLocation.RIGHT:
                return value_label_x >= sensor_body_x + sensor_body_width
            elif location == SensorValueLabelLocation.INSIDE:
                return (sensor_body_x < value_label_x < (sensor_body_x + sensor_body_width)) \
                    and (sensor_body_y < value_label_y < (sensor_body_y + sensor_body_height))
            else:
                raise ValueError(f"Invalid location: {location}")
        except TimeoutException:
            """
            The value label was correctly "hidden", and therefore not found, and therefore we want to return True.
            Truth-wise our response is equivalent to returning whether the supplied location was HIDDEN.
            """
            return location == TextLabelLocation.HIDDEN


class Valve(CommonSymbol):
    """A Perspective Valve Symbol."""
    _MAIN_VALVE_LOCATOR = (By.CSS_SELECTOR, "g.valve_2-way_main_valve")
    _LEFT_IN_OUT_LOCATOR = (By.CSS_SELECTOR, "g.valve_2-way_left")
    _RIGHT_IN_OUT_LOCATOR = (By.CSS_SELECTOR, "g.valve_2-way_right")

    class State(Enum):
        """
        Valve Symbols have different States available to it than other Perspective Symbols.
        """
        DEFAULT = "default"
        OPEN = "open"
        CLOSED = "closed"
        FAILED_TO_CLOSE = "failedToClose"
        FAILED_TO_OPEN = "failedToOpen"
        PARTIALLY_CLOSED = "partiallyClosed"

    def __init__(self, locator, driver: WebDriver, parent_locator_list: Optional[List] = None, poll_freq: float = 0.5):
        super().__init__(locator=locator, driver=driver, parent_locator_list=parent_locator_list, poll_freq=poll_freq)
        self._main_valve = ComponentPiece(
            locator=self._MAIN_VALVE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._left_in_out = ComponentPiece(
            locator=self._LEFT_IN_OUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)
        self._right_in_out = ComponentPiece(
            locator=self._RIGHT_IN_OUT_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def flow_is_reversed(self) -> bool:
        """
        Determine if the flow of the Valve component is currently displaying as reversed. Flow is considered to be
        "reversed" if the "left" in/out is a different color than the main valve piece and the right in/out
        is the same color as the main valve piece.
        """
        main_fill = self._main_valve.get_css_property(property_name=CSS.FILL)
        right_fill = self._right_in_out.get_css_property(property_name=CSS.FILL)
        return (right_fill == main_fill) and self._left_and_right_have_different_fill()

    def symbol_has_state(self, state: State) -> bool:
        """
        Determine if the supplied state is currently in use by the Valve.

        returns: True, if the state is currently in use by the Valve - False otherwise.
        """
        valve_has_state = super().symbol_has_state(state=state)
        # We can actually check that the valve is displaying this state because of visual differences.
        if state == Valve.State.PARTIALLY_CLOSED:
            valve_has_state = valve_has_state and \
                              self._left_and_right_have_different_fill()
        return valve_has_state

    def _left_and_right_have_different_fill(self) -> bool:
        """
        Determine if the left and right in/out pieces of the Valve have different fill values.

        returns: True, if the fill property is different - False otherwise.
        """
        left_fill = self._left_in_out.get_css_property(property_name=CSS.FILL)
        right_fill = self._right_in_out.get_css_property(property_name=CSS.FILL)
        return left_fill != right_fill


class Vessel(CommonSymbol):
    """A Perspective Vessel Symbol."""
    _AGITATOR_AND_ROTOR_LOCATOR = (By.CSS_SELECTOR, "g[class^='vessel_cylindricalTank_agitator']")

    class Orientation(Enum):
        """The Vessel Symbol has only two orientations available for use."""
        VERTICAL = "vertical"
        HORIZONTAL = "horizontal"

    def __init__(self, locator, driver: WebDriver, parent_locator_list: Optional[List] = None, poll_freq: float = 0.5):
        super().__init__(locator=locator, driver=driver, parent_locator_list=parent_locator_list, poll_freq=poll_freq)
        self._agitator_and_rotor = ComponentPiece(
            locator=self._AGITATOR_AND_ROTOR_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            poll_freq=poll_freq)

    def agitator_and_rotor_are_displayed(self) -> bool:
        """
        Determine if the Vessel is currently displaying the agitator and rotor.

        returns: True, if both are displayed - False otherwise.
        """
        try:
            return len(self._agitator_and_rotor.find_all(wait_timeout=1)) == 2
        except TimeoutException:
            return False

Components/PerspectiveComponents/Symbols/Symbol.py
inductiveautomation/ignition-automation-tools
from typing import Union, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import ComponentPiece
from Helpers.CSSEnumerations import CSS
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Helpers.Point import Point


class Tooltip(ComponentPiece):
    """
    A Tooltip for a Perspective Component, driven by the META category within props. Note that tooltips defined by
    properties outside of META will not work with this class.

    Also, tooltips do not provide insight into their originating component, so all functions contained within this class
    will report on or interact with any tooltip which is displayed.
    """
    _COMPONENT_TOOLTIP_LOCATOR = (By.CSS_SELECTOR, 'div.component-tooltip')
    _TAIL_LOCATOR = (By.CSS_SELECTOR, 'div.tail')

    def __init__(
            self,
            driver: WebDriver,
            wait_timeout: Union[int, float] = 2,
            description: Optional[str] = None,
            poll_freq: float = 0.5):
        super().__init__(
            locator=self._COMPONENT_TOOLTIP_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=wait_timeout,
            description=description,
            poll_freq=poll_freq)
        self._tail = ComponentPiece(
            locator=self._TAIL_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            wait_timeout=wait_timeout,
            poll_freq=poll_freq)

    def any_component_tooltip_is_displayed(self) -> bool:
        """
        Determine if any tooltips is displayed for any component.

        :returns: True, if any tooltip is displayed for any component.
        """
        try:
            return self.find() is not None
        except TimeoutException:
            return False

    def component_tooltip_has_tail(self) -> bool:
        """
        Determine if the displayed tooltip is rendering a tail as part of its appearance.

        :returns: True, if the displayed tooltip is currently rendering a tail.
        """
        try:
            return self._tail.find() is not None
        except TimeoutException:
            return False

    def get_count_of_tooltips(
            self, expected_count: int = 1, wait_timeout: Union[int, float] = 5, poll_frequency: float = 0.5) -> int:
        """
        Obtain a count of all displayed tooltips.

        :param expected_count: If supplied, we will attempt to wait until this many tooltips are displayed before
            returning a value.
        :param wait_timeout: The amount of time (in seconds) to wait for any tooltips to appear. Use this when tooltips
            contain any configured delay.
        :param poll_frequency: How often to poll the DOM as we wait for an expected count of tooltips.

        :returns: A count of displayed tooltips.
        """
        try:
            WebDriverWait(driver=self.driver, timeout=wait_timeout, poll_frequency=poll_frequency).until(
                IAec.function_returns_true(
                    custom_function=self._expected_number_of_tooltips_displayed,
                    function_args={'expected_count': expected_count}))
            return expected_count
        except TimeoutException:
            pass
        try:
            return len(self.find_all(wait_timeout=1))
        except TimeoutException:
            return 0

    def _get_origin_of_component_tooltip(self) -> Point:
        """
        Obtain the origin (upper-left corner) of a tooltip. If multiple tooltips are present, this will return the
        origin of the tooltip which was rendered first.

        :returns: A two-dimensional point which represents the upper-left corner of the tooltip.

        :raises TimeoutException: If no tooltips are rendered.
        """
        return self.get_origin()

    def get_height_of_component_tooltip(self, include_units: bool = False) -> str:
        """
        Obtain the height of a tooltip, with or without units (usually px). If multiple tooltips are present, this will
        return the height of the tooltip which was rendered first.

        :param include_units: Dictates whether the returned value contains units of measurement (almost always 'px').

        :returns: The height of the first rendered tooltip - with or without units.

        :raises TimeoutException: If no tooltips are rendered.
        """
        return self.get_computed_height(include_units=include_units)

    def get_tail_color(self) -> str:
        """
        Obtain the color in use for the tail of a tooltip as a string.

        :returns: The color in use for the tail of a tooltip. Note that the format of this value will change based on
            the browser in use.

        :raises TimeoutException: If no tooltips are rendered.
        """
        return self._tail.get_css_property(property_name=CSS.BORDER_TOP_COLOR)

    def get_width_of_component_tooltip(self, include_units: bool = False) -> str:
        """
        Obtain the width of a tooltip, with or without units (usually px). If multiple tooltips are present, this will
        return the width of the tooltip which was rendered first.

        :param include_units: Dictates whether the returned value contains units of measurement (almost always 'px').

        :returns: The width of the first rendered tooltip - with or without units.

        :raises TimeoutException: If no tooltips are rendered.
        """
        return self.get_computed_width(include_units=include_units)

    def get_text_of_tooltip(self) -> str:
        """
        Obtain the text present within the tooltip.

        :returns: The currently rendered text of the tooltip. If multiple tooltips are present, this will return the
            text of the tooltip which was rendered first.

        :raises TimeoutException: If no tooltips are rendered.
        """
        return self.get_text()

    def _expected_number_of_tooltips_displayed(self, expected_count: int) -> bool:
        """
        Determine if a specific number of tooltips are displayed.

        :param expected_count: The count of tooltips you expect to be rendered.

        :returns: True, if the currently rendered count of tooltips matches the supplied expected_count value - False
            otherwise.
        """
        try:
            return len(self.find_all(wait_timeout=0)) == expected_count
        except TimeoutException:
            return False

Components/PerspectiveComponents/Tooltip.py
inductiveautomation/ignition-automation-tools
class CSSPropertyValue:
    """
    The CSSPropertyValue class serves two purposes:
    1. It allows for some areas of code to specify a type other than string, which would otherwise be confusing.
    2. It provides an avenue to reduce potential typos.

    Consider the following example:
    self._component.get_css_property_value('full') == ""

    Such a statement would always return True, because the minor typo prevents the 'fill' value from being queried. By
    providing and requiring an enumerated value we remove any chance of such a typo.
    """
    value = None

    def __init__(self, value: str):
        self.value = value

    def __str__(self):
        return self.value


class CSS:
    """
    An ever-expanding enumeration of sorts which contains common CSS properties. For properties which are themselves
    enumerations, we provide those enumerated values.
    """

    class AlignItems:
        CENTER = CSSPropertyValue("center")
        END = CSSPropertyValue("end")
        FLEX_END = CSSPropertyValue("flex-end")
        FLEX_START = CSSPropertyValue("flex-start")
        NORMAL = CSSPropertyValue("normal")
        START = CSSPropertyValue("start")
        STRETCH = CSSPropertyValue("stretch")

    BACKGROUND_COLOR = CSSPropertyValue("background-color")
    BORDER_TOP_COLOR = CSSPropertyValue("border-top-color")
    BORDER_TOP_LEFT_RADIUS = CSSPropertyValue("border-top-left-radius")
    BORDER_TOP_RIGHT_RADIUS = CSSPropertyValue("border-top-right-radius")
    BORDER_TOP_STYLE = CSSPropertyValue("border-top-style")
    BOTTOM = CSSPropertyValue("bottom")
    COLOR = CSSPropertyValue("color")

    class Cursor:
        GRAB = CSSPropertyValue("grab")
        NOT_ALLOWED = CSSPropertyValue("not-allowed")
        POINTER = CSSPropertyValue("pointer")
        WAIT = CSSPropertyValue("wait")

    class Display:
        """
        Display has loads of options and can take multiple arguments, but these are the basic/common options.
        """
        BLOCK = CSSPropertyValue("block")
        FLEX = CSSPropertyValue("flex")
        INLINE = CSSPropertyValue("inline")

    FILL = CSSPropertyValue("fill")
    FILL_OPACITY = CSSPropertyValue("fill-opacity")
    FONT_SIZE = CSSPropertyValue("font-size")
    HEIGHT = CSSPropertyValue("height")
    JUSTIFY_CONTENT = CSSPropertyValue('justify-content')
    MARGIN_BOTTOM = CSSPropertyValue("margin-bottom")
    MARGIN_LEFT = CSSPropertyValue("margin-left")
    MARGIN_RIGHT = CSSPropertyValue("margin-right")
    MARGIN_TOP = CSSPropertyValue("margin-top")
    MAX_WIDTH = CSSPropertyValue("max-width")
    OBJECT_FIT = CSSPropertyValue("object-fit")
    OPACITY = CSSPropertyValue("opacity")
    OVERFLOW = CSSPropertyValue("overflow")
    POSITION = CSSPropertyValue("position")
    STROKE = CSSPropertyValue("stroke")
    STROKE_DASHARRAY = CSSPropertyValue("stroke-dasharray")
    STROKE_WIDTH = CSSPropertyValue("stroke-width")
    TEXT_ALIGN = CSSPropertyValue('text-align')
    TRANSITION = CSSPropertyValue("transition")
    TRANSITION_DURATION = CSSPropertyValue("transition-duration")
    TRANSITION_TIMING_FUNCTION = CSSPropertyValue("transition-timing-function")
    WIDTH = CSSPropertyValue("width")
    Z_INDEX = CSSPropertyValue("z-index")
    
Helpers/CSSEnumerations.py
inductiveautomation/ignition-automation-tools
from typing import List, Iterable


class Items:
    """
    This helper allows for defining some iterable collection of items so that they can then be filtered by some
    function.
    """

    def __init__(self, items: Iterable):
        self._items = items

    def filter(self, filtering_function) -> List:
        """
        Filter this Items object based on whether each item results in a truthy evaluation when run through the
        supplied function.

        Example:
            items (constructor) = "apple"
            filtering_function = lambda e: e in "laptop"
            In this example, every item (character) within the iterable supplied within the constructor (our string
            "apple") will be evaluated to see if it (e) is in the string "laptop". The returned list after evaluation
            will be ["a", "p", "p", "l"].

        :param filtering_function: A function (usually a lambda) which accepts one argument and performs some sort of
            evaluation of that argument. As each item in this Items object is evaluated as the argument of the function,
            a truthy evaluation will result in the item being evaluated remaining in the returned list, while a falsy
            evaluation will result in the item being omitted from the returned list.

        :returns: A list, where each item in the list was evaluated by the supplied function and resulted in a truthy
            evaluation.
        """
        return list(filter(filtering_function, self._items))

Helpers/Filtering.py
inductiveautomation/ignition-automation-tools
import os
from pathlib import Path


class FilePathFormatting:
    """
    The functions contained within this class are intended to operate on string file paths and make them
    platform-agnostic, so that they can be used regardless of Operating System.
    """

    @staticmethod
    def system_safe_file_path(exec_dir: str, file_path: str) -> str:
        """
        Attempts to take a standard string file path and make it work across any operating system.

        :param exec_dir: The execution directory to be used in constructing the file path.
        :param file_path: The path of the file.

        :returns: A platform-agnostic path to a file.
        """
        return str(Path(os.path.join(exec_dir, file_path)).resolve())

    @staticmethod
    def create_incremented_file_path(file_path: str) -> str:
        """
        Given a desired file path, create a unique file name by incrementing on the supplied path.

        Example:
            file_path = "something/myPicture.jpeg"
            If a file with that name already exists on the operating system, you will receive a new file path:
            "something/myPicture_1.jpeg". Had the file not already existed, you would receive the same file path you
            had supplied.

        :param file_path: A File path to look for on the operating system.

        :returns: A unique and unused file path built from the supplied file_path.
        """
        add = 1
        final_file_path = file_path
        while os.path.isfile(final_file_path):
            split = file_path.split('.')
            part_1 = split[0] + "_" + str(add)
            final_file_path = '.'.join([part_1, split[1]])
            add += 1
        return final_file_path

Helpers/Formatting.py
inductiveautomation/ignition-automation-tools
import math
from typing import Union, Optional, Iterable

from selenium.webdriver.support.color import Color

from Helpers.Ignition.Tag import UdtInstance, Tag, UdtDef, Folder


class IAAssert:

    @staticmethod
    def contains(iterable: Iterable, expected_value, failure_msg: Optional[str] = None) -> None:
        """
        Assert that some iterable contains a specific value.

        :param iterable: Some Iterable object, like a list, dict, tuple, or set.
        :param expected_value: A value which should be an element of the iterable.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the supplied expected_value is not an element of the supplied iterable.
        """
        msg = IAAssert._generate_failure_msg(msg=f'Assert {expected_value} in {iterable}',
                                             failure_msg=failure_msg)
        assert expected_value in iterable, msg

    @staticmethod
    def does_not_contain(iterable: Iterable, expected_value, failure_msg: Optional[str] = None) -> None:
        """
        Assert that some iterable does not contain a specific value.

        :param iterable: Some Iterable object, like a list, dict, tuple, or set.
        :param expected_value: A value which should not be an element of the iterable.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the supplied expected_value is an element of the supplied iterable.
        """
        msg = IAAssert._generate_failure_msg(msg=f'Assert {expected_value} is not in {iterable}',
                                             failure_msg=failure_msg)
        assert expected_value not in iterable, msg

    @staticmethod
    def is_equal_to(
            actual_value,
            expected_value,
            failure_msg: Optional[str] = None,
            as_type=None) -> None:
        """
        Verify some value is equal to another value. When comparing two values which might be of different types, use
        the as_type argument to specify the type both values should be cast as; this is useful for ignoring instances
        where numeric and string values appear the same but differ in type.

        :param actual_value: The actual value you are comparing/verifying.
        :param expected_value: The value expected to be reflected in the actual value.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.
        :param as_type: Used to compare the two values as a specific type.

        :raises AssertionError: If the supplied expected_value is an element of the supplied iterable.
        """
        if as_type is not None:
            if as_type == int:
                # must convert to float first!
                actual_value = float(actual_value)
                expected_value = float(expected_value)
            if as_type == Color:
                # Color only accepts a tuple constructor, which throws a wrench into our previous casting.
                assert IAAssert._color_convert(color_as_string=actual_value) ==\
                       IAAssert._color_convert(color_as_string=expected_value),\
                       IAAssert._generate_failure_msg(
                           msg=f'Assert {IAAssert._color_convert(color_as_string=actual_value)} == '
                               f'{IAAssert._color_convert(color_as_string=expected_value)} -> '
                               f'These values were converted to hex before comparison.',
                           failure_msg=failure_msg)
            elif as_type in [Tag, UdtDef, UdtInstance, Folder]:
                # Tags need to be compared as their dictionary counterparts.
                # msg will provide additional insight into any failure.
                assert actual_value.to_dict() == expected_value.to_dict(), \
                    IAAssert._generate_failure_msg(
                        msg=f'Dictionary Values do not match.  The following items are different:\n'
                            f'keys & values in Expected not in Actual: '
                            f'{expected_value.diff_my_unmatched_keys_values(actual_value)}\n'
                            f'keys & values in Actual not in Expected: '
                            f'{expected_value.diff_their_unmatched_keys_values(actual_value)}\n'
                            f'values that do not match: '
                            f'[{actual_value.diff_key_values(expected_tag=expected_value)}]',
                        failure_msg=failure_msg
                )
            else:
                assert as_type(actual_value) == as_type(expected_value), \
                    IAAssert._generate_failure_msg(msg=f'Assert {as_type(actual_value)} == '
                                                       f'{as_type(expected_value)}',
                                                   failure_msg=failure_msg)
        else:
            assert actual_value == expected_value, \
                IAAssert._generate_failure_msg(msg=f'Assert {str(type(actual_value))} {actual_value} == '
                                                   f'{str(type(expected_value))} {expected_value}',
                                               failure_msg=failure_msg)

    @staticmethod
    def is_close_to(
            actual_value: float,
            expected_value: float,
            decimal_places: int = 9,
            failure_msg: Optional[str] = None):
        """
        Verify that two floating point values are close to one another, within a specified number of decimal places.
        This is useful for verifying approximate equality while accounting for possible floating point imprecision.

        :param actual_value: The actual value to be compared.
        :param expected_value: The value that actual_value is expected to be close to.
        :param decimal_places: The relative difference between the two values that we are willing to tolerate when
            comparing them, in terms of decimal places (e.g. an argument of 3 asserts that the values are within 0.001
            of each other)
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the provided values are not within the specified number of decimal places of one
            another.
        """
        relative_tolerance = abs(1 / (pow(10, decimal_places)))
        assert math.isclose(actual_value, expected_value, rel_tol=relative_tolerance), \
            IAAssert._generate_failure_msg(
                msg=f'Assert {actual_value} is within {decimal_places} decimal places of {expected_value}',
                failure_msg=failure_msg)

    @staticmethod
    def is_greater_than(
            left_value,
            right_value,
            failure_msg: Optional[str] = None) -> None:
        """
        Assert that one value is strictly greater than another value. Note that type differences here can cause issues,
        so cast your values before supplying them.

        left_value > right_value

        :param left_value: The value to verify is greater than the right value.
        :param right_value: The value to verify is less than the left value.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the left value is not strictly greater than the right value.
        """
        IAAssert._is_greater_than(
            left_value=left_value,
            right_value=right_value,
            failure_msg=failure_msg,
            or_equal_to=False)

    @staticmethod
    def is_greater_than_or_equal_to(
            left_value,
            right_value,
            failure_msg: Optional[str] = None) -> None:
        """
        Assert that one value is greater than or equal to than another value. Note that type differences here can cause
        issues, so cast your values before supplying them.

        left_value >= right_value

        :param left_value: The value to verify is greater than or equal to the right value.
        :param right_value: The value to verify is less than or equal to the left value.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the left value is not greater than or equal to the right value.
        """
        IAAssert._is_greater_than(
            left_value=left_value,
            right_value=right_value,
            failure_msg=failure_msg,
            or_equal_to=True)

    @staticmethod
    def is_less_than(
            left_value,
            right_value,
            failure_msg: str = None) -> None:
        """
        Assert that a value is strictly less than another value. Note that type differences here can cause issues, so
        cast your values before supplying them.

        left_value < right_value

        :param left_value: The value to verify is less than the right value.
        :param right_value: The value to verify is greater than the left value.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the left value is not less than the right value.
        """
        IAAssert._is_less_than(
            left_value=left_value,
            right_value=right_value,
            failure_msg=failure_msg,
            or_equal_to=False)

    @staticmethod
    def is_less_than_or_equal_to(
            left_value,
            right_value,
            failure_msg: Optional[str] = None) -> None:
        """
        Assert that a value is less than or equal to another value. Note that type differences here can cause issues, so
        cast your values before supplying them.

        left_value <= right_value

        :param left_value: The value to verify is less than or equal to the right value.
        :param right_value: The value to verify is greater than or equal to the left value.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the left value is not less than or equal to the right value.
        """
        IAAssert._is_less_than(
            left_value=left_value,
            right_value=right_value,
            failure_msg=failure_msg,
            or_equal_to=True)

    @staticmethod
    def is_not_equal_to(
            actual_value,
            expected_value,
            failure_msg: Optional[str] = None,
            as_type=None) -> None:
        """
        Assert that two values are not equal. Due to type differences, this function should almost always have a
        supplied type as part of the as_type argument.

        Example: A value of numeric 2 compared to a value of string "2" will return True because they are different
        types. Supplying a value of int or str for as_type will cast both as the supplied type resulting in supplied
        values of 2 and "2" as returning False (it is not true that they are not equal, meaning they are equal)
        because we will have removed the type difference as a root cause of the inequality.

        :param actual_value: The actual value to be verified.
        :param expected_value: The value expected to be reflected by the actual value.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.
        :param as_type: Used to compare the two values as a specific type.

        :raises AssertionError: If the two values are equal.
        """
        if as_type is not None:
            if as_type == int:
                # must convert to float first!
                actual_value = float(actual_value)
                expected_value = float(expected_value)
            if as_type == Color:
                # special handling for color comparisons
                assert IAAssert._color_convert(color_as_string=actual_value) \
                       != IAAssert._color_convert(color_as_string=expected_value),\
                       IAAssert._generate_failure_msg(
                           msg=f'Assert {IAAssert._color_convert(color_as_string=actual_value)} != '
                               f'{IAAssert._color_convert(color_as_string=expected_value)}.',
                           failure_msg=failure_msg)
            else:
                assert as_type(actual_value) != as_type(expected_value),\
                    IAAssert._generate_failure_msg(msg=f'Assert {as_type(actual_value)} != '
                                                       f'{as_type(expected_value)}',
                                                   failure_msg=failure_msg)
        else:
            assert actual_value != expected_value,\
                IAAssert._generate_failure_msg(msg=f'Assert {actual_value} != {expected_value}',
                                               failure_msg=failure_msg)

    @staticmethod
    def is_not_true(value: bool, failure_msg: str = None) -> None:
        """
        Assert that some value is not truthy. NOTE: This is not a pure boolean check for False; falsy values like
        0 and None and empty Iterables will not raise an AssertionError here because they register as falsy in Python.

        :param value: A value you expect to be falsy, such as False, None, numeric 0, or empty Iterable types
            (including Strings).
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the supplied value is truthy, such as True, any non-zero numeric value, any
            non-empty string, or any non-empty Iterable.
        """
        msg = IAAssert._generate_failure_msg(msg=f'Assert {value}',
                                             failure_msg=failure_msg)
        assert not value, msg

    @staticmethod
    def is_true(value: bool, failure_msg: str = None) -> None:
        """
        Assert that some value is truthy. NOTE: This is not a pure boolean check for True; truthy values like
        1 and non-empty Iterables will not raise an AssertionError here because they register as truthy in Python.

        :param value: A value you expect to be truthy, such as True, 1, or non-empty Iterable types  (including
            Strings).
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the supplied value is falsy, such as False, 0, an empty string, or any empty
            Iterable.
        """
        msg = IAAssert._generate_failure_msg(msg=f'Assert {value}',
                                             failure_msg=failure_msg)
        assert value, msg

    @staticmethod
    def is_within_range(
            low_end_value: Union[int, float],
            value: Union[int, float],
            high_end_value: Union[int, float],
            inclusive: bool,
            failure_msg: str = None) -> None:
        """
        Assert some value falls within some defined range. Inclusion or exclusion is controlled by the inclusive arg.
        For one-sided ranges, use less-than or greater-than assertion functions.

        :param low_end_value: The low-end of your range.
        :param value: The value you are verifying falls within the range.
        :param high_end_value: The high-end of your range.
        :param inclusive: Controls whether the low-end AND high-end of your range are inclusive.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the supplied value does not fall within the defined range.
        """
        msg = IAAssert._generate_failure_msg(msg=f"Assert {low_end_value} <{'=' if inclusive else ''} {value}"
                                                 f" <{'=' if inclusive else ''} {high_end_value}",
                                             failure_msg=failure_msg)
        if inclusive:
            assert low_end_value <= value <= high_end_value, msg
        else:
            assert low_end_value < value < high_end_value, msg

    @classmethod
    def _color_convert(cls, color_as_string: str) -> str:
        """
        Selenium's color helper does not recognize alpha values in hex strings longer than 7 characters (#XXXXXX), so
        if a hex value is supplied which is longer than seven characters we will force the kwarg conversion.
        :param color_as_string: Your color as an rgb/rgba or hex string.
        :returns: The string converted to a hex representation of a color
        """
        if color_as_string and color_as_string[0] == "#" and len(color_as_string) > 7 and color_as_string[-1] == "0":
            _conv = Color.from_string(color_as_string)
            return Color(red=_conv.red,
                         green=_conv.green,
                         blue=_conv.blue,
                         alpha=0).hex
        else:
            return Color.from_string(color_as_string).hex

    @classmethod
    def _generate_failure_msg(cls, msg: str, failure_msg: str) -> str:
        """Convenience function to concatenate and pretty-print failure messages to a defined assertion message."""
        return msg if not failure_msg else msg + '\nMessage: ' + failure_msg

    @classmethod
    def _is_greater_than(
            cls, left_value, right_value, failure_msg: Optional[str] = None, or_equal_to: bool = False) -> None:
        """
        Assert that one value is greater than or potentially equal to than another value. Note that type differences
        here can cause issues, so cast your values before supplying them.

        :param left_value: The value to verify is greater than or potentially equal to the right value.
        :param right_value: The value to verify is less than or potentially equal to the left value.
        :param or_equal_to: Determines if this will be a strict equality check or a loose equality check.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the left value is not greater than or potentially equal to the right value.
        """
        msg = IAAssert._generate_failure_msg(msg=f'Assert {left_value} >{"= " if or_equal_to else " "}{right_value}.',
                                             failure_msg=failure_msg)
        if or_equal_to:
            assert left_value >= right_value, msg
        else:
            assert left_value > right_value, msg

    @classmethod
    def _is_less_than(cls, left_value, right_value, failure_msg: str = None, or_equal_to: bool = False) -> None:
        """
        Assert that one value is less than or potentially equal to than another value. Note that type differences
        here can cause issues, so cast your values before supplying them.

        :param left_value: The value to verify is less than or potentially equal to the right value.
        :param right_value: The value to verify is greater than or potentially equal to the left value.
        :param or_equal_to: Determines if this will be a strict equality check or a loose equality check.
        :param failure_msg: Any custom failure message you'd like to supply to explain the impact or cause of a
            potential assertion failure.

        :raises AssertionError: If the left value is not less than or potentially equal to the right value.
        """
        msg = IAAssert._generate_failure_msg(msg=f'Assert {left_value} <{"= " if or_equal_to else " "}{right_value}.',
                                             failure_msg=failure_msg)
        if or_equal_to:
            assert left_value <= right_value, msg
        else:
            assert left_value < right_value, msg

Helpers/IAAssert.py
inductiveautomation/ignition-automation-tools
"""
A collection of our defined Expected Conditions. For a brief explanation on how to create more, go to the Custom Wait
Conditions on https://selenium-python.readthedocs.io/waits.html.
Leaving class names in lowercase to conform with existing expected_conditions classes.
"""
from enum import Enum, auto
from typing import Tuple, Callable

from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException, WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement


class TextCondition(Enum):
    CONTAINS = auto()
    DOES_NOT_CONTAIN = auto()
    EQUALS = auto()
    DOES_NOT_EQUAL = auto()


class child_element_has_partial_css_class(object):
    """
    An expectation that within a WebElement there is a specific child WebElement
    which has a provided string as part of its CSS class.
    """
    def __init__(self, parent_web_element: WebElement, locator: Tuple[By, str], text: str):
        """
        :param parent_web_element: The WebElement you expect to contain a child WebElement which contains your
            supplied text as part of its HTML `class` attribute.
        :param locator: The locator used to describe the child you expect to contain the supplied text as part of the
            HTML `class` attribute.
        :param text: The text you expect to be present within the HTML `class` attribute of the child element.
        """
        self.parent_web_element = parent_web_element
        self.locator = locator
        self.text = str(text)

    def __call__(self, driver):
        element = self.parent_web_element.find_element(*self.locator)
        if element:
            if self.text in element.get_attribute('class'):
                return self.parent_web_element
        return False


class element_has_exact_css_class(object):
    """
    An expectation that a WebElement has an exact class attribute.
    """
    def __init__(self, web_element: WebElement, expected_css_class: str):
        """
        :param web_element: The WebElement you expect to have some exact HTML `class` attribute.
        :param expected_css_class: The exact contents you expect to be present in the HTML `class` attribute of the
            supplied WebElement.
        """
        self.web_element = web_element
        self.expected_css_class = str(expected_css_class)

    def __call__(self, driver):
        return self.expected_css_class == self.web_element.get_attribute('class')


class element_in_list_contains_text(object):
    """
    An expectation that within a collection of elements, one of the elements contains the expected text.
    Waits if the list of elements is empty, or if none of the elements currently contain the text.
    """
    def __init__(self, locator: Tuple[By, str], text: str):
        """
        :param locator: The tuple used to describe the collection of elements
        :param text: The expected text content of any one of the WebElements described by the supplied locator.
        """
        self.locator = locator
        self.text = str(text)

    def __call__(self, driver):
        elements = driver.find_elements(*self.locator)
        if elements:
            try:
                for element in elements:
                    if element.text == self.text:
                        return element
                return False
            except StaleElementReferenceException:
                return False
        else:
            return False


class element_identified_by_locator_equals_text(object):
    """
    An expectation for checking if the given text is an exact match to the text of the
    element identified by the supplied locator.
    """
    def __init__(self, locator: Tuple[By, str], text: str):
        """
        :param locator: The tuple used to describe the singular WebElement we expect to have the supplied text. If
            more than one WebElement would match the supplied locator, only the first will be checked.
        :param text: The exact text we expect to be present in the WebElement described by the supplied locator.
        """
        self.locator = locator
        self.text = str(text)

    def __call__(self, driver):
        try:
            element = driver.find_element(*self.locator)
            if self.text == element.text:
                return element
            else:
                return False
        except StaleElementReferenceException:
            return False


class element_identified_by_locator_contains_text(object):
    """
    An expectation for checking that the given text is contained in the text of the element identified by the
    supplied locator.
    """
    def __init__(self, locator: Tuple[By, str], text: str):
        """
        :param locator: The tuple used to describe the singular WebElement we expect to contain the supplied text. If
            more than one WebElement would match the supplied locator, only the first will be checked.
        :param text: The text we expect to be present within the full text of the WebElement described by the
            supplied locator.
        """
        self.locator = locator
        self.text = str(text)

    def __call__(self, driver):
        try:
            element = driver.find_element(*self.locator)
            if self.text in element.text:
                return element
            else:
                return False
        except StaleElementReferenceException:
            return False


class element_identified_by_locator_does_not_equal_text(object):
    """
    An expectation for checking that the element identified by the provided locator does not have the provided text.
    Sometimes we want to wait for text to change away from a value and evaluate instead of evaluating while waiting
    for some expected value
    """
    def __init__(self, locator: Tuple[By, str], text: str):
        """
        :param locator: The tuple which describes the singular WebElement you expect to not have the supplied text.
        :param text: The text you expect to not be present in the WebElement described by the supplied locator.
        """
        self.locator = locator
        self.text = str(text)

    def __call__(self, driver):
        try:
            element = driver.find_element(*self.locator)
            if self.text == element.text:
                return False
            else:
                return element  # Returns whole element once the condition fulfilled
        except StaleElementReferenceException:
            return False


class element_identified_by_locator_has_value(object):
    """
    Checks to see if the WebElement described by the supplied locator has the supplied expected value.
    """
    def __init__(self, locator: Tuple[By, str], expected_value: str):
        """
        :param locator: The tuple which describes the singular WebElement from which we will check the HTMl `value`
            attribute for the supplied expected value.
        :param expected_value: The value we expect the WebElement described by the supplied locator to contain.
        """
        self.locator = locator
        self.expected_value = str(expected_value)

    def __call__(self, driver):
        try:
            if driver.find_element(*self.locator).get_attribute("value") == self.expected_value:
                return self.expected_value
            else:
                return False
        except NoSuchElementException:
            return False
        except StaleElementReferenceException:
            return False


class element_does_not_equal_value(object):
    """
    An expectation that the supplied WebElement does not have the specified string value.
    """
    def __init__(self, element: WebElement, value: str):
        """
        :param element: The WebElement we will check for the supplied value.
        :param value: The string value you expect the WebElement to no longer have.
        """
        self.element = element
        self.value = str(value)

    def __call__(self, driver):
        try:
            if self.value == self.element.find_element(By.TAG_NAME, 'input').get_attribute('value'):
                return False
            else:
                return self.element  # Returns whole element once the condition fulfilled
        except StaleElementReferenceException:
            return False


class child_element_exists(object):
    """
    An expectation for checking that a child component exists and returns the child element. This allows us to
    wait for an element to exist within another element
    """
    def __init__(self, parent_web_element: WebElement, child_locator: Tuple[By, str]):
        """
        :param parent_web_element: The WebElement you expect to contain a child described by the supplied child locator.
        :param child_locator: The locator which describes a WebElement you expect to eventually be a child of the
            supplied parent WebElement.
        """
        self.parent_web_element = parent_web_element
        self.child_locator = child_locator

    def __call__(self, driver):
        try:
            return self.parent_web_element.find_element(*self.child_locator)
        except NoSuchElementException:
            return None


class driver_is_ready(object):
    """
    An expectation that the supplied driver be in a usable state.
    """
    def __init__(self, driver: WebDriver):
        self._driver = driver

    def __call__(self, driver):
        try:
            return self._driver.current_url is not None
        except WebDriverException:
            return False


class function_returns_true(object):
    """
    An expectation that a function will evaluate to True provided a set of kwargs. When providing a function, always
    make sure to exclude the parentheses which would cause an invocation.

    Example:
    def myFunc(arg1, arg2):
        ...

    Should be used in this manner (note no parentheses after "myFunc"):
    IAExpectedCondition.function_returns_true(
        custom_function=myFunc,
        function_args={
            "arg1": someValue:
            "arg2": someOtherValue})
    """
    def __init__(self, custom_function: Callable, function_args: dict):
        """
        :param custom_function: An actual function (non-invoked) to execute with some supplied arguments.
        :param function_args: A dictionary containing the arguments of the supplied function.
        """
        self.custom_function = custom_function
        self.function_args = function_args

    def __call__(self, driver):
        try:
            return self.custom_function(**self.function_args)
        except StaleElementReferenceException:
            return False


class function_returns_false(object):
    """
    An expectation that a function will evaluate to False provided a set of kwargs. When providing a function, always
    make sure to exclude the parentheses which would cause an invocation.

    Example:
    def myFunc(arg1, arg2):
        ...

    Should be used in this manner (note no parentheses after "myFunc"):
    IAExpectedCondition.function_returns_false(
        custom_function=myFunc,
        function_args={
            "arg1": someValue:
            "arg2": someOtherValue})
    """
    def __init__(self, custom_function: Callable, function_args: dict):
        """
        :param custom_function: An actual function (non-invoked) to execute with some supplied arguments.
        :param function_args: A dictionary containing the arguments of the supplied function.
        """
        self.custom_function = custom_function
        self.function_args = function_args

    def __call__(self, driver):
        try:
            return not self.custom_function(**self.function_args)
        except StaleElementReferenceException:
            return False


class element_is_fully_in_viewport(object):
    def __init__(self, driver: WebDriver, locator: Tuple[By, str]):
        """
        :param driver: The WebDriver of the Selenium session.
        :param locator: The locator which describes the singular element to verify is within the boundaries of the
            viewport.
        """
        self.driver = driver
        self.locator = locator

    def __call__(self, driver):
        _rect = self.driver.find_element(*self.locator).rect
        _viewport_height = self.driver.execute_script('return window.innerHeight')
        _viewport_width = self.driver.execute_script('return window.innerWidth')
        return (_rect['x'] >= 0) and \
               (_rect['x'] + _rect['width'] <= _viewport_width) and \
               (_rect['y'] >= 0) and \
               (_rect['y'] + _rect['height'] <= _viewport_height)

Helpers/IAExpectedConditions/IAExpectedConditions.py
inductiveautomation/ignition-automation-tools
import datetime
import logging
import os
import time
from typing import List

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webdriver import WebDriver, WebElement
from selenium.webdriver.support.wait import WebDriverWait

from Helpers.Formatting import FilePathFormatting
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec


class IASelenium:
    """
    A collection of functions intended to provide easier/uniform interactions with the Selenium browser session.
    """

    def __init__(self, driver: WebDriver):
        self.driver = driver
        self.logger = logging.getLogger(name='IASelenium')

    def click_at_offset(self, x: int = 0, y: int = 0) -> None:
        """
        Move some number of pixels away from the current position of the cursor and then click.

        :param x: The number of pixels to move left/right. Positive values are right, and negative values are left.
        :param y: The number of pixels to move up/down. Positive values are down, and negative values are up.
        """
        ActionChains(self.driver).move_by_offset(xoffset=x, yoffset=y).click().perform()

    def click_drag_release(self, web_element: WebElement, x: int, y: int) -> None:
        """
        Click-and-hold on a WebElement, then drag it some number of pixels before finally releasing the held click.

        :param web_element: The WebElement to be dragged.
        :param x: The number of pixels to drag left/right. Positive values are right, and negative values are left.
        :param y: The number of pixels to drag up/down. Positive values are down, and negative values are up.
        """
        ActionChains(self.driver) \
            .move_to_element(web_element) \
            .click_and_hold(on_element=web_element) \
            .move_by_offset(x, y) \
            .release() \
            .perform()

    def click_element_with_offset(self, web_element: WebElement, x_offset: int, y_offset: int) -> None:
        """
        Hover over a WebElement before then moving some number of pixels and clicking.

        :param web_element: The WebElement to hover over.
        :param x_offset: The number of pixels to drag left/right. Positive values are right, and negative values
            are left.
        :param y_offset: The number of pixels to drag up/down. Positive values are down, and negative values are up.
        """
        ActionChains(self.driver).move_to_element_with_offset(
            to_element=web_element,
            xoffset=x_offset,
            yoffset=y_offset).click().perform()

    def close_current_tab(self) -> None:
        """
        Close the current tab in use by the browser. CAUTION: if invoked on the only available tab, the browser
        session will end.
        """
        self.driver.close()

    def close_extra_tabs(self) -> None:
        """
        Close any browser tabs which are not the 0th tab, and then switch to the 0th tab.
        """
        while self.get_tab_count() > 1:
            self.switch_to_tab_by_index(zero_based_index=1)
            self.close_current_tab()
        self.switch_to_tab_by_index(zero_based_index=0)

    def current_tab_index(self) -> int:
        """
        Obtain the zero-based index of the tab currently in use by the browser.

        :returns: The index of the tab currently in use by the browser.
        """
        handle = self.driver.current_window_handle
        count = len(self.driver.window_handles)
        for i in range(count):
            if str(self.driver.window_handles[i]) == handle:
                return i
        assert False, 'We never found a matching window handle.'

    def disable_context_menu(self) -> None:
        """
        Modify the browser so that context menu does not open on right-click.
        """
        self.driver.execute_script('document.body.setAttribute("oncontextmenu", "return false");')

    def enable_context_menu(self) -> None:
        """
        Modify the browser so that context menu opens on right-click.
        """
        self.driver.execute_script('document.body.setAttribute("oncontextmenu", "return true");')

    def double_click(self, web_element: WebElement) -> None:
        """
        Double-click on a WebElement. Fundamentally different from clicking on a WebElement and then clicking again.

        :param web_element: The WebElement which will receive the double-click.
        """
        ActionChains(self.driver).move_to_element(to_element=web_element).double_click(on_element=web_element).perform()

    def get_inner_height(self) -> int:
        """
        Obtain the height of the viewable area of the browser.
        """
        return self.driver.execute_script(script='return window.innerHeight')

    def get_inner_width(self) -> int:
        """
        Obtain the width of the viewable area of the browser.
        """
        return self.driver.execute_script(script='return window.innerWidth')

    def get_tab_count(self) -> int:
        """
        Obtain a count of all tabs open within the browser.

        :returns: The count of all tabs open within the current browser.
        """
        return len(self.driver.window_handles)

    def get_tab_title(self) -> str:
        """
        Obtain the title of the current tab. This is the text displayed in the tab of the browser.

        :returns: The text content of the browser tab.
        """
        return self.driver.title

    def go_back(self) -> None:
        """
        Go back to the previous browser page.
        """
        self.driver.back()

    def highlight_web_element(self, web_element: WebElement) -> None:
        """
        Force a yellow background and red border onto the supplied WebElement.

        :param web_element: The WebElement to be modified.
        """
        original_style = web_element.get_attribute('style')
        highlight_style = original_style + 'background: yellow; border: 2px solid red; color: black;'

        self.driver.execute_script("arguments[0].setAttribute('style', arguments[1]);",
                                   web_element, highlight_style)

    def hover_over_web_element(self, web_element: WebElement) -> WebElement:
        """
        Hover over a WebElement.

        :param web_element: The WebElement to hover over.

        :returns: The WebElement supplied, to allow for chaining.
        """
        ActionChains(self.driver).move_to_element(web_element).perform()
        return web_element

    def inclusive_multi_select_elements(self, web_element_list: List[WebElement]) -> None:
        """
        Click on each element in the supplied list while holding SHIFT.

        :param web_element_list: A list of WebElements, where we will click on each WebElement while holding down
            the SHIFT key.
        """
        actions = ActionChains(self.driver)
        actions.key_down(Keys.SHIFT)
        for element in web_element_list:
            actions.click(element)
        actions.key_up(Keys.SHIFT)
        actions.perform()

    def long_click(self, web_element: WebElement) -> None:
        """
        Click-and-hold on a WebElement for 2 seconds.

        :param web_element: The WebElement which will receive the elongated click event.
        """
        ActionChains(self.driver).move_to_element(to_element=web_element).click_and_hold().pause(2).release().perform()

    def move_mouse(self, x_offset: int = 0, y_offset: int = 0) -> None:
        """
        Move the mouse from its current location by some number of pixels.
        :param x_offset: The number of pixels to move left/right. Positive values are right, and negative values
            are left.
        :param y_offset: The number of pixels to move up/down. Positive values are down, and negative values are up.
        """
        ActionChains(self.driver).move_by_offset(xoffset=x_offset, yoffset=y_offset).perform()

    def open_new_tab_and_switch_to_it(self) -> None:
        """
        Open a new tab before then switching to the new created tab.
        """
        current_index = self.current_tab_index()  # need this BEFORE we open
        self.driver.execute_script("window.open('','_blank');")
        self.switch_to_tab_by_index(current_index + 1)

    def right_click(self, web_element: WebElement) -> None:
        """
        Right-click on a WebElement. To bypass the context menu of the browser, we disable the context menu before
        we right-click, then re-enable the context menu when done.

        :param web_element: The WebElement which should receive the right-click.
        """
        try:
            self.disable_context_menu()
            self.scroll_to_element(web_element=web_element)
            ActionChains(self.driver).context_click(on_element=web_element).perform()
        finally:
            self.enable_context_menu()

    def scroll_to_element(self, web_element: WebElement, align_to_top: bool = True) -> WebElement:
        """
        Scroll to a WebElement.

        :param web_element: The WebElement to scroll to.
        :param align_to_top: If True, page will attempt to scroll such that the WebElement will have its top edge
            touching the top of the viewport. If False, the page will attempt to scroll such that the WebElement will
            have its bottom edge touching the bottom of the viewport.

        :returns: The supplied WebElement, to allow for chaining.
        """
        align_string = str(align_to_top).lower()
        self.driver.execute_script('arguments[0].scrollIntoView(' + align_string + ');', web_element)
        return web_element

    def scroll_to_element_with_settings(
            self, web_element: WebElement, block_setting: str, inline_setting: str) -> WebElement:
        """
        A highly-specialized scrolling event, used primarily for dealing with input components which are hidden from
        the view of the user. These are common within File Upload components, but - unfortunately - it makes them
        impossible for Selenium to use without these particular settings.

        :param web_element: The WebElement which should be scrolled to and have its display settings modified.
        :param block_setting: The CSS 'block' setting to apply to the WebElement.
        :param inline_setting: The CSS 'inline' setting to apply to the WebElement.

        :returns: The Supplied WebElement, to allow for chaining.
        """
        _script = 'arguments[0].scrollIntoView({block:"' + block_setting + '", inline:"' + inline_setting + '"});'
        self.driver.execute_script(_script, web_element)
        return web_element

    def set_inner_window_dimensions(self, inner_width=None, inner_height=None) -> None:
        """
        Set the dimensions of the inner viewport of the browser.

        :param inner_width: The desired width of the viewport.
        :param inner_height: The desired height of the viewport.

        :raises ValueError: If the supplied inner_width is less than 500px.
        """
        if inner_width is not None and inner_width < 500:
            raise ValueError("Browser constraints prevent setting of inner width below 500px.")
        current_size = self.driver.get_window_size()
        current_width = current_size['width']  # OUTER dimension
        current_height = current_size['height']  # OUTER dimension
        adjusted_width = current_width
        adjusted_height = current_height
        if inner_width:
            adjusted_width = current_size['width'] + inner_width - self.get_inner_width()
        if inner_height:
            adjusted_height = current_size['height'] + inner_height - self.get_inner_height()
        print(f'\nAdjusting browser outer dimensions to {adjusted_width}x{adjusted_height}')
        # this actually sets the OUTER dimensions to our adjusted dimensions
        self.driver.set_window_size(width=adjusted_width, height=adjusted_height)

    def simple_click(self, web_element: WebElement) -> None:
        """
        Perform a "brainless" click on a WebElement. Standard Selenium clicks are actually quite complicated on the
        back-end, where several checks are performed, and exceptions may be raised before a click even occurs. This
        functions instead uses the primitive click options which performs no checks and will click the WebElement
        regardless of state.

        :param web_element: The WebElement to click.
        """
        ActionChains(self.driver).click(on_element=web_element).perform()

    def switch_to_tab_by_index(self, zero_based_index: int) -> None:
        """
        Switch to a specific tab based on its zero-based index.

        :param zero_based_index: The index of the tab to switch to.

        :raises TimeoutException: If there are fewer open tabs than the supplied index.
        """
        # if targeting anything beyond the first tab, wait for it to exist / finish opening
        if zero_based_index > 0:
            necessary_length = zero_based_index + 1

            def desired_tab_exists() -> bool:
                handles = len(self.driver.window_handles)
                return handles >= necessary_length
            try:
                WebDriverWait(self.driver, 1).until(IAec.function_returns_true(
                    custom_function=desired_tab_exists,
                    function_args={}))
            except TimeoutException as toe:
                raise TimeoutException(
                    msg=f"An attempt was made to switch tabs to index {zero_based_index}, but there weren't that "
                        f"many tabs present.") from toe
        handle = self.driver.window_handles[zero_based_index]
        self.switch_to_tab_by_title(title=handle)
        time.sleep(1)

    def switch_to_tab_by_title(self, title: str) -> None:
        """
        Switch to a tab by the text of the tab.

        :param title: The text content of the tab to switch to.
        """
        assert title in self.driver.window_handles, f'There is no tab titled \'{title}\' in the available tab listings.'
        self.driver.switch_to.window(title)
        time.sleep(1)

    def take_screenshot(self, directory: str = 'Screenshots', screenshot_name: str = None) -> str:
        """
        Take a screenshot of the tab currently in use.

        :param directory: The path of the directory into which the file will be saved.
        :param screenshot_name: The name to supply to the screenshot. If no name is provided, a filename which includes
            the current time will be generated.

        :returns: The location and name of the saved screenshot.
        """
        current_time = str(datetime.datetime.now()).replace(' ', '').replace(':', '')
        screenshot_title = 'AutoScreenshot' + current_time
        if screenshot_name:
            screenshot_title = str(screenshot_name)
        screenshot_title += '.png'
        screenshot_folder = f'{os.getcwd()}/{directory}'
        if not os.path.exists(screenshot_folder):
            os.mkdir(screenshot_folder)
        safe_path = FilePathFormatting.system_safe_file_path(screenshot_folder, screenshot_title)
        self.driver.save_screenshot(safe_path)
        self.logger.info(msg=f'Screenshot taken: {safe_path}')
        return safe_path

    def take_screenshot_of_element(self, web_element: WebElement, directory: str = 'Screenshots') -> str:
        """
        This method will apply a style to the supplied WebElement, giving it a red
        border and yellow background (Perspective <div> elements will only see the border)
        before taking a screenshot and saving the screenshot with a timestamp into the
        /<output_directory>/Screenshots directory of the project.

        :param web_element: The WebElement you'd like to take a screenshot of.
        :param directory: The directory where you are storing screenshots.

        :returns: The filename of the screenshot.
        """
        current_time = str(datetime.datetime.now()).replace(' ', '').replace(':', '')
        self.highlight_web_element(web_element=web_element)
        screenshot_title = 'AutoScreenshot' + current_time + '.png'
        screenshot_folder = f'{os.getcwd()}/{directory}'
        if not os.path.exists(screenshot_folder):
            os.mkdir(screenshot_folder)
        safe_path = FilePathFormatting.system_safe_file_path(screenshot_folder, screenshot_title)
        print(f'Screenshot path: \'{safe_path}\'')
        self.driver.save_screenshot(safe_path)
        return safe_path

Helpers/IASelenium.py
inductiveautomation/ignition-automation-tools
import copy
import json
import re
from enum import Enum
from typing import Dict, List


class AlarmState(Enum):
    """
    An enumeration of the possible states an alarm could have. Each state here should have a matching event type in
    Ignition's Alarm Journal.
    """
    ACKNOWLEDGED = 'Acknowledged'
    ACTIVE = 'Active'
    CLEARED = 'Cleared'
    UNACKNOWLEDGED = 'Unacknowledged'
    SYSTEM = 'System'
    ENABLED = 'Enabled'
    DISABLED = 'Disabled'


class AlarmStatusTableAlarmState(Enum):
    """
    The Alarm Status Table has its own unique set of states, built out of intersections of basic Alarm States.
    """
    ACTIVE_ACKNOWLEDGED = ", ".join([AlarmState.ACTIVE.value, AlarmState.ACKNOWLEDGED.value])
    ACTIVE_UNACKNOWLEDGED = ", ".join([AlarmState.ACTIVE.value, AlarmState.UNACKNOWLEDGED.value])
    CLEARED_ACKNOWLEDGED = ", ".join([AlarmState.CLEARED.value, AlarmState.ACKNOWLEDGED.value])
    CLEARED_UNACKNOWLEDGED = ", ".join([AlarmState.CLEARED.value, AlarmState.UNACKNOWLEDGED.value])


class AlarmPriority(Enum):
    """
    An enumeration of the possible priorities an alarm could have.
    """
    CRITICAL = 'Critical'
    DIAGNOSTIC = 'Diagnostic'
    HIGH = 'High'
    LOW = 'Low'
    MEDIUM = 'Medium'


class AlarmMode(Enum):
    EQUALITY = 'Equality'
    INEQUALITY = 'Inequality'
    ABOVE_VALUE = 'AboveValue'
    BELOW_VALUE = 'BelowValue'
    BETWEEN_VALUES = 'BetweenValues'
    OUTSIDE_VALUES = 'OutsideValues'
    OUT_OF_ENG_RANGE = 'OutOfEngRange'
    BAD_QUALITY = 'BadQuality'
    ANY_CHANGE = 'AnyChange'
    BIT = 'Bit'
    ON_CONDITION = 'OnCondition'


class AlarmDefinition(object):
    _name = None
    ack_mode = None
    ack_notes_reqd = None
    ack_pipeline = None
    active_pipeline = None
    clear_pipeline = None
    deadband = None
    deadband_eval_mode = None
    display_path = None
    enabled = None
    label = None
    notes = None
    notify_initial_event = None
    pipeline_project = None
    priority: AlarmPriority = None
    shelving_allowed = None
    time_off_delay_seconds = None
    time_on_delay_seconds = None
    timestamp_source = None

    # Alarm Mode Settings
    active_condition = None
    any_change = None
    bit_on_zero = None
    bit_position = None
    display_name = None
    inclusive_a = None
    inclusive_b = None
    mode: AlarmMode = None
    on_each_evaluation = None
    setpoint_a = None
    setpoint_b = None

    def __init__(self, name: str):
        self._name = name

    def get_name(self) -> str:
        return self._name

    def to_dict(self) -> Dict:
        local_alarm = self.duplicate()
        special_case = ['_name']
        # Get set attributes of the Tag Object
        attrs = vars(local_alarm)
        alarm_dict = {}
        for item in special_case:
            new_item = item.replace('_', '')
            attrs[new_item] = attrs[item]
            del attrs[item]
        for attr in attrs:
            ignition_key = self._to_camel_case(attr)
            if isinstance(getattr(local_alarm, attr), Enum):
                alarm_dict[ignition_key] = getattr(local_alarm, attr).value
            else:
                alarm_dict[ignition_key] = getattr(local_alarm, attr)
        return alarm_dict

    def duplicate(self):
        return copy.deepcopy(self)

    @staticmethod
    def _to_camel_case(name) -> str:
        init, *temp = name.split('_')
        return ''.join([init.lower(), *map(str.title, temp)])


class AlarmHelper:
    @staticmethod
    def build_alarm_from_string(dict_as_string: str) -> AlarmDefinition:
        """Creates an AlarmDefinition object from a string representation of a python dict
        Used for converting alarm definitions the Ignition Gateway provides into Objects"""
        response_json = json.loads(
            dict_as_string)
        if 'error' not in response_json:
            if isinstance(
                    response_json,
                    list):
                response_json = response_json[0]
            name = response_json.get('name')
            alarm = AlarmDefinition(
                    name=name)
            for key in response_json.keys():
                alarm = AlarmHelper._process_key(
                    alarm,
                    key,
                    response_json[key])
            return alarm

    @staticmethod
    def convert_alarmdefs_to_config_dicts(alarm_list: List[AlarmDefinition]) -> List[Dict]:
        """Take a list of AlarmDefinition objects and convert them into a list of dicts """
        alarms = []
        for alarm in alarm_list:
            alarms.append(
                alarm.to_dict())
        return alarms

    @staticmethod
    def _process_path(path_parts: json):
        path_parts.pop()
        return '/'.join(
            path_parts)

    @staticmethod
    def _process_key(alarm: AlarmDefinition, key: str, value: object):
        excluded_types = ['path', 'name']
        if key not in excluded_types:
            mapped_key = AlarmHelper._to_snake_case(
                key)
            setattr(
                alarm,
                mapped_key,
                value)
        return alarm

    @staticmethod
    def _to_snake_case(name):
        name = re.sub(
            '(.)([A-Z][a-z]+)',
            r'\1_\2',
            name)
        name = re.sub(
            '__([A-Z])',
            r'_\1',
            name)
        name = re.sub(
            '([a-z0-9])([A-Z])',
            r'\1_\2',
            name)
        return name.lower()

Helpers/Ignition/Alarm.py
inductiveautomation/ignition-automation-tools
from datetime import datetime
from enum import Enum
from typing import Optional, Any


class LegacyQualityCode(Enum):
    GOOD_UNSPECIFIED = 0
    GOOD_WRITEPENDING = 2
    GOOD = 192
    GOOD_PROVISIONAL = 200
    GOOD_INITIAL = 201
    GOOD_OVERLOAD = 202
    GOOD_BACKFILL = 203
    UNCERTAIN = 256
    UNCERTAIN_LASTKNOWNVALUE = 257
    UNCERTAIN_INITIALVALUE = 258
    UNCERTAIN_DATASUBNORMAL = 259
    UNCERTAIN_ENGINEERINGUNITSEXCEEDED = 260
    UNCERTAIN_INCOMPLETEOPERATION = 261
    BAD = 512
    BAD_UNAUTHORIZED = 513
    BAD_ACCESSDENIED = 514
    BAD_DISABLED = 515
    BAD_STALE = 516
    BAD_TRIALEXPIRED = 517
    BAD_LICENSEEXCEEDED = 518
    BAD_NOTFOUND = 519
    BAD_REFERENCENOTFOUND = 520
    BAD_AGGREGATENOTFOUND = 521
    BAD_NOTCONNECTED = 522
    BAD_GATEWAYCOMMOFF = 523
    BAD_OUTOFRANGE = 524
    BAD_DATABASENOTCONNECTED = 525
    BAD_READONLY = 526
    BAD_FAILURE = 527
    BAD_UNSUPPORTED = 528
    ERROR = 768
    ERROR_CONFIGURATION = 769
    ERROR_EXPRESSIONEVAL = 770
    ERROR_TAGEXECUTION = 771
    ERROR_TYPECONVERSION = 772
    ERROR_DATABASEQUERY = 773
    ERROR_IO = 774
    ERROR_TIMEOUTEXPIRED = 775
    ERROR_EXCEPTION = 776
    ERROR_INVALIDPATHSYNTAX = 777
    ERROR_FORMATTING = 778
    ERROR_SCRIPTEVAL = 779
    ERROR_CYCLEDETECTED = 780


class QualifiedValue:
    """
    The QualifiedValue class is provided as a way to store Qualified Value objects with easy to reference attributes.
    """

    def __init__(
            self,
            value: Optional[Any] = None,
            quality: LegacyQualityCode = LegacyQualityCode.UNCERTAIN,
            timestamp: datetime = datetime.now()):
        self.value = value
        self.quality = quality
        self.timestamp = timestamp

Helpers/Ignition/QualifiedValue.py
inductiveautomation/ignition-automation-tools
import copy
import json
import re
from enum import Enum
from typing import List, Dict

from Helpers.Ignition.Alarm import AlarmHelper, AlarmDefinition


class DataType(Enum):
    BYTE = 'Int1'
    SHORT = 'Int2'
    INTEGER = 'Int4'
    LONG = 'Int8'
    FLOAT = 'Float4'
    DOUBLE = 'Float8'
    BOOLEAN = 'Boolean'
    STRING = 'String'
    DATE_TIME = 'DateTime'
    TEXT = 'Text'
    BYTE_ARRAY = 'Int1Array'
    SHORT_ARRAY = 'Int2Array'
    INTEGER_ARRAY = 'Int4Array'
    LONG_ARRAY = 'Int8Array'
    FLOAT_ARRAY = 'Float4Array'
    DOUBLE_ARRAY = 'Float8Array'
    BOOLEAN_ARRAY = 'BooleanArray'
    STRING_ARRAY = 'StringArray'
    DATE_TIME_ARRAY = 'DateTimeArray'
    DATASET = 'DataSet'
    DOCUMENT = 'Document'


class ValueSource(Enum):
    DERIVED = 'derived'
    EXPRESSION = 'expr'
    MEMORY = 'memory'
    OPC = 'opc'
    QUERY = 'db'
    REFERENCE = 'reference'


class SampleMode(Enum):
    PERODIC = 'Periodic'
    ON_CHANGE = 'OnChange'
    TAG_GROUP = 'TagGroup'


class _BaseTag(object):
    _name = None
    _path = None
    _provider = None
    tag_type = None
    access_rights = None
    alarm_eval_enabled = None
    alarms = None
    clamp_mode = None
    data_type: DataType = None
    deadband = None
    deadband_mode = None
    default_eng_units = None
    documentation = None
    enabled = None
    eng_high = None
    eng_limit_mode = None
    eng_low = None
    eng_unit = None
    event_scripts = None
    execution_mode = None
    execution_rate = None
    format_string = None
    historical_deadband_style = None
    history_enabled = None
    history_provider = None
    history_tag_group = None
    permission_model = None
    persist_value = None
    quality = None
    raw_high = None
    raw_low = None
    read_only = None
    read_permissions = None
    sample_mode: SampleMode = None
    scale_factor = None
    scale_mode = None
    scaled_high = None
    scaled_low = None
    tag_group = None
    timestamp = None
    tooltip = None
    type_color = None
    type_id = None
    value = None
    value_source: ValueSource = None
    write_permissions = None

    def __init__(self, name: str, path: str, provider: str = '[default]'):
        self._name = name
        self._path = path
        self._provider = f'{provider}'
        self._base_path = f'{self._provider}{path}'
        self._full_path = f'{self._base_path}{name}' if self._path == '' else f'{self._base_path}/{name}'

    def get_name(self) -> str:
        return self._name

    def get_path(self) -> str:
        return self._path

    def get_provider(self):
        return self._provider

    def get_base_path(self) -> str:
        return self._base_path

    def get_full_path(self) -> str:
        return self._full_path

    def to_dict(self) -> Dict:
        local_tag = self.duplicate()
        special_case = ['_name']
        not_used = ['_path', '_base_path', '_provider', '_full_path', '_type_id_path']
        # Get set attributes of the Tag Object
        attrs = vars(
            local_tag)
        tag_dict = {}
        for item in not_used:
            if item in attrs:
                del attrs[item]
        for item in special_case:
            new_item = item.replace('_', '')
            attrs[new_item] = attrs[item]
            del attrs[item]
        for attr in attrs:
            ignition_key = self._to_camel_case(
                name=attr)
            if getattr(local_tag, attr) is None:
                pass
            elif isinstance(getattr(local_tag, attr), Enum):
                tag_dict[ignition_key] = getattr(local_tag, attr).value
            else:
                tag_dict[ignition_key] = getattr(local_tag, attr)
        return tag_dict

    def diff(self, expected_tag):
        """Compares current tag definition to and expected tag definition and returns any differences"""
        return {
            'actual_extra_keys': self.diff_my_unmatched_keys_values(
                compare_tag=expected_tag),
            'expected_extra_keys': self.diff_their_unmatched_keys_values(
                compare_tag=expected_tag),
            'diff_values': self.diff_key_values(
                expected_tag=expected_tag)
        }

    def duplicate(self):
        return copy.deepcopy(self)

    def diff_key_values(self, expected_tag):
        """Compares the keys 2 tag definitions have in common and returns value differences"""
        diff_values = {}
        for my_key in self.to_dict().keys():
            if my_key not in self.diff_my_unmatched_keys(
                    compare_tag=expected_tag):
                if self.to_dict()[my_key] != expected_tag.to_dict()[my_key]:
                    diff_values[my_key] = {'actual': self.to_dict()[my_key], 'expected': expected_tag.to_dict()[my_key]}
        return diff_values

    def diff_my_unmatched_keys_values(self, compare_tag):
        """Compares against another tag definition and returns keys & values that are unique to this tag"""
        result = {}
        for key in self.diff_my_unmatched_keys(
                compare_tag=compare_tag):
            result[key] = self.to_dict()[key]
        return result

    def diff_their_unmatched_keys_values(self, compare_tag):
        """Compares against another tag definition and returns keys & values that are unique to the other tag"""
        result = {}
        for key in self.diff_their_unmatched_keys(
                compare_tag=compare_tag):
            result[key] = compare_tag.to_dict()[key]
        return result

    def diff_my_unmatched_keys(self, compare_tag):
        """Compares against another tag definition and returns keys that are unique to this tag"""
        return self._get_unmatched_keys(
            dict_keys1=self.to_dict().keys(),
            dict_keys2=compare_tag.to_dict().keys())

    def diff_their_unmatched_keys(self, compare_tag):
        """Compares against another tag definition and returns keys that are unique to the other tag"""
        return self._get_unmatched_keys(
            dict_keys1=compare_tag.to_dict().keys(),
            dict_keys2=self.to_dict().keys())

    @staticmethod
    def _get_unmatched_keys(dict_keys1, dict_keys2):
        key_diffs = []
        for key in dict_keys1:
            if key not in dict_keys2:
                key_diffs.append(key)
        return key_diffs

    @staticmethod
    def _to_camel_case(name):
        init, *temp = name.split(
            '_')
        return ''.join(
            [init.lower(), *map(
                str.title,
                temp)])

    def __str__(self):
        return str(
            self.to_dict())


class Tag(_BaseTag):
    """
    The Tag class allows for easy storage and reference of Tag attributes. This class does not perform as a Tag, only
    storing information for later reference.
    """
    def __init__(self, name: str, path: str, provider: str = '[default]'):
        super().__init__(
            name=name,
            path=path,
            provider=provider)
        self.tag_type = 'AtomicTag'
        self.value_source = ValueSource.MEMORY

    def get_alarm(self, alarm_name: str) -> AlarmDefinition:
        """
        Get a specific AlarmDefinition from a self.alarms list returned from an Ignition Gateway

        :param alarm_name: The name of the alarm definition you would like to obtain.

        :return: The AlarmDefinition configuration for the specified alarm.
        """
        for alarm in self.get_alarms():
            if alarm.get_name() == alarm_name:
                return alarm

    def get_alarms(self) -> List[AlarmDefinition]:
        """
        Get a list of configured AlarmDefinitions from this Tag.

        :returns: All Alarm configurations for this Tag.
        """
        my_alarms = []
        for alarm in copy.deepcopy(self.alarms):
            my_alarms.append(AlarmHelper.build_alarm_from_string(json.dumps(alarm)))
        return my_alarms

    def replace_alarm(self, alarm_name: str, new_config: dict):
        """Replace a specific alarm with a dict representation of a new alarm"""
        if self.alarms is not None:
            self.alarms[:] = [alarm for alarm in self.alarms if not alarm['name'] == alarm_name]
            self.alarms.append(new_config)
        else:
            self.alarms = [new_config]


class _ComplexTag(_BaseTag):
    tags = []

    def to_dict(self):
        local_tag = self.duplicate()
        special_case = ['_name']
        not_used = ['_path', '_base_path', '_provider', '_full_path', '_type_id_path']
        # Get set attributes of the Tag Object
        attrs = vars(
            local_tag)
        tag_dict = {}
        for item in not_used:
            if item in attrs:
                del attrs[item]
        for item in special_case:
            new_item = item.replace('_', '')
            attrs[new_item] = attrs[item]
            del attrs[item]
        for attr in attrs:
            ignition_key = self._to_camel_case(
                name=attr)
            if getattr(local_tag, attr) is None:
                pass
            elif isinstance(getattr(local_tag, attr), Enum):
                tag_dict[ignition_key] = getattr(local_tag, attr).value
            else:
                tag_dict[ignition_key] = getattr(local_tag, attr)
        if local_tag.tags != [] and 'tags' not in tag_dict:
            tag_dict['tags'] = local_tag.tags
        return tag_dict

    def diff_key_values(self, expected_tag):
        """Compares the keys 2 tag definitions have in common and returns value differences,
        includes special handling for 'tags' included in _ComplexTag objects"""
        diff_values = {}
        for my_key in self.to_dict().keys():
            if my_key not in self.diff_my_unmatched_keys(
                    compare_tag=expected_tag):
                if self.to_dict()[my_key] != expected_tag.to_dict()[my_key]:
                    diff_values[my_key] = {'actual': self.to_dict()[my_key], 'expected': expected_tag.to_dict()[my_key]}
        if 'tags' in diff_values:
            diff_values['tags'] = self.get_tag_differences(actual=diff_values['tags']['actual'],
                                                           expected=diff_values['tags']['expected'])

        return diff_values

    def get_member_tag(self, member_tag_name: str):
        """Get Tag object of a specific tag from a self.tags list returned from an Ignition Gateway"""
        tags = copy.deepcopy(self.tags)
        for tag in tags:
            if tag['name'] == member_tag_name:
                return TagHelper.build_tag_from_string(json.dumps(tag))

    def get_member_tags(self):
        """Get list of Tag objects from a self.tags list returned from an Ignition Gateway"""
        tags = copy.deepcopy(self.tags)
        my_tags = []
        for tag in tags:
            my_tags.append(self.get_member_tag(tag['name']))
        return my_tags

    def replace_member_tag(self, tag_name, new_config: dict):
        """Replace a specific tag with a dict representation of a new tag"""
        if self.tags is not None:
            self.tags[:] = [tag for tag in self.tags if not tag['name'] == tag_name]
            self.tags.append(new_config)
        else:
            self.tags = [new_config]

    @staticmethod
    def get_tag_differences(actual: list, expected: list):
        """Get the differences between two list of string representations of dicts that contain Tag Definitions
        This is normally what is returned from an Ignition Gateway"""
        actual_tags = []
        expected_tags = []
        tags_processed = []
        failures = {}
        for tag in actual:
            actual_tags.append(TagHelper.build_tag_from_string(
                dict_as_string=json.dumps(tag)))
        for tag in expected:
            expected_tags.append(TagHelper.build_tag_from_string(
                dict_as_string=json.dumps(tag)))

        for expected_tag in expected_tags:
            for actual_tag in actual_tags:
                if actual_tag.get_full_path() == expected_tag.get_full_path():
                    tags_processed.append(expected_tag.get_full_path())
                    if expected_tag.to_dict() != actual_tag.to_dict():
                        failures[expected_tag.get_full_path()] = actual_tag.diff(expected_tag=expected_tag)
        for expected_tag in expected_tags:
            if expected_tag.get_full_path() not in tags_processed:
                failures[expected_tag.get_full_path()] = {'actual': '', 'expected_tag': expected_tag.to_dict()}
        for actual_tag in actual_tags:
            if actual_tag.get_full_path() not in tags_processed:
                failures[actual_tag.get_full_path()] = {'actual': actual_tag.to_dict(), 'expected_tag': ''}

        return failures


class UdtInstance(_ComplexTag):
    type_id = None
    parameters = []

    def __init__(self, name: str, path: str, type_id: str, provider: str = '[default]'):
        super().__init__(
            name=name,
            path=path,
            provider=provider)
        self.tag_type = 'UdtInstance'
        self.type_id = type_id


class UdtDef(_ComplexTag):
    _type_id_path = None
    type_id = None
    parameters = []

    def __init__(self, name: str, path: str, provider: str = '[default]'):
        super().__init__(
            name=name,
            path=path,
            provider=provider)
        self.tag_type = 'UdtType'
        self._type_id_path = f'{self.get_path()}/{self.get_name()}'.replace('_types_/', '')

    def get_type_id_path(self):
        return self._type_id_path


class Folder(_ComplexTag):
    def __init__(self, name: str, path: str, provider: str = '[default]'):
        super().__init__(
            name=name,
            path=path,
            provider=provider)
        self.tag_type = 'Folder'
        self._path = f'{name}' if self._path == '' else f'{self._path}/{name}'


class TagHelper:
    @staticmethod
    def build_tag_from_string(dict_as_string: str):
        """Build a Tag, UdtInstance, UdtDef or Folder object from a string representation of a dict"""
        provider = ''
        path = ''
        response_json = json.loads(dict_as_string)
        if 'error' not in response_json:
            if isinstance(response_json, list):
                response_json = response_json[0]
            name = response_json.get('name')
            if 'path' in response_json:
                provider = f'[{response_json.get("path").get("source")}]'
                path = TagHelper._process_path(response_json.get('path', '').get('pathParts', ''))
            if response_json.get('tagType') == 'UdtInstance':
                tag = UdtInstance(name=name, provider=provider, path=path, type_id='')
            elif response_json.get('tagType') == 'UdtType':
                tag = UdtDef(name=name, provider=provider, path=path)
            elif response_json.get('tagType') == 'Folder':
                tag = Folder(name=name, provider=provider, path=path)
            else:
                tag = Tag(name=name, provider=provider, path=path)
            for key in response_json.keys():
                tag = TagHelper._process_key(tag, key, response_json[key])
            return tag

    @staticmethod
    def convert_tags_to_config_dicts(tag_list: list):
        """Convert a list of _baseTag objects a list of dicts to use for importing into Ignition"""
        tags = []
        for tag in tag_list:
            tags.append(tag.to_dict())
        return tags

    @staticmethod
    def _process_path(path_parts: json):
        path_parts.pop()
        return '/'.join(path_parts)

    @staticmethod
    def _process_key(tag: Tag, key: str, value: object):
        excluded_types = ['path', 'name']
        if key not in excluded_types:
            mapped_key = TagHelper._to_snake_case(key)
            setattr(tag, mapped_key, value)
        return tag

    @staticmethod
    def _to_snake_case(name):
        name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
        name = re.sub('__([A-Z])', r'_\1', name)
        name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', name)
        return name.lower()

Helpers/Ignition/Tag.py
inductiveautomation/ignition-automation-tools
class PerspectiveAlarm(object):
    """
    Defines the configuration of an Ignition alarm, and is not intended to be used for storing information
    about the state of an alarm over a period of time. Specifically, this class stores information about the
    important configuration attributes Perspective could have need of while interacting with the Alarm Tables, where
    information like `source_path` is required while working with shelved alarms.
    """

    def __init__(self, name: str, display_path: str, priority: str, label: str,
                 source_path: str, tag_path: str, notes: str = None):
        self.name = name
        self.display_path = display_path
        self.priority = priority
        self.label = label
        self.notes = notes
        self.source_path = source_path
        self.tag_path = tag_path
        self.notes = notes

Helpers/PerspectiveAlarm.py
inductiveautomation/ignition-automation-tools
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Helpers.IASelenium import IASelenium
from Helpers.PerspectivePages.LoginHelper import LoginHelper as LoginHelper
from Pages.PagePiece import PagePiece


class AuthChallengeHelper(PagePiece):
    _AC_EMBEDDED_FRAME_POPUP_LOCATOR = (By.CSS_SELECTOR, 'div.popup-header')

    def __init__(self, driver: WebDriver):
        super().__init__(driver=driver)
        self._selenium = IASelenium(driver=self.driver)
        self.perspective_login_helper = LoginHelper(driver=driver)

    def authenticate_with_ignition_idp_within_iframe(self, username: str, password: str) -> None:
        self.switch_driver_to_embedded_frame()
        try:
            self.perspective_login_helper.log_in_through_login_page(username=username, password=password)
        except AssertionError:
            pass
        self.switch_driver_back_to_page()

    def authenticate_with_ignition_idp_on_new_tab(self, username: str, password: str) -> None:
        self._selenium.switch_to_tab_by_index(zero_based_index=1)
        try:
            self.perspective_login_helper.log_in_through_login_page(username=username, password=password)
        except AssertionError:
            pass
        self._selenium.switch_to_tab_by_index(zero_based_index=0)

    def embedded_ac_login_popup_is_displayed(self) -> bool:
        try:
            return self.wait.until(ec.text_to_be_present_in_element(self._AC_EMBEDDED_FRAME_POPUP_LOCATOR, 'Login'))
        except TimeoutException:
            return False

    def switch_driver_to_embedded_frame(self, iframe_index: int = 0) -> None:
        """
        Point the webdriver to the content of the embedded iframe in order to interact with it.
        """
        self.driver.switch_to.frame(self.driver.find_elements(By.TAG_NAME, "iframe")[iframe_index])

    def switch_driver_back_to_page(self) -> None:
        """
        Point the driver back to the main page after switching to an iframe to authenticate.
        """
        self.driver.switch_to.default_content()

    def switch_driver_back_to_project_tab(self) -> None:
        """
        Point the driver back to the main project tab after switching toa new tab to authenticate.
        """
        self._selenium.switch_to_tab_by_index(zero_based_index=0)

Helpers/PerspectivePages/AuthChallengeHelper.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import Optional, List

from selenium.common.exceptions import NoSuchElementException, TimeoutException, StaleElementReferenceException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import ComponentPiece
from Pages.PagePiece import PagePiece


class ConfigurationOptions:
    """
    These configuration options define what is available for Docked Views
    """
    class Display(Enum):
        AUTO = "auto"
        ON_DEMAND = "onDemand"
        VISIBLE = "visible"

    class Content(Enum):
        AUTO = "auto"
        COVER = "cover"
        PUSH = "push"

    class Anchor(Enum):
        FIXED = "fixed"
        SCROLLABLE = "scrollable"

    class Handle(Enum):
        AUTOHIDE = "autoHide"
        HIDE = "hide"
        SHOW = "show"


class Side(Enum):
    """The sides a Docked View may belong to. Used most commonly for interaction with handles."""
    BOTTOM = "bottom"
    LEFT = "left"
    RIGHT = "right"
    TOP = "top"


class DockedViewHelper(PagePiece):
    """
    Used for interacting with Docked Views and determining their current state. The terms "displayed" and "expanded"
    may be used interchangeably.
    """
    _DOCKED_VIEW_UNFORMATTED_DIV_CSS = 'div.docked-view-{0} '
    _RESIZE_HANDLES_UNFORMATTED_DIV_CSS = '{0}div.dock-border.drag-border div.resize-zone'
    _DOCK_MODAL_LOCATOR = (By.CSS_SELECTOR, "div.modal.dock-modal")

    def __init__(self, driver: WebDriver):
        super().__init__(driver=driver)
        self._modal = ComponentPiece(
            locator=self._DOCK_MODAL_LOCATOR, driver=driver, wait_timeout=1)
        self._docked_views = {}

    def click_modal_overlay(self) -> None:
        """Click a Docked View modal overlay, if present. If no overlay is present, no action is taken."""
        if self.modal_overlay_is_present():
            self._modal.click(wait_timeout=1, binding_wait_time=1)

    def docked_view_with_root_id_is_expanded(
            self, docked_view_root_container_id: str, side: Optional[Side] = None) -> bool:
        """
        Determine if a Docked View with a specified `root` id is expanded. The `root` ID should not be confused with
        the ID of the Docked View configuration.

        :param docked_view_root_container_id: The ID in use by the `root` node of the View we will verify the
            expansion status of.
        :param side: The side you expect the View to be expanded on. If not supplied, we only look for basic
            visibility; usages of the View OUTSIDE of a Docked View will result in a returned value of True.

        :returns: True, if the supplied `root` ID identifies a View which is expanded on the specified side - False
            otherwise.
        """
        locator_text = 'div[id="{0}"]'.format(docked_view_root_container_id)
        if side:
            side = side.value
            side_locator = self._DOCKED_VIEW_UNFORMATTED_DIV_CSS.format(side)
            locator_text = " ".join([side_locator, locator_text])
        try:
            return WebDriverWait(self.driver, 1).until(
                ec.visibility_of_element_located((By.CSS_SELECTOR, locator_text))) is not None
        except TimeoutException:
            return False

    def docked_view_is_resizable(self, side: Side) -> bool:
        """
        Determine if resize handles are displayed for the currently expanded Docked View on the given side.

        :param side: The side of the Page which we will check for resize handles.

        :returns: True, if resize handles are present for the currently expanded Docked View on the specified side -
            False otherwise.
        """
        side_css_as_str = self._DOCKED_VIEW_UNFORMATTED_DIV_CSS.format(side.value)
        css_selector = self._RESIZE_HANDLES_UNFORMATTED_DIV_CSS.format(side_css_as_str)
        try:
            return self.driver.find_element(By.CSS_SELECTOR, css_selector).is_displayed()
        except NoSuchElementException:
            return False

    def drag_docked_view(self, side: Side, distance_in_px: int) -> None:
        """
        Drag the Docked View on the supplied side some number of pixels. The distance is always measured from the
        edge which the Docked View originates from.

        Example:
            A distance of 50 will drag a left-based Docked View 50 pixels to the right and will drag a
            right-based Docked View 50 pixels to the left.

        :param side: The side the Docked View to be dragged belongs to.
        :param distance_in_px: The numeric count of pixels to drag the Docked View.

        :raises NoSuchElementException: If the specified side does not have a Docked View currently expanded which
            is resizable.
        """
        if side in [Side.RIGHT, Side.BOTTOM]:
            distance_in_px = int(float(distance_in_px)) * -1.0
        if side in [Side.LEFT, Side.RIGHT]:
            x = distance_in_px
            y = 0
        else:
            x = 0
            y = distance_in_px
        handle_locator = f'{self._DOCKED_VIEW_UNFORMATTED_DIV_CSS.format(side.value)} ' \
                         f'div.dock-border.drag-border svg.drag-icon'
        self.selenium.click_drag_release(
            web_element=self.driver.find_element(By.CSS_SELECTOR, handle_locator), x=x, y=y)

    def get_count_of_docked_views_open_by_side(self, side: Side) -> int:
        """
        Returns a count of all Docked Views on a given side.

        :param side: The side of the Page from which you would like a count of configured Docked Views.

        :returns: a count of Docked Views for the specified side.
        """
        try:
            return len(self._get_all_displayed_docked_views_by_side(side=side))
        except TimeoutException:
            return 0

    def get_path_of_custom_icon(self, side: Side) -> str:
        """
        Returns the icon path in use for the FIRST Docked View handle on the supplied side. This function is unsafe
        to use when dealing with multiple Docked Views on a given side.

        :param side: The side the Docked View to be dragged belongs to.

        :returns: The slash-delimited path of the icon in use for the FIRST handle found on the specified side.

        :raises AssertionError: If not EXACTLY ONE Docked View is open on the supplied side.
        :raises NoSuchElementException: If the displayed Docked View does not contain a handle.
        """
        return self._get_all_displayed_docked_views_by_side(
            side=side)[0].find_element(*(By.TAG_NAME, "svg")).get_attribute("data-icon")

    def get_width_of_displayed_docked_view_by_side(self, side: Side, include_units: bool = False) -> str:
        """
        Obtain the width of the displayed Docked View on a given side of the page.

        :param side: The side the Docked View to be dragged belongs to.
        :param include_units: Dictates whether the returned value includes units of measurement.

        :returns: The width of the displayed Docked View on the specified side, as a string value. Units may be included
            depending on supplied arguments.
        """
        return self._get_docked_view(side=side).get_computed_width(include_units=include_units)

    def docked_view_is_using_default_icon_path(self, side: Side) -> bool:
        """
        Determine if the Docked View on a specified side of the Page is using the default icon. Will always check the
        first available handle.

        :param side: The side the Docked View to be dragged belongs to.

        :returns: True, if the first available Docked View handle on the specified side is using the default icon -
            False otherwise.
        """
        return self.get_path_of_custom_icon(side=side) is None

    def handle_with_custom_icon_path_exists_on_side(self, side: Side, custom_icon_path: str) -> bool:
        """
        Determine if any Docked View handle on the specified side is using the specified custom icon path.

        :param side: The side the Docked View to be dragged belongs to.
        :param custom_icon_path: The slash-delimited path you expect to be in use by a Docked View handle on the
            specified side.

        :returns: True, if any Docked View handle on the specified side is using the supplied custom icon path - False
            otherwise.
        """
        try:
            handles = self.driver.find_elements(By.CSS_SELECTOR, f'div.toggle-{side.value} svg')
            for handle in handles:
                if handle.get_attribute("data-icon") == custom_icon_path:
                    return True
            return False
        except NoSuchElementException:
            return False

    def handle_with_custom_icon_path_is_visible_on_side(self, side: Side, custom_icon_path: str) -> bool:
        """
        Determine if any Docked View handle on the specified side is using the specified custom icon path and is
        currently visible.

        :param side: The side the Docked View to be dragged belongs to.
        :param custom_icon_path: The slash-delimited path you expect to be in use by a Docked View handle on the
            specified side.

        :returns: True, if any Docked View handle on the specified side is using the supplied custom icon path and is
            currently visible - False otherwise.
        """
        try:
            docked_views = self.driver.find_elements(By.CSS_SELECTOR, f'div.toggle-{side.value}')
            for docked_view in docked_views:
                handle = docked_view.find_element(By.TAG_NAME, "svg")
                if handle.get_attribute("data-icon") == custom_icon_path:
                    return "toggle-visible" in docked_view.get_attribute("class")
            return False
        except NoSuchElementException:
            return False
        except StaleElementReferenceException:
            return False

    def modal_overlay_is_present(self) -> bool:
        """
        Determine if the overlay in use by Docked Views is currently present in the session.

        :returns: True, if a Docked View is currently displaying in a modal form, where an overlay is covering the
            primary view - False otherwise.
        """
        try:
            return self._modal.find() is not None
        except TimeoutException:
            return False

    def _get_all_displayed_docked_views_by_side(self, side: Side) -> List[WebElement]:
        """
        Obtain all Docked Views which are visible on a given side. There should only ever be either 0 or 1.

        :param side: The side from which we would like all of the visible Docked Views.

        :returns: A list of WebElements, where each WebElement is a visible Docked View on the specified side.

        :raises AssertionError: If there is some number other than 0 or 1 visible on a specified side.
        """
        docked_view = self._get_docked_view(side=side)
        assert len(docked_view.find_all()) in [0, 1], "There should only ever be 0 or 1 Docked Views visible on a side."
        return docked_view.find_all() if self._is_visible(side=side) else []

    def _is_visible(self, side: Side) -> bool:
        """
        Determine if the Specified side currently has a visible Docked View.

        :param side: The side to check for any visible Docked Views.

        :returns: True, if the specified side has a visible Docked View - False otherwise.
        """
        docked_view = self._get_docked_view(side=side)
        if side in [Side.LEFT, Side.RIGHT]:
            origin = docked_view.get_origin().X
            width_or_height = docked_view.get_computed_width(include_units=False)
            viewport_width_or_height = self.selenium.get_inner_width()
        else:
            origin = docked_view.get_origin().Y
            width_or_height = docked_view.get_computed_height(include_units=False)
            viewport_width_or_height = self.selenium.get_inner_height()
        if float(width_or_height) < 1:  # return now because Docked View is not visible
            return False
        # Bottom/Right: If the top-left corner is between 0 and the width of the viewport.
        # Top: If the top-left corner plus the height of the Docked View is between 0 and the height of the viewport.
        # Left: If the top-left corner plus the width of the Docked View is between 0 and the width of the viewport.
        return (0 < float(origin) < float(viewport_width_or_height)) or \
               (0 < (float(origin) + float(width_or_height)) < viewport_width_or_height)

    def _get_docked_view(self, side: Side) -> ComponentPiece:
        """
        Obtain a generic Docked View definition based on the side of the Page.

        :param side: The side of the page for which you would like a generic Docked View definition.

        :returns: A Component Piece which defines a Docked View on the specified side.
        """
        side = side.value
        docked_view = self._docked_views.get(side)
        if not docked_view:
            docked_view = ComponentPiece(
                locator=(By.CSS_SELECTOR, f"{self._DOCKED_VIEW_UNFORMATTED_DIV_CSS.format(side)}"),
                driver=self.driver)
            self._docked_views[side] = docked_view
        return docked_view

Helpers/PerspectivePages/DockedViewHelper.py
inductiveautomation/ignition-automation-tools
from selenium.common.exceptions import TimeoutException, NoSuchWindowException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import BasicComponent
from Helpers.IAAssert import IAAssert
from Pages.Perspective.AppBar import AppBar
from Pages.Perspective.TerminalStates.LoggedOut import LoggedOut


class LoginHelper:
    """
    A Login Helper for Perspective Sessions. Functions here assist in logging users into sessions with provided
    credentials either via the Perspective Login page or active Perspective Sessions. Note that if your session ends up
    on the LoggedOut Terminal State Page that you must handle that scenario on your own - it is unsafe for this class
    to naively click the "Back" button of the LoggedOut Terminal State Page due to the potential for cached credentials.
    """
    _USERNAME_FIELD_CSS_LOCATOR = (By.CSS_SELECTOR, 'input.username-field')
    _PASSWORD_FIELD_LOCATOR = (By.CSS_SELECTOR, 'input.password-field')
    _EXIT_LOGIN_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'button.exit-login-button')
    _SUBMIT_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.submit-button')
    _CONTINUE_DESCRIPTOR_LOCATOR = (By.CSS_SELECTOR, 'div.panel-header p.panel-description')

    def __init__(self, driver: WebDriver):
        self.driver = driver
        self._logged_out_ts_page = LoggedOut(driver=driver)
        self._app_bar = AppBar(driver=driver)
        self._username_input = BasicComponent(
            locator=self._USERNAME_FIELD_CSS_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=0,
            description="The username input of the Perspective Login page.")
        self._exit_login_button = BasicComponent(
            locator=self._EXIT_LOGIN_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=0,
            description="The button which allows a user to exit login. Available only within projects which do not "
                        "require authentication.")
        self._submit_button = BasicComponent(
            locator=self._SUBMIT_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=0,
            description="A generic 'submit' button, available while inputting both the username and the password on "
                        "the Perspective Login page.")
        self._password_input = BasicComponent(
            locator=self._PASSWORD_FIELD_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=0,
            description="The password input of the Perspective Login page.")
        self._continue_descriptor = BasicComponent(
            locator=self._CONTINUE_DESCRIPTOR_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=0,
            description="The label on the interstitial 'continue' page which informs a user they must continue to the "
                        "Login page.")

    def click_exit_login(self) -> None:
        """
        Click the button to exit the login process and go back to the Perspective session.
        """
        self._exit_login_button.click()

    def continue_to_log_in(self) -> None:
        """
        Click the button on the interstitial 'continue' page so that you may continue to the main Perspective Login
        page.
        """
        try:
            self._submit_button.click(wait_timeout=3)
        except TimeoutException:
            pass
        self.wait_for_login_page()

    def log_in_through_login_page(self, username: str, password: str = "password") -> None:
        """
        Login to a Perspective project via the main Perspective Login page.

        :param username: The username to be used for the login process.
        :param password: The password to use for the supplied user.

        :raises AssertionError: If the project being logged into both has an App Bar configured to display, and this
            function was unsuccessful in logging in with the supplied credentials.
        """
        self.continue_to_log_in()
        try:
            self._username_input.find().send_keys(username)
            self._submit_button.click(binding_wait_time=1)
            self._password_input.find(wait_timeout=1).send_keys(password)
            self._submit_button.click()
        except TimeoutException:
            pass  # user credentials may have been cached.
        try:
            # wait for the Perspective page to load.
            self._app_bar.wait_for_revealer_or_app_bar_to_be_displayed()
            while self._app_bar.is_present() and not self._app_bar.is_expanded():
                # interaction with the revealer can sometimes occur too quickly after a page loads, so keep trying
                # if we are unsuccessful in expanding the App Bar
                try:
                    self._app_bar.expand()
                except AssertionError:
                    pass
            IAAssert.is_equal_to(
                actual_value=self._app_bar.get_logged_in_user(),
                expected_value=username,
                failure_msg=f"Failed to log in as the '{username}' user. Credentials from the previous user might have "
                            f"been cached by the browser.")
            self._app_bar.collapse()
        except TimeoutException:
            pass  # maybe the AppBar is not present in this project
        except NoSuchWindowException:
            pass  # AuthChallenge could force login in a new window

    def log_in_through_active_session(self, username: str, password: str = "password") -> None:
        """
        Log into an active Perspective Session, where the user is currently viewing a Perspective page which belongs
        to a project. If the supplied username is already logged in, no action is taken.

        :param username: The username to be used for the login process.
        :param password: The password to use for the supplied user.

        :raises AssertionError: If the current project does not allow for authentication (and therefore the
            SIGN IN/OUT buttons are not present in the App Bar), or if unsuccessful in logging in with the supplied
            credentials.
        :raises TimeoutException: If the App Bar is not present on the current page.
        """
        if self._app_bar.get_logged_in_user() != username:
            IAAssert.is_true(
                value=self._app_bar.sign_out_button_is_displayed() or self._app_bar.sign_in_button_is_displayed(),
                failure_msg="Unable to perform login, as neither the 'SIGN IN' nor 'SIGN OUT' button was present in "
                            "the App Bar.")
            if self._app_bar.sign_out_button_is_displayed():  # potential for project to not require auth
                self._app_bar.click_sign_out_button()
                self._app_bar.wait_for_revealer_or_app_bar_to_be_displayed(wait_timeout=3)
            if not (self.on_interstitial_continue_to_login_page() or self.on_login_page()):
                # Still in project, because auth not required
                self._app_bar.click_sign_in_button()
            self.log_in_through_login_page(username=username, password=password)

    def on_interstitial_continue_to_login_page(self) -> bool:
        """
        Determine if the session is currently viewing the 'Continue to Login' page. This is not considered to be the
        'main Login page'.

        :returns: True, if the user can see 'Continue to Login' - False otherwise.
        """
        try:
            self._submit_button.find(wait_timeout=0)  # to trigger the TOE if we are not on this page.
            return self._continue_descriptor.get_text() == 'You must log in to continue.'
        except TimeoutException:
            return False

    def on_login_page(self) -> bool:
        """
        Determine if you are currently on the main Perspective Login page.

        :returns: True, if you are currently on the main Perspective Login page. False, if you are not on the main
            Perspective Login page - even if you are on the interstitial 'continue' page.
        """
        try:
            return self._username_input.find() is not None
        except TimeoutException:
            return False

    def wait_for_login_page(self, time_to_wait: int = 10) -> None:
        """
        Wait up to some specified period of time for the main Login page to be displayed.

        :param time_to_wait: The amount of time (in seconds) you are prepared to wait for the Login page to be
            displayed before allowing code to continue.
        """
        try:
            self._username_input.find(wait_timeout=time_to_wait)
        except TimeoutException:
            pass

Helpers/PerspectivePages/LoginHelper.py
inductiveautomation/ignition-automation-tools
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Helpers.CSSEnumerations import CSS


class NotificationHelper:
    """
    A Helper class for interacting with Perspective browser notifications. Unfortunately, these notifications do not
    supply any sort of identifying attributes, so when more than one notification is present any sort of reference
    needs to be by index.
    """
    _NOTIFICATION_LOCATOR = (By.CSS_SELECTOR, 'div.notification')
    _CLOSE_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.close-icon')
    _BODY_LOCATOR = (By.CSS_SELECTOR, 'div.notification-body')
    _ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.notification-icon')
    _SUMMARY_LOCATOR = (By.CSS_SELECTOR, 'p.message-summary')
    _DETAILS_LOCATOR = (By.CSS_SELECTOR, 'p.message-detail')
    _ACTION_ITEM_LOCATOR = (By.CSS_SELECTOR, 'div.notification-action-item')

    def __init__(self, driver: WebDriver):
        self.driver = driver
        self._notifications = ComponentPiece(
            locator=self._NOTIFICATION_LOCATOR,
            driver=driver,
            description="A generic notification, used primarily for determining presence.",
            wait_timeout=0)
        self._close_icon = ComponentPiece(
            locator=self._CLOSE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._notifications.locator_list,
            description="The 'X' icon in the upper-right of notifications which allow for dismissal.",
            wait_timeout=0)
        self._body = ComponentPiece(
            locator=self._BODY_LOCATOR,
            driver=driver,
            parent_locator_list=self._notifications.locator_list,
            description="The body of the Notification, where the details and summary reside.",
            wait_timeout=0)
        self._icon = ComponentPiece(
            locator=self._ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._body.locator_list,
            description="The icon on the left-hand side of the Notification.",
            wait_timeout=0)
        self._summary = ComponentPiece(
            locator=self._SUMMARY_LOCATOR,
            driver=driver,
            parent_locator_list=self._body.locator_list,
            description="The summary (short description) of what the Notification is in regard to.",
            wait_timeout=0)
        self._details = ComponentPiece(
            locator=self._DETAILS_LOCATOR,
            driver=driver,
            parent_locator_list=self._body.locator_list,
            description="The in-depth details of the Notification.",
            wait_timeout=0)
        self._action_item = ComponentPiece(
            locator=self._ACTION_ITEM_LOCATOR,
            driver=driver,
            parent_locator_list=self._notifications.locator_list,
            description="The interactive piece of the Notification, used to reset or ignore the Notification.",
            wait_timeout=0
        )

    def any_notification_is_visible(self) -> bool:
        """
        Determine if any notification is currently visible.

        :returns: True, if any Notification is visible - False otherwise.
        """
        try:
            return self.get_count_of_current_notifications() > 0
        except TimeoutException:
            return False

    def close_any_notifications(self) -> None:
        """
        Close all displayed notifications.

        :raises TimeoutException: If any of the Notifications does not allow for dismissal.
        """
        while self.any_notification_is_visible():
            self._close_icon.click(binding_wait_time=1)  # glorified sleep

    def get_count_of_current_notifications(self) -> int:
        """
        Obtain a count of how many Notifications are currently displayed.

        :returns: A count of the number of displayed Notifications.
        """
        try:
            return len(self._notifications.find_all(wait_timeout=0))
        except TimeoutException:
            return 0

    def get_details(self, notification_index: int = 0) -> str:
        """
        Obtain the full details of the Notification.

        :param notification_index: The zero-based index of the Notification to target.

        :returns: The in-depth details portion of the Notification.

        :raises IndexError: If the supplied index is invalid based on the number of displayed notifications.
        """
        return self._details.find_all(wait_timeout=0)[notification_index].text

    def get_details_color(self, notification_index: int = 0) -> str:
        """
        Obtain the color in use for the details of the Notification. Note that some browsers may return the color in
        different formats (RGB vs hex).

        :param notification_index: The zero-based index of the Notification to target.

        :returns: The color of the details text, as a string.

        :raises IndexError: If the supplied index is invalid based on the number of displayed notifications.
        """
        return self._details.find_all(wait_timeout=0)[notification_index].value_of_css_property(CSS.COLOR.value)

    def get_icon_color(self, notification_index: int = 0) -> str:
        """
        Obtain the color in use for the fill of the icon of the Notification. Note that some browsers may return the
        color in different formats (RGB vs hex).

        :param notification_index: The zero-based index of the Notification to target.

        :returns: The color (fill) of the icon, as a string.

        :raises IndexError: If the supplied index is invalid based on the number of displayed notifications.
        """
        return self._icon.find_all(wait_timeout=0)[notification_index].value_of_css_property(CSS.FILL.value)

    def get_icon_id(self, notification_index: int = 0) -> str:
        """
        Obtain the id (name) of the icon in use for the Notification.

        :param notification_index: The zero-based index of the Notification to target.

        :returns: The id in use by the svg (icon) of the Notification.

        :raises IndexError: If the supplied index is invalid based on the number of displayed notifications.
        """
        return self._icon.find_all(
            wait_timeout=0)[notification_index].find_element(By.TAG_NAME, "symbol").get_attribute("id")

    def get_summary(self, notification_index: int = 0) -> str:
        """
        Obtain the summary of the Notification.

        :param notification_index: The zero-based index of the Notification to target.

        :returns: The summary (short description) of the Notification.

        :raises IndexError: If the supplied index is invalid based on the number of displayed notifications.
        """
        return self._summary.find_all(wait_timeout=0)[notification_index].text

    def get_summary_color(self, notification_index: int = 0) -> str:
        """
        Obtain the color in use for the summary of the Notification. Note that some browsers may return the color in
        different formats (RGB vs hex).

        :param notification_index: The zero-based index of the Notification to target.

        :returns: The color of the summary text, as a string.

        :raises IndexError: If the supplied index is invalid based on the number of displayed notifications.
        """
        return self._summary.find_all(wait_timeout=0)[notification_index].value_of_css_property(CSS.COLOR.value)

    def click_notification_action(self, notification_index: int = 0) -> None:
        """
        Click the action item of the Notification.

        :param notification_index: The zero-based index of the Notification to target.

        :raises IndexError: If the supplied index is invalid based on the number of displayed notifications.
        """
        self._notifications.find_all(
            wait_timeout=0)[notification_index].find_element(*self._ACTION_ITEM_LOCATOR).click()

Helpers/PerspectivePages/NotificationHelper.py
inductiveautomation/ignition-automation-tools
from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Helpers.CSSEnumerations import CSS
from Helpers.IAAssert import IAAssert


class PopupHelper:
    """
    This helper is intended to help with Popups in a generalized capacity. It does not work for interacting with
    or determining the state of specific Popups.
    """
    _CLOSE_ICON_LOCATOR = (By.CSS_SELECTOR, "svg.close-icon")
    _GENERIC_POPUP_LOCATOR = (By.CSS_SELECTOR, "div.popup")
    _MODAL_LOCATOR = (By.CSS_SELECTOR, "div.modal")

    def __init__(self, driver: WebDriver):
        self.driver = driver
        self._generic_popup = ComponentPiece(
            locator=self._GENERIC_POPUP_LOCATOR,
            driver=driver,
            description="A generic definition of a Popup.",
            wait_timeout=0)  # no parent
        self._modal = ComponentPiece(
            locator=self._MODAL_LOCATOR, driver=driver, parent_locator_list=None, wait_timeout=0)

    def close_all_popups(self) -> None:
        """
        Make an attempt to close all open Popups. This approach requires that each open Popup be configured to display
        a close icon.

        :raises NoSuchElementException: If any of the open Popups does not have a close icon.
        """
        # find all popups
        try:
            list_of_popups = self._generic_popup.find_all(wait_timeout=1)
            # now sort them so the "highest" is first.
            popups_sorted_by_z_index = sorted(
                list_of_popups, key=lambda e: e.value_of_css_property(CSS.Z_INDEX.value), reverse=True)
            # click the close icon for each popup
            [popup.find_element(*self._CLOSE_ICON_LOCATOR).click() for popup in popups_sorted_by_z_index]
        except NoSuchElementException as nsee:
            raise NoSuchElementException(msg="Unable to close a Popup because it had no close icon present.") from nsee
        except TimeoutException:
            pass
        IAAssert.is_equal_to(
            actual_value=self.get_count_of_all_open_popups(),
            expected_value=0,
            failure_msg="We failed to close all open Popups.")

    def get_count_of_all_open_popups(self) -> int:
        """
        Obtain a count of how many Popups are currently displayed.

        :returns: A count of how many Popups are currently displayed.
        """
        try:
            return len(self._generic_popup.find_all(wait_timeout=0))
        except TimeoutException:
            return 0

    def overlay_is_present(self) -> bool:
        """
        Determine if an overlay is present.

        :returns: True, if any Popup is displaying as a modal - False otherwise.
        """
        try:
            return self._modal.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

Helpers/PerspectivePages/PopupHelper.py
inductiveautomation/ignition-automation-tools
from enum import Enum


class PerspectiveQuality(Enum):
    """
    These "qualities" are representative of Perspective's handling of Ignition Quality Codes, and differ slightly
    from the normal wording (ie: "unknown" vs "uncertain"). These qualities are intended only to be used within the
    context of Perspective Pages, in order to assist with areas which must address or reference Qualities in
    Perspective or the Quality Overlays of Perspective Components.
    """
    ERROR = 'error'
    UNKNOWN = 'unknown'
    PENDING = 'pending'

Helpers/PerspectivePages/Quality.py
inductiveautomation/ignition-automation-tools
from typing import List

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Helpers.IAAssert import IAAssert
from Helpers.PerspectivePages.Quality import PerspectiveQuality


class QualityOverlayHelper:
    """
    An assistant for examining Quality Overlays and interacting with any popovers. As popover elements do not
    actually refer to their originating component, there is a chance that an open popover might not "belong" to a
    component under test, and so it is the responsibility of the test to verify no other popovers are open before
    opening a popover for a given component.

    Important terminology:

    overlay: The encompassing border of a Perspective component which denotes a non-good Quality.
    popover: The available informational window opened by clicking the icon within the overlay.
    """
    _QUALITY_FORCED_OVERLAY_PREFIX = 'cqfo'
    _MICRO_CONTAINER_LOCATOR = (By.CSS_SELECTOR, 'div.micro-icon-container')
    _BACK_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.popover-back-button')
    _NEXT_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.popover-next-button')
    _CARD_COUNT_LOCATOR = (By.CSS_SELECTOR, 'div.popover-card-count')
    _POPOVER_LOCATOR = (By.CSS_SELECTOR, 'div.component-popover')
    _POPOVER_ICON_LOCATOR = (By.CSS_SELECTOR, 'div.popover-icon')
    _BODY_SECTION_LOCATOR = (By.CSS_SELECTOR, 'div.popover-body-section')
    _BODY_DESCRIPTOR_LOCATOR = (By.CSS_SELECTOR, 'h6.body-descriptor')
    _BODY_CONTENT_LOCATOR = (By.CSS_SELECTOR, 'div.body-content')

    def __init__(self, driver: WebDriver):
        self.driver = driver
        self._popover = ComponentPiece(
            locator=self._POPOVER_LOCATOR, driver=driver, parent_locator_list=None, wait_timeout=0)
        self._popover_icon = ComponentPiece(
            locator=self._POPOVER_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._popover.locator_list,
            wait_timeout=0,
            description="The icon in the upper-right of the popover which allows for direct dismissal.")
        self._popover_back_button = ComponentPiece(
            locator=self._BACK_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._popover.locator_list,
            wait_timeout=0,
            description="The '<' button/chevron in the bottom of the popover, available only when a component has "
                        "multiple faulted bindings.")
        self._popover_next_button = ComponentPiece(
            locator=self._NEXT_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._popover.locator_list,
            wait_timeout=0,
            description="The '>' button/chevron in the bottom of the popover, available only when a component has "
                        "multiple faulted bindings.")
        self._card_count = ComponentPiece(
            locator=self._CARD_COUNT_LOCATOR,
            driver=driver,
            parent_locator_list=self._popover.locator_list,
            wait_timeout=0,
            description="The piece of popover UI which describes how many faulted bindings a component has.")
        self._body_section = ComponentPiece(
            locator=self._BODY_SECTION_LOCATOR,
            driver=driver,
            parent_locator_list=self._popover.locator_list,
            wait_timeout=0,
            description="The area between the header and footer of the popover.")
        self._body_descriptor = ComponentPiece(
            locator=self._BODY_DESCRIPTOR_LOCATOR,
            driver=driver,
            parent_locator_list=self._body_section.locator_list,
            wait_timeout=0,
            description="The label which SHOULD read as 'Subcode'.")
        self._body_content = ComponentPiece(
            locator=self._BODY_CONTENT_LOCATOR,
            driver=driver,
            parent_locator_list=self._body_section.locator_list,
            wait_timeout=0,
            description="The string designation of the subcode conveyed by a popover.")

    def any_popover_is_displayed(self) -> bool:
        """
        Determine if any popover is currently displayed.

        :returns: True, if any Quality popover is currently displayed - False otherwise.
        """
        try:
            return self._popover.find() is not None
        except TimeoutException:
            return False

    def click_back_in_popover_footer(self) -> None:
        """
        Click the '<' button/chevron in the footer of the Quality popover.
        """
        self.hover_over_icon_in_popover_header()
        self._popover_back_button.click()

    def click_next_in_popover_footer(self) -> None:
        """
        Click the '>' button/chevron in the footer of the Quality popover.
        """
        self.hover_over_icon_in_popover_header()
        self._popover_next_button.click()

    def close_all_popovers(self) -> None:
        """
        Close all Quality popovers currently open in the page.

        :raises AssertionError: If unsuccessful in closing all open Quality popovers.
        """
        elements = self.driver.find_elements(By.CSS_SELECTOR, 'svg.quality-icon')
        for element in reversed(elements):
            element.click()
        IAAssert.is_equal_to(
            actual_value=self.get_count_of_all_popovers(),
            expected_value=0,
            failure_msg="Failed to close all Quality Overlay popovers.")

    def get_count_of_all_popovers(self) -> int:
        """
        Obtain a count of all Quality popovers which are currently open in the page.

        :returns: A count of Quality popovers currently displayed to the user.
        """
        try:
            return len(self._popover.find_all())
        except TimeoutException:
            # might not be necessary
            return 0

    def get_count_of_overlays(self, quality: PerspectiveQuality) -> int:
        """
        Obtain a count of Quality overlays currently displayed to the user which convey the specified quality type.

        :returns: A count of Quality overlays currently displayed with the specified quality.
        """
        return len(self.driver.find_elements(
            By.CSS_SELECTOR, f'div.{self._QUALITY_FORCED_OVERLAY_PREFIX}-{quality.value}'))

    def get_count_of_all_overlays(self) -> int:
        """
        Obtain a count of all Quality overlays - regardless of quality.

        :returns: A count of all Quality overlays - regardless of their quality.
        """
        return self.get_count_of_overlays(quality=PerspectiveQuality.ERROR) \
            + self.get_count_of_overlays(quality=PerspectiveQuality.UNKNOWN) \
            + self.get_count_of_overlays(quality=PerspectiveQuality.PENDING)

    def get_count_of_quality_concerns_from_popover(self) -> int:
        """
        Obtain a count of Quality concerns from the first found Quality popover. Any value larger than 1 suggests
        that the component has multiple faulted bindings.

        :returns: A count of faulted bindings conveyed within the first Quality popover.
        """
        try:
            return int(self._card_count.get_text().split('/')[-1])
        except TimeoutException:
            try:
                return 1 if self._popover.find() is not None else 0
                # popover open, but no footer/count present
            except TimeoutException as toe:
                raise TimeoutException(
                    msg="Unable to discern a count of quality concerns from the popover as no popover "
                        "was displayed.") from toe

    def get_list_of_ids_with_overlays(self) -> List[str]:
        """
        Obtain a list of the HTML id attributes of any elements which have any overlay present. If the HTML element
        does not have an id, the element will not be represented in the list.

        :returns: A list of strings which are the HTML ids of any elements which have any type of overlay If those
            components have an id attribute.
        """
        id_list = []
        for el in self.driver.find_elements(By.CSS_SELECTOR, f"div.{self._QUALITY_FORCED_OVERLAY_PREFIX}"):
            potential_id = el.get_attribute('id')
            if (potential_id is not None) and (len(potential_id) > 0):
                id_list.append(potential_id)
        return id_list

    def hover_over_icon_in_popover_header(self) -> None:
        """
        Popovers do not declare what component they belong to, so if you are seeing information
        which seems incorrect, make sure only one popover is open and that it was triggered by
        the expected component.
        """
        self._popover_icon.hover()

    def get_sub_code_text_from_open_popover(self) -> str:
        """
        Obtain the subcode text from the first open popover. In the event a component has multiple faulted bindings,
        error subcodes take priority over uncertain (unknown) subcodes, which take priority over good subcodes.

        :returns: The string designation of the subcode which describes the quality of the most severe faulted binding.

        :raises AssertionError: If an unexpected popover structure is encountered.
        """
        IAAssert.is_equal_to(
            actual_value=self._body_descriptor.get_text(),
            expected_value='Subcode',
            failure_msg="The popover has an unexpected structure; we expected to find a subcode, but we found "
                        "something else")
        return self._body_content.get_text()

Helpers/PerspectivePages/QualityOverlayHelper.py
inductiveautomation/ignition-automation-tools
class Point:
    """
    A standard two-dimensional point within the DOM, consisting of both an X and Y coordinate. This location is
    understood to be pixels measured from the top-left corner of the Viewport of the browser.
    """

    def __init__(self, x: float, y: float):
        self.X = x
        self.Y = y
        self._current_index = 0
        self._items = [x, y]

    def __getitem__(self, index):
        try:
            return self._items[index]
        except IndexError:
            raise IndexError("Points only have two coordinates; supply an index in this range: -2<=N<=1.")

    def __iter__(self):
        return self

    def __next__(self):
        if self._current_index < 2:
            coordinate_value = self._items[self._current_index]
            self._current_index += 1
            return coordinate_value
        raise StopIteration

Helpers/Point.py
inductiveautomation/ignition-automation-tools
import logging
from time import sleep
from typing import Tuple, Union

from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait

from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Pages.PagePiece import PagePiece


class BasicPageObject(PagePiece):
    """
    This Page Object may be used to interact with any page outside of Ignition.
    """

    def __init__(
            self,
            driver: WebDriver,
            url: str,
            wait_timeout: float = 10,
            primary_locator: Tuple[Union[By, str], str] = None):
        """
        :param driver: The WebDriver in use for the browser window.
        :param url: The URL of this page.
        :param wait_timeout: The amount of time (in seconds) to implicitly wait.
        :param primary_locator: A Tuple which describes how to locate a unique element on the page, if there is one.
        """
        PagePiece.__init__(
            self,
            driver=driver,
            wait_timeout=wait_timeout,
            primary_locator=primary_locator)
        self.url = url
        self._navigation_string = f'Navigated to {self.url}'

    def navigate_to(self, force: bool = False) -> None:
        """
        Navigate to this page in the browser.

        :param force: Dictates whether the navigation should happen even if you are already on the page. Potentially
            forces a pseudo-refresh.
        """
        self._wait_on_driver()
        if force or not self.is_current_page():
            try:
                for i in range(3):
                    self.driver.get(self.url)
                    if self.is_current_page():
                        break
            except WebDriverException as wde:
                logging.error(f'Encountered a WebDriverException while attempting to navigate to {self.url} .\n'
                              f'The error was ignored and we are going to continue.\n'
                              f'{wde.msg}')
            self._log_navigation()
            self._wait_for_page()

    def get_page_url(self) -> str:
        """
        Obtain the configured URL of this page. To obtain the URL currently in use by the browser, use self.driver.url.

        :return: The URL configured for this Page resource.
        """
        return self.url

    def is_current_page(self) -> bool:
        """
        Determine if this Page is the page currently displayed in your browser tab.

        :return: True if the PageObject's URL matches the current URL, without any parameters. If the
            Page being checked has also specified a primary locator, this value also implies that the locator
            is present.
        """
        try:
            return WebDriverWait(driver=self.driver, timeout=3).until(IAec.function_returns_true(
                custom_function=self._is_current_page,
                function_args={}))
        except TimeoutException:
            return False

    def _is_current_page(self) -> bool:
        """
        Determine if the URL configured for this page resource matches the URL in use in the browser, sans any URL
            parameters.

        :return: True, if the PageObject's URL matches the current URL, without any parameters - False otherwise. If the
            Page being checked has also specified a primary locator, this value also implies that the locator
            is present.
        """
        # Home pages may or may not have a trailing slash, so naively strip any trailing slash
        page_url = self.url.rstrip('/')
        displayed_url = self.driver.current_url.split('?')[0].rstrip('/')
        # if no primary locator is supplied, naively assume that portion as succeeding
        primary_locator_check = self.primary_locator is None
        try:
            # if a primary locator WAS supplied, we must check it is present
            if self.primary_locator is not None:
                primary_locator_check = self.driver.find_element(*self.primary_locator) is not None
        except NoSuchElementException:
            primary_locator_check = False
        except WebDriverException:
            primary_locator_check = False
        return (page_url == displayed_url) and primary_locator_check

    def _log_navigation(self) -> None:
        self.py_logger.info(msg=self._navigation_string)

    def _wait_for_page(self) -> None:
        """
        It's the responsibility of the inheriting class to Override this method if a better approach is available.
        """
        if self.primary_locator is not None:
            try:
                self.wait.until(ec.presence_of_element_located(self.primary_locator))
            except TimeoutException:
                pass
            except WebDriverException:
                pass
        else:
            msg = f'This class ({self.__class__.__name__}) is using the default \'_wait_for_page\' ' \
                  f'method in {BasicPageObject.__name__}.\n' \
                  f'Check to see if there is a better way to wait for the page to load.'
            logging.warning(msg)
            sleep(3)  # hard-coded wait to be used when no primary locator is supplied.

    def _wait_on_driver(self, time_to_wait=3) -> None:
        """
        Wait for the webdriver to know about itself.

        :raise TimeoutException: If the driver takes too long to become usable.
        """
        try:
            WebDriverWait(self.driver, time_to_wait).until(IAec.driver_is_ready(driver=self.driver))
        except TimeoutException as toe:
            raise TimeoutException(msg="The driver did not respond in a timely manner.") from toe

Pages/BasicPageObject.py
inductiveautomation/ignition-automation-tools
from typing import Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Pages.BasicPageObject import BasicPageObject


class IgnitionPageObject(BasicPageObject):
    """
    The Ignition Page Object is intended to encompass the final pieces shared among all Pages which would belong to an
    Ignition instance.
    """

    def __init__(
            self,
            driver: WebDriver,
            gateway_address: str,
            destination_path: str,
            wait_timeout: float = 10,
            primary_locator: Tuple[By, str] = None):
        """
        :param driver: The WebDriver being used to control the browser.
        :param gateway_address: The address (including port) of your Gateway. Expects scheme, all domains, and port.
        :param destination_path: The "subdirectory" and "path" pieces of the URL of this page resource, including a
            leading slash - eg: "/path/to/my/page"
        :param wait_timeout: The amount of time (in seconds) this page resource should implicitly wait when needed.
        :param primary_locator: A locator which describes an HTML element unique to this page.
        """
        self._PATH = destination_path
        self.url = gateway_address + destination_path
        BasicPageObject.__init__(
            self, driver=driver, url=self.url, wait_timeout=wait_timeout, primary_locator=primary_locator)

Pages/IgnitionPageObject.py
inductiveautomation/ignition-automation-tools
import logging
from typing import Tuple, Union

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait

from Helpers.IASelenium import IASelenium


class PagePiece(object):
    """
    A PagePiece is the smallest, most basic concept of any browser page. While pages in general are free to specify
    any number of unique locators/Component/WebElements, Page Pieces are understood to build out collections of shared
    structure which may be re-used across various scopes. Even Pages may be considered "Page Pieces" as they are a
    collection of shared structures or pieces which are re-used.
    """

    def __init__(
            self,
            driver: WebDriver,
            wait_timeout: float = 10,
            primary_locator: Tuple[Union[By, str], str] = None):
        """
        :param driver: The WebDriver instance which is controlling the browser.
        :param wait_timeout: The amount of time this page should implicitly wait when using functions which would wait.
        :param primary_locator: The locator which defines a known unique element within this content.
        """
        self.driver = driver
        self._wait_timeout = wait_timeout
        self.wait = WebDriverWait(self.driver, self._wait_timeout)
        self.primary_locator = primary_locator
        self.selenium = IASelenium(driver=self.driver)
        self.py_logger = logging.getLogger(self.__class__.__name__)

    def is_present(self) -> bool:
        """
        Determine if this Page Piece is currently located within the Document Object Model (DOM).

        :return: True, if the Page Piece could be located - False otherwise.

        :raises AssertionError: If no primary locator was supplied for the Page Piece. A locator is required for
            checking for the presence of the Page Piece.
        """
        assert self.primary_locator is not None, "Unable to check presence of view without a primary locator."
        return self.driver.find_element(*self.primary_locator) is not None

Pages/PagePiece.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from time import sleep
from typing import Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.wait import WebDriverWait

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.CSSEnumerations import CSSPropertyValue
from Helpers.Filtering import Items
from Helpers.IAAssert import IAAssert
from Helpers.IAExpectedConditions import IAExpectedConditions as IAec
from Pages.PagePiece import PagePiece


class AppBar(PagePiece):
    """
    Important terminology:
    App Bar: The thin piece of UI which spans the bottom of the session when expanded.
    Revealer: The tiny piece of UI - usually found in the bottom-right of the session - which expands the App Bar.
    Action Panel: The small panel which contains basic gateway information and the Sign in/out button.
    Session Status modal: The full-blown modal which contains information about the session and the project.
    About modal: The full-blown modal which may contain information about Ignition, or may be overridden with custom
        content.
    """

    class TogglePosition(Enum):
        LEFT = "left"
        RIGHT = "right"

    _ANIMATION_DELAY_IN_SECONDS = 1.5
    _ABOUT_BUTTON_LOCATOR = (By.CSS_SELECTOR, "div svg.about-icon")
    _ABOUT_BUTTON_LOCAL_SYMBOL_LOCATOR = (By.CSS_SELECTOR, "symbol")
    _GATEWAY_PANEL_LOCATOR = (By.ID, 'gateway-nav')
    _PROJECT_PANEL_LOCATOR = (By.ID, 'project-nav')
    _EXIT_PROJECT_LOCATOR = (By.CSS_SELECTOR, '.exit-project-icon')
    _USERNAME_LABEL_LOCATOR = (By.CSS_SELECTOR, 'div.username')
    _APP_BAR_WRAPPER_LOCATOR = (By.CSS_SELECTOR, 'div.app-bar')
    _ACTION_PANEL_TOGGLE_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.app-center')
    _ACTION_PANEL_LOCATOR = (By.CSS_SELECTOR, "div.action-panel")
    _ACTION_PANEL_GATEWAY_NAME_LOCATOR = (By.CSS_SELECTOR, "div.primary_message")
    _CONNECTION_SECURITY_LABEL_LOCATOR = (By.CSS_SELECTOR, "div.connection-security")
    _SESSION_STATUS_MODAL_GATEWAY_NAME_LOCATOR = (By.CSS_SELECTOR, "div.gateway-name")
    _TRIAL_TIMER_LOCATOR = (By.CSS_SELECTOR, "div.trial-clock")
    _GENERIC_TEXT_BUTTON_LOCATOR = (By.CSS_SELECTOR, "div.text-button")
    _ABOUT_IGNITION_ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.about-icon')
    _REVEALER_LOCATOR = (By.CSS_SELECTOR, "div.bar-reveal")
    _SHOW_ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.show-icon')
    _HIDE_ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.hide-icon')
    _TIMER_ICON_LOCATOR = (By.CSS_SELECTOR, 'svg.timer-icon')
    _CLOSE_ICON_LOCATOR = (By.CSS_SELECTOR, "svg.close-icon")
    _GENERIC_NOTIFICATION_ICON_LOCATOR = (By.CSS_SELECTOR, "div.notification-icons > svg")
    _TOOLTIP_LOCATOR = (By.CSS_SELECTOR, 'div.app-tooltip')
    _STATUS_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.status-button')
    _ABOUT_MODAL_AND_SESSION_STATUS_MODAL_LOCATOR = (By.CSS_SELECTOR, "div.app-modal")
    _ABOUT_MODAL_TITLE_LOCATOR = (By.CSS_SELECTOR, "div.modal-title")
    _GATEWAY_TAB_LOCATOR = (By.ID, "gateway-nav")
    _PROJECT_TAB_LOCATOR = (By.ID, "project-nav")
    _CONNECTION_STATUS_LABEL_LOCATOR = (
        By.CSS_SELECTOR, 'div.modal-connection-status div.gateway-status-message div.message-text')
    _DETAIL_DATA_CLASS = "detail-data"
    _GATEWAY_URL_LABEL_LOCATOR = (By.CSS_SELECTOR, f"div.gateway-url .{_DETAIL_DATA_CLASS}")
    _SESSION_ID_LABEL_LOCATOR = (By.CSS_SELECTOR, f"div.session-id .{_DETAIL_DATA_CLASS}")
    _PAGE_ID_LABEL_LOCATOR = (By.CSS_SELECTOR, f"div.page-id .{_DETAIL_DATA_CLASS}")
    _PROJECT_NAME_LABEL_LOCATOR = (By.CSS_SELECTOR, "div.project-page h4")
    _UPDATE_STATUS_LOCATOR = (By.CSS_SELECTOR, 'div.update-status')
    _LEARN_MORE_BUTTON_LOCATOR = (By.CSS_SELECTOR, "a.visit-site-link")
    _CUSTOM_VIEW_WRAPPER_LOCATOR = (By.CSS_SELECTOR, "div.custom-view-wrapper")
    _CUSTOM_VIEW_LOCATOR = (By.CSS_SELECTOR, "div.view")
    _SESSION_STATUS_MODAL_TITLE_TEXT = "Session Status"

    def __init__(self, driver: WebDriver):
        super().__init__(driver=driver, primary_locator=self._APP_BAR_WRAPPER_LOCATOR)
        self._animation_wait = WebDriverWait(driver=driver, timeout=self._ANIMATION_DELAY_IN_SECONDS)
        self._about_button = CommonIcon(
            locator=self._ABOUT_BUTTON_LOCATOR,
            driver=driver,
            description="The button at the extreme left of the expanded App Bar, which opens an info modal.",
            wait_timeout=0)
        self._about_button_local_symbol = ComponentPiece(
            locator=self._ABOUT_BUTTON_LOCAL_SYMBOL_LOCATOR,
            driver=driver,
            parent_locator_list=self._about_button.locator_list,
            description="The icon used within the 'About' button within the expanded App Bar.",
            wait_timeout=0)
        self._about_panel = ComponentPiece(
            locator=self._ABOUT_MODAL_AND_SESSION_STATUS_MODAL_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            description="The modal displayed which either displays info regarding Ignition, or a configured custom "
                        "View.",
            wait_timeout=0)
        self._about_modal_and_session_status_modal_title = ComponentPiece(
            locator=self._ABOUT_MODAL_TITLE_LOCATOR,
            driver=driver,
            parent_locator_list=self._about_panel.locator_list,
            description="The title of the 'About' modal.",
            wait_timeout=0)
        self._app_bar_wrapper = ComponentPiece(
            locator=self._APP_BAR_WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            description="The highest-level locator available to the App Bar.",
            wait_timeout=0)
        self._action_panel_toggle_button = ComponentPiece(
            locator=self._ACTION_PANEL_TOGGLE_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._app_bar_wrapper.locator_list,
            description="The button in the center of the App Bar which expands and collapses the Action panel.",
            wait_timeout=0)
        self._generic_app_bar_notification_icon = CommonIcon(
            locator=self._GENERIC_NOTIFICATION_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._action_panel_toggle_button.locator_list,
            description="A notification icon within the Action panel button (NOT the Revealer).",
            wait_timeout=0)
        self._action_panel = ComponentPiece(
            locator=self._ACTION_PANEL_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            description="The panel which appears above the App Bar when the central button is clicked.",
            wait_timeout=0)
        self._connection_security_label = ComponentPiece(
            locator=self._CONNECTION_SECURITY_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._action_panel.locator_list,
            description="The label which displays the connection security of the Session.",
            wait_timeout=0)
        self._status_button = ComponentPiece(
            locator=self._STATUS_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._action_panel.locator_list,
            description="The STATUS button within the Action panel.",
            wait_timeout=0)
        self._username_label = ComponentPiece(
            locator=self._USERNAME_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._action_panel.locator_list,
            description="The label in the Action panel which conveys which user is logged in (if any).",
            wait_timeout=0)
        self._revealer = ComponentPiece(
            locator=self._REVEALER_LOCATOR,
            driver=driver,
            parent_locator_list=self._app_bar_wrapper.locator_list,
            description="The small piece of UI in the session (usually bottom-right) which allows for expanding the "
                        "App Bar.",
            wait_timeout=0)
        self._expand_icon = CommonIcon(
            locator=self._SHOW_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._revealer.locator_list,
            description="The expand icon, which is only present while the App Bar is collapsed.",
            wait_timeout=0)
        self._collapse_icon = CommonIcon(
            locator=self._HIDE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._app_bar_wrapper.locator_list,  # Belongs to the App Bar, NOT the Revealer!
            description="The collapse icon, which is only present while the App Bar is expanded.",
            wait_timeout=0)
        self._timer_icon = CommonIcon(
            locator=self._TIMER_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._revealer.locator_list,
            description="The timer icon, which is only present while using Perspective in Trial mode.",
            wait_timeout=0)
        self._exit_project_icon = CommonIcon(
            locator=self._EXIT_PROJECT_LOCATOR,
            driver=driver,
            parent_locator_list=self._app_bar_wrapper.locator_list,
            description="The Exit Project icon, found only in workstation.",
            wait_timeout=0)
        self._generic_revealer_notification_icon = CommonIcon(
            locator=self._GENERIC_NOTIFICATION_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._revealer.locator_list,
            description="A notification icon within the Revealer (NOT the Action panel button).",
            wait_timeout=0)
        self._session_status_modal = ComponentPiece(
            locator=self._ABOUT_MODAL_AND_SESSION_STATUS_MODAL_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            description="The Session Status modal, which houses information about the Session and Gateway.",
            wait_timeout=0)
        self._gateway_tab = ComponentPiece(
            locator=self._GATEWAY_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The GATEWAY tab in the Session Status modal.",
            wait_timeout=0)
        self._project_tab = ComponentPiece(
            locator=self._PROJECT_TAB_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The PROJECT tab in the Session Status modal.",
            wait_timeout=0)
        self._gateway_name = ComponentPiece(
            locator=self._SESSION_STATUS_MODAL_GATEWAY_NAME_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The label which displays the name of the Gateway.",
            wait_timeout=0)
        self._connection_status_label = ComponentPiece(
            locator=self._CONNECTION_STATUS_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The label which houses the connection status in the Session Status modal.",
            wait_timeout=0)
        self._session_status_panel_close_icon = CommonIcon(
            locator=self._CLOSE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The 'X' in the upper-right of the Session Status modal.",
            wait_timeout=0)
        self._gateway_url_label = ComponentPiece(
            locator=self._GATEWAY_URL_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The label which houses the Gateway URL information in the Session Status modal.",
            wait_timeout=0)
        self._session_id_label = ComponentPiece(
            locator=self._SESSION_ID_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The label which houses the Session ID information in the Session Status modal.",
            wait_timeout=0)
        self._page_id_label = ComponentPiece(
            locator=self._PAGE_ID_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The label which houses the Page ID information in the Session Status modal.",
            wait_timeout=0)
        self._project_name_label = ComponentPiece(
            locator=self._PROJECT_NAME_LABEL_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The label which contains the project name at the top of the Project tab.",
            wait_timeout=0)
        self._up_to_date_status_label = ComponentPiece(
            locator=self._UPDATE_STATUS_LOCATOR,
            driver=driver,
            parent_locator_list=self._session_status_modal.locator_list,
            description="The label which describes whether a project is up-to-date with the Gateway.",
            wait_timeout=0)
        self._tooltip = ComponentPiece(
            locator=self._TOOLTIP_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            description="the tooltip which is only visible when hovering over the expand/collapse icon of the "
                        "Revealer.",
            wait_timeout=1)  # this piece does need a wait period as it takes a moment to appear after the hover event.
        self._generic_text_button = ComponentPiece(
            locator=self._GENERIC_TEXT_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            description="A 'button' (<div>) within the greater Action panel.",
            wait_timeout=0)
        self._about_panel_close_icon = CommonIcon(
            locator=self._CLOSE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            description="The 'X' in the upper-right of the 'About' modal.",
            wait_timeout=0)
        self._learn_more_button = ComponentPiece(
            locator=self._LEARN_MORE_BUTTON_LOCATOR,
            driver=driver,
            parent_locator_list=self._about_panel.locator_list,
            description="The button within the 'About' modal which has text of 'LEARN MORE ABOUT IGNITION'.",
            wait_timeout=1)
        self._view_wrapper = ComponentPiece(
            locator=self._CUSTOM_VIEW_WRAPPER_LOCATOR,
            driver=driver,
            parent_locator_list=self._about_panel.locator_list,
            wait_timeout=1)
        self._generic_custom_view_top_level = ComponentPiece(
            locator=self._CUSTOM_VIEW_LOCATOR,
            driver=driver,
            parent_locator_list=self._about_panel.locator_list,
            wait_timeout=1)

    def about_modal_is_displayed(self) -> bool:
        """
        Determine if the 'About' panel is displayed.

        :returns: True, if the 'About Ignition' panel is displayed - False otherwise.
        """
        try:
            return self._about_panel.find(wait_timeout=0).is_displayed() \
                and "open" in self._about_panel.find().get_attribute("class") \
                and self._about_modal_and_session_status_modal_title.get_text() != self._SESSION_STATUS_MODAL_TITLE_TEXT
        except TimeoutException:
            return False

    def action_panel_is_expanded(self) -> bool:
        """
        Determine if the Action Panel is currently displayed.

        :returns: True, if the Action Panel is currently displayed - False otherwise.
        """
        try:
            return self._action_panel.find(wait_timeout=0).is_displayed() and \
                self._action_panel.get_termination().Y <= self._app_bar_wrapper.get_origin().Y
        except TimeoutException:
            return False

    def click_about_button(self) -> None:
        """
        Click the About button located in the App Bar.
        """
        self._expand_app_bar_if_not_expanded()
        # some tests check button appearance after click, so let the button have time to change color
        self._about_button.click(binding_wait_time=1)

    def click_sign_in_button(self) -> None:
        """
        Click the "Sign In" button.

        :raises IndexError: When user is already logged in, or if the project does not have a configured Identity
            Provider.
        """
        self._expand_action_panel_if_not_expanded()
        Items(
            items=self._generic_text_button.find_all(
                wait_timeout=1)).filter(
            lambda e: e.text == 'SIGN IN')[0].click()

    def click_sign_out_button(self) -> None:
        """
        Click the "Sign Out" button.

        :raises IndexError: When no user is logged in
        """
        self._expand_action_panel_if_not_expanded()
        Items(
            items=self._generic_text_button.find_all(
                wait_timeout=1)).filter(
            lambda e: e.text == 'SIGN OUT')[0].click()

    def click_status_button(self) -> None:
        """
        Click the Status Button within the App Bar.
        """
        self._expand_action_panel_if_not_expanded()
        self._status_button.click()

    def click_visit_gateway(self) -> None:
        """
        Click the Visit Gateway option in the Session Status modal. This will open a new tab in the browser.
        """
        self._open_session_status_modal_if_not_open()
        self._get_generic_text_button(button_text="VISIT GATEWAY").click()

    def close_about_modal_via_button(self) -> None:
        """
        Close the 'About' modal by clicking the 'About' button which originally opened the modal.

        :raises AssertionError: If the 'About' modal remains open.
        """
        if self.about_modal_is_displayed():
            self._click_about_button()
            self._wait_for_animations_to_cease()
        IAAssert.is_not_true(
            value=self.about_modal_is_displayed(),
            failure_msg="Failed to close the 'About' modal.")

    def close_about_modal_via_close_icon(self) -> None:
        """
        Close the 'About' modal by clicking the 'X' icon in the upper-right corner.

        :raises AssertionError: If the 'About' modal remains open.
        """
        if self.about_modal_is_displayed():
            self._about_panel_close_icon.click()
            self._wait_for_animations_to_cease()
        IAAssert.is_not_true(
            value=self.about_modal_is_displayed(),
            failure_msg="Failed to close the 'About' modal.")

    def close_session_status_modal(self) -> None:
        """
        Close the Session Status modal, if it is open.

        :raises AssertionError: If the Session Status modal remains open.
        """
        self._close_session_status_modal_if_open()
        IAAssert.is_not_true(
            value=self.session_status_modal_is_open(),
            failure_msg="Failed to close the Session Status modal.")

    def collapse_action_panel(self) -> None:
        """
        Collapse the Action Panel if it is open.

        :raises AssertionError: If the Action panel remains open.
        """
        if self.action_panel_is_expanded():
            self._action_panel_toggle_button.click()
            try:
                self._wait_for_action_panel_to_not_be_visible()
            except TimeoutException:
                # allow the following AssertionError to be raised
                pass
        IAAssert.is_not_true(
            value=self.action_panel_is_expanded(),
            failure_msg="Failed to close the Action panel.")

    def collapse(self) -> None:
        """
        Collapse every piece of the App Bar, until only the Revealer is present.

        :raises AssertionError: If unsuccessful in closing or collapsing every piece of the App Bar.
        """
        self.close_session_status_modal()
        self.close_about_modal_via_button()
        self.collapse_action_panel()
        if self.is_expanded():
            self._collapse_icon.click()
            self._wait_for_app_bar_to_be_fully_collapsed()
        IAAssert.is_not_true(
            value=self.is_expanded(),
            failure_msg="Failed to collapse the App Bar.")

    def exit_project(self) -> None:
        """
        Exit the Workstation Session. Unavailable outside of Workstation sessions.

        :raises TimeoutException: If used outside of Workstation.
        """
        self.expand()
        self._exit_project_icon.click()  # only available to Workstation

    def expand_action_panel(self) -> None:
        """
        Expand the Action panel.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_action_panel_if_not_expanded()

    def expand(self) -> None:
        """
        Expand the App Bar.

        :raises AssertionError: If unsuccessful in expanding the App Bar.
        """
        self._expand_app_bar_if_not_expanded()
        IAAssert.is_true(
            value=self.is_expanded(),
            failure_msg="Failed to expand the App Bar.")

    def get_about_modal_title(self) -> Optional[str]:
        """
        Obtain the title text of the 'About' modal.

        :returns: The text of the title of the 'About' modal, or None if no text is configured to appear.

        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        self._open_about_modal_if_not_open()
        self._wait_for_about_modal_to_be_displayed()  # This piece outside of TOE so that it can raise its own TOE.
        try:
            return self._about_modal_and_session_status_modal_title.get_text()
        except TimeoutException:
            return None

    def get_tooltip_text(self) -> str:
        """
        Obtain the text of the displayed tooltip after hovering over the Revealer.

        :returns: The text of the tooltip.
        """
        self.hover_over_revealer()
        return self._tooltip.get_text()

    def get_connection_security(self) -> str:
        """
        Obtain the connection security status of the Session.

        :returns: The connection security of the Session, along the lines of "Insecure Connection".

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_action_panel_if_not_expanded()
        return self._connection_security_label.get_text()

    def get_connection_status(self) -> str:
        """
        Obtain the current connection status of the Session.

        :returns: The connection status of the Session, along the lines of "CONNECTED".

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._select_gateway_tab()
        return self._connection_status_label.get_text()

    def get_count_of_notification_icons_in_app_bar(self) -> int:
        """
        Obtain a count of notification icons displayed within the App Bar.

        :returns: The number of notification icons found in the App Bar.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_app_bar_if_not_expanded()  # This piece outside of TOE so that it can raise its own TOE.
        try:
            return len(self._generic_app_bar_notification_icon.find_all())
        except TimeoutException:
            return 0

    def get_count_of_notification_icons_in_revealer(self) -> int:
        """
        Obtain a count of notification icons displayed within the Revealer. Requires the Revealer be displayed, so
        we force a collapse of any open App Bar content first.

        :returns: The number of notification icons found in the Revealer.

        :raises AssertionError: If unsuccessful in collapsing the App Bar.
        """
        self.collapse()
        try:
            return len(self._generic_revealer_notification_icon.find_all())
        except TimeoutException:
            return 0

    def get_css_property_of_about_button(self, property_name: CSSPropertyValue) -> str:
        """
        Obtain any CSS property value of the 'About' button.

        :param property_name: The name of the CSS property you want information about.

        :returns: The underlying CSS property value of the About button.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_app_bar_if_not_expanded()
        return self._about_button.get_css_property(property_name=property_name)

    def get_custom_view_height(self, include_units: bool = False) -> str:
        """
        Obtain the height of the custom View in use within the 'About' modal.

        :param include_units: Dictates whether the returned height contains units of measurement.

        :returns: The height of the custom View as a string, potentially with units.

        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        self._open_about_modal_if_not_open()
        return self._generic_custom_view_top_level.get_computed_height(include_units=include_units)

    def get_custom_view_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of the custom View in use within the 'About' modal.

        :param include_units: Dictates whether the returned width contains units of measurement.

        :returns: The width of the custom View as a string, potentially with units.

        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        self._open_about_modal_if_not_open()
        return self._generic_custom_view_top_level.get_computed_width(include_units=include_units)

    def get_displayed_gateway_url(self) -> str:
        """
        Obtain the URL of the Gateway.

        :returns: The URL in use for the Gateway this Session is connected to.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._select_gateway_tab()
        return self._gateway_url_label.get_text()

    def get_displayed_project_name(self) -> str:
        """
        Obtain the name in use for the Project this Session is currently accessing.

        :returns: The name of the project this Page belongs to.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._select_project_tab()
        return self._project_name_label.get_text()

    def get_gateway_name(self) -> str:
        """
        Obtain the name of the Gateway this Project belongs to.

        :returns: The configured name of the Gateway.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._select_gateway_tab()
        return self._gateway_name.get_text()

    def get_icon_path_of_about_button(self) -> str:
        """
        Obtain the path of the icon in use for the 'About' button.

        :returns: The path in use by the <svg> of the 'About' button.

        :raises AssertionError: If unsuccessful in expanding the App Bar.
        """
        self._expand_app_bar_if_not_expanded()
        try:
            # Not using the standard icon structure because it isn't technically a Perspective component
            return self._about_button_local_symbol.find(wait_timeout=0).get_attribute("id")
        except TimeoutException:
            return self._about_button.get_icon_name()

    def get_logged_in_user(self) -> Optional[str]:
        """
        Obtain the name of the user currently logged in to this Session.

        :returns: The name of the user currently logged in, or None if no user is logged in.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_action_panel_if_not_expanded()
        try:
            username = self._username_label.get_text()
            return username if username != "" else None
        except TimeoutException:
            return None

    def get_page_id(self) -> str:
        """
        Obtain the Page ID in use for this Page of the Session.

        :returns: The Perspective Page ID in use for this Page.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._open_session_status_modal_if_not_open()
        return self._page_id_label.get_text()

    def get_project_status(self) -> str:
        """
        Obtain the status of this project.

        :returns: The status of this project, along the lines of "Project Up to Date".

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._select_project_tab()
        return self._up_to_date_status_label.get_text()

    def get_session_id(self) -> str:
        """
        Obtain the ID in use by this Session.

        :returns: The Perspective Session ID in use by this Session.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._open_session_status_modal_if_not_open()
        return self._session_id_label.get_text()

    def get_view_wrapper_height(self, include_units: bool = False) -> str:
        """
        Obtain the height of the wrapper of the custom View in use by the 'About' modal.

        :param include_units: Dictates whether the returned value includes units of measurement.

        :returns: The height of the View wrapper of the custom View within the 'About' modal as a string, potentially
            with units of measurement.

        :raises TimeoutException: If no custom View is in use.
        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        self._open_about_modal_if_not_open()
        return self._view_wrapper.get_computed_height(include_units=include_units)

    def get_view_wrapper_width(self, include_units: bool = False) -> str:
        """
        Obtain the width of the wrapper of the custom View in use by the 'About' modal.

        :param include_units: Dictates whether the returned value includes units of measurement.

        :returns: The width of the View wrapper of the custom View within the 'About' modal as a string, potentially
            with units of measurement.

        :raises TimeoutException: If no custom View is in use.
        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        return self._view_wrapper.get_computed_width(include_units=include_units)

    def hover_over_revealer(self) -> None:
        """
        Hover over the icon of the Revealer.
        """
        revealer_icon = \
            self._collapse_icon if self.is_expanded() else self._expand_icon
        revealer_icon.hover()
        revealer_icon.wait_on_binding(time_to_wait=0.5)

    def is_expanded(self) -> bool:
        """
        Determine if the App Bar is currently expanded.

        :returns: True, if the App Bar is spanning the bottom of the Page - False otherwise.
        """
        try:
            return self._action_panel_toggle_button.find().is_displayed() \
                and self._action_panel_toggle_button.get_termination().Y < self.selenium.get_inner_height()
        except TimeoutException:
            return False

    def learn_more_button_is_present(self) -> bool:
        """
        Determines if the 'LEARN MORE ABOUT IGNITION' button is currently displayed.

        :returns: True, if the button is currently displayed - False otherwise.
        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        self._open_about_modal_if_not_open()
        try:
            return self._learn_more_button.find() is not None
        except TimeoutException:
            return False

    def log_out(self) -> None:
        """
        Logs out any signed-in user.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        try:
            if self.get_logged_in_user() is not None:
                self.click_sign_out_button()
        finally:
            try:
                self.collapse()
            except TimeoutException:
                # ignore, because we may not be on a Perspective page any longer.
                pass

    def open_about_modal(self) -> None:
        """
        Open the 'About' modal.

        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        self._open_about_modal_if_not_open()

    def open_session_status_modal(self) -> None:
        """
        Open the 'Session Status' modal.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._open_session_status_modal_if_not_open()

    def revealer_displayed_in_position(self, toggle_position: TogglePosition) -> bool:
        """
        Determine if the Revealer is currently displayed in the expected position.

        :param toggle_position: The position in which you expect the Revealer to be displayed.

        :returns: True, if the Revealer is displayed in the supplied position - False otherwise.
        """
        return (f'bar-reveal-{toggle_position.value}' in self._revealer.find(wait_timeout=0).get_attribute('class'))\
            and self._revealer.find().is_displayed()

    def revealer_tooltip_is_displayed(self) -> bool:
        """
        Determine if the Revealer tooltip is currently displayed. This function does NOT hover over the Revealer icon;
        you must perform that step yourself.

        :returns: True, if the tooltip of the Revealer is currently displayed - False otherwise.
        """
        try:
            return self._tooltip.find(wait_timeout=0).is_displayed()
        except TimeoutException:
            return False

    def select_gateway_tab(self) -> None:
        """
        In the Session Status modal, select the Gateway tab.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._select_gateway_tab()

    def select_project_tab(self) -> None:
        """
        In the Session Status modal, select the Project tab.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._select_project_tab()

    def revealer_is_displayed(self) -> bool:
        """
        Determine if the Revealer is currently displayed.

        :returns: True, if the Revealer is currently displayed - False otherwise.
        """
        try:
            return self._revealer.find(wait_timeout=0).is_displayed()
        except TimeoutException:
            return False

    def session_status_modal_is_open(self) -> bool:
        """
        Determine if the Session Status modal is currently open.

        :returns: True, if the Session Status modal is currently open - False otherwise.
        """
        try:
            return self._session_status_modal.find(wait_timeout=0).is_displayed() \
                and "open" in self._session_status_modal.find().get_attribute("class") \
                and self._about_modal_and_session_status_modal_title.get_text() == self._SESSION_STATUS_MODAL_TITLE_TEXT
        except TimeoutException:
            return False

    def sign_in_button_is_displayed(self) -> bool:
        """
        Determine if the Action panel is currently displaying the SIGN IN button.

        :returns: True, if the SIGN IN button is currently displayed in the Action panel - False otherwise.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_action_panel_if_not_expanded()
        try:
            return self._get_generic_text_button(button_text='SIGN IN').is_displayed()
        except IndexError:
            return False

    def sign_out_button_is_displayed(self) -> bool:
        """
        Determine if the Action panel is currently displaying the SIGN OUT button.

        :returns: True, if the SIGN OUT button is currently displayed in the Action panel - False otherwise.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_action_panel_if_not_expanded()
        try:
            return self._get_generic_text_button(button_text="SIGN OUT").is_displayed()
        except IndexError:
            return False

    def status_button_is_displayed(self) -> bool:
        """
        Determine if the Action panel is currently displaying the STATUS button.

        :returns: True, if the STATUS button is currently displayed in the Action panel - False otherwise.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_action_panel_if_not_expanded()
        try:
            return self._status_button.find(wait_timeout=0).is_displayed()
        except TimeoutException:
            return False

    def wait_for_revealer_or_app_bar_to_be_displayed(self, wait_timeout: int = 10) -> None:
        """
        Wait for the Session to display either the Revealer or the expanded App Bar. Useful for waiting for Perspective
        Pages.

        :param wait_timeout: The amount of time (in seconds) you ar wiling to wait for the Revealer to be present
            before allowing code to continue.

        :raises TimeoutException: If neither the Revealer nor the App Bar become visible in the alotted time.
        """
        try:
            WebDriverWait(driver=self.driver, timeout=wait_timeout).until(
                IAec.function_returns_true(custom_function=self._app_bar_or_revealer_is_displayed, function_args={}))
        except TimeoutException as toe:
            raise TimeoutException(msg="Neither the App Bar nor the Revealer ever became visible.") from toe

    def _app_bar_is_fully_collapsed(self) -> bool:
        """
        Determine if the App Bar is completely out of view of the user.

        :returns: True, if the App Bar is not visible to the user - False otherwise.
        """
        return self._app_bar_wrapper.get_origin().Y == self.selenium.get_inner_height()

    def _app_bar_is_fully_expanded(self) -> bool:
        """
        Determine if the App Bar is expanded and visible to the user.

        :returns: True, if the App Bar is expanded and visible to the user - False otherwise.
        """
        return self._app_bar_wrapper.get_termination().Y <= self.selenium.get_inner_height()

    def _app_bar_or_revealer_is_displayed(self) -> bool:
        """
        Determine if either the Revealer or the expanded App Bar is displayed to the user.

        :returns: True, if either the App Bar or Revealer is visible to the user - False otherwise.
        """
        revealer_is_displayed = False
        app_bar_is_displayed = False
        try:
            revealer_is_displayed = self.revealer_is_displayed()
        except TimeoutException:
            pass
        try:
            app_bar_is_displayed = self._app_bar_wrapper.find(wait_timeout=0).is_displayed()
        except TimeoutException:
            pass
        return revealer_is_displayed or app_bar_is_displayed

    def _click_about_button(self) -> None:
        """
        Click the 'About' button. This function performs special handling, as the button slides into view and clicks
        made too soon can result in no effect.
        """
        self._expand_app_bar_if_not_expanded()
        self.wait.until(
            IAec.element_is_fully_in_viewport(
                driver=self.driver, locator=self._ABOUT_IGNITION_ICON_LOCATOR))
        self._about_button.click()
        self._wait_for_animations_to_cease()

    def _click_action_panel_button(self) -> None:
        """
        Click the button which expands and collapses the Action panel. This function performs special handling, as the
        button slides into view and clicks made too soon can result in no effect.
        """
        self._expand_app_bar_if_not_expanded()
        self.wait.until(
            IAec.element_is_fully_in_viewport(
                driver=self.driver, locator=self._ACTION_PANEL_TOGGLE_BUTTON_LOCATOR))
        self._action_panel_toggle_button.click()

    def _close_session_status_modal_if_open(self) -> None:
        """
        Close the Session Status modal if it is open. If the modal is not open, no action is taken.
        """
        if self.session_status_modal_is_open():
            self._session_status_panel_close_icon.click()
            self._wait_for_session_status_modal_to_not_be_displayed()

    def _expand_action_panel_if_not_expanded(self) -> None:
        """
        Expand the App Bar if required before then expanding the Action panel.

        :raises AssertionError: If unsuccessful in expanding the Action Panel.
        """
        self._expand_app_bar_if_not_expanded()
        if not self.action_panel_is_expanded():
            self._action_panel_toggle_button.click()
            self._wait_for_action_panel_to_be_visible()
        IAAssert.is_true(
            value=self.action_panel_is_expanded(),
            failure_msg="Failed to expand the Action panel.")

    def _expand_app_bar_if_not_expanded(self) -> None:
        """
        Expand the App Bar, if it is not already expanded.

        :raises AssertionError: If unsuccessful in expanding the App Bar.
        """
        if not self.is_expanded():
            self._expand_icon.click(wait_timeout=0.5)
            self._wait_for_app_bar_to_be_fully_expanded()
        IAAssert.is_true(
            value=self.is_expanded(),
            failure_msg="Failed to expand the App Bar.")

    def _get_generic_text_button(self, button_text: str) -> WebElement:
        """
        Obtain a button within the Action panel based on its text.

        :returns: The WebElement "button" (<div>) with the specified text.
        """
        return Items(
            items=self._generic_text_button.find_all(
                wait_timeout=1)).filter(
            lambda e: e.text == button_text)[0]

    def _open_about_modal_if_not_open(self) -> None:
        """
        Expand the App Bar if required, and then click the About button.

        :raises AssertionError: If unsuccessful in opening the 'About' modal.
        """
        self._expand_action_panel_if_not_expanded()
        if not self.about_modal_is_displayed():
            self._about_button.click()
            self._wait_for_about_modal_to_be_displayed()
        IAAssert.is_true(
            value=self.about_modal_is_displayed(),
            failure_msg="We failed to open the 'About' modal."
        )

    def _open_session_status_modal_if_not_open(self) -> None:
        """
        Expand the App Bar, open the Action panel, and then click the STATUS button.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._expand_action_panel_if_not_expanded()
        if self.about_modal_is_displayed():  # About modal can interfere with click of STATUS button.
            self.close_about_modal_via_button()
        if not self.session_status_modal_is_open():
            self.click_status_button()
            self._wait_for_session_status_modal_to_be_displayed()
        IAAssert.is_true(
            value=self.session_status_modal_is_open(),
            failure_msg="We failed to open the Session Status modal."
        )

    def _select_gateway_tab(self) -> None:
        """
        Within the Session Status modal, select the Gateway tab.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._open_session_status_modal_if_not_open()
        self._gateway_tab.click()

    def _select_project_tab(self) -> None:
        """
        Within the Session Status modal, select the project tab.

        :raises AssertionError: If unsuccessful in opening the Session Status modal.
        """
        self._open_session_status_modal_if_not_open()
        self._project_tab.click()

    def _session_status_modal_is_open(self) -> bool:
        """
        Determine if the Session Status modal is open.

        :returns: True, if the Session Status modal is currently open and visible to the user - False otherwise.
        """
        try:
            return self._session_status_modal.find(wait_timeout=0).is_displayed() \
                and "open" in self._session_status_modal.find().get_attribute("class")
        except TimeoutException:
            return False

    def _wait_for_about_modal_to_be_displayed(self) -> None:
        """
        Wait for the 'About' modal to become visible.
        """
        try:
            self._animation_wait.until(
                IAec.function_returns_true(custom_function=self.about_modal_is_displayed, function_args={}))
        except TimeoutException as toe:
            raise TimeoutException(msg='The About modal never opened.') from toe

    def _wait_for_action_panel_to_be_visible(self) -> None:
        """
        Wait for the Action panel to become visible.
        """
        try:
            self._animation_wait.until(
                IAec.function_returns_true(custom_function=self.action_panel_is_expanded, function_args={}))
        except TimeoutException:
            pass

    def _wait_for_action_panel_to_not_be_visible(self) -> None:
        """
        Wait for the Action panel to become visible.
        """
        try:
            self._animation_wait.until(
                IAec.function_returns_false(custom_function=self.action_panel_is_expanded, function_args={}))
        except TimeoutException:
            pass

    def _wait_for_animations_to_cease(self) -> None:
        """
        Wait a period of time for any animations of the App Bar to complete.
        """
        sleep(self._ANIMATION_DELAY_IN_SECONDS)

    def _wait_for_app_bar_to_be_fully_collapsed(self) -> None:
        """
        Wait for the App Bar to be completely removed from view.
        """
        try:
            self._animation_wait.until(IAec.function_returns_true(
                custom_function=self._app_bar_is_fully_collapsed, function_args={}))
        except TimeoutException:
            pass

    def _wait_for_app_bar_to_be_fully_expanded(self) -> None:
        """
        Wait for the App Bar to be completely expanded and visible.
        """
        try:
            self._animation_wait.until(IAec.function_returns_true(
                custom_function=self._app_bar_is_fully_expanded, function_args={}))
        except TimeoutException:
            pass

    def _wait_for_session_status_modal_to_be_displayed(self) -> None:
        """
        Wait for the Session Status modal to be visible to the user.
        """
        try:
            self._animation_wait.until(
                IAec.function_returns_true(custom_function=self.session_status_modal_is_open, function_args={}))
        except TimeoutException:
            pass

    def _wait_for_session_status_modal_to_not_be_displayed(self) -> None:
        """
        Wait for the Session Status modal to be visible to the user.
        """
        try:
            self._animation_wait.until(
                IAec.function_returns_false(custom_function=self.session_status_modal_is_open, function_args={}))
        except TimeoutException:
            pass

Pages/Perspective/AppBar.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from time import sleep
from typing import Tuple, Optional

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Components.BasicComponent import BasicPerspectiveComponent, ComponentPiece
from Helpers.IAAssert import IAAssert
from Helpers.Point import Point
from Pages.Perspective.View import View


class DockedView(View):
    """
    We need to know which direction the view is expected to be in, and we need to know the id of the
    root container (node). To set this id, select the root node of the view, then insert a value into the META
    properties for the view with a key of "domId".

    This does mean that we will be unable to accurately test Docked Views if they are also a View presently displayed
    on the Page.
    """
    class Side(Enum):
        TOP = 'top'
        BOTTOM = 'bottom'
        LEFT = 'left'
        RIGHT = 'right'

    def __init__(
            self,
            driver: WebDriver,
            side: Side,
            root_id: str,
            primary_locator: Tuple[By, str] = None,
            view_resource_path: Optional[str] = None,
            config_id: Optional[str] = None):
        """
        :param driver: The WebDriver in use for the browser window.
        :param side: The side on which this Docked View is configured.
        :param root_id: The domId of the root node for this Docked View.
        :param primary_locator: The primary locator used to identify a component of the View. This will be the same as
            the `root_id` in most cases.
        :param view_resource_path: The path of the View within the "Perspective/Views" directory of the Designer.
        :param config_id: The ID configured within the Designer for this Docked View.
        """
        View.__init__(
            self,
            driver=driver,
            primary_locator=primary_locator,
            view_resource_path=view_resource_path)
        self._side = side
        self._direction = side
        self._root_id = root_id
        self._config_id = config_id
        self._root = BasicPerspectiveComponent(
            locator=(By.ID, root_id), driver=driver)
        dock_id_piece = f' > div[data-dock-id="{config_id}"] ' if config_id else ''
        handle_locator = (
            By.CSS_SELECTOR,
            f"div.docked-view-{self._side.value}{dock_id_piece} div.view-toggle")
        self._dock_toggle_handle = ComponentPiece(driver=driver, locator=handle_locator, wait_timeout=1)

    def click_handle(self) -> None:
        """
        Click the handle of this Docked View.

        :raises TimeoutException: If no Docked View handle exists on the side with this Docked View.
        :raises AssertionError: If unsuccessful in changing the expansion state of this Docked View after clicking the
            associated handle.
        """
        original_state = self.is_expanded()
        if not self.handle_is_visible():
            self.selenium.move_mouse(x_offset=1, y_offset=1)
            try:
                locator = (By.CSS_SELECTOR, f'div.toggle-{self._side.value}.toggle-visible')
                self.wait.until(
                    ec.presence_of_element_located(locator), f"Locator was never found: {locator}")
            except TimeoutException as toe:
                raise TimeoutException(msg="The handle of the docked view never became visible.") from toe
            sleep(1)
        self._dock_toggle_handle.click()
        sleep(2)  # We need to allow for the view to go through its expand/collapse animation (Edge is slow)
        if original_state == self.is_expanded():
            # geckodriver issue! Firefox does not correctly release the mouse click, so click AGAIN to force it
            self.click_handle()
        IAAssert.is_not_equal_to(
            actual_value=self.is_expanded(),
            expected_value=original_state,
            failure_msg=f'We failed to {"collapse" if original_state else "expand"} the Docked View.')

    def collapse(self) -> None:
        """
        Collapse this Docked View if it is currently expanded. If this Docked View is already collapsed, then no action
        is taken.

        :raises TimeoutException: If no Docked View handle exists on the side with this Docked View.
        :raises AssertionError: If unsuccessful in changing the expansion state of this Docked View after clicking the
            associated handle.
        """
        if self.is_expanded():
            self.click_handle()

    def expand(self) -> None:
        """
        Expand this Docked View if it is currently collapsed. If this Docked View is already expanded, then no action
        is taken.

        :raises TimeoutException: If no Docked View handle exists on the side with this Docked View.
        :raises AssertionError: If unsuccessful in changing the expansion state of this Docked View after clicking the
            associated handle.
        """
        if not self.is_expanded():
            self.click_handle()

    def get_config_id(self) -> str:
        """
        The configuration ID is the optionally supplied 'quasi-unique' identifier for the docked view configuration.
        The uniqueness of this ID is not enforced, and the existence of docked views on ANY side which share
        the same ID could result in undocumented and contentious behavior. This config ID is not tied to the View -
        only the configuration - and due to system.perspective.alterDock and the ability to configure any view with
        any ID, this property should only be set on initialization of the docked view.
        """
        return self._config_id

    def get_height(self, include_units: bool = False) -> str:
        """
        Get the computed height of the Docked View. Must return as a string because of the possibility of
        included units.

        :param include_units: Include the units of height (typically "px") if True, otherwise return only the numeric
            value (as a string).

        :returns: The computed height of the Docked View as a string.

        :raises TimeoutException: If the root of the Docked View is not found in the DOM.
        """
        return self._root.get_computed_height(include_units=include_units)

    def get_origin(self) -> Point:
        """
        Get the Cartesian Coordinate of the upper-left corner of the component, measured from the
        top-left of the viewport.

        :returns: The Cartesian Coordinate of the upper-left corner of the component, measured from the
            top-left of the viewport.
        """
        return self._root.get_origin()

    def get_termination(self) -> Point:
        """
        Get the Cartesian Coordinate of the bottom-right corner of the component, measured from the
        top-left of the viewport.

        :returns: The Cartesian Coordinate of the bottom-right corner of the component, measured from the
            top-left of the viewport.
        """
        return self._root.get_termination()

    def get_width(self, include_units: bool = False) -> str:
        """
        Get the computed width of the Docked View. Must return as a string because of the possibility of
        included units.

        :param include_units: Include the units of width (typically "px") if True, otherwise return only the numeric
            value (as a string).

        :returns: The computed width of the Docked View as a string.

        :raises TimeoutException: If the root of the Docked View is not found in the DOM.
        """
        return self._root.get_computed_width(include_units=include_units)

    def handle_is_visible(self) -> bool:
        """
        Determine if the handle of this Docked View is visible. May return "false positive" if the Docked View does not
        have a configured ID.

        :returns: True, if this Docked View has a configured ID and the handle is visible, or if this Docked View does
            not have a configured ID but any Docked View on the same side of this Docked View has a handle visible.
            False, if This Docked View has a configured ID but no handle is visible, or if no Docked View on the same
            side as this Docked View has a handle which is visible.
        """
        try:
            return 'toggle-visible' in self._dock_toggle_handle.find().get_attribute('class')
        except TimeoutException:
            # no dock toggle found
            return False

    def is_expanded(self) -> bool:
        """
        Determine if the Docked View is currently expanded.

        :returns: True, if the underlying View is displayed - False if the underlying view is not displayed, or if the
            View does not have an HTML `id` attribute applied to the root node.
        """
        try:
            return self._root.find(wait_timeout=0).is_displayed()
        except TimeoutException:
            return False

    def is_present_in_dom(self) -> bool:
        """
        Determine if the Docked View is present within the DOM.

        :returns: True, if the underlying View in use for the Docked View is found within the DOM - False otherwise.
        """
        try:
            return self._root.find() is not None
        except TimeoutException:
            return False

Pages/Perspective/DockedView.py
inductiveautomation/ignition-automation-tools
from enum import Enum
from typing import TypeVar

from selenium.common.exceptions import TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.IAAssert import IAAssert
from Helpers.IASelenium import IASelenium
from Pages.Perspective.View import View

V = TypeVar("V", bound=View)  # specifies that any subclass of View is acceptable.


class Popup(ComponentPiece):
    """
    Popups are glorified View wrappers which provide access to the view as a public attribute.
    """
    class ResizeHandle(Enum):
        """The resize handles available to Popups."""
        EAST = "e"
        NORTH = "n"
        NORTHWEST = "nw"
        NORTHEAST = "ne"
        SOUTH = "s"
        SOUTHEAST = "se"
        SOUTHWEST = "sw"
        WEST = "w"

    _GENERIC_POPUP_LOCATOR = (By.CSS_SELECTOR, "div.popup")
    _HEADER_LOCATOR = (By.CSS_SELECTOR, "div.popup-header")
    _BODY_LOCATOR = (By.CSS_SELECTOR, "div.popup-body")
    _CLOSE_ICON_LOCATOR = (By.CSS_SELECTOR, "svg.close-icon")
    _DRAG_HANDLE_LOCATOR = (By.CSS_SELECTOR, "div.popup-drag")
    _RESIZE_ZONE_LOCATOR = (By.CSS_SELECTOR, "div.resize-zone")
    _MODAL_LOCATOR = (By.CSS_SELECTOR, "div.modal")

    def __init__(
            self,
            driver: WebDriver,
            popup_id: str,
            view: V = None):
        """
        :param driver: The WebDriver in use.
        :param popup_id: The ID in use for the popup as configured in the Designer. Do NOT supply the HTML id attribute
            for this value. On the backend, Perspective will prepend "popup-" to all popups - it is important you supply
            this ID value exactly as you have it configured in the Designer.
        :param view: An instance of a View.
        """
        # build the formatted ID first
        self.formatted_id = f"popup-{popup_id}"
        ComponentPiece.__init__(
            self,
            locator=(By.ID, self.formatted_id),
            driver=driver,
            parent_locator_list=None)
        self.view = view
        self._selenium = IASelenium(driver=driver)
        self.title_bar = ComponentPiece(  # needs to be public for drag testing
            locator=self._HEADER_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            description="The title bar of the Popup, where the title text and close icon are displayed.",
            wait_timeout=0)
        self._drag_handle = ComponentPiece(
            locator=self._DRAG_HANDLE_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            description="The drag handle located within the title bar.",
            wait_timeout=0)
        self._popup_body = ComponentPiece(
            locator=self._BODY_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            description="The internal contents of the Popup, where the View is visible.",
            wait_timeout=0)
        self._close_icon = CommonIcon(
            locator=self._CLOSE_ICON_LOCATOR,
            driver=driver,
            parent_locator_list=self.locator_list,
            description="The 'X' used to close the Popup.",
            wait_timeout=0)
        self._modal = ComponentPiece(
            locator=self._MODAL_LOCATOR,
            driver=driver,
            description="The overlay displayed when a Popup is opened as a modal.",
            parent_locator_list=None,
            wait_timeout=0)
        self._resize_zones = {}
        self._make_resize_zones()

    def click_modal_overlay(self) -> None:
        """
        Click the modal overlay. We accomplish this by hovering over the title bar, and then clicking slightly above
        the title bar.

        Clicking the overlay is a bit tricky because of browser differences; Chrome might click in the top-left, and
        Firefox might click in the middle - where your Popup is likely to intercept the click. To get around this, we
        actually target the title bar (if present) or Popup, then specify a location some number of pixels directly
        above the Popup, blindly clicking at the location instead of targeting the modal itself.

        :raises AssertionError: If the Popup is not a modal, or the overlay is not present.
        """
        IAAssert.is_true(
            value=self.is_modal(),
            failure_msg=f"Either this popup ({self.formatted_id}) is not a modal, or the overlay is not present.")
        if self.has_title_bar():
            # Targeting the title bar when available reduces the vertical offset, which reduces potential issues.
            self.title_bar.hover()
            height = self.title_bar.find().size.get('height')
        else:
            self.hover()
            height = self.find().size.get('height')
        y_offset = (height / 2 * -1) - 20  # safety handling for firefox vs chrome click location discrepancy
        self._selenium.click_at_offset(y=y_offset)

    def drag_popup_by_offset(self, x_offset: int = 0, y_offset: int = 0) -> None:
        """
        Drag the Popup some direction by clicking the drag handle in the title bar.

        :param x_offset: The left/right value to drag the Popup. Positive values are right, and negative values are
            left.
        :param y_offset: The up/down value to drag the Popup. Positive values are DOWN, negative values are UP.

        :raises TimeoutException: If the Popup is not present, or if the Popup is not configured to be draggable.
        """
        self._selenium.click_drag_release(web_element=self._drag_handle.find(), x=x_offset, y=y_offset)

    def close_popup(self) -> None:
        """
        Close the Popup by clicking the close icon.

        :raises TimeoutException: If the Popup is not configured to display the close icon.
        """
        self._close_icon.click()

    def get_height_of_popup_body(self, include_units: bool = False) -> str:
        """
        Obtain the height of the area used to display the View of this Popup.

        :param include_units: Dictates whether the returned value includes units of measurement.

        :returns: The height of the area available to the View used within this Popup as a string.
        """
        return self._popup_body.get_computed_height(include_units=include_units)

    def get_width_of_popup_body(self, include_units: bool = False) -> str:
        """
        Obtain the width of the area used to display the View of this Popup.

        :param include_units: Dictates whether the returned value includes units of measurement.

        :returns: The width of the area available to the View used within this Popup as a string.
        """
        return self._popup_body.get_computed_width(include_units=include_units)

    def get_title(self) -> str:
        """
        Obtain the text displayed within the title bar of this Popup.

        :returns: The text displayed within the title bar of the Popup.
        """
        return self.title_bar.get_text()

    def has_close_icon(self) -> bool:
        """
        Determine if this Popup contains a close icon.

        :returns: True, if this Popup contains a close icon - False otherwise.
        """
        try:
            return self._close_icon.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def has_title_bar(self) -> bool:
        """
        Determine if this Popup is displaying a Title Bar. Note that the title bar may be displayed even if no text is
        configured, as the title bar also contains the close icon.

        :returns: True, if the Popup is currently displaying a title bar at the top of the Popup.
        """
        try:
            return self.title_bar.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def is_modal(self) -> bool:
        """
        Determine if the Popup is currently displayed as a modal. As modal overlays are not directly attached to the
        Popup itself, the best we can do is verify the Popup is self-reporting as a modal and that an overlay is
        currently in place on the Page.

        :returns: True, if the Popup is declaring itself to be a modal AND an overlay exists - False otherwise.
        """
        return 'popup-modal' in self.find(wait_timeout=0).get_attribute('class') and self.overlay_is_present()

    def is_draggable(self) -> bool:
        """
        Determine if the Popup is draggable.

        :returns: True, if the Popup has a draggable handle within the title bar - False otherwise.
        """
        try:
            return self._drag_handle.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def is_resizable(self) -> bool:
        """
        Determine if the Popup is resizable.

        :returns: True, if the Popup contains resize handles as part of its boundaries - False otherwise.
        """
        try:
            return self._resize_zones[Popup.ResizeHandle.NORTH].find() is not None
        except TimeoutException:
            return False

    def overlay_is_present(self) -> bool:
        """
        Determine if an overlay is currently displayed. Will return true even if the overlay does not belong to this
        Popup as there is no direct connection in the DOM.

        :returns: True if ANY overlay is present, even if for another Popup. False if no overlays are displayed.
        """
        try:
            return self._modal.find(wait_timeout=0) is not None
        except TimeoutException:
            return False

    def is_displayed(self) -> bool:
        """
        Determine if this Popup is currently displayed.

        :returns: True, if this Popup is currently displayed - False otherwise.
        """
        try:
            return self.find(wait_timeout=0).is_displayed()
        except TimeoutException:
            return False

    def resize_popup(self, x_drag: int, y_drag: int, resize_handle: ResizeHandle) -> None:
        """
        Resizes this Popup by dragging the specified resize handle.

        :param x_drag: The horizontal distance to drag the resize handle. Positive values are to the right, and negative
            values are to the left.
        :param y_drag: The vertical distance to drag the resize handle. Positive values are DOWN, and negative values
            are UP.
        :param resize_handle: The handle to use for resizing.

        :raises TimeoutException: If the Popup is not present, or is not configured to be resizable.
        """
        ActionChains(driver=self.driver)\
            .click_and_hold(on_element=self._resize_zones.get(resize_handle).find())\
            .move_by_offset(xoffset=x_drag, yoffset=y_drag)\
            .release()\
            .perform()

    def _make_resize_zones(self) -> None:
        """
        Initialize all the potential resize zones of the Popup.
        """
        for resize_zone in Popup.ResizeHandle:
            self._resize_zones[resize_zone] = ComponentPiece(
                locator=(By.CSS_SELECTOR, f'{self._RESIZE_ZONE_LOCATOR[1]}.-{resize_zone.value}'),
                driver=self.driver,
                parent_locator_list=self.locator_list,
                description=f"The resize zone of the {self.formatted_id} Popup in the {resize_zone.value} region "
                            f"({resize_zone}).",
                wait_timeout=0)

Pages/Perspective/Popup.py
inductiveautomation/ignition-automation-tools
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Components.BasicComponent import ComponentPiece


class ChromePrintPreviewPage:
    """Interface for the print preview page implemented in Chrome."""
    CANCEL_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'div.controls > cr-button.cancel-button')
    PRINT_PREVIEW_APP_LOCATOR = (By.CSS_SELECTOR, 'print-preview-app')
    PRINT_PREVIEW_BUTTON_STRIP_LOCATOR = (By.CSS_SELECTOR, 'print-preview-button-strip')
    SIDE_BAR_LOCATOR = (By.ID, 'sidebar')

    def __init__(self, driver: WebDriver):
        self._driver = driver
        self._print_preview_app = ComponentPiece(
            locator=self.PRINT_PREVIEW_APP_LOCATOR,
            driver=driver,
            parent_locator_list=None,
            wait_timeout=1,
            poll_freq=0)

    def click_cancel_button(self, wait_timeout: float = 1) -> None:
        """
        Closes the print preview page.
        When the page is closed, the print preview's window handle will be invalid.
        This resource does not attempt to manage driver windows, so they will need to be handled elsewhere.

        :param wait_timeout: The amount of time (in seconds) to wait for the print preview tag to appear before
            returning.

        """
        self._print_preview_app.find(wait_timeout=wait_timeout).shadow_root.find_element(*self.SIDE_BAR_LOCATOR) \
            .shadow_root.find_element(*self.PRINT_PREVIEW_BUTTON_STRIP_LOCATOR).shadow_root \
            .find_element(*self.CANCEL_BUTTON_LOCATOR).click()

    def print_preview_page_is_open(self, wait_timeout: float = 1) -> bool:
        """
        Checks if the print preview page is open by attempting to find the print-preview-app tag in the DOM.

        :param wait_timeout: The amount of time (in seconds) to wait for the print preview tag to appear
            before returning.

        :returns: True, if the print-preview-app tag can be located within the timeout window - False otherwise.
        """
        try:
            return self._print_preview_app.find(wait_timeout=wait_timeout) is not None
        except TimeoutException:
            return False

Pages/Perspective/PrintPreviewPage.py
inductiveautomation/ignition-automation-tools
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Pages.Perspective.TerminalStates.TerminalStatePageObject import TerminalStatePageObject


class Forbidden(TerminalStatePageObject):
    """
    The Forbidden Terminal State Page is to be expected when a user does not have the Security Level required by a
    project.
    """
    _SIGN_OUT_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'a.terminal-state-link-inverted')

    def __init__(self, driver: WebDriver):
        super().__init__(
            driver=driver,
            expected_page_header_text='Forbidden')

    def click_sign_out(self) -> None:
        """
        Click the Sign Out button.
        """
        self.wait.until(
            ec.presence_of_element_located(
                self._SIGN_OUT_BUTTON_LOCATOR)).click()

Pages/Perspective/TerminalStates/Forbidden.py
inductiveautomation/ignition-automation-tools
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Pages.Perspective.TerminalStates.TerminalStatePageObject import TerminalStatePageObject


class LoggedOut(TerminalStatePageObject):
    """
    The Logged Out Terminal State Page is expected when a user has logged out of a project and that project requires
    Authentication. Projects which do not require authentication typically log the user out but leave them on
    whichever page they were on before having been logged out.
    """
    _LOGGER_NAME = 'LoggedOutTerminalStatePage'
    _BACK_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'a.terminal-state-link')

    def __init__(self, driver: WebDriver):
        super().__init__(
            driver=driver,
            expected_page_header_text='Logged Out')

    def click_back_button(self) -> None:
        """
        Click the BACK button. This should take the user to the interstitial "Continue to log in" Page.
        """
        self.wait.until(
            ec.presence_of_element_located(
                self._BACK_BUTTON_LOCATOR)).click()
        try:
            self.wait.until(ec.invisibility_of_element_located(self._BACK_BUTTON_LOCATOR))
        except TimeoutException:
            pass

    def click_launch_gateway(self) -> None:
        """
        :raise NotImplementedError: The LoggedOut Terminal State Page does not have a Launch Gateway button.
        """
        raise NotImplementedError("The LoggedOut Page does not have a Launch Gateway button.")

    def get_launch_gateway_button_text(self) -> str:
        """
        :raise NotImplementedError: The LoggedOut Terminal State Page does not have a Launch Gateway button.
        """
        raise NotImplementedError("The LoggedOut Page does not have a Launch Gateway button.")

Pages/Perspective/TerminalStates/LoggedOut.py
inductiveautomation/ignition-automation-tools
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Pages.Perspective.TerminalStates.TerminalStatePageObject import TerminalStatePageObject


class MobileLicense(TerminalStatePageObject):
    """
    The MobileLicense Page is to be expected when the applied license of the Gateway only allows for Perspective to be
    accessed via mobile device.
    """
    _SIGN_OUT_BUTTON_LOCATOR = (By.CSS_SELECTOR, 'a.terminal-state-link-inverted')
    _MESSAGE_LOCATOR = (By.CSS_SELECTOR, 'section.new-trial p')

    def __init__(self, driver: WebDriver):
        super().__init__(
            driver=driver,
            expected_page_header_text='Mobile License Only')

Pages/Perspective/TerminalStates/MobileLicense.py
inductiveautomation/ignition-automation-tools
from selenium.webdriver.remote.webdriver import WebDriver

from Pages.Perspective.TerminalStates.TerminalStatePageObject import TerminalStatePageObject


class PageClosed(TerminalStatePageObject):
    """
    The PageClosed Terminal State Page is to be expected when a PerspectivePage has been forcibly closed by the
    Gateway.
    """

    def __init__(self, driver: WebDriver):
        super().__init__(
            driver=driver,
            expected_page_header_text='Page Closed')

Pages/Perspective/TerminalStates/PageClosed.py
inductiveautomation/ignition-automation-tools
from selenium.webdriver.remote.webdriver import WebDriver

from Pages.Perspective.TerminalStates.TerminalStatePageObject import TerminalStatePageObject


class ProjectNotFound(TerminalStatePageObject):
    """
    The ProjectNotFound Terminal State Page is only expected to be present when a project is deleted from the Gateway
    while an active Perspective Session was using a Page from the deleted project. In general, attempts to visit a
    project which does not exist will instead encounter a generic 404 page.
    """

    def __init__(self, driver: WebDriver):
        super().__init__(
            driver=driver,
            expected_page_header_text='Project Not Found')

Pages/Perspective/TerminalStates/ProjectNotFound.py
inductiveautomation/ignition-automation-tools
from selenium.webdriver.remote.webdriver import WebDriver

from Pages.Perspective.TerminalStates.TerminalStatePageObject import TerminalStatePageObject


class ProjectNotRunnable(TerminalStatePageObject):
    """
    The ProjectNotRunnable Terminal State Page is to be expected when a user attempts to visit any path which would
    belong to a project that is configured as not runnable (inheritable, or disabled).
    """

    def __init__(self, driver: WebDriver):
        super().__init__(
            driver=driver,
            expected_page_header_text='Project Not Runnable')

Pages/Perspective/TerminalStates/ProjectNotRunnable.py
inductiveautomation/ignition-automation-tools
from selenium.webdriver.remote.webdriver import WebDriver

from Pages.Perspective.TerminalStates.TerminalStatePageObject import TerminalStatePageObject


class SessionClosed(TerminalStatePageObject):
    """
    The SessionClosed Terminal State Page is to be expected when the Gateway has forcibly closed an entire Session.
    """

    def __init__(self, driver: WebDriver):
        super().__init__(
            driver=driver,
            expected_page_header_text='Session Closed')

Pages/Perspective/TerminalStates/SessionClosed.py
inductiveautomation/ignition-automation-tools
from typing import Union

from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Components.BasicComponent import BasicComponent, ComponentPiece
from Helpers.CSSEnumerations import CSS, CSSPropertyValue
from Pages.PagePiece import PagePiece


class TerminalStatePageObject(PagePiece):
    """
    The over-arching ancestor of all Terminal State Pages.
    """
    _HEADER_LOCATOR = (By.CSS_SELECTOR, 'h1.terminal-state-header')
    _MESSAGE_LOCATOR = (By.CSS_SELECTOR, 'section.terminal-state-section p')
    _LAUNCH_GATEWAY_ANCHOR_LOCATOR = (By.CSS_SELECTOR, 'a.terminal-state-link')

    def __init__(
            self,
            driver: WebDriver,
            expected_page_header_text: str):
        PagePiece.__init__(
            self,
            driver=driver,
            primary_locator=self._HEADER_LOCATOR)
        self._expected_page_header_text = expected_page_header_text
        self._main_body = ComponentPiece(
            locator=(By.CSS_SELECTOR, "div.terminal-state-page"),
            driver=driver)
        self._launch_gateway_button = BasicComponent(
            locator=self._LAUNCH_GATEWAY_ANCHOR_LOCATOR,
            driver=driver)

    def click_launch_gateway(self) -> None:
        """
        Click the Launch Gateway button.
        """
        self._launch_gateway_button.click()

    def get_background(self) -> str:
        """
        Obtain the background in use for this Terminal State Page.

        :return: While using Branding Customization, this returns a hex color (as a string) - otherwise a path to the
            gradient image.
        """
        return self._main_body.get_css_property(property_name=CSS.BACKGROUND_COLOR)

    def get_launch_gateway_css_property(self, property_name: Union[CSSPropertyValue, str]) -> str:
        """
        Obtain some CSS property of the Launch Gateway button.

        :param property_name: The name of the CSS property you would like the value of.

        :return: The value of the specified CSS property as applied to the Launch Gateway button.
        """
        return self._launch_gateway_button.get_css_property(property_name=property_name)

    def get_message(self) -> str:
        """
        Obtain the message displayed for this Terminal State Page.

        :return: The message displayed by this Terminal State Page.
        """
        return self.wait.until(
            method=ec.presence_of_element_located(self._MESSAGE_LOCATOR),
            message="No message found on Terminal State page.").text

    def get_header_text(self) -> str:
        """
        Obtain the header text displayed for this Terminal State Page.

        :return: The header text displayed by this Terminal State Page.
        """
        return self.wait.until(
            method=ec.presence_of_element_located(self._HEADER_LOCATOR),
            message="No header found on Terminal State page.").text.strip()

    def get_launch_gateway_button_text(self) -> str:
        """
        Obtain the text of the Launch Gateway button.

        :return: The text of the Launch Gateway button.
        """
        return self._launch_gateway_button.find().text.strip()

    def get_text_color(self) -> str:
        """
        Obtain the color in use for the text of the body of this Terminal State Page.

        :return: The color in use for the text of the body of this Terminal State Page.
        """
        return self._main_body.get_css_property(property_name=CSS.COLOR)

    def launch_gateway_button_is_present(self) -> bool:
        """
        Determine if this page contains a Launch Gateway button.

        :return: True, if the Launch Gateway button is present - False otherwise.
        """
        try:
            return self.driver.find_element(*self._LAUNCH_GATEWAY_ANCHOR_LOCATOR) is not None
        except NoSuchElementException:
            return False

    def wait_for_terminal_state_page(self) -> None:
        """
        Wait for the Terminal State Page to load, but allow code to continue in the event the page does not load.
        """
        try:
            self.wait.until(
                method=ec.presence_of_element_located(
                    self._HEADER_LOCATOR),
                message="The header was not found on the Terminal State page - the page may not have loaded")
        except TimeoutException:
            pass

    def is_current_page(self) -> bool:
        """
        Determine if this Terminal State Page is the currently displayed page.

        :return: True, if the header text of this page is what we would expect to find - False otherwise.
        """
        try:
            return self.driver.find_element(*self.primary_locator).text == self._expected_page_header_text
        except NoSuchElementException:
            return False

Pages/Perspective/TerminalStates/TerminalStatePageObject.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, Optional

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver

from Pages.PagePiece import PagePiece


class View(PagePiece):
    """
    "View" is a Perspective concept of a displayed container which does NOT have to be a page.
    Views can have components and properties, but they MUST NEVER have a URL path. Headers and Footers are examples
    of "Docked" Views, as they are anchored to a location and are visible (potentially across many pages),
    yet you can never visit a header/footer by navigating to it via URL. Views are essentially identical to
    PageComponents, and are only used instead of PageComponents for clarity.
    """

    def __init__(
            self,
            driver: WebDriver,
            primary_locator: Tuple[By, str] = None,
            view_resource_path: Optional[str] = None,
            wait_timeout: float = 10):
        """
        :param driver: The WebDriver in use for the browser window.
        :param primary_locator: The primary locator used to identify a component of the View. This will be the same as
            the `root_id` in most cases.
        :param view_resource_path: The path to the View within the Designer, after `Perspective/Views`.
        :param wait_timeout: The amount of time this resource should implicitly wait when querying.
        """
        PagePiece.__init__(
            self,
            driver=driver,
            primary_locator=primary_locator,
            wait_timeout=wait_timeout)
        self._view_resource_path = view_resource_path

    def get_view_resource_path(self) -> Optional[str]:
        return self._view_resource_path

Pages/Perspective/View.py
inductiveautomation/ignition-automation-tools
from typing import Tuple

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement

from Components.BasicComponent import BasicPerspectiveComponent
from Helpers.CSSEnumerations import CSS
from Helpers.Point import Point
from Pages.Perspective.View import View


class ViewCanvasInstancedView(View):
    """
    View Canvas Instanced Views are extremely similar to regular Views except the expectation is that each Instanced
    View have a unique locator at the root node and that each instance may have different positioning attributes
    applied.
    """

    def __init__(
            self,
            driver: WebDriver,
            root_locator: Tuple,
            view_resource_path: str = None):
        """
        :param driver: The WebDriver in use for the browser window.
        :param root_locator: The CSS locator of the root node of the View. XPATH is not allowed.
        :param view_resource_path: The path to the View within the Designer, after `Perspective/Views`.
        """
        View.__init__(
            self,
            driver=driver,
            primary_locator=root_locator,
            view_resource_path=view_resource_path)
        self._root = BasicPerspectiveComponent(
            locator=root_locator,
            driver=driver)

    def get_z_index(self) -> int:
        """
        Obtain the z-index in use by this instance of the View.

        :returns: The z-index in use by this Instanced View.
        
        :raises TimeoutException: If the root node (and therefore the View) is not found in the DOM.
        """
        return int(self._get_wrapper_div().value_of_css_property(property_name=CSS.Z_INDEX.value))

    def get_height(self, include_units: bool = False) -> str:
        """
        Get the computed height of the root node. Must return as a string because of the possibility of included units.

        :param include_units: Include the units of height (typically "px") if True, otherwise return only the numeric
            value (as a str).

        :returns: The computed height of the root node as a string.

        :raises TimeoutException: If the root node (and therefore the View) is not found in the DOM.
        """
        return self._root.get_computed_height(include_units=include_units)

    def get_width(self, include_units: bool = False) -> str:
        """
        Get the computed width of the root node. Must return as a string because of the possibility of included units.

        :param include_units: Include the units of width (typically "px") if True, otherwise return only the numeric
            value (as a str).

        :returns: The computed width of the root node as a string.

        :raises TimeoutException: If the root node (and therefore the View) is not found in the DOM.
        """
        return self._root.get_computed_width(include_units=include_units)

    def get_origin(self) -> Point:
        """
        Get the Cartesian Coordinate of the upper-left corner of the root node, measured from the
        top-left of the viewport.

        :returns: The Cartesian Coordinate of the upper-left corner of the root node, measured from the
            top-left of the viewport.

        :raises TimeoutException: If the root node (and therefore the View) is not found in the DOM.
        """
        return self._root.get_origin()

    def is_using_absolute_position(self) -> bool:
        """
        Determine if this instance of the View is using absolute positioning (as opposed to relative).

        :returns: True, if this instance of the View is using absolute positioning - False otherwise.

        :raises TimeoutException: If the root node (and therefore the View) is not found in the DOM.
        """
        return self._get_wrapper_div().value_of_css_property(property_name=CSS.POSITION.value) == 'absolute'

    def _get_wrapper_div(self) -> WebElement:
        """
        Obtain the wrapper <div> as a WebElement.

        :returns: The actual WebElement which is the wrapper of the `root` node.

        :raises TimeoutException: If the root node (and therefore the View) is not found in the DOM.
        """
        return self._root.find().find_element(By.XPATH, '..')

Pages/Perspective/ViewCanvasInstancedView.py
inductiveautomation/ignition-automation-tools
from typing import Tuple, Union

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement

from Components.BasicComponent import ComponentPiece
from Pages.Perspective.View import View


class Widget(View):
    """
    "Widget" is a Perspective concept of a view which is used within the Dashboard component. It is essentially a
    View which contains multiple additional properties that allow for a user to identify the widget in a dropdown/list,
    as well as defining default drop size and minimum configuration sizes. These properties are set independently
    in each Dashboard, and so the values must either be kept in lock-step, or each new Dashboard must have its
    own Widgets.

    Due to the complex interactions between Widget and Dashboard, the Widget object needs to know about the Dashboard
    it belongs to.
    """
    # must use xpath here because the WIDGET uses xpath
    _HEADER_LOCATOR = (
        By.XPATH, '//div[contains(concat(" ",normalize-space(@class)," ")," dashboard-widget-head-title ")]')

    def __init__(
            self,
            driver: WebDriver,
            default_size: Tuple[int, int],
            min_size: Tuple[int, int],
            name: str,
            root_locator: Tuple[Union[By, str], str],
            category: str = None,
            header: str = None):
        """
        :param driver: The WebDriver in use for the browser window.
        :param default_size: A Tuple, where the 0th item represents the "width" of the Widget and the 1th item
            represents the "height" of the Widget.
        :param min_size: A Tuple, where the 0th item represents the minimum width of the Widget, and the 1th item
            represents the minimum height of the Widget.
        :param name: The name of the Widget as it will appear in the selection panel.
        :param root_locator: A locator which defines the `root` node of the widget.
        :param category: The category this Widget resides under in the selection panel.
        :param header: The header configured for this Widget, if so configured.
        """
        View.__init__(
            self,
            driver=driver,
            primary_locator=root_locator)

        """
        :param driver: The WebDriver in use by the browser window.
        :param default_size: A tuple where the 0th item represents the default column span (width) of the Widget, and
            the 1th item represents the default row span (height).
        :param min_size: A tuple where the 0th item represents the minimum configured column span of the Widget, and
            the 1th item represents the minimum configured row span of the Widget.
        :param name: The name of the Widget as it is expected to appear during selection.
        :param root_locator: The locator used to identify the root node of the View in use for this Widget.
        :param category: The category the Widget falls under during selection.
        :param header: The configured header of the Widget.
        """
        View.__init__(
            self,
            driver=driver,
            primary_locator=root_locator)
        self.default_column_span = default_size[0]
        self.default_row_span = default_size[1]
        self.min_column_span = min_size[0]
        self.min_row_span = min_size[1]
        self.category = category
        self.name = name
        self.header = header
        self._root = ComponentPiece(locator=root_locator, driver=driver)
        self._widget = ComponentPiece(
            locator=(By.XPATH, "/../.."), driver=driver, parent_locator_list=self._root.locator_list)
        self._widget_header = ComponentPiece(
            locator=self._HEADER_LOCATOR, driver=driver, parent_locator_list=self._widget.locator_list)

    def get_column_span(self) -> int:
        """
        Obtain a count of columns this widget spans.

        :returns: The number of columns this widget is currently spanning.

        :raises TimeoutException: If this widget is not present.
        """
        return self.get_next_available_cell_as_column_row()[0] - self.get_starting_column()

    def get_header_text(self) -> str:
        """
        Obtain the text of the header of the widget.

        :returns: The text of the header for this widget.

        :raises TimeoutException: If this widget is not present.
        """
        try:
            return self._widget_header.xfind().text
        except TimeoutException as toe:
            raise TimeoutException(
                msg="Unable to retrieve header text because the widget could not be located within "
                    "the Dashboard.") from toe

    def get_next_available_cell_as_column_row(self) -> Tuple[int, int]:
        """
        Obtain the next available cells based on the termination of this widget.

        If this widget is placed at (3,2), and spans two columns and two rows, you would expect a returned value of
        (5,4) because the widget occupies the 3rd and 4th columns, making the 5th column the next possibly available
        columns, and the 2nd and 3rd rows are occupied, so the 4th row is the next available row.

        :returns: A Tuple, where the 0th element is the next potentially available column after this widget, and the 1th
            element is the next potentially available row.

        :raises TimeoutException: If this widget is not present in the Dashboard.
        """
        try:
            arr_of_strs = self._widget.xfind().value_of_css_property(property_name="grid-area").split(" / ")[-2:]
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate widget within the Dashboard.") from toe
        ending_row = int(arr_of_strs[0])
        ending_col = int(arr_of_strs[1])
        return ending_col, ending_row  # (column, row) to retain (x, y)

    def get_row_span(self) -> int:
        """
        Obtain the count of rows this widget spans.

        :returns: The vertical count of rows this widget spans.

        :raises TimeoutException: If the widget is not present.
        """
        return self.get_next_available_cell_as_column_row()[1] - self.get_starting_row()

    def get_starting_column(self) -> int:
        """
        Obtain the one-based index of the column this widget originates in.

        :returns: The one-based index of the column in which this widget originates.

        :raises TimeoutException: If the widget is not present.
        """
        return self._get_origin_as_column_row()[0]

    def get_starting_row(self) -> int:
        """
        Obtain the one-based index of the row this widget originates in.

        :returns: The one-based index of the row in which this widget originates.

        :raises TimeoutException: If the widget is not present.
        """
        return self._get_origin_as_column_row()[1]

    def get_widget(self) -> WebElement:
        """
        Obtain the widget as a WebElement.

        :returns: The WebElement defined by the widget.

        :raises TimeoutException: If the widget is not present.
        """
        try:
            return self._widget.xfind()
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate Widget within the Dashboard.") from toe

    def has_header(self) -> bool:
        """
        Determine if this widget has a header.

        :returns: True, if this widget contains a header - False otherwise.
        """
        try:
            return self._widget_header.xfind() is not None
        except TimeoutException:
            return False

    def is_selected_during_edit_mode(self) -> bool:
        """
        Determine if this widget is selected during editing mode.

        :returns: True, if this widget is selected/active during editing mode - False otherwise.
        """
        try:
            return 'isEditing' in self._widget.xfind().get_attribute('class')
        except TimeoutException as toe:
            raise TimeoutException(
                msg="Unable to determine selection of widget as we could not locate the widget within the "
                    "Dashboard.") from toe

    def is_currently_placed_on_dashboard(self) -> bool:
        """
        Determine if this widget is currently displayed on the Dashboard.

        :returns: True, if this widget is currently displayed on the Dashboard, regardless of mode. False, if this
            widget is not displayed.
        """
        try:
            return self._root.xfind() is not None
        except TimeoutException:
            return False

    def select_during_edit_mode(self) -> None:
        """
        Select this widget while the Dashboard is in editing mode.

        :raises TimeoutException: If this widget is not currently placed, or the Dashboard is not in editing mode.
        """
        if not self.is_selected_during_edit_mode():
            self._widget.xfind().click()

    def _get_origin_as_column_row(self) -> Tuple[int, int]:
        """
        Obtain the column:row indices of this widget's origin cell.

        :returns: A Tuple, where the 0th element id the one-based column index of the cell this widget originates in,
            and the 1th element is the one-based row index the widget originates in.

        :raises TimeoutException: If the widget is not currently placed.
        """
        try:
            column = int(self._widget.xfind().get_attribute('data-column'))
            row = int(self._widget.xfind().get_attribute('data-row'))
            return column, row
        except TimeoutException as toe:
            raise TimeoutException(msg="Unable to locate widget within the Dashboard.") from toe

Pages/Perspective/Widget.py
inductiveautomation/ignition-automation-tools
import logging
from typing import Tuple, Optional, Union

from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec

from Components.BasicComponent import BasicComponent, ComponentPiece
from Components.PerspectiveComponents.Common.Icon import CommonIcon
from Helpers.CSSEnumerations import CSSPropertyValue
from Helpers.Point import Point
from Pages.IgnitionPageObject import IgnitionPageObject
from Pages.Perspective.AppBar import AppBar


class PerspectivePageObject(IgnitionPageObject):
    _PERSPECTIVE_PATH_PREFIX = '/data/perspective/client'
    _LAPSED_TRIAL_HEADER_LOCATOR = (By.CSS_SELECTOR, 'h1.terminal-state-header')
    _LOADING_SPINNER = (By.CLASS_NAME, 'ignition-loading-spinner')
    _KNOWN_ERROR_CLASSES = [
        'component-error-boundary',
        'error-pie',
        'ia-xy-error'
    ]
    configured_page_title = None

    class ComponentContextMenu(BasicComponent):
        _ITEM_LOCATOR = (By.CSS_SELECTOR, "div.menu-item.ia_componentContextMenu__item")
        _MENU_LOCATOR = (By.ID, "context-menu")
        _SEPARATOR_LOCATOR = (By.CSS_SELECTOR, "div.separator hr")
        _SUBMENU_EXPANSION_ICON_LOCATOR = (By.CSS_SELECTOR, "div.ia_componentContextMenu__submenu--icon svg")

        def __init__(
                self,
                driver: WebDriver):
            """
            :param driver: The WebDriver in use by the browser.
            """
            BasicComponent.__init__(
                self,
                locator=self._MENU_LOCATOR,
                driver=driver,
                parent_locator_list=None)
            self._separators = ComponentPiece(
                locator=self._SEPARATOR_LOCATOR,
                driver=driver,
                parent_locator_list=self.locator_list,
                wait_timeout=1)
            self._item_coll = {}
            self._icon_coll = {}

        def click_item(
                self, item_text: str, submenu_depth: Optional[int] = None, binding_wait_time: float = 0.5) -> None:
            """
            Click an item within the context menu. For items within submenus, you must first hover over whichever other
            items would expand the submenus.

            :param item_text: The text of the item to click.
            :param submenu_depth: The depth of the submenu to which the item belongs.
            :param binding_wait_time: The amount of time to wait after clicking the item before allowing code to
                continue.
            """
            if submenu_depth is not None:
                # For sub-menu items, we must first hover over the 0th element in that sub-menu, otherwise
                # the sub-menu collapses as we leave our current item
                self._get_item(submenu_depth=submenu_depth).hover()
            self._get_item(item_text=item_text, submenu_depth=submenu_depth).click(binding_wait_time=binding_wait_time)

        def get_path_of_expansion_icon(self, item_text: str, submenu_depth: Optional[int] = None) -> str:
            """
            Obtain the library path in use by an expansion icon of the context menu.

            :param item_text: The text of the item the expansion icon belongs to.
            :param submenu_depth: The depth of the item in any submenus.

            :return: The library path in use by the expansion icon which belongs to the specified context menu item.
            """
            return self._get_expansion_icon(item_text=item_text, submenu_depth=submenu_depth).get_icon_name()

        def get_path_of_icon(self, item_text: str, submenu_depth: Optional[int] = None) -> str:
            """
            Obtain the library path in use by an icon of the context menu.

            :param item_text: The text of the item the icon belongs to.
            :param submenu_depth: The depth of the item in any submenus.

            :return: The library path in use by the icon which belongs to the specified context menu item.
            """
            return self._get_icon(item_text=item_text, submenu_depth=submenu_depth).get_icon_name()

        def get_property_of_icon(
                self, item_text: str, property_name: CSSPropertyValue, submenu_depth: Optional[int] = None) -> str:
            """
            Obtain the CSS value of a property of an icon which belongs to the described context menu item.

            :param item_text: The text of the item the icon belongs to.
            :param property_name: The name of the CSS property you would like a value for.
            :param submenu_depth: The depth of the item/icon.

            :return: The CSS value of the requested property for the icon of the specified context menu item.
            """
            return self._get_icon(
                item_text=item_text, submenu_depth=submenu_depth).get_css_property(property_name=property_name)

        def get_property_of_separator(self, index: int, property_name: CSSPropertyValue) -> str:
            """
            Obtain the CSS value of a property of a separator. Does not currently support submenus.

            :param index: The zero-based index of the separator from which you would like a CSS value.
            :param property_name: The name of the CSS property you would like a value for.

            :return: The CSS value of the specified property for the specified separator.

            :raises TimeoutException: If no separators are found.
            :raises IndexError: If separators are found, but fewer than the specified index.
            """
            # allow IndexError to bubble up if encountered
            return self._separators.find_all(wait_timeout=0)[index].get_attribute(property_name.value)

        def hover_over_item(
                self, item_text: str, submenu_depth: Optional[int] = None, binding_wait_time: float = 0) -> None:
            """
            Hover over an item of the context menu. This is extremely important while dealing with submenus.

            :param item_text: The text of the item to hover over.
            :param submenu_depth: The depth of the item which will be hovered over.
            :param binding_wait_time: The amount of time after hovering over the item to wait before allowing code to
                continue.
            """
            if submenu_depth is not None:
                # For sub-menu items, we MUST first hover over the 0th element in that sub-menu, otherwise
                # the sub-menu collapses as we leave our current item
                self._get_item(submenu_depth=submenu_depth).hover()
            self._get_item(item_text=item_text, submenu_depth=submenu_depth).hover()
            self._get_item(
                item_text=item_text,
                submenu_depth=submenu_depth).wait_on_binding(time_to_wait=binding_wait_time)

        def is_displayed(self) -> bool:
            """
            Determine if a context menu is currently displayed on the Page.

            :return: True, if a context menu is displayed on the current Page - False otherwise.
            """
            try:
                return self.find(wait_timeout=0) is not None
            except TimeoutException:
                return False

        def item_contains_configured_icon(self, item_text: str, submenu_depth: Optional[int] = None) -> bool:
            """
            Determine if an item is currently displaying an icon.

            :param item_text: The text of the item to search for an icon.
            :param submenu_depth: The depth of the item to check.

            :return: True, if the specified item of the context menu is displaying an icon - False otherwise.
            """
            try:
                return self._get_icon(
                    item_text=item_text, submenu_depth=submenu_depth).find(wait_timeout=0).is_displayed()
            except TimeoutException:
                return False

        def item_contains_submenu_expansion_icon(self, item_text: str, submenu_depth: Optional[int] = None) -> bool:
            """
            Determine if an item of the context menu is currently displaying an expansion icon.

            :param item_text: The text of the item to check for an expansion icon.
            :param submenu_depth: The depth of the item within submenus.

            :return: True, if the item is currently displaying an expansion icon - False otherwise.
            """
            try:
                return self._get_expansion_icon(
                    item_text=item_text, submenu_depth=submenu_depth).find(wait_timeout=0).is_displayed()
            except TimeoutException:
                return False

        def item_is_displayed(self, item_text: str, submenu_depth: Optional[int] = None) -> bool:
            """
            Determine if an item is currently displayed.

            :param item_text: The text of the item to search for.
            :param submenu_depth: The submenu depth of the item to search for. A None value implies the item belongs to
                the top-most level of the context menu, while a 0 value implies the item must belong to the 0th
                submenu, etc.

            :return: True, if the described item is currently displayed - False otherwise.
            """
            try:
                item = self._get_item(item_text=item_text, submenu_depth=submenu_depth)
                return item.find(wait_timeout=0).is_displayed() and item.get_text() == item_text
            except TimeoutException:
                return False

        def submenu_is_displayed(self, submenu_depth: int = 0) -> bool:
            """
            Determine if a submenu at the specified depth is currently displayed.

            :param submenu_depth: The depth of the submenu to query for.

            :return: True, if a submenu with the specified depth is currently displayed - False otherwise.
            """
            try:
                return ComponentPiece(
                    locator=self._get_submenu_locator(
                        submenu_depth=submenu_depth),
                    driver=self.driver,
                    parent_locator_list=None,
                    wait_timeout=1).find(wait_timeout=0).is_displayed()
            except TimeoutException:
                return False

        def _get_expansion_icon(self, item_text: str, submenu_depth: Optional[int] = None) -> CommonIcon:
            """
            Obtain the expansion icon in use for an item of the context menu, based on the item's text and submenu
                depth.

            :param item_text: The text of the item from which you would like the expansion icon.
            :param submenu_depth: The submenu depth of the expansion icon to be obtained. A None value implies the icon
                is at the top-most level of the context menu, while 0 implies the icon is within the 0th nested submenu,
                etc.

            :return: The ComponentPiece which defines the expansion icon of the item with the supplied text at the
                specified submenu depth.
            """
            identifier = (item_text, submenu_depth)
            icon = self._icon_coll.get(identifier)
            if not icon:
                icon = CommonIcon(
                    locator=self._SUBMENU_EXPANSION_ICON_LOCATOR,
                    driver=self.driver,
                    parent_locator_list=self._get_item(item_text=item_text, submenu_depth=submenu_depth).locator_list,
                    wait_timeout=1)
                self._icon_coll[identifier] = icon
            return icon

        def _get_icon(self, item_text: str, submenu_depth: Optional[int] = None) -> CommonIcon:
            """
            Obtain the icon in use for an item of the context menu, based on the item's text and submenu depth.

            :param item_text: The text of the item from which you would like the icon.
            :param submenu_depth: The submenu depth of the icon to be obtained. A None value implies the icon is
                at the top-most level of the context menu, while 0 implies the icon is within the 0th nested submenu,
                etc.

            :return: The ComponentPiece which defines the icon of the item with the supplied text at the specified
                submenu depth.
            """
            identifier = (item_text, submenu_depth)
            icon = self._icon_coll.get(identifier)
            if not icon:
                parent_locator_list = self._get_item(item_text=item_text, submenu_depth=submenu_depth).locator_list
                icon = CommonIcon(
                    locator=(By.CSS_SELECTOR, 'svg'),
                    driver=self.driver,
                    parent_locator_list=parent_locator_list,
                    wait_timeout=1)
                self._icon_coll[identifier] = icon
            return icon

        def _get_item(self, item_text: Optional[str] = None, submenu_depth: Optional[int] = None) -> ComponentPiece:
            """
            Obtain the Component Piece defined by text and depth.

            :param item_text: The text of the item you wish to obtain.
            :param submenu_depth: The submenu depth of the item to be obtained. A None value implies the menu item is
                at the top-most level of the context menu, while 0 implies the item is within the 0th nested submenu,
                etc.

            :return: The Component Piece defined by the supplied text and submenu depth.
            """
            identifier = (item_text, submenu_depth)
            item = self._item_coll.get(identifier)
            if not item:
                parent_locator_list = self.locator_list if submenu_depth is None \
                    else [self._get_submenu_locator(submenu_depth=submenu_depth)]
                # if item_text is None, we're looking for the submenu wrapper, so ignore any label attribute
                label_piece = f'[data-label="{item_text}"]' if item_text is not None else ""
                item = ComponentPiece(
                    locator=(By.CSS_SELECTOR, f'{self._ITEM_LOCATOR[1]}{label_piece}'),
                    driver=self.driver,
                    parent_locator_list=parent_locator_list,
                    wait_timeout=1)
                self._item_coll[identifier] = item
            return item

        @classmethod
        def _get_submenu_locator(cls, submenu_depth: int = 0) -> Tuple[Union[By, str], str]:
            return By.CSS_SELECTOR, f'#context-submenu-{submenu_depth}'

    def __init__(
            self,
            driver: WebDriver,
            gateway_address: str,
            page_config_path: str,
            primary_view_resource_path: str = None,
            primary_locator: Tuple = None,
            configured_tab_title: str = None,
            wait_timeout: int = 10):
        """
        :param driver: The WebDriver in use for the browser window.
        :param gateway_address: The address of the Gateway this page belongs to.
        :param page_config_path: The path as configured in the Page Configuration panel of the Designer.
        :param primary_view_resource_path: The path of the primary View in use for this Page.
        :param primary_locator: A tuple which describes an element unique to this Page.
        :param configured_tab_title: The title configured for this tab.
        :param wait_timeout: The amount of time (in seconds) to implicitly wait.
        """
        self._internal_page_url_path = page_config_path
        self._full_perspective_path = self._PERSPECTIVE_PATH_PREFIX + self._internal_page_url_path
        super().__init__(
            driver=driver,
            gateway_address=gateway_address,
            destination_path=self._full_perspective_path,
            primary_locator=primary_locator,
            wait_timeout=wait_timeout)
        self.app_bar = AppBar(driver=driver)
        self._primary_view_resource_path = primary_view_resource_path
        self.configured_page_title = configured_tab_title
        self._component_context_menu = PerspectivePageObject.ComponentContextMenu(
            driver=driver)

    def click_item_of_component_context_menu(
            self, item_text: str, submenu_depth: Optional[int] = None, binding_wait_time: float = 0.5) -> None:
        """
        Click an item within the context menu displayed on the Page.
        :param item_text: The text of the item you wish to click.
        :param submenu_depth: The depth of the submenu to query for.
        :param binding_wait_time: The amount of time to wait after clicking the specified item before allowing code to
            continue.
        """
        self._component_context_menu.click_item(
            item_text=item_text, submenu_depth=submenu_depth, binding_wait_time=binding_wait_time)

    def component_context_menu_is_displayed(self) -> bool:
        """
        Determine if a context menu is currently displayed on the Page.

        :return: True, if a context menu is displayed on the current Page - False otherwise.
        """
        return self._component_context_menu.is_displayed()

    def component_context_menu_submenu_is_displayed(self, submenu_depth: int = 0) -> bool:
        """
        Determine if a submenu at the specified depth is currently displayed.

        :param submenu_depth: The depth of the submenu to query for.

        :return: True, if a submenu with the specified depth is currently displayed - False otherwise.
        """
        return self._component_context_menu.submenu_is_displayed(submenu_depth=submenu_depth)

    def get_count_of_components_with_errors(self) -> int:
        """
        Obtain a count of components which are currently displaying a Quality Overlay.

        :return: A count of all components on this Page which are currently displaying a Quality Overlay.
        """
        try:
            error_over_lay_list = self.wait.until(ec.presence_of_all_elements_located((By.TAG_NAME, 'div.cqfo-error')))
        except TimeoutException:
            error_over_lay_list = []
        errors_caught_in_boundary_list = []
        for error_class in self._KNOWN_ERROR_CLASSES:
            errors_caught_in_boundary_list += self.driver.find_elements(By.CSS_SELECTOR, f'div.{error_class}')
        return len(error_over_lay_list + errors_caught_in_boundary_list)

    def get_origin_of_component_context_menu(self) -> Point:
        """
        Get the Cartesian Coordinate of the upper-left corner of the Context Menu, measured from the
        top-left of the viewport.

        :returns: The Cartesian Coordinate of the upper-left corner of the Context Menu, measured from the
            top-left of the viewport.
        """
        return self._component_context_menu.get_origin()

    def get_path_of_expansion_icon_in_submenu_item(self, item_text: str, submenu_depth: Optional[int] = None) -> str:
        """
        Obtain the library path in use by an expansion icon of the context menu.

        :param item_text: The text of the item the expansion icon belongs to.
        :param submenu_depth: The depth of the item in any submenus.

        :return: The library path in use by the expansion icon which belongs to the specified context menu item.
        """
        return self._component_context_menu.get_path_of_expansion_icon(item_text=item_text, submenu_depth=submenu_depth)

    def get_path_of_icon_in_component_context_menu(self, item_text: str, submenu_depth: Optional[int] = None) -> str:
        """
        Obtain the library path in use by an icon of the context menu.

        :param item_text: The text of the item the icon belongs to.
        :param submenu_depth: The depth of the item in any submenus.

        :return: The library path in use by the icon which belongs to the specified context menu item.
        """
        return self._component_context_menu.get_path_of_icon(item_text=item_text, submenu_depth=submenu_depth)

    def get_primary_view_resource_path(self) -> str:
        """
        :return: The path and name of the View used to construct the Page
        """
        if self._primary_view_resource_path is None:
            logging.getLogger(PerspectivePageObject.__name__).warning(
                f'A request was made for a primary view resource path, but none was configured during init() '
                f'for {self._internal_page_url_path}')
        return self._primary_view_resource_path

    def get_page_url_as_configured_in_designer(self) -> str:
        """
        :return: The configured url of the current page WITHOUT the preceding '/project_name'.
        """
        return self._internal_page_url_path

    def get_property_of_component_context_menu_item_icon(
            self, property_name: CSSPropertyValue, item_text: str, submenu_depth: Optional[int] = None) -> str:
        """
        Obtain the CSS value of a property of an icon which belongs to the described context menu item.

        :param item_text: The text of the item the icon belongs to.
        :param property_name: The name of the CSS property you would like a value for.
        :param submenu_depth: The depth of the item/icon.

        :return: The CSS value of the requested property for the icon of the specified context menu item.
        """
        return self._component_context_menu.get_property_of_icon(
            property_name=property_name, item_text=item_text, submenu_depth=submenu_depth)

    def get_property_of_component_context_menu_separator(self, index: int, property_name: CSSPropertyValue) -> str:
        """
        Obtain the CSS value of a property of a separator. Does not currently support submenus.

        :param index: The zero-based index of the separator from which you would like a CSS value.
        :param property_name: The name of the CSS property you would like a value for.

        :return: The CSS value of the specified property for the specified separator.

        :raises TimeoutException: If no separators are found.
        :raises IndexError: If separators are found, but fewer than the specified index.
        """
        return self._component_context_menu.get_property_of_separator(index=index, property_name=property_name)

    def get_termination_of_component_context_menu(self) -> Point:
        """
        Get the Cartesian Coordinate of the bottom-right corner of the Context Menu, measured from the
        top-left of the viewport.

        :returns: The Cartesian Coordinate of the bottom-right corner of the Context Menu, measured from the
            top-left of the viewport.
        """
        return self._component_context_menu.get_termination()

    def hover_over_item_of_component_context_menu(
            self, item_text: str, submenu_depth: Optional[int] = None, binding_wait_time: float = 0) -> None:
        """
        Hover over an item of the context menu. This is extremely important while dealing with submenus.

        :param item_text: The text of the item to hover over.
        :param submenu_depth: The depth of the item which will be hovered over.
        :param binding_wait_time: The amount of time after hovering over the item to wait before allowing code to
            continue.
        """
        self._component_context_menu.hover_over_item(
            item_text=item_text, submenu_depth=submenu_depth, binding_wait_time=binding_wait_time)

    def item_in_component_context_menu_contains_icon(self, item_text: str, submenu_depth: Optional[int] = None) -> bool:
        """
        Determine if an item is currently displaying an icon.

        :param item_text: The text of the item to search for an icon.
        :param submenu_depth: The depth of the item to check.

        :return: True, if the specified item of the context menu is displaying an icon - False otherwise.
        """
        return self._component_context_menu.item_contains_configured_icon(
            item_text=item_text, submenu_depth=submenu_depth)

    def item_is_displayed_in_component_context_menu(self, item_text: str, submenu_depth: Optional[int] = None) -> bool:
        """
        Determine if an item is currently displayed.

        :param item_text: The text of the item to search for.
        :param submenu_depth: The submenu depth of the item to search for. A None value implies the item belongs to
            the top-most level of the context menu, while a 0 value implies the item must belong to the 0th
            submenu, etc.

        :return: True, if the described item is currently displayed - False otherwise.
        """
        return self._component_context_menu.item_is_displayed(item_text=item_text, submenu_depth=submenu_depth)

    def navigate_to(self, **kwargs):
        raise NotImplementedError("You must provide your own navigation pattern for this page.")

    def submenu_item_in_component_context_menu_contains_expansion_icon(
            self, item_text: str, submenu_depth: Optional[int] = None) -> bool:
        """
        Determine if an item of the context menu is currently displaying an expansion icon.

        :param item_text: The text of the item to check for an expansion icon.
        :param submenu_depth: The depth of the item within submenus.

        :return: True, if the item is currently displaying an expansion icon - False otherwise.
        """
        return self._component_context_menu.item_contains_submenu_expansion_icon(
            item_text=item_text, submenu_depth=submenu_depth)

    def wait_for_perspective_page(self) -> None:
        """
        Wait for the page to load, and for the App Bar to render. For projects which do NOT have the App Bar configured
        to display, this function should be overridden or usages should be wrapped in a try/except block to capture
        the TimeoutException which would be raised.

        :raise TimeoutException: If the page does not load, or the App Bar never renders.
        """
        self._wait_for_page()
        self.app_bar.wait_for_revealer_or_app_bar_to_be_displayed()

Pages/PerspectivePageObject.py
inductiveautomation/ignition-automation-tools