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

GitHub Viewer

"""Tests for screen-host lifecycle behavior (``pythonnative.hosts``). Off-device the host class is the headless ``ScreenHost``; these tests drive it against a ``FakeBackend`` installed as the process registry. The on-device host is covered by ``test_bridge.py`` through a ``FakeTransport``. """ import os import sys import types from pathlib import Path from typing import Any, Iterator, List, Optional, cast import pytest from pythonnative import diagnostics from pythonnative.bridge import set_transport from pythonnative.bridge.fake import FakeTransport from pythonnative.component import component from pythonnative.element import Element from pythonnative.hooks import use_back_handler, use_effect, use_state from pythonnative.hosts import create_screen, import_component from pythonnative.native_views import set_backend from pythonnative.testing import FakeBackend, FakeView, settle @pytest.fixture def backend(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeBackend]: """Install a ``FakeBackend`` as the registry the host mounts into.""" fake = FakeBackend() set_backend(fake) transport = FakeTransport() set_transport(transport) from pythonnative.bridge import codec from pythonnative.hosts.base import ScreenHost monkeypatch.setattr( ScreenHost, "_show_error", lambda self, payload: transport.call("Host", "show_error", codec.dumps({"call_id": 0, "args": payload})), ) monkeypatch.setattr( ScreenHost, "_dismiss_error", lambda self: transport.call("Host", "dismiss_error", codec.dumps({"call_id": 0, "args": {}})), ) try: yield fake finally: set_backend(None) set_transport(None) def _install_app_module(monkeypatch: pytest.MonkeyPatch, name: str, root: Any) -> str: """Register an in-memory module exposing ``root`` as its ``App``.""" module = types.ModuleType(name) module.App = root # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, name, module) return name def _text_of(view: Optional[FakeView]) -> List[str]: return [v.text for v in view.walk() if v.text is not None] if view is not None else [] def _write_screen(path: Path, text: str) -> None: path.write_text( "from pythonnative.component import component\n" "from pythonnative.element import Element\n\n" "@component\n" "def MainPage():\n" f" return Element('Text', {{'text': {text!r}}}, [])\n", encoding="utf-8", ) # ====================================================================== # Hot reload # ====================================================================== def test_screen_reload_reimports_root_component( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, backend: FakeBackend, ) -> None: package_dir = tmp_path / "reload_app" package_dir.mkdir() screen_path = package_dir / "screen.py" _write_screen(screen_path, "before") monkeypatch.syspath_prepend(os.fspath(tmp_path)) monkeypatch.setattr(sys, "dont_write_bytecode", True) sys.modules.pop("reload_app.screen", None) sys.modules.pop("reload_app", None) host = create_screen("reload_app.screen.MainPage") host.on_create() assert _text_of(host.root_native_view) == ["before"] _write_screen(screen_path, "after") host.reload(["reload_app.screen"]) assert _text_of(host.root_native_view) == ["after"] host.on_destroy() # ====================================================================== # Lifecycle # ====================================================================== def test_screen_host_on_destroy_unmounts_tree(monkeypatch: pytest.MonkeyPatch, backend: FakeBackend) -> None: """``on_destroy`` must unmount the reconciler: cleanups run, views die.""" cleanups: list = [] @component def Root() -> Element: use_effect(lambda: lambda: cleanups.append("cleaned"), []) return Element("Text", {"text": "hi"}, []) host = create_screen(_install_app_module(monkeypatch, "destroy_app", Root)) host.on_create() assert host.reconciler is not None assert backend.live_view_count() > 0 assert cleanups == [] host.on_destroy() assert cleanups == ["cleaned"], "effect cleanup must run on destroy" assert host.reconciler is None assert host.root_native_view is None assert backend.live_view_count() == 0 def test_screen_host_on_create_is_idempotent_across_view_recreation( monkeypatch: pytest.MonkeyPatch, backend: FakeBackend ) -> None: """Android recreates a fragment's view on pop-back; the tree must persist.""" mounts: list = [] @component def Root() -> Element: use_effect(lambda: mounts.append("mounted"), []) return Element("Text", {"text": "hi"}, []) host = create_screen(_install_app_module(monkeypatch, "recreate_app", Root)) host.on_create() reconciler, root = host.reconciler, host.root_native_view host.on_create() assert host.reconciler is reconciler assert host.root_native_view is root assert mounts == ["mounted"] host.on_destroy() def test_screen_host_focus_and_args(monkeypatch: pytest.MonkeyPatch, backend: FakeBackend) -> None: """``on_pause`` / ``on_resume`` drive focus listeners; ``set_args`` accepts JSON.""" @component def Root() -> Element: return Element("Text", {"text": "hi"}, []) host = create_screen(_install_app_module(monkeypatch, "focus_app", Root), None, '{"pn_nav": {"routes": []}}') assert host.args == {"pn_nav": {"routes": []}} assert host.initial_navigation_state() is None, "an empty route list is not a navigation state" host.set_args("not json") assert host.args == {} host.set_args({"k": 1}) assert host.args == {"k": 1} seen: List[bool] = [] remove = host.add_focus_listener(seen.append) host.on_create() assert host.is_focused is True host.on_pause() host.on_pause() host.on_resume() assert seen == [False, True], "focus listeners fire once per transition" remove() host.on_pause() assert seen == [False, True] host.on_destroy() def test_screen_host_on_back_pressed_routes_to_handlers(monkeypatch: pytest.MonkeyPatch, backend: FakeBackend) -> None: """``on_back_pressed`` consults ``use_back_handler`` subscribers.""" consume = {"value": True} @component def Root() -> Element: _count, _set_count = use_state(0) use_back_handler(lambda: consume["value"]) return Element("Text", {"text": "hi"}, []) host = create_screen(_install_app_module(monkeypatch, "back_app", Root)) host.on_create() assert host.on_back_pressed() is True consume["value"] = False assert host.on_back_pressed() is False host.on_destroy() assert host.on_back_pressed() is False, "destroyed host must decline back events" # ====================================================================== # Viewport # ====================================================================== def test_set_viewport_size_forwards_to_reconciler(monkeypatch: pytest.MonkeyPatch, backend: FakeBackend) -> None: @component def Root() -> Element: return Element("Column", {}, [Element("Text", {"text": "hi"}, [])]) host = create_screen(_install_app_module(monkeypatch, "viewport_app", Root)) host.set_viewport_size(390, 844) # before mount: nothing to forward to host.on_create() assert host.reconciler is not None assert backend.ops_of("set_frame") == [], "the headless host supplies no viewport, so no layout yet" host.set_viewport_size(390, 844) assert host.reconciler.viewport_size == (390.0, 844.0) # The root's own frame is owned by the platform host; children get laid out to the viewport width. text = host.root_native_view.find_first("Text") assert text is not None and text.frame[2] == 390.0 host.set_viewport_size(0, 844) assert host.reconciler.viewport_size == (390.0, 844.0), "non-positive sizes are ignored" host.on_destroy() @pytest.fixture def _metrics() -> Iterator[None]: from pythonnative import platform_metrics def reset() -> None: platform_metrics.reset_window_dimensions() platform_metrics.reset_screen_dimensions() platform_metrics.reset_keyboard_height() platform_metrics.reset_safe_area_insets() reset() yield reset() def test_native_viewport_payload_publishes_density_and_screen(_metrics: None) -> None: """The host payload's scale, font_scale, screen_width, and screen_height land in platform_metrics.""" from pythonnative import platform_metrics from pythonnative.hosts.native import _publish_metrics assert _publish_metrics( { "width": 390, "height": 700, "scale": 3, "font_scale": 1.25, "screen_width": 390, "screen_height": 844, "keyboard_height": 144, } ) == (390.0, 700.0) window = platform_metrics.get_window_dimensions() assert (window.width, window.height, window.scale, window.font_scale) == (390.0, 700.0, 3.0, 1.25) screen = platform_metrics.get_screen_dimensions() assert (screen.width, screen.height, screen.scale, screen.font_scale) == (390.0, 844.0, 3.0, 1.25) assert platform_metrics.get_keyboard_height() == 144.0 def test_native_viewport_payload_falls_back_without_density_or_screen(_metrics: None) -> None: """A payload with only width and height keeps scale 1.0 and reports the window as the screen.""" from pythonnative import platform_metrics from pythonnative.hosts.native import _publish_metrics _publish_metrics({"width": 360, "height": 800, "scale": "3", "font_scale": None}) window = platform_metrics.get_window_dimensions() assert window == platform_metrics.WindowDimensions(360.0, 800.0, 1.0, 1.0) assert platform_metrics.get_screen_dimensions() == window # A later payload without density keeps the density an earlier one reported. _publish_metrics({"width": 360, "height": 800, "scale": 2.625, "font_scale": 1.1}) _publish_metrics({"width": 800, "height": 360}) window = platform_metrics.get_window_dimensions() assert (window.width, window.scale, window.font_scale) == (800.0, 2.625, 1.1) assert _publish_metrics("not a dict") == (0.0, 0.0) # ====================================================================== # RedBox # ====================================================================== def test_mount_error_shows_redbox_in_dev_mode(monkeypatch: pytest.MonkeyPatch, backend: FakeBackend) -> None: monkeypatch.setattr(diagnostics, "_dev_mode", True) @component def Root() -> Element: raise RuntimeError("kaboom") host = create_screen(_install_app_module(monkeypatch, "redbox_app", Root)) host.on_create() from pythonnative.bridge import get_transport assert host._redbox_visible payload = cast(FakeTransport, get_transport()).calls[-1][2] assert "RuntimeError in mount: kaboom" == payload["title"] assert "kaboom" in payload["trace"] assert backend.live_view_count() == 0 host.clear_redbox() assert not host._redbox_visible host.on_destroy() def test_render_error_shows_redbox_and_dismiss_restores_tree( monkeypatch: pytest.MonkeyPatch, backend: FakeBackend ) -> None: monkeypatch.setattr(diagnostics, "_dev_mode", True) setter: dict = {} @component def Root() -> Element: broken, set_broken = use_state(False) setter["set"] = set_broken if broken: raise RuntimeError("render broke") return Element("Text", {"text": "fine"}, []) host = create_screen(_install_app_module(monkeypatch, "redbox_render_app", Root)) host.on_create() assert not host._redbox_visible assert _text_of(host.root_native_view) == ["fine"] setter["set"](True) settle() from pythonnative.bridge import get_transport assert host._redbox_visible assert "RuntimeError in render" in cast(FakeTransport, get_transport()).calls[-1][2]["title"] assert _text_of(host.root_native_view) == ["fine"] assert backend.live_view_count() == 1 host.clear_redbox() assert not host._redbox_visible host.on_destroy() def test_mount_error_propagates_outside_dev_mode(monkeypatch: pytest.MonkeyPatch, backend: FakeBackend) -> None: monkeypatch.setattr(diagnostics, "_dev_mode", False) @component def Root() -> Element: raise RuntimeError("kaboom") host = create_screen(_install_app_module(monkeypatch, "redbox_off_app", Root)) with pytest.raises(RuntimeError, match="kaboom"): host.on_create() assert not host._redbox_visible host.on_destroy() # ====================================================================== # Component resolution # ====================================================================== def test_import_component_propagates_real_dependency_errors( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """A failing import *inside* the app must not be masked. Regression: ``import_component`` caught every ``ModuleNotFoundError`` and reported a generic "could not resolve component", hiding the real cause (e.g. a dependency the developer forgot to install). The actual error must propagate, while a genuinely absent path still yields the friendly resolve error. This is what makes ``pn preview`` surface ``No module named 'emoji'`` instead of a misleading message. """ pkg = tmp_path / "resolver_app" pkg.mkdir() (pkg / "__init__.py").write_text("", encoding="utf-8") (pkg / "good.py").write_text("def App():\n return None\n\ndef Other():\n return None\n", encoding="utf-8") (pkg / "bad.py").write_text( "import a_dependency_that_is_not_installed_xyz\n\ndef App():\n return None\n", encoding="utf-8", ) monkeypatch.syspath_prepend(os.fspath(tmp_path)) monkeypatch.setattr(sys, "dont_write_bytecode", True) for name in ("resolver_app", "resolver_app.good", "resolver_app.bad"): sys.modules.pop(name, None) # A resolvable module returns its ``App``; a dotted path returns the named attribute. assert import_component("resolver_app.good").__name__ == "App" assert import_component("resolver_app.good.Other").__name__ == "Other" # The module exists but imports a missing dependency: the real # ``ModuleNotFoundError`` (naming that dependency) must propagate. with pytest.raises(ModuleNotFoundError) as excinfo: import_component("resolver_app.bad") assert excinfo.value.name == "a_dependency_that_is_not_installed_xyz" # A genuinely absent path still gives the friendly resolve error. with pytest.raises(ImportError, match="Could not resolve component"): import_component("resolver_app.nope_does_not_exist")

Back | FazBrowse Home | New Git URL