Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

py/tests/test_app.py

"""Headless robot-UI tests (LAW) — drive the app with Streamlit's AppTest.

We assert:
  • the app runs without exception and renders its title + sidebar build stamp;
  • WHEN the server is up: every pane (Map / Graph / Population / Errors) renders and
    hands its facett component a NON-EMPTY payload — never a blank false-green;
  • WHEN the server is down: the app still runs and shows an HONEST error panel
    (not a crash, not a silent blank).

The live-data assertion cannot look at Streamlit widgets any more, and that is the
point: the panes render through the `facet-wrapped` wasm, so there is no
`st.dataframe`/`st.metric` left holding the domain data (an earlier version of this
test asserted on exactly those, and passed only because the Errors pane was still a
Streamlit lookalike). Instead we SPY on each pane's `facett_component` mount and
assert on the payload the pane actually handed the component — the same JSON the
wasm receives.

The suite auto-detects whether korp-server is reachable and asserts the matching
contract, so it is green both on oden (server up) and in a bare CI (server down).
"""

from __future__ import annotations

import os
from typing import Any

import grpc
import pytest
from streamlit.testing.v1 import AppTest

from facett_demo.client import DEFAULT_TARGET

import facett_demo

APP = os.path.join(os.path.dirname(os.path.abspath(facett_demo.__file__)), "app.py")


def _server_up(target: str = DEFAULT_TARGET, timeout: float = 3.0) -> bool:
    try:
        ch = grpc.insecure_channel(target)
        grpc.channel_ready_future(ch).result(timeout=timeout)
        return True
    except Exception:
        return False


SERVER_UP = _server_up()


def _run() -> AppTest:
    at = AppTest.from_file(APP, default_timeout=120)
    at.run()
    return at


def test_app_runs_without_exception():
    at = _run()
    assert not at.exception, f"app raised: {at.exception}"


def test_title_and_sidebar_build_stamp_present():
    at = _run()
    titles = [t.value for t in at.title]
    assert any("facett-demo" in t for t in titles), titles
    # sidebar shows the app's own version+git-hash (build-version LAW)
    sidebar_code = " ".join(c.value for c in at.sidebar.code)
    from importlib.metadata import version

    assert version("facett-demo") in sidebar_code, sidebar_code


def test_all_three_panes_render():
    """Tabs render all children in one pass — assert each pane's header exists."""
    at = _run()
    subheaders = " ".join(s.value for s in at.subheader)
    assert "Sweden map" in subheaders, subheaders
    assert "Graph" in subheaders, subheaders
    assert "Population" in subheaders, subheaders


@pytest.mark.skipif(not SERVER_UP, reason="korp-server not reachable")
def test_server_up_sidebar_shows_connected_version():
    at = _run()
    sidebar_code = " ".join(c.value for c in at.sidebar.code)
    # the connected server's version banner, e.g. "korp 0.1.0 (3a3d259)"
    assert "korp " in sidebar_code, sidebar_code
    success = " ".join(m.value for m in at.sidebar.success)
    assert "connected" in success, success


#: Pane module → the component it must mount with live data.
_PANE_MOUNTS = {
    "map_pane": "osm2d",
    "graph_pane": "graphnav",
    "population_pane": "table",
    "errcode_pane": "table",
}

#: Payload keys that carry the actual rows/features. A mount whose payload has all of
#: these empty (or none of them at all) rendered nothing — a blank false-green.
_DATA_KEYS = ("rows", "points", "ways", "nodes", "edges", "bars", "series", "snapshots")


@pytest.fixture
def mounts(monkeypatch) -> list[dict[str, Any]]:
    """Spy every `facett_component(...)` mount the panes make during a run.

    Each pane does `from ..facet_host import facett_component`, so the name is bound
    in the PANE module — patch it there, not at the source, or the spy sees nothing.
    """
    import importlib

    seen: list[dict[str, Any]] = []

    def spy(name: str, *, data: Any = None, height: int = 520, key: str | None = None) -> None:
        seen.append({"pane": _current["pane"], "component": name, "data": data, "key": key})
        return None

    for mod_name in _PANE_MOUNTS:
        mod = importlib.import_module(f"facett_demo.panes.{mod_name}")

        def make(mod_name=mod_name):
            def wrapper(name: str, **kw: Any):
                _current["pane"] = mod_name
                return spy(name, **kw)
            return wrapper

        monkeypatch.setattr(mod, "facett_component", make(), raising=True)
    return seen


