[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/pythonnative/pythonnative/main/tests/test_reconciler.py [Back]  [Original]

"""Unit tests for the reconciler using a mock native backend."""

from typing import Any, Optional

import pytest

from pythonnative.component import component
from pythonnative.element import ERROR_BOUNDARY, Element
from pythonnative.mutations import UNSET
from pythonnative.reconciler import Reconciler
from pythonnative.testing import FakeBackend as MockBackend
from pythonnative.testing import FakeView as MockView

# ======================================================================
# Tests: mount
# ======================================================================


def test_mount_single_element() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el = Element("Text", {"text": "hello"}, [])
    root = rec.mount(el)
    assert isinstance(root, MockView)
    assert root.type_name == "Text"
    assert root.props["text"] == "hello"


def test_mount_nested_elements() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el = Element(
        "Column",
        {},
        [
            Element("Text", {"text": "a"}, []),
            Element("Button", {"title": "b"}, []),
        ],
    )
    root = rec.mount(el)
    assert root.type_name == "Column"
    assert len(root.children) == 2
    assert root.children[0].type_name == "Text"
    assert root.children[1].type_name == "Button"


def test_mount_deeply_nested() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el = Element(
        "ScrollView",
        {},
        [
            Element(
                "Column",
                {},
                [
                    Element("Text", {"text": "deep"}, []),
                ],
            ),
        ],
    )
    root = rec.mount(el)
    assert root.children[0].children[0].props["text"] == "deep"


# ======================================================================
# Tests: reconcile (update props)
# ======================================================================


def test_reconcile_updates_props() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el1 = Element("Text", {"text": "hello"}, [])
    rec.mount(el1)

    backend.ops.clear()
    el2 = Element("Text", {"text": "world"}, [])
    rec.reconcile(el2)

    update_ops = [op for op in backend.ops if op[0] == "update"]
    assert len(update_ops) == 1
    assert "text" in update_ops[0][3]


def test_reconcile_no_change_no_update() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el = Element("Text", {"text": "same"}, [])
    rec.mount(el)

    backend.ops.clear()
    rec.reconcile(Element("Text", {"text": "same"}, []))

    update_ops = [op for op in backend.ops if op[0] == "update"]
    assert len(update_ops) == 0


# ======================================================================
# Tests: reconcile children (add / remove)
# ======================================================================


def test_reconcile_add_child() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])])
    root = rec.mount(el1)
    assert len(root.children) == 1

    backend.ops.clear()
    el2 = Element(
        "Column",
        {},
        [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])],
    )
    rec.reconcile(el2)

    assert len(root.children) == 2


def test_reconcile_remove_child() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el1 = Element(
        "Column",
        {},
        [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])],
    )
    root = rec.mount(el1)
    assert len(root.children) == 2

    backend.ops.clear()
    el2 = Element("Column", {}, [Element("Text", {"text": "a"}, [])])
    rec.reconcile(el2)

    assert len(root.children) == 1


def test_reconcile_replace_child_type() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])])
    root = rec.mount(el1)

    backend.ops.clear()
    el2 = Element("Column", {}, [Element("Button", {"title": "b"}, [])])
    rec.reconcile(el2)

    assert root.children[0].type_name == "Button"


# ======================================================================
# Tests: reconcile root type change
# ======================================================================


def test_reconcile_root_type_change() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el1 = Element("Text", {"text": "a"}, [])
    root1 = rec.mount(el1)

    el2 = Element("Button", {"title": "b"}, [])
    root2 = rec.reconcile(el2)
    assert root2.type_name == "Button"
    assert root2 is not root1


# ======================================================================
# Tests: callbacks live in the event registry, not the native layer
# ======================================================================


def test_reconcile_callback_swap_costs_no_native_traffic() -> None:
    """Fresh closures re-route the event registry without an UpdateOp."""
    from pythonnative.events import get_event_registry

    backend = MockBackend()
    rec = Reconciler(backend)
    calls: list = []
    el1 = Element("Button", {"title": "x", "on_press": lambda: calls.append("first")}, [])
    rec.mount(el1)
    tag = rec.root_tag
    assert tag is not None

    backend.ops.clear()
    el2 = Element("Button", {"title": "x", "on_press": lambda: calls.append("second")}, [])
    rec.reconcile(el2)

    update_ops = [op for op in backend.ops if op[0] == "update"]
    assert update_ops == [], "listener identity churn must not reach the native layer"
    # The registry now routes to the latest closure.
    assert get_event_registry().dispatch(tag, "on_press") is True
    assert calls == ["second"]


