[tests] Working on an integration test for the vertical layout

This commit is contained in:
Olivier Philippon
2022-05-05 14:05:56 +01:00
parent 8fd7703fc5
commit a92f0f7ec5
11 changed files with 364 additions and 18 deletions

View File

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

View File

@@ -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": (" ", " ", " "),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
} }

View File

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

View 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
View 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