[App] Integration tests now work on Windows too

This commit is contained in:
Olivier Philippon
2022-05-17 09:54:14 +01:00
parent 15df759197
commit 5789816333
5 changed files with 89 additions and 55 deletions

View File

@@ -5,4 +5,9 @@ from contextvars import ContextVar
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App from .app import App
class NoActiveAppError(RuntimeError):
pass
active_app: ContextVar["App"] = ContextVar("active_app") active_app: ContextVar["App"] = ContextVar("active_app")

View File

@@ -9,6 +9,7 @@ from rich.style import Style
from rich.text import Text from rich.text import Text
from rich.tree import Tree from rich.tree import Tree
from ._context import NoActiveAppError
from ._node_list import NodeList from ._node_list import NodeList
from .color import Color from .color import Color
from .css._error_tools import friendly_list from .css._error_tools import friendly_list
@@ -463,7 +464,7 @@ class DOMNode(MessagePump):
try: try:
self.app.stylesheet.update(self.app, animate=True) self.app.stylesheet.update(self.app, animate=True)
self.refresh() self.refresh()
except LookupError: except NoActiveAppError:
pass pass
def remove_class(self, *class_names: str) -> None: def remove_class(self, *class_names: str) -> None:
@@ -477,7 +478,7 @@ class DOMNode(MessagePump):
try: try:
self.app.stylesheet.update(self.app, animate=True) self.app.stylesheet.update(self.app, animate=True)
self.refresh() self.refresh()
except LookupError: except NoActiveAppError:
pass pass
def toggle_class(self, *class_names: str) -> None: def toggle_class(self, *class_names: str) -> None:
@@ -491,7 +492,7 @@ class DOMNode(MessagePump):
try: try:
self.app.stylesheet.update(self.app, animate=True) self.app.stylesheet.update(self.app, animate=True)
self.refresh() self.refresh()
except LookupError: except NoActiveAppError:
pass pass
def has_pseudo_class(self, *class_names: str) -> bool: def has_pseudo_class(self, *class_names: str) -> bool:

View File

@@ -12,7 +12,7 @@ from . import events
from . import log from . import log
from ._timer import Timer, TimerCallback from ._timer import Timer, TimerCallback
from ._callback import invoke from ._callback import invoke
from ._context import active_app from ._context import active_app, NoActiveAppError
from .message import Message from .message import Message
from . import messages from . import messages
@@ -74,8 +74,16 @@ class MessagePump:
@property @property
def app(self) -> "App": 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 @property
def is_parent_active(self): def is_parent_active(self):
@@ -152,7 +160,13 @@ class MessagePump:
pause: bool = False, pause: bool = False,
) -> Timer: ) -> Timer:
timer = 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()) self._child_tasks.add(timer.start())
return timer return timer
@@ -170,7 +184,7 @@ class MessagePump:
self, self,
interval, interval,
self, self,
name=name, name=name or f"set_interval#{Timer._timer_count}",
callback=callback, callback=callback,
repeat=repeat or None, repeat=repeat or None,
pause=pause, pause=pause,

View File

@@ -169,7 +169,7 @@ class Screen(Widget):
def on_mount(self, event: events.Mount) -> None: def on_mount(self, event: events.Mount) -> None:
self._update_timer = self.set_interval( 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: async def on_resize(self, event: events.Resize) -> None:

View File

@@ -13,7 +13,8 @@ from unittest import mock
from rich.console import Console from rich.console import Console
from textual import events, errors 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.driver import Driver
from textual.geometry import Size from textual.geometry import Size
@@ -77,7 +78,7 @@ class AppTest(App):
self, self,
*, *,
time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks 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, waiting_duration_after_yield: float = 0,
) -> AsyncContextManager[MockedTimeMoveClockForward]: ) -> AsyncContextManager[MockedTimeMoveClockForward]:
async def run_app() -> None: async def run_app() -> None:
@@ -85,50 +86,35 @@ class AppTest(App):
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def get_running_state_context_manager(): async def get_running_state_context_manager():
self._set_active()
with mock_textual_timers( with mock_textual_timers(
ticks_granularity_fps=time_mocking_ticks_granularity_fps ticks_granularity_fps=time_mocking_ticks_granularity_fps
) as move_time_forward: ) as move_clock_forward:
run_task = asyncio.create_task(run_app()) 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 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() # Make sure our screen is up to date before exiting the context manager,
# waiting_duration = max( # so tests using our `last_display_capture` for example can assert things on an up to date screen:
# waiting_duration_post_yield or 0, await self.force_screen_update()
# self.screen._update_timer._interval,
# )
# await asyncio.sleep(waiting_duration)
# if force_timers_tick_after_yield: # End of simulated time: we just shut down ourselves:
# await textual_timers_force_tick() assert not run_task.done()
await self.shutdown()
assert not run_task.done()
await self.shutdown()
return get_running_state_context_manager() return get_running_state_context_manager()
@@ -175,12 +161,17 @@ class AppTest(App):
return segment.text[0] return segment.text[0]
return "" 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: try:
self.screen.refresh(repaint=repaint, layout=layout) screen = self.screen
self.screen._on_update()
except IndexError: 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: def on_exception(self, error: Exception) -> None:
# In tests we want the errors to be raised, rather than printed to a Console # 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()`" "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 @property
def total_capture(self) -> str | None: def total_capture(self) -> str | None:
return self.console.file.getvalue() return self.console.file.getvalue()
@@ -259,6 +254,16 @@ class DriverTest(Driver):
pass 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( def mock_textual_timers(
*, *,
ticks_granularity_fps: int = 60, ticks_granularity_fps: int = 60,
@@ -278,11 +283,17 @@ def mock_textual_timers(
target_event_monotonic_time = current_time + duration target_event_monotonic_time = current_time + duration
pending_sleep_events.append((target_event_monotonic_time, event)) pending_sleep_events.append((target_event_monotonic_time, event))
# Ok, let's wait for this 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() await event.wait()
# Our replacement for "textual._timer.Timer.get_time" and "textual.message.Message._get_time": # Our replacement for "textual._timer.Timer.get_time" and "textual.message.Message._get_time":
def get_time_mock() -> float: 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 return current_time
async def move_clock_forward(*, seconds: float) -> tuple[float, int]: async def move_clock_forward(*, seconds: float) -> tuple[float, int]:
@@ -292,11 +303,14 @@ def mock_textual_timers(
activated_timers_count_total = 0 activated_timers_count_total = 0
for tick_counter in range(ticks_count): for tick_counter in range(ticks_count):
current_time += single_tick_duration 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, await let_asyncio_process_some_events()
# now that we unlocked some occurrences of `await sleep(duration)`:
await asyncio.sleep(0.0001)
return current_time, activated_timers_count_total return current_time, activated_timers_count_total
@@ -307,8 +321,8 @@ def mock_textual_timers(
for i, (target_event_monotonic_time, event) in enumerate( for i, (target_event_monotonic_time, event) in enumerate(
pending_sleep_events pending_sleep_events
): ):
if target_event_monotonic_time < current_time: if current_time < target_event_monotonic_time:
continue continue # not time for you yet, dear awaiter...
# Right, let's release this waiting event! # Right, let's release this waiting event!
event.set() event.set()
activated_timers_count += 1 activated_timers_count += 1