From 6f4bf63b85587c7de2712505dc65a91036ec34a1 Mon Sep 17 00:00:00 2001 From: Karan Date: Fri, 2 Feb 2024 14:30:26 -0800 Subject: [PATCH] Test input task button and extended task decorator (#1099) Co-authored-by: Barret Schloerke --- tests/playwright/controls.py | 101 +++++++++++++++--- .../components/accordion/test_accordion.py | 3 + .../shiny/inputs/input_task_button/app.py | 61 +++++++++++ .../test_input_task_button.py | 63 +++++++++++ 4 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 tests/playwright/shiny/inputs/input_task_button/app.py create mode 100644 tests/playwright/shiny/inputs/input_task_button/test_input_task_button.py diff --git a/tests/playwright/controls.py b/tests/playwright/controls.py index 039e46ae3..cdce59212 100644 --- a/tests/playwright/controls.py +++ b/tests/playwright/controls.py @@ -1,4 +1,5 @@ """Facade classes for working with Shiny inputs/outputs in Playwright""" + from __future__ import annotations import json @@ -790,6 +791,67 @@ def __init__( ) +class InputTaskButton( + _WidthLocM, + _InputActionBase, +): + # TODO-Karan: Test auto_reset functionality + # id: str, + # label: TagChild, + # *args: TagChild, + # icon: TagChild = None, + # label_busy: TagChild = "Processing...", + # icon_busy: TagChild | MISSING_TYPE = MISSING, + # width: Optional[str] = None, + # type: Optional[str] = "primary", + # auto_reset: bool = True, + # **kwargs: TagAttrValue, + def __init__( + self, + page: Page, + id: str, + ) -> None: + super().__init__( + page, + id=id, + loc=f"button#{id}.bslib-task-button.shiny-bound-input", + ) + + def expect_state( + self, value: Literal["ready", "busy"] | str, *, timeout: Timeout = None + ): + expect_attr( + self.loc.locator("> bslib-switch-inline"), + name="case", + value=value, + timeout=timeout, + ) + + def expect_label(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + self.expect_label_ready(value, timeout=timeout) + + def expect_label_ready(self, value: PatternOrStr, *, timeout: Timeout = None): + self.expect_label_state("ready", value, timeout=timeout) + + def expect_label_busy(self, value: PatternOrStr, *, timeout: Timeout = None): + self.expect_label_state("busy", value, timeout=timeout) + + def expect_label_state( + self, state: str, value: PatternOrStr, *, timeout: Timeout = None + ): + playwright_expect( + self.loc.locator(f"> bslib-switch-inline > span[slot='{state}']") + ).to_have_text(value, timeout=timeout) + + def expect_auto_reset(self, value: bool, timeout: Timeout = None): + expect_attr( + self.loc, + name="data-auto-reset", + value="" if value else None, + timeout=timeout, + ) + + class InputActionLink(_InputActionBase): # label: TagChild, # icon: TagChild = None, @@ -1309,11 +1371,13 @@ def __init__( def set( self, - file_path: str - | pathlib.Path - | FilePayload - | list[str | pathlib.Path] - | list[FilePayload], + file_path: ( + str + | pathlib.Path + | FilePayload + | list[str | pathlib.Path] + | list[FilePayload] + ), *, timeout: Timeout = None, expect_complete_timeout: Timeout = 30 * 1000, @@ -1668,9 +1732,11 @@ def __init__( def expect_value( self, - value: typing.Tuple[PatternOrStr, PatternOrStr] - | typing.Tuple[PatternOrStr, MISSING_TYPE] - | typing.Tuple[MISSING_TYPE, PatternOrStr], + value: ( + typing.Tuple[PatternOrStr, PatternOrStr] + | typing.Tuple[PatternOrStr, MISSING_TYPE] + | typing.Tuple[MISSING_TYPE, PatternOrStr] + ), *, timeout: Timeout = None, ) -> None: @@ -1713,9 +1779,11 @@ def _set_fraction( def set( self, - value: typing.Tuple[str, str] - | typing.Tuple[str, MISSING_TYPE] - | typing.Tuple[MISSING_TYPE, str], + value: ( + typing.Tuple[str, str] + | typing.Tuple[str, MISSING_TYPE] + | typing.Tuple[MISSING_TYPE, str] + ), *, max_err_values: int = 15, timeout: Timeout = None, @@ -1974,9 +2042,11 @@ def set( def expect_value( self, - value: typing.Tuple[PatternOrStr, PatternOrStr] - | typing.Tuple[PatternOrStr, MISSING_TYPE] - | typing.Tuple[MISSING_TYPE, PatternOrStr], + value: ( + typing.Tuple[PatternOrStr, PatternOrStr] + | typing.Tuple[PatternOrStr, MISSING_TYPE] + | typing.Tuple[MISSING_TYPE, PatternOrStr] + ), *, timeout: Timeout = None, ) -> None: @@ -2169,6 +2239,9 @@ def __init__( ) -> None: super().__init__(page, id=id, loc=f"#{id}.shiny-text-output") + def get_value(self, *, timeout: Timeout = None) -> str: + return self.loc.inner_text(timeout=timeout) + class OutputCode(_OutputTextValue): def __init__(self, page: Page, id: str) -> None: diff --git a/tests/playwright/shiny/components/accordion/test_accordion.py b/tests/playwright/shiny/components/accordion/test_accordion.py index 1345d3a83..f47786728 100644 --- a/tests/playwright/shiny/components/accordion/test_accordion.py +++ b/tests/playwright/shiny/components/accordion/test_accordion.py @@ -1,8 +1,11 @@ +import pytest from conftest import ShinyAppProc from controls import Accordion, InputActionButton, OutputTextVerbatim +from examples.example_apps import reruns, reruns_delay from playwright.sync_api import Page +@pytest.mark.flaky(reruns=reruns, reruns_delay=reruns_delay) def test_accordion(page: Page, local_app: ShinyAppProc) -> None: page.goto(local_app.url) diff --git a/tests/playwright/shiny/inputs/input_task_button/app.py b/tests/playwright/shiny/inputs/input_task_button/app.py new file mode 100644 index 000000000..edd483f9e --- /dev/null +++ b/tests/playwright/shiny/inputs/input_task_button/app.py @@ -0,0 +1,61 @@ +import asyncio +from datetime import datetime + +from shiny import reactive, render +from shiny.express import input, ui + +ui.h5("Current time") + + +@render.text() +def current_time() -> str: + reactive.invalidate_later(0.1) + return str(datetime.now().utcnow()) + + +with ui.p(): + "Notice that the time above updates every second, even if you click the button below." + + +@ui.bind_task_button(button_id="btn_task") +@reactive.extended_task +async def slow_compute(a: int, b: int) -> int: + await asyncio.sleep(1.5) + return a + b + + +async def slow_input_compute(a: int, b: int) -> int: + await asyncio.sleep(1.5) + return a + b + + +with ui.layout_sidebar(): + with ui.sidebar(): + ui.input_numeric("x", "x", 1) + ui.input_numeric("y", "y", 2) + ui.input_task_button("btn_task", "Non-blocking task") + ui.input_task_button("btn_block", "Block compute", label_busy="Blocking...") + ui.input_action_button("btn_cancel", "Cancel") + + @reactive.Effect + @reactive.event(input.btn_task, ignore_none=False) + def handle_click(): + # slow_compute.cancel() + slow_compute(input.x(), input.y()) + + @reactive.Effect + @reactive.event(input.btn_block, ignore_none=False) + async def handle_click2(): + # slow_compute.cancel() + await slow_input_compute(input.x(), input.y()) + + @reactive.Effect + @reactive.event(input.btn_cancel) + def handle_cancel(): + slow_compute.cancel() + + ui.h5("Sum of x and y") + + @render.text + def show_result(): + return str(slow_compute.result()) diff --git a/tests/playwright/shiny/inputs/input_task_button/test_input_task_button.py b/tests/playwright/shiny/inputs/input_task_button/test_input_task_button.py new file mode 100644 index 000000000..f90ce3d5b --- /dev/null +++ b/tests/playwright/shiny/inputs/input_task_button/test_input_task_button.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import time + +from conftest import ShinyAppProc +from controls import InputNumeric, InputTaskButton, OutputText +from playwright.sync_api import Page + + +def click_extended_task_button( + button: InputTaskButton, + current_time: OutputText, +) -> str: + button.expect_state("ready") + button.click(timeout=0) + button.expect_state("busy", timeout=0) + return current_time.get_value(timeout=0) + + +def test_input_action_task_button(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + y = InputNumeric(page, "y") + y.set("4") + result = OutputText(page, "show_result") + current_time = OutputText(page, "current_time") + # Make sure the time has content + current_time.expect.not_to_be_empty() + + # Wait until shiny is stable + result.expect_value("3") + + # Extended task + button_task = InputTaskButton(page, "btn_task") + button_task.expect_label_busy("\n \n Processing...") + button_task.expect_label_ready("Non-blocking task") + button_task.expect_auto_reset(True) + # Click button and collect the current time from the app + time1 = click_extended_task_button( + button_task, + current_time, + ) + # Make sure time value updates (before the calculation finishes + current_time.expect.not_to_have_text(time1, timeout=500) + result.expect_value("3", timeout=0) + # After the calculation time plus a buffer, make sure the calculation finishes + result.expect_value("5", timeout=(1.5 + 1) * 1000) + + # set up Blocking test + y.set("15") + result.expect_value("5") + + # Blocking verification + button_block = InputTaskButton(page, "btn_block") + button_block.expect_label_busy("\n \n Blocking...") + button_block.expect_label_ready("Block compute") + button_block.expect_auto_reset(True) + time_block = click_extended_task_button( + button_block, + current_time, + ) + # Make sure time value has not changed after 500ms has ellapsed + time.sleep(0.5) + current_time.expect_value(time_block, timeout=0)