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

View File

@@ -8,7 +8,6 @@ from asyncio import (
sleep,
Task,
)
from functools import partial
from time import monotonic
from typing import Awaitable, Callable, Union
@@ -28,6 +27,8 @@ class EventTargetGone(Exception):
@rich_repr
class Timer:
_timer_count: int = 1
# Used to mock Timers' behaviour in a Textual app's integration test:
_instances: weakref.WeakSet[Timer] = weakref.WeakSet()
def __init__(
self,
@@ -63,6 +64,7 @@ class Timer:
self._repeat = repeat
self._skip = skip
self._active = Event()
Timer._instances.add(self)
if not pause:
self._active.set()
@@ -104,37 +106,53 @@ class Timer:
"""Result a paused timer."""
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:
"""Run the timer."""
count = 0
_repeat = self._repeat
_interval = self._interval
start = monotonic()
start = self.get_time()
try:
while _repeat is None or count <= _repeat:
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
continue
wait_time = max(0, next_timer - monotonic())
wait_time = max(0, next_timer - now)
if wait_time:
await sleep(wait_time)
event = events.Timer(
self.sender,
timer=self,
time=next_timer,
count=count,
callback=self._callback,
)
await self._sleep(wait_time)
count += 1
try:
if self._callback is not None:
await invoke(self._callback)
else:
await self.target.post_priority_message(event)
await self._tick(next_timer=next_timer, count=count)
except EventTargetGone:
break
await self._active.wait()
except CancelledError:
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.name = camel_to_snake(self.__class__.__name__.replace("Message", ""))
self.time = monotonic()
self.time = self._get_time()
self._forwarded = False
self._no_default_action = False
self._stop_propagation = False
@@ -99,3 +99,9 @@ class Message:
"""
self._stop_propagation = stop
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],
)
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
import asyncio
from typing import cast, List
import pytest
@@ -107,7 +106,6 @@ async def test_composition_of_vertical_container_with_children(
expected_placeholders_size: tuple[int, int],
expected_root_widget_virtual_size: tuple[int, int],
expected_placeholders_offset_x: int,
event_loop: asyncio.AbstractEventLoop,
):
class VerticalContainer(Widget):
CSS = (

View File

@@ -21,7 +21,6 @@ from textual.widgets import Placeholder
SCREEN_SIZE = Size(100, 30)
@pytest.mark.skip("flaky test")
@pytest.mark.asyncio
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
@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,
# 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:
[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(
@@ -94,7 +93,7 @@ async def test_scroll_to_widget(
id_: f"placeholder_{id_}" in last_display_capture
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:
for placeholder_id in last_screen_expected_placeholder_ids:
assert (

View File

@@ -4,12 +4,15 @@ import asyncio
import contextlib
import io
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 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.geometry import Size
@@ -63,33 +66,52 @@ class AppTest(App):
*,
waiting_duration_after_initialisation: float = 0.1,
waiting_duration_post_yield: float = 0,
time_acceleration: bool = True,
time_acceleration_factor: float = 10,
# force_timers_tick_after_yield: bool = True,
) -> AsyncContextManager:
async def run_app() -> None:
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
async def get_running_state_context_manager():
self._set_active()
run_task = asyncio.create_task(run_app())
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"
with time_acceleration_context:
run_task = asyncio.create_task(run_app())
timeout_before_yielding_task = asyncio.create_task(
asyncio.sleep(waiting_duration_after_initialisation)
)
yield
if waiting_duration_post_yield:
await asyncio.sleep(waiting_duration_post_yield)
assert not run_task.done()
await self.shutdown()
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
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()
@@ -207,3 +229,45 @@ class DriverTest(Driver):
def stop_application_mode(self) -> None:
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()