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