"""API integration tests (skipped if the `api` extra is not installed)."""
from __future__ import annotations
import numpy as np
import pytest
from splasher.demo import make_demo_source
from splasher.engine import Session
from splasher.server.protocol import decode_array
pytest.importorskip("fastapi", reason="`api` extra not installed")
pytest.importorskip("httpx", reason="httpx required by fastapi.testclient")
from fastapi.testclient import TestClient # noqa: E402
from splasher.server import create_app # noqa: E402
@pytest.fixture
def client() -> TestClient:
session = Session(make_demo_source(n_frames=4, seed=2))
return TestClient(create_app(session))
def test_get_session(client: TestClient) -> None:
r = client.get("/api/session")
assert r.status_code == 200
assert r.json()["n_frames"] == 4
def test_web_front_served_without_shadowing_api(client: TestClient) -> None:
# The front is mounted on "/", but /api/* (and /docs) keep priority.
root = client.get("/")
assert root.status_code == 200 and "Splasher" in root.text
assert client.get("/api/view").status_code == 200
assert client.get("/docs").status_code == 200
def test_get_view_has_points(client: TestClient) -> None:
d = client.get("/api/view").json()
points = decode_array(d["points"])
assert points.shape[1] >= 3
def test_paint_via_api_sets_grid_labels(client: TestClient) -> None:
d = client.post("/api/paint", json={"rect": [-5, -5, 5, 5]}).json()
grid_labels = decode_array(d["grid_labels"])
assert grid_labels is not None and (grid_labels != 0).any()
def test_frame_and_accum_roundtrip(client: TestClient) -> None:
client.post("/api/frame", json={"index": 2})
n0 = len(decode_array(client.get("/api/view").json()["points"]))
d = client.post("/api/accum", json={"radius": 2}).json()
assert d["index"] == 2 and d["accum_radius"] == 2
assert len(decode_array(d["points"])) > n0
def test_select_apply_clears_selection(client: TestClient) -> None:
client.post("/api/tool", json={"tool": "select"})
d = client.post("/api/select", json={"rect": [-3, -3, 3, 3]}).json()
assert decode_array(d["selection"]) is not None
d = client.post("/api/selection/apply").json()
assert d["selection"] is None
def test_file_viewer_empty_session_and_fs(tmp_path) -> None:
from splasher import ArraySource
c = TestClient(create_app(ArraySource([], []))) # empty = file-viewer mode
assert c.get("/api/session").json()["n_frames"] == 0
assert decode_array(c.get("/api/view").json()["points"]).shape == (0, 3) # no clouds, no features
np.save(tmp_path / "scan.npy", np.random.rand(20, 4).astype(np.float32))
(tmp_path / "note.txt").write_text("nope")
listing = c.get("/api/fs", params={"path": str(tmp_path)}).json()
names = {e["name"]: e["openable"] for e in listing["entries"]}
assert names["scan.npy"] is True and names["note.txt"] is False
opened = c.post("/api/fs/open", json={"path": str(tmp_path / "scan.npy")}).json()
assert opened["kind"] == "cloud" and decode_array(opened["points"]).shape == (20, 4)
# a .npy of shape (H, W, 4) is an image, not a cloud
np.save(tmp_path / "pic.npy", (np.random.rand(8, 10, 4) * 255).astype(np.uint8))
img = c.post("/api/fs/open", json={"path": str(tmp_path / "pic.npy")}).json()
assert img["kind"] == "image" and decode_array(img["image"]).shape == (8, 10, 4)
bad = c.post("/api/fs/open", json={"path": str(tmp_path / "note.txt")})
assert bad.status_code == 422 and "unsupported" in bad.json()["detail"]
# loaded cloud becomes the labelable session source; grid labels then export to a file
src = c.post("/api/source/files", json={"paths": [str(tmp_path / "scan.npy")]}).json()
assert decode_array(src["points"]).shape[0] == 20
c.post("/api/paint", json={"rect": [-100, -100, 100, 100]})
r = c.post("/api/export", json={"dir": str(tmp_path), "name": "scan_bev.npy"})
assert r.status_code == 200 and r.json()["path"].endswith("scan_bev.npy")
assert (tmp_path / "scan_bev.npy").exists()
def test_save_load_via_api(client: TestClient, tmp_path) -> None:
client.post("/api/paint", json={"rect": [-5, -5, 5, 5]})
r = client.post("/api/save", json={"dir": str(tmp_path)})
assert r.status_code == 200 and r.json()["ok"]
d = client.post("/api/load", json={"dir": str(tmp_path)}).json()
assert decode_array(d["grid_labels"]) is not None
def test_open_file_gathers_sibling_features(tmp_path) -> None:
"""File viewer (no apairo): a cloud + its `_.npy` siblings load together as
`[x, y, z, *feature_names]`, opening either the coordinate file or a feature file."""
from splasher.server.files import open_file
xyz = np.random.rand(6, 3).astype(np.float32)
inten = np.arange(6, dtype=np.uint8)
rng = np.linspace(0.0, 1.0, 6, dtype=np.float32)
np.save(tmp_path / "000000.npy", xyz)
np.save(tmp_path / "000000_intensity.npy", inten)
np.save(tmp_path / "000000_range.npy", rng)
res = open_file(str(tmp_path / "000000.npy")) # open the coordinate cloud
assert res["kind"] == "cloud" and res["feature_names"] == ["intensity", "range"]
assert res["points"].shape == (6, 5)
np.testing.assert_allclose(res["points"][:, 3], inten)
np.testing.assert_allclose(res["points"][:, 4], rng)
res2 = open_file(str(tmp_path / "000000_intensity.npy")) # open a lone (N,) feature file
assert res2["feature_names"] == ["intensity", "range"] and res2["points"].shape == (6, 5)
assert res2["name"] == "000000_intensity.npy" # the panel keeps the opened file's name
def test_open_file_lone_scalar_without_coords_is_a_feature(tmp_path) -> None:
"""A per-point file with no coordinate sibling is returned as an attachable measure."""
from splasher.server.files import open_file
np.save(tmp_path / "lonely_intensity.npy", np.arange(4, dtype=np.uint8))
res = open_file(str(tmp_path / "lonely_intensity.npy"))
assert res["kind"] == "feature" and res["length"] == 4
def test_open_file_attaches_explicit_measures(tmp_path) -> None:
"""`features=[...]` merges per-point measure files living anywhere into the cloud.
Naming: `_` → suffix; same stem in another dir → that dir's name."""
from splasher.server.files import open_file
xyz = np.random.rand(6, 3).astype(np.float32)
labels = np.array([0, 1, 0, 1, 1, 0], dtype=np.uint8)
np.save(tmp_path / "00123.npy", xyz)
(tmp_path / "ground_truth").mkdir()
np.save(tmp_path / "ground_truth" / "00123.npy", labels) # same stem, other dir
np.save(tmp_path / "elsewhere_intensity.npy", np.arange(6, dtype=np.float32))
res = open_file(str(tmp_path / "00123.npy"),
features=[str(tmp_path / "ground_truth" / "00123.npy")])
assert res["kind"] == "cloud" and res["feature_names"] == ["ground_truth"]
assert res["points"].shape == (6, 4)
np.testing.assert_allclose(res["points"][:, 3], labels)
res2 = open_file(str(tmp_path / "00123.npy"),
features=[str(tmp_path / "ground_truth" / "00123.npy"),
str(tmp_path / "elsewhere_intensity.npy")])
assert res2["feature_names"] == ["elsewhere_intensity", "ground_truth"]
# length mismatch fails loudly (an explicit attach is a user action)
np.save(tmp_path / "short.npy", np.arange(3, dtype=np.float32))
with pytest.raises(ValueError, match="3 values for a 6-point cloud"):
open_file(str(tmp_path / "00123.npy"), features=[str(tmp_path / "short.npy")])
def test_extra_feature_name_uses_suffix_when_stem_matches(tmp_path) -> None:
from splasher.server.files import _extra_feature_name
cloud = tmp_path / "00123.npy"
assert _extra_feature_name(cloud, tmp_path / "00123_labels.npy") == "labels"
assert _extra_feature_name(cloud, tmp_path / "ground_truth" / "00123.npy") == "ground_truth"
assert _extra_feature_name(cloud, tmp_path / "semantics.npy") == "semantics"
def test_combine_clouds_unions_named_features() -> None:
from splasher.server.files import combine_clouds
a = np.hstack([np.random.rand(4, 3), np.arange(4).reshape(-1, 1)]).astype(np.float32)
b = np.hstack([np.random.rand(2, 3), np.ones((2, 1))]).astype(np.float32)
pts, names = combine_clouds([(a, ["labels"]), (b, ["intensity"])])
assert names == ["intensity", "labels"] and pts.shape == (6, 5)
np.testing.assert_allclose(pts[:4, 4], np.arange(4)) # a's labels
assert np.isnan(pts[:4, 3]).all() # a has no intensity
np.testing.assert_allclose(pts[4:, 3], 1.0) # b's intensity
assert np.isnan(pts[4:, 4]).all() # b has no labels
def test_api_attach_measure_and_bev_feature(tmp_path) -> None:
"""End to end: open a cloud, open a lone measure (kind=feature), re-open the cloud with
it attached, and use it as the BEV underlay of the combined source."""
from splasher import ArraySource
c = TestClient(create_app(ArraySource([], [])))
xyz = np.random.rand(10, 3).astype(np.float32)
labels = (np.arange(10) % 2).astype(np.uint8)
np.save(tmp_path / "00123.npy", xyz)
(tmp_path / "ground_truth").mkdir()
np.save(tmp_path / "ground_truth" / "00123.npy", labels)
lone = c.post("/api/fs/open", json={"path": str(tmp_path / "ground_truth" / "00123.npy")}).json()
assert lone["kind"] == "feature" and lone["length"] == 10
merged = c.post("/api/fs/open", json={
"path": str(tmp_path / "00123.npy"),
"features": [str(tmp_path / "ground_truth" / "00123.npy")]}).json()
assert merged["kind"] == "cloud" and merged["feature_names"] == ["ground_truth"]
assert decode_array(merged["points"]).shape == (10, 4)
src = c.post("/api/source/files", json={"paths": [
{"path": str(tmp_path / "00123.npy"),
"features": [str(tmp_path / "ground_truth" / "00123.npy")]}]}).json()
assert decode_array(src["points"]).shape == (10, 4)
assert c.get("/api/session").json()["feature_names"] == ["ground_truth"]
d = c.post("/api/bev_mode", json={"mode": "ground_truth"}).json()
assert d["bev_mode"] == "ground_truth"
assert decode_array(d["bev_field"]) is not None