Repository: Textualize/textual

Issue:
Testing Using Pilot - `pilot.click()` Doesn't Have Effect On `Static` Widget
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:

```python
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:

```python
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                 |



Discussion:
**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()`:

```python
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"
```
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`.

```python
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`.

```python
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.

> 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.
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!

Resolving patch:
diff --git a/src/textual/pilot.py b/src/textual/pilot.py
--- a/src/textual/pilot.py
+++ b/src/textual/pilot.py
@@ -14,20 +14,24 @@
 
 
 def _get_mouse_message_arguments(
-    target: Widget, offset: Offset = Offset(), button: int = 0
+    target: Widget,
+    offset: Offset = Offset(),
+    button: int = 0,
+    shift: bool = False,
+    meta: bool = False,
+    control: bool = False,
 ) -> dict[str, Any]:
     """Get the arguments to pass into mouse messages for the click and hover methods."""
-    x, y = offset
     click_x, click_y, _, _ = target.region.translate(offset)
     message_arguments = {
-        "x": x,
-        "y": y,
+        "x": click_x,
+        "y": click_y,
         "delta_x": 0,
         "delta_y": 0,
         "button": button,
-        "shift": False,
-        "meta": False,
-        "ctrl": False,
+        "shift": shift,
+        "meta": meta,
+        "ctrl": control,
         "screen_x": click_x,
         "screen_y": click_y,
     }
@@ -60,7 +64,12 @@ async def press(self, *keys: str) -> None:
             await self._app._press_keys(keys)
 
     async def click(
-        self, selector: QueryType | None = None, offset: Offset = Offset()
+        self,
+        selector: QueryType | None = None,
+        offset: Offset = Offset(),
+        shift: bool = False,
+        meta: bool = False,
+        control: bool = False,
     ) -> None:
         """Simulate clicking with the mouse.
 
@@ -71,6 +80,9 @@ async def click(
                 currently hidden or obscured by another widget, then the click may
                 not land on it.
             offset: The offset to click within the selected widget.
+            shift: Click with the shift key held down.
+            meta: Click with the meta key held down.
+            control: Click with the control key held down.
         """
         app = self.app
         screen = app.screen
@@ -80,7 +92,7 @@ async def click(
             target_widget = screen
 
         message_arguments = _get_mouse_message_arguments(
-            target_widget, offset, button=1
+            target_widget, offset, button=1, shift=shift, meta=meta, control=control
         )
         app.post_message(MouseDown(**message_arguments))
         app.post_message(MouseUp(**message_arguments))


Test patch: