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"