reorganize layout tests

This commit is contained in:
Aran-Fey
2024-12-26 21:24:56 +01:00
parent 05ff195141
commit a76c3a29d8
17 changed files with 422 additions and 325 deletions

View File

@@ -1,7 +1,7 @@
name: Bug report
description: Create a report to help us improve
title: "[BUG]: "
description: Create a bug report to help us improve
labels: [bug]
body:
- type: markdown
attributes:

View File

@@ -1,6 +1,5 @@
name: Feature Request
description: Create a feature request to help us improve
title: "[Feature Request]"
labels: ["enhancement"]
body:
@@ -25,10 +24,10 @@ body:
id: suggested_solution
attributes:
label: Suggested Solution
description: Describe the solution you'd like.
description: If you have a specific solution in mind, describe it here.
placeholder: "A clear and concise description of what you want to happen."
validations:
required: true
required: false
- type: textarea
id: alternatives

View File

@@ -107,6 +107,7 @@ dev-dependencies = [
"hatch>=1.11.1",
"pyfakefs>=5.7.3",
"pytest-cov>=5.0",
"asyncio-atexit>=1.0.1",
]
managed = true

View File

@@ -257,10 +257,32 @@ class ComponentPage:
for param_name, parameter in signature.parameters.items():
# Is this a path parameter, a query parameter, or neither?
type_info = introspection.typing.TypeInfo(
parameter.annotation,
forward_ref_context=parameter.forward_ref_context,
)
try:
type_info = introspection.typing.TypeInfo(
parameter.annotation,
forward_ref_context=parameter.forward_ref_context,
)
except introspection.errors.CannotResolveForwardref:
# If it seems likely that this was supposed to be a query
# parameter, emit a warning. Otherwise, silently ignore it.
if isinstance(parameter.annotation, t.ForwardRef):
annotation_str = parameter.annotation.__forward_arg__
else:
annotation_str = str(parameter.annotation)
annotation_str = annotation_str.lower()
if "query" in annotation_str or "param" in annotation_str:
warnings.warn(
f"The type annotation of the {param_name!r} parameter"
f" of the {self.build.__name__!r} `build` function"
f" cannot be resolved. If this was intended to be a"
f" query parameter, make sure the annotation is valid"
f" at runtime."
)
continue
if param_name not in self._url_pattern.path_parameter_names:
if QUERY_PARAMETER not in type_info.annotations:
continue

View File

