mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
[App] Integration tests now work on Windows too
This commit is contained in:
@@ -5,4 +5,9 @@ from contextvars import ContextVar
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
|
||||
|
||||
class NoActiveAppError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
active_app: ContextVar["App"] = ContextVar("active_app")
|
||||
|
||||
@@ -9,6 +9,7 @@ from rich.style import Style
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from ._context import NoActiveAppError
|
||||
from ._node_list import NodeList
|
||||
from .color import Color
|
||||
from .css._error_tools import friendly_list
|
||||
@@ -463,7 +464,7 @@ class DOMNode(MessagePump):
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
|
||||
def remove_class(self, *class_names: str) -> None:
|
||||
@@ -477,7 +478,7 @@ class DOMNode(MessagePump):
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
|
||||
def toggle_class(self, *class_names: str) -> None:
|
||||
@@ -491,7 +492,7 @@ class DOMNode(MessagePump):
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
|
||||
def has_pseudo_class(self, *class_names: str) -> bool:
|
||||
|
||||
@@ -12,7 +12,7 @@ from . import events
|
||||
from . import log
|
||||
from ._timer import Timer, TimerCallback
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from ._context import active_app, NoActiveAppError
|
||||
from .message import Message
|
||||
from . import messages
|
||||
|
||||
@@ -74,8 +74,16 @@ class MessagePump:
|
||||
|
||||
@property
|
||||
def app(self) -> "App":
|
||||
"""Get the current app."""
|
||||
return active_app.get()
|
||||
"""
|
||||
Get the current app.
|
||||
|
||||
Raises:
|
||||
NoActiveAppError: if no active app could be found for the current asyncio context
|
||||
"""
|
||||
try:
|
||||
return active_app.get()
|
||||
except LookupError:
|
||||
raise NoActiveAppError()
|
||||
|
||||
@property
|
||||
def is_parent_active(self):
|
||||
@@ -152,7 +160,13 @@ class MessagePump:
|
||||
pause: bool = False,
|
||||
) -> Timer:
|
||||
timer = Timer(
|
||||
self, delay, self, name=name, callback=callback, repeat=0, pause=pause
|
||||
self,
|
||||
delay,
|
||||
self,
|
||||
name=name or f"set_timer#{Timer._timer_count}",
|
||||
callback=callback,
|
||||
repeat=0,
|
||||
pause=pause,
|
||||
)
|
||||
self._child_tasks.add(timer.start())
|
||||
return timer
|
||||
@@ -170,7 +184,7 @@ class MessagePump:
|
||||
self,
|
||||
interval,
|
||||
self,
|
||||
name=name,
|
||||
name=name or f"set_interval#{Timer._timer_count}",
|
||||
callback=callback,
|
||||
repeat=repeat or None,
|
||||
pause=pause,
|
||||
|
||||
@@ -169,7 +169,7 @@ class Screen(Widget):
|
||||
|
||||
def on_mount(self, event: events.Mount) -> None:
|
||||
self._update_timer = self.set_interval(
|
||||
UPDATE_PERIOD, self._on_update, pause=True
|
||||
UPDATE_PERIOD, self._on_update, name="screen_update", pause=True
|
||||
)
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
|
||||
@@ -13,7 +13,8 @@ from unittest import mock
|
||||
from rich.console import Console
|
||||
|
||||
from textual import events, errors
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.app import App, ComposeResult, WINDOWS
|
||||
from textual._context import active_app
|
||||
from textual.driver import Driver
|
||||
from textual.geometry import Size
|
||||
|
||||
@@ -77,7 +78,7 @@ class AppTest(App):
|
||||
self,
|
||||
*,
|
||||
time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks
|
||||
waiting_duration_after_initialisation: float = 0.1,
|
||||
waiting_duration_after_initialisation: float = 1,
|
||||
waiting_duration_after_yield: float = 0,
|
||||
) -> AsyncContextManager[MockedTimeMoveClockForward]:
|
||||
async def run_app() -> None:
|
||||
@@ -85,50 +86,35 @@ class AppTest(App):
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def get_running_state_context_manager():
|
||||
self._set_active()
|
||||
|
||||
with mock_textual_timers(
|
||||
ticks_granularity_fps=time_mocking_ticks_granularity_fps
|
||||
) as move_time_forward:
|
||||
) as move_clock_forward:
|
||||
run_task = asyncio.create_task(run_app())
|
||||
await asyncio.sleep(0.001)
|
||||
# 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"
|
||||
# )
|
||||
|
||||
await move_time_forward(seconds=waiting_duration_after_initialisation)
|
||||
# We have to do this because `run_app()` is running in its own async task, and our test is going to
|
||||
# run in this one - so the app must also be the active App in our current context:
|
||||
self._set_active()
|
||||
|
||||
await move_clock_forward(seconds=waiting_duration_after_initialisation)
|
||||
# make sure the App has entered its main loop at this stage:
|
||||
assert self._driver is not None
|
||||
|
||||
self.force_screen_update()
|
||||
await self.force_screen_update()
|
||||
|
||||
yield move_time_forward
|
||||
# And now it's time to pass the torch on to the test function!
|
||||
# We provide the `move_clock_forward` function to it,
|
||||
# so it can also do some time-based Textual stuff if it needs to:
|
||||
yield move_clock_forward
|
||||
|
||||
await move_time_forward(seconds=waiting_duration_after_yield)
|
||||
await move_clock_forward(seconds=waiting_duration_after_yield)
|
||||
|
||||
self.force_screen_update()
|
||||
# waiting_duration = max(
|
||||
# waiting_duration_post_yield or 0,
|
||||
# self.screen._update_timer._interval,
|
||||
# )
|
||||
# await asyncio.sleep(waiting_duration)
|
||||
# Make sure our screen is up to date before exiting the context manager,
|
||||
# so tests using our `last_display_capture` for example can assert things on an up to date screen:
|
||||
await self.force_screen_update()
|
||||
|
||||
# if force_timers_tick_after_yield:
|
||||
# await textual_timers_force_tick()
|
||||
|
||||
assert not run_task.done()
|
||||
await self.shutdown()
|
||||
# End of simulated time: we just shut down ourselves:
|
||||
assert not run_task.done()
|
||||
await self.shutdown()
|
||||
|
||||
return get_running_state_context_manager()
|
||||
|
||||
@@ -175,12 +161,17 @@ class AppTest(App):
|
||||
return segment.text[0]
|
||||
return ""
|
||||
|
||||
def force_screen_update(self, *, repaint: bool = True, layout: bool = True) -> None:
|
||||
async def force_screen_update(
|
||||
self, *, repaint: bool = True, layout: bool = True
|
||||
) -> None:
|
||||
try:
|
||||
self.screen.refresh(repaint=repaint, layout=layout)
|
||||
self.screen._on_update()
|
||||
screen = self.screen
|
||||
except IndexError:
|
||||
pass # the app may not have a screen yet
|
||||
return # the app may not have a screen yet
|
||||
screen.refresh(repaint=repaint, layout=layout)
|
||||
screen._on_update()
|
||||
|
||||
await let_asyncio_process_some_events()
|
||||
|
||||
def on_exception(self, error: Exception) -> None:
|
||||
# In tests we want the errors to be raised, rather than printed to a Console
|
||||
@@ -191,6 +182,10 @@ class AppTest(App):
|
||||
"Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`"
|
||||
)
|
||||
|
||||
@property
|
||||
def active_app(self) -> App | None:
|
||||
return active_app.get()
|
||||
|
||||
@property
|
||||
def total_capture(self) -> str | None:
|
||||
return self.console.file.getvalue()
|
||||
@@ -259,6 +254,16 @@ class DriverTest(Driver):
|
||||
pass
|
||||
|
||||
|
||||
# > The resolution of the monotonic clock on Windows is usually around 15.6 msec.
|
||||
# > The best resolution is 0.5 msec.
|
||||
# @link https://docs.python.org/3/library/asyncio-platforms.html:
|
||||
ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD = 0.025 if WINDOWS else 0.002
|
||||
|
||||
|
||||
async def let_asyncio_process_some_events() -> None:
|
||||
await asyncio.sleep(ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD)
|
||||
|
||||
|
||||
def mock_textual_timers(
|
||||
*,
|
||||
ticks_granularity_fps: int = 60,
|
||||
@@ -278,11 +283,17 @@ def mock_textual_timers(
|
||||
target_event_monotonic_time = current_time + duration
|
||||
pending_sleep_events.append((target_event_monotonic_time, event))
|
||||
# Ok, let's wait for this Event
|
||||
# - which can only be "unlocked" by calls to `move_clock_forward()`
|
||||
# (which can only be "unlocked" by calls to `move_clock_forward()`)
|
||||
await event.wait()
|
||||
|
||||
# Our replacement for "textual._timer.Timer.get_time" and "textual.message.Message._get_time":
|
||||
def get_time_mock() -> float:
|
||||
nonlocal current_time
|
||||
|
||||
# let's make the time advance slightly between 2 consecutive calls of this function,
|
||||
# within the same order of magnitude than 2 consecutive calls to ` timer.monotonic()`:
|
||||
current_time += 1.1e-06
|
||||
|
||||
return current_time
|
||||
|
||||
async def move_clock_forward(*, seconds: float) -> tuple[float, int]:
|
||||
@@ -292,11 +303,14 @@ def mock_textual_timers(
|
||||
activated_timers_count_total = 0
|
||||
for tick_counter in range(ticks_count):
|
||||
current_time += single_tick_duration
|
||||
activated_timers_count_total += check_sleep_timers_to_activate()
|
||||
activated_timers_count = check_sleep_timers_to_activate()
|
||||
activated_timers_count_total += activated_timers_count
|
||||
# Let's give an opportunity to asyncio-related stuff to happen,
|
||||
# now that we likely unlocked some occurrences of `await sleep(duration)`:
|
||||
if activated_timers_count:
|
||||
await let_asyncio_process_some_events()
|
||||
|
||||
# Let's give an opportunity to asyncio-related stuff to happen,
|
||||
# now that we unlocked some occurrences of `await sleep(duration)`:
|
||||
await asyncio.sleep(0.0001)
|
||||
await let_asyncio_process_some_events()
|
||||
|
||||
return current_time, activated_timers_count_total
|
||||
|
||||
@@ -307,8 +321,8 @@ def mock_textual_timers(
|
||||
for i, (target_event_monotonic_time, event) in enumerate(
|
||||
pending_sleep_events
|
||||
):
|
||||
if target_event_monotonic_time < current_time:
|
||||
continue
|
||||
if current_time < target_event_monotonic_time:
|
||||
continue # not time for you yet, dear awaiter...
|
||||
# Right, let's release this waiting event!
|
||||
event.set()
|
||||
activated_timers_count += 1
|
||||
|
||||
Reference in New Issue
Block a user