_current: dict[str, str] = {"pane": "?"}


def _payload_is_nonempty(data: Any) -> bool:
    if not isinstance(data, dict):
        return False
    present = [k for k in _DATA_KEYS if k in data]
    return bool(present) and any(data[k] for k in present)


@pytest.mark.skipif(not SERVER_UP, reason="korp-server not reachable")
def test_server_up_panes_mount_components_with_nonempty_live_data(mounts):
    at = _run()
    assert not at.exception, f"app raised: {at.exception}"
    assert mounts, "no pane mounted a facett component at all"

    by_pane: dict[str, list[dict[str, Any]]] = {}
    for m in mounts:
        by_pane.setdefault(m["pane"], []).append(m)

    # A pane that mounted nothing usually said WHY (honest-empty / refusal panels) —
    # surface those in the failure so it is diagnosable without a rerun.
    said = " | ".join(
        [e.value for e in at.error] + [w.value for w in at.warning] + [i.value for i in at.info]
    )

    for pane, component in _PANE_MOUNTS.items():
        got = by_pane.get(pane, [])
        assert got, (
            f"{pane} mounted no component (expected `{component}`). "
            f"panes that did mount: {sorted(by_pane)}. app said: {said or '(nothing)'}"
        )
        names = {m["component"] for m in got}
        assert component in names, f"{pane} mounted {sorted(names)}, expected `{component}`"
        live = [m for m in got if _payload_is_nonempty(m["data"])]
        assert live, (
            f"{pane} mounted {sorted(names)} but EVERY payload was empty — "
            f"a blank false-green. payloads={[m['data'] for m in got][:2]}"
        )

    # NO error panel when the server is up
    errors = " ".join(e.value for e in at.error)
    assert "unreachable" not in errors.lower(), errors


@pytest.mark.skipif(not SERVER_UP, reason="korp-server not reachable")
def test_no_pane_falls_back_to_a_streamlit_data_widget(mounts):
    """With the components spied out, nothing may be left drawing domain data.

    `st.dataframe` / `st.table` / charts in the rendered app would mean a pane still
    has a Python renderer behind the component — the lookalike this demo exists to
    not be.
    """
    at = _run()
    assert not at.dataframe, f"{len(at.dataframe)} st.dataframe(s) rendered — mount a facett table"
    assert not at.table, f"{len(at.table)} st.table(s) rendered — mount a facett table"


def test_bad_target_shows_honest_error_not_blank():
    """Point the sidebar at an unreachable target and rerun — every surface must
    show an HONEST error, never a crash and never a silent blank. Runs regardless
    of whether the real server is up (it drives the app to a KNOWN-down target)."""
    at = AppTest.from_file(APP, default_timeout=120)
    at.run()
    at.sidebar.text_input[0].set_value("localhost:59999").run()
    assert not at.exception, f"app crashed instead of honest error: {at.exception}"
    blob = " ".join(e.value for e in at.error)
    blob += " " + " ".join(e.value for e in at.sidebar.error)
    assert blob.strip(), "expected an honest error panel, got none (blank false-green)"
    assert "unreachable" in blob.lower() or "59999" in blob, blob


def test_the_app_runs_without_the_korp_extra(monkeypatch):
    """`pip install facett-demo` WITHOUT `[korp]` must run: no grpc, no korp-server.

    Simulated here by marking the korp wire as not importable (the client reads the
    flag at call time); the clean-install check (`cargo ops verify-demo-install`) proves
    the same thing for real, in a container where grpcio was never installed.
    """
    import facett_demo.client as client

    monkeypatch.setattr(client, "KORP_IMPORT_ERROR", "ImportError: No module named 'grpc' (simulated)")
    at = _run()
    assert not at.exception, f"app raised without the korp extra: {at.exception}"
    sidebar = " ".join(i.value for i in at.sidebar.info)
    assert "facett-demo[korp]" in sidebar, sidebar
    banners = " ".join(i.value for i in at.info)
    assert "NOT live data" in banners and "facett-demo[korp]" in banners, banners
    assert not any("server unreachable" in e.value for e in at.sidebar.error), "probed a wire that is not installed"