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

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

Web Proxy Viewer  |  New URL  |  Original Page