diff --git a/src/textual/_animator.py b/src/textual/_animator.py index c03378953..65b00244b 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -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] diff --git a/src/textual/_timer.py b/src/textual/_timer.py index ef4d41a2b..3a4f453a9 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -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) diff --git a/src/textual/message.py b/src/textual/message.py index 21a35c735..eef6ff48d 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -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() diff --git a/tests/test_animator.py b/tests/test_animator.py index 76caf31dd..84a242fee 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -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() diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index 4dc5d01c0..7fde168fa 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -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 = ( diff --git a/tests/test_integration_scrolling.py b/tests/test_integration_scrolling.py index 3ee0cab31..60cf1154a 100644 --- a/tests/test_integration_scrolling.py +++ b/tests/test_integration_scrolling.py @@ -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 ( diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index 802578842..a63cff6c9 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -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()