def test_callback_routed_through_event_registry_on_mount() -> None:
    from pythonnative.events import get_event_registry

    backend = MockBackend()
    rec = Reconciler(backend)
    calls: list = []
    root = rec.mount(Element("Button", {"title": "x", "on_press": lambda: calls.append(1)}, []))
    tag = rec.root_tag

    # The callable never reaches the backend; only the event-name set does.
    assert "on_press" not in root.props
    assert "on_press" in root.props["_pn_events"]
    assert tag is not None
    get_event_registry().dispatch(tag, "on_press")
    assert calls == [1]


def test_event_registry_cleared_on_destroy() -> None:
    from pythonnative.events import get_event_registry

    backend = MockBackend()
    rec = Reconciler(backend)
    rec.mount(Element("Button", {"title": "x", "on_press": lambda: None}, []))
    tag = rec.root_tag
    assert tag is not None
    assert get_event_registry().has(tag, "on_press")

    rec.reconcile(Element("Text", {"text": "replaced"}, []))
    assert not get_event_registry().has(tag, "on_press")


# ======================================================================
# Tests: removed props signaled as UNSET
# ======================================================================


def test_reconcile_removed_prop_becomes_absent() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el1 = Element("Text", {"text": "hi", "color": "#FF0000"}, [])
    root = rec.mount(el1)

    backend.ops.clear()
    el2 = Element("Text", {"text": "hi"}, [])
    rec.reconcile(el2)

    update_ops = [op for op in backend.ops if op[0] == "update"]
    assert len(update_ops) == 1
    assert "color" in update_ops[0][3]
    assert backend.last_update_changes["color"] is UNSET
    assert "color" not in root.props


# ======================================================================
# Tests: complex multi-step reconciliation
# ======================================================================


def test_multiple_reconcile_cycles() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    rec.mount(Element("Column", {}, [Element("Text", {"text": "0"}, [])]))

    for i in range(1, 5):
        rec.reconcile(Element("Column", {}, [Element("Text", {"text": str(i)}, [])]))

    assert rec.root is not None
    assert rec.root.children[0].element.props["text"] == "4"


# ======================================================================
# Tests: key-based reconciliation
# ======================================================================


def test_keyed_children_preserve_identity() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    el1 = Element(
        "Column",
        {},
        [
            Element("Text", {"text": "A"}, [], key="a"),
            Element("Text", {"text": "B"}, [], key="b"),
            Element("Text", {"text": "C"}, [], key="c"),
        ],
    )
    root = rec.mount(el1)
    assert rec.root is not None
    view_a = rec.root.children[0].native_view
    view_b = rec.root.children[1].native_view
    view_c = rec.root.children[2].native_view

    backend.ops.clear()
    el2 = Element(
        "Column",
        {},
        [
            Element("Text", {"text": "C"}, [], key="c"),
            Element("Text", {"text": "A"}, [], key="a"),
            Element("Text", {"text": "B"}, [], key="b"),
        ],
    )
    rec.reconcile(el2)

    assert rec.root.children[0].native_view is view_c
    assert rec.root.children[1].native_view is view_a
    assert rec.root.children[2].native_view is view_b

    # Native children must also reflect the new order
    assert root.children[0] is view_c
    assert root.children[1] is view_a
    assert root.children[2] is view_b


def test_keyed_children_remove_by_key() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    el1 = Element(
        "Column",
        {},
        [
            Element("Text", {"text": "A"}, [], key="a"),
            Element("Text", {"text": "B"}, [], key="b"),
            Element("Text", {"text": "C"}, [], key="c"),
        ],
    )
    rec.mount(el1)

    el2 = Element(
        "Column",
        {},
        [
            Element("Text", {"text": "A"}, [], key="a"),
            Element("Text", {"text": "C"}, [], key="c"),
        ],
    )
    rec.reconcile(el2)

    assert rec.root is not None
    assert len(rec.root.children) == 2
    assert rec.root.children[0].element.key == "a"
    assert rec.root.children[1].element.key == "c"


