File: test_app.py

package info (click to toggle)
textual 2.1.2-1.1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 55,080 kB
  • sloc: python: 85,423; lisp: 1,669; makefile: 101
file content (342 lines) | stat: -rw-r--r-- 9,710 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import contextlib

import pytest
from rich.terminal_theme import DIMMED_MONOKAI, MONOKAI, NIGHT_OWLISH

from textual import events
from textual.app import App, ComposeResult
from textual.command import SimpleCommand
from textual.pilot import Pilot, _get_mouse_message_arguments
from textual.widgets import Button, Input, Label, Static


def test_batch_update():
    """Test `batch_update` context manager"""
    app = App()
    assert app._batch_count == 0  # Start at zero

    with app.batch_update():
        assert app._batch_count == 1  # Increments in context manager

        with app.batch_update():
            assert app._batch_count == 2  # Nested updates

        assert app._batch_count == 1  # Exiting decrements

    assert app._batch_count == 0  # Back to zero


class MyApp(App):
    def compose(self) -> ComposeResult:
        yield Input()
        yield Button("Click me!")


async def test_hover_update_styles():
    app = MyApp(ansi_color=False)
    async with app.run_test() as pilot:
        button = app.query_one(Button)
        assert button.pseudo_classes == {
            "blur",
            "can-focus",
            "dark",
            "enabled",
            "first-of-type",
            "last-of-type",
            "even",
        }

        # Take note of the initial background colour
        initial_background = button.styles.background
        await pilot.hover(Button)

        # We've hovered, so ensure the pseudoclass is present and background changed
        assert button.pseudo_classes == {
            "blur",
            "can-focus",
            "dark",
            "enabled",
            "hover",
            "first-of-type",
            "last-of-type",
            "even",
        }
        assert button.styles.background != initial_background


def test_setting_title():
    app = MyApp()
    app.title = None
    assert app.title == "None"

    app.title = ""
    assert app.title == ""

    app.title = 0.125
    assert app.title == "0.125"

    app.title = [True, False, 2]
    assert app.title == "[True, False, 2]"


def test_setting_sub_title():
    app = MyApp()
    app.sub_title = None
    assert app.sub_title == "None"

    app.sub_title = ""
    assert app.sub_title == ""

    app.sub_title = 0.125
    assert app.sub_title == "0.125"

    app.sub_title = [True, False, 2]
    assert app.sub_title == "[True, False, 2]"


async def test_default_return_code_is_zero():
    app = App()
    async with app.run_test():
        app.exit()
    assert app.return_code == 0


async def test_return_code_is_one_after_crash():
    class MyApp(App):
        def key_p(self):
            1 / 0

    app = MyApp()
    with contextlib.suppress(ZeroDivisionError):
        async with app.run_test() as pilot:
            await pilot.press("p")
    assert app.return_code == 1


async def test_set_return_code():
    app = App()
    async with app.run_test():
        app.exit(return_code=42)
    assert app.return_code == 42


def test_no_return_code_before_running():
    app = App()
    assert app.return_code is None


async def test_no_return_code_while_running():
    app = App()
    async with app.run_test():
        assert app.return_code is None


async def test_ansi_theme():
    app = App()
    async with app.run_test():
        app.ansi_theme_dark = NIGHT_OWLISH
        assert app.ansi_theme == NIGHT_OWLISH

        app.theme = "textual-light"
        assert app.ansi_theme != NIGHT_OWLISH

        app.ansi_theme_light = MONOKAI
        assert app.ansi_theme == MONOKAI

        # Ensure if we change the dark theme while on light mode,
        # then change back to dark mode, the dark theme is updated.
        app.ansi_theme_dark = DIMMED_MONOKAI
        assert app.ansi_theme == MONOKAI

        app.theme = "textual-dark"
        assert app.ansi_theme == DIMMED_MONOKAI


async def test_early_exit():
    """Test exiting early doesn't cause issues."""
    from textual.app import App

    class AppExit(App):
        def compose(self):
            yield Static("Hello")

        def on_mount(self) -> None:
            # Exit after creating app
            self.exit()

    app = AppExit()
    async with app.run_test():
        pass


def test_early_exit_inline():
    """Test exiting early in inline mode doesn't break."""

    class AppExit(App[None]):
        def compose(self):
            yield Static("Hello")

        def on_mount(self) -> None:
            # Exit after creating app
            self.exit()

    app = AppExit()
    app.run(inline=True, inline_no_clear=True)


