From 5789816333538a78c76e6dd67eb1f80bbd50b998 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 17 May 2022 09:54:14 +0100 Subject: [PATCH] [App] Integration tests now work on Windows too --- src/textual/_context.py | 5 ++ src/textual/dom.py | 7 ++- src/textual/message_pump.py | 24 ++++++-- src/textual/screen.py | 2 +- tests/utilities/test_app.py | 106 ++++++++++++++++++++---------------- 5 files changed, 89 insertions(+), 55 deletions(-) diff --git a/src/textual/_context.py b/src/textual/_context.py index e16817631..04b264d33 100644 --- a/src/textual/_context.py +++ b/src/textual/_context.py @@ -5,4 +5,9 @@ from contextvars import ContextVar if TYPE_CHECKING: from .app import App + +class NoActiveAppError(RuntimeError): + pass + + active_app: ContextVar["App"] = ContextVar("active_app") diff --git a/src/textual/dom.py b/src/textual/dom.py index 760bc3def..700534398 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -9,6 +9,7 @@ from rich.style import Style from rich.text import Text from rich.tree import Tree +from ._context import NoActiveAppError from ._node_list import NodeList from .color import Color from .css._error_tools import friendly_list @@ -463,7 +464,7 @@ class DOMNode(MessagePump): try: self.app.stylesheet.update(self.app, animate=True) self.refresh() - except LookupError: + except NoActiveAppError: pass def remove_class(self, *class_names: str) -> None: @@ -477,7 +478,7 @@ class DOMNode(MessagePump): try: self.app.stylesheet.update(self.app, animate=True) self.refresh() - except LookupError: + except NoActiveAppError: pass def toggle_class(self, *class_names: str) -> None: @@ -491,7 +492,7 @@ class DOMNode(MessagePump): try: self.app.stylesheet.update(self.app, animate=True) self.refresh() - except LookupError: + except NoActiveAppError: pass def has_pseudo_class(self, *class_names: str) -> bool: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 02d533e4c..0c04dbbcd 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -12,7 +12,7 @@ from . import events from . import log from ._timer import Timer, TimerCallback from ._callback import invoke -from ._context import active_app +from ._context import active_app, NoActiveAppError from .message import Message from . import messages @@ -74,8 +74,16 @@ class MessagePump: @property def app(self) -> "App": - """Get the current app.""" - return active_app.get() + """ + Get the current app. + + Raises: + NoActiveAppError: if no active app could be found for the current asyncio context + """ + try: + return active_app.get() + except LookupError: + raise NoActiveAppError() @property def is_parent_active(self): @@ -152,7 +160,13 @@ class MessagePump: pause: bool = False, ) -> Timer: timer = Timer( - self, delay, self, name=name, callback=callback, repeat=0, pause=pause + self, + delay, + self, + name=name or f"set_timer#{Timer._timer_count}", + callback=callback, + repeat=0, + pause=pause, ) self._child_tasks.add(timer.start()) return timer @@ -170,7 +184,7 @@ class MessagePump: self, interval, self, - name=name, + name=name or f"set_interval#{Timer._timer_count}", callback=callback, repeat=repeat or None, pause=pause, diff --git a/src/textual/screen.py b/src/textual/screen.py index a33b236f9..6f9725647 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -169,7 +169,7 @@ class Screen(Widget): def on_mount(self, event: events.Mount) -> None: self._update_timer = self.set_interval( - UPDATE_PERIOD, self._on_update, pause=True + UPDATE_PERIOD, self._on_update, name="screen_update", pause=True ) async def on_resize(self, event: events.Resize) -> None: diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index 2040f87ea..9f624d715 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -13,7 +13,8 @@ from unittest import mock from rich.console import Console from textual import events, errors -from textual.app import App, ComposeResult +from textual.app import App, ComposeResult, WINDOWS +from textual._context import active_app from textual.driver import Driver from textual.geometry import Size @@ -77,7 +78,7 @@ class AppTest(App): self, *, time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks - waiting_duration_after_initialisation: float = 0.1, + waiting_duration_after_initialisation: float = 1, waiting_duration_after_yield: float = 0, ) -> AsyncContextManager[MockedTimeMoveClockForward]: async def run_app() -> None: @@ -85,50 +86,35 @@ class AppTest(App): @contextlib.asynccontextmanager async def get_running_state_context_manager(): - self._set_active() - with mock_textual_timers( ticks_granularity_fps=time_mocking_ticks_granularity_fps - ) as move_time_forward: + ) as move_clock_forward: run_task = asyncio.create_task(run_app()) - await asyncio.sleep(0.001) - # timeout_before_yielding_task = asyncio.create_task( - # asyncio.sleep(waiting_duration_after_initialisation) - # ) - # done, pending = await asyncio.wait( - # ( - # run_task, - # timeout_before_yielding_task, - # ), - # return_when=asyncio.FIRST_COMPLETED, - # ) - # if run_task in done or run_task not in pending: - # raise RuntimeError( - # "TestApp is no longer running after its initialization period" - # ) - await move_time_forward(seconds=waiting_duration_after_initialisation) + # We have to do this because `run_app()` is running in its own async task, and our test is going to + # run in this one - so the app must also be the active App in our current context: + self._set_active() + await move_clock_forward(seconds=waiting_duration_after_initialisation) + # make sure the App has entered its main loop at this stage: assert self._driver is not None - self.force_screen_update() + await self.force_screen_update() - yield move_time_forward + # And now it's time to pass the torch on to the test function! + # We provide the `move_clock_forward` function to it, + # so it can also do some time-based Textual stuff if it needs to: + yield move_clock_forward - await move_time_forward(seconds=waiting_duration_after_yield) + await move_clock_forward(seconds=waiting_duration_after_yield) - self.force_screen_update() - # waiting_duration = max( - # waiting_duration_post_yield or 0, - # self.screen._update_timer._interval, - # ) - # await asyncio.sleep(waiting_duration) + # Make sure our screen is up to date before exiting the context manager, + # so tests using our `last_display_capture` for example can assert things on an up to date screen: + await self.force_screen_update() - # if force_timers_tick_after_yield: - # await textual_timers_force_tick() - - assert not run_task.done() - await self.shutdown() + # End of simulated time: we just shut down ourselves: + assert not run_task.done() + await self.shutdown() return get_running_state_context_manager() @@ -175,12 +161,17 @@ class AppTest(App): return segment.text[0] return "" - def force_screen_update(self, *, repaint: bool = True, layout: bool = True) -> None: + async def force_screen_update( + self, *, repaint: bool = True, layout: bool = True + ) -> None: try: - self.screen.refresh(repaint=repaint, layout=layout) - self.screen._on_update() + screen = self.screen except IndexError: - pass # the app may not have a screen yet + return # the app may not have a screen yet + screen.refresh(repaint=repaint, layout=layout) + screen._on_update() + + await let_asyncio_process_some_events() def on_exception(self, error: Exception) -> None: # In tests we want the errors to be raised, rather than printed to a Console @@ -191,6 +182,10 @@ class AppTest(App): "Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`" ) + @property + def active_app(self) -> App | None: + return active_app.get() + @property def total_capture(self) -> str | None: return self.console.file.getvalue() @@ -259,6 +254,16 @@ class DriverTest(Driver): pass +# > The resolution of the monotonic clock on Windows is usually around 15.6 msec. +# > The best resolution is 0.5 msec. +# @link https://docs.python.org/3/library/asyncio-platforms.html: +ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD = 0.025 if WINDOWS else 0.002 + + +async def let_asyncio_process_some_events() -> None: + await asyncio.sleep(ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD) + + def mock_textual_timers( *, ticks_granularity_fps: int = 60, @@ -278,11 +283,17 @@ def mock_textual_timers( target_event_monotonic_time = current_time + duration pending_sleep_events.append((target_event_monotonic_time, event)) # Ok, let's wait for this Event - # - which can only be "unlocked" by calls to `move_clock_forward()` + # (which can only be "unlocked" by calls to `move_clock_forward()`) await event.wait() # Our replacement for "textual._timer.Timer.get_time" and "textual.message.Message._get_time": def get_time_mock() -> float: + nonlocal current_time + + # let's make the time advance slightly between 2 consecutive calls of this function, + # within the same order of magnitude than 2 consecutive calls to ` timer.monotonic()`: + current_time += 1.1e-06 + return current_time async def move_clock_forward(*, seconds: float) -> tuple[float, int]: @@ -292,11 +303,14 @@ def mock_textual_timers( activated_timers_count_total = 0 for tick_counter in range(ticks_count): current_time += single_tick_duration - activated_timers_count_total += check_sleep_timers_to_activate() + activated_timers_count = check_sleep_timers_to_activate() + activated_timers_count_total += activated_timers_count + # Let's give an opportunity to asyncio-related stuff to happen, + # now that we likely unlocked some occurrences of `await sleep(duration)`: + if activated_timers_count: + await let_asyncio_process_some_events() - # Let's give an opportunity to asyncio-related stuff to happen, - # now that we unlocked some occurrences of `await sleep(duration)`: - await asyncio.sleep(0.0001) + await let_asyncio_process_some_events() return current_time, activated_timers_count_total @@ -307,8 +321,8 @@ def mock_textual_timers( for i, (target_event_monotonic_time, event) in enumerate( pending_sleep_events ): - if target_event_monotonic_time < current_time: - continue + if current_time < target_event_monotonic_time: + continue # not time for you yet, dear awaiter... # Right, let's release this waiting event! event.set() activated_timers_count += 1