From 0e8f6671de185cd3165cc4b8d33945663a7dc0b3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 19 Apr 2026 22:27:30 -0500 Subject: [PATCH 1/3] Add CI configuration with test coverage and Codecov integration --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ README.md | 2 ++ pyproject.toml | 16 ++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8cda664 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests with coverage + run: uv run pytest + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + diff --git a/README.md b/README.md index 22237ad..c672b11 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ Anyplotlib ---------- +[![codecov](https://codecov.io/gh/CSSFrancis/anyplotlib/branch/main/graph/badge.svg)](https://codecov.io/gh/CSSFrancis/anyplotlib) + Welcome to **anyplotlib** – a lightweight, interactive viewer for 1-D signals and 2-D images, backed by [anywidget](https://anywidget.dev/) and a pure-JavaScript canvas renderer. The goal is to duplicate and extend the interactive plotting capabilities of Matplotlib, diff --git a/pyproject.toml b/pyproject.toml index 4e9952d..e7c21a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,10 +38,26 @@ jupyter = [ dev = [ "playwright>=1.58.0", "pytest>=9.0.2", + "pytest-cov>=5.0.0", "scipy>=1.15.3", "towncrier>=24.0.0", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=anyplotlib --cov-report=xml --cov-report=term-missing" + +[tool.coverage.run] +source = ["anyplotlib"] +omit = ["tests/*", "Examples/*", "docs/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] + # --------------------------------------------------------------------------- # Changelog management (towncrier) # --------------------------------------------------------------------------- From e106a33559dbf08c482f47827d029238c84db415 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 19 Apr 2026 22:27:39 -0500 Subject: [PATCH 2/3] Add unit tests for Plot2D, Plot1D, PlotMesh, and Plot3D features --- tests/test_imshow_extras.py | 310 ++++++++++++++++++ tests/test_markers.py | 555 ++++++++++++++++++++++++++++++++ tests/test_pcolormesh_extras.py | 192 +++++++++++ tests/test_plot1d_extras.py | 397 +++++++++++++++++++++++ tests/test_plot3d.py | 198 ++++++++++++ tests/test_plotbar_extras.py | 315 ++++++++++++++++++ 6 files changed, 1967 insertions(+) create mode 100644 tests/test_imshow_extras.py create mode 100644 tests/test_markers.py create mode 100644 tests/test_pcolormesh_extras.py create mode 100644 tests/test_plot1d_extras.py create mode 100644 tests/test_plot3d.py create mode 100644 tests/test_plotbar_extras.py diff --git a/tests/test_imshow_extras.py b/tests/test_imshow_extras.py new file mode 100644 index 0000000..6f6da38 --- /dev/null +++ b/tests/test_imshow_extras.py @@ -0,0 +1,310 @@ +""" +tests/test_imshow_extras.py +============================ + +Tests for Plot2D (imshow) features covered in Examples/plot_image2d.py +and Examples/plot_inset.py but not yet well covered. + +Covers: + * cmap / vmin / vmax kwargs at construction + * origin='lower' — data orientation, y-axis reversal + * origin='upper' (default) + * set_colormap() + * set_clim() — vmin only, vmax only, both + * set_scale_mode() + * set_data() — replace image + * colormap_name property + * data property (read-only, origin-aware) + * Validation: bad origin, bad data shape + * add_widget() — all widget kinds + * Widget management: remove_widget, list_widgets, clear_widgets, get_widget + * Insets: add_inset, minimize, maximize, restore, inset_state +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.figure_plots import Plot2D + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _img(n=32, **kwargs) -> Plot2D: + fig, ax = apl.subplots(1, 1) + data = np.arange(n * n, dtype=float).reshape(n, n) + return ax.imshow(data, **kwargs) + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + +class TestPlot2DConstruction: + + def test_kind_is_2d(self): + v = _img() + assert v._state["kind"] == "2d" + + def test_default_cmap_is_gray(self): + v = _img() + assert v._state["colormap_name"] == "gray" + + def test_cmap_kwarg(self): + v = _img(cmap="viridis") + assert v._state["colormap_name"] == "viridis" + + def test_vmin_vmax_clamp(self): + data = np.linspace(0, 1, 64).reshape(8, 8) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(data, vmin=0.2, vmax=0.8) + assert v._state["display_min"] == pytest.approx(0.2) + assert v._state["display_max"] == pytest.approx(0.8) + + def test_default_vmin_vmax_full_range(self): + data = np.linspace(0.0, 1.0, 64).reshape(8, 8) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(data) + assert v._state["display_min"] == pytest.approx(0.0) + assert v._state["display_max"] == pytest.approx(1.0) + + def test_origin_upper_default(self): + v = _img() + assert v._origin == "upper" + + def test_origin_lower_stored(self): + v = _img(origin="lower") + assert v._origin == "lower" + + def test_origin_lower_reverses_y_axis(self): + data = np.zeros((8, 8)) + y = np.arange(8, dtype=float) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(data, axes=[np.arange(8), y], origin="lower") + # y-axis should be reversed (values decreasing) + stored = v._state["y_axis"] + assert stored[0] > stored[-1] + + def test_origin_invalid(self): + with pytest.raises(ValueError, match="origin"): + fig, ax = apl.subplots(1, 1) + ax.imshow(np.zeros((4, 4)), origin="diagonal") + + def test_bad_data_shape_1d(self): + with pytest.raises(ValueError): + fig, ax = apl.subplots(1, 1) + ax.imshow(np.zeros(16)) + + def test_3d_data_squeezed(self): + """3-D input with one channel should be accepted (first channel used).""" + data = np.zeros((8, 8, 3)) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(data) + assert v._state["image_width"] == 8 + + def test_with_physical_axes(self): + data = np.zeros((8, 8)) + x = np.linspace(0, 1, 8) + y = np.linspace(0, 1, 8) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(data, axes=[x, y], units="nm") + assert v._state["has_axes"] is True + assert v._state["units"] == "nm" + + +# --------------------------------------------------------------------------- +# Display setting mutations +# --------------------------------------------------------------------------- + +class TestPlot2DSetters: + + def test_set_colormap(self): + v = _img() + v.set_colormap("plasma") + assert v._state["colormap_name"] == "plasma" + assert isinstance(v._state["colormap_data"], list) + + def test_colormap_name_property(self): + v = _img(cmap="viridis") + assert v.colormap_name == "viridis" + + def test_colormap_name_setter(self): + v = _img() + v.colormap_name = "inferno" + assert v._state["colormap_name"] == "inferno" + + def test_set_clim_vmin(self): + v = _img() + v.set_clim(vmin=0.1) + assert v._state["display_min"] == pytest.approx(0.1) + + def test_set_clim_vmax(self): + v = _img() + v.set_clim(vmax=0.9) + assert v._state["display_max"] == pytest.approx(0.9) + + def test_set_clim_both(self): + v = _img() + v.set_clim(vmin=0.0, vmax=0.8) + assert v._state["display_min"] == pytest.approx(0.0) + assert v._state["display_max"] == pytest.approx(0.8) + + def test_set_scale_mode_log(self): + v = _img() + v.set_scale_mode("log") + assert v._state["scale_mode"] == "log" + + def test_set_scale_mode_invalid(self): + v = _img() + with pytest.raises(ValueError): + v.set_scale_mode("square_root") + + def test_set_data_replaces(self): + v = _img() + new = np.ones((32, 32)) + v.set_data(new) + assert v._state["image_width"] == 32 + assert v._state["image_height"] == 32 + + def test_set_data_updates_units(self): + v = _img() + v.set_data(np.zeros((32, 32)), units="Å") + assert v._state["units"] == "Å" + + def test_set_data_bad_shape(self): + v = _img() + with pytest.raises(ValueError): + v.set_data(np.zeros(16)) + + def test_data_property_readonly(self): + v = _img() + arr = v.data + assert not arr.flags.writeable + + def test_data_property_origin_lower(self): + """data property should undo the internal flipud for origin='lower'.""" + data = np.arange(64, dtype=float).reshape(8, 8) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(data, origin="lower") + np.testing.assert_array_equal(v.data, data) + + +# --------------------------------------------------------------------------- +# add_widget +# --------------------------------------------------------------------------- + +class TestPlot2DAddWidget: + + def test_add_circle_widget(self): + v = _img(n=64) + w = v.add_widget("circle", cx=32, cy=32, r=10) + assert w is not None + assert len(v._widgets) == 1 + + def test_add_rectangle_widget(self): + v = _img(n=64) + w = v.add_widget("rectangle") + assert len(v._widgets) == 1 + + def test_add_annular_widget(self): + v = _img(n=64) + w = v.add_widget("annular", r_outer=20, r_inner=10) + assert len(v._widgets) == 1 + + def test_add_polygon_widget(self): + v = _img(n=64) + w = v.add_widget("polygon") + assert len(v._widgets) == 1 + + def test_add_crosshair_widget(self): + v = _img(n=64) + w = v.add_widget("crosshair", cx=32, cy=32) + assert len(v._widgets) == 1 + + def test_add_label_widget(self): + v = _img(n=64) + w = v.add_widget("label", text="hello") + assert len(v._widgets) == 1 + + def test_bad_widget_kind(self): + v = _img(n=64) + with pytest.raises(ValueError): + v.add_widget("star") + + def test_remove_widget(self): + v = _img(n=64) + w = v.add_widget("circle") + v.remove_widget(w) + assert len(v._widgets) == 0 + + def test_list_widgets(self): + v = _img(n=64) + v.add_widget("circle") + v.add_widget("crosshair") + assert len(v.list_widgets()) == 2 + + def test_clear_widgets(self): + v = _img(n=64) + v.add_widget("circle") + v.clear_widgets() + assert v.list_widgets() == [] + + +# --------------------------------------------------------------------------- +# Insets +# --------------------------------------------------------------------------- + +class TestInsets: + + def _fig_with_inset(self, **kwargs): + fig, ax = apl.subplots(1, 1, figsize=(500, 500)) + ax.imshow(np.zeros((64, 64))) + inset = fig.add_inset(0.25, 0.25, **kwargs) + return fig, inset + + def test_add_inset_returns_axes(self): + fig, inset = self._fig_with_inset(title="Test") + assert inset is not None + + def test_inset_default_state(self): + fig, inset = self._fig_with_inset() + assert inset.inset_state == "normal" + + def test_inset_minimize(self): + fig, inset = self._fig_with_inset() + inset.minimize() + assert inset.inset_state == "minimized" + + def test_inset_maximize(self): + fig, inset = self._fig_with_inset() + inset.maximize() + assert inset.inset_state == "maximized" + + def test_inset_restore(self): + fig, inset = self._fig_with_inset() + inset.minimize() + inset.restore() + assert inset.inset_state == "normal" + + def test_inset_with_plot(self): + fig, ax = apl.subplots(1, 1, figsize=(500, 500)) + ax.imshow(np.zeros((64, 64))) + inset = fig.add_inset(0.3, 0.3, corner="top-right", title="Profile") + inset.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#4fc3f7") + + def test_inset_with_imshow(self): + fig, ax = apl.subplots(1, 1, figsize=(500, 500)) + ax.imshow(np.zeros((64, 64))) + inset = fig.add_inset(0.3, 0.3, corner="bottom-left") + inset.imshow(np.ones((32, 32)), cmap="hot") + + def test_multiple_insets_same_corner(self): + fig, ax = apl.subplots(1, 1, figsize=(600, 600)) + ax.imshow(np.zeros((64, 64))) + i1 = fig.add_inset(0.25, 0.25, corner="top-right", title="I1") + i2 = fig.add_inset(0.25, 0.25, corner="top-right", title="I2") + assert i1 is not i2 + diff --git a/tests/test_markers.py b/tests/test_markers.py new file mode 100644 index 0000000..db4a39e --- /dev/null +++ b/tests/test_markers.py @@ -0,0 +1,555 @@ +""" +tests/test_markers.py +===================== + +Tests for the marker system (MarkerGroup, MarkerTypeDict, MarkerRegistry) +and the high-level add_* helpers on Plot2D, Plot1D, and PlotMesh. + +Exercises all marker types from the Examples/Markers gallery: + circles, arrows, ellipses, lines, rectangles, squares, polygons, texts, + points, vlines, hlines. + +Also covers: + * set() — live update + * remove() / clear() + * auto-naming (circles_1, circles_2, …) + * to_wire() output structure for every type + * to_wire() validation errors + * MarkerTypeDict dict-like interface (contains, iter, len, keys/values/items, pop) + * MarkerRegistry allowed-type restriction +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.markers import MarkerGroup, MarkerTypeDict, MarkerRegistry + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_plot2d(): + fig, ax = apl.subplots(1, 1) + data = np.random.default_rng(0).standard_normal((64, 64)) + return ax.imshow(data) + + +def _make_plot1d(): + fig, ax = apl.subplots(1, 1) + return ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128))) + + +def _make_mesh(): + fig, ax = apl.subplots(1, 1) + data = np.ones((8, 12)) + x_edges = np.linspace(0, 12, 13) + y_edges = np.linspace(0, 8, 9) + return ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges) + + +# --------------------------------------------------------------------------- +# MarkerGroup — to_wire() for every type +# --------------------------------------------------------------------------- + +def _push_noop(): + pass + + +class TestMarkerGroupToWire: + + def _group(self, mtype, **kwargs): + return MarkerGroup(mtype, "g1", kwargs, _push_noop) + + # ── 2-D types ─────────────────────────────────────────────────────────── + + def test_circles_basic(self): + g = self._group("circles", offsets=[[10.0, 20.0], [30.0, 40.0]], radius=5) + w = g.to_wire("gid") + assert w["type"] == "circles" + assert len(w["offsets"]) == 2 + assert len(w["sizes"]) == 2 + assert w["sizes"][0] == pytest.approx(5.0) + + def test_circles_with_facecolors(self): + g = self._group("circles", offsets=[[0.0, 0.0]], facecolors="#ff0000", alpha=0.5) + w = g.to_wire("gid") + assert "fill_color" in w + assert w["fill_alpha"] == pytest.approx(0.5) + + def test_arrows_basic(self): + g = self._group("arrows", offsets=[[0.0, 0.0]], U=1.0, V=2.0, linewidths=2.0) + w = g.to_wire("gid") + assert w["type"] == "arrows" + assert len(w["U"]) == 1 + assert len(w["V"]) == 1 + assert w["linewidth"] == pytest.approx(2.0) + + def test_ellipses_basic(self): + g = self._group("ellipses", + offsets=[[32.0, 32.0], [64.0, 96.0]], + widths=30, heights=14, angles=[0.0, 45.0]) + w = g.to_wire("gid") + assert w["type"] == "ellipses" + assert len(w["widths"]) == 2 + assert len(w["heights"]) == 2 + + def test_ellipses_with_fill(self): + g = self._group("ellipses", offsets=[[0.0, 0.0]], widths=10, heights=5, + facecolors="#00ff00", alpha=0.4) + w = g.to_wire("gid") + assert "fill_color" in w + + def test_lines_single_segment(self): + g = self._group("lines", segments=[[0.0, 0.0], [10.0, 10.0]]) + w = g.to_wire("gid") + assert w["type"] == "lines" + assert len(w["segments"]) == 1 + + def test_lines_multi_segment(self): + segs = [[[0.0, 0.0], [5.0, 5.0]], [[5.0, 5.0], [10.0, 0.0]]] + g = self._group("lines", segments=segs) + w = g.to_wire("gid") + assert len(w["segments"]) == 2 + + def test_lines_bad_shape(self): + g = self._group("lines", segments=[[[0.0, 0.0], [1.0, 2.0], [3.0, 4.0]]]) + with pytest.raises(ValueError): + g.to_wire("gid") + + def test_rectangles_basic(self): + g = self._group("rectangles", offsets=[[10.0, 10.0]], widths=20, heights=10) + w = g.to_wire("gid") + assert w["type"] == "rectangles" + + def test_rectangles_with_fill(self): + g = self._group("rectangles", offsets=[[0.0, 0.0]], widths=5, heights=5, + facecolors="#0000ff", alpha=0.2) + w = g.to_wire("gid") + assert "fill_color" in w + + def test_squares_basic(self): + g = self._group("squares", offsets=[[32.0, 32.0]], widths=20, angles=[15.0]) + w = g.to_wire("gid") + assert w["type"] == "squares" + + def test_squares_with_fill(self): + g = self._group("squares", offsets=[[0.0, 0.0]], widths=10, + facecolors="#ff00ff", alpha=0.3) + w = g.to_wire("gid") + assert "fill_color" in w + + def test_polygons_basic(self): + tri = [[0.0, 0.0], [10.0, 0.0], [5.0, 8.0]] + g = self._group("polygons", vertices_list=[tri]) + w = g.to_wire("gid") + assert w["type"] == "polygons" + assert len(w["vertices_list"]) == 1 + + def test_polygons_with_fill(self): + tri = [[0.0, 0.0], [10.0, 0.0], [5.0, 8.0]] + g = self._group("polygons", vertices_list=[tri], facecolors="#aaa", alpha=0.5) + w = g.to_wire("gid") + assert "fill_color" in w + + def test_polygons_bad_vertex(self): + bad = [[0.0, 0.0], [1.0, 1.0]] # only 2 points — must be ≥3 + g = self._group("polygons", vertices_list=[bad]) + with pytest.raises(ValueError): + g.to_wire("gid") + + def test_texts_basic(self): + g = self._group("texts", offsets=[[10.0, 20.0]], texts=["hello"], fontsize=14) + w = g.to_wire("gid") + assert w["type"] == "texts" + assert w["texts"] == ["hello"] + assert w["fontsize"] == 14 + + # ── 1-D types ─────────────────────────────────────────────────────────── + + def test_points_basic(self): + g = self._group("points", offsets=[1.0, 2.0, 3.0], sizes=7, color="#ff0000") + w = g.to_wire("gid") + assert w["type"] == "points" + assert len(w["offsets"]) == 3 + + def test_points_with_fill(self): + g = self._group("points", offsets=[1.0], facecolors="#00ff00", alpha=0.6) + w = g.to_wire("gid") + assert "fill_color" in w + + def test_vlines_basic(self): + g = self._group("vlines", offsets=[1.0, 2.5, 4.0]) + w = g.to_wire("gid") + assert w["type"] == "vlines" + assert len(w["offsets"]) == 3 + assert all(len(r) == 1 for r in w["offsets"]) + + def test_hlines_basic(self): + g = self._group("hlines", offsets=[0.5, 1.0]) + w = g.to_wire("gid") + assert w["type"] == "hlines" + assert len(w["offsets"]) == 2 + + def test_unknown_type_raises(self): + g = self._group("stars", offsets=[[0.0, 0.0]]) + with pytest.raises(ValueError, match="Unknown marker type"): + g.to_wire("gid") + + # ── Optional common fields ─────────────────────────────────────────────── + + def test_label_included(self): + g = self._group("circles", offsets=[[0.0, 0.0]], label="my label") + w = g.to_wire("gid") + assert w["label"] == "my label" + + def test_labels_included(self): + g = self._group("circles", offsets=[[0.0, 0.0], [1.0, 1.0]], + labels=["A", "B"]) + w = g.to_wire("gid") + assert w["labels"] == ["A", "B"] + + def test_hover_edgecolors(self): + g = self._group("circles", offsets=[[0.0, 0.0]], hover_edgecolors="#ff0") + w = g.to_wire("gid") + assert w["hover_color"] == "#ff0" + + def test_hover_facecolors(self): + g = self._group("circles", offsets=[[0.0, 0.0]], hover_facecolors="#0f0") + w = g.to_wire("gid") + assert w["hover_facecolor"] == "#0f0" + + +# --------------------------------------------------------------------------- +# MarkerGroup — set() triggers push +# --------------------------------------------------------------------------- + +class TestMarkerGroupSet: + + def test_set_updates_data(self): + calls = [] + g = MarkerGroup("circles", "g", {"offsets": [[0.0, 0.0]], "radius": 5}, + lambda: calls.append(1)) + g.set(radius=10) + assert g._data["radius"] == 10 + assert len(calls) == 1 + + def test_count_zero_when_no_offsets(self): + g = MarkerGroup("circles", "g", {}, _push_noop) + assert g._count() == 0 + + +# --------------------------------------------------------------------------- +# MarkerTypeDict +# --------------------------------------------------------------------------- + +class TestMarkerTypeDict: + + def _td(self): + calls = [] + td = MarkerTypeDict("circles", lambda: calls.append(1)) + return td, calls + + def test_setitem_triggers_push(self): + td, calls = self._td() + g = MarkerGroup("circles", "g", {"offsets": [[0.0, 0.0]]}, _push_noop) + td["g"] = g + assert len(calls) == 1 + + def test_delitem_triggers_push(self): + td, calls = self._td() + g = MarkerGroup("circles", "g", {"offsets": [[0.0, 0.0]]}, _push_noop) + td._groups["g"] = g + del td["g"] + assert len(calls) == 1 + + def test_contains(self): + td, _ = self._td() + g = MarkerGroup("circles", "g", {}, _push_noop) + td._groups["g"] = g + assert "g" in td + assert "x" not in td + + def test_iter(self): + td, _ = self._td() + g = MarkerGroup("circles", "g", {}, _push_noop) + td._groups["g"] = g + assert list(td) == ["g"] + + def test_len(self): + td, _ = self._td() + assert len(td) == 0 + td._groups["a"] = MarkerGroup("circles", "a", {}, _push_noop) + assert len(td) == 1 + + def test_keys_values_items(self): + td, _ = self._td() + g = MarkerGroup("circles", "g", {}, _push_noop) + td._groups["g"] = g + assert "g" in td.keys() + assert g in td.values() + assert ("g", g) in td.items() + + def test_pop_triggers_push(self): + td, calls = self._td() + g = MarkerGroup("circles", "g", {}, _push_noop) + td._groups["g"] = g + result = td.pop("g") + assert result is g + assert len(calls) == 1 + + def test_pop_default(self): + td, _ = self._td() + result = td.pop("missing", None) + assert result is None + + def test_to_wire_list(self): + td, _ = self._td() + g = MarkerGroup("circles", "g", {"offsets": [[5.0, 5.0]]}, _push_noop) + td._groups["g"] = g + wl = td.to_wire_list() + assert len(wl) == 1 + assert wl[0]["type"] == "circles" + + +# --------------------------------------------------------------------------- +# MarkerRegistry +# --------------------------------------------------------------------------- + +class TestMarkerRegistry: + + def _reg(self, allowed=None): + calls = [] + reg = MarkerRegistry(lambda: calls.append(1), allowed=allowed) + return reg, calls + + def test_auto_creates_type_dict(self): + reg, _ = self._reg() + td = reg["circles"] + assert isinstance(td, MarkerTypeDict) + assert "circles" in reg + + def test_allowed_restriction(self): + reg, _ = self._reg(allowed=frozenset({"circles"})) + with pytest.raises(ValueError, match="not allowed"): + reg["arrows"] + + def test_add_returns_marker_group(self): + reg, calls = self._reg() + g = reg.add("circles", name="g1", offsets=[[0.0, 0.0]], radius=5) + assert isinstance(g, MarkerGroup) + assert len(calls) == 1 + + def test_add_auto_name(self): + reg, _ = self._reg() + g1 = reg.add("circles", offsets=[[0.0, 0.0]]) + g2 = reg.add("circles", offsets=[[1.0, 1.0]]) + assert g1._name == "circles_1" + assert g2._name == "circles_2" + + def test_remove(self): + reg, calls = self._reg() + reg.add("circles", name="g1", offsets=[[0.0, 0.0]]) + n_before = len(calls) + reg.remove("circles", "g1") + assert len(calls) > n_before + + def test_clear(self): + reg, calls = self._reg() + reg.add("circles", name="g1", offsets=[[0.0, 0.0]]) + reg.clear() + assert "circles" not in reg + + def test_iter(self): + reg, _ = self._reg() + reg.add("circles", name="g1", offsets=[[0.0, 0.0]]) + assert "circles" in list(reg) + + def test_to_wire_list(self): + reg, _ = self._reg() + reg.add("circles", name="g1", offsets=[[10.0, 20.0]], radius=4) + wl = reg.to_wire_list() + assert len(wl) == 1 + assert wl[0]["type"] == "circles" + + def test_auto_name_with_custom_names(self): + """Auto-naming should not be confused by custom-named groups.""" + reg, _ = self._reg() + reg.add("circles", name="my_spot", offsets=[[0.0, 0.0]]) + g = reg.add("circles", offsets=[[1.0, 1.0]]) + assert g._name == "circles_1" + + +# --------------------------------------------------------------------------- +# Plot2D high-level add_* helpers (from Examples/Markers) +# --------------------------------------------------------------------------- + +class TestPlot2DMarkerHelpers: + + def test_add_circles(self): + v = _make_plot2d() + centres = np.array([[10.0, 20.0], [30.0, 40.0]]) + v.add_circles(centres, name="spots", radius=10, + edgecolors="#ff1744", facecolors="#ff174433", + labels=["A", "B"]) + assert "spots" in v.markers["circles"] + wl = v.markers.to_wire_list() + assert any(w["type"] == "circles" for w in wl) + + def test_add_circles_set(self): + v = _make_plot2d() + v.add_circles([[5.0, 5.0]], name="c", radius=5) + v.markers["circles"]["c"].set(radius=12, edgecolors="#ffcc00") + assert v.markers["circles"]["c"]._data["radius"] == 12 + + def test_add_arrows(self): + v = _make_plot2d() + tails = np.array([[20.0, 20.0], [60.0, 60.0]]) + U = np.array([5.0, -5.0]) + V = np.array([5.0, -5.0]) + v.add_arrows(tails, U, V, name="flow", edgecolors="#76ff03") + assert "flow" in v.markers["arrows"] + + def test_add_arrows_set(self): + v = _make_plot2d() + v.add_arrows([[5.0, 5.0]], [1.0], [1.0], name="arr") + v.markers["arrows"]["arr"].set(edgecolors="#ff9100", linewidths=2.5) + assert v.markers["arrows"]["arr"]._data["edgecolors"] == "#ff9100" + + def test_add_ellipses(self): + v = _make_plot2d() + centres = np.array([[32.0, 32.0], [64.0, 96.0]]) + v.add_ellipses(centres, widths=30, heights=14, angles=[0.0, 45.0], + name="grains", edgecolors="#ff9100") + assert "grains" in v.markers["ellipses"] + + def test_add_ellipses_set(self): + v = _make_plot2d() + v.add_ellipses([[0.0, 0.0]], widths=10, heights=5, name="e") + v.markers["ellipses"]["e"].set(widths=20) + assert v.markers["ellipses"]["e"]._data["widths"] == 20 + + def test_add_rectangles(self): + v = _make_plot2d() + centres = np.array([[20.0, 20.0], [50.0, 50.0]]) + v.add_rectangles(centres, widths=22, heights=14, name="boxes", + edgecolors="#00e5ff") + assert "boxes" in v.markers["rectangles"] + + def test_add_rectangles_set(self): + v = _make_plot2d() + v.add_rectangles([[5.0, 5.0]], widths=10, heights=5, name="r") + v.markers["rectangles"]["r"].set(widths=20, heights=10) + assert v.markers["rectangles"]["r"]._data["widths"] == 20 + + def test_add_squares(self): + v = _make_plot2d() + centres = np.array([[32.0, 32.0], [64.0, 64.0]]) + v.add_squares(centres, widths=20, angles=[0, 15], name="tiles") + assert "tiles" in v.markers["squares"] + + def test_add_squares_set(self): + v = _make_plot2d() + v.add_squares([[5.0, 5.0]], widths=10, name="s") + v.markers["squares"]["s"].set(widths=20, edgecolors="#e040fb") + assert v.markers["squares"]["s"]._data["widths"] == 20 + + def test_add_polygons(self): + v = _make_plot2d() + tri = [[10.0, 5.0], [20.0, 25.0], [0.0, 25.0]] + v.add_polygons([tri], name="poly", edgecolors="#ff9100") + assert "poly" in v.markers["polygons"] + + def test_add_texts(self): + v = _make_plot2d() + v.add_texts([[10.0, 10.0], [30.0, 30.0]], texts=["A", "B"], + name="labels", color="#ffffff", fontsize=12) + assert "labels" in v.markers["texts"] + + def test_add_lines_2d(self): + v = _make_plot2d() + segs = [[[5.0, 5.0], [20.0, 20.0]], [[20.0, 20.0], [40.0, 5.0]]] + v.add_lines(segs, name="segs") + assert "segs" in v.markers["lines"] + + def test_remove_marker(self): + v = _make_plot2d() + v.add_circles([[0.0, 0.0]], name="c") + v.remove_marker("circles", "c") + assert "c" not in v.markers["circles"] + + def test_clear_markers(self): + v = _make_plot2d() + v.add_circles([[0.0, 0.0]], name="c1") + v.add_circles([[1.0, 1.0]], name="c2") + v.clear_markers() + assert v.markers.to_wire_list() == [] + + def test_list_markers(self): + v = _make_plot2d() + v.add_circles([[0.0, 0.0], [1.0, 1.0]], name="c") + info = v.list_markers() + assert any(d["name"] == "c" and d["n"] == 2 for d in info) + + +# --------------------------------------------------------------------------- +# Plot1D marker helpers +# --------------------------------------------------------------------------- + +class TestPlot1DMarkerHelpers: + + def test_add_points(self): + v = _make_plot1d() + offsets = np.column_stack([[1.0, 2.0, 3.0], [0.5, 0.8, 0.3]]) + v.add_points(offsets, name="peaks", sizes=7, color="#ff1744") + assert "peaks" in v.markers["points"] + + def test_add_vlines(self): + v = _make_plot1d() + v.add_vlines([1.0, 2.0, 3.0], name="marks", color="#00e5ff") + assert "marks" in v.markers["vlines"] + + def test_add_hlines(self): + v = _make_plot1d() + v.add_hlines([0.5, -0.5], name="levels", color="#ff9100") + assert "levels" in v.markers["hlines"] + + def test_remove_marker_1d(self): + v = _make_plot1d() + v.add_vlines([1.0], name="m") + v.remove_marker("vlines", "m") + assert "m" not in v.markers["vlines"] + + def test_clear_markers_1d(self): + v = _make_plot1d() + v.add_vlines([1.0], name="v1") + v.add_hlines([0.5], name="h1") + v.clear_markers() + assert v.markers.to_wire_list() == [] + + +# --------------------------------------------------------------------------- +# PlotMesh marker helpers +# --------------------------------------------------------------------------- + +class TestPlotMeshMarkerHelpers: + + def test_add_circles_mesh(self): + mesh = _make_mesh() + pts = np.array([[2.0, 2.0], [6.0, 4.0]]) + mesh.add_circles(pts, name="peaks", radius=0.5, edgecolors="#ff1744") + assert "peaks" in mesh.markers["circles"] + + def test_add_lines_mesh(self): + mesh = _make_mesh() + segs = [[[1.0, 1.0], [5.0, 5.0]]] + mesh.add_lines(segs, name="path", edgecolors="#00e5ff") + assert "path" in mesh.markers["lines"] + + def test_mesh_disallows_arrows(self): + mesh = _make_mesh() + with pytest.raises(ValueError, match="not allowed"): + mesh.add_arrows([[0.0, 0.0]], [1.0], [1.0]) + diff --git a/tests/test_pcolormesh_extras.py b/tests/test_pcolormesh_extras.py new file mode 100644 index 0000000..a8c0d58 --- /dev/null +++ b/tests/test_pcolormesh_extras.py @@ -0,0 +1,192 @@ +""" +tests/test_pcolormesh_extras.py +================================ + +Tests for PlotMesh (pcolormesh) mirroring Examples/plot_pcolormesh.py. + +Covers: + * Basic construction with non-uniform edges + * set_colormap() + * set_data() — data replacement + * add_circles / add_lines marker helpers + * Restriction to circles+lines only + * State dict keys +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.figure_plots import PlotMesh + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mesh(M=8, N=12): + rng = np.random.default_rng(42) + data = rng.standard_normal((M, N)) + x_edges = np.linspace(0, N, N + 1) + y_edges = np.linspace(0, M, M + 1) + fig, ax = apl.subplots(1, 1) + return ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges) + + +def _log_mesh(): + """Mesh with non-uniform (log-spaced) x edges, as in the gallery example.""" + M, N = 32, 48 + rng = np.random.default_rng(1) + data = np.sin(np.linspace(0, 3 * np.pi, N)) + np.cos(np.linspace(0, 2 * np.pi, M))[:, None] + data += rng.normal(scale=0.15, size=(M, N)) + x_edges = np.logspace(-1, 2, N + 1) + y_edges = np.linspace(0, 100, M + 1) + fig, ax = apl.subplots(1, 1) + return ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="arb.") + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + +class TestPlotMeshConstruction: + + def test_kind_is_2d(self): + mesh = _mesh() + assert mesh._state["kind"] == "2d" + + def test_is_mesh_flag(self): + mesh = _mesh() + assert mesh._state["is_mesh"] is True + + def test_x_axis_has_edges(self): + mesh = _mesh(M=8, N=12) + # x_axis stores edges (N+1 values) + assert len(mesh._state["x_axis"]) == 13 + + def test_y_axis_has_edges(self): + mesh = _mesh(M=8, N=12) + assert len(mesh._state["y_axis"]) == 9 + + def test_units_stored(self): + mesh = _log_mesh() + assert mesh._state["units"] == "arb." + + def test_log_x_edges(self): + """Non-uniform (log-spaced) edges should be accepted without error.""" + mesh = _log_mesh() + assert mesh._state["image_width"] == 48 + + def test_default_colormap(self): + mesh = _mesh() + assert "colormap_name" in mesh._state + + def test_wrong_x_edge_count(self): + data = np.ones((8, 12)) + x_edges = np.linspace(0, 10, 10) # should be 13 + y_edges = np.linspace(0, 8, 9) + with pytest.raises(ValueError): + fig, ax = apl.subplots(1, 1) + ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges) + + def test_wrong_y_edge_count(self): + data = np.ones((8, 12)) + x_edges = np.linspace(0, 12, 13) + y_edges = np.linspace(0, 10, 5) # should be 9 + with pytest.raises(ValueError): + fig, ax = apl.subplots(1, 1) + ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges) + + +# --------------------------------------------------------------------------- +# Mutations +# --------------------------------------------------------------------------- + +class TestPlotMeshMutations: + + def test_set_colormap(self): + mesh = _mesh() + mesh.set_colormap("viridis") + assert mesh._state["colormap_name"] == "viridis" + + def test_set_colormap_updates_lut(self): + mesh = _mesh() + mesh.set_colormap("plasma") + lut = mesh._state["colormap_data"] + assert isinstance(lut, list) + assert len(lut) == 256 + + def test_set_data_same_shape(self): + mesh = _mesh(M=8, N=12) + new_data = np.ones((8, 12)) + mesh.set_data(new_data) + assert mesh._state["image_width"] == 12 + + def test_set_data_with_new_units(self): + mesh = _mesh() + mesh.set_data(np.zeros((8, 12)), units="nm") + assert mesh._state["units"] == "nm" + + def test_set_data_wrong_ndim(self): + mesh = _mesh() + with pytest.raises(ValueError): + mesh.set_data(np.zeros(12)) + + def test_set_data_wrong_x_edges(self): + mesh = _mesh(M=8, N=12) + new_data = np.zeros((8, 12)) + bad_x = np.linspace(0, 10, 5) + with pytest.raises(ValueError): + mesh.set_data(new_data, x_edges=bad_x) + + +# --------------------------------------------------------------------------- +# Markers +# --------------------------------------------------------------------------- + +class TestPlotMeshMarkers: + + def test_add_circles(self): + mesh = _mesh() + pts = np.array([[2.0, 2.0], [6.0, 4.0]]) + mesh.add_circles(pts, name="peaks", radius=0.5, edgecolors="#ff1744") + assert "peaks" in mesh.markers["circles"] + + def test_add_circles_labels(self): + mesh = _mesh() + pts = np.array([[1.0, 2.0], [5.0, 4.0], [9.0, 6.0], [11.0, 2.0]]) + mesh.add_circles(pts, name="pks", radius=0.3, + edgecolors="#ff1744", facecolors="#ff174433", + labels=["A", "B", "C", "D"]) + wl = mesh.markers.to_wire_list() + assert any(w.get("labels") == ["A", "B", "C", "D"] for w in wl) + + def test_add_lines(self): + mesh = _mesh() + segs = [[[1.0, 1.0], [5.0, 5.0]], [[5.0, 5.0], [10.0, 2.0]]] + mesh.add_lines(segs, name="path", edgecolors="#00e5ff") + assert "path" in mesh.markers["lines"] + + def test_arrows_disallowed_on_mesh(self): + mesh = _mesh() + with pytest.raises(ValueError, match="not allowed"): + mesh.add_arrows([[0.0, 0.0]], [1.0], [1.0]) + + def test_ellipses_disallowed_on_mesh(self): + mesh = _mesh() + with pytest.raises(ValueError, match="not allowed"): + mesh.add_ellipses([[0.0, 0.0]], widths=5, heights=3) + + def test_circles_set(self): + mesh = _mesh() + mesh.add_circles([[2.0, 2.0]], name="c", radius=1.0) + mesh.markers["circles"]["c"].set(radius=2.0) + assert mesh.markers["circles"]["c"]._data["radius"] == 2.0 + + def test_to_wire_list_contains_circles(self): + mesh = _mesh() + mesh.add_circles([[2.0, 2.0]], name="spot") + wl = mesh.markers.to_wire_list() + assert any(w["type"] == "circles" for w in wl) + diff --git a/tests/test_plot1d_extras.py b/tests/test_plot1d_extras.py new file mode 100644 index 0000000..cd43434 --- /dev/null +++ b/tests/test_plot1d_extras.py @@ -0,0 +1,397 @@ +""" +tests/test_plot1d_extras.py +============================ + +Additional tests for Plot1D — focusing on features exercised in the +Examples/plot_spectra1d.py and Examples/plot_line_styles.py galleries but +not yet covered by the existing test_plot1d_linestyle.py. + +Covers: + * add_line() with linestyle / alpha / marker / ls shorthand + * remove_line() / clear_lines() + * Line1D.set_data() / Line1D.remove() + * add_span() / remove_span() / clear_spans() + * add_vline_widget() / add_hline_widget() / add_range_widget() + * Widget management: get_widget, remove_widget, list_widgets, clear_widgets + * set_color, set_linewidth, set_linestyle, set_alpha, set_marker, set_data + * data property (read-only view) + * Primary-line Line1D.set_data raises + * Primary-line Line1D.remove raises + * line property returns Line1D(id=None) + * list_markers / remove_marker / clear_markers +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.figure_plots import Line1D, Plot1D + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _plot(n=128, **kwargs) -> Plot1D: + fig, ax = apl.subplots(1, 1) + data = np.sin(np.linspace(0, 2 * np.pi, n)) + return ax.plot(data, **kwargs) + + +t = np.linspace(0, 2 * np.pi, 128) + +# --------------------------------------------------------------------------- +# Primary-line style setters +# --------------------------------------------------------------------------- + +class TestPlot1DSetters: + + def test_set_color(self): + v = _plot(color="#4fc3f7") + v.set_color("#ff7043") + assert v._state["line_color"] == "#ff7043" + + def test_set_linewidth(self): + v = _plot() + v.set_linewidth(3.0) + assert v._state["line_linewidth"] == pytest.approx(3.0) + + def test_set_linestyle_word(self): + v = _plot() + v.set_linestyle("dashed") + assert v._state["line_linestyle"] == "dashed" + + def test_set_linestyle_shorthand(self): + v = _plot() + v.set_linestyle("-.") + assert v._state["line_linestyle"] == "dashdot" + + def test_set_alpha(self): + v = _plot() + v.set_alpha(0.5) + assert v._state["line_alpha"] == pytest.approx(0.5) + + def test_set_marker(self): + v = _plot() + v.set_marker("o", markersize=6) + assert v._state["line_marker"] == "o" + assert v._state["line_markersize"] == pytest.approx(6.0) + + def test_set_data_replaces_primary(self): + v = _plot(n=64) + new_data = np.cos(np.linspace(0, 2 * np.pi, 64)) + v.set_data(new_data) + np.testing.assert_allclose(v._state["data"], new_data) + + def test_set_data_with_new_x_axis(self): + v = _plot(n=32) + y = np.ones(32) + x = np.linspace(10, 42, 32) + v.set_data(y, x_axis=x) + np.testing.assert_allclose(v._state["x_axis"], x) + + def test_set_data_updates_units(self): + v = _plot() + v.set_data(np.zeros(128), units="eV") + assert v._state["units"] == "eV" + + def test_set_data_2d_raises(self): + v = _plot() + with pytest.raises(ValueError): + v.set_data(np.ones((4, 4))) + + def test_data_property_readonly(self): + v = _plot() + arr = v.data + assert not arr.flags.writeable + + def test_line_property_returns_line1d(self): + v = _plot() + assert isinstance(v.line, Line1D) + assert v.line.id is None + + +# --------------------------------------------------------------------------- +# Construction — linestyle / alpha / marker at creation time +# --------------------------------------------------------------------------- + +class TestPlot1DConstruction: + + def test_linestyle_dashed(self): + v = _plot(linestyle="dashed") + assert v._state["line_linestyle"] == "dashed" + + def test_ls_shorthand(self): + v = _plot(ls="--") + assert v._state["line_linestyle"] == "dashed" + + def test_linestyle_dotted(self): + v = _plot(linestyle="dotted") + assert v._state["line_linestyle"] == "dotted" + + def test_linestyle_dashdot(self): + v = _plot(linestyle="-.") + assert v._state["line_linestyle"] == "dashdot" + + def test_alpha_stored(self): + v = _plot(alpha=0.4) + assert v._state["line_alpha"] == pytest.approx(0.4) + + def test_marker_stored(self): + v = _plot(marker="s", markersize=5) + assert v._state["line_marker"] == "s" + assert v._state["line_markersize"] == pytest.approx(5.0) + + +# --------------------------------------------------------------------------- +# add_line / remove_line / clear_lines +# --------------------------------------------------------------------------- + +class TestPlot1DOverlayLines: + + def test_add_line_returns_line1d(self): + v = _plot() + line = v.add_line(np.cos(t)) + assert isinstance(line, Line1D) + assert line.id is not None + + def test_add_line_stored_in_extra_lines(self): + v = _plot() + v.add_line(np.cos(t), color="#ff7043", label="cos") + assert len(v._state["extra_lines"]) == 1 + assert v._state["extra_lines"][0]["color"] == "#ff7043" + + def test_add_line_linestyle_alpha_marker(self): + v = _plot() + line = v.add_line(np.cos(t), linestyle="dashed", alpha=0.75, + marker="o", markersize=5) + entry = v._state["extra_lines"][0] + assert entry["linestyle"] == "dashed" + assert entry["alpha"] == pytest.approx(0.75) + assert entry["marker"] == "o" + + def test_add_line_ls_shorthand(self): + v = _plot() + v.add_line(np.cos(t), ls=":") + assert v._state["extra_lines"][0]["linestyle"] == "dotted" + + def test_add_multiple_lines(self): + v = _plot() + v.add_line(np.cos(t)) + v.add_line(np.cos(t) * 0.5) + assert len(v._state["extra_lines"]) == 2 + + def test_remove_line_by_id(self): + v = _plot() + line = v.add_line(np.cos(t)) + v.remove_line(line.id) + assert len(v._state["extra_lines"]) == 0 + + def test_remove_line_by_line1d(self): + v = _plot() + line = v.add_line(np.cos(t)) + v.remove_line(line) + assert len(v._state["extra_lines"]) == 0 + + def test_remove_line_bad_id(self): + v = _plot() + with pytest.raises(KeyError): + v.remove_line("nonexistent") + + def test_clear_lines(self): + v = _plot() + v.add_line(np.cos(t)) + v.add_line(np.cos(2 * t)) + v.clear_lines() + assert v._state["extra_lines"] == [] + + def test_data_range_expands_for_overlay(self): + v = _plot() + old_max = v._state["data_max"] + v.add_line(np.sin(t) + 5) # shifted much higher + assert v._state["data_max"] > old_max + + def test_line1d_set_data(self): + v = _plot() + line = v.add_line(np.cos(t)) + new_y = np.zeros(128) + line.set_data(new_y) + entry = next(e for e in v._state["extra_lines"] if e["id"] == line.id) + np.testing.assert_allclose(entry["data"], new_y) + + def test_line1d_set_data_primary_raises(self): + v = _plot() + primary = Line1D(v, None) + with pytest.raises(ValueError, match="primary line"): + primary.set_data(np.zeros(10)) + + def test_line1d_set_data_bad_id_raises(self): + v = _plot() + phantom = Line1D(v, "deadbeef") + with pytest.raises(KeyError): + phantom.set_data(np.zeros(128)) + + def test_line1d_remove(self): + v = _plot() + line = v.add_line(np.cos(t)) + line.remove() + assert len(v._state["extra_lines"]) == 0 + + def test_line1d_remove_primary_raises(self): + v = _plot() + primary = Line1D(v, None) + with pytest.raises(ValueError): + primary.remove() + + def test_line1d_eq_str(self): + v = _plot() + line = v.add_line(np.cos(t)) + assert line == line.id + assert not (line == "other") + + def test_line1d_hash(self): + v = _plot() + line = v.add_line(np.cos(t)) + d = {line: "val"} + assert d[line] == "val" + + def test_line1d_str(self): + v = _plot() + line = v.add_line(np.cos(t)) + assert str(line) == line.id + + +# --------------------------------------------------------------------------- +# Spans +# --------------------------------------------------------------------------- + +class TestPlot1DSpans: + + def test_add_span_returns_id(self): + v = _plot() + sid = v.add_span(1.0, 2.0) + assert isinstance(sid, str) + assert len(v._state["spans"]) == 1 + + def test_add_span_y_axis(self): + v = _plot() + v.add_span(0.5, 0.8, axis="y", color="#ff0000") + assert v._state["spans"][0]["axis"] == "y" + + def test_remove_span(self): + v = _plot() + sid = v.add_span(1.0, 2.0) + v.remove_span(sid) + assert v._state["spans"] == [] + + def test_remove_span_bad_id(self): + v = _plot() + with pytest.raises(KeyError): + v.remove_span("nonexistent") + + def test_clear_spans(self): + v = _plot() + v.add_span(1.0, 2.0) + v.add_span(3.0, 4.0) + v.clear_spans() + assert v._state["spans"] == [] + + +# --------------------------------------------------------------------------- +# Widgets +# --------------------------------------------------------------------------- + +class TestPlot1DWidgets: + + def test_add_vline_widget(self): + v = _plot() + w = v.add_vline_widget(1.5, color="#ff6e40") + assert w is not None + assert len(v._widgets) == 1 + + def test_add_hline_widget(self): + v = _plot() + w = v.add_hline_widget(0.5) + assert len(v._widgets) == 1 + + def test_add_range_widget(self): + v = _plot() + w = v.add_range_widget(1.0, 3.0) + assert len(v._widgets) == 1 + + def test_get_widget_by_id(self): + v = _plot() + w = v.add_vline_widget(1.0) + assert v.get_widget(w.id) is w + + def test_get_widget_by_widget(self): + v = _plot() + w = v.add_vline_widget(1.0) + assert v.get_widget(w) is w + + def test_get_widget_missing(self): + v = _plot() + with pytest.raises(KeyError): + v.get_widget("bad_id") + + def test_remove_widget(self): + v = _plot() + w = v.add_vline_widget(1.0) + v.remove_widget(w) + assert len(v._widgets) == 0 + + def test_remove_widget_missing(self): + v = _plot() + with pytest.raises(KeyError): + v.remove_widget("bad_id") + + def test_list_widgets(self): + v = _plot() + w1 = v.add_vline_widget(1.0) + w2 = v.add_hline_widget(0.5) + wlist = v.list_widgets() + assert len(wlist) == 2 + + def test_clear_widgets(self): + v = _plot() + v.add_vline_widget(1.0) + v.add_hline_widget(0.5) + v.clear_widgets() + assert v.list_widgets() == [] + + +# --------------------------------------------------------------------------- +# Marker helpers (add_points, add_vlines, add_hlines, list_markers) +# --------------------------------------------------------------------------- + +class TestPlot1DMarkerHelpersExtras: + + def test_add_points_with_facecolors(self): + v = _plot() + offsets = np.column_stack([[1.0, 2.0], [0.5, 0.8]]) + v.add_points(offsets, name="peaks", sizes=7, + color="#ff1744", facecolors="#ff174433") + wl = v.markers.to_wire_list() + assert any(w["type"] == "points" for w in wl) + + def test_list_markers_count(self): + v = _plot() + offsets = np.column_stack([[1.0, 2.0, 3.0], [0.1, 0.2, 0.3]]) + v.add_points(offsets, name="pts") + info = v.list_markers() + assert any(d["name"] == "pts" and d["n"] == 3 for d in info) + + def test_remove_marker_1d(self): + v = _plot() + v.add_vlines([1.0, 2.0], name="m") + v.remove_marker("vlines", "m") + assert v.markers.to_wire_list() == [] + + def test_clear_markers_1d(self): + v = _plot() + v.add_vlines([1.0], name="v") + v.add_hlines([0.5], name="h") + v.clear_markers() + assert v.markers.to_wire_list() == [] + diff --git a/tests/test_plot3d.py b/tests/test_plot3d.py new file mode 100644 index 0000000..c113792 --- /dev/null +++ b/tests/test_plot3d.py @@ -0,0 +1,198 @@ +""" +tests/test_plot3d.py +==================== + +Tests for Plot3D — surface, scatter, and line geometry types. +Mirrors the Examples/plot_3d.py gallery example. + +Covers: + * plot_surface with 2-D meshgrid arrays + * scatter3d + * plot3d (line) + * set_data() — replace geometry + * set_colormap() — change colormap + * set_view() — azimuth and elevation + * set_zoom() + * State dict keys and shape sanity checks + * Validation: bad geom_type, bad surface array shapes +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.figure_plots import Plot3D + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _surface(): + x = np.linspace(-2, 2, 10) + y = np.linspace(-2, 2, 10) + XX, YY = np.meshgrid(x, y) + ZZ = np.sin(np.sqrt(XX ** 2 + YY ** 2)) + fig, ax = apl.subplots(1, 1) + return ax.plot_surface(XX, YY, ZZ, colormap="viridis"), XX, YY, ZZ + + +def _scatter(): + rng = np.random.default_rng(1) + n = 50 + x, y, z = rng.uniform(-1, 1, n), rng.uniform(-1, 1, n), rng.uniform(-1, 1, n) + fig, ax = apl.subplots(1, 1) + return ax.scatter3d(x, y, z, color="#4fc3f7", point_size=3), x, y, z + + +def _line(): + t = np.linspace(0, 4 * np.pi, 50) + x, y, z = np.cos(t), np.sin(t), t / (4 * np.pi) + fig, ax = apl.subplots(1, 1) + return ax.plot3d(x, y, z, color="#ff7043"), x, y, z + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + +class TestPlot3DConstruction: + + def test_surface_kind(self): + surf, *_ = _surface() + assert surf._state["kind"] == "3d" + assert surf._state["geom_type"] == "surface" + + def test_scatter_kind(self): + sc, *_ = _scatter() + assert sc._state["geom_type"] == "scatter" + + def test_line_kind(self): + ln, *_ = _line() + assert ln._state["geom_type"] == "line" + + def test_surface_has_vertices(self): + surf, *_ = _surface() + assert surf._state["vertices_count"] == 100 # 10×10 grid + + def test_surface_has_faces(self): + surf, *_ = _surface() + assert surf._state["faces_count"] > 0 + + def test_scatter_no_faces(self): + sc, *_ = _scatter() + assert sc._state["faces_count"] == 0 + + def test_colormap_name_stored(self): + surf, *_ = _surface() + assert surf._state["colormap_name"] == "viridis" + + def test_colormap_data_is_list(self): + surf, *_ = _surface() + lut = surf._state["colormap_data"] + assert isinstance(lut, list) + assert len(lut) == 256 + + def test_default_azimuth_elevation(self): + surf, *_ = _surface() + assert surf._state["azimuth"] == pytest.approx(-60.0) + assert surf._state["elevation"] == pytest.approx(30.0) + + def test_labels_stored(self): + x = np.linspace(-1, 1, 5) + y = np.linspace(-1, 1, 5) + XX, YY = np.meshgrid(x, y) + ZZ = XX * YY + fig, ax = apl.subplots(1, 1) + surf = ax.plot_surface(XX, YY, ZZ, x_label="a", y_label="b", z_label="c") + assert surf._state["x_label"] == "a" + assert surf._state["y_label"] == "b" + assert surf._state["z_label"] == "c" + + def test_bad_geom_type(self): + x = np.array([0.0, 1.0]) + with pytest.raises(ValueError): + Plot3D("cube", x, x, x) + + def test_surface_1d_xy_arrays(self): + """plot_surface also accepts 1-D x/y + 2-D z (meshgrid already done).""" + x = np.linspace(-1, 1, 5) + y = np.linspace(-1, 1, 5) + ZZ = np.ones((5, 5)) + fig, ax = apl.subplots(1, 1) + surf = ax.plot_surface(x, y, ZZ) + assert surf._state["vertices_count"] == 25 + + def test_surface_1d_xy_shape_mismatch(self): + x = np.linspace(-1, 1, 4) + y = np.linspace(-1, 1, 5) + ZZ = np.ones((5, 5)) + with pytest.raises(ValueError): + fig, ax = apl.subplots(1, 1) + ax.plot_surface(x, y, ZZ) + + def test_surface_bad_array_shape(self): + x = np.array([1.0, 2.0]) # 1-D but z is also 1-D → invalid + with pytest.raises(ValueError): + Plot3D("surface", x, x, x) + + +# --------------------------------------------------------------------------- +# Mutations +# --------------------------------------------------------------------------- + +class TestPlot3DMutations: + + def test_set_colormap(self): + surf, *_ = _surface() + surf.set_colormap("plasma") + assert surf._state["colormap_name"] == "plasma" + assert isinstance(surf._state["colormap_data"], list) + + def test_set_view_azimuth(self): + surf, *_ = _surface() + surf.set_view(azimuth=45.0) + assert surf._state["azimuth"] == pytest.approx(45.0) + + def test_set_view_elevation(self): + surf, *_ = _surface() + surf.set_view(elevation=60.0) + assert surf._state["elevation"] == pytest.approx(60.0) + + def test_set_view_both(self): + surf, *_ = _surface() + surf.set_view(azimuth=30.0, elevation=40.0) + assert surf._state["azimuth"] == pytest.approx(30.0) + assert surf._state["elevation"] == pytest.approx(40.0) + + def test_set_zoom(self): + surf, *_ = _surface() + surf.set_zoom(2.0) + assert surf._state["zoom"] == pytest.approx(2.0) + + def test_set_data_surface(self): + surf, XX, YY, ZZ = _surface() + ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2)) + surf.set_data(XX, YY, ZZ2) + # vertices_count should stay the same (same grid) + assert surf._state["vertices_count"] == 100 + + def test_set_data_scatter(self): + sc, x, y, z = _scatter() + sc.set_data(x * 2, y * 2, z * 2) + bounds = sc._state["data_bounds"] + assert bounds["xmax"] > bounds["xmin"] + + def test_set_data_line(self): + ln, x, y, z = _line() + ln.set_data(x[::-1], y[::-1], z[::-1]) + assert ln._state["vertices_count"] == len(x) + + def test_set_data_surface_bad_shape(self): + surf, XX, YY, ZZ = _surface() + x = np.array([1.0, 2.0]) + with pytest.raises(ValueError): + surf.set_data(x, x, x) + + diff --git a/tests/test_plotbar_extras.py b/tests/test_plotbar_extras.py new file mode 100644 index 0000000..1f1d428 --- /dev/null +++ b/tests/test_plotbar_extras.py @@ -0,0 +1,315 @@ +""" +tests/test_plotbar_extras.py +============================= + +Tests for PlotBar features exercised in Examples/plot_bar.py but not yet +covered by the existing test_bar.py. + +Covers: + * New matplotlib-aligned API: bar(x, height, width, ...) + * String x → category labels auto-detected + * Grouped bars — 2-D height, group_labels, group_colors + * Horizontal orientation (orient='h') + * log_scale at construction time + * set_data() with new x / x_labels + * set_color(), set_colors(), set_show_values(), set_log_scale() + * add_vline_widget() / add_hline_widget() / add_range_widget() / add_point_widget() + * Widget management: get_widget, remove_widget, list_widgets, clear_widgets + * on_click callback registration / disconnect + * on_changed callback + * repr +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.figure_plots import PlotBar + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _bar(x, height=None, **kwargs) -> PlotBar: + fig, ax = apl.subplots(1, 1) + if height is not None: + return ax.bar(x, height, **kwargs) + return ax.bar(x, **kwargs) + + +# --------------------------------------------------------------------------- +# New API — bar(x, height, ...) +# --------------------------------------------------------------------------- + +class TestPlotBarNewAPI: + + def test_string_x_becomes_labels(self): + months = ["Jan", "Feb", "Mar"] + bar = _bar(months, [10, 20, 30]) + assert bar._state["x_labels"] == months + + def test_numeric_x_becomes_centers(self): + x = [0.0, 1.0, 2.0] + bar = _bar(x, [10, 20, 30]) + assert bar._state["x_centers"] == pytest.approx(x) + + def test_width_kwarg(self): + bar = _bar([1, 2, 3], [10, 20, 30], width=0.4) + assert bar._state["bar_width"] == pytest.approx(0.4) + + def test_bottom_kwarg(self): + bar = _bar([1, 2, 3], [10, 20, 30], bottom=5.0) + assert bar._state["baseline"] == pytest.approx(5.0) + + def test_show_values_kwarg(self): + bar = _bar([1, 2, 3], [10, 20, 30], show_values=True) + assert bar._state["show_values"] is True + + def test_orient_h(self): + bar = _bar(["A", "B"], [10, 20], orient="h") + assert bar._state["orient"] == "h" + + def test_orient_v_default(self): + bar = _bar([1, 2], [5, 6]) + assert bar._state["orient"] == "v" + + def test_orient_invalid(self): + with pytest.raises(ValueError): + _bar([1, 2], [5, 6], orient="diagonal") + + def test_per_bar_colors(self): + palette = ["#ff0000", "#00ff00", "#0000ff"] + bar = _bar([1, 2, 3], [10, 20, 30], colors=palette) + assert bar._state["bar_colors"] == palette + + +# --------------------------------------------------------------------------- +# Grouped bars +# --------------------------------------------------------------------------- + +class TestPlotBarGrouped: + + def test_grouped_2d_height(self): + data = np.array([[1, 2, 3], [4, 5, 6]], dtype=float) + bar = _bar(["A", "B"], data, group_labels=["G1", "G2", "G3"]) + assert bar._state["groups"] == 3 + assert bar._state["group_labels"] == ["G1", "G2", "G3"] + + def test_grouped_default_colors_assigned(self): + data = np.ones((3, 2)) + bar = _bar([1, 2, 3], data) + assert len(bar._state["group_colors"]) == 2 + + def test_grouped_custom_colors(self): + data = np.ones((3, 2)) + bar = _bar([1, 2, 3], data, group_colors=["#aaa", "#bbb"]) + assert bar._state["group_colors"] == ["#aaa", "#bbb"] + + def test_grouped_3d_raises(self): + with pytest.raises(ValueError): + _bar([1], np.ones((1, 2, 3))) + + def test_set_data_group_mismatch(self): + data = np.ones((3, 2)) + bar = _bar([1, 2, 3], data) + with pytest.raises(ValueError, match="Group count"): + bar.set_data(np.ones((3, 3))) # 3 groups vs original 2 + + +# --------------------------------------------------------------------------- +# Log scale +# --------------------------------------------------------------------------- + +class TestPlotBarLogScale: + + def test_log_scale_construction(self): + bar = _bar(["A", "B", "C", "D", "E"], + [1, 10, 100, 1000, 10000], log_scale=True) + assert bar._state["log_scale"] is True + + def test_set_log_scale_on(self): + bar = _bar([1, 2, 3], [1, 10, 100]) + bar.set_log_scale(True) + assert bar._state["log_scale"] is True + + def test_set_log_scale_off(self): + bar = _bar([1, 2, 3], [1, 10, 100], log_scale=True) + bar.set_log_scale(False) + assert bar._state["log_scale"] is False + + +# --------------------------------------------------------------------------- +# set_data +# --------------------------------------------------------------------------- + +class TestPlotBarSetData: + + def test_set_data_updates_values(self): + bar = _bar([1, 2, 3], [10, 20, 30]) + bar.set_data([5, 15, 25]) + assert bar._state["values"] == [[5], [15], [25]] + + def test_set_data_recalculates_range(self): + bar = _bar([1, 2, 3], [10, 20, 30]) + old_max = bar._state["data_max"] + bar.set_data([100, 200, 300]) + assert bar._state["data_max"] > old_max + + def test_set_data_with_new_x_labels(self): + bar = _bar(["A", "B"], [1, 2]) + bar.set_data([3, 4], x_labels=["X", "Y"]) + assert bar._state["x_labels"] == ["X", "Y"] + + def test_set_data_with_x_centers(self): + bar = _bar([0, 1], [10, 20]) + bar.set_data([30, 40], x=[5, 10]) + assert bar._state["x_centers"] == [5, 10] + + def test_set_data_bad_ndim(self): + bar = _bar([1, 2], [10, 20]) + with pytest.raises(ValueError): + bar.set_data(np.ones((2, 2, 2))) + + +# --------------------------------------------------------------------------- +# Display setters +# --------------------------------------------------------------------------- + +class TestPlotBarDisplaySetters: + + def test_set_color(self): + bar = _bar([1, 2], [10, 20]) + bar.set_color("#ff7043") + assert bar._state["bar_color"] == "#ff7043" + + def test_set_colors(self): + bar = _bar([1, 2, 3], [10, 20, 30]) + bar.set_colors(["#r", "#g", "#b"]) + assert bar._state["bar_colors"] == ["#r", "#g", "#b"] + + def test_set_show_values_true(self): + bar = _bar([1, 2], [10, 20]) + bar.set_show_values(True) + assert bar._state["show_values"] is True + + def test_set_show_values_false(self): + bar = _bar([1, 2], [10, 20], show_values=True) + bar.set_show_values(False) + assert bar._state["show_values"] is False + + +# --------------------------------------------------------------------------- +# Widgets on PlotBar +# --------------------------------------------------------------------------- + +class TestPlotBarWidgets: + + def test_add_vline_widget(self): + bar = _bar(["A", "B", "C"], [10, 20, 30]) + w = bar.add_vline_widget(1.5, color="#ff6e40") + assert len(bar._widgets) == 1 + + def test_add_hline_widget(self): + bar = _bar([1, 2, 3], [10, 20, 30]) + w = bar.add_hline_widget(15.0) + assert len(bar._widgets) == 1 + + def test_add_range_widget(self): + bar = _bar([1, 2, 3], [10, 20, 30]) + w = bar.add_range_widget(0.5, 2.5) + assert len(bar._widgets) == 1 + + def test_add_point_widget(self): + bar = _bar([1, 2, 3], [10, 20, 30]) + w = bar.add_point_widget(1.0, 15.0) + assert len(bar._widgets) == 1 + + def test_get_widget_by_id(self): + bar = _bar([1, 2], [10, 20]) + w = bar.add_vline_widget(1.0) + assert bar.get_widget(w.id) is w + + def test_get_widget_missing(self): + bar = _bar([1, 2], [10, 20]) + with pytest.raises(KeyError): + bar.get_widget("nope") + + def test_remove_widget(self): + bar = _bar([1, 2], [10, 20]) + w = bar.add_vline_widget(1.0) + bar.remove_widget(w) + assert len(bar._widgets) == 0 + + def test_remove_widget_missing(self): + bar = _bar([1, 2], [10, 20]) + with pytest.raises(KeyError): + bar.remove_widget("bad") + + def test_list_widgets(self): + bar = _bar([1, 2], [10, 20]) + bar.add_vline_widget(1.0) + bar.add_hline_widget(5.0) + assert len(bar.list_widgets()) == 2 + + def test_clear_widgets(self): + bar = _bar([1, 2], [10, 20]) + bar.add_vline_widget(1.0) + bar.clear_widgets() + assert bar.list_widgets() == [] + + +# --------------------------------------------------------------------------- +# Callbacks +# --------------------------------------------------------------------------- + +class TestPlotBarCallbacks: + + def test_on_click_registration(self): + from anyplotlib.callbacks import Event + bar = _bar([1, 2, 3], [10, 20, 30]) + fired = [] + + @bar.on_click + def cb(event): + fired.append(event) + + bar.callbacks.fire(Event("on_click", bar, {"bar_index": 0})) + assert len(fired) == 1 + assert fired[0].bar_index == 0 + + def test_on_changed_registration(self): + from anyplotlib.callbacks import Event + bar = _bar([1, 2, 3], [10, 20, 30]) + fired = [] + + @bar.on_changed + def cb(event): + fired.append(event) + + bar.callbacks.fire(Event("on_changed", bar, {"view_x0": 0.1})) + assert len(fired) == 1 + + def test_disconnect(self): + from anyplotlib.callbacks import Event + bar = _bar([1, 2], [5, 6]) + fired = [] + + @bar.on_click + def cb(event): + fired.append(1) + + bar.disconnect(cb._cid) + bar.callbacks.fire(Event("on_click", bar, {})) + assert fired == [] + + def test_repr(self): + bar = _bar([1, 2, 3], [10, 20, 30]) + r = repr(bar) + assert "PlotBar" in r + + + + + From bb191af931b6a59254f5bb8d181cb0e8b9059e02 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 19 Apr 2026 22:31:36 -0500 Subject: [PATCH 3/3] Add Playwright browser installation step in CI configuration --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cda664..4ea5770 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Install dependencies run: uv sync --group dev + - name: Install Playwright browsers + run: uv run playwright install --with-deps chromium + - name: Run tests with coverage run: uv run pytest @@ -33,3 +36,4 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +