File size: 10,323 Bytes
1b63144 5c72cc9 1b63144 031f45c 1b63144 5c72cc9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | """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 `<base>_<suffix>.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: `<cloudstem>_<suffix>` → 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
|