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
|
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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user