diff --git a/shiny/playwright/controller/__init__.py b/shiny/playwright/controller/__init__.py index bd20a0eb6..89b403d2c 100644 --- a/shiny/playwright/controller/__init__.py +++ b/shiny/playwright/controller/__init__.py @@ -1,29 +1,56 @@ -from ._controls import ( - Accordion, - AccordionPanel, - Card, - Chat, - DownloadButton, - DownloadLink, +from ._input_buttons import ( InputActionButton, InputActionLink, - InputCheckbox, - InputCheckboxGroup, InputDarkMode, + InputFile, + InputTaskButton, +) + +from ._input_fields import ( InputDate, InputDateRange, - InputFile, InputNumeric, InputPassword, + InputText, + InputTextArea, +) + +from ._input_controls import ( + InputCheckbox, + InputCheckboxGroup, InputRadioButtons, InputSelect, InputSelectize, InputSlider, InputSliderRange, InputSwitch, - InputTaskButton, - InputText, - InputTextArea, +) + +from ._overlay import ( + Popover, + Tooltip, +) + +from ._layout import ( + Sidebar, +) + +from ._accordion import ( + Accordion, + AccordionPanel, +) + +from ._card import Card, ValueBox + +from ._file import ( + DownloadButton, + DownloadLink, +) + +from ._chat import ( + Chat, +) +from ._navs import ( NavPanel, NavsetBar, NavsetCardPill, @@ -34,6 +61,8 @@ NavsetPillList, NavsetTab, NavsetUnderline, +) +from ._output import ( OutputCode, OutputDataFrame, OutputImage, @@ -42,10 +71,6 @@ OutputText, OutputTextVerbatim, OutputUi, - Popover, - Sidebar, - Tooltip, - ValueBox, ) __all__ = [ diff --git a/shiny/playwright/controller/_accordion.py b/shiny/playwright/controller/_accordion.py new file mode 100644 index 000000000..d5981e156 --- /dev/null +++ b/shiny/playwright/controller/_accordion.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from playwright.sync_api import Locator, Page +from playwright.sync_api import expect as playwright_expect + +from .._types import PatternOrStr, StyleValue, Timeout +from ..expect import expect_not_to_have_class +from ..expect._internal import expect_class_to_have_value as _expect_class_to_have_value +from ..expect._internal import expect_style_to_have_value as _expect_style_to_have_value +from ._base import UiWithContainer, WidthLocM +from ._expect import expect_locator_values_in_list + + +class AccordionPanel( + WidthLocM, + UiWithContainer, +): + """ + Controller for :func:`shiny.ui.accordion_panel`. + """ + + loc_label: Locator + """ + Playwright `Locator` for the accordion panel's label. + """ + loc_icon: Locator + """ + Playwright `Locator` for the accordion panel's icon. + """ + loc_body: Locator + """ + Playwright `Locator` for the accordion panel's body. + """ + loc_header: Locator + """ + Playwright `Locator` for the accordion panel's header. + """ + # loc_body_visible: Locator + # """ + # Playwright `Locator` for the visible accordion panel body + # """ + + def __init__(self, page: Page, id: str, data_value: str) -> None: + """ + Initializes a new instance of the `AccordionPanel` class. + + Parameters + ---------- + page + Playwright `Page` of the Shiny app. + id + The ID of the accordion panel. + data_value + The data value of the accordion panel. + """ + super().__init__( + page, + id=id, + loc=f"> div.accordion-item[data-value='{data_value}']", + loc_container=f"div#{id}.accordion.shiny-bound-input", + ) + + self.loc_label = self.loc.locator( + "> .accordion-header > .accordion-button > .accordion-title" + ) + + self.loc_icon = self.loc.locator( + "> .accordion-header > .accordion-button > .accordion-icon" + ) + + self.loc_body = self.loc.locator("> .accordion-collapse") + self.loc_header = self.loc.locator("> .accordion-header") + self._loc_body_visible = self.loc.locator("> .accordion-collapse.show") + + def expect_label(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + """ + Expects the accordion panel label to have the specified text. + + Parameters + ---------- + value + The expected text pattern or string. + timeout + The maximum time to wait for the label to appear. Defaults to `None`. + """ + playwright_expect(self.loc_label).to_have_text(value, timeout=timeout) + + def expect_body(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + """ + Expects the accordion panel body to have the specified text. + + Parameters + ---------- + value + The expected text pattern or string. + timeout + The maximum time to wait for the body to appear. Defaults to `None`. + """ + playwright_expect(self.loc_body).to_have_text(value, timeout=timeout) + + def expect_icon(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + """ + Expects the accordion panel icon to have the specified text. + + Parameters + ---------- + value + The expected text pattern or string. + timeout + The maximum time to wait for the icon to appear. Defaults to `None`. + """ + playwright_expect(self.loc_icon).to_have_text(value, timeout=timeout) + + def expect_open(self, value: bool, *, timeout: Timeout = None) -> None: + """ + Expects the accordion panel to be open or closed. + + Parameters + ---------- + value + `True` if the accordion panel is expected to be open, `False` otherwise. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + _expect_class_to_have_value( + self.loc_body, + "show", + has_class=value, + timeout=timeout, + ) + + # user sends value of Open: true | false + def set(self, open: bool, *, timeout: Timeout = None) -> None: + """ + Sets the state of the control to open or closed. + + Parameters + ---------- + open + `True` to open the accordion panel, False to close it. + timeout + The maximum time to wait for the control to be visible and interactable. Defaults to `None`. + """ + self.loc.wait_for(state="visible", timeout=timeout) + self.loc.scroll_into_view_if_needed(timeout=timeout) + expect_not_to_have_class(self.loc_body, "collapsing", timeout=timeout) + if self._loc_body_visible.count() != int(open): + self._toggle(timeout=timeout) + + def _toggle(self, *, timeout: Timeout = None) -> None: + """ + Toggles the state of the control. + + Parameters + ---------- + timeout + The maximum time to wait for the control to be visible and interactable. Defaults to `None`. + """ + self.loc.wait_for(state="visible", timeout=timeout) + self.loc.scroll_into_view_if_needed(timeout=timeout) + self.loc_header.click(timeout=timeout) + + +class Accordion( + WidthLocM, + UiWithContainer, +): + """Controller for :func:`shiny.ui.accordion`.""" + + loc: Locator + """ + Playwright `Locator` for each accordion items. + """ + loc_container: Locator + """ + Playwright `Locator` for the accordion container. + """ + # loc_open: Locator + # """ + # `Locator` for the open accordion panel + # """ + + def __init__(self, page: Page, id: str) -> None: + """ + Initializes a new instance of the `Accordion` class. + + Parameters + ---------- + page + Playwright `Page` of the Shiny app. + id + The ID of the accordion. + """ + super().__init__( + page, + id=id, + loc="> div.accordion-item", + loc_container=f"div#{id}.accordion.shiny-bound-input", + ) + # self.loc_open = self.loc.locator( + # "xpath=.", + # # Simple approach as position is not needed + # has=page.locator( + # "> div.accordion-collapse.show", + # ), + # ) + + def expect_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + """ + Expects the accordion to have the specified height. + + Parameters + ---------- + value + The expected height. + timeout + The maximum time to wait for the height to be visible and interactable. Defaults to `None`. + """ + _expect_style_to_have_value( + self.loc_container, "height", value, timeout=timeout + ) + + def expect_width(self, value: StyleValue, *, timeout: Timeout = None) -> None: + """ + Expects the accordion to have the specified width. + + Parameters + ---------- + value + The expected width. + timeout + The maximum time to wait for the width to be visible and interactable. Defaults to `None`. + """ + _expect_style_to_have_value(self.loc_container, "width", value, timeout=timeout) + + def expect_open( + self, + value: list[PatternOrStr], + *, + timeout: Timeout = None, + ) -> None: + expect_locator_values_in_list( + page=self.page, + loc_container=self.loc_container, + el_type=self.page.locator( + "> div.accordion-item", + has=self.page.locator("> div.accordion-collapse.show"), + ), + # el_type="> div.accordion-item:has(> div.accordion-collapse.show)", + arr_name="value", + arr=value, + key="data-value", + timeout=timeout, + ) + + def expect_panels( + self, + value: list[PatternOrStr], + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the accordion to have the specified panels. + + Parameters + ---------- + value + The expected panels. + timeout + The maximum time to wait for the panels to be visible and interactable. Defaults to `None`. + """ + expect_locator_values_in_list( + page=self.page, + loc_container=self.loc_container, + el_type="> div.accordion-item", + arr_name="value", + arr=value, + key="data-value", + timeout=timeout, + ) + + def set( + self, + open: str | list[str], + *, + timeout: Timeout = None, + ) -> None: + """ + Sets the state of the accordion panel. + + Parameters + ---------- + open + The open accordion panel(s). + timeout + The maximum time to wait for the accordion panel to be visible and interactable. Defaults to `None`. + """ + if isinstance(open, str): + open = [open] + for element in self.loc.element_handles(): + element.wait_for_element_state(state="visible", timeout=timeout) + element.scroll_into_view_if_needed(timeout=timeout) + elem_value = element.get_attribute("data-value") + if elem_value is None: + raise ValueError( + "Accordion panel does not have a `data-value` attribute" + ) + self.accordion_panel(elem_value).set(elem_value in open, timeout=timeout) + + def accordion_panel( + self, + data_value: str, + ) -> AccordionPanel: + """ + Returns the accordion panel (:class:`~shiny.playwright.controls.AccordionPanel`) + with the specified data value. + + Parameters + ---------- + data_value + The data value of the accordion panel. + """ + return AccordionPanel(self.page, self.id, data_value) diff --git a/shiny/playwright/controller/_base.py b/shiny/playwright/controller/_base.py new file mode 100644 index 000000000..119e99dab --- /dev/null +++ b/shiny/playwright/controller/_base.py @@ -0,0 +1,440 @@ +"""Facade classes for working with Shiny inputs/outputs in Playwright""" + +# TODO-barret; Possibly move all navset's loc_containers to the parent element (e.g. NavsetCardUnderline should have the container be the containing card.) The `.loc` will then point to the `ul` that has the id +# TODO-barret; This should be the container and the `ul#{id}.navbar-nav` should be the loc +# TODO-barret; Maybe add a `loc_nav_item` that contains `> li.nav-item`? +# TODO-barret; Note: We have access to the panel via `.nav_panel("key")` +# TODO-barret; Maybe add `.loc_sidebar` for the sidebar? + + +from __future__ import annotations + +import typing +from typing import Literal, Protocol + +from playwright.sync_api import Locator, Page +from playwright.sync_api import expect as playwright_expect + +# Import `shiny`'s typing extentions. +# Since this is a private file, tell pyright to ignore the import +from ..._typing_extensions import TypeGuard +from ...types import MISSING_TYPE +from .._types import AttrValue, OptionalFloat, PatternOrStr, Timeout +from ..expect._internal import ( + expect_attribute_to_have_value as _expect_attribute_to_have_value, +) +from ..expect._internal import expect_style_to_have_value as _expect_style_to_have_value + +""" +Questions: +* `_DateBase` is signaled as private, but `InputDateRange` will have two fields of `date_start` and `date_end`. Due to how the init selectors are created, they are not `InputDate` instances. Should we make `_DateBase` public? + +* While `expect_*_to_have_value()` matches the setup of `expect(x).to_have_value()`, it is a bit verbose. Should we just use `expect_*()` as we only use it in a single context? (Only adding the suffix if other methods like `to_have_html()` or `to_have_text()` would make sense.) + * Ans: Try things out + * Ans 3-7-2023: Use small names as there is no need to differentiate using longer names. + +* TODO-future; Make sure multiple usage of `timeout` has the proper values. Should followup usages be `0` to force it to be immediate? (Is `0` the right value?) + * Ans: There was no definition of "now" for a timeout. 0 disables the timeout. +""" + +""" +# Class definitions +* Fields + * Try to mirror playwright as much as possible. + * Do not use verbose methods as we do not need the long name of + `expect_label_to_have_text()` as there is not `expect_label_to_have_html()` + or `expect_label_to_have_attribute()` methods. Just use `expect_label()` + * There are no properties, only methods; This allows for timeout values to be passed through and for complex methods. + * Locators will stay as properties + * Don't sub-class. For now, use `_` separatation and use `loc` or `value` as a prefix +* Approach + * Use locators / playwright_expect as much as possible + * It should not be necessary to use `assert` directly. + * MUST wait for `Locator`s to do their job + * DO NOT provide `value` methods + * Add _set_ methods (or set like methods) only if a user would perform them +""" + +""" +# Mixins +* Use mixins to add consistent functionality to different classes +* These classes should **never** be instantiated directly +* Use `typing.Protocol` to define the interface of what is required on `self` +* Add methods to the mixin if they are consistently used across multiple classes + * If a method is only used in one class, it should be defined in that class + * If a method is used inconsistently, make/use a helper method +""" + +InitLocator = typing.Union[Locator, str] + +R = typing.TypeVar("R") +M1 = typing.TypeVar("M1") +M2 = typing.TypeVar("M2") + + +def is_missing(x: object) -> TypeGuard[MISSING_TYPE]: + return isinstance(x, MISSING_TYPE) + + +# TypeGuard does not work for `not isinstance(x, MISSING_TYPE)` +# See discussion for `StrictTypeGuard`: https://github.com/python/typing/discussions/1013 +# Until then, we need `not_is_missing(x=)` to narrow within an `if` statement +def not_is_missing(x: R | MISSING_TYPE) -> TypeGuard[R]: + return not isinstance(x, MISSING_TYPE) + + +def all_missing(*args: object) -> TypeGuard[MISSING_TYPE]: + for arg in args: + if not_is_missing(arg): + return False + return True + + +def maybe_missing(x: M1 | MISSING_TYPE, default: M2) -> M1 | M2: + if isinstance(x, MISSING_TYPE): + return default + return x + + +def set_text( + loc: Locator, + text: str, + *, + delay: OptionalFloat = None, + timeout: Timeout = None, +) -> None: + """ + Sets the text of a DOM element. + + Parameters + ---------- + loc + Playwright `Locator` of the element. + text + The text to set. + delay + The delay between key presses in milliseconds. Defaults to `None`. + timeout + The maximum time to wait for the text to be set. Defaults to `None`. + """ + # TODO-future; Composable set() method + loc.fill("", timeout=timeout) # Reset the value + loc.type(text, delay=delay, timeout=timeout) # Type the value + + +def _expect_multiple(loc: Locator, multiple: bool, timeout: Timeout = None) -> None: + value = "True" if multiple else None + _expect_style_to_have_value(loc, "multiple", value, timeout=timeout) + + +###################################################### +# # Inputs +###################################################### + + +class UiBaseP(Protocol): + id: str + loc: Locator + page: Page + + +class UiWithContainerP(UiBaseP, Protocol): + """A protocol class representing UI with a container.""" + + loc_container: Locator + """ + Playwright `Locator` for the container of the UI element. + """ + + +class UiWithSidebarP(UiWithContainerP, Protocol): + """A protocol class representing UI with an associated sidebar.""" + + loc_sidebar: Locator + """ + Playwright `Locator` for its sidebar of the UI element. + """ + + +class UiWithTitleP(UiWithContainerP, Protocol): + """A protocol class representing UI with an associated title.""" + + loc_title: Locator + """ + Playwright `Locator` for its title of the UI element. + """ + + +class UiBase: + """A base class representing shiny UI components.""" + + # timeout: Timeout + id: str + """ + The browser DOM `id` of the UI element. + """ + loc: Locator + """ + Playwright `Locator` of the UI element. + """ + page: Page + """ + Playwright `Page` of the Shiny app. + """ + + def __init__( + self, + page: Page, + *, + id: str, + loc: InitLocator, + ) -> None: + self.page = page + # Needed?!? This is covered by `self.loc_root` and possibly `self.loc` + self.id = id + if isinstance(loc, str): + loc = page.locator(loc) + self.loc = loc + + @property + # TODO; Can not publicly find `LocatorAssertions` in `playwright` + def expect(self): + """Expectation method equivalent to `playwright.expect(self.loc)`.""" + # TODO-karan-test: Search for `.loc)` and convert `expect(FOO.loc)` to `FOO.expect`. If we don't like the helper API, we should remove it. + return playwright_expect(self.loc) + + +class UiWithContainer(UiBase): + """ + A mixin class representing UI with a container. + """ + + loc_container: Locator + """ + Playwright `Locator` for the container of the UI element. + """ + + def __init__( + self, + page: Page, + *, + id: str, + loc: InitLocator, + loc_container: InitLocator = "div.shiny-input-container", + ) -> None: + """ + Initializes the input with a container. + + Parameters + ---------- + page + Playwright `Page` of the Shiny app. + id + The id of the UI element. + loc + Playwright `Locator` of the UI element. + loc_container + Playwright `Locator` of the container of the UI element. + """ + loc_is_str = isinstance(loc, str) + loc_container_is_str = isinstance(loc_container, str) + + if loc_is_str and loc_container_is_str: + loc_container = page.locator(loc_container) + if loc == "xpath=.": + # If `loc` is self, then use `loc_container` as `loc` + loc = loc_container + + else: + loc_container = loc_container.locator( + # `page.locator(loc)` is executed from within `loc_container` + "xpath=.", + has=page.locator(loc), + ) + + loc = loc_container.locator(loc) + elif not loc_is_str and not loc_container_is_str: + ... # Do nothing; Use values as is + else: + raise ValueError( + "`loc` and `loc_container` must both be strings or both be Locators" + ) + + super().__init__( + page, + id=id, + loc=loc, + ) + self.loc_container = loc_container + + +class UiWithLabel(UiWithContainer): + """A mixin class representing UI components with a label.""" + + loc_label: Locator + """ + Playwright `Locator` for the label of the UI element. + """ + + def __init__( + self, + page: Page, + *, + id: str, + loc: InitLocator, + loc_container: InitLocator = "div.shiny-input-container", + loc_label: InitLocator | None = None, + ) -> None: + """ + Initializes the input with a label. + + Parameters + ---------- + page + The page where the input is located. + id + The id of the UI element. + loc + Playwright `Locator` of the UI element. + loc_container + Playwright `Locator` of the container of the UI element. + loc_label + Playwright `Locator` of the label of the UI element. Defaults to `None`. + """ + super().__init__( + page, + id=id, + loc_container=loc_container, + loc=loc, + ) + + if loc_label is None: + loc_label = f"label#{id}-label" + if isinstance(loc_label, str): + loc_label = self.loc_container.locator(loc_label) + self.loc_label = loc_label + + def expect_label( + self, + value: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + """ + Expect the label of the input to have a specific text. + + Parameters + ---------- + value + The expected text value of the label. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + playwright_expect(self.loc_label).to_have_text(value, timeout=timeout) + + +class WidthLocM: + """ + A mixin class representing the `.loc`'s width. + + This class provides methods to expect the width attribute of a DOM element. + """ + + def expect_width( + self: UiBaseP, + value: AttrValue, + *, + timeout: Timeout = None, + ) -> None: + """ + Expect the `width` attribute of a DOM element to have a specific value. + + Parameters + ---------- + value + The expected value of the `width` attribute. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + _expect_attribute_to_have_value(self.loc, "width", value=value, timeout=timeout) + + +class WidthContainerM: + """ + A mixin class representing the container's width. + + This class provides methods to expect the width attribute of a DOM element's container. + """ + + def expect_width( + self: UiWithContainerP, + value: AttrValue, + *, + timeout: Timeout = None, + ) -> None: + """ + Expect the `width` attribute of a input's container to have a specific value. + + Parameters + ---------- + value + The expected value of the `width` attribute. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + _expect_attribute_to_have_value( + self.loc_container, "width", value=value, timeout=timeout + ) + + +class InputActionBase(UiBase): + def expect_label( + self, + value: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + """ + Expect the label of the input button to have a specific value. + + Note: This must include the icon if it is present! + + Parameters + ---------- + value + The expected value of the label. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + + self.expect.to_have_text(value, timeout=timeout) + + def click(self, *, timeout: Timeout = None, **kwargs: object) -> None: + """ + Clicks the input action. + + Parameters + ---------- + timeout + The maximum time to wait for the input action to be clicked. Defaults to `None`. + """ + self.loc.click(timeout=timeout, **kwargs) # pyright: ignore[reportArgumentType] + + +Resize = Literal["none", "both", "horizontal", "vertical"] + + +# * click: +# * input_checkbox_group +# * input_radio_buttons + + +###################################################### +# # Outputs +###################################################### + + +class OutputBaseP(Protocol): + id: str + loc: Locator + page: Page diff --git a/shiny/playwright/controller/_card.py b/shiny/playwright/controller/_card.py new file mode 100644 index 000000000..dda14507f --- /dev/null +++ b/shiny/playwright/controller/_card.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +from typing import Protocol + +from playwright.sync_api import Locator, Page +from playwright.sync_api import expect as playwright_expect + +from .._types import PatternOrStr, StyleValue, Timeout +from ..expect._internal import expect_style_to_have_value as _expect_style_to_have_value +from ._base import OutputBaseP, UiBaseP, UiWithContainer, WidthLocM + + +class _CardBodyP(UiBaseP, Protocol): + """ + Represents the body of a card control. + """ + + loc_body: Locator + """ + Playwright `Locator` for the body element of the card control. + """ + + +class _CardBodyM: + """Represents a card body element with additional methods for testing.""" + + def expect_body( + self: _CardBodyP, + value: PatternOrStr | list[PatternOrStr], + *, + timeout: Timeout = None, + ) -> None: + """Expect the card body element to have the specified text. + + Parameters + ---------- + value + The expected text or a list of expected texts. + timeout + The maximum time to wait for the text to appear. Defaults to `None`. + """ + playwright_expect(self.loc).to_have_text( + value, + timeout=timeout, + ) + + +class _CardFooterLayoutP(UiBaseP, Protocol): + """ + Represents the layout of the footer in a card. + """ + + loc_footer: Locator + """ + Playwright `Locator` for the footer element. + """ + + +class _CardFooterM: + """ + Represents the footer section of a card. + """ + + def expect_footer( + self: _CardFooterLayoutP, + value: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + """ + Asserts that the footer section of the card has the expected text. + + Parameters + ---------- + value + The expected text in the footer section. + timeout + The maximum time to wait for the footer text to appear. Defaults to `None`. + """ + playwright_expect(self.loc_footer).to_have_text( + value, + timeout=timeout, + ) + + +class _CardValueBoxFullScreenLayoutP(OutputBaseP, Protocol): + """ + Represents a card / Value Box full-screen layout for the Playwright controls. + """ + + loc_title: Locator + """ + Playwright `Locator` for the title element. + """ + _loc_fullscreen: Locator + """ + Playwright `Locator` for the full-screen element. + """ + _loc_close_button: Locator + """ + Playwright `Locator` for the close button element. + """ + + +class _CardValueBoxFullScreenM: + """ + Represents a class for managing full screen functionality of a Card or Value Box. + """ + + def set_full_screen( + self: _CardValueBoxFullScreenLayoutP, open: bool, *, timeout: Timeout = None + ) -> None: + """ + Sets the element to full screen mode or exits full screen mode. + + Parameters + ---------- + open + `True` to open the element in full screen mode, `False` to exit full screen mode. + timeout + The maximum time to wait for the operation to complete. Defaults to `None`. + """ + if open: + self.loc_title.hover(timeout=timeout) + self._loc_fullscreen.wait_for(state="visible", timeout=timeout) + self._loc_fullscreen.click(timeout=timeout) + else: + self._loc_close_button.click(timeout=timeout) + + def expect_full_screen( + self: _CardValueBoxFullScreenLayoutP, value: bool, *, timeout: Timeout = None + ) -> None: + """ + Verifies if the full screen mode is currently open. + + Parameters + ---------- + value + `True` if the item is to be in full screen mode, `False` otherwise. + timeout + The maximum time to wait for the verification. Defaults to `None`. + """ + playwright_expect(self._loc_close_button).to_have_count( + int(value), timeout=timeout + ) + + def expect_full_screen_available( + self: _CardValueBoxFullScreenLayoutP, + value: bool, + *, + timeout: Timeout = None, + ) -> None: + """ + Expects whether full screen mode is available for the element. + + Parameters + ---------- + value + `True` if the element is expected to be available for full screen mode, False otherwise. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + playwright_expect(self._loc_fullscreen).to_have_count( + int(value), timeout=timeout + ) + + +class ValueBox( + WidthLocM, + _CardValueBoxFullScreenM, + UiWithContainer, +): + """ + Controller for :func:`shiny.ui.value_box`. + """ + + loc: Locator + """ + Playwright `Locator` for the value box's value. + """ + loc_showcase: Locator + """ + Playwright `Locator` for the value box showcase. + """ + loc_title: Locator + """ + Playwright `Locator` for the value box title. + """ + loc_body: Locator + """ + Playwright `Locator` for the value box body. + """ + + def __init__(self, page: Page, id: str) -> None: + """ + Initializes a new instance of the `ValueBox` class. + + Parameters + ---------- + page + Playwright `Page` of the Shiny app. + id + The ID of the value box. + + """ + super().__init__( + page, + id=id, + loc_container=f"div#{id}.bslib-value-box", + loc="> div.card-body > .value-box-grid, > div.card-body", + ) + value_box_grid = self.loc + self.loc_showcase = value_box_grid.locator("> .value-box-showcase") + self.loc_title = value_box_grid.locator("> .value-box-area > .value-box-title") + self.loc = value_box_grid.locator("> .value-box-area > .value-box-value") + self.loc_body = value_box_grid.locator( + "> .value-box-area > :not(.value-box-title, .value-box-value)" + ) + self._loc_fullscreen = self.loc_container.locator( + "> bslib-tooltip > .bslib-full-screen-enter" + ) + + # an easier approach is using `#bslib-full-screen-overlay:has(+ div#{id}.card) > a` + # but playwright doesn't allow that + self._loc_close_button = ( + self.page.locator(f"#bslib-full-screen-overlay + div#{id}.bslib-value-box") + .locator("..") + .locator("#bslib-full-screen-overlay > a") + ) + + def expect_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + """ + Expects the value box to have a specific height. + + Parameters + ---------- + value + The expected height value. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + _expect_style_to_have_value( + self.loc_container, "max-height", value, timeout=timeout + ) + + def expect_title( + self, + value: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the value box title to have a specific text. + + Parameters + ---------- + value + The expected text pattern or string. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + + """ + playwright_expect(self.loc_title).to_have_text( + value, + timeout=timeout, + ) + + def expect_value( + self, + value: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the value box value to have a specific text. + + Parameters + ---------- + value + The expected text pattern or string. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + playwright_expect(self.loc).to_have_text( + value, + timeout=timeout, + ) + + def expect_body( + self, + value: PatternOrStr | list[PatternOrStr], + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the value box body to have specific text. + + Parameters + ---------- + value + The expected text pattern or list of patterns/strings. + + Note: If testing against multiple elements, text should be an array. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + playwright_expect(self.loc_body).to_have_text( + value, + timeout=timeout, + ) + + +class Card( + WidthLocM, + _CardFooterM, + _CardBodyM, + _CardValueBoxFullScreenM, + UiWithContainer, +): + """ + Controller for :func:`shiny.ui.card`. + """ + + loc_container: Locator + """ + Playwright `Locator` for the card container. + """ + loc: Locator + """ + Playwright `Locator` for the card's value. + """ + loc_title: Locator + """ + Playwright `Locator` for the card title. + """ + loc_footer: Locator + """ + Playwright `Locator` for the card footer. + """ + loc_body: Locator + """ + Playwright `Locator` for the card body. + """ + + def __init__(self, page: Page, id: str) -> None: + """ + Initializes a new instance of the `Card` class. + + Parameters + ---------- + page + Playwright `Page` of the Shiny app. + id + The ID of the card. + """ + super().__init__( + page, + id=id, + loc_container=f"div#{id}.card", + loc="> div.card-body", + ) + self.loc_title = self.loc_container.locator("> div.card-header") + self.loc_footer = self.loc_container.locator("> div.card-footer") + self._loc_fullscreen = self.loc_container.locator( + "> bslib-tooltip > .bslib-full-screen-enter" + ) + # an easier approach is using `#bslib-full-screen-overlay:has(+ div#{id}.card) > a` + # but playwright doesn't allow that + self._loc_close_button = ( + self.page.locator(f"#bslib-full-screen-overlay + div#{id}") + .locator("..") + .locator("#bslib-full-screen-overlay > a") + ) + self.loc_body = self.loc + + def expect_header( + self, + value: PatternOrStr | None, + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the card header to have a specific text. + + Parameters + ---------- + value + The expected text pattern or string. + + Note: `None` if the header is expected to not exist. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + if value is None: + playwright_expect(self.loc_title).to_have_count(0, timeout=timeout) + else: + playwright_expect(self.loc_title).to_have_text(value, timeout=timeout) + + # def expect_body( + # self, + # text: PatternOrStr, + # index: int = 0, + # *, + # timeout: Timeout = None, + # ) -> None: + # """Note: Function requires an index since multiple bodies can exist in loc""" + # playwright_expect(self.loc.nth(index).locator("> :first-child")).to_have_text( + # text, + # timeout=timeout, + # ) + + def expect_footer( + self, + value: PatternOrStr | None, + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the card footer to have a specific text. + + Parameters + ---------- + value + The expected text pattern or string + Note: None if the footer is expected to not exist. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + if value is None: + playwright_expect(self.loc_footer).to_have_count(0, timeout=timeout) + else: + playwright_expect(self.loc_footer).to_have_text(value, timeout=timeout) + + def expect_max_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + """ + Expects the card to have a specific maximum height. + + Parameters + ---------- + value + The expected maximum height value. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + _expect_style_to_have_value( + self.loc_container, "max-height", value, timeout=timeout + ) + + def expect_min_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + """ + Expects the card to have a specific minimum height. + + Parameters + ---------- + value + The expected minimum height value. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + _expect_style_to_have_value( + self.loc_container, "min-height", value, timeout=timeout + ) + + def expect_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + """ + Expects the card to have a specific height. + + Parameters + ---------- + value + The expected height value. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + _expect_style_to_have_value( + self.loc_container, "height", value, timeout=timeout + ) diff --git a/shiny/playwright/controller/_chat.py b/shiny/playwright/controller/_chat.py new file mode 100644 index 000000000..edcfcaa79 --- /dev/null +++ b/shiny/playwright/controller/_chat.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Literal + +from playwright.sync_api import Locator, Page +from playwright.sync_api import expect as playwright_expect + +from .._types import PatternOrStr, Timeout +from ._base import UiBase + + +class Chat(UiBase): + """Controller for :func:`shiny.ui.chat`.""" + + loc: Locator + """ + Playwright `Locator` for the chat. + """ + loc_messages: Locator + """ + Playwright `Locator` for the chat messages. + """ + loc_latest_message: Locator + """ + Playwright `Locator` for the last message in the chat. + """ + loc_input_container: Locator + """ + Playwright `Locator` for the chat input container. + """ + loc_input: Locator + """ + Playwright `Locator` for the chat's