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_gallery_pane.py

"""The Gallery pane must MOUNT every shipped component — not merely not crash.

`AppTest` runs the app server-side, so nothing here proves a pixel. What it does
prove is the half that a browser test cannot isolate: that the pane reaches every
component the installed wheel declares, hands each data-driven one its payload, and
hands the fixture-only ones NOTHING.

That last clause is the one worth a test. A component with no data seam that is given
a payload renders its own fixture and looks exactly like success — the same failure
the loader's refusal path exists for, and the same one that once put a knut/korp
table where the Sweden map belonged. So the pane's silence about those components is
asserted, not assumed.

The Rust side (`xtask/tests/gallery_covers_the_manifest.rs`) proves the payload TABLE
covers the registry and that every payload builds through the real adapter. This
proves the PANE actually uses it. Neither implies the other.
"""

from __future__ import annotations

import pathlib
from typing import Any

import pytest
from streamlit.testing.v1 import AppTest

import facett
import facett_demo
from facett_demo.demo_payloads import DEMO_PAYLOADS, FEATURE_PAYLOADS, FIXTURE_ONLY

#: The app UNDER TEST — inside the installed package (`cargo ops test-demo` installs the
#: wheel), so the suite drives what `pip install facett-demo` delivers.
APP = str(pathlib.Path(facett_demo.__file__).resolve().parent / "app.py")


