mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
[tests] Working on an integration test for the vertical layout
This commit is contained in:
@@ -46,6 +46,7 @@ includes = "src"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|||||||
46
sandbox/color_names.py
Normal file
46
sandbox/color_names.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import rich.repr
|
||||||
|
from rich.align import Align
|
||||||
|
from rich.console import RenderableType
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.pretty import Pretty
|
||||||
|
|
||||||
|
from textual._color_constants import COLOR_NAME_TO_RGB
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
@rich.repr.auto(angular=False)
|
||||||
|
class ColorDisplay(Widget, can_focus=True):
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
return Panel(
|
||||||
|
Align.center(
|
||||||
|
Pretty(self, no_wrap=True, overflow="ellipsis"), vertical="middle"
|
||||||
|
),
|
||||||
|
title=self.name,
|
||||||
|
border_style="none",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ColorNames(App):
|
||||||
|
CSS = """
|
||||||
|
ColorDisplay {
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
self.bind("q", "quit")
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
for color_name, color in COLOR_NAME_TO_RGB.items():
|
||||||
|
color_placeholder = ColorDisplay(name=color_name)
|
||||||
|
is_dark_color = sum(color) < 400
|
||||||
|
color_placeholder.styles.color = "white" if is_dark_color else "black"
|
||||||
|
color_placeholder.styles.background = color_name
|
||||||
|
yield color_placeholder
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
color_name_app = ColorNames()
|
||||||
|
color_name_app.run()
|
||||||
@@ -15,6 +15,7 @@ INNER = 1
|
|||||||
OUTER = 2
|
OUTER = 2
|
||||||
|
|
||||||
BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
|
BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
|
||||||
|
# TODO: in "browsers' CSS" `none` and `hidden` both set the border width to zero. Should we do the same?
|
||||||
"": (" ", " ", " "),
|
"": (" ", " ", " "),
|
||||||
"none": (" ", " ", " "),
|
"none": (" ", " ", " "),
|
||||||
"hidden": (" ", " ", " "),
|
"hidden": (" ", " ", " "),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from operator import attrgetter, itemgetter
|
from operator import attrgetter, itemgetter
|
||||||
import sys
|
import sys
|
||||||
from typing import cast, Callable, Iterator, Iterable, NamedTuple, TYPE_CHECKING
|
from typing import cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
|
||||||
|
|
||||||
import rich.repr
|
import rich.repr
|
||||||
from rich.console import Console, ConsoleOptions, RenderResult
|
from rich.console import Console, ConsoleOptions, RenderResult
|
||||||
@@ -23,15 +23,13 @@ from rich.control import Control
|
|||||||
from rich.segment import Segment, SegmentLines
|
from rich.segment import Segment, SegmentLines
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
|
||||||
from . import errors, log
|
from . import errors
|
||||||
from .geometry import Region, Offset, Size
|
from .geometry import Region, Offset, Size
|
||||||
|
|
||||||
|
|
||||||
from ._loop import loop_last
|
from ._loop import loop_last
|
||||||
from ._profile import timer
|
|
||||||
from ._segment_tools import line_crop
|
from ._segment_tools import line_crop
|
||||||
from ._types import Lines
|
from ._types import Lines
|
||||||
from .widget import Widget
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 10):
|
if sys.version_info >= (3, 10):
|
||||||
from typing import TypeAlias
|
from typing import TypeAlias
|
||||||
@@ -462,7 +460,6 @@ class Compositor:
|
|||||||
"""Render a layout.
|
"""Render a layout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
console (Console): Console instance.
|
|
||||||
clip (Optional[Region]): Region to clip to.
|
clip (Optional[Region]): Region to clip to.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ from typing import (
|
|||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 8):
|
||||||
|
from typing import Literal
|
||||||
|
else:
|
||||||
|
from typing_extensions import Literal # pragma: no cover
|
||||||
|
|
||||||
import rich
|
import rich
|
||||||
import rich.repr
|
import rich.repr
|
||||||
from rich.console import Console, RenderableType
|
from rich.console import Console, RenderableType
|
||||||
@@ -108,6 +113,10 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
driver_class: Type[Driver] | None = None,
|
driver_class: Type[Driver] | None = None,
|
||||||
log_path: str | PurePath = "",
|
log_path: str | PurePath = "",
|
||||||
log_verbosity: int = 1,
|
log_verbosity: int = 1,
|
||||||
|
# TODO: make this Literal a proper type in Rich, so we re-use it?
|
||||||
|
log_color_system: Literal[
|
||||||
|
"auto", "standard", "256", "truecolor", "windows"
|
||||||
|
] = "auto",
|
||||||
title: str = "Textual Application",
|
title: str = "Textual Application",
|
||||||
css_path: str | PurePath | None = None,
|
css_path: str | PurePath | None = None,
|
||||||
watch_css: bool = False,
|
watch_css: bool = False,
|
||||||
@@ -155,6 +164,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._log_file = open(log_path, "wt")
|
self._log_file = open(log_path, "wt")
|
||||||
self._log_console = Console(
|
self._log_console = Console(
|
||||||
file=self._log_file,
|
file=self._log_file,
|
||||||
|
color_system=log_color_system,
|
||||||
markup=False,
|
markup=False,
|
||||||
emoji=False,
|
emoji=False,
|
||||||
highlight=False,
|
highlight=False,
|
||||||
@@ -484,6 +494,18 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
return DOMQuery(self.screen, selector)
|
return DOMQuery(self.screen, selector)
|
||||||
|
|
||||||
|
def query_one(self, selector: str) -> DOMNode | None:
|
||||||
|
"""Retrieves a single node via a DOM query in the current screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
selector (str): A CSS selector .
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DOMNode | None: The first node matching the query, or None if no node matches the selector.
|
||||||
|
"""
|
||||||
|
result = self.query(selector)
|
||||||
|
return result.first() if len(result) else None
|
||||||
|
|
||||||
def get_child(self, id: str) -> DOMNode:
|
def get_child(self, id: str) -> DOMNode:
|
||||||
"""Shorthand for self.screen.get_child(id: str)
|
"""Shorthand for self.screen.get_child(id: str)
|
||||||
Returns the first child (immediate descendent) of this DOMNode
|
Returns the first child (immediate descendent) of this DOMNode
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ class LinuxDriver(Driver):
|
|||||||
self.console.show_cursor(False)
|
self.console.show_cursor(False)
|
||||||
self.console.file.write("\033[?1003h\n")
|
self.console.file.write("\033[?1003h\n")
|
||||||
self.console.file.flush()
|
self.console.file.flush()
|
||||||
self._key_thread = Thread(
|
self._key_thread = Thread(target=self.run_input_thread, args=(loop,))
|
||||||
target=self.run_input_thread, args=(asyncio.get_running_loop(),)
|
|
||||||
)
|
|
||||||
send_size_event()
|
send_size_event()
|
||||||
self._key_thread.start()
|
self._key_thread.start()
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import cast, TYPE_CHECKING
|
from typing import cast, TYPE_CHECKING
|
||||||
|
|
||||||
from .. import log
|
|
||||||
|
|
||||||
from ..geometry import Offset, Region, Size
|
from ..geometry import Offset, Region, Size
|
||||||
from .._layout import Layout, WidgetPlacement
|
from .._layout import Layout, WidgetPlacement
|
||||||
|
|
||||||
@@ -23,7 +21,7 @@ class VerticalLayout(Layout):
|
|||||||
placements: list[WidgetPlacement] = []
|
placements: list[WidgetPlacement] = []
|
||||||
add_placement = placements.append
|
add_placement = placements.append
|
||||||
|
|
||||||
y = max_width = max_height = 0
|
max_width = max_height = 0
|
||||||
parent_size = parent.size
|
parent_size = parent.size
|
||||||
|
|
||||||
box_models = [
|
box_models = [
|
||||||
@@ -40,14 +38,11 @@ class VerticalLayout(Layout):
|
|||||||
|
|
||||||
y = box_models[0].margin.top if box_models else 0
|
y = box_models[0].margin.top if box_models else 0
|
||||||
|
|
||||||
displayed_children = parent.displayed_children
|
displayed_children = cast("list[Widget]", parent.displayed_children)
|
||||||
|
|
||||||
for widget, box_model, margin in zip(displayed_children, box_models, margins):
|
for widget, box_model, margin in zip(displayed_children, box_models, margins):
|
||||||
content_width, content_height = box_model.size
|
content_width, content_height = box_model.size
|
||||||
offset_x = widget.styles.align_width(content_width, parent_size.width)
|
offset_x = widget.styles.align_width(content_width, parent_size.width)
|
||||||
region = Region(offset_x, y, content_width, content_height)
|
region = Region(offset_x, y, content_width, content_height)
|
||||||
# TODO: it seems that `max_height` is not used?
|
|
||||||
max_height = max(max_height, content_height)
|
|
||||||
add_placement(WidgetPlacement(region, widget, 0))
|
add_placement(WidgetPlacement(region, widget, 0))
|
||||||
y += region.height + margin
|
y += region.height + margin
|
||||||
max_height = y
|
max_height = y
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ class Screen(Widget):
|
|||||||
CSS = """
|
CSS = """
|
||||||
|
|
||||||
Screen {
|
Screen {
|
||||||
layout: dock;
|
layout: vertical;
|
||||||
docks: _default=top;
|
overflow-y: auto;
|
||||||
background: $surface;
|
background: $surface;
|
||||||
color: $text-surface;
|
color: $text-surface;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ._context import active_app
|
|||||||
from ._types import Lines
|
from ._types import Lines
|
||||||
from .dom import DOMNode
|
from .dom import DOMNode
|
||||||
from .geometry import clamp, Offset, Region, Size
|
from .geometry import clamp, Offset, Region, Size
|
||||||
|
from .layouts.vertical import VerticalLayout
|
||||||
from .message import Message
|
from .message import Message
|
||||||
from . import messages
|
from . import messages
|
||||||
from ._layout import Layout
|
from ._layout import Layout
|
||||||
@@ -82,6 +83,7 @@ class Widget(DOMNode):
|
|||||||
self._virtual_size = Size(0, 0)
|
self._virtual_size = Size(0, 0)
|
||||||
self._container_size = Size(0, 0)
|
self._container_size = Size(0, 0)
|
||||||
self._layout_required = False
|
self._layout_required = False
|
||||||
|
self._default_layout = VerticalLayout()
|
||||||
self._animate: BoundAnimator | None = None
|
self._animate: BoundAnimator | None = None
|
||||||
self._reactive_watches: dict[str, Callable] = {}
|
self._reactive_watches: dict[str, Callable] = {}
|
||||||
self.highlight_style: Style | None = None
|
self.highlight_style: Style | None = None
|
||||||
@@ -401,6 +403,7 @@ class Widget(DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
Region: The widget region minus scrollbars.
|
Region: The widget region minus scrollbars.
|
||||||
"""
|
"""
|
||||||
|
# return region
|
||||||
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
||||||
if show_horizontal_scrollbar and show_vertical_scrollbar:
|
if show_horizontal_scrollbar and show_vertical_scrollbar:
|
||||||
(region, _, _, _) = region.split(-1, -1)
|
(region, _, _, _) = region.split(-1, -1)
|
||||||
@@ -553,7 +556,12 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def layout(self) -> Layout | None:
|
def layout(self) -> Layout | None:
|
||||||
return self.styles.layout
|
return self.styles.layout or (
|
||||||
|
# If we have children we _should_ return a layout, otherwise they won't be displayed:
|
||||||
|
self._default_layout
|
||||||
|
if self.children
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_container(self) -> bool:
|
def is_container(self) -> bool:
|
||||||
|
|||||||
148
tests/test_integration_layout.py
Normal file
148
tests/test_integration_layout.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
from typing import cast, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.utilities.test_app import AppTest
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.geometry import Size
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
# Let's allow ourselves some abbreviated names for those tests,
|
||||||
|
# in order to make the test cases a bit easier to read :-)
|
||||||
|
SCREEN_W = 100 # width of our Screens
|
||||||
|
SCREEN_H = 8 # height of our Screens
|
||||||
|
SCREEN_SIZE = Size(SCREEN_W, SCREEN_H)
|
||||||
|
PLACEHOLDERS_DEFAULT_H = 3 # the default height for our Placeholder widgets
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"screen_size",
|
||||||
|
"placeholders_count",
|
||||||
|
"root_container_style",
|
||||||
|
"placeholders_style",
|
||||||
|
"expected_placeholders_size",
|
||||||
|
"expected_root_widget_virtual_size",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
SCREEN_SIZE,
|
||||||
|
1,
|
||||||
|
"border: ;", # #root has no border
|
||||||
|
"", # no specific placeholder style
|
||||||
|
# placeholders width=same than screen :: height=default height
|
||||||
|
(SCREEN_W, PLACEHOLDERS_DEFAULT_H),
|
||||||
|
# same for #root's virtual size
|
||||||
|
(SCREEN_W, SCREEN_H),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
# "none" borders still allocate a space for the (invisible) border
|
||||||
|
SCREEN_SIZE,
|
||||||
|
1,
|
||||||
|
"border: none;", # #root has an invisible border
|
||||||
|
"", # no specific placeholder style
|
||||||
|
# placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders
|
||||||
|
(SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H),
|
||||||
|
# same for #root's virtual size
|
||||||
|
(SCREEN_W - 2, SCREEN_H - 2),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
SCREEN_SIZE,
|
||||||
|
1,
|
||||||
|
"border: solid white;", # #root has a visible border
|
||||||
|
"", # no specific placeholder style
|
||||||
|
# placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders
|
||||||
|
(SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H),
|
||||||
|
# same for #root's virtual size
|
||||||
|
(SCREEN_W - 2, SCREEN_H - 2),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
SCREEN_SIZE,
|
||||||
|
4,
|
||||||
|
"border: solid white;", # #root has a visible border
|
||||||
|
"", # no specific placeholder style
|
||||||
|
# placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height minus 2 borders
|
||||||
|
(SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H),
|
||||||
|
# #root's virtual height should be as high as its stacked content
|
||||||
|
(SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H * 4),
|
||||||
|
],
|
||||||
|
# TODO: fix the bug that messes up the layout when the placeholders have "align: center top;":
|
||||||
|
# [
|
||||||
|
# SCREEN_SIZE,
|
||||||
|
# 4,
|
||||||
|
# "border: solid white;", # #root has a visible border
|
||||||
|
# "align: center top;",
|
||||||
|
# # placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height minus 2 borders
|
||||||
|
# (SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H),
|
||||||
|
# # #root's virtual height should be as high as its stacked content
|
||||||
|
# (SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H * 4),
|
||||||
|
# ],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_vertical_container_with_children(
|
||||||
|
screen_size: Size,
|
||||||
|
placeholders_count: int,
|
||||||
|
root_container_style: str,
|
||||||
|
placeholders_style: str,
|
||||||
|
expected_placeholders_size: tuple[int, int],
|
||||||
|
expected_root_widget_virtual_size: tuple[int, int],
|
||||||
|
event_loop: asyncio.AbstractEventLoop,
|
||||||
|
):
|
||||||
|
class VerticalContainer(Widget):
|
||||||
|
CSS = (
|
||||||
|
"""
|
||||||
|
VerticalContainer {
|
||||||
|
layout: vertical;
|
||||||
|
overflow: hidden auto;
|
||||||
|
${root_container_style}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalContainer Placeholder {
|
||||||
|
height: ${placeholders_height};
|
||||||
|
${placeholders_style}
|
||||||
|
}
|
||||||
|
""".replace(
|
||||||
|
"${root_container_style}", root_container_style
|
||||||
|
)
|
||||||
|
.replace("${placeholders_height}", str(PLACEHOLDERS_DEFAULT_H))
|
||||||
|
.replace("${placeholders_style}", placeholders_style)
|
||||||
|
)
|
||||||
|
|
||||||
|
class MyTestApp(AppTest):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
placeholders = [
|
||||||
|
Placeholder(id=f"placeholder_{i}", name=f"Placeholder #{i}")
|
||||||
|
for i in range(placeholders_count)
|
||||||
|
]
|
||||||
|
|
||||||
|
yield VerticalContainer(*placeholders, id="root")
|
||||||
|
|
||||||
|
app = MyTestApp(size=screen_size, test_name="compositor")
|
||||||
|
|
||||||
|
async with app.in_running_state():
|
||||||
|
app.log_tree()
|
||||||
|
|
||||||
|
root_widget = cast(Widget, app.query_one("#root"))
|
||||||
|
assert root_widget.size == screen_size
|
||||||
|
assert root_widget.virtual_size == expected_root_widget_virtual_size
|
||||||
|
root_widget_region = app.screen.get_widget_region(root_widget)
|
||||||
|
assert root_widget_region == (0, 0, screen_size.width, screen_size.height)
|
||||||
|
|
||||||
|
app_placeholders = cast(List[Widget], app.query("Placeholder"))
|
||||||
|
assert len(app_placeholders) == placeholders_count
|
||||||
|
|
||||||
|
for placeholder in app_placeholders:
|
||||||
|
assert placeholder.size == expected_placeholders_size
|
||||||
|
# expected_placeholder_size = (
|
||||||
|
# test_size.width - 2, # because of the 2 horizontal borders of "#root"
|
||||||
|
# test_size.height - 2, # ditto with vertical borders
|
||||||
|
# )
|
||||||
|
# assert placeholder.size == expected_root_widget_size
|
||||||
|
# assert placeholder.virtual_size == expected_root_widget_virtual_size
|
||||||
|
assert placeholder.styles.offset.x.value == 0.0
|
||||||
|
# assert app.screen.get_offset(placeholder).x == 1
|
||||||
130
tests/utilities/test_app.py
Normal file
130
tests/utilities/test_app.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncContextManager
|
||||||
|
|
||||||
|
from rich.console import Console, Capture
|
||||||
|
from textual import events
|
||||||
|
from textual.app import App, ReturnType, ComposeResult
|
||||||
|
from textual.driver import Driver
|
||||||
|
from textual.geometry import Size
|
||||||
|
|
||||||
|
|
||||||
|
# N.B. These classes would better be named TestApp/TestConsole/TestDriver/etc,
|
||||||
|
# but it makes pytest emit warning as it will try to collect them as classes containing test cases :-/
|
||||||
|
|
||||||
|
|
||||||
|
class AppTest(App):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
test_name: str,
|
||||||
|
size: Size,
|
||||||
|
log_verbosity: int = 2,
|
||||||
|
):
|
||||||
|
# will log in "/tests/test.[test name].log":
|
||||||
|
log_path = Path(__file__).parent.parent / f"test.{test_name}.log"
|
||||||
|
super().__init__(
|
||||||
|
driver_class=DriverTest,
|
||||||
|
log_path=log_path,
|
||||||
|
log_verbosity=log_verbosity,
|
||||||
|
log_color_system="256",
|
||||||
|
)
|
||||||
|
self._size = size
|
||||||
|
self._console = ConsoleTest(width=size.width, height=size.height)
|
||||||
|
self._error_console = ConsoleTest(width=size.width, height=size.height)
|
||||||
|
|
||||||
|
def log_tree(self) -> None:
|
||||||
|
"""Handy shortcut when testing stuff"""
|
||||||
|
self.log(self.tree)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Create a subclass of TestApp and override its `compose()` method, rather than using TestApp directly"
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_running_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
initialisation_timeout: float = 0.1,
|
||||||
|
) -> AsyncContextManager[Capture]:
|
||||||
|
async def run_app() -> None:
|
||||||
|
await self.process_messages()
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def get_running_state_context_manager():
|
||||||
|
run_task = asyncio.create_task(run_app())
|
||||||
|
timeout_before_yielding_task = asyncio.create_task(
|
||||||
|
asyncio.sleep(initialisation_timeout)
|
||||||
|
)
|
||||||
|
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 return after its initialization period"
|
||||||
|
)
|
||||||
|
with self.console.capture() as capture:
|
||||||
|
yield capture
|
||||||
|
assert not run_task.done()
|
||||||
|
await self.shutdown()
|
||||||
|
|
||||||
|
return get_running_state_context_manager()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Use `async with my_test_app.get_running_state()` rather than `my_test_app.run()`"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def console(self) -> ConsoleTest:
|
||||||
|
return self._console
|
||||||
|
|
||||||
|
@console.setter
|
||||||
|
def console(self, console: Console) -> None:
|
||||||
|
"""This is a no-op, the console is always a TestConsole"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_console(self) -> ConsoleTest:
|
||||||
|
return self._error_console
|
||||||
|
|
||||||
|
@error_console.setter
|
||||||
|
def error_console(self, console: Console) -> None:
|
||||||
|
"""This is a no-op, the error console is always a TestConsole"""
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleTest(Console):
|
||||||
|
def __init__(self, *, width: int, height: int):
|
||||||
|
file = io.StringIO()
|
||||||
|
super().__init__(
|
||||||
|
color_system="256",
|
||||||
|
file=file,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
force_terminal=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DriverTest(Driver):
|
||||||
|
def start_application_mode(self) -> None:
|
||||||
|
size = Size(self.console.size.width, self.console.size.height)
|
||||||
|
event = events.Resize(self._target, size, size)
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._target.post_message(event),
|
||||||
|
loop=asyncio.get_running_loop(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def disable_input(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop_application_mode(self) -> None:
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user