def test_keyed_children_insert_new() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    el1 = Element(
        "Column",
        {},
        [
            Element("Text", {"text": "A"}, [], key="a"),
            Element("Text", {"text": "C"}, [], key="c"),
        ],
    )
    rec.mount(el1)

    el2 = Element(
        "Column",
        {},
        [
            Element("Text", {"text": "A"}, [], key="a"),
            Element("Text", {"text": "B"}, [], key="b"),
            Element("Text", {"text": "C"}, [], key="c"),
        ],
    )
    rec.reconcile(el2)

    assert rec.root is not None
    assert len(rec.root.children) == 3
    assert rec.root.children[1].element.key == "b"


# ======================================================================
# Tests: error boundaries
# ======================================================================


def test_error_boundary_catches_mount_error() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    def bad_component(**props: Any) -> Element:
        raise ValueError("boom")

    fallback = Element("Text", {"text": "error caught"}, [])
    child = component(bad_component)()
    eb = Element(ERROR_BOUNDARY, {"fallback": fallback}, [child])

    root = rec.mount(eb)
    assert root.type_name == "Text"
    assert root.props["text"] == "error caught"


def test_error_boundary_callable_fallback() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    def bad_component(**props: Any) -> Element:
        raise RuntimeError("oops")

    def fallback_fn(exc: Exception) -> Element:
        return Element("Text", {"text": f"caught: {exc}"}, [])

    child = component(bad_component)()
    eb = Element(ERROR_BOUNDARY, {"fallback": fallback_fn}, [child])

    root = rec.mount(eb)
    assert root.type_name == "Text"
    assert "caught: oops" in root.props["text"]


def test_error_boundary_no_error_renders_child() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    child = Element("Text", {"text": "ok"}, [])
    fallback = Element("Text", {"text": "error"}, [])
    eb = Element(ERROR_BOUNDARY, {"fallback": fallback}, [child])

    root = rec.mount(eb)
    assert root.type_name == "Text"
    assert root.props["text"] == "ok"


def test_error_boundary_catches_reconcile_error() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    call_count = [0]

    @component
    def flaky() -> Element:
        call_count[0] += 1
        if call_count[0] > 1:
            raise RuntimeError("reconcile boom")
        return Element("Text", {"text": "ok"}, [])

    def fallback_fn(exc: Exception) -> Element:
        return Element("Text", {"text": f"recovered: {exc}"}, [])

    eb1 = Element(ERROR_BOUNDARY, {"fallback": fallback_fn}, [flaky()])
    root = rec.mount(eb1)
    assert root.props["text"] == "ok"

    eb2 = Element(ERROR_BOUNDARY, {"fallback": fallback_fn}, [flaky()])
    root = rec.reconcile(eb2)
    assert "recovered" in root.props["text"]


def test_error_boundary_without_fallback_propagates() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)

    def bad(**props: Any) -> Element:
        raise ValueError("no fallback")

    child = component(bad)()
    eb = Element(ERROR_BOUNDARY, {}, [child])

    with pytest.raises(ValueError, match="no fallback"):
        rec.mount(eb)


# ======================================================================
# Tests: post-render effect flushing
# ======================================================================


def test_effects_flushed_after_mount() -> None:
    calls: list = []

    @component
    def my_comp() -> Element:
        from pythonnative.hooks import use_effect

        use_effect(lambda: calls.append("mounted"), [])
        return Element("Text", {"text": "hi"}, [])

    backend = MockBackend()
    rec = Reconciler(backend)
    rec.mount(my_comp())
    assert calls == ["mounted"]


def test_effects_flushed_after_reconcile() -> None:
    calls: list = []

    @component
    def my_comp(dep: int = 0) -> Element:
        from pythonnative.hooks import use_effect

        use_effect(lambda: calls.append(f"e{dep}"), [dep])
        return Element("Text", {"text": str(dep)}, [])

    backend = MockBackend()
    rec = Reconciler(backend)
    rec.mount(my_comp(dep=1))
    assert calls == ["e1"]

    rec.reconcile(my_comp(dep=2))
    assert calls == ["e1", "e2"]


