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:
|
if TYPE_CHECKING:
|
||||||
from .app import App
|
from .app import App
|
||||||
|
|
||||||
|
|
||||||
|
class NoActiveAppError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
active_app: ContextVar["App"] = ContextVar("active_app")
|
active_app: ContextVar["App"] = ContextVar("active_app")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from rich.style import Style
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from ._context import NoActiveAppError
|
||||||
from ._node_list import NodeList
|
from ._node_list import NodeList
|
||||||
from .color import Color
|
from .color import Color
|
||||||
from .css._error_tools import friendly_list
|
from .css._error_tools import friendly_list
|
||||||
@@ -463,7 +464,7 @@ class DOMNode(MessagePump):
|
|||||||
try:
|
try:
|
||||||
self.app.stylesheet.update(self.app, animate=True)
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
except LookupError:
|
except NoActiveAppError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def remove_class(self, *class_names: str) -> None:
|
def remove_class(self, *class_names: str) -> None:
|
||||||
@@ -477,7 +478,7 @@ class DOMNode(MessagePump):
|
|||||||
try:
|
try:
|
||||||
self.app.stylesheet.update(self.app, animate=True)
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
except LookupError:
|
except NoActiveAppError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def toggle_class(self, *class_names: str) -> None:
|
def toggle_class(self, *class_names: str) -> None:
|
||||||
@@ -491,7 +492,7 @@ class DOMNode(MessagePump):
|
|||||||
try:
|
try:
|
||||||
self.app.stylesheet.update(self.app, animate=True)
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
except LookupError:
|
except NoActiveAppError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def has_pseudo_class(self, *class_names: str) -> bool:
|
def has_pseudo_class(self, *class_names: str) -> bool:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from . import events
|
|||||||
from . import log
|
from . import log
|
||||||
from ._timer import Timer, TimerCallback
|
from ._timer import Timer, TimerCallback
|
||||||
from ._callback import invoke
|
from ._callback import invoke
|
||||||
from ._context import active_app
|
from ._context import active_app, NoActiveAppError
|
||||||
from .message import Message
|
from .message import Message
|
||||||
from . import messages
|
from . import messages
|
||||||
|
|
||||||
@@ -74,8 +74,16 @@ class MessagePump:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def app(self) -> "App":
|
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
|
@property
|
||||||
def is_parent_active(self):
|
def is_parent_active(self):
|
||||||
@@ -152,7 +160,13 @@ class MessagePump:
|
|||||||
pause: bool = False,
|
pause: bool = False,
|
||||||
) -> Timer:
|
) -> Timer:
|
||||||
timer = 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())
|
self._child_tasks.add(timer.start())
|
||||||
return timer
|
return timer
|
||||||
@@ -170,7 +184,7 @@ class MessagePump:
|
|||||||
self,
|
self,
|
||||||
interval,
|
interval,
|
||||||
self,
|
self,
|
||||||
name=name,
|
name=name or f"set_interval#{Timer._timer_count}",
|
||||||
callback=callback,
|
callback=callback,
|
||||||
repeat=repeat or None,
|
repeat=repeat or None,
|
||||||
pause=pause,
|
pause=pause,
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class Screen(Widget):
|
|||||||
|
|
||||||
def on_mount(self, event: events.Mount) -> None:
|
def on_mount(self, event: events.Mount) -> None:
|
||||||
self._update_timer = self.set_interval(
|
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:
|
async def on_resize(self, event: events.Resize) -> None:
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ 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, ComposeResult
|
from textual.app import App, ComposeResult, WINDOWS
|
||||||
|
from textual._context import active_app
|
||||||
from textual.driver import Driver
|
from textual.driver import Driver
|
||||||
from textual.geometry import Size
|
from textual.geometry import Size
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ class AppTest(App):
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks
|
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,
|
waiting_duration_after_yield: float = 0,
|
||||||
) -> AsyncContextManager[MockedTimeMoveClockForward]:
|
) -> AsyncContextManager[MockedTimeMoveClockForward]:
|
||||||
async def run_app() -> None:
|
async def run_app() -> None:
|
||||||
@@ -85,50 +86,35 @@ class AppTest(App):
|
|||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def get_running_state_context_manager():
|
async def get_running_state_context_manager():
|
||||||
self._set_active()
|
|
||||||
|
|
||||||
with mock_textual_timers(
|
with mock_textual_timers(
|
||||||
ticks_granularity_fps=time_mocking_ticks_granularity_fps
|
ticks_granularity_fps=time_mocking_ticks_granularity_fps
|
||||||
) as move_time_forward:
|
) as move_clock_forward:
|
||||||
run_task = asyncio.create_task(run_app())
|
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
|
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()
|
# Make sure our screen is up to date before exiting the context manager,
|
||||||
# waiting_duration = max(
|
# so tests using our `last_display_capture` for example can assert things on an up to date screen:
|
||||||
# waiting_duration_post_yield or 0,
|
await self.force_screen_update()
|
||||||
# self.screen._update_timer._interval,
|
|
||||||
# )
|
|
||||||
# await asyncio.sleep(waiting_duration)
|
|
||||||
|
|
||||||
# if force_timers_tick_after_yield:
|
# End of simulated time: we just shut down ourselves:
|
||||||
# await textual_timers_force_tick()
|
assert not run_task.done()
|
||||||
|
await self.shutdown()
|
||||||
assert not run_task.done()
|
|
||||||
await self.shutdown()
|
|
||||||
|
|
||||||
return get_running_state_context_manager()
|
return get_running_state_context_manager()
|
||||||
|
|
||||||
@@ -175,12 +161,17 @@ class AppTest(App):
|
|||||||
return segment.text[0]
|
return segment.text[0]
|
||||||
return ""
|
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:
|
try:
|
||||||
self.screen.refresh(repaint=repaint, layout=layout)
|
screen = self.screen
|
||||||
self.screen._on_update()
|
|
||||||
except IndexError:
|
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:
|
def on_exception(self, error: Exception) -> None:
|
||||||
# In tests we want the errors to be raised, rather than printed to a Console
|
# 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()`"
|
"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
|
@property
|
||||||
def total_capture(self) -> str | None:
|
def total_capture(self) -> str | None:
|
||||||
return self.console.file.getvalue()
|
return self.console.file.getvalue()
|
||||||
@@ -259,6 +254,16 @@ class DriverTest(Driver):
|
|||||||
pass
|
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(
|
def mock_textual_timers(
|
||||||
*,
|
*,
|
||||||
ticks_granularity_fps: int = 60,
|
ticks_granularity_fps: int = 60,
|
||||||
@@ -278,11 +283,17 @@ def mock_textual_timers(
|
|||||||
target_event_monotonic_time = current_time + duration
|
target_event_monotonic_time = current_time + duration
|
||||||
pending_sleep_events.append((target_event_monotonic_time, event))
|
pending_sleep_events.append((target_event_monotonic_time, event))
|
||||||
# Ok, let's wait for this 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()
|
await event.wait()
|
||||||
|
|
||||||
# Our replacement for "textual._timer.Timer.get_time" and "textual.message.Message._get_time":
|
# Our replacement for "textual._timer.Timer.get_time" and "textual.message.Message._get_time":
|
||||||
def get_time_mock() -> float:
|
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
|
return current_time
|
||||||
|
|
||||||
async def move_clock_forward(*, seconds: float) -> tuple[float, int]:
|
async def move_clock_forward(*, seconds: float) -> tuple[float, int]:
|
||||||
@@ -292,11 +303,14 @@ def mock_textual_timers(
|
|||||||
activated_timers_count_total = 0
|
activated_timers_count_total = 0
|
||||||
for tick_counter in range(ticks_count):
|
for tick_counter in range(ticks_count):
|
||||||
current_time += single_tick_duration
|
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,
|
await let_asyncio_process_some_events()
|
||||||
# now that we unlocked some occurrences of `await sleep(duration)`:
|
|
||||||
await asyncio.sleep(0.0001)
|
|
||||||
|
|
||||||
return current_time, activated_timers_count_total
|
return current_time, activated_timers_count_total
|
||||||
|
|
||||||
@@ -307,8 +321,8 @@ def mock_textual_timers(
|
|||||||
for i, (target_event_monotonic_time, event) in enumerate(
|
for i, (target_event_monotonic_time, event) in enumerate(
|
||||||
pending_sleep_events
|
pending_sleep_events
|
||||||
):
|
):
|
||||||
if target_event_monotonic_time < current_time:
|
if current_time < target_event_monotonic_time:
|
||||||
continue
|
continue # not time for you yet, dear awaiter...
|
||||||
# Right, let's release this waiting event!
|
# Right, let's release this waiting event!
|
||||||
event.set()
|
event.set()
|
||||||
activated_timers_count += 1
|
activated_timers_count += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user