FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

GitHub Viewer

"""Tests for the core-semantics overhaul: multi-child rendering, effect phases, reactive context, portals, error boundary v2, back handlers, and dev-mode diagnostics (hook order, style keys, warnings).""" from __future__ import annotations from typing import Any, Dict, List, Tuple import pytest from pythonnative import diagnostics from pythonnative.component import component, memo from pythonnative.components import ( ErrorBoundary, Fragment, Portal, Text, ) from pythonnative.element import Element from pythonnative.hooks import ( Ref, create_context, use_back_handler, use_context, use_effect, use_imperative_handle, use_layout_effect, use_ref, use_state, ) from pythonnative.reconciler import Reconciler from pythonnative.testing import FakeBackend as MockBackend def _make_reconciler() -> Tuple[Reconciler, MockBackend]: backend = MockBackend() rec = Reconciler(backend) rec.on_render_requested = lambda: None return rec, backend @pytest.fixture() def dev_mode() -> Any: """Enable dev diagnostics for one test and reset state afterwards.""" diagnostics.set_dev_mode(True) diagnostics.clear_warnings() yield None diagnostics.clear_warnings() diagnostics.set_dev_mode(False) # ====================================================================== # Multi-child components (None / list returns) # ====================================================================== def test_component_returning_none_mounts_nothing() -> None: @component def Nothing() -> Any: return None rec, backend = _make_reconciler() root = rec.mount(Element("Column", {}, [Nothing()])) assert root.children == [] assert backend.live_view_count() == 1 # just the Column def test_component_returning_list_mounts_all_siblings() -> None: @component def Pair() -> Any: return [ Element("Text", {"text": "one"}, []), Element("Text", {"text": "two"}, []), ] rec, _backend = _make_reconciler() root = rec.mount(Element("Column", {}, [Pair()])) assert [c.props["text"] for c in root.children] == ["one", "two"] def test_component_toggling_none_and_element() -> None: setters: Dict[str, Any] = {} @component def Toggle() -> Any: visible, set_visible = use_state(False) setters["set"] = set_visible if not visible: return None return Element("Text", {"text": "now you see me"}, []) @component def App() -> Element: return Element( "Column", {}, [ Element("Text", {"text": "header"}, []), Toggle(), Element("Text", {"text": "footer"}, []), ], ) rec, _backend = _make_reconciler() root = rec.mount(App()) assert [c.props["text"] for c in root.children] == ["header", "footer"] setters["set"](True) rec.flush_dirty() assert [c.props["text"] for c in root.children] == [ "header", "now you see me", "footer", ] setters["set"](False) rec.flush_dirty() assert [c.props["text"] for c in root.children] == ["header", "footer"] def test_none_and_false_children_are_dropped() -> None: rec, _backend = _make_reconciler() root = rec.mount( Element( "Column", {}, [ Element("Text", {"text": "a"}, []), None, False, Element("Text", {"text": "b"}, []), ], ) ) assert [c.props["text"] for c in root.children] == ["a", "b"] def test_component_list_grows_and_shrinks() -> None: setters: Dict[str, Any] = {} @component def Repeats() -> Any: n, set_n = use_state(1) setters["set"] = set_n return [Element("Text", {"text": f"row {i}"}, [], key=str(i)) for i in range(n)] rec, _backend = _make_reconciler() root = rec.mount(Element("Column", {}, [Repeats()])) assert len(root.children) == 1 setters["set"](3) rec.flush_dirty() assert [c.props["text"] for c in root.children] == ["row 0", "row 1", "row 2"] setters["set"](2) rec.flush_dirty() assert [c.props["text"] for c in root.children] == ["row 0", "row 1"] def test_keyed_fragment_moves_children_as_a_unit() -> None: def group(name: str) -> Element: return Fragment( Element("Text", {"text": f"{name}-1"}, []), Element("Text", {"text": f"{name}-2"}, []), key=name, ) rec, _backend = _make_reconciler() root = rec.mount(Element("Column", {}, [group("a"), group("b")])) assert [c.props["text"] for c in root.children] == ["a-1", "a-2", "b-1", "b-2"] first_ids = [c.id for c in root.children] rec.reconcile(Element("Column", {}, [group("b"), group("a")])) assert [c.props["text"] for c in root.children] == ["b-1", "b-2", "a-1", "a-2"] # The same native views were reordered, not recreated. assert sorted(c.id for c in root.children) == sorted(first_ids) # ====================================================================== # Effect phases: use_layout_effect before use_effect # ====================================================================== def test_layout_effects_run_before_passive_effects() -> None: order: List[str] = [] @component def App() -> Element: use_effect(lambda: order.append("passive"), []) use_layout_effect(lambda: order.append("layout"), []) return Element("Text", {"text": "x"}, []) rec, _backend = _make_reconciler() rec.mount(App()) assert order == ["layout", "passive"] def test_layout_effect_sees_committed_frame() -> None: frames: List[Any] = [] @component def App() -> Element: ref: Ref = use_ref(None) def read_frame() -> None: frames.append(ref.current.frame if ref.current is not None else None) use_layout_effect(read_frame, None) return Element( "Column", {}, [Element("View", {"ref": ref, "height": 50}, [])], ) rec, _backend = _make_reconciler() # Viewport known up front: the mount commit runs the layout pass # before layout effects flush, so the effect observes a real frame. rec.set_viewport_size(300.0, 600.0) rec.mount(App()) committed = [f for f in frames if f is not None] assert committed, "layout effect should observe a committed frame" frame = committed[-1] assert (frame.width, frame.height) == (300.0, 50.0) def test_layout_effect_cleanup_on_unmount() -> None: cleaned: List[str] = [] @component def Child() -> Element: use_layout_effect(lambda: lambda: cleaned.append("layout"), []) return Element("Text", {"text": "c"}, []) rec, _backend = _make_reconciler() rec.mount(Element("Column", {}, [Child()])) assert cleaned == [] rec.reconcile(Element("Column", {}, [])) assert cleaned == ["layout"] # ====================================================================== # use_imperative_handle # ====================================================================== def test_use_imperative_handle_publishes_and_clears() -> None: class Controller: def __init__(self) -> None: self.calls: List[str] = [] def focus(self) -> None: self.calls.append("focus") @component def Field(ref: Any = None) -> Element: use_imperative_handle(ref, Controller, []) return Element("TextInput", {}, []) handle: Ref = Ref() rec, _backend = _make_reconciler() rec.mount(Element("Column", {}, [Field(ref=handle)])) assert isinstance(handle.current, Controller) handle.current.focus() assert handle.current.calls == ["focus"] rec.reconcile(Element("Column", {}, [])) assert handle.current is None, "unmount must clear the published handle" def test_use_imperative_handle_none_ref_is_noop() -> None: @component def Field(ref: Any = None) -> Element: use_imperative_handle(ref, lambda: object(), []) return Element("TextInput", {}, []) rec, _backend = _make_reconciler() rec.mount(Element("Column", {}, [Field()])) # must not raise # ====================================================================== # Reactive context # ====================================================================== def test_context_change_rerenders_consumer_under_memo() -> None: theme_ctx = create_context("light") consumer_renders: List[str] = [] setters: Dict[str, Any] = {} @component def Leaf() -> Element: theme = use_context(theme_ctx) consumer_renders.append(theme) return Element("Text", {"text": theme}, []) @memo @component def Wall() -> Element: # Memoized, props never change: would skip re-renders entirely # if context were not reactive. return Element("Column", {}, [Leaf()]) @component def App() -> Element: theme, set_theme = use_state("light") setters["set"] = set_theme return theme_ctx.Provider(Wall(), value=theme) rec, backend = _make_reconciler() rec.mount(App()) assert consumer_renders == ["light"] setters["set"]("dark") rec.flush_dirty() assert consumer_renders == ["light", "dark"] root = rec.root_view() assert root.find_first("Text").props["text"] == "dark" assert backend.live_view_count() > 0 def test_context_same_value_does_not_rerender_consumer() -> None: ctx = create_context(0) consumer_renders: List[int] = [] setters: Dict[str, Any] = {} @component def Leaf() -> Element: value = use_context(ctx) consumer_renders.append(value) return Element("Text", {"text": str(value)}, []) @memo @component def Wall() -> Element: return Element("Column", {}, [Leaf()]) @component def App() -> Element: _tick, set_tick = use_state(0) setters["set"] = set_tick # Provider value is constant even though App re-renders. return ctx.Provider(Wall(), value=42) rec, _backend = _make_reconciler() rec.mount(App()) assert consumer_renders == [42] setters["set"](1) rec.flush_dirty() assert consumer_renders == [42], "unchanged context value must not re-render consumers" def test_provider_multiple_children_render_flat() -> None: ctx = create_context("x") @component def Leaf(label: str = "") -> Element: value = use_context(ctx) return Element("Text", {"text": f"{label}:{value}"}, []) rec, _backend = _make_reconciler() root = rec.mount( Element( "Column", {}, [ctx.Provider(Leaf(label="a"), Leaf(label="b"), value="v")], ) ) assert [c.props["text"] for c in root.children] == ["a:v", "b:v"] # ====================================================================== # Portals # ====================================================================== def test_portal_children_render_into_detached_overlay() -> None: rec, backend = _make_reconciler() root = rec.mount( Element( "Column", {}, [ Element("Text", {"text": "content"}, []), Portal(Element("Text", {"text": "floating"}, [])), ], ) ) # The portal contributes no native child to its parent. assert [c.props["text"] for c in root.children] == ["content"] overlays = backend.detached_views("Portal") assert len(overlays) == 1 texts = overlays[0].find_all("Text") assert [t.props["text"] for t in texts] == ["floating"] def test_portal_children_laid_out_against_viewport() -> None: rec, backend = _make_reconciler() rec.mount( Element( "Column", {}, [ Element("Text", {"text": "content"}, []), Portal( Element( "Column", {"position": "absolute", "left": 10, "top": 20, "width": 100, "height": 40}, [], ) ), ], ) ) rec.set_viewport_size(390.0, 844.0) overlay = backend.detached_views("Portal")[0] child = overlay.children[0] assert child.frame == (10.0, 20.0, 100.0, 40.0) def test_portal_unmount_destroys_overlay() -> None: setters: Dict[str, Any] = {} @component def App() -> Element: show, set_show = use_state(True) setters["set"] = set_show children = [Element("Text", {"text": "content"}, [])] if show: children.append(Portal(Element("Text", {"text": "floating"}, []))) return Element("Column", {}, children) rec, backend = _make_reconciler() rec.mount(App()) assert len(backend.detached_views("Portal")) == 1 setters["set"](False) rec.flush_dirty() assert backend.detached_views("Portal") == [] def test_portal_state_and_events_stay_wired() -> None: from pythonnative.events import dispatch_event presses: List[int] = [] @component def App() -> Element: count, set_count = use_state(0) return Element( "Column", {}, [ Element("Text", {"text": f"count:{count}"}, []), Portal( Element( "Button", {"title": "bump", "on_press": lambda: set_count(count + 1)}, [], ) ), ], ) rec, backend = _make_reconciler() root = rec.mount(App()) overlay = backend.detached_views("Portal")[0] button = overlay.find_first("Button") assert button is not None handled = dispatch_event(button.tag, "on_press") assert handled, "portal button press must reach the Python handler" rec.flush_dirty() assert root.find_first("Text").props["text"] == "count:1" assert presses == [] # unused, guards against accidental capture # ====================================================================== # Error boundary v2 # ====================================================================== def test_error_boundary_on_error_callback() -> None: caught: List[BaseException] = [] @component def Boom() -> Element: raise ValueError("kapow") rec, _backend = _make_reconciler() rec.mount( Element( "Column", {}, [ ErrorBoundary( Boom(), fallback=Text("failed"), on_error=lambda exc: caught.append(exc), ) ], ) ) assert len(caught) == 1 assert isinstance(caught[0], ValueError) def test_error_boundary_fallback_receives_error_and_reset() -> None: seen: Dict[str, Any] = {} @component def Boom() -> Element: raise ValueError("kapow") def fallback(error: BaseException, reset: Any) -> Element: seen["error"] = error seen["reset"] = reset return Element("Text", {"text": f"error: {error}"}, []) rec, _backend = _make_reconciler() root = rec.mount(Element("Column", {}, [ErrorBoundary(Boom(), fallback=fallback)])) assert isinstance(seen["error"], ValueError) assert callable(seen["reset"]) assert root.find_first("Text").props["text"] == "error: kapow" def test_error_boundary_reset_retries_children() -> None: attempts: List[int] = [] resets: Dict[str, Any] = {} @component def Flaky() -> Element: attempts.append(1) if len(attempts) == 1: raise ValueError("first time fails") return Element("Text", {"text": "recovered"}, []) def fallback(error: BaseException, reset: Any) -> Element: resets["reset"] = reset return Element("Text", {"text": "failed"}, []) rec, _backend = _make_reconciler() root = rec.mount(Element("Column", {}, [ErrorBoundary(Flaky(), fallback=fallback)])) assert root.find_first("Text").props["text"] == "failed" resets["reset"]() rec.flush_dirty() assert root.find_first("Text").props["text"] == "recovered" assert len(attempts) == 2 # ====================================================================== # use_back_handler # ====================================================================== def test_back_handler_consumes_event() -> None: @component def App() -> Element: use_back_handler(lambda: True) return Element("Text", {"text": "x"}, []) rec, _backend = _make_reconciler() rec.mount(App()) assert rec.dispatch_back_press() is True def test_back_handler_pass_through_and_priority() -> None: calls: List[str] = [] @component def Outer() -> Element: def handle() -> bool: calls.append("outer") return False # observe, do not consume use_back_handler(handle) return Element("Column", {}, [Inner()]) @component def Inner() -> Element: def handle() -> bool: calls.append("inner") return True use_back_handler(handle) return Element("Text", {"text": "x"}, []) rec, _backend = _make_reconciler() rec.mount(Outer()) assert rec.dispatch_back_press() is True # Effects flush children-first, so the parent registered *last* and # dispatch (last-registered first, matching React Native) runs it # first; it declines, then the child consumes. assert calls == ["outer", "inner"] def test_back_handler_unregisters_on_unmount() -> None: setters: Dict[str, Any] = {} @component def Guard() -> Element: use_back_handler(lambda: True) return Element("Text", {"text": "guard"}, []) @component def App() -> Element: show, set_show = use_state(True) setters["set"] = set_show return Element("Column", {}, [Guard() if show else None]) rec, _backend = _make_reconciler() rec.mount(App()) assert rec.dispatch_back_press() is True setters["set"](False) rec.flush_dirty() assert rec.dispatch_back_press() is False # ====================================================================== # Hook-order guard (dev mode) # ====================================================================== def test_conditional_hook_raises_in_dev_mode(dev_mode: Any) -> None: setters: Dict[str, Any] = {} @component def Sneaky() -> Element: flag, set_flag = use_state(False) setters["set"] = set_flag if flag: use_ref(None) # extra hook appears on the second render return Element("Text", {"text": str(flag)}, []) rec, _backend = _make_reconciler() rec.mount(Sneaky()) setters["set"](True) with pytest.raises(diagnostics.HookOrderError): rec.flush_dirty() def test_hook_kind_swap_raises_in_dev_mode(dev_mode: Any) -> None: setters: Dict[str, Any] = {} @component def Shifty() -> Element: flag, set_flag = use_state(False) setters["set"] = set_flag if flag: use_ref(None) else: use_state(0) return Element("Text", {"text": str(flag)}, []) rec, _backend = _make_reconciler() rec.mount(Shifty()) setters["set"](True) with pytest.raises(diagnostics.HookOrderError): rec.flush_dirty() def test_consistent_hooks_pass_in_dev_mode(dev_mode: Any) -> None: setters: Dict[str, Any] = {} @component def Steady() -> Element: count, set_count = use_state(0) setters["set"] = set_count use_ref(None) return Element("Text", {"text": str(count)}, []) rec, _backend = _make_reconciler() rec.mount(Steady()) setters["set"](1) rec.flush_dirty() # must not raise assert rec.root_view().props["text"] == "1" def test_hook_order_not_checked_in_production() -> None: diagnostics.set_dev_mode(False) setters: Dict[str, Any] = {} @component def Sneaky() -> Element: flag, set_flag = use_state(False) setters["set"] = set_flag if flag: use_ref(None) return Element("Text", {"text": str(flag)}, []) rec, _backend = _make_reconciler() rec.mount(Sneaky()) setters["set"](True) rec.flush_dirty() # silently tolerated in production # ====================================================================== # Style-key validation (dev mode) # ====================================================================== def test_unknown_style_key_warns_with_suggestion(dev_mode: Any) -> None: Text("hi", style={"font_siez": 20}) # type: ignore[arg-type] warnings = diagnostics.get_warnings() assert any("font_siez" in w for w in warnings) assert any("font_size" in w for w in warnings), "should suggest the close match" def test_unknown_style_key_warns_once(dev_mode: Any) -> None: Text("hi", style={"font_siez": 20}) # type: ignore[arg-type] Text("hi again", style={"font_siez": 22}) # type: ignore[arg-type] warnings = [w for w in diagnostics.get_warnings() if "font_siez" in w] assert len(warnings) == 1 def test_known_style_keys_do_not_warn(dev_mode: Any) -> None: Text("hi", style={"font_size": 20, "color": "#333333", "margin_top": 4}) assert diagnostics.get_warnings() == [] def test_style_validation_skipped_in_production() -> None: diagnostics.set_dev_mode(False) diagnostics.clear_warnings() Text("hi", style={"font_siez": 20}) # type: ignore[arg-type] assert diagnostics.get_warnings() == [] # ====================================================================== # Duplicate-key warning (dev mode) # ====================================================================== def test_duplicate_sibling_keys_warn(dev_mode: Any) -> None: rec, _backend = _make_reconciler() rec.mount( Element( "Column", {}, [ Element("Text", {"text": "a"}, [], key="same"), Element("Text", {"text": "b"}, [], key="same"), ], ) ) warnings = diagnostics.get_warnings() assert any("same" in w and "key" in w.lower() for w in warnings) # ====================================================================== # Diagnostics primitives # ====================================================================== def test_warn_once_dedupes_by_key(dev_mode: Any) -> None: diagnostics.warn_once("message one", key="k") diagnostics.warn_once("message two", key="k") assert diagnostics.get_warnings() == ["message one"] def test_report_error_routes_to_last_reporter(dev_mode: Any) -> None: seen: List[Tuple[str, str]] = [] owner_a = object() owner_b = object() diagnostics.set_error_reporter(owner_a, lambda exc, phase: seen.append(("a", phase))) diagnostics.set_error_reporter(owner_b, lambda exc, phase: seen.append(("b", phase))) try: assert diagnostics.report_error(ValueError("x"), phase="event") is True assert seen == [("b", "event")] diagnostics.set_error_reporter(owner_b, None) assert diagnostics.report_error(ValueError("y"), phase="render") is True assert seen == [("b", "event"), ("a", "render")] finally: diagnostics.set_error_reporter(owner_a, None) diagnostics.set_error_reporter(owner_b, None) def test_report_error_without_reporter_returns_false(dev_mode: Any) -> None: assert diagnostics.report_error(ValueError("z")) is False def test_event_handler_error_routed_to_reporter(dev_mode: Any) -> None: from pythonnative.events import dispatch_event reported: List[str] = [] owner = object() diagnostics.set_error_reporter(owner, lambda exc, phase: reported.append(phase)) try: @component def App() -> Element: def explode() -> None: raise RuntimeError("handler blew up") return Element("Button", {"title": "x", "on_press": explode}, []) rec, backend = _make_reconciler() root = rec.mount(App()) dispatch_event(root.tag, "on_press") assert reported and "on_press" in reported[0] finally: diagnostics.set_error_reporter(owner, None) # ====================================================================== # Multiple screen-level roots warning # ====================================================================== def test_multi_root_screen_warns_in_dev(dev_mode: Any) -> None: @component def App() -> Any: return [ Element("Text", {"text": "one"}, []), Element("Text", {"text": "two"}, []), ] rec, _backend = _make_reconciler() rec.mount(App()) warnings = diagnostics.get_warnings() assert any("root" in w.lower() for w in warnings)

Back | FazBrowse Home | New Git URL