[e2e] Add a way to accelerate time in our integration tests

This commit is contained in:
Olivier Philippon
2022-05-12 11:53:07 +01:00
parent edc1e54aed
commit 74ad6f73fa
7 changed files with 131 additions and 60 deletions

View File

@@ -3,14 +3,11 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import sys import sys
from time import monotonic
from typing import Any, Callable, TypeVar from typing import Any, Callable, TypeVar
from dataclasses import dataclass from dataclasses import dataclass
from . import log
from ._easing import DEFAULT_EASING, EASING from ._easing import DEFAULT_EASING, EASING
from ._profile import timer
from ._timer import Timer from ._timer import Timer
from ._types import MessageTarget from ._types import MessageTarget
@@ -139,10 +136,6 @@ class Animator:
pause=True, pause=True,
) )
def get_time(self) -> float:
"""Get the current wall clock time."""
return monotonic()
async def start(self) -> None: async def start(self) -> None:
"""Start the animator task.""" """Start the animator task."""
@@ -189,7 +182,7 @@ class Animator:
if final_value is ...: if final_value is ...:
final_value = value final_value = value
start_time = self.get_time() start_time = self._timer.get_time()
animation_key = (id(obj), attribute) animation_key = (id(obj), attribute)
@@ -240,7 +233,7 @@ class Animator:
if not self._animations: if not self._animations:
self._timer.pause() self._timer.pause()
else: else:
animation_time = self.get_time() animation_time = self._timer.get_time()
animation_keys = list(self._animations.keys()) animation_keys = list(self._animations.keys())
for animation_key in animation_keys: for animation_key in animation_keys:
animation = self._animations[animation_key] animation = self._animations[animation_key]

View File

@@ -8,7 +8,6 @@ from asyncio import (
sleep, sleep,
Task, Task,
) )
from functools import partial
from time import monotonic from time import monotonic
from typing import Awaitable, Callable, Union from typing import Awaitable, Callable, Union
@@ -28,6 +27,8 @@ class EventTargetGone(Exception):
@rich_repr @rich_repr
class Timer: class Timer:
_timer_count: int = 1 _timer_count: int = 1
# Used to mock Timers' behaviour in a Textual app's integration test:
_instances: weakref.WeakSet[Timer] = weakref.WeakSet()
def __init__( def __init__(
self, self,
@@ -63,6 +64,7 @@ class Timer:
self._repeat = repeat self._repeat = repeat
self._skip = skip self._skip = skip
self._active = Event() self._active = Event()
Timer._instances.add(self)
if not pause: if not pause:
self._active.set() self._active.set()
@@ -104,37 +106,53 @@ class Timer:
"""Result a paused timer.""" """Result a paused timer."""
self._active.set() self._active.set()
@staticmethod
def get_time() -> float:
"""Get the current wall clock time."""
# N.B. This method will likely be a mocking target in integration tests.
return monotonic()
@staticmethod
async def _sleep(duration: float) -> None:
# N.B. This method will likely be a mocking target in integration tests.
await sleep(duration)
async def _run(self) -> None: async def _run(self) -> None:
"""Run the timer.""" """Run the timer."""
count = 0 count = 0
_repeat = self._repeat _repeat = self._repeat
_interval = self._interval _interval = self._interval
start = monotonic() start = self.get_time()
try: try:
while _repeat is None or count <= _repeat: while _repeat is None or count <= _repeat:
next_timer = start + ((count + 1) * _interval) next_timer = start + ((count + 1) * _interval)
if self._skip and next_timer < monotonic(): now = self.get_time()
if self._skip and next_timer < now:
count += 1 count += 1
continue continue
wait_time = max(0, next_timer - monotonic()) wait_time = max(0, next_timer - now)
if wait_time: if wait_time:
await sleep(wait_time) await self._sleep(wait_time)
event = events.Timer(
self.sender,
timer=self,
time=next_timer,
count=count,
callback=self._callback,
)
count += 1 count += 1
try: try:
if self._callback is not None: await self._tick(next_timer=next_timer, count=count)
await invoke(self._callback)
else:
await self.target.post_priority_message(event)
except EventTargetGone: except EventTargetGone:
break break
await self._active.wait() await self._active.wait()
except CancelledError: except CancelledError:
pass pass
async def _tick(self, *, next_timer: float, count: int) -> None:
"""Triggers the Timer's action: either call its callback, or sends an event to its target"""
if self._callback is not None:
await invoke(self._callback)
else:
event = events.Timer(
self.sender,
timer=self,
time=next_timer,
count=count,
callback=self._callback,
)
await self.target.post_priority_message(event)

View File

@@ -39,7 +39,7 @@ class Message:
self.sender = sender self.sender = sender
self.name = camel_to_snake(self.__class__.__name__.replace("Message", "")) self.name = camel_to_snake(self.__class__.__name__.replace("Message", ""))
self.time = monotonic() self.time = self._get_time()
self._forwarded = False self._forwarded = False
self._no_default_action = False self._no_default_action = False
self._stop_propagation = False self._stop_propagation = False
@@ -99,3 +99,9 @@ class Message:
""" """
self._stop_propagation = stop self._stop_propagation = stop
return self return self
@staticmethod
def _get_time() -> float:
"""Get the current wall clock time."""
# N.B. This method will likely be a mocking target in integration tests.
return monotonic()

View File

@@ -245,10 +245,3 @@ def test_bound_animator():
easing=EASING[DEFAULT_EASING], easing=EASING[DEFAULT_EASING],
) )
assert animator._animations[(id(animate_test), "foo")] == expected assert animator._animations[(id(animate_test), "foo")] == expected
def test_animator_get_time():
target = Mock()
animator = Animator(target)
assert isinstance(animator.get_time(), float)
assert animator.get_time() <= animator.get_time()

