From 7983736c2e6ddeb527ba94cdba1de1f9b3f0ef5c Mon Sep 17 00:00:00 2001 From: matei jordache Date: Sun, 29 Mar 2026 12:32:11 -0700 Subject: [PATCH] start coverage testing --- .coverage | Bin 0 -> 69632 bytes backend/server.py | 16 +++++- pyproject.toml | 9 ++++ tests/test_nodes.py | 123 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 .coverage diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..26c5629eb5ef7bc6a9686f83d8de3bab0942844a GIT binary patch literal 69632 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 DR|Ry% literal 0 HcmV?d00001 diff --git a/backend/server.py b/backend/server.py index 193f6a7..095194f 100644 --- a/backend/server.py +++ b/backend/server.py @@ -74,6 +74,20 @@ class _SafeEncoder(json.JSONEncoder): return super().default(obj) +def _sanitize_non_finite(obj): + """Recursively replace non-finite floats so they survive JSON serialization.""" + if isinstance(obj, float): + if math.isnan(obj): + return "NaN" + if math.isinf(obj): + return "∞" if obj > 0 else "-∞" + elif isinstance(obj, dict): + return {k: _sanitize_non_finite(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_sanitize_non_finite(v) for v in obj] + return obj + + def _dumps(obj) -> str: return json.dumps(obj, cls=_SafeEncoder) @@ -190,7 +204,7 @@ def create_app( broadcast(session_id, {"type": "preview", "data": {"node_id": node_id, "image": data_uri}}) def on_table(session_id: str, node_id: str, rows: list) -> None: - broadcast(session_id, {"type": "table", "data": {"node_id": node_id, "rows": rows}}) + broadcast(session_id, {"type": "table", "data": {"node_id": node_id, "rows": _sanitize_non_finite(rows)}}) def on_mesh(session_id: str, node_id: str, mesh_data: dict) -> None: broadcast(session_id, {"type": "mesh3d", "data": {"node_id": node_id, "mesh": mesh_data}}) diff --git a/pyproject.toml b/pyproject.toml index e83b80c..5e45366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8,<9", + "pytest-cov>=7,<8", ] desktop = [ "pyinstaller>=6,<7", @@ -31,3 +32,11 @@ desktop = [ [tool.setuptools.packages.find] include = ["backend*"] + +[tool.coverage.run] +source = ["backend"] +omit = ["backend/nodes/__init__.py"] + +[tool.coverage.report] +show_missing = true +skip_covered = false diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 45491f6..26731f7 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -704,7 +704,7 @@ def test_curvature(): recovered_radii = sorted([rows["Curvature radius 1"]["value"], rows["Curvature radius 2"]["value"]]) expected_radii = sorted([rx, ry]) assert len(previews) == 1 - assert previews[0].startswith("data:image/png;base64,") + assert isinstance(previews[0], dict) and previews[0].get("kind") == "panels" assert len(tables) == 1 assert abs(rows["Center x position"]["value"] - x0) < xreal * 0.02 assert abs(rows["Center y position"]["value"] - y0) < yreal * 0.02 @@ -743,6 +743,127 @@ def test_curvature(): print(" PASS\n") +def test_curvature_flat_surface(): + """A perfectly flat surface has zero curvature — both radii must be float('inf').""" + print("=== Test: Curvature (flat surface → inf radii) ===") + from backend.execution_context import active_node, execution_callbacks + from backend.nodes.curvature import Curvature + + node = Curvature() + data = np.zeros((64, 64), dtype=np.float64) + field = DataField(data=data, xreal=1e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m") + + warnings = [] + tables = [] + with execution_callbacks( + preview=lambda nid, v: None, + table=lambda nid, rows: tables.append(rows), + warning=lambda nid, msg: warnings.append(msg), + ), active_node("test"): + _, table, _, _ = node.process(field, masking="ignore") + + rows = {row["quantity"]: row for row in table} + assert rows["Curvature radius 1"]["value"] == float("inf") + assert rows["Curvature radius 2"]["value"] == float("inf") + # No warnings expected for a valid (flat) surface + assert len(warnings) == 0 + print(" PASS\n") + + +def test_curvature_cylindrical(): + """A cylindrical surface is curved in one direction only — one radius finite, one inf.""" + print("=== Test: Curvature (cylindrical → one inf radius) ===") + from backend.execution_context import active_node, execution_callbacks + from backend.nodes.curvature import Curvature + + node = Curvature() + N = 64 + xreal = yreal = 1e-6 + x = np.linspace(-xreal / 2, xreal / 2, N, dtype=np.float64) + xx = np.broadcast_to(x, (N, N)) + r_x = 0.8e-6 + # Curved parabolically in x, flat in y + data = xx**2 / (2.0 * r_x) + field = DataField(data=data, xreal=xreal, yreal=yreal, si_unit_xy="m", si_unit_z="m") + + tables = [] + with execution_callbacks( + preview=lambda nid, v: None, + table=lambda nid, rows: tables.append(rows), + ), active_node("test"): + _, table, _, _ = node.process(field, masking="ignore") + + rows = {row["quantity"]: row for row in table} + radii = sorted([rows["Curvature radius 1"]["value"], rows["Curvature radius 2"]["value"]]) + # One radius should be finite (≈ r_x), the other infinite + finite = [r for r in radii if np.isfinite(r)] + infinite = [r for r in radii if not np.isfinite(r)] + assert len(finite) == 1, f"Expected 1 finite radius, got {radii}" + assert len(infinite) == 1, f"Expected 1 inf radius, got {radii}" + assert abs(finite[0] - r_x) < r_x * 0.1, f"Finite radius {finite[0]} far from expected {r_x}" + print(" PASS\n") + + +def test_curvature_too_few_pixels(): + """Curvature with fewer than 6 valid pixels emits a warning and returns an empty table.""" + print("=== Test: Curvature (too few valid pixels) ===") + from backend.execution_context import active_node, execution_callbacks + from backend.nodes.curvature import Curvature + + node = Curvature() + N = 16 + data = np.random.default_rng(0).standard_normal((N, N)) + field = DataField(data=data, xreal=1e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m") + + # Mask with only 4 'include' pixels — below the 6-pixel minimum + mask = np.zeros((N, N), dtype=np.uint8) + mask[N // 2, N // 2:N // 2 + 4] = 255 + + warnings = [] + tables = [] + with execution_callbacks( + preview=lambda nid, v: None, + table=lambda nid, rows: tables.append(rows), + warning=lambda nid, msg: warnings.append(msg), + ), active_node("test"): + _, table, profile1, profile2 = node.process(field, masking="include", mask=mask) + + assert len(warnings) == 1 + assert "six" in warnings[0].lower() or "6" in warnings[0] + assert len(list(table)) == 0 + # Empty profiles are returned + assert len(profile1.data) == 0 + assert len(profile2.data) == 0 + print(" PASS\n") + + +def test_curvature_inf_json_safe(): + """inf radii from curvature must not produce invalid JSON when sent over the wire.""" + print("=== Test: Curvature (inf radii → valid JSON via server sanitizer) ===") + import json + from backend.server import _sanitize_non_finite, _dumps + + # Simulate a table row as produced by the curvature node for a flat surface + rows = [ + {"quantity": "Curvature radius 1", "value": float("inf"), "unit": "m"}, + {"quantity": "Curvature radius 2", "value": float("-inf"), "unit": "m"}, + {"quantity": "Center value", "value": float("nan"), "unit": "m"}, + {"quantity": "Center x position", "value": 1.5e-7, "unit": "m"}, + ] + + sanitized = _sanitize_non_finite(rows) + assert sanitized[0]["value"] == "∞" + assert sanitized[1]["value"] == "-∞" + assert sanitized[2]["value"] == "NaN" + assert sanitized[3]["value"] == 1.5e-7 # finite float unchanged + + # Must not raise and must produce parseable JSON + payload = _dumps({"type": "table", "data": {"node_id": "n1", "rows": sanitized}}) + decoded = json.loads(payload) + assert decoded["data"]["rows"][0]["value"] == "∞" + print(" PASS\n") + + def test_line_correction(): print("=== Test: LineCorrection ===") from backend.node_registry import get_node_info