Skip to content

Commit e72b2b3

Browse files
committed
test(python): cover macro proxy, plugin loader, interactive fit, h5 browser
Adds 40 headless tests for the four src/runtime modules that had none, exercising the JS-bridge proxy, plugin load/unload/discovery, the interactive-fit lifecycle and the HDF5 browser (open/preview/import). Assisted-by: Claude Opus 4.8
1 parent 5323b72 commit e72b2b3

4 files changed

Lines changed: 565 additions & 0 deletions

File tree

tests/python/test_h5browser.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause License
2+
# See LICENSE file for details
3+
"""Tests for the HDF5 browser backend (``dlw_h5browser``).
4+
5+
Builds a small real HDF5 file with h5py (a 1-D signal, a 2-D image and a
6+
nested group), feeds its raw bytes through ``open_file`` and exercises
7+
the public surface (tree walk, attrs, preview, array data, import,
8+
close). Malformed input and the encoding helper are covered too.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import dlw_h5browser as h5b
14+
import h5py
15+
import numpy as np
16+
import pytest
17+
18+
19+
@pytest.fixture
20+
def h5_bytes(tmp_path):
21+
"""Return the raw bytes of a small multi-node HDF5 file."""
22+
path = tmp_path / "sample.h5"
23+
with h5py.File(path, "w") as f:
24+
sig = f.create_dataset("sig", data=np.linspace(0.0, 1.0, 32))
25+
sig.attrs["units"] = "V"
26+
f.create_dataset("img", data=np.arange(64, dtype=float).reshape(8, 8))
27+
grp = f.create_group("grp")
28+
grp.create_dataset("inner", data=np.ones(10))
29+
return path.read_bytes()
30+
31+
32+
@pytest.fixture
33+
def opened(h5_bytes):
34+
"""Open the sample file and guarantee cleanup of all browser state."""
35+
result = h5b.open_file("sample.h5", h5_bytes)
36+
try:
37+
yield result
38+
finally:
39+
h5b.close_all()
40+
41+
42+
# ---------------------------------------------------------------------------
43+
# Encoding helper
44+
# ---------------------------------------------------------------------------
45+
46+
47+
def test_safe_decode_bytes():
48+
assert h5b._safe_decode_bytes(b"hello") == "hello"
49+
assert h5b._safe_decode_bytes("already str") == "already str"
50+
assert h5b._safe_decode_bytes(123) == "123"
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# open / close
55+
# ---------------------------------------------------------------------------
56+
57+
58+
def test_open_file_returns_tree(opened):
59+
assert opened["filename"] == "sample.h5"
60+
root = opened["root"]
61+
child_ids = {c["id"] for c in root["children"]}
62+
assert {"/sig", "/img", "/grp"} <= child_ids
63+
64+
65+
def test_open_invalid_file_raises():
66+
with pytest.raises(ValueError):
67+
h5b.open_file("bogus.h5", b"definitely not an HDF5 file")
68+
69+
70+
def test_close_file_clears_state(opened):
71+
file_id = opened["file_id"]
72+
assert file_id in h5b.STATE
73+
h5b.close_file(file_id)
74+
assert file_id not in h5b.STATE
75+
76+
77+
def test_close_file_unknown_id_is_noop():
78+
# Must not raise even for an id that was never opened.
79+
h5b.close_file("never-opened")
80+
81+
82+
# ---------------------------------------------------------------------------
83+
# Node inspection
84+
# ---------------------------------------------------------------------------
85+
86+
87+
def test_node_attrs_exposes_attributes(opened):
88+
file_id = opened["file_id"]
89+
attrs = h5b.node_attrs(file_id, "/sig")
90+
assert attrs["path"] == "/sig"
91+
assert attrs["attributes"].get("units") == "V"
92+
93+
94+
def test_preview_signal(opened):
95+
file_id = opened["file_id"]
96+
prev = h5b.preview(file_id, "/sig")
97+
assert prev["kind"] == "signal"
98+
assert len(prev["x"]) == len(prev["y"]) == 32
99+
100+
101+
def test_preview_image(opened):
102+
file_id = opened["file_id"]
103+
prev = h5b.preview(file_id, "/img")
104+
assert prev["kind"] == "image"
105+
assert prev["width"] == 8
106+
assert prev["height"] == 8
107+
108+
109+
def test_array_data_returns_shape_and_values(opened):
110+
file_id = opened["file_id"]
111+
data = h5b.array_data(file_id, "/img")
112+
assert data["shape"] == [8, 8]
113+
assert "float" in data["dtype"]
114+
assert len(data["data"]) == 8
115+
116+
117+
def test_require_node_rejects_unknown(opened):
118+
file_id = opened["file_id"]
119+
with pytest.raises(KeyError):
120+
h5b.node_attrs(file_id, "/does-not-exist")
121+
122+
123+
# ---------------------------------------------------------------------------
124+
# Import
125+
# ---------------------------------------------------------------------------
126+
127+
128+
def test_import_nodes_builds_objects(opened):
129+
file_id = opened["file_id"]
130+
result = h5b.import_nodes(file_id, ["/sig", "/img"])
131+
kinds = sorted(kind for kind, _obj in result["objects"])
132+
assert kinds == ["image", "signal"]
133+
assert result["uint32_clipped"] is False
134+
135+
136+
def test_import_nodes_unknown_id_raises(opened):
137+
file_id = opened["file_id"]
138+
with pytest.raises(KeyError):
139+
h5b.import_nodes(file_id, ["/nope"])
140+
141+
142+
def test_import_nodes_unknown_file_raises():
143+
with pytest.raises(KeyError):
144+
h5b.import_nodes("never-opened", ["/sig"])
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause License
2+
# See LICENSE file for details
3+
"""Tests for the interactive-fit helpers (``dlw_interactive_fit``).
4+
5+
The module exposes a JSON-friendly fitting API consumed by the React
6+
``InteractiveFitDialog``. These tests drive a Gaussian signal through
7+
the full session lifecycle (catalogue → init → evaluate → auto-fit →
8+
commit) under CPython, with the live ``_MODEL`` borrowed from a fresh
9+
bootstrap and published on ``__main__`` (where ``_get_model`` reads it).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import sys
15+
16+
import dlw_interactive_fit as ifit
17+
import numpy as np
18+
import pytest
19+
20+
21+
def _gaussian_xy():
22+
"""Return a clean Gaussian (amp=3, mu=0.5, sigma=0.8, y0=0.1)."""
23+
x = np.linspace(-5.0, 5.0, 200)
24+
y = 3.0 * np.exp(-((x - 0.5) ** 2) / (2.0 * 0.8**2)) + 0.1
25+
return x, y
26+
27+
28+
@pytest.fixture
29+
def fit_session(fresh_bootstrap, monkeypatch):
30+
"""Create a Gaussian signal and expose ``_MODEL`` on ``__main__``."""
31+
bs = fresh_bootstrap
32+
main_mod = sys.modules["__main__"]
33+
monkeypatch.setattr(main_mod, "_MODEL", bs._MODEL, raising=False)
34+
x, y = _gaussian_xy()
35+
oid = bs.add_signal_from_arrays("Gaussian", x, y)
36+
return bs, oid
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# Catalogue
41+
# ---------------------------------------------------------------------------
42+
43+
44+
def test_list_interactive_fits_catalogue():
45+
fits = ifit.list_interactive_fits()
46+
ids = {f["id"] for f in fits}
47+
assert {"linear_fit", "gaussian_fit", "polynomial_fit"} <= ids
48+
# Each entry exposes the menu-rendering contract.
49+
for entry in fits:
50+
assert set(entry) == {"id", "label", "needs_degree"}
51+
poly = next(f for f in fits if f["id"] == "polynomial_fit")
52+
assert poly["needs_degree"] is True
53+
54+
55+
# ---------------------------------------------------------------------------
56+
# Session lifecycle
57+
# ---------------------------------------------------------------------------
58+
59+
60+
def test_init_interactive_fit_returns_session(fit_session):
61+
_bs, oid = fit_session
62+
session = ifit.init_interactive_fit(oid, "gaussian_fit")
63+
assert session["fit_id"] == "gaussian_fit"
64+
assert len(session["x"]) == len(session["y"]) == 200
65+
assert len(session["y_fit"]) == 200
66+
assert session["params"] # at least one slider row
67+
for row in session["params"]:
68+
assert set(row) >= {"name", "label", "value", "min", "max"}
69+
# The initial value always lies within the slider bounds.
70+
assert row["min"] <= row["value"] <= row["max"]
71+
72+
73+
def test_evaluate_interactive_fit_matches_length(fit_session):
74+
_bs, oid = fit_session
75+
session = ifit.init_interactive_fit(oid, "gaussian_fit")
76+
values = {p["name"]: p["value"] for p in session["params"]}
77+
x = session["x"]
78+
y_eval = ifit.evaluate_interactive_fit("gaussian_fit", x, values)
79+
assert len(y_eval) == len(x)
80+
assert all(np.isfinite(y_eval))
81+
82+
83+
def test_auto_fit_recovers_gaussian_params(fit_session):
84+
_bs, oid = fit_session
85+
result = ifit.auto_fit_interactive(oid, "gaussian_fit")
86+
assert len(result["y_fit"]) == 200
87+
# The optimiser should fit clean synthetic data almost exactly.
88+
assert result["residual_rms"] < 1e-3
89+
# The fitted curve reproduces the source Gaussian (peak ≈ 3.1, the
90+
# 3.0 amplitude plus the 0.1 offset) — independent of how Sigima
91+
# parametrises the model internally.
92+
_x, y = _gaussian_xy()
93+
assert abs(max(result["y_fit"]) - float(y.max())) < 0.05
94+
95+
96+
def test_commit_interactive_fit_adds_signal(fit_session):
97+
bs, oid = fit_session
98+
before = len(bs.get_object_uuids("signal"))
99+
session = ifit.init_interactive_fit(oid, "gaussian_fit")
100+
values = {p["name"]: p["value"] for p in session["params"]}
101+
new_oid = ifit.commit_interactive_fit(oid, "gaussian_fit", values)
102+
assert new_oid != oid
103+
after = bs.get_object_uuids("signal")
104+
assert len(after) == before + 1
105+
assert new_oid in after
106+
# The committed signal carries interactive-fit metadata.
107+
obj = bs._MODEL.get(new_oid)
108+
assert obj.metadata["fit_params"]["interactive"] is True
109+
110+
111+
def test_evaluate_polynomial_uses_degree(fit_session):
112+
_bs, oid = fit_session
113+
session = ifit.init_interactive_fit(oid, "polynomial_fit", {"degree": 2})
114+
values = {p["name"]: p["value"] for p in session["params"]}
115+
y_eval = ifit.evaluate_interactive_fit(
116+
"polynomial_fit", session["x"], values, {"degree": 2}
117+
)
118+
assert len(y_eval) == len(session["x"])
119+
assert all(np.isfinite(y_eval))

0 commit comments

Comments
 (0)