def test_effect_cleanup_runs_on_rerun() -> None:
    log: list = []

    @component
    def my_comp(dep: int = 0) -> Element:
        from pythonnative.hooks import use_effect

        def effect() -> Any:
            log.append(f"run-{dep}")
            return lambda: log.append(f"cleanup-{dep}")

        use_effect(effect, [dep])
        return Element("Text", {"text": str(dep)}, [])

    backend = MockBackend()
    rec = Reconciler(backend)
    rec.mount(my_comp(dep=1))
    assert log == ["run-1"]

    rec.reconcile(my_comp(dep=2))
    assert log == ["run-1", "cleanup-1", "run-2"]


def test_provider_child_native_view_swap() -> None:
    """When a Provider wraps different component types across renders,
    the parent native container must swap the old native subview for the new one."""
    from pythonnative.hooks import Context, create_context

    ctx: Context[Optional[str]] = create_context(None)

    @component
    def CompA() -> Element:
        return Element("Text", {"text": "A"}, [])

    @component
    def CompB() -> Element:
        return Element("Text", {"text": "B"}, [])

    backend = MockBackend()
    rec = Reconciler(backend)

    tree1 = Element("View", {}, [ctx.Provider(CompA(), value="v1")])
    root = rec.mount(tree1)
    assert len(root.children) == 1
    assert root.children[0].props["text"] == "A"
    old_child_id = root.children[0].id

    tree2 = Element("View", {}, [ctx.Provider(CompB(), value="v2")])
    rec.reconcile(tree2)
    assert len(root.children) == 1
    assert root.children[0].props["text"] == "B"
    assert root.children[0].id != old_child_id


# ======================================================================
# Tests: layout pass
# ======================================================================


def test_layout_pass_runs_after_mount_when_viewport_set() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(320, 480)

    el = Element(
        "Column",
        {"width": 320, "height": 480, "gap": 4},
        [
            Element("View", {"height": 50}, []),
            Element("View", {"height": 80}, []),
        ],
    )
    rec.mount(el)

    set_frame_ops = [op for op in backend.ops if op[0] == "set_frame"]
    assert set_frame_ops, "Layout pass should emit set_frame ops"


def test_layout_pass_skipped_when_no_viewport() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el = Element("Column", {}, [Element("Text", {"text": "x"}, [])])
    rec.mount(el)
    set_frame_ops = [op for op in backend.ops if op[0] == "set_frame"]
    assert set_frame_ops == []


def test_layout_pass_positions_flex_children_in_row() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(300, 100)

    el = Element(
        "Row",
        {"flex_direction": "row", "width": 300, "height": 100},
        [
            Element("View", {"width": 80, "height": 40}, []),
            Element("View", {"flex": 1, "height": 40}, []),
            Element("View", {"width": 60, "height": 40}, []),
        ],
    )
    root = rec.mount(el)
    # The root view's frame is owned by the screen host (e.g., on iOS the root
    # is positioned below the top safe-area inset by ``_sync_root_frame``);
    # the layout engine intentionally leaves it untouched. Children are
    # positioned relative to the root's local origin.
    assert root.frame == (0.0, 0.0, 0.0, 0.0)
    assert root.children[0].frame == (0.0, 0.0, 80.0, 40.0)
    assert root.children[1].frame == (80.0, 0.0, 160.0, 40.0)
    assert root.children[2].frame == (240.0, 0.0, 60.0, 40.0)


def test_layout_pass_clamps_scroll_view_to_viewport_height() -> None:
    """ScrollView's own measured height fills the parent rather than tracking content.

    Regression test: without this clamp, a screen-sized ScrollView that
    wraps a taller child would size itself to the child's natural
    height, leaving the native scroll view with
    ``frame.height == contentSize.height`` and nothing to scroll. We
    wrap the ScrollView in a View so it isn't the root (the root's
    frame is owned by the screen host, not by the layout engine).
    """
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(400, 600)

    el = Element(
        "View",
        {"width": 400, "height": 600},
        [
            Element(
                "ScrollView",
                {},
                [
                    Element(
                        "Column",
                        {},
                        [Element("View", {"height": 200}, []) for _ in range(6)],
                    ),
                ],
            ),
        ],
    )
    root = rec.mount(el)
    scroll = root.children[0]
    assert scroll.type_name == "ScrollView"
    assert scroll.frame[3] == 600.0
    inner_column = scroll.children[0]
    assert inner_column.frame[3] == 1200.0


