"""Unit tests for the pure-Python flexbox layout engine.""" import math from typing import Tuple import pytest from pythonnative.layout import ( LayoutNode, calculate_layout, extract_layout_style, ) # ====================================================================== # Helpers # ====================================================================== def text_measure(width: float, height: float) -> Tuple[float, float]: """Stand-in measure that returns a fixed natural size for a leaf.""" return (min(80.0, width), 20.0) # ====================================================================== # Sizing: explicit dimensions # ====================================================================== def test_explicit_dimensions_are_respected() -> None: root = LayoutNode(style={"width": 200, "height": 100}) calculate_layout(root, 320, 480) assert (root.x, root.y, root.width, root.height) == (0, 0, 200, 100) def test_root_with_children_sizes_to_content_when_unsized() -> None: # Without a synthetic outer wrapper the root sizes to its # children. The reconciler's layout pass wraps user trees in a # synthetic viewport node so apps still fill the screen. root = LayoutNode( style={"flex_direction": "column"}, children=[LayoutNode(style={"width": 100, "height": 50})], ) calculate_layout(root, 320, 480) assert root.width == 100 assert root.height == 50 def test_root_explicit_size_wins() -> None: root = LayoutNode(style={"width": 100, "height": 50}) calculate_layout(root, 320, 480) assert root.width == 100 assert root.height == 50 def test_root_fills_viewport_when_wrapped() -> None: user_root = LayoutNode( style={"flex_direction": "column"}, children=[LayoutNode(style={"height": 30})], ) viewport = LayoutNode( style={"width": 320, "height": 480}, children=[user_root], ) calculate_layout(viewport, 320, 480) # User root stretches to viewport on cross axis (default align_items: stretch), # and is positioned at the viewport's origin. assert user_root.width == 320 assert user_root.x == 0 assert user_root.y == 0 def test_percentage_dimensions_resolve_against_parent() -> None: root = LayoutNode( style={"width": 200, "height": 100}, children=[LayoutNode(style={"width": "50%", "height": "25%"})], ) calculate_layout(root, 320, 480) child = root.children[0] assert child.width == 100 assert child.height == 25 def test_percentage_collapses_when_parent_unbounded() -> None: root = LayoutNode( style={"width": 200}, children=[LayoutNode(style={"width": "50%", "height": "25%"})], ) calculate_layout(root, 200, math.inf) child = root.children[0] assert child.width == 100 assert child.height == 0 def test_min_max_constraints_clamp_size() -> None: root = LayoutNode(style={"width": 50, "min_width": 100, "max_height": 30, "height": 200}) calculate_layout(root, 320, 480) assert root.width == 100 assert root.height == 30 def test_aspect_ratio_with_only_width() -> None: root = LayoutNode(style={"width": 100, "aspect_ratio": 2.0}) calculate_layout(root, 320, 480) assert root.width == 100 assert root.height == 50 def test_aspect_ratio_with_only_height() -> None: root = LayoutNode(style={"height": 50, "aspect_ratio": 2.0}) calculate_layout(root, 320, 480) assert root.width == 100 assert root.height == 50 # ====================================================================== # Leaf measurement callback # ====================================================================== def test_leaf_measure_callback_used_when_no_explicit_size() -> None: leaf = LayoutNode(measure=text_measure) calculate_layout(leaf, 320, 480) assert leaf.width == 80 assert leaf.height == 20 def test_leaf_explicit_width_overrides_measure() -> None: leaf = LayoutNode(style={"width": 150}, measure=text_measure) calculate_layout(leaf, 320, 480) assert leaf.width == 150 assert leaf.height == 20 def test_leaf_measure_receives_max_width() -> None: captured: list = [] def measure(w: float, h: float) -> Tuple[float, float]: captured.append((w, h)) return (40.0, 20.0) leaf = LayoutNode(measure=measure) parent = LayoutNode(style={"width": 100, "height": 50}, children=[leaf]) calculate_layout(parent, 320, 480) assert captured assert captured[0][0] <= 100 # ====================================================================== # Column flex layout # ====================================================================== def test_column_stacks_children_vertically() -> None: root = LayoutNode( style={"width": 100, "height": 200, "flex_direction": "column"}, children=[ LayoutNode(style={"height": 50}), LayoutNode(style={"height": 30}), LayoutNode(style={"height": 70}), ], ) calculate_layout(root, 320, 480) a, b, c = root.children assert a.y == 0 assert b.y == 50 assert c.y == 80 def test_column_default_align_items_stretch() -> None: root = LayoutNode( style={"width": 100, "height": 200}, children=[LayoutNode(style={"height": 50})], ) calculate_layout(root, 320, 480) assert root.children[0].width == 100 def test_column_align_items_center() -> None: root = LayoutNode( style={"width": 100, "height": 200, "align_items": "center"}, children=[LayoutNode(style={"width": 40, "height": 50})], ) calculate_layout(root, 320, 480) child = root.children[0] assert child.x == 30 assert child.width == 40 def test_column_align_items_flex_end() -> None: root = LayoutNode( style={"width": 100, "height": 200, "align_items": "flex_end"}, children=[LayoutNode(style={"width": 40, "height": 50})], ) calculate_layout(root, 320, 480) assert root.children[0].x == 60 def test_align_self_overrides_parent_align_items() -> None: root = LayoutNode( style={"width": 100, "height": 200, "align_items": "flex_start"}, children=[ LayoutNode(style={"width": 40, "height": 50}), LayoutNode(style={"width": 40, "height": 50, "align_self": "flex_end"}), ], ) calculate_layout(root, 320, 480) assert root.children[0].x == 0 assert root.children[1].x == 60 # ====================================================================== # Row flex layout # ====================================================================== def test_row_lays_out_children_horizontally() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50}, children=[ LayoutNode(style={"width": 80, "height": 40}), LayoutNode(style={"width": 100, "height": 40}), LayoutNode(style={"width": 60, "height": 40}), ], ) calculate_layout(root, 320, 480) a, b, c = root.children assert (a.x, a.y) == (0, 0) assert (b.x, b.y) == (80, 0) assert (c.x, c.y) == (180, 0) def test_row_align_items_center() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 100, "align_items": "center"}, children=[LayoutNode(style={"width": 50, "height": 30})], ) calculate_layout(root, 320, 480) assert root.children[0].y == 35 # ====================================================================== # Flex distribution # ====================================================================== def test_flex_one_fills_remaining_space() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50}, children=[ LayoutNode(style={"width": 80, "height": 40}), LayoutNode(style={"flex": 1, "height": 40}), LayoutNode(style={"width": 60, "height": 40}), ], ) calculate_layout(root, 320, 480) a, b, c = root.children assert a.width == 80 assert b.width == 160 assert c.width == 60 assert (a.x, b.x, c.x) == (0, 80, 240) def test_flex_proportional_distribution() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50}, children=[ LayoutNode(style={"flex": 1, "height": 40}), LayoutNode(style={"flex": 2, "height": 40}), ], ) calculate_layout(root, 320, 480) a, b = root.children assert a.width == 100 assert b.width == 200 def test_flex_grow_distributes_like_flex() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 200, "height": 50}, children=[ LayoutNode(style={"flex_grow": 1, "height": 40}), LayoutNode(style={"flex_grow": 1, "height": 40}), ], ) calculate_layout(root, 320, 480) assert root.children[0].width == 100 assert root.children[1].width == 100 def test_flex_zero_uses_natural_size() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50}, children=[ LayoutNode(style={"flex": 0, "width": 80, "height": 40}), LayoutNode(style={"flex": 0, "width": 80, "height": 40}), ], ) calculate_layout(root, 320, 480) assert root.children[0].width == 80 assert root.children[1].width == 80 # ====================================================================== # justify_content # ====================================================================== def test_justify_flex_start_packs_at_start() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50, "justify_content": "flex_start"}, children=[ LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), ], ) calculate_layout(root, 320, 480) assert root.children[0].x == 0 assert root.children[1].x == 50 def test_justify_center() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50, "justify_content": "center"}, children=[ LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), ], ) calculate_layout(root, 320, 480) assert root.children[0].x == 100 assert root.children[1].x == 150 def test_justify_flex_end() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50, "justify_content": "flex_end"}, children=[ LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), ], ) calculate_layout(root, 320, 480) assert root.children[0].x == 200 assert root.children[1].x == 250 def test_justify_space_between() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50, "justify_content": "space_between"}, children=[ LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), ], ) calculate_layout(root, 320, 480) assert root.children[0].x == 0 assert root.children[1].x == 125 assert root.children[2].x == 250 def test_justify_space_around() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50, "justify_content": "space_around"}, children=[ LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), ], ) calculate_layout(root, 320, 480) # Free space = 200; each child gets 100/2 = 50 padding either side. assert root.children[0].x == 50 assert root.children[1].x == 200 def test_justify_space_evenly() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 50, "justify_content": "space_evenly"}, children=[ LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), ], ) calculate_layout(root, 320, 480) # Free space = 200; 3 gaps of 200/3 ≈ 66.67. assert root.children[0].x == pytest.approx(66.666, rel=1e-3) assert root.children[1].x == pytest.approx(183.333, rel=1e-3) # ====================================================================== # Padding & spacing # ====================================================================== def test_padding_offsets_children() -> None: root = LayoutNode( style={"width": 100, "height": 100, "padding": 10}, children=[LayoutNode(style={"width": 50, "height": 30})], ) calculate_layout(root, 320, 480) assert root.children[0].x == 10 assert root.children[0].y == 10 def test_padding_dict_horizontal_vertical() -> None: root = LayoutNode( style={"width": 200, "height": 100, "padding": {"horizontal": 20, "vertical": 5}}, children=[LayoutNode(style={"width": 50, "height": 30})], ) calculate_layout(root, 320, 480) assert root.children[0].x == 20 assert root.children[0].y == 5 def test_padding_per_edge_keys_override_omnibus() -> None: root = LayoutNode( style={"width": 200, "height": 100, "padding": 5, "padding_left": 25}, children=[LayoutNode(style={"width": 50, "height": 30})], ) calculate_layout(root, 320, 480) assert root.children[0].x == 25 assert root.children[0].y == 5 def test_spacing_between_children_in_column() -> None: root = LayoutNode( style={"width": 100, "height": 200, "spacing": 8}, children=[ LayoutNode(style={"height": 30}), LayoutNode(style={"height": 30}), LayoutNode(style={"height": 30}), ], ) calculate_layout(root, 320, 480) assert root.children[0].y == 0 assert root.children[1].y == 38 assert root.children[2].y == 76 def test_gap_alias_for_spacing() -> None: root = LayoutNode( style={"width": 100, "height": 200, "gap": 4}, children=[ LayoutNode(style={"height": 30}), LayoutNode(style={"height": 30}), ], ) calculate_layout(root, 320, 480) assert root.children[1].y == 34 # ====================================================================== # Margin # ====================================================================== def test_child_margin_offsets_position() -> None: root = LayoutNode( style={"width": 200, "height": 100}, children=[LayoutNode(style={"width": 50, "height": 30, "margin": 10})], ) calculate_layout(root, 320, 480) child = root.children[0] assert child.x == 10 assert child.y == 10 def test_margin_pushes_siblings_in_column() -> None: root = LayoutNode( style={"width": 100, "height": 300}, children=[ LayoutNode(style={"height": 40, "margin": 10}), LayoutNode(style={"height": 40}), ], ) calculate_layout(root, 320, 480) a, b = root.children assert a.y == 10 assert b.y == 60 # 10 (margin top) + 40 (height) + 10 (margin bottom) def test_margin_dict_per_edge() -> None: root = LayoutNode( style={"width": 200, "height": 100}, children=[ LayoutNode( style={ "width": 50, "height": 30, "margin": {"left": 5, "top": 8, "right": 5, "bottom": 8}, } ) ], ) calculate_layout(root, 320, 480) child = root.children[0] assert child.x == 5 assert child.y == 8 # ====================================================================== # Absolute positioning # ====================================================================== def test_absolute_with_top_left() -> None: root = LayoutNode( style={"width": 200, "height": 200}, children=[ LayoutNode(style={"position": "absolute", "top": 10, "left": 20, "width": 30, "height": 40}), ], ) calculate_layout(root, 320, 480) child = root.children[0] assert (child.x, child.y, child.width, child.height) == (20, 10, 30, 40) def test_absolute_with_right_bottom() -> None: root = LayoutNode( style={"width": 200, "height": 200}, children=[ LayoutNode(style={"position": "absolute", "right": 10, "bottom": 20, "width": 30, "height": 40}), ], ) calculate_layout(root, 320, 480) child = root.children[0] assert child.x == 160 # 200 - 10 - 30 assert child.y == 140 # 200 - 20 - 40 def test_absolute_left_and_right_resolves_width() -> None: root = LayoutNode( style={"width": 200, "height": 200}, children=[ LayoutNode(style={"position": "absolute", "left": 20, "right": 30, "height": 40}), ], ) calculate_layout(root, 320, 480) child = root.children[0] assert child.x == 20 assert child.width == 150 # 200 - 20 - 30 def test_absolute_positioning_respects_parent_padding_box() -> None: root = LayoutNode( style={"width": 200, "height": 100, "padding": {"left": 10, "right": 20, "top": 5, "bottom": 15}}, children=[ LayoutNode(style={"position": "absolute", "right": 10, "bottom": 5, "width": 30, "height": 20}), LayoutNode(style={"position": "absolute", "left": 0, "top": 0, "width": 10, "height": 10}), ], ) calculate_layout(root, 320, 480) right_child, left_child = root.children assert right_child.x == 140 # padding_left + content_width - right - width assert right_child.y == 60 # padding_top + content_height - bottom - height assert left_child.x == 10 assert left_child.y == 5 def test_absolute_does_not_shift_flex_siblings() -> None: root = LayoutNode( style={"width": 100, "height": 200}, children=[ LayoutNode(style={"height": 50}), LayoutNode(style={"position": "absolute", "top": 0, "right": 0, "width": 20, "height": 20}), LayoutNode(style={"height": 50}), ], ) calculate_layout(root, 320, 480) a, abs_child, c = root.children assert a.y == 0 assert c.y == 50 # Sibling after absolute is still positioned as if absolute weren't there. assert abs_child.x == 80 assert abs_child.y == 0 # ====================================================================== # Reverse directions # ====================================================================== def test_row_reverse_lays_out_right_to_left() -> None: root = LayoutNode( style={"flex_direction": "row_reverse", "width": 300, "height": 50}, children=[ LayoutNode(style={"width": 50, "height": 40}), LayoutNode(style={"width": 50, "height": 40}), ], ) calculate_layout(root, 320, 480) # In CSS flexbox, `row-reverse` flips the visual order: the LAST # element appears at the visual start (left), the FIRST element # appears at the visual end. assert root.children[1].x == 0 assert root.children[0].x == 50 visual_order = sorted(root.children, key=lambda c: c.x) assert visual_order[0] is root.children[1] assert visual_order[1] is root.children[0] def test_column_reverse_lays_out_bottom_to_top() -> None: root = LayoutNode( style={"flex_direction": "column_reverse", "width": 100, "height": 300}, children=[ LayoutNode(style={"height": 50}), LayoutNode(style={"height": 50}), ], ) calculate_layout(root, 320, 480) assert root.children[1].y == 0 assert root.children[0].y == 50 # ====================================================================== # Nested layouts # ====================================================================== def test_nested_column_in_row() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 300, "height": 100}, children=[ LayoutNode( style={"flex_direction": "column", "width": 100, "height": 100}, children=[ LayoutNode(style={"height": 30}), LayoutNode(style={"height": 30}), ], ), LayoutNode(style={"flex": 1, "height": 100}), ], ) calculate_layout(root, 320, 480) nested = root.children[0] flex_sib = root.children[1] assert nested.x == 0 assert nested.children[0].y == 0 assert nested.children[1].y == 30 assert flex_sib.x == 100 assert flex_sib.width == 200 def test_nested_padding_compounds_correctly() -> None: root = LayoutNode( style={"width": 200, "height": 200, "padding": 10}, children=[ LayoutNode( style={"padding": 5}, children=[LayoutNode(style={"width": 30, "height": 20})], ), ], ) calculate_layout(root, 320, 480) inner = root.children[0] grandchild = inner.children[0] # Outer padding 10, inner padding 5 -> grandchild offset by 15 from root. # But x/y are relative to the immediate parent's coordinate space. assert inner.x == 10 assert inner.y == 10 assert grandchild.x == 5 assert grandchild.y == 5 # ====================================================================== # Flex-shrink # ====================================================================== def test_flex_shrink_distributes_overflow() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 100, "height": 50}, children=[ LayoutNode(style={"width": 80, "height": 40, "flex_shrink": 1}), LayoutNode(style={"width": 80, "height": 40, "flex_shrink": 1}), ], ) calculate_layout(root, 320, 480) # 160 wants to fit in 100; each shrinks proportionally to its size. a, b = root.children assert a.width == pytest.approx(50) assert b.width == pytest.approx(50) def test_flex_shorthand_shrinks_when_parent_overflows() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 100, "height": 50}, children=[ LayoutNode(style={"flex": 1, "flex_basis": 80, "height": 40}), LayoutNode(style={"flex": 1, "flex_basis": 80, "height": 40}), ], ) calculate_layout(root, 320, 480) a, b = root.children assert a.width == pytest.approx(50) assert b.width == pytest.approx(50) def test_flex_shrink_zero_does_not_shrink() -> None: root = LayoutNode( style={"flex_direction": "row", "width": 100, "height": 50}, children=[ LayoutNode(style={"width": 80, "height": 40}), LayoutNode(style={"width": 80, "height": 40}), ], ) calculate_layout(root, 320, 480) assert root.children[0].width == 80 assert root.children[1].width == 80 # ====================================================================== # Unbounded main axis (ScrollView-style) # ====================================================================== def test_unbounded_height_lets_children_overflow() -> None: root = LayoutNode( style={"width": 100}, children=[ LayoutNode(style={"height": 500}), LayoutNode(style={"height": 500}), ], ) calculate_layout(root, 320, math.inf) assert root.height == 1000 assert root.children[1].y == 500 def test_unbounded_with_flex_basis_uses_basis() -> None: root = LayoutNode( style={"flex_direction": "column", "width": 100}, children=[ LayoutNode(style={"flex_grow": 1, "flex_basis": 50}), ], ) calculate_layout(root, 320, math.inf) assert root.children[0].height == 50 def test_unbounded_with_pure_flex_collapses_to_zero() -> None: root = LayoutNode( style={"flex_direction": "column", "width": 100}, children=[ LayoutNode(style={"flex": 1}), LayoutNode(style={"height": 30}), ], ) calculate_layout(root, 320, math.inf) # `flex: 1` (RN shorthand) implies flex_basis: 0, so an unbounded # parent gives the flex item zero size; non-flex siblings keep # their natural size. assert root.children[0].height == 0 assert root.children[1].height == 30 assert root.height == 30 # ====================================================================== # extract_layout_style helper # ====================================================================== def test_extract_layout_style_keeps_only_layout_keys() -> None: props = { "width": 100, "height": 50, "color": "#FF0000", "text": "Hello", "padding": 8, "background_color": "#EEE", "flex": 1, } style = extract_layout_style(props) assert style == {"width": 100, "height": 50, "padding": 8, "flex": 1}