diff --git a/CHANGELOG.md b/CHANGELOG.md index 092eaa051..3437a3213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Some copies of Windows 10 have registry entries mapping .js files to content type "text/plain", which was causing all sorts of problems for browsers. (#1624) +* Added missing support for `express.ui.navset_card_pill(placement:)`. (#1602) + +* Added `.expect_sidebar()` and `.expect_title()` methods for `NavsetCardTab`, `NavsetCardPill`, `NavsetCardUnderline`, and `NavsetBar`. (#1602) + +* Added `.expect_placement()` method for `NavsetCardPill` and `NavsetCardUnderline`. (#1602) + ### Deprecations ## [1.0.0] - 2024-07-18 diff --git a/docs/_quartodoc-testing.yml b/docs/_quartodoc-testing.yml index f91f1c854..a94c1291f 100644 --- a/docs/_quartodoc-testing.yml +++ b/docs/_quartodoc-testing.yml @@ -48,7 +48,6 @@ quartodoc: - title: Navigation (tab) panels desc: Methods for interacting with Shiny app UI content controller. contents: - - playwright.controller.NavPanel - playwright.controller.NavsetBar - playwright.controller.NavsetCardPill - playwright.controller.NavsetCardTab @@ -58,6 +57,7 @@ quartodoc: - playwright.controller.NavsetPillList - playwright.controller.NavsetTab - playwright.controller.NavsetUnderline + - playwright.controller.NavPanel - title: Upload and download desc: Methods for interacting with Shiny app uploading and downloading controller. contents: diff --git a/shiny/express/ui/_cm_components.py b/shiny/express/ui/_cm_components.py index 96fa410a1..9d5b5e517 100644 --- a/shiny/express/ui/_cm_components.py +++ b/shiny/express/ui/_cm_components.py @@ -927,6 +927,7 @@ def navset_card_pill( sidebar: Optional[ui.Sidebar] = None, header: TagChild = None, footer: TagChild = None, + placement: Literal["above", "below"] = "above", ) -> RecallContextManager[NavSetCard]: """ Context manager for a set of nav items as a tabset inside a card container. @@ -947,6 +948,8 @@ def navset_card_pill( UI to display above the selected content. footer UI to display below the selected content. + placement + Placement of the nav items relative to the content. """ return RecallContextManager( ui.navset_card_pill, @@ -957,6 +960,7 @@ def navset_card_pill( sidebar=sidebar, header=header, footer=footer, + placement=placement, ), ) diff --git a/shiny/playwright/controller/_controls.py b/shiny/playwright/controller/_controls.py index 6b9fa78da..021cdb29e 100644 --- a/shiny/playwright/controller/_controls.py +++ b/shiny/playwright/controller/_controls.py @@ -1,5 +1,12 @@ """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 json @@ -157,6 +164,24 @@ class _UiWithContainerP(_UiBaseP, Protocol): """ +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.""" @@ -238,9 +263,10 @@ def __init__( loc = loc_container else: - loc_container = loc_container.filter( + loc_container = loc_container.locator( # `page.locator(loc)` is executed from within `loc_container` - has=page.locator(loc) + "xpath=.", + has=page.locator(loc), ) loc = loc_container.locator(loc) @@ -1587,7 +1613,6 @@ def set(self, value: bool, *, timeout: Timeout = None, **kwargs: object) -> None value, timeout=timeout, **kwargs # pyright: ignore[reportArgumentType] ) - # TODO-karan-test: Convert usage of _toggle() to set() def _toggle(self, *, timeout: Timeout = None, **kwargs: object) -> None: """ Toggles the input checkbox. @@ -1769,7 +1794,6 @@ def expect_locator_contains_values_in_list( for item in arr: # Given the container, make sure it contains this locator loc_container = loc_container.locator( - # Return self "xpath=.", # Simple approach as position is not needed has=page.locator( @@ -4891,8 +4915,7 @@ def __init__(self, page: Page, id: str) -> None: loc_container=f"div#{id}.accordion.shiny-bound-input", ) # self.loc_open = self.loc.locator( - # # Return self - # "xpath=.", + # "xpath=.", # # Simple approach as position is not needed # has=page.locator( # "> div.accordion-collapse.show", @@ -5352,7 +5375,7 @@ def set(self, open: bool, timeout: Timeout = None) -> None: The maximum time to wait for the popover to be visible and interactable. Defaults to `None`. """ if open ^ self.get_loc_overlay_body(timeout=timeout).count() > 0: - self._toggle() + self._toggle(timeout=timeout) def _toggle(self, timeout: Timeout = None) -> None: """ @@ -5441,13 +5464,87 @@ def _toggle(self, timeout: Timeout = None) -> None: self.loc_trigger.hover(timeout=timeout) -class _NavPanelBase(_UiWithContainer): +class _ExpectNavsetSidebarM: + def expect_sidebar( + self: _UiWithSidebarP, + exists: bool, + *, + timeout: Timeout = None, + ) -> None: + """ + Assert whether or not the sidebar exists within the navset. + + Parameters + ---------- + exists + `True` if the sidebar exists within the navset. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + playwright_expect(self.loc_sidebar).to_have_count(int(exists), timeout=timeout) + + +class _ExpectNavsetTitleM: + """A mixin class for Navset title controls""" + + def expect_title( + self: _UiWithTitleP, + value: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the navset title to have the specified 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) + + +class _ExpectNavsetPlacementM: + def expect_placement( + self: _UiWithContainerP, + location: Literal["above", "below"] = "above", + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the navset to have the specified placement. + + Parameters + ---------- + location + The expected placement location. Defaults to `'above'`. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + ex_class = "card-header" if location == "above" else "card-footer" + playwright_expect(self.loc_container.locator("..")).to_have_class( + ex_class, timeout=timeout + ) + + +class _NavsetBase(_UiWithContainer): """A Base mixin class for Nav controls""" def nav_panel( self, value: str, ) -> NavPanel: + """ + Returns the nav panel (:class:`~shiny.playwright.controls.NavPanel`) + with the specified value. + + Parameters + ---------- + value + The value of the nav panel. + """ return NavPanel(self.page, self.id, value) def set(self, value: str, *, timeout: Timeout = None) -> None: @@ -5561,6 +5658,20 @@ def expect_nav_titles( """ self.expect.to_have_text(value, timeout=timeout) + # # 2024-08-23-barret: + # # These two functions are not implemented due to the inability to create a locator + # # as some of the text contents are not contained within a defined parent element. + # # This makes querying the header only (or footer only) impossible. When they are + # # used within `navset_card_*()`, the header or footer _could_ be given within a + # # `core_ui.CardItem()`. This CardItem could contain a TagList, putting us back into + # # the same situation. Therefore, no move is currently the safe move. If we want to + # # expose anything, maybe we could expose the navset card body container, but there + # # is no container for the non-card navsets. :-( + # def expect_header(): + # raise NotImplementedError("Not implemented yet") + # def expect_footer(): + # raise NotImplementedError("Not implemented yet") + class NavPanel(_UiWithContainer): """Controller for :func:`shiny.ui.nav_panel`.""" @@ -5576,8 +5687,12 @@ class NavPanel(_UiWithContainer): """ Playwright `Locator` for the nav panel container. """ + panel_value: str + """ + The `data-value` attribute used to identify the nav panel within the larger navset. + """ - def __init__(self, page: Page, id: str, data_value: str) -> None: + def __init__(self, page: Page, id: str, panel_value: str) -> None: """ Initializes a new instance of the `NavPanel` class. @@ -5587,17 +5702,17 @@ def __init__(self, page: Page, id: str, data_value: str) -> None: Playwright `Page` of the Shiny app. id The ID of the nav panel. - data_value - The data value of the nav panel. + panel_value + The panel value of the nav panel. """ super().__init__( page, id=id, - loc=f"a[role='tab'][data-value='{data_value}']", + loc=f"a[role='tab'][data-value='{panel_value}']", loc_container=f"ul#{id}", ) - self._data_value: str = data_value + self.panel_value: str = panel_value # TODO-future: Make it a single locator expectation # get active content instead of assertion @@ -5610,7 +5725,7 @@ def loc_content(self) -> Locator: """ datatab_id = self.loc_container.get_attribute("data-tabsetid") return self.page.locator( - f"div.tab-content[data-tabsetid='{datatab_id}'] > div.tab-pane[data-value='{self._data_value}']" + f"div.tab-content[data-tabsetid='{datatab_id}'] > div.tab-pane[data-value='{self.panel_value}']" ) def click(self, *, timeout: Timeout = None) -> None: @@ -5658,7 +5773,7 @@ def _expect_content_text( playwright_expect(self.loc_content).to_have_text(value, timeout=timeout) -class NavsetTab(_NavPanelBase): +class NavsetTab(_NavsetBase): """Controller for :func:`shiny.ui.navset_tab`.""" loc: Locator @@ -5689,7 +5804,7 @@ def __init__(self, page: Page, id: str) -> None: ) -class NavsetPill(_NavPanelBase): +class NavsetPill(_NavsetBase): """Controller for :func:`shiny.ui.navset_pill`.""" def __init__(self, page: Page, id: str) -> None: @@ -5711,7 +5826,7 @@ def __init__(self, page: Page, id: str) -> None: ) -class NavsetUnderline(_NavPanelBase): +class NavsetUnderline(_NavsetBase): """Controller for :func:`shiny.ui.navset_underline`.""" def __init__(self, page: Page, id: str) -> None: @@ -5733,7 +5848,7 @@ def __init__(self, page: Page, id: str) -> None: ) -class NavsetPillList(_NavPanelBase): +class NavsetPillList(_NavsetBase): """Controller for :func:`shiny.ui.navset_pill_list`.""" def __init__(self, page: Page, id: str) -> None: @@ -5754,8 +5869,58 @@ def __init__(self, page: Page, id: str) -> None: loc="> li.nav-item", ) + def expect_well(self, has_well: bool, *, timeout: Timeout = None) -> None: + """ + Expects the navset pill list to have a well. + + Parameters + ---------- + has_well + `True` if the navset pill list is expected to have a well, `False` otherwise. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + if has_well: + playwright_expect(self.loc_container.locator("..")).to_have_class("well") + else: + playwright_expect(self.loc_container.locator("..")).not_to_have_class( + "well" + ) + + +class _NavsetCardBase( + _ExpectNavsetSidebarM, + _ExpectNavsetTitleM, + _NavsetBase, +): + def __init__( + self, + page: Page, + *, + id: str, + loc: InitLocator, + loc_container: InitLocator, + ) -> None: + """ + Shim class to add consistent `.loc_sidebar` definition + """ + super().__init__( + page, + id=id, + loc_container=loc_container, + loc=loc, + ) + self.loc_sidebar = self.loc_container.locator("..").locator( + "+ .bslib-sidebar-layout" + ) + self.loc_title = ( + self.loc_container.locator("..") + .locator("> span") + .locator("xpath=.", has=self.page.locator(f"+ ul#{self.id}")) + ) + -class NavsetCardTab(_NavPanelBase): +class NavsetCardTab(_NavsetCardBase): """Controller for :func:`shiny.ui.navset_card_tab`.""" def __init__(self, page: Page, id: str) -> None: @@ -5777,7 +5942,7 @@ def __init__(self, page: Page, id: str) -> None: ) -class NavsetCardPill(_NavPanelBase): +class NavsetCardPill(_ExpectNavsetPlacementM, _NavsetCardBase): """Controller for :func:`shiny.ui.navset_card_pill`.""" def __init__(self, page: Page, id: str) -> None: @@ -5799,7 +5964,7 @@ def __init__(self, page: Page, id: str) -> None: ) -class NavsetCardUnderline(_NavPanelBase): +class NavsetCardUnderline(_ExpectNavsetPlacementM, _NavsetCardBase): """Controller for :func:`shiny.ui.navset_card_underline`.""" def __init__(self, page: Page, id: str) -> None: @@ -5821,7 +5986,7 @@ def __init__(self, page: Page, id: str) -> None: ) -class NavsetHidden(_NavPanelBase): +class NavsetHidden(_NavsetBase): """Controller for :func:`shiny.ui.navset_hidden`.""" def __init__(self, page: Page, id: str) -> None: @@ -5843,7 +6008,11 @@ def __init__(self, page: Page, id: str) -> None: ) -class NavsetBar(_NavPanelBase): +class NavsetBar( + _ExpectNavsetSidebarM, + _ExpectNavsetTitleM, + _NavsetBase, +): """Controller for :func:`shiny.ui.navset_bar`.""" def __init__(self, page: Page, id: str) -> None: @@ -5863,6 +6032,114 @@ def __init__(self, page: Page, id: str) -> None: loc_container=f"ul#{id}.navbar-nav", loc="> li.nav-item", ) + self._loc_navbar = self.loc_container.locator("..").locator("..").locator("..") + + # This location is different than the `_NavsetCardBase.loc_title` + self.loc_title = ( + self.loc_container.locator("..").locator("..").locator("> .navbar-brand") + ) + # This location is different than the `_NavsetCardBase.loc_sidebar` + self.loc_sidebar = ( + self.loc_container.locator("..") + .locator("..") + .locator("..") + .locator("+ div > .bslib-sidebar-layout") + ) + + def expect_position( + self, + position: Literal[ + "fixed-top", "fixed-bottom", "static-top", "sticky-top" + ] = "static-top", + *, + timeout: Timeout = None, + ) -> None: + """ + Expects the navset bar to have the specified position. + + Parameters + ---------- + position + The expected position. Defaults to `'static-top'`. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + if position == "static-top": + # static-top class is not provided by the code. + # Therefore we must check that all other classes are **not** found + playwright_expect(self._loc_navbar).not_to_have_class( + re.compile(r"(^|\s+)(fixed-top|fixed-bottom|sticky-top)(\s+|$)"), + timeout=timeout, + ) + else: + playwright_expect(self._loc_navbar).to_have_class( + re.compile(rf"{position}"), timeout=timeout + ) + + def expect_inverse(self, *, timeout: Timeout = None) -> None: + """ + Expects the navset bar to be light text color if inverse is True + + Parameters + ---------- + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + playwright_expect(self._loc_navbar).to_have_class( + re.compile("navbar-inverse"), timeout=timeout + ) + + def expect_bg(self, bg: str, *, timeout: Timeout = None) -> None: + """ + Expects the navset bar to have the specified background color. + + Parameters + ---------- + bg + The expected background color. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + _expect_style_to_have_value( + self._loc_navbar, "background-color", f"{bg} !important", timeout=timeout + ) + + def expect_gap(self, gap: str, *, timeout: Timeout = None) -> None: + """ + Expects the navset bar to have the specified gap. + + Parameters + ---------- + gap + The expected gap. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + _expect_style_to_have_value( + self.get_loc_active_content(), "gap", gap, timeout=timeout + ) + + def expect_layout( + self, layout: Literal["fluid", "fixed"] = "fluid", *, timeout: Timeout = None + ) -> None: + """ + Expects the navset bar to have the specified layout. + + Parameters + ---------- + layout + The expected layout. + timeout + The maximum time to wait for the expectation to pass. Defaults to `None`. + """ + if layout == "fluid": + playwright_expect( + self.loc_container.locator("..").locator("..") + ).to_have_class(re.compile("container-fluid"), timeout=timeout) + else: + playwright_expect(self.loc_container.locator("..")).to_have_class( + re.compile("container"), timeout=timeout + ) class Chat(_UiBase): diff --git a/tests/playwright/shiny/components/accordion/test_accordion.py b/tests/playwright/shiny/components/accordion/test_accordion.py index 4d8b6629e..adb038d03 100644 --- a/tests/playwright/shiny/components/accordion/test_accordion.py +++ b/tests/playwright/shiny/components/accordion/test_accordion.py @@ -74,6 +74,7 @@ def test_accordion(page: Page, local_app: ShinyAppProc) -> None: # add timeout to wait for css animation page.wait_for_timeout(100) acc_panel_updated_A._toggle() + output_txt_verbatim.expect_value( "input.acc(): ('updated_section_a', 'Section C', 'Section D')" ) diff --git a/tests/playwright/shiny/components/nav/navset_bar_kitchensink/app.py b/tests/playwright/shiny/components/nav/navset_bar_kitchensink/app.py new file mode 100644 index 000000000..0ec95f4f2 --- /dev/null +++ b/tests/playwright/shiny/components/nav/navset_bar_kitchensink/app.py @@ -0,0 +1,75 @@ +from shiny.express import ui + + +def navset_sidebar(): + from shiny import ui as core_ui + + return core_ui.sidebar(core_ui.markdown("Sidebar content")) + + +navset_bar_infos = [ + ( + "fixed-top", + { + "title": "navset_bar_fixed_top_position_selected", + "id": "navset_bar_fixed_top_position_selected", + "position": "fixed-top", + "selected": "B", + }, + ), + ( + "fixed-bottom", + { + "title": "navset_bar_header_footer_fixed_bottom_position", + "id": "navset_bar_header_footer_fixed_bottom_position", + "header": "Header", + "footer": "Footer", + "position": "fixed-bottom", + }, + ), + ( + "sticky-top", + { + "title": "navset_bar_with_sidebar_collapsible_bg_inverse", + "id": "navset_bar_with_sidebar_collapsible_bg_inverse", + "sidebar": navset_sidebar(), + "collapsible": True, + "bg": "DodgerBlue", + "inverse": True, + "position": "sticky-top", + }, + ), + ( + "fixed", + { + "title": "navset_bar_collapsible_underline_fixed_gap", + "id": "navset_bar_collapsible_underline_fixed_gap", + "collapsible": False, + "underline": True, + "fluid": False, + "gap": "50px", + }, + ), +] + +# Add extra spaces so that the navset_tab is below the fixed-top navset_bar +ui.br() +ui.br() +ui.br() +ui.br() + +# TODO-karan; Put each navset_bar into a navpanel within a navset_tab (similar to the navsets_kitchensink app) +with ui.navset_tab(id="navsets_collection"): + for tab_name, navset_args in navset_bar_infos: + with ui.nav_panel(tab_name): + + with ui.card(style="position: relative;"): + + with ui.navset_bar( + **navset_args # pyright: ignore[reportArgumentType] + ): + with ui.nav_panel("A"): + "Panel A content" + + with ui.nav_panel("B"): + "Panel B content" diff --git a/tests/playwright/shiny/components/nav/navset_bar_kitchensink/test_navset_bar.py b/tests/playwright/shiny/components/nav/navset_bar_kitchensink/test_navset_bar.py new file mode 100644 index 000000000..b7d323ca9 --- /dev/null +++ b/tests/playwright/shiny/components/nav/navset_bar_kitchensink/test_navset_bar.py @@ -0,0 +1,57 @@ +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_navset_bar_kitchensink(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + # Update the page size to be wider + page.set_viewport_size({"width": 1500, "height": 800}) + + navset_tab = controller.NavsetTab(page, "navsets_collection") + navset_bar_fixed_top_position_selected = controller.NavsetBar( + page, + "navset_bar_fixed_top_position_selected", + ) + navset_bar_header_footer_fixed_bottom_position = controller.NavsetBar( + page, + "navset_bar_header_footer_fixed_bottom_position", + ) + navset_bar_with_sidebar_collapsible_bg_inverse = controller.NavsetBar( + page, + "navset_bar_with_sidebar_collapsible_bg_inverse", + ) + navset_bar_collapsible_underline_fixed_gap = controller.NavsetBar( + page, + "navset_bar_collapsible_underline_fixed_gap", + ) + + navset_tab.set("fixed-top") + navset_bar_fixed_top_position_selected._expect_content_text("Panel B content") + navset_bar_fixed_top_position_selected.expect_position("fixed-top") + navset_bar_fixed_top_position_selected.expect_value("B") + + navset_tab.set("fixed-bottom") + navset_bar_header_footer_fixed_bottom_position._expect_content_text( + "Panel A content" + ) + navset_bar_header_footer_fixed_bottom_position.expect_position("fixed-bottom") + navset_bar_header_footer_fixed_bottom_position.expect_value("A") + + navset_tab.set("sticky-top") + navset_bar_with_sidebar_collapsible_bg_inverse._expect_content_text( + "Panel A content" + ) + navset_bar_with_sidebar_collapsible_bg_inverse.expect_position("sticky-top") + navset_bar_with_sidebar_collapsible_bg_inverse.expect_inverse() + navset_bar_with_sidebar_collapsible_bg_inverse.expect_bg("DodgerBlue") + navset_bar_with_sidebar_collapsible_bg_inverse.expect_sidebar(True) + navset_bar_with_sidebar_collapsible_bg_inverse.expect_layout("fluid") + + navset_tab.set("fixed") + navset_bar_collapsible_underline_fixed_gap._expect_content_text("Panel A content") + navset_bar_collapsible_underline_fixed_gap.expect_value("A") + navset_bar_collapsible_underline_fixed_gap.expect_gap("50px") + navset_bar_collapsible_underline_fixed_gap.expect_layout("fixed") diff --git a/tests/playwright/shiny/components/nav/navsets_kitchensink/app.py b/tests/playwright/shiny/components/nav/navsets_kitchensink/app.py new file mode 100644 index 000000000..6dc08f2d6 --- /dev/null +++ b/tests/playwright/shiny/components/nav/navsets_kitchensink/app.py @@ -0,0 +1,104 @@ +from typing import Any, Dict + +from shiny.express import expressify, ui + +ui.page_opts(title="Navsets kitchensink App") + + +def navset_sidebar(): + from shiny import ui as core_ui + + return core_ui.sidebar(core_ui.markdown("Sidebar content")) + + +navset_configs: Dict[str, Dict[str, Dict[str, Any]]] = { + "navset_pill": { + "default": {}, + "with_header_footer": { + "header": "navset_pill_with_header_footer header", + "footer": "navset_pill_with_header_footer footer", + }, + "selected": {"selected": "navset_pill_b"}, + }, + "navset_underline": { + "default": {}, + "with_header_footer": { + "header": "navset_underline_with_header_footer header", + "footer": "navset_underline_with_header_footer footer", + }, + "selected": {"selected": "navset_underline_b"}, + }, + "navset_tab": { + "default": {}, + "with_header_footer": { + "header": "navset_tab_with_header_footer header", + "footer": "navset_tab_with_header_footer footer", + }, + "selected": {"selected": "navset_tab_b"}, + }, + "navset_pill_list": { + "default": {}, + "with_header_footer": { + "header": "navset_pill_list_with_header_footer header", + "footer": "navset_pill_list_with_header_footer footer", + }, + "selected": {"selected": "navset_pill_list_b"}, + "widths_no_well": {"widths": (10, 2), "well": False}, + }, + "navset_card_pill": { + "with_header_footer": { + "title": "navset_card_pill_with_header_footer", + "header": "navset_card_pill_with_header_footer header", + "footer": "navset_card_pill_with_header_footer footer", + }, + "default": {}, + "placement": {"placement": "below"}, + "selected": {"selected": "navset_card_pill_b"}, + "with_sidebar": {"sidebar": navset_sidebar()}, + }, + "navset_card_tab": { + "with_header_footer": { + "title": "navset_card_tab_with_header_footer", + "header": "navset_card_tab_with_header_footer header", + "footer": "navset_card_tab_with_header_footer footer", + }, + "default": {}, + "selected": {"selected": "navset_card_tab_b"}, + "with_sidebar": {"sidebar": navset_sidebar()}, + }, + "navset_card_underline": { + "with_header_footer": { + "title": "navset_card_underline_with_header_footer", + "header": "navset_card_underline_with_header_footer header", + # "header": core_ui.CardItem( + # core_ui.TagList( + # "navset_card_underline_with_header_footer header1", + # core_ui.br(), + # "navset_card_underline_with_header_footer header2", + # ) + # ), + "footer": "navset_card_underline_with_header_footer footer", + }, + "default": {}, + "selected": {"selected": "navset_card_underline_b"}, + "placement": {"placement": "below"}, + "with_sidebar": {"sidebar": navset_sidebar()}, + }, +} + + +@expressify +def create_navset(navset_type: str) -> None: + navset_function = getattr(ui, navset_type) + + for navset_id, params in navset_configs[navset_type].items(): + with navset_function(id=f"{navset_type}_{navset_id}", **params): + for suffix in ["a", "b", "c"]: + with ui.nav_panel(f"{navset_type}_{suffix}"): + ui.markdown(f"{navset_type}_{suffix} content") + + +with ui.navset_tab(id="navsets_collection"): + for navset_type in navset_configs.keys(): + with ui.nav_panel(navset_type): + create_navset(navset_type) diff --git a/tests/playwright/shiny/components/nav/navsets_kitchensink/test_kitchensink.py b/tests/playwright/shiny/components/nav/navsets_kitchensink/test_kitchensink.py new file mode 100644 index 000000000..36b71833a --- /dev/null +++ b/tests/playwright/shiny/components/nav/navsets_kitchensink/test_kitchensink.py @@ -0,0 +1,62 @@ +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + +navsets = [ + ("navset_pill", "navset_pill_a content", "navset_pill_b content"), + ("navset_underline", "navset_underline_a content", "navset_underline_b content"), + ("navset_tab", "navset_tab_a content", "navset_tab_b content"), + ("navset_pill_list", "navset_pill_list_a content", "navset_pill_list_b content"), + ("navset_card_pill", "navset_card_pill_a content", "navset_card_pill_b content"), + ("navset_card_tab", "navset_card_tab_a content", "navset_card_tab_b content"), + ( + "navset_card_underline", + "navset_card_underline_a content", + "navset_card_underline_b content", + ), +] + + +def test_navset_kitchensink(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + # Update the page size to be wider + page.set_viewport_size({"width": 1500, "height": 800}) + + # for cases across all navsets + for navset_name, default_content, selected_content in navsets: + navset = controller.NavPanel(page, "navsets_collection", navset_name) + navset.click() + + navset_default = getattr( + controller, f"{navset_name.replace('_', ' ').title().replace(' ', '')}" + )(page, f"{navset_name}_default") + navset_default._expect_content_text(default_content) + navset_default.expect_value(f"{navset_name}_a") + + navset_selected = getattr( + controller, f"{navset_name.replace('_', ' ').title().replace(' ', '')}" + )(page, f"{navset_name}_selected") + navset_selected._expect_content_text(selected_content) + navset_selected.expect_value(f"{navset_name}_b") + + # TODO-future: uncomment test to check for header & footer content once class is added + # navset_with_header_footer.expect_header(f"{navset_name}_with_header_footer header") + # navset_with_header_footer.expect_footer(f"{navset_name}_with_header_footer footer") + if navset_name.startswith("navset_card"): + navset_with_header_footer = getattr( + controller, f"{navset_name.replace('_', ' ').title().replace(' ', '')}" + )(page, f"{navset_name}_with_header_footer") + navset_with_header_footer.expect_title(f"{navset_name}_with_header_footer") + + navset_card_underline_with_sidebar = getattr( + controller, f"{navset_name.replace('_', ' ').title().replace(' ', '')}" + )(page, f"{navset_name}_with_sidebar") + navset_card_underline_with_sidebar.expect_sidebar(True) + + if navset_name in {"navset_card_underline", "navset_card_pill"}: + navset_card_underline_placement = getattr( + controller, f"{navset_name.replace('_', ' ').title().replace(' ', '')}" + )(page, f"{navset_name}_placement") + navset_card_underline_placement.expect_placement("below") diff --git a/tests/playwright/shiny/components/value_box/smoke/test_valuebox.py b/tests/playwright/shiny/components/value_box/smoke/test_valuebox.py index bc0210f97..4574cb5ff 100644 --- a/tests/playwright/shiny/components/value_box/smoke/test_valuebox.py +++ b/tests/playwright/shiny/components/value_box/smoke/test_valuebox.py @@ -19,7 +19,7 @@ def test_valuebox(page: Page, local_app: ShinyAppProc, value_box_id: str) -> Non value_box.expect_value("$1 Billion Dollars") value_box.expect_body(["30% VS PREVIOUS 30 DAYS"]) else: - # value_box.expect_height("300px") # TODO-fix-karan; + value_box.expect_height("500px") value_box.expect_title("title") value_box.expect_value("value") value_box.expect_body(["content", "more body"]) diff --git a/tests/playwright/shiny/inputs/test_input_checkbox.py b/tests/playwright/shiny/inputs/test_input_checkbox.py index abee922b1..3f350741c 100644 --- a/tests/playwright/shiny/inputs/test_input_checkbox.py +++ b/tests/playwright/shiny/inputs/test_input_checkbox.py @@ -25,10 +25,10 @@ def test_input_checkbox_kitchen(page: Page, app: ShinyAppProc) -> None: somevalue.expect_checked(True) - somevalue._toggle() + somevalue.set(False) somevalue.expect_checked(False) - somevalue._toggle() + somevalue.set(True) somevalue.expect_checked(True) output_txt.expect.to_have_text("True") diff --git a/tests/playwright/shiny/inputs/test_input_switch.py b/tests/playwright/shiny/inputs/test_input_switch.py index 7d9509649..e8e014751 100644 --- a/tests/playwright/shiny/inputs/test_input_switch.py +++ b/tests/playwright/shiny/inputs/test_input_switch.py @@ -24,10 +24,10 @@ def test_input_switch_kitchen(page: Page, app: ShinyAppProc) -> None: somevalue.expect_checked(True) - somevalue._toggle() + somevalue.set(False) somevalue.expect_checked(False) - somevalue._toggle() + somevalue.set(True) somevalue.expect_checked(True) expect(controller.OutputUi(page, "value").loc).to_have_text("True") diff --git a/tests/pytest/test_playwright_filter.py b/tests/pytest/test_playwright_filter.py new file mode 100644 index 000000000..8c9b2c730 --- /dev/null +++ b/tests/pytest/test_playwright_filter.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import glob +from pathlib import Path +from typing import Dict, Set + +from tests.pytest._utils import skip_on_windows + +known_entries: Dict[str, Set[str]] = { + # "tests/pytest/test_poll.py": { + # "my_locator.filter('foo')", + # } +} + +# Trim all line values of `known_entries` +for k, v in known_entries.items(): + known_entries[k] = {x.strip() for x in v} + + +@skip_on_windows +def test_named_temporary_file_is_not_used(): + """ + Playwright's Locator hangs when we call `.filter(foo)`. + Instead you should use `.locator("xpath=.", has=page.locator(foo))` + """ + + root_here = Path(__file__).parent.parent.parent + shiny_files = glob.glob(str(root_here / "shiny" / "**" / "*.py"), recursive=True) + tests_files = glob.glob(str(root_here / "tests" / "**" / "*.py"), recursive=True) + + assert len(shiny_files) > 0 + assert len(tests_files) > 0 + + all_files = [*shiny_files, *tests_files] + + search_string = ".filter(" + + bad_entries: list[tuple[Path, int, str]] = [] + + # For every python file... + for path in all_files: + path = Path(path) + # Skip if dir + if path.is_dir(): + continue + + # Skip this file + if path.name in {"test_playwright_filter.py"}: + continue + + with open(path, "r") as f: + # Read file contents + txt = f.read().replace(".filter()", ".not_playwright_filter()") + + # Skip if search string is not in file + if search_string not in txt: + continue + + # Split file contents by line + lines = txt.split("\n") + rel_path = path.relative_to(root_here) + known_lines = known_entries.get(str(rel_path), set()) + seen_lines: set[str] = set() + + # If the search string is in the line + # and the line is not in the known lines, + # add it to the bad entries + for i, line in enumerate(lines): + line = line.strip() + if search_string in line: + seen_lines.add(line) + if line not in known_lines: + bad_entries.append((rel_path, i + 1, line)) + + if (len(known_lines) > 0) and (len(seen_lines) != len(known_lines)): + raise ValueError( + f"Lines not found in {rel_path}: {known_lines - seen_lines}" + "\nPlease remove them from the known_entries dictionary." + ) + + assert ( + len(bad_entries) == 0 + ), f"Unexpected files containing `.filter(`: {str(bad_entries)}"