mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
[e2e] Add a way to accelerate time in our integration tests
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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,21 +106,47 @@ 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)
|
||||
await self._sleep(wait_time)
|
||||
count += 1
|
||||
try:
|
||||
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,
|
||||
@@ -126,15 +154,5 @@ class Timer:
|
||||
count=count,
|
||||
callback=self._callback,
|
||||
)
|
||||
count += 1
|
||||
try:
|
||||
if self._callback is not None:
|
||||
await invoke(self._callback)
|
||||
else:
|
||||
await self.target.post_priority_message(event)
|
||||
|
||||
except EventTargetGone:
|
||||
break
|
||||
await self._active.wait()
|
||||
except CancelledError:
|
||||
pass
|
||||
await self.target.post_priority_message(event)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,13 +66,27 @@ 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()
|
||||
with time_acceleration_context:
|
||||
run_task = asyncio.create_task(run_app())
|
||||
timeout_before_yielding_task = asyncio.create_task(
|
||||
asyncio.sleep(waiting_duration_after_initialisation)
|
||||
@@ -86,8 +103,13 @@ class AppTest(App):
|
||||
"TestApp is no longer running after its initialization period"
|
||||
)
|
||||
yield
|
||||
if waiting_duration_post_yield:
|
||||
await asyncio.sleep(waiting_duration_post_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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user