View File

@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import cast, List from typing import cast, List
import pytest import pytest
@@ -107,7 +106,6 @@ async def test_composition_of_vertical_container_with_children(
expected_placeholders_size: tuple[int, int], expected_placeholders_size: tuple[int, int],
expected_root_widget_virtual_size: tuple[int, int], expected_root_widget_virtual_size: tuple[int, int],
expected_placeholders_offset_x: int, expected_placeholders_offset_x: int,
event_loop: asyncio.AbstractEventLoop,
): ):
class VerticalContainer(Widget): class VerticalContainer(Widget):
CSS = ( CSS = (

View File

@@ -21,7 +21,6 @@ from textual.widgets import Placeholder
SCREEN_SIZE = Size(100, 30) SCREEN_SIZE = Size(100, 30)
@pytest.mark.skip("flaky test")
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts @pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -48,7 +47,7 @@ SCREEN_SIZE = Size(100, 30)
# The state of the screen at this "halfway there" timing looks to not be deterministic though, # The state of the screen at this "halfway there" timing looks to not be deterministic though,
# depending on the environment - so let's only assert stuff for the middle placeholders # depending on the environment - so let's only assert stuff for the middle placeholders
# and the first and last ones, but without being too specific about the others: # and the first and last ones, but without being too specific about the others:
[SCREEN_SIZE, 10, "placeholder_9", True, 0.1, (5, 6, 7), (1, 2, 9)], [SCREEN_SIZE, 10, "placeholder_9", True, 0.1, (6, 7, 8), (1, 2, 5, 9)],
), ),
) )
async def test_scroll_to_widget( async def test_scroll_to_widget(
@@ -94,7 +93,7 @@ async def test_scroll_to_widget(
id_: f"placeholder_{id_}" in last_display_capture id_: f"placeholder_{id_}" in last_display_capture
for id_ in range(placeholders_count) for id_ in range(placeholders_count)
} }
print(f"placeholders_visibility_by_id={placeholders_visibility_by_id}")
# Let's start by checking placeholders that should be visible: # Let's start by checking placeholders that should be visible:
for placeholder_id in last_screen_expected_placeholder_ids: for placeholder_id in last_screen_expected_placeholder_ids:
assert ( assert (

View File

@@ -4,12 +4,15 @@ import asyncio
import contextlib import contextlib
import io import io
from pathlib import Path from pathlib import Path
from typing import AsyncContextManager, cast from time import monotonic
from typing import AsyncContextManager, cast, ContextManager, Callable
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, ReturnType, ComposeResult from textual._timer import Timer
from textual.app import App, ComposeResult
from textual.driver import Driver from textual.driver import Driver
from textual.geometry import Size from textual.geometry import Size
@@ -63,33 +66,52 @@ class AppTest(App):
*, *,
waiting_duration_after_initialisation: float = 0.1, waiting_duration_after_initialisation: float = 0.1,
waiting_duration_post_yield: float = 0, waiting_duration_post_yield: float = 0,
time_acceleration: bool = True,
time_acceleration_factor: float = 10,
# force_timers_tick_after_yield: bool = True,
) -> AsyncContextManager: ) -> AsyncContextManager:
async def run_app() -> None: async def run_app() -> None:
await self.process_messages() await self.process_messages()
if time_acceleration:
waiting_duration_after_initialisation /= time_acceleration_factor
waiting_duration_post_yield /= time_acceleration_factor
time_acceleration_context: ContextManager = (
textual_timers_accelerate_time(acceleration_factor=time_acceleration_factor)
if time_acceleration
else contextlib.nullcontext()
)
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def get_running_state_context_manager(): async def get_running_state_context_manager():
self._set_active() self._set_active()
run_task = asyncio.create_task(run_app()) with time_acceleration_context:
timeout_before_yielding_task = asyncio.create_task( run_task = asyncio.create_task(run_app())
asyncio.sleep(waiting_duration_after_initialisation) 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"
) )
yield done, pending = await asyncio.wait(
if waiting_duration_post_yield: (
await asyncio.sleep(waiting_duration_post_yield) run_task,
assert not run_task.done() timeout_before_yielding_task,
await self.shutdown() ),
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"
)
yield
waiting_duration = max(
waiting_duration_post_yield or 0,
self.screen._update_timer._interval,
)
await asyncio.sleep(waiting_duration)
# if force_timers_tick_after_yield:
# await textual_timers_force_tick()
assert not run_task.done()
await self.shutdown()
return get_running_state_context_manager() return get_running_state_context_manager()
@@ -207,3 +229,45 @@ class DriverTest(Driver):
def stop_application_mode(self) -> None: def stop_application_mode(self) -> None:
pass pass
async def textual_timers_force_tick() -> None:
timer_instances_tick_tasks: list[asyncio.Task] = []
for timer in Timer._instances:
task = asyncio.create_task(timer._tick(next_timer=0, count=0))
timer_instances_tick_tasks.append(task)
await asyncio.wait(timer_instances_tick_tasks)
def textual_timers_accelerate_time(
*, acceleration_factor: float = 10
) -> ContextManager:
@contextlib.contextmanager
def accelerate_time_for_timer_context_manager():
starting_time = monotonic()
# Our replacement for "textual._timer.Timer._sleep":
async def timer_sleep(duration: float) -> None:
await asyncio.sleep(duration / acceleration_factor)
# Our replacement for "textual._timer.Timer.get_time":
def timer_get_time() -> float:
real_now = monotonic()
real_elapsed_time = real_now - starting_time
accelerated_elapsed_time = real_elapsed_time * acceleration_factor
print(
f"timer_get_time:: accelerated_elapsed_time={accelerated_elapsed_time}"
)
return starting_time + accelerated_elapsed_time
with mock.patch("textual._timer.Timer._sleep") as timer_sleep_mock, mock.patch(
"textual._timer.Timer.get_time"
) as timer_get_time_mock, mock.patch(
"textual.message.Message._get_time"
) as message_get_time_mock:
timer_sleep_mock.side_effect = timer_sleep
timer_get_time_mock.side_effect = timer_get_time
message_get_time_mock.side_effect = timer_get_time
yield
return accelerate_time_for_timer_context_manager()