rework ergonomics for standalone use
This commit is contained in:
@@ -39,8 +39,8 @@ def test_parse_ibw_note():
|
||||
|
||||
|
||||
def test_note_load_no_file():
|
||||
from backend.nodes.note import Note
|
||||
node = Note()
|
||||
from backend.nodes.note import IgorNote
|
||||
node = IgorNote()
|
||||
try:
|
||||
node.load(filename="")
|
||||
assert False, "Expected ValueError for empty filename"
|
||||
@@ -49,8 +49,8 @@ def test_note_load_no_file():
|
||||
|
||||
|
||||
def test_note_load_file_not_found():
|
||||
from backend.nodes.note import Note
|
||||
node = Note()
|
||||
from backend.nodes.note import IgorNote
|
||||
node = IgorNote()
|
||||
try:
|
||||
node.load(filename="/nonexistent/path/file.ibw")
|
||||
assert False, "Expected FileNotFoundError"
|
||||
@@ -59,8 +59,8 @@ def test_note_load_file_not_found():
|
||||
|
||||
|
||||
def test_note_load_wrong_extension():
|
||||
from backend.nodes.note import Note
|
||||
node = Note()
|
||||
from backend.nodes.note import IgorNote
|
||||
node = IgorNote()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
txt_path = os.path.join(tmpdir, "notes.txt")
|
||||
Path(txt_path).write_text("Key=Value")
|
||||
@@ -73,11 +73,11 @@ def test_note_load_wrong_extension():
|
||||
|
||||
def test_note_load_empty_note():
|
||||
"""An .ibw file with no parseable note entries raises ValueError."""
|
||||
from backend.nodes.note import Note
|
||||
from backend.nodes.note import IgorNote
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
|
||||
node = Note()
|
||||
node = IgorNote()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ibw_path = os.path.join(tmpdir, "empty_note.ibw")
|
||||
Path(ibw_path).write_bytes(b"fake_ibw")
|
||||
@@ -94,7 +94,7 @@ def test_note_load_empty_note():
|
||||
|
||||
def test_note_load_ibw():
|
||||
"""Load from a real .ibw file if available in the demo directory."""
|
||||
from backend.nodes.note import Note
|
||||
from backend.nodes.note import IgorNote
|
||||
from backend.data_types import DataTable
|
||||
|
||||
demo_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "demo"))
|
||||
@@ -107,7 +107,7 @@ def test_note_load_ibw():
|
||||
if ibw_path is None:
|
||||
return
|
||||
|
||||
node = Note()
|
||||
node = IgorNote()
|
||||
try:
|
||||
result, = node.load(filename=ibw_path)
|
||||
assert isinstance(result, DataTable)
|
||||
|
||||
154
tests/test_tono_api.py
Normal file
154
tests/test_tono_api.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tests for the public ``tono`` library API surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Literal, get_args, get_origin
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import tono
|
||||
from backend.data_types import DataField, RecordTable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_field() -> DataField:
|
||||
"""A small deterministic field for fast tests."""
|
||||
rng = np.random.default_rng(0)
|
||||
data = rng.standard_normal((32, 32)).astype(np.float64)
|
||||
return tono.field(data, xreal=1e-6, yreal=1e-6)
|
||||
|
||||
|
||||
# ── Typed call syntax ───────────────────────────────────────────────────
|
||||
|
||||
def test_gaussian_filter_call(sample_field):
|
||||
result = tono.GaussianFilter(sample_field, sigma=2.0)
|
||||
assert isinstance(result, DataField)
|
||||
assert result.data.shape == sample_field.data.shape
|
||||
|
||||
|
||||
def test_default_filling_from_metadata(sample_field):
|
||||
# sigma has a metadata default of 1.0 — call should succeed without it.
|
||||
result = tono.GaussianFilter(sample_field)
|
||||
assert isinstance(result, DataField)
|
||||
|
||||
|
||||
def test_keyword_only_call(sample_field):
|
||||
result = tono.GaussianFilter(field=sample_field, sigma=0.5)
|
||||
assert isinstance(result, DataField)
|
||||
|
||||
|
||||
def test_enum_input_call(sample_field):
|
||||
result = tono.EdgeDetect(sample_field, method="sobel", sigma=1.0)
|
||||
assert isinstance(result, DataField)
|
||||
|
||||
|
||||
def test_multi_output_returns_tuple(sample_field):
|
||||
out = tono.FFT2D(sample_field, windowing="hann", level="mean")
|
||||
assert isinstance(out, tuple)
|
||||
assert len(out) == 4
|
||||
assert all(isinstance(x, DataField) for x in out)
|
||||
|
||||
|
||||
def test_awkward_required_order(sample_field):
|
||||
# ThresholdMask has ``threshold`` (default 0.0) followed by ``direction``
|
||||
# (no default). The wrapper must handle this without raising, and the
|
||||
# metadata default for threshold must still fire at call time.
|
||||
mask, record = tono.ThresholdMask(
|
||||
sample_field, method="otsu", direction="above"
|
||||
)
|
||||
assert mask.shape == sample_field.data.shape
|
||||
assert isinstance(record, RecordTable)
|
||||
|
||||
|
||||
# ── Signature introspection ─────────────────────────────────────────────
|
||||
|
||||
def test_signature_has_expected_params():
|
||||
sig = inspect.signature(tono.GaussianFilter)
|
||||
assert list(sig.parameters) == ["field", "sigma"]
|
||||
assert sig.parameters["sigma"].default == 1.0
|
||||
assert sig.parameters["field"].annotation is DataField
|
||||
|
||||
|
||||
def test_signature_enum_uses_literal():
|
||||
sig = inspect.signature(tono.EdgeDetect)
|
||||
annotation = sig.parameters["method"].annotation
|
||||
assert get_origin(annotation) is Literal
|
||||
assert set(get_args(annotation)) == {"sobel", "prewitt", "laplacian", "log"}
|
||||
|
||||
|
||||
def test_signature_return_annotation_single_output():
|
||||
sig = inspect.signature(tono.GaussianFilter)
|
||||
assert sig.return_annotation is DataField
|
||||
|
||||
|
||||
def test_signature_return_annotation_multi_output():
|
||||
sig = inspect.signature(tono.FFT2D)
|
||||
assert sig.return_annotation is tuple
|
||||
|
||||
|
||||
def test_docstring_contains_description_and_params():
|
||||
doc = tono.EdgeDetect.__doc__ or ""
|
||||
assert "Sobel" in doc or "sobel" in doc
|
||||
assert "field" in doc
|
||||
assert "method" in doc
|
||||
assert "sigma" in doc
|
||||
|
||||
|
||||
# ── Module-level dunder behaviour ───────────────────────────────────────
|
||||
|
||||
def test_dir_lists_nodes():
|
||||
entries = dir(tono)
|
||||
assert "GaussianFilter" in entries
|
||||
assert "EdgeDetect" in entries
|
||||
assert "apply" in entries # existing top-level export
|
||||
assert "describe" in entries
|
||||
|
||||
|
||||
def test_unknown_attribute_raises_attribute_error():
|
||||
with pytest.raises(AttributeError, match="NotARealNode"):
|
||||
tono.NotARealNode # noqa: B018
|
||||
|
||||
|
||||
def test_dunder_lookup_raises_immediately():
|
||||
# __getattr__ should short-circuit dunder lookups so that e.g. copy/pickle
|
||||
# machinery doesn't trigger registry loading.
|
||||
with pytest.raises(AttributeError):
|
||||
tono.__some_dunder__ # noqa: B018
|
||||
|
||||
|
||||
def test_wrappers_are_cached():
|
||||
first = tono.GaussianFilter
|
||||
second = tono.GaussianFilter
|
||||
assert first is second
|
||||
|
||||
|
||||
# ── describe() ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_describe_returns_expected_keys():
|
||||
info = tono.describe("EdgeDetect")
|
||||
assert info["name"] == "EdgeDetect"
|
||||
assert "input" in info
|
||||
assert "output" in info
|
||||
assert "description" in info
|
||||
assert "field" in info["input"]["required"]
|
||||
|
||||
|
||||
def test_describe_unknown_node_raises():
|
||||
with pytest.raises(KeyError):
|
||||
tono.describe("NotARealNode")
|
||||
|
||||
|
||||
# ── apply() still works and matches wrapper semantics ───────────────────
|
||||
|
||||
def test_apply_default_filling(sample_field):
|
||||
# apply() should also fill metadata defaults now.
|
||||
result = tono.apply("GaussianFilter", sample_field)
|
||||
assert isinstance(result, DataField)
|
||||
|
||||
|
||||
def test_apply_and_wrapper_are_equivalent(sample_field):
|
||||
via_apply = tono.apply("GaussianFilter", sample_field, sigma=1.5)
|
||||
via_wrapper = tono.GaussianFilter(sample_field, sigma=1.5)
|
||||
np.testing.assert_array_equal(via_apply.data, via_wrapper.data)
|
||||
Reference in New Issue
Block a user