@pytest.fixture
def mounts(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
    """Spy on every `facett_component` call the app makes.

    Patched on the PANE's own reference, because that is the name the pane resolves
    at call time — patching the host module only would leave the already-imported
    binding untouched and this fixture would record nothing while passing.
    """
    seen: list[dict[str, Any]] = []

    def _spy(name: str, *, data: Any | None = None, height: int = 520, key: str | None = None):
        seen.append({"name": name, "data": data, "height": height, "key": key})
        return None

    from facett_demo.panes import gallery_pane

    monkeypatch.setattr(gallery_pane, "facett_component", _spy)
    return seen


def _run_gallery_showing_everything(at: AppTest) -> None:
    """Put every group tab into 'the whole group' mode and re-run."""
    at.run()
    # The radio is the pane's only mode switch; selecting the all-at-once value makes
    # one pass mount every member of every group.
    for radio in at.radio:
        if radio.key == "gallery-mode":
            radio.set_value("the whole group")
    at.run()


def test_the_gallery_mounts_every_component_the_wheel_ships(mounts):
    at = AppTest.from_file(APP, default_timeout=180)
    _run_gallery_showing_everything(at)
    assert not at.exception, [str(e.value) for e in at.exception]

    shipped = {c["name"] for c in facett.manifest()}
    mounted = {m["name"] for m in mounts}
    missing = shipped - mounted
    assert not missing, (
        f"the installed wheel ships these components and the gallery never mounted "
        f"them: {sorted(missing)}"
    )


def test_every_data_driven_component_got_its_payload(mounts):
    at = AppTest.from_file(APP, default_timeout=180)
    _run_gallery_showing_everything(at)

    by_name = {m["name"]: m for m in mounts}
    for name, payload in DEMO_PAYLOADS.items():
        assert name in by_name, f"`{name}` was never mounted"
        got = by_name[name]["data"]
        assert got is not None, f"`{name}` was mounted with NO data — it would show its fixture"
        # The payload the component received must BE the table's, not a reshaped copy.
        assert got == payload, f"`{name}` was mounted with something other than its payload"


def test_every_non_core_bundle_is_mounted_with_its_payload(mounts):
    """docs (docview, manual) and model3d ship in the wheel under `assets/<group>/`.

    The gallery once listed the CORE manifest alone, so these three were in every
    wheel and never on the page. Held against the INSTALLED wheel's routing table:
    every non-core bundle contributes at least one mount, each with its payload.
    """
    at = AppTest.from_file(APP, default_timeout=180)
    _run_gallery_showing_everything(at)
    assert not at.exception, [str(e.value) for e in at.exception]

    by_name = {m["name"]: m for m in mounts}
    shipped = facett.manifest()
    groups = {g["name"] for g in facett.bundles().get("groups", [])}
    assert {"core", "docs", "model3d"} <= groups, f"the wheel routes {sorted(groups)}"
    for group in sorted(groups - {"core"}):
        members = [c["name"] for c in shipped if c["bundle"] == group]
        assert members, f"the `{group}` bundle ships no manifest entries"
        for name in members:
            assert name in by_name, f"`{name}` (bundle `{group}`) was never mounted"
    for name, payload in FEATURE_PAYLOADS.items():
        assert by_name.get(name, {}).get("data") == payload, f"`{name}` was not mounted with its payload"


def test_pick_opens_the_gallery_on_a_named_component_of_each_bundle(mounts):
    """`?pick=` preselects a component in its tab (one-at-a-time mode), so a headless
    browser that cannot click can open the gallery on a docs or model3d component."""
    at = AppTest.from_file(APP, default_timeout=180)
    at.query_params["pick"] = "manual,model3d"
    at.run()
    assert not at.exception, [str(e.value) for e in at.exception]
    names = [m["name"] for m in mounts]
    # manual comes first in `pick`, so the ui tab (which holds both) shows manual.
    assert "manual" in names, names
    assert "model3d" not in names, names
    at2 = AppTest.from_file(APP, default_timeout=180)
    at2.query_params["pick"] = "model3d"
    mounts.clear()
    at2.run()
    assert "model3d" in [m["name"] for m in mounts]


def test_fixture_only_components_are_given_nothing(mounts):
    # The dangerous direction. Handing data to a component with no data seam makes it
    # render its own fixture while the page implies it is showing ours.
    at = AppTest.from_file(APP, default_timeout=180)
    _run_gallery_showing_everything(at)

    by_name = {m["name"]: m for m in mounts}
    for name in FIXTURE_ONLY:
        assert name in by_name, f"fixture-only `{name}` was never mounted at all"
        assert by_name[name]["data"] is None, (
            f"`{name}` has no data seam but the gallery handed it a payload — it will "
            f"render its OWN fixture and the page will imply otherwise"
        )


def _ast_of(module) -> "ast.Module":
    import ast
    import inspect

    return ast.parse(inspect.getsource(module))


def test_the_gallery_needs_no_server():
    # The whole reason the Gallery is the first tab. If this ever starts depending on
    # korp, the demo stops being showable on a box with nothing else running.
    import ast

    import facett_demo.panes.gallery_pane as g

    imported: set[str] = set()
    for node in ast.walk(_ast_of(g)):
        if isinstance(node, ast.Import):
            imported.update(a.name.split(".")[0] for a in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module:
            imported.add(node.module.split(".")[0])
    assert "grpc" not in imported, "the gallery pane imports grpc"
    assert not any(
        m.startswith("facett_demo.client") for m in imported
    ), "the gallery pane reaches for the korp client"


def test_the_gallery_draws_nothing_itself():
    """The owner's rule, asserted on the pane most tempted to break it.

    A showcase is exactly where someone reaches for a quick `st.line_chart`.

    This walks the AST rather than grepping the source, and the difference is not
    pedantry: the first version of this test grepped for the forbidden names and
    FAILED on this module's own docstring, which lists them in order to forbid them.
    A guard that cannot tell a call from a sentence about a call will be silenced by
    whoever hits it next, and then it guards nothing.
    """
    import ast

    import facett_demo.panes.gallery_pane as g

    # Renderer libraries: an import is enough. There is no innocent reason to have
    # one in a module whose pixels come from wasm.
    banned_modules = {"pandas", "plotly", "altair", "numpy", "matplotlib", "pydeck", "networkx"}
    # Streamlit's own chart/table primitives: each one is a facett component
    # reimplemented in the host.
    banned_calls = {
        "line_chart", "bar_chart", "area_chart", "scatter_chart", "map",
        "pyplot", "altair_chart", "plotly_chart", "graphviz_chart", "vega_lite_chart",
        "dataframe", "table", "metric", "image",
    }

    tree = _ast_of(g)
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for a in node.names:
                assert a.name.split(".")[0] not in banned_modules, (
                    f"the gallery pane imports `{a.name}` — that is a renderer, and every "
                    f"pixel on this page must come from the wasm"
                )
        elif isinstance(node, ast.ImportFrom) and node.module:
            assert node.module.split(".")[0] not in banned_modules, (
                f"the gallery pane imports from `{node.module}`"
            )
        elif isinstance(node, ast.Call):
            f = node.func
            if (
                isinstance(f, ast.Attribute)
                and isinstance(f.value, ast.Name)
                and f.value.id == "st"
                and f.attr in banned_calls
            ):
                assert False, (
                    f"the gallery pane calls `st.{f.attr}()` — facett already ships that "
                    f"surface as a wasm component; using streamlit's is reimplementing it "
                    f"in the host, which is the one thing this package exists to not do"
                )


def test_that_guard_would_actually_fire():
    """The guard above, driven RED — on a module that really does draw.

    Without this, `test_the_gallery_draws_nothing_itself` is a test that has only ever
    been run on a file that passes. It already gave a wrong answer once (it failed on
    a docstring), so its second version does not get to be trusted on reputation.
    """
    import ast

    drawing = ast.parse(
        "import streamlit as st\n"
        "import pandas as pd\n"
        "def render():\n"
        "    st.line_chart({'a': [1, 2, 3]})\n"
    )
    banned_modules = {"pandas", "plotly", "altair", "numpy"}
    banned_calls = {"line_chart", "bar_chart", "dataframe"}

    hits = []
    for node in ast.walk(drawing):
        if isinstance(node, ast.Import):
            hits += [a.name for a in node.names if a.name.split(".")[0] in banned_modules]
        elif isinstance(node, ast.Call):
            f = node.func
            if (
                isinstance(f, ast.Attribute)
                and isinstance(f.value, ast.Name)
                and f.value.id == "st"
                and f.attr in banned_calls
            ):
                hits.append(f"st.{f.attr}")
    assert "pandas" in hits, "the import scan is blind"
    assert "st.line_chart" in hits, "the call scan is blind"


def test_no_pane_imports_a_renderer():
    """NO pane may import pandas / plotly / altair / numpy / matplotlib / pydeck.

    ## A correction to the audit this lane was briefed from

    The 2026-09-05 t14s audit listed `panes/population_pane.py` as *"165 lines
    pandas+plotly … draws charts in JS via Plotly = same banned class"*, and row 8 was
    scheduled to replace it. **That is stale.** Measured today, on this tree:

      * `population_pane.py` imports exactly `streamlit` and `..facet_host` — no
        pandas, no plotly, nothing else;
      * its own module docstring says so: *"It previously used pandas + plotly +
        st.dataframe"* — past tense, and the conversion is done;
      * it mounts `table` and `stat-grid` through `facett_component`, like every
        other pane;
      * across all of `py/`, there is not one call to `st.line_chart`,
        `st.bar_chart`, `st.plotly_chart`, `st.dataframe`, `st.table` or `st.metric`.

    Somebody did that work before this lane started, and the audit was written
    against an older tree. Reporting it the other way round — "found and fixed a
    Plotly renderer" — would have been claiming credit for a fix that already
    existed.

    So this test expects the empty set, not one known offender. An earlier draft of
    it expected `["population_pane.py"]` and FAILED, which is how the staleness was
    found: the guard disagreed with the briefing and the guard was right.
    """
    import ast
    import pathlib

    panes_dir = pathlib.Path(facett_demo.__file__).resolve().parent
    banned_modules = {"pandas", "plotly", "altair", "numpy", "matplotlib", "pydeck", "networkx"}

    offenders: dict[str, list[str]] = {}
    for p in sorted(panes_dir.rglob("*.py")):
        tree = ast.parse(p.read_text(encoding="utf-8"))
        for node in ast.walk(tree):
            names: list[str] = []
            if isinstance(node, ast.Import):
                names = [a.name.split(".")[0] for a in node.names]
            elif isinstance(node, ast.ImportFrom) and node.module:
                names = [node.module.split(".")[0]]
            bad = [n for n in names if n in banned_modules]
            if bad:
                offenders.setdefault(str(p.relative_to(panes_dir)), []).extend(bad)

    assert offenders == {}, (
        f"these modules import a renderer: {offenders}. facett already ships every one "
        f"of those surfaces as a wasm component — mount it instead."
    )


def test_no_module_in_the_app_calls_a_streamlit_chart_or_table():
    """The other half: streamlit's own primitives are facett components by proxy.

    `st.dataframe` is `facett-table` (1 483 LOC) reimplemented in the host;
    `st.line_chart` is `facett-plot`; `st.map` is `facett-geomap`. Importing nothing
    and still calling these would satisfy the import scan above.
    """
    import ast
    import pathlib

    app_dir = pathlib.Path(facett_demo.__file__).resolve().parent
    banned = {
        "line_chart", "bar_chart", "area_chart", "scatter_chart", "map",
        "pyplot", "altair_chart", "plotly_chart", "graphviz_chart", "vega_lite_chart",
        "dataframe", "table", "metric",
    }

    hits: list[str] = []
    for p in sorted(app_dir.rglob("*.py")):
        tree = ast.parse(p.read_text(encoding="utf-8"))
        for node in ast.walk(tree):
            if isinstance(node, ast.Call):
                f = node.func
                if (
                    isinstance(f, ast.Attribute)
                    and isinstance(f.value, ast.Name)
                    and f.value.id == "st"
                    and f.attr in banned
                ):
                    hits.append(f"{p.relative_to(app_dir)}:{node.lineno} st.{f.attr}")
    assert hits == [], f"streamlit renderers in the app: {hits}"


# ── size + generator (lane F, 2026-10-02) ──────────────────────────────────────────


def _generated() -> list[dict[str, Any]]:
    return [c for c in facett.manifest() if c.get("generate")]


def test_the_wheel_ships_the_demos_generated_graph_components():
    """`universe`, `decograph` and `graphpan` (the owner's three) are in the shipped
    manifest WITH a generator seam, and the universe reaches 1M, the others 100k."""
    by = {c["name"]: c for c in _generated()}
    for name in ("universe", "decograph", "graphpan", "graphnav", "graph3d", "graphview", "lineage"):
        assert name in by, f"`{name}` ships no generator seam: {sorted(by)}"
        keys = [s["key"] for s in by[name]["generate"]["sources"]]
        assert "sbm" in keys and "demo" not in keys, (name, keys)
    assert by["universe"]["generate"]["max"] == 1_000_000
    assert by["graphpan"]["generate"]["max"] == 100_000
    assert 100_000 in by["decograph"]["generate"]["sizes"]


def test_gen_link_preselects_the_generator_request(mounts):
    """`?gen=sbm:100000:1&pick=graphpan` mounts graphpan with the generator REQUEST —
    three numbers, not 100 000 nodes — exactly the demo's deep link."""
    at = AppTest.from_file(APP, default_timeout=180)
    at.query_params["pick"] = "graphpan"
    at.query_params["gen"] = "sbm:100000:1"
    at.run()
    assert not at.exception, [str(e.value) for e in at.exception]
    got = [m for m in mounts if m["name"] == "graphpan"]
    assert got, [m["name"] for m in mounts]
    assert got[0]["data"] == {"generate": {"source": "sbm", "size": 100_000, "seed": 1}}, got[0]["data"]
    assert len(str(got[0]["data"])) < 200, "a request, never the graph itself"


def test_the_picker_offers_the_manifests_sizes_and_drives_the_request(mounts):
    at = AppTest.from_file(APP, default_timeout=180)
    at.query_params["pick"] = "graphpan"
    at.run()
    src = next(s for s in at.selectbox if s.key == "one-graph-graphpan-gen-source")
    assert src.value == "gallery payload", "nothing changes until a generator is picked"
    src.set_value("tree")
    at.run()
    size = next(s for s in at.select_slider if s.key == "one-graph-graphpan-gen-size")
    assert list(size.options)[-1] in ("100k", "100000"), size.options
    size.set_value(100_000)
    at.run()
    assert not at.exception, [str(e.value) for e in at.exception]
    last = [m for m in mounts if m["name"] == "graphpan"][-1]
    assert last["data"] == {"generate": {"source": "tree", "size": 100_000, "seed": 1}}, last["data"]


def test_the_app_is_grouped_like_the_demo():
    """The top tabs are facett-demo's groups (Grafer, Kartor, …), each opening on its
    gallery components; the live korp panes sit inside their group."""
    at = AppTest.from_file(APP, default_timeout=180)
    at.run()
    labels = [t.label for t in at.tabs]
    for want in ("🕸 Grafer", "🗺 Kartor", "📊 Tabeller & diagram", "⚙ Bygg & jobb", "🎛 App & UI"):
        assert want in labels, labels
    assert "🎨 Komponenter" in labels, labels
    assert labels.index("🕸 Grafer") < labels.index("🗺 Kartor")


def test_parse_gen_link_reads_the_demos_shape():
    from facett_demo.panes.gallery_pane import parse_gen_link

    assert parse_gen_link("sbm:100000:1") == {"source": "sbm", "size": 100_000, "seed": 1}
    assert parse_gen_link("crates") == {"source": "crates"}
    assert parse_gen_link("tree:big") is None
    assert parse_gen_link("") is None