def test_layout_pass_handles_absolute_positioning() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(400, 300)

    el = Element(
        "View",
        {"width": 400, "height": 300},
        [
            Element("View", {"height": 60}, []),
            Element(
                "View",
                {"position": "absolute", "top": 10, "right": 20, "width": 50, "height": 50},
                [],
            ),
        ],
    )
    root = rec.mount(el)
    flow_child, abs_child = root.children
    assert flow_child.frame[1] == 0
    assert abs_child.frame == (330.0, 10.0, 50.0, 50.0)


def test_layout_pass_handles_padding_and_align_items() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(200, 100)

    el = Element(
        "Column",
        {"width": 200, "height": 100, "padding": 8, "align_items": "center"},
        [Element("View", {"width": 40, "height": 30}, [])],
    )
    root = rec.mount(el)
    child = root.children[0]
    # Available cross axis after padding = 200 - 16 = 184; centered child
    # at x = 8 (pad_left) + (184 - 40) / 2 = 80
    assert child.frame == (80.0, 8.0, 40.0, 30.0)


def test_layout_pass_uses_intrinsic_text_size() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(300, 200)

    el = Element(
        "Column",
        {"width": 300, "height": 200, "align_items": "flex_start"},
        [Element("Text", {"text": "hi"}, [])],
    )
    root = rec.mount(el)
    text_view = root.children[0]
    # MockBackend.measure_intrinsic returns (60, 16) for Text.
    assert text_view.frame == (0.0, 0.0, 60.0, 16.0)


def test_layout_re_runs_on_reconcile() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(200, 100)

    el1 = Element("Row", {"width": 200, "height": 100}, [Element("View", {"width": 50, "height": 50}, [])])
    root = rec.mount(el1)
    assert root.children[0].frame == (0.0, 0.0, 50.0, 50.0)

    el2 = Element("Row", {"width": 200, "height": 100}, [Element("View", {"width": 100, "height": 60}, [])])
    rec.reconcile(el2)
    assert root.children[0].frame == (0.0, 0.0, 100.0, 60.0)


def test_set_viewport_size_triggers_layout_when_tree_exists() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    el = Element("Column", {"width": 100, "height": 50}, [Element("View", {"width": 30, "height": 20}, [])])
    rec.mount(el)
    assert all(op[0] != "set_frame" for op in backend.ops)

    rec.set_viewport_size(320, 480)
    assert any(op[0] == "set_frame" for op in backend.ops)


def test_set_viewport_size_idempotent() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(320, 480)
    rec.mount(Element("Column", {"width": 100, "height": 50}, [Element("View", {"width": 30, "height": 20}, [])]))
    n_frames_after_mount = sum(1 for op in backend.ops if op[0] == "set_frame")

    rec.set_viewport_size(320, 480)
    n_frames_after_dup = sum(1 for op in backend.ops if op[0] == "set_frame")
    assert n_frames_after_mount == n_frames_after_dup


def test_set_viewport_size_rejects_non_positive() -> None:
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(0, 0)
    rec.set_viewport_size(-100, 100)
    rec.mount(Element("Column", {"width": 100, "height": 50}, []))
    assert all(op[0] != "set_frame" for op in backend.ops)


def test_layout_pass_gives_tabbar_intrinsic_height() -> None:
    """Regression: TabBar inside a flex:1 column must get its intrinsic height.

    The hello-world tab navigator renders ``View(flex_direction=column,
    flex=1)`` containing a ``View(flex=1)`` content area and a ``TabBar``
    leaf. Without an intrinsic-size measure on TabBar the bar collapses
    to height 0 and disappears from the screen.
    """
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(320, 600)

    el = Element(
        "View",
        {"flex_direction": "column", "flex": 1},
        [
            Element("View", {"flex": 1}, []),
            Element("TabBar", {"items": [{"name": "a", "title": "A"}]}, []),
        ],
    )
    root = rec.mount(el)
    content_view, tab_bar = root.children
    # MockBackend reports TabBar intrinsic = (320, 49); the bar must
    # take that height and the content view must fill the rest.
    assert tab_bar.frame[3] == 49.0, "TabBar must keep its intrinsic 49pt height"
    assert tab_bar.frame[1] == 600.0 - 49.0, "TabBar must sit at the bottom of the viewport"
    assert content_view.frame[3] == 600.0 - 49.0, "Content area must fill the rest"


