Textualize / textual

The lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.
https://textual.textualize.io/
MIT License
25.63k stars 787 forks source link

Testing Using Pilot - `pilot.click()` Doesn't Have Effect On `Static` Widget #2022

Closed MalayAgr closed 1 year ago

MalayAgr commented 1 year ago

Hi,

I am trying to write tests for the example calculator app present in the repo (at the same time, expanding it to add a few more operators). This is my CalculatorApp class:

class CalculatorApp(App):
    CSS_PATH = "calculator.css"

    viewport = var("0")
    show_ac = var(True)
    left = var(Decimal("0"))
    right = var(Decimal("0"))
    value = var("")
    operator = var("plus")

    def watch_viewport(self, value: str) -> None:
        self.query_one("#viewport", Static).update(value)

    def compute_show_ac(self) -> bool:
        return self.value in ("", "0") and self.viewport == "0"

    def watch_show_ac(self, show_ac: bool) -> None:
        self.query_one("#ac").display = show_ac
        self.query_one("#c").display = not show_ac

    def compose(self) -> ComposeResult:
        with Container(id="calculator"):
            yield Static(id="viewport")
            yield Button("AC", id="ac", variant="primary")
            yield Button("C", id="c", variant="primary")
            yield Button("+/-", id="negation", variant="primary")
            yield Button("%", id="percent", variant="primary")
            yield Button("sin(x)", id="sine", variant="warning")
            yield Button("cos(x)", id="cosine", variant="warning")
            yield Button("7", id="number-7", variant="primary")
            yield Button("8", id="number-8", variant="primary")
            yield Button("9", id="number-9", variant="primary")
            yield Button("+", id="plus", variant="warning")
            yield Button("x^y", id="exponent", variant="warning")
            yield Button("4", id="number-4", variant="primary")
            yield Button("5", id="number-5", variant="primary")
            yield Button("6", id="number-6", variant="primary")
            yield Button("-", id="minus", variant="warning")
            yield Button("ln(x)", id="logarithm", variant="warning")
            yield Button("1", id="number-1", variant="primary")
            yield Button("2", id="number-2", variant="primary")
            yield Button("3", id="number-3", variant="primary")
            yield Button("*", id="multiply", variant="warning")
            yield Button("x!", id="factorial", variant="warning")
            yield Button("0", id="number-0", variant="primary")
            yield Button(".", id="point", variant="primary")
            yield Button("÷", id="divide", variant="warning")
            yield Button("=", id="equals", variant="warning")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        button_id = event.button.id

        assert button_id is not None

        if button_id.startswith("number-"):
            number = button_id.split("-")[-1]
            self.viewport = self.value = self.value.lstrip("0") + number

I wrote the following test to check that clicking the number buttons results in the calculator's display (Static(id="viewport")) accumulating digits to make a number:

async def test_number_buttons():
    async with CalculatorApp().run_test() as pilot:
        app = pilot.app

        await pilot.click("#number-1")

        display_content = app.query_one("#viewport").render()
        assert str(display_content) == "1"

        await pilot.click("#number-2")

        display_content = app.query_one("#viewport").render()
        assert str(display_content) == "12"

        await pilot.click("#number-3")

        display_content = app.query_one("#viewport").render()
        assert str(display_content) == "123"

While the GUI gets updated correctly on clicking the buttons, the test always fails since app.query_one("#viewport").render() always returns "0". I've also tried replacing app.query_one("#viewport").render() with app.query_one("#viewport", Static).render() but that hasn't helped either.

Is this supposed to happen?

Textual Diagnostics

Versions

Name Value
Textual 0.14.0
Rich 13.3.2

Python

Name Value
Version 3.10.9
Implementation CPython
Compiler GCC 11.2.0
Executable /home/malay_agr/anaconda3/envs/spe/bin/python

Operating System

Name Value
System Linux
Release 5.15.0-52-generic
Version #58-Ubuntu SMP Thu Oct 13 08:03:55 UTC 2022

Terminal

Name Value
Terminal Application vscode (1.76.1)
TERM xterm-256color
COLORTERM truecolor
FORCE_COLOR Not set
NO_COLOR Not set

Rich Console options

Name Value
size width=197, height=19
legacy_windows False
min_width 1
max_width 197
is_terminal True
encoding utf-8
max_height 19
justify None
overflow None
no_wrap False
highlight None
markup None
height None
MalayAgr commented 1 year ago

Update: Since then, I've implemented keyboard support for the number buttons and the test passes if I use pilot.press() instead of pilot.click(). This is the testing code with pilot.press():

async def test_number_buttons():
    async with CalculatorApp().run_test() as pilot:
        await pilot.press("1")

        display_content = pilot.app.query_one("#viewport", Static).render()
        assert str(display_content) == "1"

        await pilot.press("2")

        display_content = pilot.app.query_one("#viewport", Static).render()
        assert str(display_content) == "12"

        await pilot.press("3")

        display_content = pilot.app.query_one("#viewport", Static).render()
        assert str(display_content) == "123"
MalayAgr commented 1 year ago

Update: Since then, I've realized that it is possible to access class attributes like viewport, show_ac, left, right, etc. I've tried two variants of the above test with this in mind.

This one uses app.value.

async def test_number_buttons():
    async with CalculatorApp().run_test() as pilot:
        await pilot.click("#number-1")

        assert str(pilot.app.value) == "1"

        await pilot.click("#number-2")

        assert str(pilot.app.value) == "12"

        await pilot.click("#number-3")

        assert str(pilot.app.value) == "123"

This one uses app.viewport.

async def test_number_buttons():
    async with CalculatorApp().run_test() as pilot:
        await pilot.click("#number-1")

        assert str(pilot.app.viewport) == "1"

        await pilot.click("#number-2")

        assert str(pilot.app.viewport) == "12"

        await pilot.click("#number-3")

        assert str(pilot.app.viewport) == "123"

Both of them fail because app.value is always "" and app.viewport is always "0", even though the GUI gets updated correctly.

davep commented 1 year ago

Both of them fail because app.value is always "" and app.viewport is always "0", even though the GUI gets updated correctly.

At first glance this sort of makes sense, when running with the pilot. What is happening is that the call to click is generating fake mouse messages (MouseDown, MouseUp and Click) and then returning after a pause. Those messages are going to then eventually have to result in a ButtonClicked message, which in turn will result in on_button_pressed being invoked, which in turn will result in value being changed, which in turn will eventually result in the Static that shows the numbers rendering the updated value.

Point being: by the time you get to the first assert most of that work won't have taken place yet.

However, it's reasonable to assume that the pilot.click should come back once the work has been done, so we'll dive a bit deeper into this and see if we can make that work.

davep commented 1 year ago

Right, found the issue and it's not what we were initially thinking. Fix is about to get under way.

Thanks for raising this; it's a good spot!

github-actions[bot] commented 1 year ago

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

davep commented 1 year ago

Just to update, this fix is now available: https://github.com/Textualize/textual/releases/tag/v0.15.0

MalayAgr commented 1 year ago

Thanks!