async def test_search_with_simple_commands():
    """Test search with a list of SimpleCommands and ensure callbacks are invoked."""
    called = False

    def callback():
        nonlocal called
        called = True

    app = App[None]()
    commands = [
        SimpleCommand("Test Command", callback, "A test command"),
        SimpleCommand("Another Command", callback, "Another test command"),
    ]
    async with app.run_test() as pilot:
        await app.search_commands(commands)
        await pilot.press("enter", "enter")
        assert called


async def test_search_with_tuples():
    """Test search with a list of tuples and ensure callbacks are invoked.
    In this case we also have no help text in the tuples.
    """
    called = False

    def callback():
        nonlocal called
        called = True

    app = App[None]()
    commands = [
        ("Test Command", callback),
        ("Another Command", callback),
    ]
    async with app.run_test() as pilot:
        await app.search_commands(commands)
        await pilot.press("enter", "enter")
        assert called


async def test_search_with_empty_list():
    """Test search with an empty command list doesn't crash."""
    app = App[None]()
    async with app.run_test():
        await app.search_commands([])


async def raw_click(pilot: Pilot, selector: str, times: int = 1):
    """A lower level click function that doesn't use the Pilot,
    and so doesn't bypass the click chain logic in App.on_event."""
    app = pilot.app
    kwargs = _get_mouse_message_arguments(app.query_one(selector))
    for _ in range(times):
        app.post_message(events.MouseDown(**kwargs))
        app.post_message(events.MouseUp(**kwargs))
        await pilot.pause()


@pytest.mark.parametrize("number_of_clicks,final_count", [(1, 1), (2, 3), (3, 6)])
async def test_click_chain_initial_repeated_clicks(
    number_of_clicks: int, final_count: int
):
    click_count = 0

    class MyApp(App[None]):
        # Ensure clicks are always within the time threshold
        CLICK_CHAIN_TIME_THRESHOLD = 1000.0

        def compose(self) -> ComposeResult:
            yield Label("Click me!", id="one")

        def on_click(self, event: events.Click) -> None:
            nonlocal click_count
            print(f"event: {event}")
            click_count += event.chain

    async with MyApp().run_test() as pilot:
        # Clicking the same Label at the same offset creates a double and triple click.
        for _ in range(number_of_clicks):
            await raw_click(pilot, "#one")

        assert click_count == final_count


async def test_click_chain_different_offset():
    click_count = 0

    class MyApp(App[None]):
        # Ensure clicks are always within the time threshold
        CLICK_CHAIN_TIME_THRESHOLD = 1000.0

        def compose(self) -> ComposeResult:
            yield Label("One!", id="one")
            yield Label("Two!", id="two")
            yield Label("Three!", id="three")

        def on_click(self, event: events.Click) -> None:
            nonlocal click_count
            click_count += event.chain

    async with MyApp().run_test() as pilot:
        # Clicking on different offsets in quick-succession doesn't qualify as a double or triple click.
        await raw_click(pilot, "#one")
        assert click_count == 1
        await raw_click(pilot, "#two")
        assert click_count == 2
        await raw_click(pilot, "#three")
        assert click_count == 3


async def test_click_chain_offset_changes_mid_chain():
    """If we're in the middle of a click chain (e.g. we've double clicked), and the third click
    comes in at a different offset, that third click should be considered a single click.
    """

    click_count = 0

    class MyApp(App[None]):
        # Ensure clicks are always within the time threshold
        CLICK_CHAIN_TIME_THRESHOLD = 1000.0

        def compose(self) -> ComposeResult:
            yield Label("Click me!", id="one")
            yield Label("Another button!", id="two")

        def on_click(self, event: events.Click) -> None:
            nonlocal click_count
            click_count = event.chain

    async with MyApp().run_test() as pilot:
        await raw_click(pilot, "#one", times=2)  # Double click
        assert click_count == 2
        await raw_click(pilot, "#two")  # Single click (because different widget)
        assert click_count == 1


async def test_click_chain_time_outwith_threshold():
    click_count = 0

    class MyApp(App[None]):
        # Intentionally set the threshold to 0.0 to ensure we always exceed it
        # and can confirm that a click chain is never created
        CLICK_CHAIN_TIME_THRESHOLD = 0.0

        def compose(self) -> ComposeResult:
            yield Label("Click me!", id="one")

        def on_click(self, event: events.Click) -> None:
            nonlocal click_count
            click_count += event.chain

    async with MyApp().run_test() as pilot:
        for i in range(1, 4):
            # Each click is outwith the time threshold, so a click chain is never created.
            await raw_click(pilot, "#one")
            assert click_count == i