Skip to content

Commit

Permalink
[App] Integration tests now work on Windows too
Browse files Browse the repository at this point in the history
  • Loading branch information
Olivier Philippon committed May 17, 2022
1 parent 0e1d2ee commit d54cc11
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 56 deletions.
5 changes: 5 additions & 0 deletions src/textual/_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@
if TYPE_CHECKING:
from .app import App


class NoActiveAppError(RuntimeError):
pass


active_app: ContextVar["App"] = ContextVar("active_app")
1 change: 1 addition & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ def _print_error_renderables(self) -> None:
self._exit_renderables.clear()

async def process_messages(self) -> None:

self._set_active()

if self.devtools_enabled:
Expand Down
7 changes: 4 additions & 3 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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
Expand Down Expand Up @@ -452,7 +453,7 @@ def add_class(self, *class_names: str) -> None:
try:
self.app.stylesheet.update(self.app, animate=True)
self.refresh()
except LookupError:
except NoActiveAppError:
pass

def remove_class(self, *class_names: str) -> None:
Expand All @@ -466,7 +467,7 @@ def remove_class(self, *class_names: str) -> None:
try:
self.app.stylesheet.update(self.app, animate=True)
self.refresh()
except LookupError:
except NoActiveAppError:
pass

def toggle_class(self, *class_names: str) -> None:
Expand All @@ -480,7 +481,7 @@ def toggle_class(self, *class_names: str) -> None:
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:
Expand Down
24 changes: 19 additions & 5 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
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

Expand Down Expand Up @@ -74,8 +74,16 @@ def has_parent(self) -> bool:

@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):
Expand Down Expand Up @@ -152,7 +160,13 @@ def set_timer(
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
Expand All @@ -170,7 +184,7 @@ def set_interval(
self,
interval,
self,
name=name,
name=name or f"set_interval#{Timer._timer_count}",
callback=callback,
repeat=repeat or None,
pause=pause,
Expand Down
2 changes: 1 addition & 1 deletion src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ async def handle_layout(self, message: messages.Layout) -> None:

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=f"screen_update", pause=True
)

async def on_resize(self, event: events.Resize) -> None:
Expand Down
108 changes: 61 additions & 47 deletions tests/utilities/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
from rich.console import Console

from textual import events
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

Expand Down Expand Up @@ -77,58 +78,43 @@ def in_running_state(
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:
await self.process_messages()

@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)

assert self._driver is not None
# 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()

self.force_screen_update()
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

yield move_time_forward
await self.force_screen_update()

await move_time_forward(seconds=waiting_duration_after_yield)
# 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

self.force_screen_update()
# waiting_duration = max(
# waiting_duration_post_yield or 0,
# self.screen._update_timer._interval,
# )
# await asyncio.sleep(waiting_duration)
await move_clock_forward(seconds=waiting_duration_after_yield)

# if force_timers_tick_after_yield:
# await textual_timers_force_tick()
# 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()

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()

Expand All @@ -145,12 +131,17 @@ async def boot_and_shutdown(
):
pass

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
Expand All @@ -161,6 +152,10 @@ def run(self):
"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()
Expand Down Expand Up @@ -229,6 +224,16 @@ def stop_application_mode(self) -> None:
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,
Expand All @@ -248,11 +253,17 @@ async def sleep_mock(duration: float) -> None:
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]:
Expand All @@ -262,11 +273,14 @@ async def move_clock_forward(*, seconds: float) -> tuple[float, int]:
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

Expand All @@ -277,8 +291,8 @@ def check_sleep_timers_to_activate() -> int:
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
Expand Down

0 comments on commit d54cc11

Please sign in to comment.