[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

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