@@ -103,16 +103,8 @@ def _create_tests_for_docstring(
def test_summary(self) -> None:
assert docs.summary is not None, f"{docs.name} has no summary"
# Exceptions don't need details
if not (
isinstance(docs.object, type)
and issubclass(docs.object, BaseException)
):
def test_details(self) -> None:
assert (
docs.details is not None
), f"{docs.name} has no details"
def test_details(self) -> None:
assert docs.details is not None, f"{docs.name} has no details"
@pytest.mark.parametrize("code", code_blocks, ids=code_block_ids)
def test_code_block_is_formatted(self, code: str) -> None:

View File

@@ -1,303 +0,0 @@
import asyncio
import typing as t
import pytest
import rio.data_models
import rio.debug.layouter
import rio.testing
from tests.utils.layouting import BrowserClient, cleanup, setup, verify_layout
# For debugging. Set this to a number > 0 if you want to look at the browser.
#
# Note: Chrome's console doesn't show `console.debug` messages per default. To
# see them, click on "All levels" and check "Verbose".
DEBUG_SHOW_BROWSER_DURATION = 0
if DEBUG_SHOW_BROWSER_DURATION:
pytestmark = pytest.mark.async_timeout(DEBUG_SHOW_BROWSER_DURATION + 30)
import tests.utils.layouting
tests.utils.layouting.DEBUG_EXTRA_SLEEP_DURATION = (
DEBUG_SHOW_BROWSER_DURATION
)
@pytest.fixture(scope="module", autouse=True)
async def manage_server():
await setup()
yield
await cleanup()
async def test_dropdowns_work_in_dev_tools() -> None:
# Dropdowns (and other popups) have often been broken in the dev tools, due
# to z-index issues and other reasons. This test makes sure that they work.
async with BrowserClient(rio.Spacer, debug_mode=True) as client:
# Click the 2nd entry in the sidebar, which is the "Icons" tab
await client.execute_js(
"document.querySelector('.rio-switcher-bar-option:nth-child(2) .rio-switcher-bar-icon').click()",
)
await asyncio.sleep(0.5)
# Click an arbitrary icon in the list
await client.execute_js(
"document.querySelector('.rio-switcher .rio-icon').click()"
)
await asyncio.sleep(0.5)
# Open the dropdown
await client.execute_js(
"document.querySelector('.rio-dropdown input').focus()"
)
await asyncio.sleep(0.5)
# Make sure the dropdown list is open and visible
is_entirely_visible = await client.execute_js("""
new Promise((resolve) => {
let elem = document.querySelector(".rio-dropdown-options");
let observer = new IntersectionObserver((entries) => {
resolve(entries[0].intersectionRatio === 1);
});
observer.observe(elem);
});
""")
assert is_entirely_visible
@pytest.mark.parametrize(
"text",
[
"",
"short-text",
"-".join(["long-text"] * 100),
],
)
async def test_single_component(text: str) -> None:
"""
Just one component - this should fill the whole screen.
"""
await verify_layout(lambda: rio.Text(text))
@pytest.mark.parametrize(
"container_type",
[rio.Row, rio.Column],
)
async def test_linear_container_with_no_extra_width(
container_type: t.Type,
) -> None:
await verify_layout(
lambda: container_type(
rio.Text("hi", min_width=100),
rio.Button("clicky", min_width=400),
)
)
@pytest.mark.parametrize(
"horizontal",
[True, False],
)
@pytest.mark.parametrize(
"first_child_grows",
[True, False],
)
@pytest.mark.parametrize(
"second_child_grows",
[True, False],
)
@pytest.mark.parametrize(
"proportions",
[
None,
"homogeneous",
[1, 2],
[2, 1],
],
)
async def test_linear_container_with_extra_width(
horizontal: bool,
first_child_grows: bool,
second_child_grows: bool,
proportions: None | t.Literal["homogeneous"] | list[int],
) -> None:
"""
A battery of scenarios to test the most common containers - Rows & Columns.
"""
if horizontal:
container_type = rio.Row
if first_child_grows:
first_child_width = 0
first_child_grow_x = True
else:
first_child_width = 10
first_child_grow_x = False
if second_child_grows:
second_child_width = 0
second_child_grow_x = True
else:
second_child_width = 20
second_child_grow_x = False
first_child_height = 0
second_child_height = 0
first_child_grow_y = False
second_child_grow_y = False
parent_width = 50
parent_height = 0
else:
container_type = rio.Column
if first_child_grows:
first_child_height = 0
first_child_grow_y = True
else:
first_child_height = 10
first_child_grow_y = False
if second_child_grows:
second_child_height = 0
second_child_grow_y = True
else:
second_child_height = 20
second_child_grow_y = False
first_child_width = 0
second_child_width = 0
first_child_grow_x = False
second_child_grow_x = False
parent_width = 0
parent_height = 50
await verify_layout(
lambda: container_type(
rio.Text(
"short-text",
min_width=first_child_width,
min_height=first_child_height,
grow_x=first_child_grow_x,
grow_y=first_child_grow_y,
),
rio.Text(
"very-much-longer-text",
min_width=second_child_width,
min_height=second_child_height,
grow_x=second_child_grow_x,
grow_y=second_child_grow_y,
),
# It would be nice to vary the spacing as well, but that would once
# again double the number of tests this case already has. Simply
# always specify a spacing, since that is the harder case anyway.
spacing=2,
proportions=proportions,
min_width=parent_width,
min_height=parent_height,
align_x=0.5,
align_y=0.5,
)
)
async def test_stack() -> None:
"""
All children in stacks should be the same size.
"""
layouter = await verify_layout(
lambda: rio.Stack(
rio.Text("Small", key="small_text", min_width=10, min_height=20),
rio.Text("Large", key="large_text", min_width=30, min_height=40),
align_x=0,
align_y=0,
)
)
small_layout = layouter.get_layout_by_key("small_text")
assert small_layout.left_in_viewport_inner == 0
assert small_layout.top_in_viewport_inner == 0
assert small_layout.allocated_inner_width == 30
assert small_layout.allocated_inner_height == 40
large_layout = layouter.get_layout_by_key("large_text")
assert large_layout.left_in_viewport_inner == 0
assert large_layout.top_in_viewport_inner == 0
assert large_layout.allocated_inner_width == 30
assert large_layout.allocated_inner_height == 40
@pytest.mark.parametrize(
"scroll_x,scroll_y",
[
("never", "auto"),
("auto", "never"),
("auto", "auto"),
],
)
async def test_scrolling(
scroll_x: t.Literal["never", "always", "auto"],
scroll_y: t.Literal["never", "always", "auto"],
) -> None:
await verify_layout(
lambda: rio.ScrollContainer(
rio.Text("hi", min_width=30, min_height=30),
scroll_x=scroll_x,
scroll_y=scroll_y,
min_width=20,
min_height=20,
align_x=0.5,
align_y=0.5,
)
)
async def test_ellipsized_text() -> None:
layouter = await verify_layout(
lambda: rio.Text(
"My natural size should become 0",
overflow="ellipsize",
align_x=0,
key="text",
)
)
layout = layouter.get_layout_by_key("text")
assert layout.natural_width == 0
@pytest.mark.parametrize(
"justify",
[
"left",
"right",
"center",
"justified",
"grow",
],
)
async def test_flow_container_layout(justify: str) -> None:
await verify_layout(
lambda: rio.FlowContainer(
rio.Text("foo", min_width=5),
rio.Text("bar", min_width=10),
rio.Text("qux", min_width=4),
column_spacing=3,
row_spacing=2,
justify=justify, # type: ignore
min_width=20,
align_x=0,
)
)

View File

View File

@@ -0,0 +1,12 @@
import pytest
from tests.utils.layouting import cleanup, setup
# Putting this fixture here makes it run only once, even though we have a whole
# bunch of submodules.
@pytest.fixture(scope="module", autouse=True)
async def manage_server():
await setup()
yield
await cleanup()

View File

@@ -0,0 +1,41 @@
import asyncio
import rio
from tests.utils.layouting import BrowserClient
async def test_dropdowns_work_in_dev_tools() -> None:
# Dropdowns (and other popups) have often been broken in the dev tools, due
# to z-index issues and other reasons. This test makes sure that they work.
async with BrowserClient(rio.Spacer, debug_mode=True) as client:
# Click the 2nd entry in the sidebar, which is the "Icons" tab
await client.execute_js(
"document.querySelector('.rio-switcher-bar-option:nth-child(2) .rio-switcher-bar-icon').click()",
)
await asyncio.sleep(0.5)
# Click an arbitrary icon in the list
await client.execute_js(
"document.querySelector('.rio-switcher .rio-icon').click()"
)
await asyncio.sleep(0.5)
# Open the dropdown
await client.execute_js(
"document.querySelector('.rio-dropdown input').focus()"
)
await asyncio.sleep(0.5)
# Make sure the dropdown list is open and visible
is_entirely_visible = await client.execute_js("""
new Promise((resolve) => {
let elem = document.querySelector(".rio-dropdown-options");
let observer = new IntersectionObserver((entries) => {
resolve(entries[0].intersectionRatio === 1);
});
observer.observe(elem);
});
""")
assert is_entirely_visible

View File

@@ -0,0 +1,29 @@
import pytest
import rio
from tests.utils.layouting import verify_layout
@pytest.mark.parametrize(
"justify",
[
"left",
"right",
"center",
"justified",
"grow",
],
)
async def test_flow_container_layout(justify: str) -> None:
await verify_layout(
lambda: rio.FlowContainer(
rio.Text("foo", min_width=5),
rio.Text("bar", min_width=10),
rio.Text("qux", min_width=4),
column_spacing=3,
row_spacing=2,
justify=justify, # type: ignore
min_width=20,
align_x=0,
)
)

View File

@@ -0,0 +1,131 @@
import typing as t
import pytest
import rio
from tests.utils.layouting import verify_layout
@pytest.mark.parametrize(
"container_type",
[rio.Row, rio.Column],
)
async def test_linear_container_with_no_extra_width(
container_type: type,
) -> None:
await verify_layout(
lambda: container_type(
rio.Text("hi", min_width=100),
rio.Button("clicky", min_width=400),
)
)
@pytest.mark.parametrize(
"horizontal",
[True, False],
)
@pytest.mark.parametrize(
"first_child_grows",
[True, False],
)
@pytest.mark.parametrize(
"second_child_grows",
[True, False],
)
@pytest.mark.parametrize(
"proportions",
[
None,
"homogeneous",
[1, 2],
[2, 1],
],
)
async def test_linear_container_with_extra_width(
horizontal: bool,
first_child_grows: bool,
second_child_grows: bool,
proportions: None | t.Literal["homogeneous"] | list[int],
) -> None:
"""
A battery of scenarios to test the most common containers - Rows & Columns.
"""
if horizontal:
container_type = rio.Row
if first_child_grows:
first_child_width = 0
first_child_grow_x = True
else:
first_child_width = 10
first_child_grow_x = False
if second_child_grows:
second_child_width = 0
second_child_grow_x = True
else:
second_child_width = 20
second_child_grow_x = False
first_child_height = 0
second_child_height = 0
first_child_grow_y = False
second_child_grow_y = False
parent_width = 50
parent_height = 0
else:
container_type = rio.Column
if first_child_grows:
first_child_height = 0
first_child_grow_y = True
else:
first_child_height = 10
first_child_grow_y = False
if second_child_grows:
second_child_height = 0
second_child_grow_y = True
else:
second_child_height = 20
second_child_grow_y = False
first_child_width = 0
second_child_width = 0
first_child_grow_x = False
second_child_grow_x = False
parent_width = 0
parent_height = 50
await verify_layout(
lambda: container_type(
rio.Text(
"short-text",
min_width=first_child_width,
min_height=first_child_height,
grow_x=first_child_grow_x,
grow_y=first_child_grow_y,
),
rio.Text(
"very-much-longer-text",
min_width=second_child_width,
min_height=second_child_height,
grow_x=second_child_grow_x,
grow_y=second_child_grow_y,
),
# It would be nice to vary the spacing as well, but that would once
# again double the number of tests this case already has. Simply
# always specify a spacing, since that is the harder case anyway.
spacing=2,
proportions=proportions,
min_width=parent_width,
min_height=parent_height,
align_x=0.5,
align_y=0.5,
)
)

View File

@@ -0,0 +1,36 @@
import asyncio
import rio
from tests.utils.layouting import BrowserClient
async def test_popup_moves_with_anchor():
def build():
return rio.Column(
rio.Webview('<div id="expander"></div>'),
rio.Popup(
anchor=rio.Text("anchor", min_width=10, min_height=10),
content=rio.Text("content"),
position="center",
is_open=True,
),
)
async def get_content_y_coordinate():
return await client.execute_js("""
document.querySelector('.rio-popup-content').getBoundingClientRect().top;
""")
async with BrowserClient(build) as client:
y1 = await get_content_y_coordinate()
# Move the anchor down by making the element above it taller
await client.execute_js(
"document.querySelector('#expander').style.height = '100px';"
)
await asyncio.sleep(0.5)
# Check if the popup moved down
y2 = await get_content_y_coordinate()
assert y2 > y1

View File

@@ -0,0 +1,31 @@
import typing as t
import pytest
import rio
from tests.utils.layouting import verify_layout
@pytest.mark.parametrize(
"scroll_x,scroll_y",
[
("never", "auto"),
("auto", "never"),
("auto", "auto"),
],
)
async def test_scrolling(
scroll_x: t.Literal["never", "always", "auto"],
scroll_y: t.Literal["never", "always", "auto"],
) -> None:
await verify_layout(
lambda: rio.ScrollContainer(
rio.Text("hi", min_width=30, min_height=30),
scroll_x=scroll_x,
scroll_y=scroll_y,
min_width=20,
min_height=20,
align_x=0.5,
align_y=0.5,
)
)

View File

@@ -0,0 +1,32 @@
import rio
from tests.utils.layouting import verify_layout
async def test_stack() -> None:
"""
All children in stacks should be the same size.
"""
layouter = await verify_layout(
lambda: rio.Stack(
rio.Text("Small", key="small_text", min_width=10, min_height=20),
rio.Text("Large", key="large_text", min_width=30, min_height=40),
align_x=0,
align_y=0,
)
)
small_layout = layouter.get_layout_by_key("small_text")
assert small_layout.left_in_viewport_inner == 0
assert small_layout.top_in_viewport_inner == 0
assert small_layout.allocated_inner_width == 30
assert small_layout.allocated_inner_height == 40
large_layout = layouter.get_layout_by_key("large_text")
assert large_layout.left_in_viewport_inner == 0
assert large_layout.top_in_viewport_inner == 0
assert large_layout.allocated_inner_width == 30
assert large_layout.allocated_inner_height == 40

View File

@@ -0,0 +1,34 @@
import pytest
import rio
from tests.utils.layouting import verify_layout
@pytest.mark.parametrize(
"text",
[
"",
"short-text",
"-".join(["long-text"] * 100),
],
)
async def test_single_component(text: str) -> None:
"""
Just one component - this should fill the whole screen.
"""
await verify_layout(lambda: rio.Text(text))
async def test_ellipsized_text() -> None:
layouter = await verify_layout(
lambda: rio.Text(
"My natural size should become 0",
overflow="ellipsize",
align_x=0,
key="text",
)
)
layout = layouter.get_layout_by_key("text")
assert layout.natural_width == 0

View File

@@ -3,6 +3,7 @@ Matches URL patterns to URLs, verifying that they match what they should.
"""
import typing as t
import warnings
import pytest
@@ -543,3 +544,35 @@ def test_layout_parameters_arent_url_parameters():
)
assert kwargs == {"foo": 7}
def test_annotations_dont_have_to_be_resolvable():
# The type annotation is invalid, but it's quite clearly not supposed to be
# a query parameter, so it should be silently ignored.
def build(foo: "oops" = 0): # type: ignore
return rio.Text(str(foo))
with warnings.catch_warnings(record=True) as warnings_list:
rio.ComponentPage(
name="Test Page",
url_segment="foobar",
build=build,
)
assert not warnings_list
def test_unresolvable_annotation_warning():
# The type annotation is invalid, but it's likely meant to be a query
# parameter, so rio should emit a warning.
def build(foo: "QueryParameter[int]" = 0): # type: ignore
return rio.Text(str(foo))
with warnings.catch_warnings(record=True) as warnings_list:
rio.ComponentPage(
name="Test Page",
url_segment="foobar",
build=build,
)
assert len(warnings_list) == 1

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import typing as t
import asyncio_atexit
import playwright.async_api
import uvicorn
@@ -16,7 +17,11 @@ from rio.utils import choose_free_port
__all__ = ["BrowserClient", "verify_layout", "setup", "cleanup"]
DEBUG_EXTRA_SLEEP_DURATION = 0
# For debugging. Set this to a number > 0 if you want to look at the browser.
#
# Note: Chrome's console doesn't show `console.debug` messages per default. To
# see them, click on "All levels" and check "Verbose".
DEBUG_SHOW_BROWSER_DURATION = 0
server_manager: ServerManager | None = None
@@ -86,7 +91,7 @@ class BrowserClient:
async def __aexit__(self, *args: t.Any) -> None:
# Sleep to keep the browser open for debugging
await asyncio.sleep(DEBUG_EXTRA_SLEEP_DURATION)
await asyncio.sleep(DEBUG_SHOW_BROWSER_DURATION)
if self._page is not None:
await self._page.close()
@@ -175,6 +180,8 @@ class ServerManager:
return self._browser
async def start(self) -> None:
asyncio_atexit.register(self.stop)
await self._start_browser()
await self._start_uvicorn_server()
@@ -239,7 +246,7 @@ class ServerManager:
try:
browser = await playwright_obj.chromium.launch(
headless=DEBUG_EXTRA_SLEEP_DURATION == 0
headless=DEBUG_SHOW_BROWSER_DURATION == 0
)
except Exception:
raise Exception(