def test_layout_pass_uses_intrinsic_button_size() -> None:
    """Regression: a Button with no explicit width/height must measure non-zero.

    Mirrors the iOS ``UIButton`` issue where the buttons in the
    hello-world card became invisible because their intrinsic size
    came back as ``(0, 0)``.
    """
    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(320, 600)

    el = Element(
        "Column",
        {"width": 320, "height": 200, "padding": 16, "align_items": "center"},
        [
            Element(
                "Row",
                {"flex_direction": "row", "gap": 8},
                [
                    Element("Button", {"title": "Tap me"}, []),
                    Element("Button", {"title": "Reset"}, []),
                ],
            )
        ],
    )
    root = rec.mount(el)
    row = root.children[0]
    btn_a, btn_b = row.children
    # MockBackend reports Button intrinsic = (80, 32).
    assert btn_a.frame[2] > 0 and btn_a.frame[3] > 0, "Buttons must have non-zero size"
    assert btn_a.frame[2:] == (80.0, 32.0)
    assert btn_b.frame[2:] == (80.0, 32.0)


def test_spacer_with_size_prop_takes_that_dimension() -> None:
    """Regression: ``pn.Spacer(size=16)`` must reserve real space, not zero.

    The component-level ``Spacer`` accepts a ``size`` kwarg that is
    forwarded as a prop; the layout engine only knows ``width`` /
    ``height``, so the factory must translate the prop. Without the
    translation a fixed-size spacer collapses to 0 and any siblings
    relying on it for visual separation get bunched together.
    """
    import pythonnative as pn

    backend = MockBackend()
    rec = Reconciler(backend)
    rec.set_viewport_size(320, 600)

    el = Element(
        "Row",
        {"flex_direction": "row", "width": 320, "height": 50},
        [
            Element("View", {"width": 40, "height": 40}, []),
            pn.Spacer(size=24),
            Element("View", {"width": 40, "height": 40}, []),
        ],
    )
    root = rec.mount(el)
    a, spacer, b = root.children
    assert spacer.frame[2] == 24.0, "Spacer(size=24) must take 24pt on the row's main axis"
    assert b.frame[0] == a.frame[2] + spacer.frame[2], "Sibling sits after spacer"


# ======================================================================
# Fragment (transparent grouping element)
# ======================================================================


def test_fragment_flattens_into_parent() -> None:
    """A Fragment inside a parent contributes its children at the parent level."""
    import pythonnative as pn

    backend = MockBackend()
    rec = Reconciler(backend)
    rec.mount(
        Element(
            "Column",
            {},
            [
                Element("Text", {"text": "before"}, []),
                pn.Fragment(
                    Element("Text", {"text": "frag-a"}, []),
                    Element("Text", {"text": "frag-b"}, []),
                ),
                Element("Text", {"text": "after"}, []),
            ],
        )
    )
    # Find the Column view and assert it received 4 direct children.
    create_ops = [op for op in backend.ops if op[0] == "create"]
    column_id = next(op[2] for op in create_ops if op[1] == "Column")
    add_to_column = [op for op in backend.ops if op[0] == "insert_child" and op[1] == column_id]
    assert len(add_to_column) == 4, "Fragment should be transparent and contribute 2 children directly"


def test_fragment_reconciles_keyed_siblings() -> None:
    """Reordering keyed children inside a Fragment moves rather than recreates."""
    import pythonnative as pn

    @component
    def row(reversed_order: bool = False) -> Element:
        items: list[Element] = [
            Element("Text", {"text": "A"}, [], key="a"),
            Element("Text", {"text": "B"}, [], key="b"),
        ]
        if reversed_order:
            items.reverse()
        return Element("Column", {}, [pn.Fragment(*items)])

    backend = MockBackend()
    rec = Reconciler(backend)
    rec.mount(row(reversed_order=False))

    create_ops_before = [op for op in backend.ops if op[0] == "create"]
    backend.ops.clear()

    rec.reconcile(row(reversed_order=True))

    create_ops_after = [op for op in backend.ops if op[0] == "create"]
    assert create_ops_after == [], "Reordering keyed children inside Fragment must not recreate views"
    assert any(op[0] in ("insert_child", "remove_child") for op in backend.ops)
    # Two Text views existed before; no new ones should have been added.
    assert sum(1 for op in create_ops_before if op[1] == "Text") == 2

Web Proxy Viewer  |  New URL  |  Original Page