[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

@@ -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