diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4ea5770..0b7e206 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,13 +11,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Set up Python
- uses: actions/setup-python@v5
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
-
- - name: Install uv
- uses: astral-sh/setup-uv@v4
+ enable-cache: true
- name: Install dependencies
run: uv sync --group dev
@@ -29,11 +27,9 @@ jobs:
run: uv run pytest
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
files: coverage.xml
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
-
-
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5e02c21..32cbf62 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -51,7 +51,7 @@ jobs:
run: uv run playwright install chromium
- name: Run tests
- run: uv run pytest tests/ -v --tb=short
+ run: uv run pytest anyplotlib/tests/ -v --tb=short
minimum-deps:
name: Minimum deps (Python 3.10 / ubuntu)
@@ -77,4 +77,4 @@ jobs:
run: uv run playwright install chromium --with-deps
- name: Run tests
- run: uv run pytest tests/ -v --tb=short
+ run: uv run pytest anyplotlib/tests/ -v --tb=short
diff --git a/anyplotlib/tests/__init__.py b/anyplotlib/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/_png_utils.py b/anyplotlib/tests/_png_utils.py
similarity index 100%
rename from tests/_png_utils.py
rename to anyplotlib/tests/_png_utils.py
diff --git a/tests/baselines/bar_basic.png b/anyplotlib/tests/baselines/bar_basic.png
similarity index 100%
rename from tests/baselines/bar_basic.png
rename to anyplotlib/tests/baselines/bar_basic.png
diff --git a/tests/baselines/imshow_checkerboard.png b/anyplotlib/tests/baselines/imshow_checkerboard.png
similarity index 100%
rename from tests/baselines/imshow_checkerboard.png
rename to anyplotlib/tests/baselines/imshow_checkerboard.png
diff --git a/tests/baselines/imshow_gradient.png b/anyplotlib/tests/baselines/imshow_gradient.png
similarity index 100%
rename from tests/baselines/imshow_gradient.png
rename to anyplotlib/tests/baselines/imshow_gradient.png
diff --git a/tests/baselines/imshow_viridis.png b/anyplotlib/tests/baselines/imshow_viridis.png
similarity index 100%
rename from tests/baselines/imshow_viridis.png
rename to anyplotlib/tests/baselines/imshow_viridis.png
diff --git a/tests/baselines/inset_1d.png b/anyplotlib/tests/baselines/inset_1d.png
similarity index 100%
rename from tests/baselines/inset_1d.png
rename to anyplotlib/tests/baselines/inset_1d.png
diff --git a/tests/baselines/inset_maximized.png b/anyplotlib/tests/baselines/inset_maximized.png
similarity index 100%
rename from tests/baselines/inset_maximized.png
rename to anyplotlib/tests/baselines/inset_maximized.png
diff --git a/tests/baselines/inset_minimized.png b/anyplotlib/tests/baselines/inset_minimized.png
similarity index 100%
rename from tests/baselines/inset_minimized.png
rename to anyplotlib/tests/baselines/inset_minimized.png
diff --git a/tests/baselines/inset_normal_2d.png b/anyplotlib/tests/baselines/inset_normal_2d.png
similarity index 100%
rename from tests/baselines/inset_normal_2d.png
rename to anyplotlib/tests/baselines/inset_normal_2d.png
diff --git a/tests/baselines/inset_stacked.png b/anyplotlib/tests/baselines/inset_stacked.png
similarity index 100%
rename from tests/baselines/inset_stacked.png
rename to anyplotlib/tests/baselines/inset_stacked.png
diff --git a/tests/baselines/inset_stacked_one_minimized.png b/anyplotlib/tests/baselines/inset_stacked_one_minimized.png
similarity index 100%
rename from tests/baselines/inset_stacked_one_minimized.png
rename to anyplotlib/tests/baselines/inset_stacked_one_minimized.png
diff --git a/tests/baselines/pcolormesh_uniform.png b/anyplotlib/tests/baselines/pcolormesh_uniform.png
similarity index 100%
rename from tests/baselines/pcolormesh_uniform.png
rename to anyplotlib/tests/baselines/pcolormesh_uniform.png
diff --git a/tests/baselines/plot1d_all_linestyles.png b/anyplotlib/tests/baselines/plot1d_all_linestyles.png
similarity index 100%
rename from tests/baselines/plot1d_all_linestyles.png
rename to anyplotlib/tests/baselines/plot1d_all_linestyles.png
diff --git a/tests/baselines/plot1d_alpha.png b/anyplotlib/tests/baselines/plot1d_alpha.png
similarity index 100%
rename from tests/baselines/plot1d_alpha.png
rename to anyplotlib/tests/baselines/plot1d_alpha.png
diff --git a/tests/baselines/plot1d_dashed.png b/anyplotlib/tests/baselines/plot1d_dashed.png
similarity index 100%
rename from tests/baselines/plot1d_dashed.png
rename to anyplotlib/tests/baselines/plot1d_dashed.png
diff --git a/tests/baselines/plot1d_marker_symbols.png b/anyplotlib/tests/baselines/plot1d_marker_symbols.png
similarity index 100%
rename from tests/baselines/plot1d_marker_symbols.png
rename to anyplotlib/tests/baselines/plot1d_marker_symbols.png
diff --git a/tests/baselines/plot1d_markers.png b/anyplotlib/tests/baselines/plot1d_markers.png
similarity index 100%
rename from tests/baselines/plot1d_markers.png
rename to anyplotlib/tests/baselines/plot1d_markers.png
diff --git a/tests/baselines/plot1d_multi.png b/anyplotlib/tests/baselines/plot1d_multi.png
similarity index 100%
rename from tests/baselines/plot1d_multi.png
rename to anyplotlib/tests/baselines/plot1d_multi.png
diff --git a/tests/baselines/plot1d_sine.png b/anyplotlib/tests/baselines/plot1d_sine.png
similarity index 100%
rename from tests/baselines/plot1d_sine.png
rename to anyplotlib/tests/baselines/plot1d_sine.png
diff --git a/tests/baselines/plot3d_surface.png b/anyplotlib/tests/baselines/plot3d_surface.png
similarity index 100%
rename from tests/baselines/plot3d_surface.png
rename to anyplotlib/tests/baselines/plot3d_surface.png
diff --git a/tests/baselines/subplots_2x1.png b/anyplotlib/tests/baselines/subplots_2x1.png
similarity index 100%
rename from tests/baselines/subplots_2x1.png
rename to anyplotlib/tests/baselines/subplots_2x1.png
diff --git a/tests/conftest.py b/anyplotlib/tests/conftest.py
similarity index 99%
rename from tests/conftest.py
rename to anyplotlib/tests/conftest.py
index 8f7cb8d..4284305 100644
--- a/tests/conftest.py
+++ b/anyplotlib/tests/conftest.py
@@ -167,7 +167,7 @@ def _build_ready_html(widget):
def _screenshot_widget(browser, widget):
"""Render *widget* in headless Chromium; return an H×W×C uint8 ndarray."""
- from tests._png_utils import decode_png
+ from anyplotlib.tests._png_utils import decode_png
html = _build_ready_html(widget)
diff --git a/anyplotlib/tests/test_benchmarks/__init__.py b/anyplotlib/tests/test_benchmarks/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/benchmarks/baselines.json b/anyplotlib/tests/test_benchmarks/baselines.json
similarity index 100%
rename from tests/benchmarks/baselines.json
rename to anyplotlib/tests/test_benchmarks/baselines.json
diff --git a/tests/test_benchmarks.py b/anyplotlib/tests/test_benchmarks/test_benchmarks.py
similarity index 99%
rename from tests/test_benchmarks.py
rename to anyplotlib/tests/test_benchmarks/test_benchmarks.py
index 40a7060..f2aa466 100644
--- a/tests/test_benchmarks.py
+++ b/anyplotlib/tests/test_benchmarks/test_benchmarks.py
@@ -57,7 +57,7 @@
import pytest
import anyplotlib as apl
-from tests.conftest import _run_bench
+from anyplotlib.tests.conftest import _run_bench
# ── constants ────────────────────────────────────────────────────────────────
BASELINES_PATH = pathlib.Path(__file__).parent / "benchmarks" / "baselines.json"
diff --git a/tests/test_benchmarks_py.py b/anyplotlib/tests/test_benchmarks/test_benchmarks_py.py
similarity index 100%
rename from tests/test_benchmarks_py.py
rename to anyplotlib/tests/test_benchmarks/test_benchmarks_py.py
diff --git a/anyplotlib/tests/test_documentation/__init__.py b/anyplotlib/tests/test_documentation/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/anyplotlib/tests/test_documentation/test_bridge.py b/anyplotlib/tests/test_documentation/test_bridge.py
new file mode 100644
index 0000000..08ce1a6
--- /dev/null
+++ b/anyplotlib/tests/test_documentation/test_bridge.py
@@ -0,0 +1,572 @@
+"""
+tests/test_documentation/test_bridge.py
+========================================
+
+Browser-based end-to-end tests for the Pyodide live-documentation bridge.
+
+Requires Playwright (skipped automatically when not installed). Two tiers:
+
+Tier 2 -- **iframe postMessage tests**
+ Open a standalone figure HTML as a top-level page, fire ``awi_state``
+ postMessages directly, and assert the model updates.
+ No Pyodide, no HTTP server.
+
+Tier 3 -- **Full bridge mock-boot tests**
+ Build a ``parent.html`` page that includes the real ``anywidget_bridge.js``
+ but defines ``window.loadPyodide`` as a lightweight mock. The mock
+ exercises the complete JS boot sequence without downloading Pyodide WASM.
+ Pages are served over a local stdlib HTTP server.
+
+No-browser unit tests for ``_push()`` / ``_push_layout()`` live in
+``test_push_hook.py``.
+"""
+
+from __future__ import annotations
+
+import json
+import pathlib
+import socket
+import threading
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from html import escape as _html_escape
+from typing import Generator
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib._repr_utils import build_standalone_html
+
+pytest.importorskip("playwright", reason="playwright not installed")
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+
+_BRIDGE_JS = (
+ pathlib.Path(__file__).parent.parent.parent
+ / "sphinx_anywidget" / "static" / "anywidget_bridge.js"
+)
+
+# ---------------------------------------------------------------------------
+# Shared helpers
+# ---------------------------------------------------------------------------
+
+def _capture_fig_state(fig) -> dict:
+ """Return {trait_name: json_string} for layout + every panel trait."""
+ fig._push_layout()
+ for pid in list(fig._plots_map):
+ fig._push(pid)
+ captured = {"layout_json": fig.layout_json}
+ for tname in fig.trait_names():
+ if tname.startswith("panel_") and tname.endswith("_json"):
+ captured[tname] = getattr(fig, tname)
+ return captured
+
+
+def _patched_iframe_html(fig, fig_id: str) -> str:
+ """Return standalone HTML instrumented for Playwright.
+
+ Adds ``window._aplModel`` and ``window._aplReady`` sentinels.
+ """
+ html = build_standalone_html(fig, resizable=False, fig_id=fig_id)
+ html = html.replace(
+ "const model = makeModel(STATE);",
+ "const model = makeModel(STATE);\nwindow._aplModel = model;",
+ )
+ html = html.replace(
+ "renderFn({ model, el });",
+ "renderFn({ model, el }); window._aplReady = true;",
+ )
+ return html
+
+
+def _rafter(page) -> None:
+ page.evaluate("() => new Promise(r => requestAnimationFrame(r))")
+
+
+def _click_and_wait_boot(page, timeout: int = 15_000) -> None:
+ """Click the activate button and wait until data-state reaches 'active'."""
+ page.wait_for_function(
+ "() => !!document.querySelector('button.awi-activate-btn')",
+ timeout=timeout,
+ )
+ page.click("button.awi-activate-btn")
+ page.wait_for_function(
+ """() => {
+ const btn = document.querySelector('button.awi-activate-btn');
+ return btn && btn.dataset.state === 'active';
+ }""",
+ timeout=timeout,
+ )
+
+
+def _wait_for_iframe_model(page, fig_id: str, panel_id: str,
+ timeout: int = 10_000) -> None:
+ """Block until the iframe's model has a non-empty panel JSON."""
+ js = (
+ "() => {"
+ f" const iframe = document.querySelector('iframe[data-awi-fig=\"{fig_id}\"]');"
+ " if (!iframe || !iframe.contentWindow) return false;"
+ " const mdl = iframe.contentWindow._aplModel;"
+ " if (!mdl) return false;"
+ f" const raw = mdl.get('panel_{panel_id}_json');"
+ " return typeof raw === 'string' && raw.length > 10;"
+ "}"
+ )
+ page.wait_for_function(js, timeout=timeout)
+
+
+# ---------------------------------------------------------------------------
+# HTTP-server fixture (module-scoped)
+# ---------------------------------------------------------------------------
+
+@pytest.fixture(scope="module")
+def http_server(tmp_path_factory) -> Generator:
+ """Serve a temp directory over HTTP; yield (base_url, base_dir)."""
+ base_dir = tmp_path_factory.mktemp("bridge_server")
+
+ class _SilentHandler(SimpleHTTPRequestHandler):
+ def __init__(self, *a, **kw):
+ super().__init__(*a, directory=str(base_dir), **kw)
+
+ def log_message(self, *_):
+ pass
+
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+
+ srv = HTTPServer(("127.0.0.1", port), _SilentHandler)
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
+ t.start()
+ yield f"http://127.0.0.1:{port}", base_dir
+ srv.shutdown()
+
+
+# ---------------------------------------------------------------------------
+# Parent-page builder (Tier 3)
+# ---------------------------------------------------------------------------
+
+def _build_parent_page(
+ fig,
+ fig_id: str,
+ *,
+ base_dir: pathlib.Path,
+ python_src: str = "",
+) -> pathlib.Path:
+ """Write a complete mock-Pyodide parent page to *base_dir*."""
+ # Iframe HTML + real bridge script
+ (base_dir / f"{fig_id}.html").write_text(
+ _patched_iframe_html(fig, fig_id), encoding="utf-8"
+ )
+ (base_dir / "anywidget_bridge.js").write_text(
+ _BRIDGE_JS.read_text(encoding="utf-8"), encoding="utf-8"
+ )
+
+ # Capture real figure state
+ fig_state = _capture_fig_state(fig)
+ layout_value = fig_state.get("layout_json", "{}")
+ panel_entries = [
+ {"key": k, "value": v}
+ for k, v in fig_state.items()
+ if k.startswith("panel_")
+ ]
+ fig_w, fig_h = int(fig.fig_width), int(fig.fig_height)
+ if not python_src:
+ python_src = "# mock example\n"
+ data_src_attr = _html_escape(json.dumps(python_src), quote=True)
+
+ # Build mock loadPyodide JS as a list of lines to avoid quoting hell
+ mock_lines = [
+ "",
+ ]
+ mock_js = "\n".join(mock_lines)
+
+ parent_html = (
+ "\n
\n"
+ f"bridge test - {fig_id}\n"
+ f"{mock_js}\n"
+ "\n"
+ "\n"
+ "\n"
+ "\n"
+ f"
\n"
+ f"
\n"
+ f"
\n"
+ f" \n"
+ "
\n"
+ "
\n"
+ "
\n"
+ f"\n"
+ ""
+ )
+
+ parent_path = base_dir / f"{fig_id}_parent.html"
+ parent_path.write_text(parent_html, encoding="utf-8")
+ return parent_path
+
+
+# =============================================================================
+# Tier 2 -- iframe postMessage tests (browser only, no HTTP server)
+# =============================================================================
+
+class TestIframeMessaging:
+ """Verify the awi_state postMessage protocol via the standalone iframe.
+
+ The ``interact_page`` fixture opens the figure HTML as a top-level page
+ (``window.parent === window``), so outbound awi_event forwarding is
+ disabled. Tests focus on the *inbound* direction: awi_state updates the
+ model.
+ """
+
+ def _open_fig(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#4fc3f7")
+ panel_id = list(fig._plots_map.keys())[0]
+ plot = list(fig._plots_map.values())[0]
+ page = interact_page(fig)
+ return fig, plot, panel_id, page
+
+ def test_awi_state_updates_model_key(self, interact_page):
+ """Posting {type:'awi_state', key, value} updates the model."""
+ fig, plot, panel_id, page = self._open_fig(interact_page)
+ raw = page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
+ assert raw is not None
+ curr = json.loads(raw)
+ curr["__sentinel__"] = "hello"
+ new_json = json.dumps(curr)
+ page.evaluate(
+ "() => window.postMessage("
+ + json.dumps({"type": "awi_state",
+ "key": f"panel_{panel_id}_json",
+ "value": new_json})
+ + ", '*')"
+ )
+ _rafter(page)
+ updated = json.loads(
+ page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
+ )
+ assert updated.get("__sentinel__") == "hello"
+
+ def test_no_echo_in_standalone_mode(self, interact_page):
+ """No awi_event is echoed back in standalone mode (FIG_ID is null)."""
+ fig, plot, panel_id, page = self._open_fig(interact_page)
+ raw = json.loads(
+ page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
+ )
+ raw["__flag__"] = 1
+ new_json = json.dumps(raw)
+ page.evaluate(
+ "() => {"
+ " window._aplEventsSeen = 0;"
+ " window.addEventListener('message', (e) => {"
+ " if (e.data && e.data.type === 'awi_event') window._aplEventsSeen++;"
+ " });"
+ "}"
+ )
+ page.evaluate(
+ "() => window.postMessage("
+ + json.dumps({"type": "awi_state",
+ "key": f"panel_{panel_id}_json",
+ "value": new_json})
+ + ", '*')"
+ )
+ _rafter(page)
+ assert page.evaluate("() => window._aplEventsSeen") == 0
+
+ def test_awi_state_fires_change_listeners(self, interact_page):
+ """Posting awi_state triggers on('change:...') listeners."""
+ fig, plot, panel_id, page = self._open_fig(interact_page)
+ page.evaluate(
+ f"() => {{"
+ f" window._aplChangeCount = 0;"
+ f" window._aplModel.on('change:panel_{panel_id}_json',"
+ f" () => window._aplChangeCount++);"
+ f"}}"
+ )
+ raw = json.loads(
+ page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
+ )
+ raw["__change__"] = 1
+ new_json = json.dumps(raw)
+ page.evaluate(
+ "() => window.postMessage("
+ + json.dumps({"type": "awi_state",
+ "key": f"panel_{panel_id}_json",
+ "value": new_json})
+ + ", '*')"
+ )
+ _rafter(page)
+ assert page.evaluate("() => window._aplChangeCount") >= 1
+
+ def test_layout_json_push_updates_model(self, interact_page):
+ """layout_json can be updated via awi_state."""
+ fig, plot, panel_id, page = self._open_fig(interact_page)
+ layout = json.loads(
+ page.evaluate("() => window._aplModel.get('layout_json') || '{}'")
+ )
+ layout["__layout_sentinel__"] = "bridge_test"
+ new_json = json.dumps(layout)
+ page.evaluate(
+ "() => window.postMessage("
+ + json.dumps({"type": "awi_state", "key": "layout_json", "value": new_json})
+ + ", '*')"
+ )
+ _rafter(page)
+ updated = json.loads(
+ page.evaluate("() => window._aplModel.get('layout_json') || '{}'")
+ )
+ assert updated.get("__layout_sentinel__") == "bridge_test"
+
+
+# =============================================================================
+# Tier 3 -- Full bridge mock-boot tests (HTTP server + mock Pyodide)
+# =============================================================================
+
+class TestFullBridgeBoot:
+ """Boot anywidget_bridge.js end-to-end via a mock loadPyodide.
+
+ Each test builds a parent HTML page and serves it from the shared
+ ``http_server`` fixture. All Pyodide network I/O is replaced by the JS
+ mock so tests complete in milliseconds.
+ """
+
+ def _open(self, browser, base_url, parent_path, timeout=15_000):
+ url = f"{base_url}/{parent_path.name}"
+ page = browser.new_page()
+ page.goto(url, wait_until="domcontentloaded", timeout=timeout)
+ return page
+
+ def _basic_fig(self):
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#50fa7b")
+ panel_id = list(fig._plots_map.keys())[0]
+ return fig, panel_id
+
+ def test_button_appears_when_iframe_present(self, http_server, _pw_browser):
+ """The activate button is injected on any page with a data-awi-fig iframe."""
+ base_url, base_dir = http_server
+ fig, _ = self._basic_fig()
+ parent = _build_parent_page(fig, "btn_test_001", base_dir=base_dir)
+ page = self._open(_pw_browser, base_url, parent)
+ page.wait_for_function(
+ "() => !!document.querySelector('button.awi-activate-btn')",
+ timeout=5_000,
+ )
+ tooltip = page.evaluate(
+ "() => document.querySelector('button.awi-activate-btn').title"
+ )
+ assert "interactive" in tooltip.lower()
+ page.close()
+
+ def test_boot_completes_all_mock_steps(self, http_server, _pw_browser):
+ """Clicking the button runs through all expected mock Pyodide boot steps."""
+ base_url, base_dir = http_server
+ fig, _ = self._basic_fig()
+ parent = _build_parent_page(fig, "boot_test_001", base_dir=base_dir)
+ page = self._open(_pw_browser, base_url, parent)
+ _click_and_wait_boot(page)
+ steps = page.evaluate("() => window._APL_BOOT_STEPS")
+ for step in ("loadPyodide", "micropip_install", "stub_anywidget",
+ "install_monkey_patch", "run_example"):
+ assert step in steps, f"Step {step!r} missing; got {steps}"
+ page.close()
+
+ def test_anywidgetPush_is_function_after_boot(self, http_server, _pw_browser):
+ """window._anywidgetPush must be a function after the push-hook step."""
+ base_url, base_dir = http_server
+ fig, _ = self._basic_fig()
+ parent = _build_parent_page(fig, "apush_test_001", base_dir=base_dir)
+ page = self._open(_pw_browser, base_url, parent)
+ _click_and_wait_boot(page)
+ assert page.evaluate(
+ "() => typeof window._anywidgetPush === 'function'"
+ ), "window._anywidgetPush not installed"
+ page.close()
+
+ def test_state_pushed_into_iframe_model(self, http_server, _pw_browser):
+ """After boot the iframe's model contains the figure's panel JSON."""
+ base_url, base_dir = http_server
+ fig, panel_id = self._basic_fig()
+ expected = fig._plots_map[panel_id].to_state_dict()
+ parent = _build_parent_page(fig, "state_push_001", base_dir=base_dir)
+ page = self._open(_pw_browser, base_url, parent)
+ _click_and_wait_boot(page)
+ _wait_for_iframe_model(page, "state_push_001", panel_id)
+ raw = page.evaluate(
+ "() => {"
+ " const el = document.querySelector('iframe[data-awi-fig=\"state_push_001\"]');"
+ f" return el && el.contentWindow ? el.contentWindow._aplModel.get('panel_{panel_id}_json') : null;"
+ "}"
+ )
+ assert raw is not None, "panel JSON not delivered to iframe model"
+ assert json.loads(raw).get("kind") == expected.get("kind")
+ page.close()
+
+ def test_layout_json_pushed_into_iframe(self, http_server, _pw_browser):
+ """layout_json is delivered to the iframe model."""
+ base_url, base_dir = http_server
+ fig, _ = self._basic_fig()
+ parent = _build_parent_page(fig, "layout_push_001", base_dir=base_dir)
+ page = self._open(_pw_browser, base_url, parent)
+ _click_and_wait_boot(page)
+ page.wait_for_function(
+ "() => {"
+ " const el = document.querySelector('iframe[data-awi-fig=\"layout_push_001\"]');"
+ " if (!el || !el.contentWindow) return false;"
+ " const mdl = el.contentWindow._aplModel;"
+ " if (!mdl) return false;"
+ " const raw = mdl.get('layout_json');"
+ " return typeof raw === 'string' && raw.length > 10;"
+ "}",
+ timeout=8_000,
+ )
+ raw = page.evaluate(
+ "() => {"
+ " const el = document.querySelector('iframe[data-awi-fig=\"layout_push_001\"]');"
+ " return el.contentWindow._aplModel.get('layout_json');"
+ "}"
+ )
+ assert raw is not None
+ assert "panel_specs" in json.loads(raw)
+ page.close()
+
+ def test_event_message_forwarded_to_parent(self, http_server, _pw_browser):
+ """awi_event messages from the iframe arrive at the parent window."""
+ base_url, base_dir = http_server
+ fig, panel_id = self._basic_fig()
+ parent = _build_parent_page(fig, "event_fwd_001", base_dir=base_dir)
+ page = self._open(_pw_browser, base_url, parent)
+ _click_and_wait_boot(page)
+ page.evaluate(
+ "() => {"
+ " window._aplReceivedEvents = [];"
+ " window.addEventListener('message', (e) => {"
+ " if (e.data && e.data.type === 'awi_event')"
+ " window._aplReceivedEvents.push(e.data);"
+ " });"
+ "}"
+ )
+ fake_event = json.dumps({
+ "event_type": "on_release", "panel_id": panel_id,
+ "widget_id": "w_fake", "x": 42.0,
+ })
+ page.evaluate(
+ "() => window.postMessage("
+ + json.dumps({"type": "awi_event",
+ "figId": "event_fwd_001",
+ "data": fake_event})
+ + ", '*')"
+ )
+ _rafter(page)
+ events = page.evaluate("() => window._aplReceivedEvents")
+ assert len(events) >= 1, "No awi_event reached the parent message bus"
+ assert events[0]["figId"] == "event_fwd_001"
+ page.close()
+
+ def test_multiple_panels_all_receive_state(self, http_server, _pw_browser):
+ """All panels in a multi-panel figure have their state pushed."""
+ base_url, base_dir = http_server
+ fig, axes = apl.subplots(1, 2, figsize=(700, 300))
+ axes[0].plot(np.zeros(32))
+ axes[1].plot(np.ones(32) * 0.5)
+ panel_ids = list(fig._plots_map.keys())
+ assert len(panel_ids) == 2
+ parent = _build_parent_page(fig, "multi_panel_001", base_dir=base_dir)
+ page = self._open(_pw_browser, base_url, parent)
+ _click_and_wait_boot(page)
+ for pid in panel_ids:
+ _wait_for_iframe_model(page, "multi_panel_001", pid)
+ for pid in panel_ids:
+ raw = page.evaluate(
+ "() => {"
+ " const el = document.querySelector('iframe[data-awi-fig=\"multi_panel_001\"]');"
+ f" return el && el.contentWindow ? el.contentWindow._aplModel.get('panel_{pid}_json') : null;"
+ "}"
+ )
+ assert raw is not None, f"Panel {pid!r} state not pushed"
+ page.close()
+
+ def test_button_shows_error_on_boot_failure(self, http_server, _pw_browser):
+ """If Pyodide boot fails the button switches to the error state."""
+ base_url, base_dir = http_server
+ fig, _ = self._basic_fig()
+ parent = _build_parent_page(fig, "error_test_001", base_dir=base_dir)
+ html = (base_dir / "error_test_001_parent.html").read_text(encoding="utf-8")
+ # Patch mock to throw immediately on loadPyodide
+ html = html.replace(
+ "window.loadPyodide = async function() {",
+ "window.loadPyodide = async function() { throw new Error('mock boot failure'); //",
+ )
+ (base_dir / "error_test_001_parent.html").write_text(html, encoding="utf-8")
+ page = self._open(_pw_browser, base_url, parent)
+ page.wait_for_function(
+ "() => !!document.querySelector('button.awi-activate-btn')",
+ timeout=5_000,
+ )
+ page.click("button.awi-activate-btn")
+ page.wait_for_function(
+ "() => {"
+ " const btn = document.querySelector('button.awi-activate-btn');"
+ " return btn && btn.dataset.state === 'error';"
+ "}",
+ timeout=10_000,
+ )
+ label = page.evaluate(
+ "() => document.querySelector('button.awi-activate-btn').title"
+ )
+ assert "mock boot failure" in label
+ page.close()
diff --git a/anyplotlib/tests/test_documentation/test_push_hook.py b/anyplotlib/tests/test_documentation/test_push_hook.py
new file mode 100644
index 0000000..900d6da
--- /dev/null
+++ b/anyplotlib/tests/test_documentation/test_push_hook.py
@@ -0,0 +1,158 @@
+"""
+tests/test_documentation/test_push_hook.py
+==========================================
+
+Unit tests for the Python→JS state-push pathway.
+
+These tests require **no browser** — they call ``_push()`` / ``_push_layout()``
+directly and inspect the resulting traitlet values. They cover the same
+ground that older tests exercised via ``_pyodide_push_hook``; the hook is now
+gone and state flows through standard ``sync=True`` traitlets instead.
+
+Related browser tests (iframe postMessage, full mock-boot) live in
+``test_bridge.py``.
+"""
+
+from __future__ import annotations
+
+import json
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+import anyplotlib.figure as _af
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Helper shared by multiple tests
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _capture_fig_state(fig) -> dict[str, str]:
+ """Return ``{trait_name: json_string}`` for layout + every panel trait.
+
+ Reads traitlet values directly after calling the push methods. This
+ works even when the value hasn't changed (traitlets suppress duplicate
+ change events, so an observe-based approach would return nothing on a
+ second call with the same state).
+ """
+ fig._push_layout()
+ for pid in list(fig._plots_map):
+ fig._push(pid)
+
+ captured: dict[str, str] = {}
+ captured["layout_json"] = fig.layout_json
+ for tname in fig.trait_names():
+ if tname.startswith("panel_") and tname.endswith("_json"):
+ captured[tname] = getattr(fig, tname)
+ return captured
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Tests
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestPushHook:
+ """Verify _push() / _push_layout() write to sync=True traitlets correctly."""
+
+ def test_push_does_not_crash(self):
+ """Normal mode: _push() succeeds without error."""
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ ax.plot(np.zeros(16)) # must not raise
+
+ def test_layout_json_written_on_create(self):
+ """layout_json traitlet is set when a figure is created."""
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ parsed = json.loads(fig.layout_json)
+ assert "panel_specs" in parsed, (
+ f"layout_json missing 'panel_specs': {list(parsed.keys())}"
+ )
+
+ def test_panel_json_written_after_plot(self):
+ """panel_*_json traitlet is set when a plot is added."""
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)))
+
+ panel_keys = [
+ k for k in fig.trait_names()
+ if k.startswith("panel_") and k.endswith("_json")
+ ]
+ assert len(panel_keys) >= 1, "Expected at least one panel_*_json trait"
+ for k in panel_keys:
+ parsed = json.loads(getattr(fig, k))
+ assert "kind" in parsed, (
+ f"panel JSON missing 'kind': {list(parsed.keys())}"
+ )
+
+ def test_observe_fires_on_push(self):
+ """traitlets.observe() fires when _push() writes a panel trait."""
+ seen: list[str] = []
+
+ def _watch(change):
+ seen.append(change["name"])
+
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ fig.observe(_watch)
+ ax.plot(np.zeros(8))
+ fig.unobserve(_watch)
+
+ assert any(k.startswith("panel_") for k in seen), (
+ f"Expected a panel_* trait change; got: {seen}"
+ )
+
+ def test_panel_id_deterministic(self):
+ """Panel IDs derived from SubplotSpec must be identical across rebuilds."""
+ ids: list[str] = []
+ for _ in range(3):
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ ax.plot(np.zeros(8))
+ ids.append(list(fig._plots_map.keys())[0])
+ assert ids[0] == ids[1] == ids[2], (
+ f"Panel ID must be deterministic; got {ids}"
+ )
+
+ def test_panel_ids_unique_in_multiplot(self):
+ """Each panel in a multi-panel figure has a unique ID."""
+ fig, axes = apl.subplots(1, 3, figsize=(900, 300))
+ for ax in axes:
+ ax.plot(np.zeros(8))
+ ids = list(fig._plots_map.keys())
+ assert len(ids) == len(set(ids)), f"Panel IDs not unique: {ids}"
+
+ def test_panel_id_matches_grid_position(self):
+ """Panel IDs encode the SubplotSpec row/col bounds."""
+ fig, axes = apl.subplots(2, 2, figsize=(600, 400))
+ for ax in np.asarray(axes).flat:
+ ax.plot(np.zeros(4))
+ ids = set(fig._plots_map.keys())
+ for pid in ids:
+ assert pid.startswith("p"), f"Unexpected panel ID format: {pid!r}"
+
+ def test_dispatch_event_callable_without_kernel(self):
+ """_dispatch_event() can be called directly as the Pyodide bridge does."""
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ ax.plot(np.zeros(16))
+ raw = json.dumps({
+ "event_type": "on_zoom",
+ "panel_id": list(fig._plots_map.keys())[0],
+ "source": "js",
+ })
+ fig._dispatch_event(raw) # must not raise
+
+ def test_capture_fig_state_helper(self):
+ """_capture_fig_state returns both layout_json and panel JSON(s)."""
+ fig, ax = apl.subplots(1, 1, figsize=(400, 300))
+ ax.plot(np.zeros(32))
+ state = _capture_fig_state(fig)
+ assert "layout_json" in state, (
+ f"Expected layout_json; got {list(state.keys())}"
+ )
+ panel_keys = [k for k in state if k.startswith("panel_")]
+ assert len(panel_keys) >= 1, "Expected at least one panel_ key"
+
+ def test_no_pyodide_push_hook_attribute(self):
+ """figure module no longer exposes _pyodide_push_hook."""
+ assert not hasattr(_af, "_pyodide_push_hook"), (
+ "_pyodide_push_hook should not exist on figure module"
+ )
+
diff --git a/anyplotlib/tests/test_documentation/test_scraper.py b/anyplotlib/tests/test_documentation/test_scraper.py
new file mode 100644
index 0000000..a8f3803
--- /dev/null
+++ b/anyplotlib/tests/test_documentation/test_scraper.py
@@ -0,0 +1,123 @@
+"""
+tests/test_documentation/test_scraper.py
+=========================================
+
+Tests for the Playwright-based scraper thumbnail functionality.
+
+Two sections:
+
+1. **PNG format validation** — verifies ``_make_thumbnail_png`` returns a valid
+ PNG array for common figure types. No Playwright required.
+
+2. **Dark-theme validation** — checks the top-left pixel of the thumbnail is
+ dark-blue (matching the library's dark theme). Requires Playwright; skipped
+ automatically when not installed.
+"""
+
+from __future__ import annotations
+
+import importlib.util as _ilu
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png
+from anyplotlib.tests._png_utils import decode_png
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Shared fixtures
+# ─────────────────────────────────────────────────────────────────────────────
+
+@pytest.fixture
+def line_fig():
+ fig, ax = apl.subplots(1, 1, figsize=(400, 250))
+ ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128)), color="#4fc3f7")
+ return fig
+
+
+@pytest.fixture
+def imshow_fig():
+ fig, ax = apl.subplots(1, 1, figsize=(320, 320))
+ data = np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64)
+ ax.imshow(data)
+ return fig
+
+
+@pytest.fixture
+def multi_panel_fig():
+ fig, axes = apl.subplots(1, 2, figsize=(640, 300))
+ axes[0].plot(np.cos(np.linspace(0, 2 * np.pi, 64)))
+ axes[1].imshow(
+ np.random.default_rng(0).uniform(0, 1, (32, 32)).astype(np.float32)
+ )
+ return fig
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Helper
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _decode_thumbnail(fig, label: str):
+ """Return the decoded RGBA/RGB array for *fig*'s thumbnail, asserting PNG."""
+ png = _make_thumbnail_png(fig)
+ assert png[:4] == b"\x89PNG", f"[{label}] result is not a PNG"
+ arr = decode_png(png)
+ assert arr.ndim == 3, f"[{label}] expected H×W×C array, got shape {arr.shape}"
+ assert arr.shape[2] in (3, 4), (
+ f"[{label}] expected RGB/RGBA, got {arr.shape[2]} channels"
+ )
+ return arr
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Section 1 — PNG format validation (no Playwright required)
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestThumbnailFormat:
+ """Verify that _make_thumbnail_png produces a well-formed PNG for each
+ common figure type."""
+
+ def test_thumbnail_1d_line(self, line_fig):
+ _decode_thumbnail(line_fig, "1D line")
+
+ def test_thumbnail_2d_imshow(self, imshow_fig):
+ _decode_thumbnail(imshow_fig, "2D imshow")
+
+ def test_thumbnail_multi_panel(self, multi_panel_fig):
+ _decode_thumbnail(multi_panel_fig, "multi-panel")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Section 2 — Dark-theme pixel validation (requires Playwright)
+# ─────────────────────────────────────────────────────────────────────────────
+
+_requires_playwright = pytest.mark.skipif(
+ _ilu.find_spec("playwright") is None,
+ reason="playwright not installed",
+)
+
+
+@_requires_playwright
+class TestThumbnailDarkTheme:
+ """Verify the top-left pixel of each thumbnail is dark-blue, matching the
+ library's default dark theme. These tests are skipped when Playwright is
+ not installed."""
+
+ def _assert_dark_theme(self, fig, label: str) -> None:
+ arr = _decode_thumbnail(fig, label)
+ r, g, b = int(arr[0, 0, 0]), int(arr[0, 0, 1]), int(arr[0, 0, 2])
+ assert (b > r) and (b > 30), (
+ f"[{label}] expected a dark-theme thumbnail "
+ f"(top-left RGB=({r},{g},{b}))"
+ )
+
+ def test_dark_theme_1d_line(self, line_fig):
+ self._assert_dark_theme(line_fig, "1D line")
+
+ def test_dark_theme_2d_imshow(self, imshow_fig):
+ self._assert_dark_theme(imshow_fig, "2D imshow")
+
+ def test_dark_theme_multi_panel(self, multi_panel_fig):
+ self._assert_dark_theme(multi_panel_fig, "multi-panel")
diff --git a/tests/test_sphinx_anywidget.py b/anyplotlib/tests/test_documentation/test_sphinx_anywidget.py
similarity index 100%
rename from tests/test_sphinx_anywidget.py
rename to anyplotlib/tests/test_documentation/test_sphinx_anywidget.py
diff --git a/anyplotlib/tests/test_interactive/__init__.py b/anyplotlib/tests/test_interactive/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_events.py b/anyplotlib/tests/test_interactive/test_callbacks.py
similarity index 84%
rename from tests/test_events.py
rename to anyplotlib/tests/test_interactive/test_callbacks.py
index fd4fcec..dde8d60 100644
--- a/tests/test_events.py
+++ b/anyplotlib/tests/test_interactive/test_callbacks.py
@@ -1,16 +1,18 @@
"""
-tests/test_events.py
-====================
+tests/test_interactive/test_callbacks.py
+========================================
Tests for the unified object-level callback system.
+Covers:
* Event dataclass – event_type / source / data / attribute forwarding
* CallbackRegistry – connect / disconnect / fire (event_type dispatch only)
* Plot2D / Plot1D / PlotMesh / Plot3D – on_changed / on_release / on_click
- * Widget-level – @wid.on_changed / @wid.on_release / @wid.on_click
* Figure._on_event – JSON routing to widget + plot callbacks
* Practical patterns
- * Interactive FFT example – unit tests (pure Python, no browser)
+
+Widget-level callback and event-dispatch integration tests live in
+``test_widgets.py``.
"""
from __future__ import annotations
@@ -457,65 +459,34 @@ def cb(event): fired.append(event)
# ─────────────────────────────────────────────────────────────────────────────
-# 7. Widget-level callbacks (@wid.on_changed / on_release / on_click)
+# 7. Figure._on_event routing
# ─────────────────────────────────────────────────────────────────────────────
-class TestWidgetLevelCallbacks:
+class TestFigureEventRouting:
- def test_on_changed_fires_on_drag_frame(self):
+ def test_dispatch_reaches_plot_callbacks(self):
fig, ax = apl.subplots(1, 1)
v = ax.imshow(np.zeros((32, 32)))
- wid = v.add_widget("circle")
fired = []
- @wid.on_changed
+ @v.on_release
def cb(event): fired.append(event)
- _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=10.0, cy=20.0)
+ _simulate_js_event(fig, v, "on_release", cx=10.0, cy=20.0)
assert len(fired) == 1
assert fired[0].cx == pytest.approx(10.0)
- assert fired[0].source is wid
-
- def test_on_release_fires_on_mouseup(self):
- fig, ax = apl.subplots(1, 1)
- v = ax.imshow(np.zeros((32, 32)))
- wid = v.add_widget("rectangle")
- fired = []
-
- @wid.on_release
- def cb(event): fired.append(event)
-
- _simulate_js_event(fig, v, "on_release", widget_id=wid,
- x=5.0, y=5.0, w=20.0, h=20.0)
- assert len(fired) == 1
- assert fired[0].event_type == "on_release"
-
- def test_on_click_fires_without_state_change(self):
- """on_click must fire even when no field values changed."""
- fig, ax = apl.subplots(1, 1)
- v = ax.imshow(np.zeros((32, 32)))
- wid = v.add_widget("crosshair", cx=16.0, cy=16.0)
- fired = []
-
- @wid.on_click
- def cb(event): fired.append(event)
- _simulate_js_event(fig, v, "on_click", widget_id=wid, cx=16.0, cy=16.0)
- assert len(fired) == 1
-
- def test_on_changed_not_fire_for_release(self):
+ def test_dispatch_with_widget_id_updates_widget(self):
fig, ax = apl.subplots(1, 1)
v = ax.imshow(np.zeros((32, 32)))
- wid = v.add_widget("circle")
- fired = []
-
- @wid.on_changed
- def cb(event): fired.append(event)
+ wid = v.add_widget("circle", cx=0.0, cy=0.0)
- _simulate_js_event(fig, v, "on_release", widget_id=wid, cx=5.0, cy=5.0)
- assert fired == []
+ _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=5.0)
+ assert wid.cx == pytest.approx(5.0)
- def test_widget_and_plot_both_fire(self):
+ def test_widget_and_plot_callbacks_both_fire(self):
+ """A single JS event bearing a widget_id fires both the widget-level
+ and the plot-level on_release callbacks, with the widget as source."""
fig, ax = apl.subplots(1, 1)
v = ax.imshow(np.zeros((32, 32)))
wid = v.add_widget("circle")
@@ -532,84 +503,6 @@ def pc(event): p_fired.append(event)
assert w_fired[0].source is wid
assert p_fired[0].source is wid
- def test_widget_state_updated_after_js_event(self):
- fig, ax = apl.subplots(1, 1)
- v = ax.imshow(np.zeros((32, 32)))
- wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0)
-
- _simulate_js_event(fig, v, "on_changed", widget_id=wid,
- x=50.0, y=60.0, w=20.0, h=20.0)
- assert wid.x == pytest.approx(50.0)
- assert wid.y == pytest.approx(60.0)
-
- def test_no_echo_from_python_push(self):
- fig, ax = apl.subplots(1, 1)
- v = ax.imshow(np.zeros((32, 32)))
- wid = v.add_widget("circle")
- fired = []
-
- @wid.on_changed
- def cb(event): fired.append(event)
-
- fig._on_event({"new": json.dumps({
- "source": "python", "panel_id": v._id,
- "widget_id": wid._id, "cx": 99.0
- })})
- assert fired == []
-
- def test_1d_vline_widget_event(self):
- fig, ax = apl.subplots(1, 1)
- v = ax.plot(np.zeros(64))
- wid = v.add_vline_widget(x=10.0)
- fired = []
-
- @wid.on_changed
- def cb(event): fired.append(event)
-
- _simulate_js_event(fig, v, "on_changed", widget_id=wid, x=30.0)
- assert len(fired) == 1
- assert fired[0].x == pytest.approx(30.0)
-
- def test_1d_range_widget_event(self):
- fig, ax = apl.subplots(1, 1)
- v = ax.plot(np.zeros(64))
- wid = v.add_range_widget(x0=5.0, x1=15.0)
- fired = []
-
- @wid.on_release
- def cb(event): fired.append(event)
-
- _simulate_js_event(fig, v, "on_release", widget_id=wid, x0=8.0, x1=20.0)
- assert len(fired) == 1
- assert fired[0].x0 == pytest.approx(8.0)
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 8. Figure._on_event routing
-# ─────────────────────────────────────────────────────────────────────────────
-
-class TestFigureOnEvent:
-
- def test_dispatch_reaches_plot_callbacks(self):
- fig, ax = apl.subplots(1, 1)
- v = ax.imshow(np.zeros((32, 32)))
- fired = []
-
- @v.on_release
- def cb(event): fired.append(event)
-
- _simulate_js_event(fig, v, "on_release", cx=10.0, cy=20.0)
- assert len(fired) == 1
- assert fired[0].cx == pytest.approx(10.0)
-
- def test_dispatch_with_widget_id_updates_widget(self):
- fig, ax = apl.subplots(1, 1)
- v = ax.imshow(np.zeros((32, 32)))
- wid = v.add_widget("circle", cx=0.0, cy=0.0)
-
- _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=5.0)
- assert wid.cx == pytest.approx(5.0)
-
def test_dispatch_wrong_panel_id_ignored(self):
fig, ax = apl.subplots(1, 1)
v = ax.imshow(np.zeros((32, 32)))
@@ -690,7 +583,7 @@ def cb(event): fired.append(event)
# ─────────────────────────────────────────────────────────────────────────────
-# 9. Practical patterns
+# 8. Practical patterns
# ─────────────────────────────────────────────────────────────────────────────
class TestPracticalPatterns:
diff --git a/tests/test_widgets.py b/anyplotlib/tests/test_interactive/test_widgets.py
similarity index 99%
rename from tests/test_widgets.py
rename to anyplotlib/tests/test_interactive/test_widgets.py
index 05ee6ae..218727c 100644
--- a/tests/test_widgets.py
+++ b/anyplotlib/tests/test_interactive/test_widgets.py
@@ -1,6 +1,6 @@
"""
-tests/test_widgets.py
-=====================
+tests/test_interactive/test_widgets.py
+=======================================
Tests for the Widget class system and the event_json dispatch pipeline.
@@ -8,11 +8,14 @@
* Widget creation, attribute access, set(), to_dict(), __setattr__
* on_changed / on_release / on_click decorator + disconnect
* _update_from_js — always fires for on_release/on_click
- * Plot2D / Plot1D widget integration
+ * Widget visibility — hide() / show()
+ * Plot2D / Plot1D widget integration (add / remove / list / clear)
* Figure event_json dispatch (JS→Python path via _simulate_js_event)
- * widget.x = 40 attribute assignment
- * widget.x read-back after JS event
* End-to-end FFT example with simulated JS drag
+ * Interactive fitting scenario (PointWidget + RangeWidget + line.on_click)
+
+Callback infrastructure (Event, CallbackRegistry, plot-level callbacks,
+Figure routing) is tested in ``test_callbacks.py``.
"""
from __future__ import annotations
diff --git a/anyplotlib/tests/test_layouts/__init__.py b/anyplotlib/tests/test_layouts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py
similarity index 81%
rename from tests/test_gridspec.py
rename to anyplotlib/tests/test_layouts/test_gridspec.py
index b38f8f9..d6b18cf 100644
--- a/tests/test_gridspec.py
+++ b/anyplotlib/tests/test_layouts/test_gridspec.py
@@ -2,11 +2,10 @@
tests/test_gridspec.py
======================
-Tests for GridSpec / SubplotSpec indexing AND the figure sizing pipeline
-(_compute_cell_sizes) that converts grid specs + figsize into per-panel
-canvas pixel dimensions.
+Tests for GridSpec / SubplotSpec indexing, the figure sizing pipeline
+(_compute_cell_sizes), and per-panel plot-area alignment.
-The sizing contract (all measured at the *canvas* level, before PAD margins):
+Sizing contract (all measured at the *canvas* level, before PAD margins):
- All panels in the same grid column have the same canvas width (pw).
- All panels in the same grid row have the same canvas height (ph).
- Grid tracks are pure ratio math — no aspect-locking.
@@ -19,6 +18,13 @@
sum(row tracks) <= fh.
- Images are rendered "contain" (letterboxed) in JS — the Python layout
engine never modifies tracks because of image content.
+
+Alignment contract (inner plot-area coordinates, shared PAD constants):
+ - PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42
+ - The inner plot/image area for any panel kind is:
+ x=PAD_L, y=PAD_T, w=pw-PAD_L-PAD_R, h=ph-PAD_T-PAD_B
+ - All panels in the same column share pw → same left/right edges.
+ - All panels in the same row share ph → same top/bottom edges.
"""
from __future__ import annotations
@@ -31,6 +37,9 @@
from anyplotlib.figure import Figure
from anyplotlib.figure_plots import GridSpec, SubplotSpec, Axes # noqa: F401
+# PAD constants must match figure_esm.js (used in panel-alignment tests)
+PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42
+
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
@@ -705,6 +714,129 @@ def test_figsize_in_layout_json(self):
assert layout["fig_height"] == 555
+# ─────────────────────────────────────────────────────────────────────────────
+# Part 8 – Panel alignment
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _plot_area(pw: int, ph: int) -> tuple[int, int, int, int]:
+ """Return (x, y, w, h) of the inner plot/image area for any panel kind.
+
+ Both 1-D and 2-D panels use the same PAD constants in figure_esm.js,
+ so as long as Python assigns the same (pw, ph) to sibling panels they
+ are guaranteed to be pixel-aligned inside the shared canvas grid cell.
+ """
+ return PAD_L, PAD_T, pw - PAD_L - PAD_R, ph - PAD_T - PAD_B
+
+
+class TestPanelAlignment:
+ """Same-row / same-column panels must share canvas dimensions and
+ therefore produce identical inner plot-area coordinates."""
+
+ # ── two-row, one-column ───────────────────────────────────────────────
+
+ def test_2row_1col_same_width(self):
+ fig, axs = vw.subplots(2, 1, figsize=(600, 600))
+ v2d = axs[0].imshow(np.random.rand(128, 128))
+ v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
+ s = _sizes(fig)
+ pw2d = s[v2d._id][0]
+ pw1d = s[v1d._id][0]
+ assert pw2d == pw1d, (
+ f"Panels in same column must have equal width: 2D={pw2d}, 1D={pw1d}"
+ )
+
+ def test_2row_1col_left_edge_aligned(self):
+ """Left edge of the 2D image area and 1D plot area must both be PAD_L."""
+ fig, axs = vw.subplots(2, 1, figsize=(600, 600))
+ v2d = axs[0].imshow(np.random.rand(128, 128))
+ v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
+ s = _sizes(fig)
+ x2d = _plot_area(*s[v2d._id])[0]
+ x1d = _plot_area(*s[v1d._id])[0]
+ assert x2d == x1d == PAD_L, (
+ f"Left edge must be PAD_L={PAD_L}: 2D={x2d}, 1D={x1d}"
+ )
+
+ def test_2row_1col_plot_area_widths_equal(self):
+ """Plot-area widths must match when panels share a column."""
+ fig, axs = vw.subplots(2, 1, figsize=(600, 600))
+ v2d = axs[0].imshow(np.random.rand(128, 128))
+ v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
+ s = _sizes(fig)
+ w2d = _plot_area(*s[v2d._id])[2]
+ w1d = _plot_area(*s[v1d._id])[2]
+ assert w2d == w1d, f"Plot area widths: 2D={w2d}, 1D={w1d}"
+
+ # ── one-row, two-column ───────────────────────────────────────────────
+
+ def test_1row_2col_same_height(self):
+ fig, axs = vw.subplots(1, 2, figsize=(800, 400))
+ v2d = axs[0].imshow(np.random.rand(64, 64))
+ v1d = axs[1].plot(np.cos(np.linspace(0, 6, 256)))
+ s = _sizes(fig)
+ ph2d = s[v2d._id][1]
+ ph1d = s[v1d._id][1]
+ assert ph2d == ph1d, (
+ f"Panels in same row must have equal height: 2D={ph2d}, 1D={ph1d}"
+ )
+
+ def test_1row_2col_top_bottom_aligned(self):
+ """Top and bottom y-coordinates of plot areas must match across the row."""
+ fig, axs = vw.subplots(1, 2, figsize=(800, 400))
+ v2d = axs[0].imshow(np.random.rand(64, 64))
+ v1d = axs[1].plot(np.cos(np.linspace(0, 6, 256)))
+ s = _sizes(fig)
+ y2d, h2d = _plot_area(*s[v2d._id])[1], _plot_area(*s[v2d._id])[3]
+ y1d, h1d = _plot_area(*s[v1d._id])[1], _plot_area(*s[v1d._id])[3]
+ assert y2d == y1d == PAD_T, f"Top y: 2D={y2d}, 1D={y1d}"
+ assert h2d == h1d, f"Plot area heights: 2D={h2d}, 1D={h1d}"
+
+ # ── 2D panel canvas equals its grid cell ─────────────────────────────
+
+ def test_square_image_gets_square_canvas(self):
+ """A 128×128 image in a 500×500 figsize → canvas is 500×500 (pw == ph).
+ Images are letterboxed in JS; the Python layout never changes the cell."""
+ fig, axs = vw.subplots(1, 1, figsize=(500, 500))
+ v2d = axs.imshow(np.random.rand(128, 128))
+ pw, ph = _sizes(fig)[v2d._id]
+ assert pw == ph, f"Square figsize must give pw==ph: pw={pw}, ph={ph}"
+
+ def test_wide_image_canvas_equals_cell(self):
+ """A 2:1 image in a square cell gets a square canvas — no aspect-lock."""
+ fig, axs = vw.subplots(1, 1, figsize=(512, 512))
+ v2d = axs.imshow(np.random.rand(128, 256)) # w=256, h=128
+ pw, ph = _sizes(fig)[v2d._id]
+ assert pw == 512 and ph == 512, (
+ f"Canvas should equal full figsize 512×512, got {pw}×{ph}"
+ )
+
+ # ── non-square 2D panel plus 1D panel — column width consistent ───────
+
+ def test_nonsquare_2d_and_1d_same_column(self):
+ """A tall non-square image in a 2-row, 1-col layout must not affect the
+ 1D panel's canvas width — both must equal the column track width."""
+ fig, axs = vw.subplots(2, 1, figsize=(600, 800))
+ v2d = axs[0].imshow(np.random.rand(256, 128)) # tall image
+ v1d = axs[1].plot(np.random.rand(256))
+ s = _sizes(fig)
+ pw2d = s[v2d._id][0]
+ pw1d = s[v1d._id][0]
+ assert pw2d == pw1d, (
+ f"Same-column panels must have equal width: 2D={pw2d}, 1D={pw1d}"
+ )
+
+ # ── plot-area dimensions are positive ─────────────────────────────────
+
+ def test_plot_areas_positive(self):
+ fig, axs = vw.subplots(2, 1, figsize=(400, 400))
+ v2d = axs[0].imshow(np.random.rand(64, 64))
+ v1d = axs[1].plot(np.random.rand(128))
+ for pid, (pw, ph) in _sizes(fig).items():
+ x, y, w, h = _plot_area(pw, ph)
+ assert w > 0, f"Panel {pid}: plot area width must be positive, got {w}"
+ assert h > 0, f"Panel {pid}: plot area height must be positive, got {h}"
+
+
diff --git a/tests/test_inset.py b/anyplotlib/tests/test_layouts/test_inset.py
similarity index 58%
rename from tests/test_inset.py
rename to anyplotlib/tests/test_layouts/test_inset.py
index d0435fd..261c737 100644
--- a/tests/test_inset.py
+++ b/anyplotlib/tests/test_layouts/test_inset.py
@@ -1,6 +1,8 @@
"""
Tests for InsetAxes — floating overlay inset panels.
+Unit tests
+----------
Covers:
- Creation via fig.add_inset()
- layout_json inset_specs content
@@ -13,15 +15,33 @@
- Invalid corner raises ValueError
- Figure resize keeps inset fracs correct
- plot._id registered in _plots_map
+
+Visual regression tests
+-----------------------
+Pixel-accurate rendering checks for inset panels in a headless Chromium
+browser. Each test renders a deterministic Figure and compares it against
+a golden PNG in ``tests/baselines/``.
+
+Generate / refresh baselines::
+
+ uv run pytest tests/test_layouts/test_inset.py --update-baselines -v
+
+Normal CI run (fails on regression)::
+
+ uv run pytest tests/test_layouts/test_inset.py -v
"""
+from __future__ import annotations
+
import json
+import pathlib
+
import numpy as np
import pytest
import anyplotlib as apl
from anyplotlib.figure_plots import InsetAxes
-# ── helpers ──────────────────────────────────────────────────────────────────
+# ── helpers (unit tests) ──────────────────────────────────────────────────────
def _make_fig():
fig, ax = apl.subplots(1, 1, figsize=(640, 480))
@@ -247,3 +267,120 @@ def test_repr():
assert "top-right" in r
assert "normal" in r
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Visual regression tests
+# ─────────────────────────────────────────────────────────────────────────────
+
+BASELINES = pathlib.Path(__file__).parent / "baselines"
+
+
+def _check(name: str, arr: np.ndarray, update: bool) -> None:
+ from anyplotlib.tests._png_utils import decode_png, encode_png, compare_arrays
+
+ path = BASELINES / f"{name}.png"
+
+ if update:
+ BASELINES.mkdir(exist_ok=True)
+ path.write_bytes(encode_png(arr))
+ pytest.skip(f"Baseline updated: {path.name}")
+
+ if not path.exists():
+ pytest.skip(
+ f"No baseline for {name!r} — run with --update-baselines to create it"
+ )
+
+ expected = decode_png(path.read_bytes())
+ ok, msg = compare_arrays(arr, expected)
+ assert ok, f"Visual regression [{name}]: {msg}"
+
+
+def _main_fig():
+ """640×480 figure with a grayscale 64×64 imshow — the inset host."""
+ rng = np.random.default_rng(0)
+ fig, ax = apl.subplots(1, 1, figsize=(640, 480))
+ ax.imshow(rng.uniform(0.0, 1.0, (64, 64)).astype(np.float32))
+ return fig
+
+
+class TestInsetVisual:
+ """Pixel-level visual regression tests for the floating inset panel system."""
+
+ # ── single inset, normal state ─────────────────────────────────────────
+
+ def test_inset_normal_2d(self, take_screenshot, update_baselines):
+ """2-D inset in top-right corner, normal state."""
+ rng = np.random.default_rng(1)
+ fig = _main_fig()
+ inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Zoom")
+ inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
+ cmap="viridis")
+ arr = take_screenshot(fig)
+ _check("inset_normal_2d", arr, update_baselines)
+
+ def test_inset_minimized(self, take_screenshot, update_baselines):
+ """Inset collapsed to title bar only after minimize()."""
+ rng = np.random.default_rng(2)
+ fig = _main_fig()
+ inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Phase")
+ inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
+ inset.minimize()
+ arr = take_screenshot(fig)
+ _check("inset_minimized", arr, update_baselines)
+
+ def test_inset_maximized(self, take_screenshot, update_baselines):
+ """Inset expanded to ~72 % of figure after maximize()."""
+ rng = np.random.default_rng(3)
+ fig = _main_fig()
+ inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Detail")
+ inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
+ cmap="inferno")
+ inset.maximize()
+ arr = take_screenshot(fig)
+ _check("inset_maximized", arr, update_baselines)
+
+ # ── two insets stacked in the same corner ──────────────────────────────
+
+ def test_inset_stacked(self, take_screenshot, update_baselines):
+ """Two insets sharing top-right corner stack with constant gap."""
+ rng = np.random.default_rng(4)
+ fig = _main_fig()
+ i1 = fig.add_inset(0.28, 0.25, corner="top-right", title="A")
+ i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
+ i2 = fig.add_inset(0.28, 0.25, corner="top-right", title="B")
+ i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
+ cmap="hot")
+ arr = take_screenshot(fig)
+ _check("inset_stacked", arr, update_baselines)
+
+ # ── 1-D line inset ─────────────────────────────────────────────────────
+
+ def test_inset_1d(self, take_screenshot, update_baselines):
+ """1-D line plot inset in bottom-right corner."""
+ rng = np.random.default_rng(5)
+ fig = _main_fig()
+ inset = fig.add_inset(0.32, 0.22, corner="bottom-right",
+ title="Profile")
+ t = np.linspace(0.0, 2 * np.pi, 128)
+ inset.plot(np.sin(t) + rng.normal(0, 0.05, 128),
+ color="#4fc3f7", linewidth=1.5)
+ arr = take_screenshot(fig)
+ _check("inset_1d", arr, update_baselines)
+
+ # ── stacked with one minimized (restack test) ──────────────────────────
+
+ def test_inset_stacked_one_minimized(self, take_screenshot, update_baselines):
+ """Two insets in same corner; first minimized — second shifts up."""
+ rng = np.random.default_rng(6)
+ fig = _main_fig()
+ i1 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Min")
+ i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
+ i2 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Normal")
+ i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
+ cmap="viridis")
+ i1.minimize()
+ arr = take_screenshot(fig)
+ _check("inset_stacked_one_minimized", arr, update_baselines)
+
+
+
diff --git a/tests/test_interaction.py b/anyplotlib/tests/test_layouts/test_interaction.py
similarity index 100%
rename from tests/test_interaction.py
rename to anyplotlib/tests/test_layouts/test_interaction.py
diff --git a/tests/test_visual.py b/anyplotlib/tests/test_layouts/test_visual.py
similarity index 99%
rename from tests/test_visual.py
rename to anyplotlib/tests/test_layouts/test_visual.py
index 1211bdf..2a0f71c 100644
--- a/tests/test_visual.py
+++ b/anyplotlib/tests/test_layouts/test_visual.py
@@ -36,7 +36,7 @@
import pytest
import anyplotlib as apl
-from tests._png_utils import decode_png, encode_png, compare_arrays
+from anyplotlib.tests._png_utils import decode_png, encode_png, compare_arrays
BASELINES = pathlib.Path(__file__).parent / "baselines"
diff --git a/anyplotlib/tests/test_markers/__init__.py b/anyplotlib/tests/test_markers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py
similarity index 100%
rename from tests/test_markers.py
rename to anyplotlib/tests/test_markers/test_markers.py
diff --git a/anyplotlib/tests/test_plot1d/__init__.py b/anyplotlib/tests/test_plot1d/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py
new file mode 100644
index 0000000..a35bb4a
--- /dev/null
+++ b/anyplotlib/tests/test_plot1d/test_plot1d.py
@@ -0,0 +1,661 @@
+"""
+tests/test_plot1d/test_plot1d.py
+=================================
+
+Unit tests for Plot1D — covering:
+
+ * _norm_linestyle helper
+ * Default state values
+ * Construction via Axes.plot() (linestyle, ls shorthand, alpha, marker)
+ * Setter methods: set_color, set_linewidth, set_linestyle, set_alpha,
+ set_marker, set_data
+ * data property (read-only view)
+ * line property returning Line1D
+ * add_line() / remove_line() / clear_lines() and Line1D handle
+ * add_line() field parity (linestyle/alpha/marker in extra_lines dicts)
+ * State-dict round-trip (to_state_dict)
+ * Data-range recomputation (data_min / data_max) after overlay changes
+ * 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
+ * Marker helpers: add_points, add_vlines, add_hlines,
+ 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 _norm_linestyle, Line1D, Plot1D
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _plot(n: int = 128, **kwargs) -> Plot1D:
+ """Create a Plot1D attached to a one-panel Figure with deterministic data."""
+ fig, ax = apl.subplots(1, 1)
+ data = np.sin(np.linspace(0, 2 * np.pi, n))
+ return ax.plot(data, **kwargs)
+
+
+def _plot_lin(n: int = 32, **kwargs) -> Plot1D:
+ """Create a Plot1D with linspace data (useful for range tests)."""
+ fig, ax = apl.subplots(1, 1)
+ return ax.plot(np.linspace(0.0, 1.0, n), **kwargs)
+
+
+t = np.linspace(0, 2 * np.pi, 128)
+
+
+# ===========================================================================
+# _norm_linestyle
+# ===========================================================================
+
+class TestNormLinestyle:
+
+ def test_canonical_names_round_trip(self):
+ for ls in ("solid", "dashed", "dotted", "dashdot"):
+ assert _norm_linestyle(ls) == ls
+
+ def test_shorthand_solid(self):
+ assert _norm_linestyle("-") == "solid"
+
+ def test_shorthand_dashed(self):
+ assert _norm_linestyle("--") == "dashed"
+
+ def test_shorthand_dotted(self):
+ assert _norm_linestyle(":") == "dotted"
+
+ def test_shorthand_dashdot(self):
+ assert _norm_linestyle("-.") == "dashdot"
+
+ def test_invalid_raises(self):
+ with pytest.raises(ValueError, match="Unknown linestyle"):
+ _norm_linestyle("loose")
+
+ def test_invalid_empty_raises(self):
+ with pytest.raises(ValueError):
+ _norm_linestyle("")
+
+
+# ===========================================================================
+# Default state values
+# ===========================================================================
+
+class TestPlot1DDefaults:
+
+ def test_linestyle_default(self):
+ p = _plot_lin()
+ assert p._state["line_linestyle"] == "solid"
+
+ def test_alpha_default(self):
+ p = _plot_lin()
+ assert p._state["line_alpha"] == 1.0
+
+ def test_marker_default(self):
+ p = _plot_lin()
+ assert p._state["line_marker"] == "none"
+
+ def test_markersize_default(self):
+ p = _plot_lin()
+ assert p._state["line_markersize"] == 4.0
+
+
+# ===========================================================================
+# Construction via Axes.plot()
+# ===========================================================================
+
+class TestPlot1DConstruction:
+
+ def test_linestyle_dashed(self):
+ p = _plot(linestyle="dashed")
+ assert p._state["line_linestyle"] == "dashed"
+
+ def test_linestyle_dotted(self):
+ p = _plot(linestyle="dotted")
+ assert p._state["line_linestyle"] == "dotted"
+
+ def test_linestyle_dashdot(self):
+ p = _plot(linestyle="-.")
+ assert p._state["line_linestyle"] == "dashdot"
+
+ def test_ls_shorthand(self):
+ p = _plot(ls="--")
+ assert p._state["line_linestyle"] == "dashed"
+
+ def test_ls_shorthand_takes_precedence_over_linestyle(self):
+ p = _plot_lin(linestyle="solid", ls="--")
+ assert p._state["line_linestyle"] == "dashed"
+
+ def test_ls_only(self):
+ p = _plot_lin(ls=":")
+ assert p._state["line_linestyle"] == "dotted"
+
+ def test_alpha_stored(self):
+ p = _plot(alpha=0.4)
+ assert p._state["line_alpha"] == pytest.approx(0.4)
+
+ def test_marker_stored(self):
+ p = _plot(marker="s", markersize=5)
+ assert p._state["line_marker"] == "s"
+ assert p._state["line_markersize"] == pytest.approx(5.0)
+
+ def test_markersize_stored(self):
+ p = _plot_lin(marker="s", markersize=8.0)
+ assert p._state["line_markersize"] == pytest.approx(8.0)
+
+ def test_marker_none_string(self):
+ p = _plot_lin(marker="none")
+ assert p._state["line_marker"] == "none"
+
+ def test_invalid_linestyle_raises(self):
+ with pytest.raises(ValueError, match="Unknown linestyle"):
+ _plot_lin(linestyle="zigzag")
+
+ def test_all_known_markers(self):
+ for sym in ("o", "s", "^", "v", "D", "+", "x", "none"):
+ p = _plot_lin(marker=sym)
+ assert p._state["line_marker"] == sym
+
+
+# ===========================================================================
+# Setter methods
+# ===========================================================================
+
+class TestPlot1DSetters:
+
+ def test_set_color(self):
+ p = _plot(color="#4fc3f7")
+ p.set_color("#ff7043")
+ assert p._state["line_color"] == "#ff7043"
+
+ def test_set_linewidth(self):
+ p = _plot()
+ p.set_linewidth(3.0)
+ assert p._state["line_linewidth"] == pytest.approx(3.0)
+
+ def test_set_linestyle_canonical(self):
+ p = _plot_lin()
+ p.set_linestyle("dotted")
+ assert p._state["line_linestyle"] == "dotted"
+
+ def test_set_linestyle_word(self):
+ p = _plot()
+ p.set_linestyle("dashed")
+ assert p._state["line_linestyle"] == "dashed"
+
+ def test_set_linestyle_shorthand_dashdot(self):
+ p = _plot()
+ p.set_linestyle("-.")
+ assert p._state["line_linestyle"] == "dashdot"
+
+ def test_set_linestyle_shorthand_colon(self):
+ p = _plot_lin()
+ p.set_linestyle(":")
+ assert p._state["line_linestyle"] == "dotted"
+
+ def test_set_linestyle_invalid_raises(self):
+ p = _plot_lin()
+ with pytest.raises(ValueError):
+ p.set_linestyle("bad")
+
+ def test_set_alpha(self):
+ p = _plot()
+ p.set_alpha(0.5)
+ assert p._state["line_alpha"] == pytest.approx(0.5)
+
+ def test_set_marker_with_size(self):
+ p = _plot()
+ p.set_marker("o", markersize=6)
+ assert p._state["line_marker"] == "o"
+ assert p._state["line_markersize"] == pytest.approx(6.0)
+
+ def test_set_marker_symbol_only(self):
+ p = _plot_lin()
+ p.set_marker("D")
+ assert p._state["line_marker"] == "D"
+
+ def test_set_marker_no_size_leaves_default(self):
+ p = _plot_lin()
+ p.set_marker("^")
+ assert p._state["line_markersize"] == pytest.approx(4.0)
+
+ def test_set_marker_none_normalised(self):
+ p = _plot_lin(marker="o")
+ p.set_marker(None) # type: ignore[arg-type]
+ assert p._state["line_marker"] == "none"
+
+ def test_setters_chain_without_error(self):
+ """Multiple setter calls in sequence must not raise."""
+ p = _plot_lin()
+ p.set_color("#aabbcc")
+ p.set_linewidth(2.5)
+ p.set_linestyle("--")
+ p.set_alpha(0.8)
+ p.set_marker("o", markersize=6)
+ assert p._state["line_linestyle"] == "dashed"
+ assert p._state["line_alpha"] == pytest.approx(0.8)
+ assert p._state["line_marker"] == "o"
+
+ def test_set_data_replaces_primary(self):
+ p = _plot(n=64)
+ new_data = np.cos(np.linspace(0, 2 * np.pi, 64))
+ p.set_data(new_data)
+ np.testing.assert_allclose(p._state["data"], new_data)
+
+ def test_set_data_with_new_x_axis(self):
+ p = _plot(n=32)
+ y = np.ones(32)
+ x = np.linspace(10, 42, 32)
+ p.set_data(y, x_axis=x)
+ np.testing.assert_allclose(p._state["x_axis"], x)
+
+ def test_set_data_updates_units(self):
+ p = _plot()
+ p.set_data(np.zeros(128), units="eV")
+ assert p._state["units"] == "eV"
+
+ def test_set_data_2d_raises(self):
+ p = _plot()
+ with pytest.raises(ValueError):
+ p.set_data(np.ones((4, 4)))
+
+ def test_data_property_readonly(self):
+ p = _plot()
+ arr = p.data
+ assert not arr.flags.writeable
+
+ def test_line_property_returns_line1d(self):
+ p = _plot()
+ assert isinstance(p.line, Line1D)
+ assert p.line.id is None
+
+
+# ===========================================================================
+# Overlay lines (add_line / remove_line / clear_lines / Line1D handle)
+# ===========================================================================
+
+class TestPlot1DOverlayLines:
+
+ def test_add_line_returns_line1d(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ assert isinstance(line, Line1D)
+ assert line.id is not None
+
+ def test_add_line_stored_in_extra_lines(self):
+ p = _plot()
+ p.add_line(np.cos(t), color="#ff7043", label="cos")
+ assert len(p._state["extra_lines"]) == 1
+ assert p._state["extra_lines"][0]["color"] == "#ff7043"
+
+ def test_add_line_linestyle_alpha_marker(self):
+ p = _plot()
+ p.add_line(np.cos(t), linestyle="dashed", alpha=0.75, marker="o", markersize=5)
+ entry = p._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):
+ p = _plot()
+ p.add_line(np.cos(t), ls=":")
+ assert p._state["extra_lines"][0]["linestyle"] == "dotted"
+
+ def test_add_multiple_lines(self):
+ p = _plot()
+ p.add_line(np.cos(t))
+ p.add_line(np.cos(t) * 0.5)
+ assert len(p._state["extra_lines"]) == 2
+
+ def test_remove_line_by_id(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ p.remove_line(line.id)
+ assert len(p._state["extra_lines"]) == 0
+
+ def test_remove_line_by_line1d(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ p.remove_line(line)
+ assert len(p._state["extra_lines"]) == 0
+
+ def test_remove_line_bad_id_raises(self):
+ p = _plot()
+ with pytest.raises(KeyError):
+ p.remove_line("nonexistent")
+
+ def test_clear_lines(self):
+ p = _plot()
+ p.add_line(np.cos(t))
+ p.add_line(np.cos(2 * t))
+ p.clear_lines()
+ assert p._state["extra_lines"] == []
+
+ def test_line1d_set_data(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ new_y = np.zeros(128)
+ line.set_data(new_y)
+ entry = next(e for e in p._state["extra_lines"] if e["id"] == line.id)
+ np.testing.assert_allclose(entry["data"], new_y)
+
+ def test_line1d_set_data_primary_raises(self):
+ p = _plot()
+ primary = Line1D(p, None)
+ with pytest.raises(ValueError, match="primary line"):
+ primary.set_data(np.zeros(10))
+
+ def test_line1d_set_data_bad_id_raises(self):
+ p = _plot()
+ phantom = Line1D(p, "deadbeef")
+ with pytest.raises(KeyError):
+ phantom.set_data(np.zeros(128))
+
+ def test_line1d_remove(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ line.remove()
+ assert len(p._state["extra_lines"]) == 0
+
+ def test_line1d_remove_primary_raises(self):
+ p = _plot()
+ primary = Line1D(p, None)
+ with pytest.raises(ValueError):
+ primary.remove()
+
+ def test_line1d_eq_str(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ assert line == line.id
+ assert not (line == "other")
+
+ def test_line1d_hash(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ d = {line: "val"}
+ assert d[line] == "val"
+
+ def test_line1d_str(self):
+ p = _plot()
+ line = p.add_line(np.cos(t))
+ assert str(line) == line.id
+
+
+# ===========================================================================
+# add_line() field parity
+# ===========================================================================
+
+class TestAddLineParity:
+
+ def _extra(self, **kwargs) -> dict:
+ p = _plot_lin()
+ p.add_line(np.ones(32), **kwargs)
+ return p._state["extra_lines"][0]
+
+ def test_default_linestyle(self):
+ assert self._extra()["linestyle"] == "solid"
+
+ def test_linestyle_stored(self):
+ assert self._extra(linestyle="dashed")["linestyle"] == "dashed"
+
+ def test_ls_shorthand(self):
+ assert self._extra(ls=":")["linestyle"] == "dotted"
+
+ def test_ls_overrides_linestyle(self):
+ assert self._extra(linestyle="solid", ls="--")["linestyle"] == "dashed"
+
+ def test_default_alpha(self):
+ assert self._extra()["alpha"] == pytest.approx(1.0)
+
+ def test_alpha_stored(self):
+ assert self._extra(alpha=0.4)["alpha"] == pytest.approx(0.4)
+
+ def test_default_marker(self):
+ assert self._extra()["marker"] == "none"
+
+ def test_marker_stored(self):
+ ex = self._extra(marker="o", markersize=6.0)
+ assert ex["marker"] == "o"
+ assert ex["markersize"] == pytest.approx(6.0)
+
+ def test_invalid_linestyle_raises(self):
+ p = _plot_lin()
+ with pytest.raises(ValueError):
+ p.add_line(np.ones(32), linestyle="bad")
+
+ def test_multiple_extra_lines_independent(self):
+ p = _plot_lin()
+ p.add_line(np.ones(32), linestyle="dashed", alpha=0.5)
+ p.add_line(np.ones(32), linestyle="dotted", alpha=0.8)
+ assert p._state["extra_lines"][0]["linestyle"] == "dashed"
+ assert p._state["extra_lines"][1]["linestyle"] == "dotted"
+ assert p._state["extra_lines"][0]["alpha"] == pytest.approx(0.5)
+ assert p._state["extra_lines"][1]["alpha"] == pytest.approx(0.8)
+
+
+# ===========================================================================
+# State-dict round-trip (to_state_dict)
+# ===========================================================================
+
+class TestStateDict:
+
+ def test_primary_keys_present(self):
+ p = _plot_lin(linestyle="dotted", alpha=0.7, marker="s", markersize=5.0)
+ sd = p.to_state_dict()
+ assert sd["line_linestyle"] == "dotted"
+ assert sd["line_alpha"] == pytest.approx(0.7)
+ assert sd["line_marker"] == "s"
+ assert sd["line_markersize"] == pytest.approx(5.0)
+
+ def test_extra_line_keys_present(self):
+ p = _plot_lin()
+ p.add_line(np.zeros(32), linestyle="dashdot", alpha=0.6, marker="D")
+ sd = p.to_state_dict()
+ ex = sd["extra_lines"][0]
+ assert ex["linestyle"] == "dashdot"
+ assert ex["alpha"] == pytest.approx(0.6)
+ assert ex["marker"] == "D"
+
+
+# ===========================================================================
+# Data-range recomputation
+# ===========================================================================
+
+class TestDataRangeRecompute:
+ """data_min/data_max must always cover all visible lines."""
+
+ def test_add_line_expands_range_upward(self):
+ p = _plot_lin()
+ primary_max = p._state["data_max"]
+ p.add_line(np.full(32, 5.0))
+ assert p._state["data_max"] > primary_max
+ assert p._state["data_max"] >= 5.0
+
+ def test_add_line_expands_range_downward(self):
+ p = _plot_lin()
+ primary_min = p._state["data_min"]
+ p.add_line(np.full(32, -5.0))
+ assert p._state["data_min"] < primary_min
+ assert p._state["data_min"] <= -5.0
+
+ def test_add_line_both_directions(self):
+ p = _plot_lin()
+ p.add_line(np.full(32, 10.0))
+ p.add_line(np.full(32, -10.0))
+ assert p._state["data_max"] >= 10.0
+ assert p._state["data_min"] <= -10.0
+
+ def test_remove_line_shrinks_range(self):
+ p = _plot_lin()
+ lid = p.add_line(np.full(32, 100.0))
+ assert p._state["data_max"] >= 100.0
+ p.remove_line(lid)
+ assert p._state["data_max"] < 10.0
+
+ def test_clear_lines_restores_primary_range(self):
+ p = _plot_lin()
+ original_min = p._state["data_min"]
+ original_max = p._state["data_max"]
+ p.add_line(np.full(32, 50.0))
+ p.add_line(np.full(32, -50.0))
+ p.clear_lines()
+ assert p._state["data_min"] == pytest.approx(original_min)
+ assert p._state["data_max"] == pytest.approx(original_max)
+
+ def test_range_includes_padding(self):
+ """5 % padding must be applied after recompute."""
+ p = _plot_lin()
+ p.add_line(np.zeros(32) + 3.0)
+ assert p._state["data_max"] >= 3.0 * 1.05 - 0.01
+
+ def test_overlay_within_bounds_does_not_change_range(self):
+ p = _plot_lin()
+ pre_min = p._state["data_min"]
+ pre_max = p._state["data_max"]
+ p.add_line(np.full(32, 0.5))
+ assert p._state["data_min"] == pytest.approx(pre_min)
+ assert p._state["data_max"] == pytest.approx(pre_max)
+
+ def test_sin_overlay_expands_max(self):
+ p = _plot()
+ old_max = p._state["data_max"]
+ p.add_line(np.sin(t) + 5)
+ assert p._state["data_max"] > old_max
+
+
+# ===========================================================================
+# Spans
+# ===========================================================================
+
+class TestPlot1DSpans:
+
+ def test_add_span_returns_id(self):
+ p = _plot()
+ sid = p.add_span(1.0, 2.0)
+ assert isinstance(sid, str)
+ assert len(p._state["spans"]) == 1
+
+ def test_add_span_y_axis(self):
+ p = _plot()
+ p.add_span(0.5, 0.8, axis="y", color="#ff0000")
+ assert p._state["spans"][0]["axis"] == "y"
+
+ def test_remove_span(self):
+ p = _plot()
+ sid = p.add_span(1.0, 2.0)
+ p.remove_span(sid)
+ assert p._state["spans"] == []
+
+ def test_remove_span_bad_id_raises(self):
+ p = _plot()
+ with pytest.raises(KeyError):
+ p.remove_span("nonexistent")
+
+ def test_clear_spans(self):
+ p = _plot()
+ p.add_span(1.0, 2.0)
+ p.add_span(3.0, 4.0)
+ p.clear_spans()
+ assert p._state["spans"] == []
+
+
+# ===========================================================================
+# Widgets
+# ===========================================================================
+
+class TestPlot1DWidgets:
+
+ def test_add_vline_widget(self):
+ p = _plot()
+ w = p.add_vline_widget(1.5, color="#ff6e40")
+ assert w is not None
+ assert len(p._widgets) == 1
+
+ def test_add_hline_widget(self):
+ p = _plot()
+ p.add_hline_widget(0.5)
+ assert len(p._widgets) == 1
+
+ def test_add_range_widget(self):
+ p = _plot()
+ p.add_range_widget(1.0, 3.0)
+ assert len(p._widgets) == 1
+
+ def test_get_widget_by_id(self):
+ p = _plot()
+ w = p.add_vline_widget(1.0)
+ assert p.get_widget(w.id) is w
+
+ def test_get_widget_by_widget(self):
+ p = _plot()
+ w = p.add_vline_widget(1.0)
+ assert p.get_widget(w) is w
+
+ def test_get_widget_missing_raises(self):
+ p = _plot()
+ with pytest.raises(KeyError):
+ p.get_widget("bad_id")
+
+ def test_remove_widget(self):
+ p = _plot()
+ w = p.add_vline_widget(1.0)
+ p.remove_widget(w)
+ assert len(p._widgets) == 0
+
+ def test_remove_widget_missing_raises(self):
+ p = _plot()
+ with pytest.raises(KeyError):
+ p.remove_widget("bad_id")
+
+ def test_list_widgets(self):
+ p = _plot()
+ p.add_vline_widget(1.0)
+ p.add_hline_widget(0.5)
+ assert len(p.list_widgets()) == 2
+
+ def test_clear_widgets(self):
+ p = _plot()
+ p.add_vline_widget(1.0)
+ p.add_hline_widget(0.5)
+ p.clear_widgets()
+ assert p.list_widgets() == []
+
+
+# ===========================================================================
+# Marker helpers
+# ===========================================================================
+
+class TestPlot1DMarkerHelpers:
+
+ def test_add_points_with_facecolors(self):
+ p = _plot()
+ offsets = np.column_stack([[1.0, 2.0], [0.5, 0.8]])
+ p.add_points(offsets, name="peaks", sizes=7,
+ color="#ff1744", facecolors="#ff174433")
+ wl = p.markers.to_wire_list()
+ assert any(w["type"] == "points" for w in wl)
+
+ def test_list_markers_count(self):
+ p = _plot()
+ offsets = np.column_stack([[1.0, 2.0, 3.0], [0.1, 0.2, 0.3]])
+ p.add_points(offsets, name="pts")
+ info = p.list_markers()
+ assert any(d["name"] == "pts" and d["n"] == 3 for d in info)
+
+ def test_remove_marker(self):
+ p = _plot()
+ p.add_vlines([1.0, 2.0], name="m")
+ p.remove_marker("vlines", "m")
+ assert p.markers.to_wire_list() == []
+
+ def test_clear_markers(self):
+ p = _plot()
+ p.add_vlines([1.0], name="v")
+ p.add_hlines([0.5], name="h")
+ p.clear_markers()
+ assert p.markers.to_wire_list() == []
+
diff --git a/tests/test_bar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py
similarity index 50%
rename from tests/test_bar.py
rename to anyplotlib/tests/test_plot1d/test_plotbar.py
index 3f4812b..9a21133 100644
--- a/tests/test_bar.py
+++ b/anyplotlib/tests/test_plot1d/test_plotbar.py
@@ -1,32 +1,33 @@
"""
-tests/test_bar.py
-=================
+tests/test_plot1d/test_plotbar.py
+==================================
-Tests for the bar chart (PlotBar) functionality.
+Unit tests for PlotBar (bar chart) — covering:
-Covers:
- * Construction – default and explicit arguments (matplotlib-aligned API)
+ * Construction: defaults and explicit matplotlib-aligned API
+ (bar(x, height, width, bottom, ...), string x → category labels)
* State dict contents and data integrity
- * Orientation (vertical / horizontal)
+ * Orientation: vertical / horizontal
* Colour options: single colour, per-bar colours, group colours
- * Bar-width, baseline/bottom, and show_values flags
+ * Bar-width, baseline/bottom, show_values flags
* x (positions or category labels) and x_labels
* Range / padding calculations
- * Grouped bars – 2-D height array, group_labels, group_colors
- * Log scale – log_scale flag, clamping, set_log_scale()
- * set_data() – value replacement and axis recalculation
+ * Grouped bars: 2-D height array, group_labels, group_colors
+ * Log scale: log_scale flag, clamping, set_log_scale()
+ * set_data(): value replacement and axis recalculation
* Display-setting mutations: set_color, set_colors, set_show_values, set_log_scale
- * _push() contract – state is propagated to the Figure
- * Layout JSON reflects "bar" kind for PlotBar panels
+ * _push() contract: state propagated to Figure; layout_json kind == "bar"
* Callback API: on_click (incl. group_index/group_value), on_changed, disconnect
- * Edge cases: single bar, negative values, all-equal values, large N
+ * Widgets: add_vline_widget, add_hline_widget, add_range_widget, add_point_widget,
+ get_widget, remove_widget, list_widgets, clear_widgets
+ * Edge cases: single bar, negative values, all-equal values, large N, float values
* Validation errors for bad inputs
* repr()
"""
-
from __future__ import annotations
import json
+
import numpy as np
import pytest
@@ -35,36 +36,42 @@
from anyplotlib.figure_plots import PlotBar
-# ─────────────────────────────────────────────────────────────────────────────
+# ---------------------------------------------------------------------------
# Helpers
-# ─────────────────────────────────────────────────────────────────────────────
+# ---------------------------------------------------------------------------
def _make_bar(values=None, **kwargs) -> PlotBar:
- """Create a PlotBar attached to a one-panel Figure (backward-compat call)."""
+ """Create a PlotBar attached to a one-panel Figure (values-only call)."""
if values is None:
values = [1, 2, 3, 4, 5]
fig, ax = apl.subplots(1, 1)
return ax.bar(values, **kwargs)
+def _bar(x, height=None, **kwargs) -> PlotBar:
+ """Create a PlotBar via the full bar(x, height, ...) API."""
+ fig, ax = apl.subplots(1, 1)
+ if height is not None:
+ return ax.bar(x, height, **kwargs)
+ return ax.bar(x, **kwargs)
+
+
def _state(plot: PlotBar) -> dict:
return plot.to_state_dict()
-# ─────────────────────────────────────────────────────────────────────────────
-# 1. Construction – defaults
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
+# 1. Construction — defaults
+# ===========================================================================
-class TestPlotBarConstruction:
+class TestPlotBarDefaults:
def test_kind_is_bar(self):
- p = _make_bar()
- assert _state(p)["kind"] == "bar"
+ assert _state(_make_bar())["kind"] == "bar"
def test_values_stored_as_2d(self):
values = [10, 20, 30]
p = _make_bar(values)
- # values are always stored as N×G (2-D) — [[v], [v], ...] for G=1
assert _state(p)["values"] == pytest.approx(np.array([[10.0], [20.0], [30.0]]))
def test_numpy_array_accepted(self):
@@ -73,167 +80,148 @@ def test_numpy_array_accepted(self):
assert _state(p)["values"] == pytest.approx(np.array([[1.0], [2.0], [3.0]]))
def test_default_x_centers(self):
- p = _make_bar([5, 6, 7])
- assert _state(p)["x_centers"] == pytest.approx([0.0, 1.0, 2.0])
+ assert _state(_make_bar([5, 6, 7]))["x_centers"] == pytest.approx([0.0, 1.0, 2.0])
def test_default_orient_is_vertical(self):
- p = _make_bar()
- assert _state(p)["orient"] == "v"
+ assert _state(_make_bar())["orient"] == "v"
def test_default_baseline_is_zero(self):
- p = _make_bar()
- assert _state(p)["baseline"] == pytest.approx(0.0)
+ assert _state(_make_bar())["baseline"] == pytest.approx(0.0)
def test_default_bar_width(self):
- p = _make_bar()
- assert _state(p)["bar_width"] == pytest.approx(0.8)
+ assert _state(_make_bar())["bar_width"] == pytest.approx(0.8)
def test_default_show_values_false(self):
- p = _make_bar()
- assert _state(p)["show_values"] is False
+ assert _state(_make_bar())["show_values"] is False
def test_default_color(self):
- p = _make_bar()
- assert _state(p)["bar_color"] == "#4fc3f7"
+ assert _state(_make_bar())["bar_color"] == "#4fc3f7"
def test_default_bar_colors_empty(self):
- p = _make_bar()
- assert _state(p)["bar_colors"] == []
+ assert _state(_make_bar())["bar_colors"] == []
def test_default_x_labels_empty(self):
- p = _make_bar()
- assert _state(p)["x_labels"] == []
+ assert _state(_make_bar())["x_labels"] == []
def test_default_units_empty(self):
- p = _make_bar()
- assert _state(p)["units"] == ""
- assert _state(p)["y_units"] == ""
+ st = _state(_make_bar())
+ assert st["units"] == ""
+ assert st["y_units"] == ""
def test_default_groups_is_one(self):
- p = _make_bar()
- assert _state(p)["groups"] == 1
+ assert _state(_make_bar())["groups"] == 1
def test_default_log_scale_false(self):
- p = _make_bar()
- assert _state(p)["log_scale"] is False
+ assert _state(_make_bar())["log_scale"] is False
-# ─────────────────────────────────────────────────────────────────────────────
-# 2. Construction – explicit arguments (matplotlib-aligned)
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
+# 2. Construction — explicit / matplotlib-aligned arguments
+# ===========================================================================
class TestPlotBarExplicitArgs:
- def test_x_as_positions(self):
- """bar(x, height) — x as numeric positions."""
- fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1, 2], [10, 20, 30])
+ def test_x_as_numeric_positions(self):
+ p = _bar([0, 1, 2], [10, 20, 30])
st = _state(p)
assert st["x_centers"] == pytest.approx([0.0, 1.0, 2.0])
assert st["values"] == pytest.approx(np.array([[10.0], [20.0], [30.0]]))
def test_x_as_string_labels(self):
- """bar(['A','B','C'], height) — x as category strings."""
- fig, ax = apl.subplots(1, 1)
- p = ax.bar(["A", "B", "C"], [1, 2, 3])
+ months = ["Jan", "Feb", "Mar"]
+ p = _bar(months, [10, 20, 30])
st = _state(p)
- assert st["x_labels"] == ["A", "B", "C"]
+ assert st["x_labels"] == months
assert st["x_centers"] == pytest.approx([0.0, 1.0, 2.0])
def test_width_parameter(self):
- fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1, 2], [1, 2, 3], width=0.5)
+ p = _bar([0, 1, 2], [1, 2, 3], width=0.5)
assert _state(p)["bar_width"] == pytest.approx(0.5)
def test_bottom_parameter(self):
- fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1, 2], [1, 2, 3], bottom=5.0)
+ p = _bar([0, 1, 2], [1, 2, 3], bottom=5.0)
assert _state(p)["baseline"] == pytest.approx(5.0)
- def test_legacy_x_centers(self):
- p = _make_bar([1, 2, 3], x_centers=[10, 20, 30])
- assert _state(p)["x_centers"] == pytest.approx([10.0, 20.0, 30.0])
+ def test_orient_h(self):
+ assert _bar(["A", "B"], [10, 20], orient="h")._state["orient"] == "h"
- def test_legacy_x_labels(self):
- p = _make_bar([1, 2, 3], x_labels=["A", "B", "C"])
- assert _state(p)["x_labels"] == ["A", "B", "C"]
+ def test_orient_v_default(self):
+ assert _bar([1, 2], [5, 6])._state["orient"] == "v"
+
+ def test_show_values_kwarg(self):
+ assert _bar([1, 2, 3], [10, 20, 30], show_values=True)._state["show_values"] is True
def test_custom_color(self):
- p = _make_bar(color="#ff0000")
- assert _state(p)["bar_color"] == "#ff0000"
+ assert _make_bar(color="#ff0000")._state["bar_color"] == "#ff0000"
def test_custom_colors_list(self):
- colors = ["#f00", "#0f0", "#00f"]
- p = _make_bar([1, 2, 3], colors=colors)
- assert _state(p)["bar_colors"] == colors
+ palette = ["#ff0000", "#00ff00", "#0000ff"]
+ p = _bar([1, 2, 3], [10, 20, 30], colors=palette)
+ assert _state(p)["bar_colors"] == palette
- def test_legacy_bar_width(self):
- p = _make_bar(bar_width=0.5)
- assert _state(p)["bar_width"] == pytest.approx(0.5)
+ def test_legacy_x_centers(self):
+ assert _state(_make_bar([1, 2, 3], x_centers=[10, 20, 30]))["x_centers"] == pytest.approx([10.0, 20.0, 30.0])
- def test_horizontal_orient(self):
- p = _make_bar(orient="h")
- assert _state(p)["orient"] == "h"
+ def test_legacy_x_labels(self):
+ assert _state(_make_bar([1, 2, 3], x_labels=["A", "B", "C"]))["x_labels"] == ["A", "B", "C"]
- def test_legacy_baseline(self):
- p = _make_bar(baseline=5.0)
- assert _state(p)["baseline"] == pytest.approx(5.0)
+ def test_legacy_bar_width(self):
+ assert _state(_make_bar(bar_width=0.5))["bar_width"] == pytest.approx(0.5)
- def test_show_values_true(self):
- p = _make_bar(show_values=True)
- assert _state(p)["show_values"] is True
+ def test_legacy_baseline(self):
+ assert _state(_make_bar(baseline=5.0))["baseline"] == pytest.approx(5.0)
def test_units_and_y_units(self):
- p = _make_bar(units="category", y_units="count")
- assert _state(p)["units"] == "category"
- assert _state(p)["y_units"] == "count"
+ st = _state(_make_bar(units="category", y_units="count"))
+ assert st["units"] == "category"
+ assert st["y_units"] == "count"
+
+ def test_axes_bar_returns_plotbar_instance(self):
+ fig, ax = apl.subplots(1, 1)
+ assert isinstance(ax.bar([1, 2, 3]), PlotBar)
+
+ def test_orient_invalid_raises(self):
+ with pytest.raises(ValueError):
+ _bar([1, 2], [5, 6], orient="diagonal")
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
# 3. Range / padding calculations
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
class TestPlotBarRange:
def test_data_max_exceeds_max_value(self):
- p = _make_bar([1, 2, 3, 4, 5])
- assert _state(p)["data_max"] > 5.0
+ assert _state(_make_bar([1, 2, 3, 4, 5]))["data_max"] > 5.0
def test_data_min_at_baseline_for_positive_values(self):
- p = _make_bar([1, 2, 3, 4, 5], baseline=0.0)
- assert _state(p)["data_min"] <= 0.0
+ assert _state(_make_bar([1, 2, 3, 4, 5], baseline=0.0))["data_min"] <= 0.0
def test_negative_values_extend_data_min(self):
- p = _make_bar([-3, -1, 0, 2])
- assert _state(p)["data_min"] < -3.0
+ assert _state(_make_bar([-3, -1, 0, 2]))["data_min"] < -3.0
def test_data_max_gt_data_min(self):
- p = _make_bar([1, 2, 3])
- st = _state(p)
+ st = _state(_make_bar([1, 2, 3]))
assert st["data_max"] > st["data_min"]
def test_all_equal_values_padded(self):
- p = _make_bar([5, 5, 5])
- st = _state(p)
+ st = _state(_make_bar([5, 5, 5]))
assert st["data_max"] > st["data_min"]
def test_baseline_above_all_values(self):
- p = _make_bar([1, 2, 3], baseline=10.0)
- assert _state(p)["data_max"] >= 10.0
+ assert _state(_make_bar([1, 2, 3], baseline=10.0))["data_max"] >= 10.0
def test_baseline_below_all_values(self):
- p = _make_bar([5, 6, 7], baseline=-5.0)
- assert _state(p)["data_min"] <= -5.0
+ assert _state(_make_bar([5, 6, 7], baseline=-5.0))["data_min"] <= -5.0
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
# 4. Grouped bars
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
class TestPlotBarGrouped:
def test_2d_height_creates_groups(self):
- """2-D height array (N, G) → groups == G."""
fig, ax = apl.subplots(1, 1)
p = ax.bar(["A", "B", "C"], [[1, 2], [3, 4], [5, 6]])
st = _state(p)
@@ -243,8 +231,13 @@ def test_2d_height_creates_groups(self):
def test_numpy_2d_height(self):
arr = np.array([[10, 20], [30, 40]])
fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1], arr)
- assert _state(p)["groups"] == 2
+ assert _state(ax.bar([0, 1], arr))["groups"] == 2
+
+ def test_grouped_2d_height_with_group_labels(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_group_labels_stored(self):
fig, ax = apl.subplots(1, 1)
@@ -253,27 +246,26 @@ def test_group_labels_stored(self):
def test_group_colors_stored(self):
fig, ax = apl.subplots(1, 1)
- p = ax.bar(["A", "B"], [[1, 2], [3, 4]],
- group_colors=["#f00", "#0f0"])
+ p = ax.bar(["A", "B"], [[1, 2], [3, 4]], group_colors=["#f00", "#0f0"])
assert _state(p)["group_colors"] == ["#f00", "#0f0"]
def test_default_group_colors_assigned_for_multi_group(self):
"""Multi-group without explicit group_colors gets a default palette."""
fig, ax = apl.subplots(1, 1)
- p = ax.bar(["A", "B"], [[1, 2], [3, 4]])
- gc = _state(p)["group_colors"]
+ gc = _state(ax.bar(["A", "B"], [[1, 2], [3, 4]]))["group_colors"]
assert len(gc) == 2
assert all(c.startswith("#") for c in gc)
+ def test_grouped_default_colors_count(self):
+ data = np.ones((3, 2))
+ assert len(_bar([1, 2, 3], data)._state["group_colors"]) == 2
+
def test_single_group_colors_empty_by_default(self):
- """Ungrouped charts have empty group_colors (uses bar_color)."""
- p = _make_bar([1, 2, 3])
- assert _state(p)["group_colors"] == []
+ assert _state(_make_bar([1, 2, 3]))["group_colors"] == []
- def test_repr_shows_groups(self):
- fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1], [[1, 2], [3, 4]])
- assert "groups=2" in repr(p)
+ def test_3d_height_raises(self):
+ with pytest.raises(ValueError):
+ _bar([1], np.ones((1, 2, 3)))
def test_set_data_2d_values(self):
fig, ax = apl.subplots(1, 1)
@@ -284,13 +276,13 @@ def test_set_data_2d_values(self):
def test_set_data_group_count_mismatch_raises(self):
fig, ax = apl.subplots(1, 1)
p = ax.bar(["A", "B"], [[1, 2], [3, 4]]) # groups=2
- with pytest.raises(ValueError, match="Group count mismatch"):
- p.set_data([[1, 2, 3], [4, 5, 6]]) # 3 groups → error
+ with pytest.raises(ValueError, match="Group count"):
+ p.set_data([[1, 2, 3], [4, 5, 6]])
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
# 5. Log scale
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
class TestPlotBarLogScale:
@@ -302,31 +294,28 @@ def test_log_scale_flag_stored(self):
def test_log_scale_data_min_positive(self):
"""data_min must be > 0 when log_scale=True."""
fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1, 2], [1, 10, 100], log_scale=True)
- assert _state(p)["data_min"] > 0.0
+ assert _state(ax.bar([0, 1, 2], [1, 10, 100], log_scale=True))["data_min"] > 0.0
def test_log_scale_negative_values_clamped(self):
- """Negative values do NOT raise; they are clamped for display."""
+ """Negative values are clamped for display, not raised."""
fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1, 2], [-5, 10, 100], log_scale=True)
- st = _state(p)
+ st = _state(ax.bar([0, 1, 2], [-5, 10, 100], log_scale=True))
assert st["log_scale"] is True
- assert st["data_min"] > 0.0 # clamped, not raised
+ assert st["data_min"] > 0.0
def test_log_scale_all_negative_clamped(self):
"""All-negative values → data_min clamps to 1e-10."""
fig, ax = apl.subplots(1, 1)
- p = ax.bar([0, 1], [-3, -1], log_scale=True)
- assert _state(p)["data_min"] > 0.0
+ assert _state(ax.bar([0, 1], [-3, -1], log_scale=True))["data_min"] > 0.0
- def test_set_log_scale_enables(self):
+ def test_set_log_scale_on(self):
p = _make_bar([1, 10, 100])
p.set_log_scale(True)
st = _state(p)
assert st["log_scale"] is True
assert st["data_min"] > 0.0
- def test_set_log_scale_disables(self):
+ def test_set_log_scale_off(self):
fig, ax = apl.subplots(1, 1)
p = ax.bar([0, 1, 2], [1, 10, 100], log_scale=True)
p.set_log_scale(False)
@@ -340,9 +329,9 @@ def test_set_log_scale_push(self):
assert data["log_scale"] is True
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
# 6. set_data() — value replacement
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
class TestPlotBarSetData:
@@ -386,15 +375,21 @@ def test_update_preserves_baseline(self):
p.set_data([10, 20, 30])
assert _state(p)["baseline"] == pytest.approx(2.0)
- def test_set_data_3d_raises(self):
+ def test_set_data_range_recalculated(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_bad_ndim_raises(self):
p = _make_bar([1, 2, 3])
with pytest.raises(ValueError, match="1-D or 2-D"):
p.set_data(np.zeros((2, 2, 2)))
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
# 7. Display-setting mutations
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
class TestPlotBarDisplayMutations:
@@ -419,44 +414,40 @@ def test_set_show_values_false(self):
assert _state(p)["show_values"] is False
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
# 8. _push() / Figure integration
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
class TestPlotBarPush:
def test_panel_trait_exists_after_attach(self):
fig, ax = apl.subplots(1, 1)
p = ax.bar([1, 2, 3])
- trait_name = f"panel_{p._id}_json"
- assert fig.has_trait(trait_name), f"Missing trait {trait_name!r}"
+ assert fig.has_trait(f"panel_{p._id}_json")
def test_panel_json_contains_kind_bar(self):
fig, ax = apl.subplots(1, 1)
p = ax.bar([1, 2, 3])
- trait_name = f"panel_{p._id}_json"
- data = json.loads(getattr(fig, trait_name))
+ data = json.loads(getattr(fig, f"panel_{p._id}_json"))
assert data["kind"] == "bar"
def test_panel_json_values_after_update(self):
fig, ax = apl.subplots(1, 1)
p = ax.bar([1, 2, 3])
p.set_data([7, 8, 9])
- trait_name = f"panel_{p._id}_json"
- data = json.loads(getattr(fig, trait_name))
+ data = json.loads(getattr(fig, f"panel_{p._id}_json"))
assert data["values"] == pytest.approx(np.array([[7.0], [8.0], [9.0]]))
def test_panel_json_color_after_set_color(self):
fig, ax = apl.subplots(1, 1)
p = ax.bar([1, 2, 3])
p.set_color("#112233")
- trait_name = f"panel_{p._id}_json"
- data = json.loads(getattr(fig, trait_name))
+ data = json.loads(getattr(fig, f"panel_{p._id}_json"))
assert data["bar_color"] == "#112233"
def test_push_without_figure_is_noop(self):
p = PlotBar([1, 2, 3])
- p._push()
+ p._push() # must not raise
def test_layout_json_kind_bar(self):
fig, ax = apl.subplots(1, 1)
@@ -466,21 +457,19 @@ def test_layout_json_kind_bar(self):
assert panel_spec["kind"] == "bar"
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
# 9. Callback API
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
class TestPlotBarCallbacks:
def test_has_callback_registry(self):
- p = _make_bar()
- assert isinstance(p.callbacks, CallbackRegistry)
+ assert isinstance(_make_bar().callbacks, CallbackRegistry)
def test_on_click_decorator_returns_fn(self):
p = _make_bar()
fn = lambda e: None
- returned = p.on_click(fn)
- assert returned is fn
+ assert p.on_click(fn) is fn
def test_on_click_stamps_cid(self):
p = _make_bar()
@@ -502,7 +491,6 @@ def cb(event): fired.append(event)
assert len(fired) == 1
def test_on_click_event_data_with_group(self):
- """on_click event carries group_index and group_value."""
p = _make_bar([10, 20, 30])
fired = []
@@ -513,15 +501,15 @@ def cb(event): fired.append(event)
{"bar_index": 1, "value": 20.0,
"group_index": 0, "group_value": 20.0,
"x_center": 1.0, "x_label": "B"}))
- assert fired[0].bar_index == 1
- assert fired[0].value == pytest.approx(20.0)
- assert fired[0].group_index == 0
- assert fired[0].group_value == pytest.approx(20.0)
- assert fired[0].x_center == pytest.approx(1.0)
- assert fired[0].x_label == "B"
+ ev = fired[0]
+ assert ev.bar_index == 1
+ assert ev.value == pytest.approx(20.0)
+ assert ev.group_index == 0
+ assert ev.group_value == pytest.approx(20.0)
+ assert ev.x_center == pytest.approx(1.0)
+ assert ev.x_label == "B"
def test_on_click_grouped_event(self):
- """group_index reflects which group was clicked."""
fig, ax = apl.subplots(1, 1)
p = ax.bar(["A", "B"], [[1, 10], [2, 20]])
fired = []
@@ -581,56 +569,113 @@ def cb2(event): log.append("b")
assert sorted(log) == ["a", "b"]
-# ─────────────────────────────────────────────────────────────────────────────
-# 10. Edge cases
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
+# 10. Widgets
+# ===========================================================================
+
+class TestPlotBarWidgets:
+
+ def test_add_vline_widget(self):
+ bar = _bar(["A", "B", "C"], [10, 20, 30])
+ 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])
+ 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])
+ 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])
+ 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_raises(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_raises(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() == []
+
+
+# ===========================================================================
+# 11. Edge cases
+# ===========================================================================
class TestPlotBarEdgeCases:
def test_single_bar(self):
- p = _make_bar([42])
- st = _state(p)
+ st = _state(_make_bar([42]))
assert len(st["values"]) == 1
assert st["data_max"] > st["data_min"]
def test_large_n(self):
values = list(range(200))
- p = _make_bar(values)
- assert len(_state(p)["values"]) == 200
- assert len(_state(p)["x_centers"]) == 200
+ st = _state(_make_bar(values))
+ assert len(st["values"]) == 200
+ assert len(st["x_centers"]) == 200
def test_all_negative_values(self):
- p = _make_bar([-5, -3, -1])
- st = _state(p)
+ st = _state(_make_bar([-5, -3, -1]))
assert st["data_min"] < -5.0
assert st["data_max"] >= 0.0
def test_mixed_positive_negative(self):
- p = _make_bar([-10, 0, 10])
- st = _state(p)
+ st = _state(_make_bar([-10, 0, 10]))
assert st["data_min"] < -10.0
assert st["data_max"] > 10.0
def test_float_values(self):
- p = _make_bar([1.1, 2.2, 3.3])
- assert _state(p)["values"] == pytest.approx(np.array([[1.1], [2.2], [3.3]]))
+ assert _state(_make_bar([1.1, 2.2, 3.3]))["values"] == pytest.approx(
+ np.array([[1.1], [2.2], [3.3]])
+ )
def test_x_centers_float(self):
- p = _make_bar([1, 2, 3], x_centers=[0.5, 1.5, 2.5])
- assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5])
+ assert _state(_make_bar([1, 2, 3], x_centers=[0.5, 1.5, 2.5]))["x_centers"] == pytest.approx(
+ [0.5, 1.5, 2.5]
+ )
def test_bar_width_zero_boundary(self):
- p = _make_bar(bar_width=0.0)
- assert _state(p)["bar_width"] == pytest.approx(0.0)
+ assert _state(_make_bar(bar_width=0.0))["bar_width"] == pytest.approx(0.0)
def test_bar_width_one_boundary(self):
- p = _make_bar(bar_width=1.0)
- assert _state(p)["bar_width"] == pytest.approx(1.0)
+ assert _state(_make_bar(bar_width=1.0))["bar_width"] == pytest.approx(1.0)
-# ─────────────────────────────────────────────────────────────────────────────
-# 11. Validation errors
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
+# 12. Validation errors
+# ===========================================================================
class TestPlotBarValidation:
@@ -646,33 +691,24 @@ def test_x_centers_length_mismatch_raises(self):
with pytest.raises(ValueError, match="length"):
PlotBar([1, 2, 3], x_centers=[0, 1])
- def test_axes_bar_returns_plotbar_instance(self):
- fig, ax = apl.subplots(1, 1)
- p = ax.bar([1, 2, 3])
- assert isinstance(p, PlotBar)
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 12. repr
-# ─────────────────────────────────────────────────────────────────────────────
+# ===========================================================================
+# 13. repr
+# ===========================================================================
class TestPlotBarRepr:
def test_repr_contains_n(self):
- p = _make_bar([1, 2, 3, 4])
- assert "n=4" in repr(p)
+ assert "n=4" in repr(_make_bar([1, 2, 3, 4]))
def test_repr_contains_orient_v(self):
- p = _make_bar([1, 2, 3])
- assert "orient='v'" in repr(p)
+ assert "orient='v'" in repr(_make_bar([1, 2, 3]))
def test_repr_contains_orient_h(self):
- p = _make_bar([1, 2, 3], orient="h")
- assert "orient='h'" in repr(p)
+ assert "orient='h'" in repr(_make_bar([1, 2, 3], orient="h"))
def test_repr_is_string(self):
- p = _make_bar()
- assert isinstance(repr(p), str)
+ assert isinstance(repr(_make_bar()), str)
def test_repr_grouped_shows_groups(self):
fig, ax = apl.subplots(1, 1)
@@ -680,3 +716,6 @@ def test_repr_grouped_shows_groups(self):
assert "groups=2" in repr(p)
assert "n=2" in repr(p)
+ def test_repr_contains_plotbar(self):
+ assert "PlotBar" in repr(_bar([1, 2, 3], [10, 20, 30]))
+
diff --git a/anyplotlib/tests/test_plot2d/__init__.py b/anyplotlib/tests/test_plot2d/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/anyplotlib/tests/test_plot2d/test_imshow.py b/anyplotlib/tests/test_plot2d/test_imshow.py
new file mode 100644
index 0000000..e4acaa7
--- /dev/null
+++ b/anyplotlib/tests/test_plot2d/test_imshow.py
@@ -0,0 +1,633 @@
+"""
+tests/test_plot2d/test_imshow.py
+=================================
+
+Comprehensive tests for Plot2D (imshow).
+
+Covers:
+ * Construction: kind, cmap, vmin/vmax, origin, axes, validation
+ * Colormap: cmap kwarg, LUT building, None default, name property/setter
+ * vmin/vmax: defaults, overrides, raw_min/raw_max, set_clim post-construction
+ * Origin: upper/lower storage, y-axis reversal, data flip, set_data re-flip
+ * Setters: set_colormap, set_clim, set_scale_mode, set_data, data property
+ * Widgets: add_widget (all kinds), remove_widget, list_widgets, clear_widgets, get_widget
+ * Markers: add_circles, add_points (uses "circles" wire type on Plot2D)
+ * View: set_view (x-only, y-only, x+y), reset_view, _view_from_python flag
+ * Overlay mask: set_overlay_mask, clear, shape/alpha/color validation, origin-lower flip
+ * Insets: add_inset, minimize, maximize, restore, inset_state
+ * __repr__
+"""
+from __future__ import annotations
+
+import base64
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib.figure_plots import Plot2D
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _img(n=32, **kwargs) -> Plot2D:
+ """Create a Plot2D attached to a one-panel Figure with deterministic data."""
+ fig, ax = apl.subplots(1, 1)
+ data = np.arange(n * n, dtype=float).reshape(n, n)
+ return ax.imshow(data, **kwargs)
+
+
+# 4×4 ramp: values 0..15 (row 0 = [0,1,2,3], row 3 = [12,13,14,15])
+DATA = np.arange(16, dtype=float).reshape(4, 4)
+X = np.array([1.0, 2.0, 3.0, 4.0])
+Y = np.array([10.0, 20.0, 30.0, 40.0])
+
+
+def _decoded(v: Plot2D) -> np.ndarray:
+ """Return the stored uint8 image as a (H, W) array."""
+ raw = base64.b64decode(v._state["image_b64"])
+ return np.frombuffer(raw, dtype=np.uint8).reshape(
+ v._state["image_height"], v._state["image_width"]
+ )
+
+
+# ===========================================================================
+# Construction
+# ===========================================================================
+
+class TestImshowConstruction:
+
+ def test_kind_is_2d(self):
+ v = _img()
+ assert v._state["kind"] == "2d"
+
+ 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"
+
+ def test_bad_data_shape_1d(self):
+ with pytest.raises(ValueError):
+ fig, ax = apl.subplots(1, 1)
+ ax.imshow(np.zeros(16))
+
+
+# ===========================================================================
+# Colormap
+# ===========================================================================
+
+class TestImshowColormap:
+
+ def test_default_cmap_is_gray(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA)
+ assert v._state["colormap_name"] == "gray"
+
+ def test_cmap_kwarg(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, cmap="viridis")
+ assert v._state["colormap_name"] == "viridis"
+
+ def test_cmap_builds_lut(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, cmap="inferno")
+ lut = v._state["colormap_data"]
+ assert len(lut) == 256
+ assert len(lut[0]) == 3 # [r, g, b]
+
+ def test_cmap_none_uses_gray(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, cmap=None)
+ assert v._state["colormap_name"] == "gray"
+
+ 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"
+
+
+# ===========================================================================
+# vmin / vmax
+# ===========================================================================
+
+class TestImshowVminVmax:
+
+ def test_default_uses_data_range(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA)
+ assert v._state["display_min"] == pytest.approx(0.0)
+ assert v._state["display_max"] == pytest.approx(15.0)
+
+ def test_vmin_sets_display_min(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, vmin=3.0)
+ assert v._state["display_min"] == pytest.approx(3.0)
+ assert v._state["display_max"] == pytest.approx(15.0) # unchanged
+
+ def test_vmax_sets_display_max(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, vmax=12.0)
+ assert v._state["display_min"] == pytest.approx(0.0) # unchanged
+ assert v._state["display_max"] == pytest.approx(12.0)
+
+ def test_vmin_vmax_together(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, vmin=3.0, vmax=12.0)
+ assert v._state["display_min"] == pytest.approx(3.0)
+ assert v._state["display_max"] == pytest.approx(12.0)
+
+ def test_raw_range_unaffected_by_vmin_vmax(self):
+ """raw_min/raw_max always reflect the actual data range."""
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, vmin=3.0, vmax=12.0)
+ assert v._state["raw_min"] == pytest.approx(0.0)
+ assert v._state["raw_max"] == pytest.approx(15.0)
+
+ def test_set_clim_still_works_after_construction(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, vmin=3.0, vmax=12.0)
+ v.set_clim(vmin=1.0, vmax=14.0)
+ assert v._state["display_min"] == pytest.approx(1.0)
+ assert v._state["display_max"] == pytest.approx(14.0)
+
+
+# ===========================================================================
+# Origin
+# ===========================================================================
+
+class TestImshowOrigin:
+
+ def test_upper_is_default(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA)
+ assert v._origin == "upper"
+
+ def test_upper_keeps_y_axis_order(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, axes=[X, Y], origin="upper")
+ assert v._state["y_axis"][0] == pytest.approx(10.0) # top of image
+ assert v._state["y_axis"][-1] == pytest.approx(40.0) # bottom
+
+ def test_upper_row0_at_top(self):
+ """With origin='upper', row 0 of data (min values) is stored first."""
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, origin="upper")
+ stored = _decoded(v)
+ assert stored[0, 0] == 0 # row 0, col 0 → value 0 → uint8 min
+
+ def test_lower_stored(self):
+ v = _img(origin="lower")
+ assert v._origin == "lower"
+
+ def test_lower_reverses_y_axis_with_axes(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, axes=[X, Y], origin="lower")
+ assert v._state["y_axis"][0] == pytest.approx(40.0) # max at top
+ assert v._state["y_axis"][-1] == pytest.approx(10.0) # min at bottom
+
+ def test_lower_default_y_axis_reversed(self):
+ """Without explicit axes, origin='lower' still reverses default y."""
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, origin="lower")
+ assert v._state["y_axis"][0] > v._state["y_axis"][-1]
+
+ def test_lower_flips_data(self):
+ """With origin='lower', row 0 of original data appears at the bottom."""
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, origin="lower")
+ stored = _decoded(v)
+ assert stored[0, :].max() == 255 # top row contains the global max
+ assert stored[-1, :].min() == 0 # bottom row contains the global min
+
+ def test_lower_set_data_reapplies_flip(self):
+ """set_data() with origin='lower' automatically re-flips new data."""
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, origin="lower")
+ v.set_data(DATA)
+ stored = _decoded(v)
+ assert stored[0, :].max() == 255
+ assert stored[-1, :].min() == 0
+
+ def test_lower_set_data_reverses_new_y_axis(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, origin="lower")
+ v.set_data(DATA, y_axis=Y)
+ assert v._state["y_axis"][0] == pytest.approx(40.0)
+ assert v._state["y_axis"][-1] == pytest.approx(10.0)
+
+ 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)
+
+ def test_invalid_origin_raises(self):
+ fig, ax = apl.subplots()
+ with pytest.raises(ValueError, match="origin"):
+ ax.imshow(DATA, origin="diagonal")
+
+ def test_combined_params(self):
+ fig, ax = apl.subplots()
+ v = ax.imshow(DATA, cmap="inferno", vmin=2.0, vmax=13.0,
+ origin="lower", axes=[X, Y])
+ assert v._state["colormap_name"] == "inferno"
+ assert v._state["display_min"] == pytest.approx(2.0)
+ assert v._state["display_max"] == pytest.approx(13.0)
+ assert v._state["y_axis"][0] == pytest.approx(40.0) # reversed
+ stored = _decoded(v)
+ assert stored[0, :].max() == 255 # flipped: top row has max value
+
+
+# ===========================================================================
+# Setters and data property
+# ===========================================================================
+
+class TestImshowSetters:
+
+ 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_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
+
+
+# ===========================================================================
+# Widgets
+# ===========================================================================
+
+class TestImshowWidgets:
+
+ 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)
+ v.add_widget("rectangle")
+ assert len(v._widgets) == 1
+
+ def test_add_annular_widget(self):
+ v = _img(n=64)
+ 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)
+ v.add_widget("polygon")
+ assert len(v._widgets) == 1
+
+ def test_add_crosshair_widget(self):
+ v = _img(n=64)
+ v.add_widget("crosshair", cx=32, cy=32)
+ assert len(v._widgets) == 1
+
+ def test_add_label_widget(self):
+ v = _img(n=64)
+ 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() == []
+
+
+# ===========================================================================
+# Markers (add_circles / add_points on Plot2D)
+# ===========================================================================
+
+class TestImshowMarkers:
+
+ def test_add_circles_does_not_crash(self):
+ """add_circles on a Plot2D must not raise ValueError."""
+ plot = _img()
+ offsets = np.array([[8.0, 8.0], [16.0, 16.0]])
+ mg = plot.add_circles(offsets, name="g1", radius=3)
+ assert mg is not None
+ wire = plot.markers.to_wire_list()
+ assert len(wire) == 1
+ assert wire[0]["type"] == "circles"
+
+ def test_add_circles_radius_in_wire(self):
+ """add_circles must pass radius embedded as 'sizes' in wire format."""
+ plot = _img()
+ offsets = np.array([[4.0, 4.0]])
+ plot.add_circles(offsets, name="c1", radius=7)
+ wire = plot.markers.to_wire_list()
+ assert wire[0]["type"] == "circles"
+ sizes = wire[0].get("sizes")
+ assert sizes is not None and all(s == 7.0 for s in sizes)
+
+ def test_add_points_uses_circles_type(self):
+ """add_points on a Plot2D must use the 'circles' wire type, not 'points'."""
+ plot = _img()
+ offsets = np.array([[8.0, 8.0]])
+ mg = plot.add_points(offsets, name="p1", sizes=5)
+ assert mg is not None
+ wire = plot.markers.to_wire_list()
+ assert wire[0]["type"] == "circles"
+
+
+# ===========================================================================
+# View: set_view / reset_view
+# ===========================================================================
+
+class TestImshowView:
+
+ def _make_with_x_axis(self, shape=(32, 32)):
+ data = np.zeros(shape)
+ x_axis = np.linspace(0.0, float(shape[1]), shape[1])
+ fig, ax = apl.subplots(1, 1)
+ return ax.imshow(data, axes=[x_axis, None])
+
+ def test_set_view_x_only(self):
+ """set_view(x0, x1) must update center_x and zoom, not view_x0/view_x1."""
+ plot = self._make_with_x_axis()
+ plot.set_view(x0=8.0, x1=24.0)
+ # center_x should be midpoint fraction: (8+24)/2 / 32 = 0.5
+ assert abs(plot._state["center_x"] - 0.5) < 1e-6
+ # zoom_x = 32 / (24-8) = 2.0
+ assert abs(plot._state["zoom"] - 2.0) < 1e-6
+ assert "view_x0" not in plot._state
+ assert "view_x1" not in plot._state
+
+ def test_set_view_y_only(self):
+ """set_view(y0=..., y1=...) must update center_y and zoom."""
+ data = np.zeros((32, 32))
+ y_axis = np.linspace(0.0, 32.0, 32)
+ fig, ax = apl.subplots(1, 1)
+ plot = ax.imshow(data, axes=[None, y_axis])
+ plot.set_view(y0=8.0, y1=24.0)
+ assert abs(plot._state["center_y"] - 0.5) < 1e-6
+ assert abs(plot._state["zoom"] - 2.0) < 1e-6
+
+ def test_set_view_xy(self):
+ """set_view(x0, x1, y0, y1) uses minimum zoom when both axes given."""
+ data = np.zeros((32, 64))
+ x_axis = np.linspace(0.0, 64.0, 64)
+ y_axis = np.linspace(0.0, 32.0, 32)
+ fig, ax = apl.subplots(1, 1)
+ plot = ax.imshow(data, axes=[x_axis, y_axis])
+ plot.set_view(x0=0, x1=32, y0=0, y1=16)
+ zoom_x = 64.0 / 32.0 # = 2.0
+ zoom_y = 32.0 / 16.0 # = 2.0
+ expected_zoom = min(zoom_x, zoom_y)
+ assert abs(plot._state["zoom"] - expected_zoom) < 1e-6
+
+ def test_reset_view(self):
+ """reset_view must restore zoom=1, center_x=0.5, center_y=0.5."""
+ plot = _img()
+ plot.set_view(x0=4, x1=28)
+ plot.reset_view()
+ assert plot._state["zoom"] == 1.0
+ assert plot._state["center_x"] == 0.5
+ assert plot._state["center_y"] == 0.5
+ assert "view_x0" not in plot._state
+ assert "view_x1" not in plot._state
+
+ def test_view_from_python_flag_set_view(self):
+ """set_view() sets _view_from_python briefly; it is False after push."""
+ plot = self._make_with_x_axis()
+ plot.set_view(x0=8.0, x1=24.0)
+ assert plot._state["_view_from_python"] is False
+
+ def test_view_from_python_flag_reset_view(self):
+ """reset_view() sets _view_from_python briefly; it is False after push."""
+ plot = _img()
+ plot.reset_view()
+ assert plot._state["_view_from_python"] is False
+
+
+# ===========================================================================
+# Overlay mask
+# ===========================================================================
+
+class TestImshowOverlayMask:
+
+ def test_set_overlay_mask_sets_state(self):
+ plot = _img(n=16)
+ mask = np.zeros((16, 16), dtype=bool)
+ mask[4:12, 4:12] = True
+ plot.set_overlay_mask(mask)
+ assert plot._state["overlay_mask_b64"] != ""
+ assert plot._state["overlay_mask_color"] == "#ff4444"
+ assert plot._state["overlay_mask_alpha"] == 0.4
+
+ def test_set_overlay_mask_clear(self):
+ plot = _img(n=16)
+ mask = np.ones((16, 16), dtype=bool)
+ plot.set_overlay_mask(mask)
+ assert plot._state["overlay_mask_b64"] != ""
+ plot.set_overlay_mask(None)
+ assert plot._state["overlay_mask_b64"] == ""
+
+ def test_set_overlay_mask_shape_mismatch(self):
+ fig, ax = apl.subplots(1, 1)
+ plot = ax.imshow(np.zeros((16, 32)))
+ bad_mask = np.zeros((8, 8), dtype=bool)
+ with pytest.raises(ValueError, match="mask shape"):
+ plot.set_overlay_mask(bad_mask)
+
+ def test_set_overlay_mask_alpha_boundary(self):
+ plot = _img(n=16)
+ mask = np.zeros((16, 16), dtype=bool)
+ plot.set_overlay_mask(mask, alpha=0.0)
+ assert plot._state["overlay_mask_alpha"] == 0.0
+ plot.set_overlay_mask(mask, alpha=1.0)
+ assert plot._state["overlay_mask_alpha"] == 1.0
+
+ def test_set_overlay_mask_alpha_out_of_range(self):
+ plot = _img(n=16)
+ mask = np.zeros((16, 16), dtype=bool)
+ with pytest.raises(ValueError, match="alpha"):
+ plot.set_overlay_mask(mask, alpha=1.5)
+ with pytest.raises(ValueError, match="alpha"):
+ plot.set_overlay_mask(mask, alpha=-0.1)
+
+ def test_set_overlay_mask_valid_color(self):
+ plot = _img(n=16)
+ mask = np.zeros((16, 16), dtype=bool)
+ plot.set_overlay_mask(mask, color="#aabbcc")
+ assert plot._state["overlay_mask_color"] == "#aabbcc"
+
+ def test_set_overlay_mask_invalid_color(self):
+ plot = _img(n=16)
+ mask = np.zeros((16, 16), dtype=bool)
+ with pytest.raises(ValueError, match="color"):
+ plot.set_overlay_mask(mask, color="red")
+ with pytest.raises(ValueError, match="color"):
+ plot.set_overlay_mask(mask, color="#fff")
+ with pytest.raises(ValueError, match="color"):
+ plot.set_overlay_mask(mask, color="#GGGGGG")
+
+ def test_set_overlay_mask_origin_lower_flips(self):
+ """For origin='lower' the mask is flipped to match the internally-flipped image."""
+ fig, ax = apl.subplots(1, 1)
+ data = np.zeros((4, 4))
+ plot = ax.imshow(data, origin="lower")
+ mask = np.zeros((4, 4), dtype=bool)
+ mask[0, :] = True # only the top row
+ plot.set_overlay_mask(mask)
+ raw = base64.b64decode(plot._state["overlay_mask_b64"])
+ stored = np.frombuffer(raw, dtype=np.uint8).reshape(4, 4)
+ # After flipud the True row should be at the last row (index 3), not row 0
+ assert stored[3, 0] == 255
+ assert stored[0, 0] == 0
+
+
+# ===========================================================================
+# Insets
+# ===========================================================================
+
+class TestImshowInsets:
+
+ 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
+
+
+# ===========================================================================
+# __repr__
+# ===========================================================================
+
+class TestImshowRepr:
+
+ def test_repr_contains_dimensions_and_cmap(self):
+ fig, ax = apl.subplots(1, 1)
+ plot = ax.imshow(np.zeros((128, 256)))
+ r = repr(plot)
+ assert "Plot2D" in r
+ assert "256" in r # width
+ assert "128" in r # height
+ assert "gray" in r # default colormap
+
diff --git a/tests/test_pcolormesh_extras.py b/anyplotlib/tests/test_plot2d/test_pcolormesh.py
similarity index 81%
rename from tests/test_pcolormesh_extras.py
rename to anyplotlib/tests/test_plot2d/test_pcolormesh.py
index a8c0d58..c958c56 100644
--- a/tests/test_pcolormesh_extras.py
+++ b/anyplotlib/tests/test_plot2d/test_pcolormesh.py
@@ -1,16 +1,17 @@
"""
-tests/test_pcolormesh_extras.py
-================================
+tests/test_plot2d/test_pcolormesh.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
+ * Edge-count validation (wrong x/y edge count)
+ * set_colormap() — name and LUT update
+ * set_data() — replacement, units, wrong ndim, wrong edge count
+ * Markers: add_circles, add_lines, labels, mutate via .set()
+ * Marker restrictions: arrows and ellipses disallowed on mesh
+ * to_wire_list round-trip
"""
from __future__ import annotations
@@ -25,7 +26,7 @@
# Helpers
# ---------------------------------------------------------------------------
-def _mesh(M=8, N=12):
+def _mesh(M=8, N=12) -> PlotMesh:
rng = np.random.default_rng(42)
data = rng.standard_normal((M, N))
x_edges = np.linspace(0, N, N + 1)
@@ -34,11 +35,12 @@ def _mesh(M=8, N=12):
return ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges)
-def _log_mesh():
+def _log_mesh() -> PlotMesh:
"""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 = (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)
@@ -46,9 +48,9 @@ def _log_mesh():
return ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="arb.")
-# ---------------------------------------------------------------------------
+# ===========================================================================
# Construction
-# ---------------------------------------------------------------------------
+# ===========================================================================
class TestPlotMeshConstruction:
@@ -73,12 +75,12 @@ def test_units_stored(self):
mesh = _log_mesh()
assert mesh._state["units"] == "arb."
- def test_log_x_edges(self):
+ def test_log_x_edges_accepted(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):
+ def test_default_colormap_present(self):
mesh = _mesh()
assert "colormap_name" in mesh._state
@@ -99,9 +101,9 @@ def test_wrong_y_edge_count(self):
ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges)
-# ---------------------------------------------------------------------------
+# ===========================================================================
# Mutations
-# ---------------------------------------------------------------------------
+# ===========================================================================
class TestPlotMeshMutations:
@@ -141,9 +143,9 @@ def test_set_data_wrong_x_edges(self):
mesh.set_data(new_data, x_edges=bad_x)
-# ---------------------------------------------------------------------------
+# ===========================================================================
# Markers
-# ---------------------------------------------------------------------------
+# ===========================================================================
class TestPlotMeshMarkers:
@@ -153,7 +155,7 @@ def test_add_circles(self):
mesh.add_circles(pts, name="peaks", radius=0.5, edgecolors="#ff1744")
assert "peaks" in mesh.markers["circles"]
- def test_add_circles_labels(self):
+ def test_add_circles_with_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,
@@ -178,7 +180,7 @@ def test_ellipses_disallowed_on_mesh(self):
with pytest.raises(ValueError, match="not allowed"):
mesh.add_ellipses([[0.0, 0.0]], widths=5, heights=3)
- def test_circles_set(self):
+ def test_circles_mutate_via_set(self):
mesh = _mesh()
mesh.add_circles([[2.0, 2.0]], name="c", radius=1.0)
mesh.markers["circles"]["c"].set(radius=2.0)
diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py
new file mode 100644
index 0000000..22b1eef
--- /dev/null
+++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py
@@ -0,0 +1,115 @@
+"""
+tests/test_plot2d/test_plot2d_api.py
+=====================================
+Cross-cutting API and regression tests for the anyplotlib plot2d module.
+Covers:
+ * __repr__ for Plot1D, Plot2D, Plot3D, PlotBar
+ * Plot1D.add_circles still uses "points" wire type (regression guard)
+ * cividis colormap alias resolves to a valid colorcet palette
+ * Top-level public imports: Plot1D, Plot2D, Axes, CallbackRegistry, Event
+ * __all__ completeness: all names in anyplotlib.__all__ exist on the module
+ * No debug print in Figure._on_event
+"""
+from __future__ import annotations
+import numpy as np
+import pytest
+import anyplotlib as apl
+from anyplotlib.figure_plots import Plot1D, Plot2D, Plot3D, PlotBar
+from anyplotlib.callbacks import CallbackRegistry, Event
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+def _make_plot2d(shape=(32, 32)) -> Plot2D:
+ fig, ax = apl.subplots(1, 1)
+ return ax.imshow(np.zeros(shape))
+def _make_plot1d(n=64) -> Plot1D:
+ fig, ax = apl.subplots(1, 1)
+ return ax.plot(np.zeros(n))
+def _make_plot3d() -> Plot3D:
+ fig, ax = apl.subplots(1, 1)
+ x = np.linspace(0, 1, 4)
+ y = np.linspace(0, 1, 4)
+ X, Y = np.meshgrid(x, y)
+ Z = X + Y
+ return ax.plot_surface(X, Y, Z)
+# ===========================================================================
+# __repr__
+# ===========================================================================
+class TestRepr:
+ def test_plot2d_repr(self):
+ plot = _make_plot2d((128, 256))
+ r = repr(plot)
+ assert "Plot2D" in r
+ assert "256" in r
+ assert "128" in r
+ assert "gray" in r
+ def test_plot1d_repr(self):
+ plot = _make_plot1d(100)
+ r = repr(plot)
+ assert "Plot1D" in r
+ assert "100" in r
+ def test_plot3d_repr(self):
+ plot = _make_plot3d()
+ r = repr(plot)
+ assert "Plot3D" in r
+ assert "surface" in r
+ def test_plotbar_repr(self):
+ fig, ax = apl.subplots(1, 1)
+ plot = ax.bar([1, 2, 3])
+ r = repr(plot)
+ assert "PlotBar" in r
+ assert "3" in r
+# ===========================================================================
+# Marker type regression
+# ===========================================================================
+def test_plot1d_add_circles_still_uses_points():
+ """Plot1D.add_circles should continue to use the "points" wire type."""
+ plot = _make_plot1d()
+ offsets = np.array([10.0, 20.0, 30.0])
+ plot.add_circles(offsets, name="ev")
+ wire = plot.markers.to_wire_list()
+ assert wire[0]["type"] == "points"
+# ===========================================================================
+# Colormap alias
+# ===========================================================================
+def test_cividis_alias_resolves():
+ from anyplotlib.figure_plots import _build_colormap_lut, _CMAP_ALIASES
+ alias = _CMAP_ALIASES.get("cividis", "cividis")
+ assert alias != "dimgray"
+ import colorcet as cc
+ assert alias in cc.palette
+ lut = _build_colormap_lut("cividis")
+ assert len(lut) == 256
+ assert lut[0] != lut[-1]
+# ===========================================================================
+# Top-level public API
+# ===========================================================================
+def test_top_level_imports():
+ from anyplotlib import Plot1D, Plot2D, Axes, CallbackRegistry, Event # noqa: F401
+ assert Plot1D is not None
+ assert Plot2D is not None
+ assert Axes is not None
+ assert CallbackRegistry is not None
+ assert Event is not None
+def test_top_level_all():
+ import anyplotlib
+ for name in anyplotlib.__all__:
+ assert hasattr(anyplotlib, name), f"anyplotlib.{name} not found"
+# ===========================================================================
+# No debug print in Figure._on_event
+# ===========================================================================
+def test_no_debug_print_in_on_event(capsys):
+ import json
+ fig, ax = apl.subplots(1, 1)
+ plot = ax.plot(np.zeros(16))
+ payload = {
+ "source": "js",
+ "panel_id": plot._id,
+ "event_type": "on_changed",
+ "zoom": 1.5,
+ "center_x": 0.5,
+ "center_y": 0.5,
+ }
+ fig._on_event({"new": json.dumps(payload)})
+ captured = capsys.readouterr()
+ assert captured.out == "", f"Unexpected stdout: {captured.out!r}"
diff --git a/anyplotlib/tests/test_plot3d/__init__.py b/anyplotlib/tests/test_plot3d/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py
similarity index 100%
rename from tests/test_plot3d.py
rename to anyplotlib/tests/test_plot3d/test_plot3d.py
diff --git a/pyproject.toml b/pyproject.toml
index 4c8c3cc..8142e4c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,12 +46,12 @@ dev = [
]
[tool.pytest.ini_options]
-testpaths = ["tests"]
+testpaths = ["anyplotlib/tests"]
addopts = "--cov=anyplotlib --cov-report=xml --cov-report=term-missing"
[tool.coverage.run]
source = ["anyplotlib"]
-omit = ["tests/*", "Examples/*", "docs/*"]
+omit = ["anyplotlib/tests/*", "Examples/*", "docs/*"]
[tool.coverage.report]
exclude_lines = [
diff --git a/tests/test_imshow_extras.py b/tests/test_imshow_extras.py
deleted file mode 100644
index 6f6da38..0000000
--- a/tests/test_imshow_extras.py
+++ /dev/null
@@ -1,310 +0,0 @@
-"""
-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_imshow_params.py b/tests/test_imshow_params.py
deleted file mode 100644
index d1055ca..0000000
--- a/tests/test_imshow_params.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""
-tests/test_imshow_params.py
-============================
-Tests for the new cmap, vmin, vmax, and origin parameters on Axes.imshow().
-"""
-import base64
-import numpy as np
-import pytest
-import anyplotlib as apl
-
-
-# 4×4 ramp: values 0..15 (row 0 = [0,1,2,3], row 3 = [12,13,14,15])
-DATA = np.arange(16, dtype=float).reshape(4, 4)
-X = np.array([1.0, 2.0, 3.0, 4.0])
-Y = np.array([10.0, 20.0, 30.0, 40.0])
-
-
-def _decoded(v):
- """Return the stored uint8 image as a (H, W) array."""
- raw = base64.b64decode(v._state["image_b64"])
- return np.frombuffer(raw, dtype=np.uint8).reshape(
- v._state["image_height"], v._state["image_width"]
- )
-
-
-# ── cmap ─────────────────────────────────────────────────────────────────────
-
-class TestCmap:
- def test_default_cmap_is_gray(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA)
- assert v._state["colormap_name"] == "gray"
-
- def test_cmap_sets_colormap_name(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, cmap="viridis")
- assert v._state["colormap_name"] == "viridis"
-
- def test_cmap_builds_lut(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, cmap="inferno")
- lut = v._state["colormap_data"]
- assert len(lut) == 256
- assert len(lut[0]) == 3 # [r, g, b]
-
- def test_cmap_none_uses_gray(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, cmap=None)
- assert v._state["colormap_name"] == "gray"
-
-
-# ── vmin / vmax ───────────────────────────────────────────────────────────────
-
-class TestVminVmax:
- def test_default_uses_data_range(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA)
- assert v._state["display_min"] == pytest.approx(0.0)
- assert v._state["display_max"] == pytest.approx(15.0)
-
- def test_vmin_sets_display_min(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, vmin=3.0)
- assert v._state["display_min"] == pytest.approx(3.0)
- assert v._state["display_max"] == pytest.approx(15.0) # unchanged
-
- def test_vmax_sets_display_max(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, vmax=12.0)
- assert v._state["display_min"] == pytest.approx(0.0) # unchanged
- assert v._state["display_max"] == pytest.approx(12.0)
-
- def test_vmin_vmax_together(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, vmin=3.0, vmax=12.0)
- assert v._state["display_min"] == pytest.approx(3.0)
- assert v._state["display_max"] == pytest.approx(12.0)
-
- def test_raw_range_unaffected_by_vmin_vmax(self):
- """raw_min/raw_max always reflect the actual data range."""
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, vmin=3.0, vmax=12.0)
- assert v._state["raw_min"] == pytest.approx(0.0)
- assert v._state["raw_max"] == pytest.approx(15.0)
-
- def test_set_clim_still_works_after_construction(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, vmin=3.0, vmax=12.0)
- v.set_clim(vmin=1.0, vmax=14.0)
- assert v._state["display_min"] == pytest.approx(1.0)
- assert v._state["display_max"] == pytest.approx(14.0)
-
-
-# ── origin ────────────────────────────────────────────────────────────────────
-
-class TestOrigin:
- def test_upper_is_default(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA)
- assert v._origin == "upper"
-
- def test_upper_keeps_y_axis_order(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, axes=[X, Y], origin="upper")
- assert v._state["y_axis"][0] == pytest.approx(10.0) # top of image
- assert v._state["y_axis"][-1] == pytest.approx(40.0) # bottom
-
- def test_upper_row0_at_top(self):
- """With origin='upper', row 0 of data (min values) is stored first."""
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, origin="upper")
- stored = _decoded(v)
- assert stored[0, 0] == 0 # row 0, col 0 → value 0 → uint8 min
-
- def test_lower_reverses_y_axis(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, axes=[X, Y], origin="lower")
- assert v._state["y_axis"][0] == pytest.approx(40.0) # max at top
- assert v._state["y_axis"][-1] == pytest.approx(10.0) # min at bottom
-
- def test_lower_default_y_axis_reversed(self):
- """Without explicit axes, origin='lower' still reverses default y."""
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, origin="lower")
- assert v._state["y_axis"][0] > v._state["y_axis"][-1]
-
- def test_lower_flips_data(self):
- """With origin='lower', row 0 of original data appears at the bottom."""
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, origin="lower")
- stored = _decoded(v)
- # Original row 0 (all small values) is now at the bottom after flip.
- # The max value (15) ends up at stored[0, -1] after flipud, and the
- # min value (0) ends up at stored[-1, 0], so check row extremes.
- assert stored[0, :].max() == 255 # top row contains the global max
- assert stored[-1, :].min() == 0 # bottom row contains the global min
-
- def test_lower_set_data_reapplies_flip(self):
- """set_data() with origin='lower' automatically re-flips new data."""
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, origin="lower")
- v.set_data(DATA)
- stored = _decoded(v)
- assert stored[0, :].max() == 255
- assert stored[-1, :].min() == 0
-
- def test_lower_set_data_reverses_new_y_axis(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, origin="lower")
- v.set_data(DATA, y_axis=Y)
- assert v._state["y_axis"][0] == pytest.approx(40.0)
- assert v._state["y_axis"][-1] == pytest.approx(10.0)
-
- def test_invalid_origin_raises(self):
- fig, ax = apl.subplots()
- # 'lower' is a valid origin — must not raise
- v = ax.imshow(DATA, origin="lower")
- # An unrecognised string must raise ValueError
- with pytest.raises(ValueError):
- ax.imshow(DATA, origin="bottom")
-
-
-# ── combined ─────────────────────────────────────────────────────────────────
-
-class TestCombined:
- def test_all_params_together(self):
- fig, ax = apl.subplots()
- v = ax.imshow(DATA, cmap="inferno", vmin=2.0, vmax=13.0,
- origin="lower", axes=[X, Y])
- assert v._state["colormap_name"] == "inferno"
- assert v._state["display_min"] == pytest.approx(2.0)
- assert v._state["display_max"] == pytest.approx(13.0)
- assert v._state["y_axis"][0] == pytest.approx(40.0) # reversed
- stored = _decoded(v)
- assert stored[0, :].max() == 255 # flipped: top row has max value
-
diff --git a/tests/test_inset_visual.py b/tests/test_inset_visual.py
deleted file mode 100644
index 599480a..0000000
--- a/tests/test_inset_visual.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""
-tests/test_inset_visual.py
-==========================
-
-Pixel-level visual regression tests for InsetAxes.
-
-Each test:
- 1. Builds a deterministic Figure with one or more insets.
- 2. Renders it in headless Chromium via ``take_screenshot``.
- 3. Compares against a golden PNG in ``tests/baselines/``.
-
-Generate / refresh baselines::
-
- uv run pytest tests/test_inset_visual.py --update-baselines -v
-
-Normal CI run (fails on regression)::
-
- uv run pytest tests/test_inset_visual.py -v
-"""
-from __future__ import annotations
-
-import pathlib
-
-import numpy as np
-import pytest
-
-import anyplotlib as apl
-
-BASELINES = pathlib.Path(__file__).parent / "baselines"
-
-
-def _check(name: str, arr: np.ndarray, update: bool) -> None:
- from tests._png_utils import decode_png, encode_png, compare_arrays
-
- path = BASELINES / f"{name}.png"
-
- if update:
- BASELINES.mkdir(exist_ok=True)
- path.write_bytes(encode_png(arr))
- pytest.skip(f"Baseline updated: {path.name}")
-
- if not path.exists():
- pytest.skip(
- f"No baseline for {name!r} — run with --update-baselines to create it"
- )
-
- expected = decode_png(path.read_bytes())
- ok, msg = compare_arrays(arr, expected)
- assert ok, f"Visual regression [{name}]: {msg}"
-
-
-def _main_fig():
- """640×480 figure with a grayscale 64×64 imshow — the inset host."""
- rng = np.random.default_rng(0)
- fig, ax = apl.subplots(1, 1, figsize=(640, 480))
- ax.imshow(rng.uniform(0.0, 1.0, (64, 64)).astype(np.float32))
- return fig
-
-
-class TestInsetVisual:
- """Visual regression tests for the floating inset panel system."""
-
- # ── single inset, normal state ─────────────────────────────────────────
-
- def test_inset_normal_2d(self, take_screenshot, update_baselines):
- """2-D inset in top-right corner, normal state."""
- rng = np.random.default_rng(1)
- fig = _main_fig()
- inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Zoom")
- inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
- cmap="viridis")
- arr = take_screenshot(fig)
- _check("inset_normal_2d", arr, update_baselines)
-
- def test_inset_minimized(self, take_screenshot, update_baselines):
- """Inset collapsed to title bar only after minimize()."""
- rng = np.random.default_rng(2)
- fig = _main_fig()
- inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Phase")
- inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
- inset.minimize()
- arr = take_screenshot(fig)
- _check("inset_minimized", arr, update_baselines)
-
- def test_inset_maximized(self, take_screenshot, update_baselines):
- """Inset expanded to ~72 % of figure after maximize()."""
- rng = np.random.default_rng(3)
- fig = _main_fig()
- inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Detail")
- inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
- cmap="inferno")
- inset.maximize()
- arr = take_screenshot(fig)
- _check("inset_maximized", arr, update_baselines)
-
- # ── two insets stacked in the same corner ──────────────────────────────
-
- def test_inset_stacked(self, take_screenshot, update_baselines):
- """Two insets sharing top-right corner stack with constant gap."""
- rng = np.random.default_rng(4)
- fig = _main_fig()
- i1 = fig.add_inset(0.28, 0.25, corner="top-right", title="A")
- i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
- i2 = fig.add_inset(0.28, 0.25, corner="top-right", title="B")
- i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
- cmap="hot")
- arr = take_screenshot(fig)
- _check("inset_stacked", arr, update_baselines)
-
- # ── 1-D line inset ─────────────────────────────────────────────────────
-
- def test_inset_1d(self, take_screenshot, update_baselines):
- """1-D line plot inset in bottom-right corner."""
- rng = np.random.default_rng(5)
- fig = _main_fig()
- inset = fig.add_inset(0.32, 0.22, corner="bottom-right",
- title="Profile")
- t = np.linspace(0.0, 2 * np.pi, 128)
- inset.plot(np.sin(t) + rng.normal(0, 0.05, 128),
- color="#4fc3f7", linewidth=1.5)
- arr = take_screenshot(fig)
- _check("inset_1d", arr, update_baselines)
-
- # ── stacked with one minimized (restack test) ──────────────────────────
-
- def test_inset_stacked_one_minimized(self, take_screenshot, update_baselines):
- """Two insets in same corner; first minimized — second shifts up."""
- rng = np.random.default_rng(6)
- fig = _main_fig()
- i1 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Min")
- i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
- i2 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Normal")
- i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
- cmap="viridis")
- i1.minimize()
- arr = take_screenshot(fig)
- _check("inset_stacked_one_minimized", arr, update_baselines)
-
diff --git a/tests/test_panel_alignment.py b/tests/test_panel_alignment.py
deleted file mode 100644
index 2d4c260..0000000
--- a/tests/test_panel_alignment.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""
-Tests that 1D and 2D panels within the same figure row/column are pixel-aligned.
-
-The invariant we enforce:
- - All panels in the same grid row share the same canvas height (ph).
- - All panels in the same grid col share the same canvas width (pw).
- - The image/plot area for both 1D and 2D panels sits at:
- x=PAD_L, y=PAD_T, w=pw-PAD_L-PAD_R, h=ph-PAD_T-PAD_B
- So the bottom-left corner of the image area == bottom-left of the 1D plot area.
-
-JS renders both panel types with the same PAD constants, so as long as Python
-assigns the same (pw, ph) to panels in the same row/col, alignment is guaranteed.
-"""
-
-import json
-import numpy as np
-import pytest
-
-import anyplotlib as vw
-
-PAD_L = 58
-PAD_R = 12
-PAD_T = 12
-PAD_B = 42
-
-
-def _panel_sizes(fig):
- """Return {panel_id: (pw, ph)} from the figure's layout_json."""
- layout = json.loads(fig.layout_json)
- return {s["id"]: (s["panel_width"], s["panel_height"]) for s in layout["panel_specs"]}
-
-
-def _panel_specs(fig):
- """Return list of panel spec dicts."""
- return json.loads(fig.layout_json)["panel_specs"]
-
-
-# ── helper: plot-area rect ────────────────────────────────────────────────────
-
-def plot_area(pw, ph):
- """Return (x, y, w, h) of the inner plot/image area for any panel kind."""
- return PAD_L, PAD_T, pw - PAD_L - PAD_R, ph - PAD_T - PAD_B
-
-
-# ── test 1: 2-row, 1-col (2D on top, 1D below) ───────────────────────────────
-
-def test_2row_1col_same_width():
- fig, axs = vw.subplots(2, 1, figsize=(600, 600))
- v2d = axs[0].imshow(np.random.rand(128, 128))
- v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
-
- sizes = _panel_sizes(fig)
- pw2d, ph2d = sizes[v2d._id]
- pw1d, ph1d = sizes[v1d._id]
-
- assert pw2d == pw1d, (
- f"Panels in same column must have equal width: 2D={pw2d}, 1D={pw1d}"
- )
-
-
-def test_2row_1col_plot_area_left_edge_aligned():
- """The left edge of the 2D image area and 1D plot area must be equal (PAD_L)."""
- fig, axs = vw.subplots(2, 1, figsize=(600, 600))
- v2d = axs[0].imshow(np.random.rand(128, 128))
- v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
-
- sizes = _panel_sizes(fig)
- pw2d, ph2d = sizes[v2d._id]
- pw1d, ph1d = sizes[v1d._id]
-
- x2d, _, _, _ = plot_area(pw2d, ph2d)
- x1d, _, _, _ = plot_area(pw1d, ph1d)
-
- assert x2d == x1d == PAD_L, (
- f"Left edge of plot area must be PAD_L={PAD_L}: 2D={x2d}, 1D={x1d}"
- )
-
-
-def test_2row_1col_bottom_left_corner_aligned():
- """
- The bottom-left corner of both plot areas must be at the same canvas-x offset.
- Since canvas widths are equal and PAD_L is shared, bottom-left x is always PAD_L.
- This test also checks the plot areas have the same width.
- """
- fig, axs = vw.subplots(2, 1, figsize=(600, 600))
- v2d = axs[0].imshow(np.random.rand(128, 128))
- v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
-
- sizes = _panel_sizes(fig)
- pw2d, ph2d = sizes[v2d._id]
- pw1d, ph1d = sizes[v1d._id]
-
- x2d, y2d, w2d, h2d = plot_area(pw2d, ph2d)
- x1d, y1d, w1d, h1d = plot_area(pw1d, ph1d)
-
- # Bottom-left x must match
- assert x2d == x1d, f"Bottom-left x: 2D={x2d}, 1D={x1d}"
- # Plot area widths must match (same canvas width)
- assert w2d == w1d, f"Plot area widths: 2D={w2d}, 1D={w1d}"
-
-
-# ── test 2: 1-row, 2-col (2D left, 1D right) ─────────────────────────────────
-
-def test_1row_2col_same_height():
- fig, axs = vw.subplots(1, 2, figsize=(800, 400))
- v2d = axs[0].imshow(np.random.rand(64, 64))
- v1d = axs[1].plot(np.cos(np.linspace(0, 6, 256)))
-
- sizes = _panel_sizes(fig)
- pw2d, ph2d = sizes[v2d._id]
- pw1d, ph1d = sizes[v1d._id]
-
- assert ph2d == ph1d, (
- f"Panels in same row must have equal height: 2D={ph2d}, 1D={ph1d}"
- )
-
-
-def test_1row_2col_plot_area_top_bottom_aligned():
- """Top and bottom y-coordinates of plot areas must match across the row."""
- fig, axs = vw.subplots(1, 2, figsize=(800, 400))
- v2d = axs[0].imshow(np.random.rand(64, 64))
- v1d = axs[1].plot(np.cos(np.linspace(0, 6, 256)))
-
- sizes = _panel_sizes(fig)
- pw2d, ph2d = sizes[v2d._id]
- pw1d, ph1d = sizes[v1d._id]
-
- x2d, y2d, w2d, h2d = plot_area(pw2d, ph2d)
- x1d, y1d, w1d, h1d = plot_area(pw1d, ph1d)
-
- assert y2d == y1d == PAD_T, f"Top y: 2D={y2d}, 1D={y1d}"
- assert h2d == h1d, f"Plot area heights: 2D={h2d}, 1D={h1d}"
-
-
-# ── test 3: 2D panel canvas equals its grid cell ─────────────────────────────
-
-def test_square_image_gets_square_canvas():
- """A 128×128 image in a 500×500 figsize → canvas is 500×500 (pw == ph).
- This still holds: the grid cell is square so the canvas is square too.
- Images are letterboxed in JS; the Python layout never changes the cell size."""
- fig, axs = vw.subplots(1, 1, figsize=(500, 500))
- v2d = axs.imshow(np.random.rand(128, 128))
-
- sizes = _panel_sizes(fig)
- pw, ph = sizes[v2d._id]
- assert pw == ph, f"Square figsize must give pw==ph: pw={pw}, ph={ph}"
-
-
-def test_wide_image_canvas_equals_cell():
- """A 2:1 image in a square cell gets a square canvas — no aspect-lock.
- The image is letterboxed (pillarboxed) by the JS renderer."""
- fig, axs = vw.subplots(1, 1, figsize=(512, 512))
- v2d = axs.imshow(np.random.rand(128, 256)) # w=256, h=128
-
- sizes = _panel_sizes(fig)
- pw, ph = sizes[v2d._id]
- assert pw == 512 and ph == 512, (
- f"Canvas should equal full figsize 512×512, got {pw}×{ph}"
- )
-
-
-# ── test 4: non-square 2D plus 1D — widths consistent ────────────────────────
-
-def test_nonsquare_2d_and_1d_same_column():
- """
- A tall non-square image in a 2-row, 1-col layout: both panels must have
- the same canvas width (dictated by the column track, not the image aspect).
- """
- fig, axs = vw.subplots(2, 1, figsize=(600, 800))
- v2d = axs[0].imshow(np.random.rand(256, 128)) # tall image
- v1d = axs[1].plot(np.random.rand(256))
-
- sizes = _panel_sizes(fig)
- pw2d, ph2d = sizes[v2d._id]
- pw1d, ph1d = sizes[v1d._id]
-
- assert pw2d == pw1d, (
- f"Same-column panels must have equal width: 2D={pw2d}, 1D={pw1d}"
- )
-
-
-# ── test 5: plot area pixel dimensions are positive ──────────────────────────
-
-def test_plot_areas_positive():
- fig, axs = vw.subplots(2, 1, figsize=(400, 400))
- v2d = axs[0].imshow(np.random.rand(64, 64))
- v1d = axs[1].plot(np.random.rand(128))
-
- sizes = _panel_sizes(fig)
- for pid, (pw, ph) in sizes.items():
- x, y, w, h = plot_area(pw, ph)
- assert w > 0, f"Panel {pid}: plot area width must be positive, got {w}"
- assert h > 0, f"Panel {pid}: plot area height must be positive, got {h}"
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
-
-
diff --git a/tests/test_plot1d_extras.py b/tests/test_plot1d_extras.py
deleted file mode 100644
index cd43434..0000000
--- a/tests/test_plot1d_extras.py
+++ /dev/null
@@ -1,397 +0,0 @@
-"""
-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_plot1d_linestyle.py b/tests/test_plot1d_linestyle.py
deleted file mode 100644
index 160a689..0000000
--- a/tests/test_plot1d_linestyle.py
+++ /dev/null
@@ -1,350 +0,0 @@
-"""
-tests/test_plot1d_linestyle.py
-==============================
-
-Unit tests for the new Plot1D line-style parameters:
- * linestyle / ls — dash pattern
- * alpha — line opacity
- * marker / markersize — per-point symbols
-
-Tests cover:
- - _norm_linestyle helper
- - Plot1D._state storage for all new params
- - Axes.plot() forwarding (including ``ls`` shorthand)
- - Setter methods (set_color, set_linewidth, set_linestyle, set_alpha, set_marker)
- - add_line() parity (new fields present in extra_lines dicts)
- - Edge cases: invalid linestyle, marker=None normalised to "none"
-"""
-from __future__ import annotations
-
-import numpy as np
-import pytest
-
-import anyplotlib as apl
-from anyplotlib.figure_plots import _norm_linestyle, Plot1D
-
-
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-def _plot(**kwargs) -> Plot1D:
- """Create a Plot1D via Axes.plot() with deterministic data."""
- fig, ax = apl.subplots(1, 1)
- return ax.plot(np.linspace(0.0, 1.0, 32), **kwargs)
-
-
-# ===========================================================================
-# _norm_linestyle
-# ===========================================================================
-
-class TestNormLinestyle:
- def test_canonical_names_round_trip(self):
- for ls in ("solid", "dashed", "dotted", "dashdot"):
- assert _norm_linestyle(ls) == ls
-
- def test_shorthand_dash(self):
- assert _norm_linestyle("-") == "solid"
-
- def test_shorthand_double_dash(self):
- assert _norm_linestyle("--") == "dashed"
-
- def test_shorthand_colon(self):
- assert _norm_linestyle(":") == "dotted"
-
- def test_shorthand_dashdot(self):
- assert _norm_linestyle("-.") == "dashdot"
-
- def test_invalid_raises(self):
- with pytest.raises(ValueError, match="Unknown linestyle"):
- _norm_linestyle("loose")
-
- def test_invalid_empty_raises(self):
- with pytest.raises(ValueError):
- _norm_linestyle("")
-
-
-# ===========================================================================
-# Plot1D._state — defaults
-# ===========================================================================
-
-class TestPlot1DDefaults:
- def test_linestyle_default(self):
- p = _plot()
- assert p._state["line_linestyle"] == "solid"
-
- def test_alpha_default(self):
- p = _plot()
- assert p._state["line_alpha"] == 1.0
-
- def test_marker_default(self):
- p = _plot()
- assert p._state["line_marker"] == "none"
-
- def test_markersize_default(self):
- p = _plot()
- assert p._state["line_markersize"] == 4.0
-
-
-# ===========================================================================
-# Plot1D._state — construction via Axes.plot()
-# ===========================================================================
-
-class TestAxesPlotForwarding:
- def test_linestyle_stored(self):
- p = _plot(linestyle="dashed")
- assert p._state["line_linestyle"] == "dashed"
-
- def test_ls_shorthand_takes_precedence(self):
- # ls= should win over linestyle= when both are given
- p = _plot(linestyle="solid", ls="--")
- assert p._state["line_linestyle"] == "dashed"
-
- def test_ls_only(self):
- p = _plot(ls=":")
- assert p._state["line_linestyle"] == "dotted"
-
- def test_alpha_stored(self):
- p = _plot(alpha=0.5)
- assert p._state["line_alpha"] == pytest.approx(0.5)
-
- def test_marker_stored(self):
- p = _plot(marker="o")
- assert p._state["line_marker"] == "o"
-
- def test_markersize_stored(self):
- p = _plot(marker="s", markersize=8.0)
- assert p._state["line_markersize"] == pytest.approx(8.0)
-
- def test_marker_none_string(self):
- p = _plot(marker="none")
- assert p._state["line_marker"] == "none"
-
- def test_invalid_linestyle_raises(self):
- with pytest.raises(ValueError, match="Unknown linestyle"):
- _plot(linestyle="zigzag")
-
- def test_all_known_markers(self):
- for sym in ("o", "s", "^", "v", "D", "+", "x", "none"):
- p = _plot(marker=sym)
- assert p._state["line_marker"] == sym
-
-
-# ===========================================================================
-# Setter methods
-# ===========================================================================
-
-class TestSetters:
- def test_set_color(self):
- p = _plot()
- p.set_color("#ff0000")
- assert p._state["line_color"] == "#ff0000"
-
- def test_set_linewidth(self):
- p = _plot()
- p.set_linewidth(3.0)
- assert p._state["line_linewidth"] == pytest.approx(3.0)
-
- def test_set_linestyle_canonical(self):
- p = _plot()
- p.set_linestyle("dotted")
- assert p._state["line_linestyle"] == "dotted"
-
- def test_set_linestyle_shorthand(self):
- p = _plot()
- p.set_linestyle(":")
- assert p._state["line_linestyle"] == "dotted"
-
- def test_set_linestyle_invalid_raises(self):
- p = _plot()
- with pytest.raises(ValueError):
- p.set_linestyle("bad")
-
- def test_set_alpha(self):
- p = _plot()
- p.set_alpha(0.3)
- assert p._state["line_alpha"] == pytest.approx(0.3)
-
- def test_set_marker_symbol(self):
- p = _plot()
- p.set_marker("D")
- assert p._state["line_marker"] == "D"
-
- def test_set_marker_with_size(self):
- p = _plot()
- p.set_marker("s", markersize=10.0)
- assert p._state["line_marker"] == "s"
- assert p._state["line_markersize"] == pytest.approx(10.0)
-
- def test_set_marker_no_size_leaves_default(self):
- p = _plot()
- p.set_marker("^")
- assert p._state["line_markersize"] == pytest.approx(4.0)
-
- def test_set_marker_none_normalised(self):
- p = _plot(marker="o")
- p.set_marker(None) # type: ignore[arg-type]
- assert p._state["line_marker"] == "none"
-
- def test_setters_chain_without_error(self):
- """Multiple setter calls in sequence must not raise."""
- p = _plot()
- p.set_color("#aabbcc")
- p.set_linewidth(2.5)
- p.set_linestyle("--")
- p.set_alpha(0.8)
- p.set_marker("o", markersize=6)
- assert p._state["line_linestyle"] == "dashed"
- assert p._state["line_alpha"] == pytest.approx(0.8)
- assert p._state["line_marker"] == "o"
-
-
-# ===========================================================================
-# add_line() parity
-# ===========================================================================
-
-class TestAddLineParity:
- def _extra(self, **kwargs) -> dict:
- p = _plot()
- p.add_line(np.ones(32), **kwargs)
- return p._state["extra_lines"][0]
-
- def test_default_linestyle(self):
- ex = self._extra()
- assert ex["linestyle"] == "solid"
-
- def test_linestyle_stored(self):
- ex = self._extra(linestyle="dashed")
- assert ex["linestyle"] == "dashed"
-
- def test_ls_shorthand(self):
- ex = self._extra(ls=":")
- assert ex["linestyle"] == "dotted"
-
- def test_ls_overrides_linestyle(self):
- ex = self._extra(linestyle="solid", ls="--")
- assert ex["linestyle"] == "dashed"
-
- def test_default_alpha(self):
- ex = self._extra()
- assert ex["alpha"] == pytest.approx(1.0)
-
- def test_alpha_stored(self):
- ex = self._extra(alpha=0.4)
- assert ex["alpha"] == pytest.approx(0.4)
-
- def test_default_marker(self):
- ex = self._extra()
- assert ex["marker"] == "none"
-
- def test_marker_stored(self):
- ex = self._extra(marker="o", markersize=6.0)
- assert ex["marker"] == "o"
- assert ex["markersize"] == pytest.approx(6.0)
-
- def test_invalid_linestyle_raises(self):
- p = _plot()
- with pytest.raises(ValueError):
- p.add_line(np.ones(32), linestyle="bad")
-
- def test_multiple_extra_lines_independent(self):
- p = _plot()
- p.add_line(np.ones(32), linestyle="dashed", alpha=0.5)
- p.add_line(np.ones(32), linestyle="dotted", alpha=0.8)
- assert p._state["extra_lines"][0]["linestyle"] == "dashed"
- assert p._state["extra_lines"][1]["linestyle"] == "dotted"
- assert p._state["extra_lines"][0]["alpha"] == pytest.approx(0.5)
- assert p._state["extra_lines"][1]["alpha"] == pytest.approx(0.8)
-
-
-# ===========================================================================
-# State dict completeness (to_state_dict round-trip)
-# ===========================================================================
-
-class TestStateDict:
- def test_new_keys_present_in_to_state_dict(self):
- p = _plot(linestyle="dotted", alpha=0.7, marker="s", markersize=5.0)
- sd = p.to_state_dict()
- assert sd["line_linestyle"] == "dotted"
- assert sd["line_alpha"] == pytest.approx(0.7)
- assert sd["line_marker"] == "s"
- assert sd["line_markersize"] == pytest.approx(5.0)
-
- def test_extra_line_new_keys_present(self):
- p = _plot()
- p.add_line(np.zeros(32), linestyle="dashdot", alpha=0.6, marker="D")
- sd = p.to_state_dict()
- ex = sd["extra_lines"][0]
- assert ex["linestyle"] == "dashdot"
- assert ex["alpha"] == pytest.approx(0.6)
- assert ex["marker"] == "D"
-
-
-# ===========================================================================
-# Data range recomputation
-# ===========================================================================
-
-class TestDataRangeRecompute:
- """data_min/data_max must always cover all visible lines."""
-
- def test_add_line_expands_range_upward(self):
- """Overlay line with larger values must push data_max up."""
- p = _plot() # primary: linspace(0, 1, 32) → max ≈ 1
- primary_max = p._state["data_max"]
- p.add_line(np.full(32, 5.0)) # far above primary
- assert p._state["data_max"] > primary_max
- assert p._state["data_max"] >= 5.0
-
- def test_add_line_expands_range_downward(self):
- """Overlay line with smaller values must push data_min down."""
- p = _plot() # primary: linspace(0, 1, 32) → min ≈ 0
- primary_min = p._state["data_min"]
- p.add_line(np.full(32, -5.0)) # far below primary
- assert p._state["data_min"] < primary_min
- assert p._state["data_min"] <= -5.0
-
- def test_add_line_both_directions(self):
- """Range must encompass all added lines simultaneously."""
- p = _plot()
- p.add_line(np.full(32, 10.0))
- p.add_line(np.full(32, -10.0))
- assert p._state["data_max"] >= 10.0
- assert p._state["data_min"] <= -10.0
-
- def test_remove_line_shrinks_range(self):
- """Removing the outlier line must restore a tighter range."""
- p = _plot() # primary: [0, 1]
- lid = p.add_line(np.full(32, 100.0)) # pushes max to ≥100
- assert p._state["data_max"] >= 100.0
- p.remove_line(lid)
- # After removal the range should be based on primary data only
- assert p._state["data_max"] < 10.0 # well below 100
-
- def test_clear_lines_restores_primary_range(self):
- """clear_lines must revert the range to the primary line only."""
- p = _plot()
- original_min = p._state["data_min"]
- original_max = p._state["data_max"]
- p.add_line(np.full(32, 50.0))
- p.add_line(np.full(32, -50.0))
- p.clear_lines()
- assert p._state["data_min"] == pytest.approx(original_min)
- assert p._state["data_max"] == pytest.approx(original_max)
-
- def test_range_includes_padding(self):
- """5 % padding must be applied after recompute, same as construction."""
- p = _plot() # primary min=0, max=1 → padded to (-0.05, 1.05)
- p.add_line(np.zeros(32) + 3.0) # new max = 3
- # padding = (3 - 0) * 0.05 = 0.15 → data_max ≥ 3.15
- assert p._state["data_max"] >= 3.0 * 1.05 - 0.01
-
- def test_primary_only_range_unchanged_when_overlay_within_bounds(self):
- """An overlay that fits inside the primary range must not change the range."""
- p = _plot() # primary covers [0, 1] with padding
- pre_min = p._state["data_min"]
- pre_max = p._state["data_max"]
- p.add_line(np.full(32, 0.5)) # well inside existing range
- assert p._state["data_min"] == pytest.approx(pre_min)
- assert p._state["data_max"] == pytest.approx(pre_max)
-
-
diff --git a/tests/test_plot2d_polish.py b/tests/test_plot2d_polish.py
deleted file mode 100644
index 56c7117..0000000
--- a/tests/test_plot2d_polish.py
+++ /dev/null
@@ -1,379 +0,0 @@
-"""
-tests/test_plot2d_polish.py
-===========================
-
-Regression tests for the 0.1.0 pre-release bug-fix sweep:
-
- * Plot2D.add_circles / add_points use the correct "circles" marker type
- * Plot2D.set_view writes zoom/center_x/center_y (not the non-existent view_x0)
- * Plot2D.reset_view restores zoom=1, center_x=0.5, center_y=0.5
- * Plot2D.__repr__ returns a useful string
- * Plot1D.__repr__ returns a useful string
- * Plot3D.__repr__ returns a useful string
- * cividis colormap alias resolves to a valid colorcet palette (not 'dimgray')
- * Top-level imports: Plot1D, Plot2D, Axes, CallbackRegistry, Event
- * No debug print in Figure._on_event
-"""
-
-from __future__ import annotations
-
-import io
-import sys
-import numpy as np
-import pytest
-
-import anyplotlib as apl
-from anyplotlib.figure_plots import Plot1D, Plot2D, Plot3D, PlotBar
-from anyplotlib.callbacks import CallbackRegistry, Event
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Helpers
-# ─────────────────────────────────────────────────────────────────────────────
-
-def _make_plot2d(shape=(32, 32)) -> Plot2D:
- fig, ax = apl.subplots(1, 1)
- return ax.imshow(np.zeros(shape))
-
-
-def _make_plot1d(n=64) -> Plot1D:
- fig, ax = apl.subplots(1, 1)
- return ax.plot(np.zeros(n))
-
-
-def _make_plot3d() -> Plot3D:
- fig, ax = apl.subplots(1, 1)
- x = np.linspace(0, 1, 4)
- y = np.linspace(0, 1, 4)
- X, Y = np.meshgrid(x, y)
- Z = X + Y
- return ax.plot_surface(X, Y, Z)
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 1. add_circles on Plot2D — must use "circles" type, not "points"
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_plot2d_add_circles_does_not_crash():
- """add_circles on a Plot2D must not raise ValueError ('points' absent from _KNOWN_2D)."""
- plot = _make_plot2d()
- offsets = np.array([[8.0, 8.0], [16.0, 16.0]])
- mg = plot.add_circles(offsets, name="g1", radius=3)
- assert mg is not None
- wire = plot.markers.to_wire_list()
- assert len(wire) == 1
- assert wire[0]["type"] == "circles"
-
-
-def test_plot2d_add_circles_radius_kwarg():
- """add_circles must pass radius, not sizes, to the wire format."""
- plot = _make_plot2d()
- offsets = np.array([[4.0, 4.0]])
- mg = plot.add_circles(offsets, name="c1", radius=7)
- wire = plot.markers.to_wire_list()
- assert wire[0]["type"] == "circles"
- # radius is embedded in the wire as 'sizes' by MarkerGroup.to_wire()
- sizes = wire[0].get("sizes")
- assert sizes is not None and all(s == 7.0 for s in sizes)
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 2. add_points on Plot2D — must use "circles" type
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_plot2d_add_points_does_not_crash():
- """add_points on a Plot2D must not raise ValueError."""
- plot = _make_plot2d()
- offsets = np.array([[8.0, 8.0]])
- mg = plot.add_points(offsets, name="p1", sizes=5)
- assert mg is not None
- wire = plot.markers.to_wire_list()
- assert wire[0]["type"] == "circles"
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 3. Plot1D.add_circles still uses "points" (regression guard)
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_plot1d_add_circles_still_uses_points():
- """Plot1D.add_circles should continue to use the 'points' type."""
- plot = _make_plot1d()
- offsets = np.array([10.0, 20.0, 30.0])
- mg = plot.add_circles(offsets, name="ev")
- wire = plot.markers.to_wire_list()
- assert wire[0]["type"] == "points"
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 4. Plot2D.set_view writes correct state keys
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_plot2d_set_view_x_only():
- """set_view(x0, x1) must update center_x and zoom, not view_x0/view_x1."""
- data = np.zeros((32, 32))
- x_axis = np.linspace(0.0, 32.0, 32)
- fig, ax = apl.subplots(1, 1)
- plot = ax.imshow(data, axes=[x_axis, None])
-
- plot.set_view(x0=8.0, x1=24.0)
-
- # center_x should be midpoint fraction: (8+24)/2 / 32 = 0.5
- assert abs(plot._state["center_x"] - 0.5) < 1e-6
- # zoom_x = 32 / (24-8) = 2.0
- assert abs(plot._state["zoom"] - 2.0) < 1e-6
- # The wrong keys must NOT exist
- assert "view_x0" not in plot._state
- assert "view_x1" not in plot._state
-
-
-def test_plot2d_set_view_y_only():
- """set_view(y0=..., y1=...) must update center_y and zoom."""
- data = np.zeros((32, 32))
- y_axis = np.linspace(0.0, 32.0, 32)
- fig, ax = apl.subplots(1, 1)
- plot = ax.imshow(data, axes=[None, y_axis])
-
- plot.set_view(y0=8.0, y1=24.0)
-
- assert abs(plot._state["center_y"] - 0.5) < 1e-6
- assert abs(plot._state["zoom"] - 2.0) < 1e-6
-
-
-def test_plot2d_set_view_xy():
- """set_view(x0, x1, y0, y1) uses minimum zoom when both axes given."""
- data = np.zeros((32, 64))
- x_axis = np.linspace(0.0, 64.0, 64)
- y_axis = np.linspace(0.0, 32.0, 32)
- fig, ax = apl.subplots(1, 1)
- plot = ax.imshow(data, axes=[x_axis, y_axis])
-
- # Zoom half of x (zoom=2) and a quarter of y (zoom=4): min = 2
- plot.set_view(x0=0, x1=32, y0=0, y1=16)
-
- zoom_x = 64.0 / 32.0 # = 2.0
- zoom_y = 32.0 / 16.0 # = 2.0
- expected_zoom = min(zoom_x, zoom_y)
- assert abs(plot._state["zoom"] - expected_zoom) < 1e-6
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 5. Plot2D.reset_view restores defaults
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_plot2d_reset_view():
- """reset_view must restore zoom=1, center_x=0.5, center_y=0.5."""
- plot = _make_plot2d()
- plot.set_view(x0=4, x1=28) # changes zoom & center_x
- plot.reset_view()
-
- assert plot._state["zoom"] == 1.0
- assert plot._state["center_x"] == 0.5
- assert plot._state["center_y"] == 0.5
- assert "view_x0" not in plot._state
- assert "view_x1" not in plot._state
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 6. __repr__ methods
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_plot2d_repr():
- plot = _make_plot2d((128, 256))
- r = repr(plot)
- assert "Plot2D" in r
- assert "256" in r # width
- assert "128" in r # height
- assert "gray" in r # default colormap
-
-
-def test_plot1d_repr():
- plot = _make_plot1d(100)
- r = repr(plot)
- assert "Plot1D" in r
- assert "100" in r
-
-
-def test_plot3d_repr():
- plot = _make_plot3d()
- r = repr(plot)
- assert "Plot3D" in r
- assert "surface" in r
-
-
-def test_plotbar_repr():
- """PlotBar already had __repr__; make sure it still works."""
- fig, ax = apl.subplots(1, 1)
- plot = ax.bar([1, 2, 3])
- r = repr(plot)
- assert "PlotBar" in r
- assert "3" in r
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 7. cividis colormap alias resolves to a valid colorcet palette
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_cividis_alias_resolves():
- """'cividis' must map to a real colorcet palette (not 'dimgray')."""
- from anyplotlib.figure_plots import _build_colormap_lut, _CMAP_ALIASES
- alias = _CMAP_ALIASES.get("cividis", "cividis")
- assert alias != "dimgray", "cividis alias must not be a CSS colour name"
- import colorcet as cc
- assert alias in cc.palette, f"cividis alias '{alias}' not found in colorcet"
- lut = _build_colormap_lut("cividis")
- assert len(lut) == 256
- # Must not be a gray ramp (first and last entries must differ)
- assert lut[0] != lut[-1], "cividis LUT should not be a flat gray ramp"
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 8. Top-level public API imports
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_top_level_imports():
- """Plot1D, Plot2D, Axes, CallbackRegistry, Event must all be importable."""
- from anyplotlib import Plot1D, Plot2D, Axes, CallbackRegistry, Event # noqa: F401
- assert Plot1D is not None
- assert Plot2D is not None
- assert Axes is not None
- assert CallbackRegistry is not None
- assert Event is not None
-
-
-def test_top_level_all():
- """All names in __all__ must actually exist on the module."""
- import anyplotlib
- for name in anyplotlib.__all__:
- assert hasattr(anyplotlib, name), f"anyplotlib.{name} not found"
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 9. No debug print in Figure._on_event
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_no_debug_print_in_on_event(capsys):
- """Figure._on_event must not print to stdout."""
- import json
- fig, ax = apl.subplots(1, 1)
- plot = ax.plot(np.zeros(16))
-
- # Simulate a JS event (zoom change)
- payload = {
- "source": "js",
- "panel_id": plot._id,
- "event_type": "on_changed",
- "zoom": 1.5,
- "center_x": 0.5,
- "center_y": 0.5,
- }
- fig._on_event({"new": json.dumps(payload)})
-
- captured = capsys.readouterr()
- assert captured.out == "", f"Unexpected stdout: {captured.out!r}"
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 10. set_overlay_mask()
-# ─────────────────────────────────────────────────────────────────────────────
-
-def test_set_overlay_mask_sets_state():
- """set_overlay_mask with a valid mask populates overlay_mask_b64."""
- plot = _make_plot2d((16, 16))
- mask = np.zeros((16, 16), dtype=bool)
- mask[4:12, 4:12] = True
- plot.set_overlay_mask(mask)
- assert plot._state["overlay_mask_b64"] != ""
- assert plot._state["overlay_mask_color"] == "#ff4444"
- assert plot._state["overlay_mask_alpha"] == 0.4
-
-
-def test_set_overlay_mask_clear():
- """set_overlay_mask(None) clears the overlay."""
- plot = _make_plot2d((16, 16))
- mask = np.ones((16, 16), dtype=bool)
- plot.set_overlay_mask(mask)
- assert plot._state["overlay_mask_b64"] != ""
-
- plot.set_overlay_mask(None)
- assert plot._state["overlay_mask_b64"] == ""
-
-
-def test_set_overlay_mask_shape_mismatch():
- """set_overlay_mask with wrong shape raises ValueError."""
- plot = _make_plot2d((16, 32))
- bad_mask = np.zeros((8, 8), dtype=bool)
- with pytest.raises(ValueError, match="mask shape"):
- plot.set_overlay_mask(bad_mask)
-
-
-def test_set_overlay_mask_alpha_validation():
- """set_overlay_mask clamps alpha to [0, 1]; out-of-range raises ValueError."""
- plot = _make_plot2d((16, 16))
- mask = np.zeros((16, 16), dtype=bool)
- # Valid boundary values should work
- plot.set_overlay_mask(mask, alpha=0.0)
- assert plot._state["overlay_mask_alpha"] == 0.0
- plot.set_overlay_mask(mask, alpha=1.0)
- assert plot._state["overlay_mask_alpha"] == 1.0
- # Out-of-range should raise
- with pytest.raises(ValueError, match="alpha"):
- plot.set_overlay_mask(mask, alpha=1.5)
- with pytest.raises(ValueError, match="alpha"):
- plot.set_overlay_mask(mask, alpha=-0.1)
-
-
-def test_set_overlay_mask_color_validation():
- """set_overlay_mask raises ValueError for non-#RRGGBB color strings."""
- plot = _make_plot2d((16, 16))
- mask = np.zeros((16, 16), dtype=bool)
- # Valid color should work
- plot.set_overlay_mask(mask, color="#aabbcc")
- assert plot._state["overlay_mask_color"] == "#aabbcc"
- # Short hex, named colors, or malformed should raise
- with pytest.raises(ValueError, match="color"):
- plot.set_overlay_mask(mask, color="red")
- with pytest.raises(ValueError, match="color"):
- plot.set_overlay_mask(mask, color="#fff")
- with pytest.raises(ValueError, match="color"):
- plot.set_overlay_mask(mask, color="#GGGGGG")
-
-
-def test_set_overlay_mask_origin_lower_flips():
- """For origin='lower' the mask is flipped to match the internally-flipped image."""
- import base64
- fig, ax = apl.subplots(1, 1)
- data = np.zeros((4, 4))
- plot = ax.imshow(data, origin="lower")
-
- # Mask with only the top row (row 0) set True
- mask = np.zeros((4, 4), dtype=bool)
- mask[0, :] = True
-
- plot.set_overlay_mask(mask)
- # Decode the stored bytes
- raw = base64.b64decode(plot._state["overlay_mask_b64"])
- stored = np.frombuffer(raw, dtype=np.uint8).reshape(4, 4)
- # After flipud the True row should be at the last row (index 3), not row 0
- assert stored[3, 0] == 255
- assert stored[0, 0] == 0
-
-
-def test_view_from_python_flag_set_view():
- """set_view() sets _view_from_python briefly; it is False after push."""
- data = np.zeros((32, 32))
- x_axis = np.linspace(0.0, 32.0, 32)
- fig, ax = apl.subplots(1, 1)
- plot = ax.imshow(data, axes=[x_axis, None])
-
- plot.set_view(x0=8.0, x1=24.0)
- # After the push completes _view_from_python must be reset
- assert plot._state["_view_from_python"] is False
-
-
-def test_view_from_python_flag_reset_view():
- """reset_view() sets _view_from_python briefly; it is False after push."""
- plot = _make_plot2d()
- plot.reset_view()
- assert plot._state["_view_from_python"] is False
-
-
diff --git a/tests/test_plotbar_extras.py b/tests/test_plotbar_extras.py
deleted file mode 100644
index 1f1d428..0000000
--- a/tests/test_plotbar_extras.py
+++ /dev/null
@@ -1,315 +0,0 @@
-"""
-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
-
-
-
-
-
diff --git a/tests/test_pyodide_e2e.py b/tests/test_pyodide_e2e.py
deleted file mode 100644
index 17d1078..0000000
--- a/tests/test_pyodide_e2e.py
+++ /dev/null
@@ -1,947 +0,0 @@
-"""
-tests/test_pyodide_e2e.py
-=========================
-
-End-to-end Playwright tests for the Pyodide live documentation bridge.
-
-Three test tiers, in increasing scope:
-
-1. **Python push-hook unit tests** — verify ``_pyodide_push_hook`` intercepts
- ``_push()`` / ``_push_layout()`` correctly, and that panel IDs are
- deterministic (no-browser, fast).
-
-2. **iframe postMessage tests** — reuse the existing ``interact_page`` fixture
- to open a standalone figure in headless Chromium, fire ``awi_state``
- messages directly, and assert the model updates correctly (no Pyodide, no
- HTTP server).
-
-3. **Full bridge mock-boot tests** — build a ``parent.html`` page that
- includes the real ``anywidget_bridge.js`` but defines ``window.loadPyodide``
- as a lightweight mock *before* the bridge evaluates it. The mock exercises
- the complete JS boot sequence — button click → all ``runPythonAsync`` /
- ``loadPackage`` calls → push-hook installation → state push into the iframe
- → awi_event forwarding — without downloading the ~10 MB Pyodide WASM
- runtime. Pages are served over a local stdlib HTTP server so the
- ``file://`` guard in ``anywidget_bridge.js`` is bypassed.
-
-Run::
-
- uv run pytest tests/test_pyodide_e2e.py -v
-"""
-from __future__ import annotations
-
-import json
-import pathlib
-import socket
-import tempfile
-import threading
-from http.server import HTTPServer, SimpleHTTPRequestHandler
-from html import escape as _html_escape
-from typing import Generator
-
-import numpy as np
-import pytest
-
-import anyplotlib as apl
-import anyplotlib.figure as _af
-from anyplotlib._repr_utils import build_standalone_html
-
-# ---------------------------------------------------------------------------
-# Paths
-# ---------------------------------------------------------------------------
-
-_BRIDGE_JS = (
- pathlib.Path(__file__).parent.parent
- / "anyplotlib" / "sphinx_anywidget" / "static" / "anywidget_bridge.js"
-)
-
-
-# ---------------------------------------------------------------------------
-# Helpers used by multiple tiers
-# ---------------------------------------------------------------------------
-
-def _capture_fig_state(fig) -> dict[str, str]:
- """Return ``{trait_name: json_string}`` for layout + every panel trait.
-
- Reads traitlet values directly after calling the push methods. This
- works even when the value hasn't changed (traitlets suppresses duplicate
- change events, so an observe-based approach would return nothing on a
- second call with the same state).
- """
- # Ensure state is up to date
- fig._push_layout()
- for pid in list(fig._plots_map):
- fig._push(pid)
-
- captured: dict[str, str] = {}
- captured["layout_json"] = fig.layout_json
- for tname in fig.trait_names():
- if tname.startswith("panel_") and tname.endswith("_json"):
- captured[tname] = getattr(fig, tname)
- return captured
-
-
-def _patched_iframe_html(fig, fig_id: str) -> str:
- """Return standalone figure HTML instrumented for Playwright.
-
- Patches applied on top of ``build_standalone_html``:
- * ``window._aplModel = model`` — exposes the model to parent-frame JS.
- * ``window._aplReady = true`` — sentinel polled by ``wait_for_function``.
- """
- html = build_standalone_html(fig, resizable=False, fig_id=fig_id)
- html = html.replace(
- "const model = makeModel(STATE);",
- "const model = makeModel(STATE);\nwindow._aplModel = model;",
- )
- html = html.replace(
- "renderFn({ model, el });",
- "renderFn({ model, el }); window._aplReady = true;",
- )
- return html
-
-
-# ---------------------------------------------------------------------------
-# HTTP-server fixture (module-scoped — one server per test module)
-# ---------------------------------------------------------------------------
-
-@pytest.fixture(scope="module")
-def http_server(tmp_path_factory) -> Generator[tuple[str, pathlib.Path], None, None]:
- """Serve a temp directory over HTTP; yield ``(base_url, base_dir)``.
-
- Uses a randomly-chosen free port so tests run safely alongside other
- sessions. The server is shut down after the last test in the module.
- """
- base_dir = tmp_path_factory.mktemp("bridge_server")
-
- class _SilentHandler(SimpleHTTPRequestHandler):
- def __init__(self, *a, **kw):
- super().__init__(*a, directory=str(base_dir), **kw)
-
- def log_message(self, *_):
- pass # suppress request noise in test output
-
- # Pick a free port
- with socket.socket() as s:
- s.bind(("127.0.0.1", 0))
- port = s.getsockname()[1]
-
- srv = HTTPServer(("127.0.0.1", port), _SilentHandler)
- t = threading.Thread(target=srv.serve_forever, daemon=True)
- t.start()
-
- yield f"http://127.0.0.1:{port}", base_dir
-
- srv.shutdown()
-
-
-# ---------------------------------------------------------------------------
-# Parent-page builder
-# ---------------------------------------------------------------------------
-
-def _build_parent_page(
- fig,
- fig_id: str,
- *,
- base_dir: pathlib.Path,
- python_src: str = "",
-) -> pathlib.Path:
- """Write a complete mock-Pyodide parent page to *base_dir*.
-
- Files written
- -------------
- ``{fig_id}.html`` — standalone figure iframe
- ``anywidget_bridge.js`` — the real bridge script (copied from docs/)
- ``{fig_id}_parent.html`` — parent page with mock loadPyodide
-
- The mock ``window.loadPyodide`` is defined **before** the bridge script
- so the bridge's ``typeof loadPyodide !== 'undefined'`` guard skips the CDN
- download entirely. Each ``runPythonAsync`` call is dispatched by string
- pattern to simulate the five significant Pyodide boot steps:
-
- 1. ``micropip.install`` — no-op.
- 2. ``sys.modules['anywidget']`` stub — no-op.
- 3. ``_pyodide_push_hook`` install — sets real ``window._anywidgetPush``.
- 4. ``_fig_ids`` example-run — calls ``window._anywidgetPush`` with captured state.
-
- Pre-collected figure state (``layout_json`` + ``panel_*_json``) is baked
- into the page as ``window._MOCK_LAYOUT`` / ``window._MOCK_PANELS`` so the
- mock can push real data without running any Python.
- """
- # ── 1. Iframe HTML (with Playwright instrumentation patches) ─────────
- iframe_html = _patched_iframe_html(fig, fig_id)
- (base_dir / f"{fig_id}.html").write_text(iframe_html, encoding="utf-8")
-
- # ── 2. Real bridge script ─────────────────────────────────────────────
- (base_dir / "anywidget_bridge.js").write_text(
- _BRIDGE_JS.read_text(encoding="utf-8"), encoding="utf-8"
- )
-
- # ── 3. Capture real figure state via the push-hook ────────────────────
- fig_state = _capture_fig_state(fig)
- layout_value = fig_state.get("layout_json", "{}")
- panel_entries = [
- {"key": k, "value": v}
- for k, v in fig_state.items()
- if k.startswith("panel_")
- ]
-
- fig_w, fig_h = int(fig.fig_width), int(fig.fig_height)
-
- # ── 4. Python source block (or a minimal comment stub) ────────────────
- if not python_src:
- python_src = "# mock example — state injected by test harness\n"
- data_src_attr = _html_escape(json.dumps(python_src), quote=True)
-
- # ── 5. Mock loadPyodide script ────────────────────────────────────────
- #
- # Intercepts every runPythonAsync call by pattern so the full JS boot
- # path (button → loading → active) is exercised in milliseconds.
- #
- # Step (3): install push-hook → sets window._anywidgetPush which delivers
- # postMessage awi_state updates into the correct iframe.
- # Step (4): run example → calls window._anywidgetPush with pre-baked
- # state so the iframe model receives real figure data.
- mock_js = f""""""
-
- # ── 6. Assemble the parent HTML ───────────────────────────────────────
- parent_html = f"""
-
-
-
-anywidget bridge test — {fig_id}
-{mock_js}
-
-
-
-
-
-
-"""
-
- parent_path = base_dir / f"{fig_id}_parent.html"
- parent_path.write_text(parent_html, encoding="utf-8")
- return parent_path
-
-
-# ---------------------------------------------------------------------------
-# Browser helpers
-# ---------------------------------------------------------------------------
-
-def _rafter(page) -> None:
- page.evaluate("() => new Promise(r => requestAnimationFrame(r))")
-
-
-def _open_page(browser, url: str, timeout: int = 15_000):
- page = browser.new_page()
- page.goto(url, wait_until="domcontentloaded", timeout=timeout)
- return page
-
-
-def _click_and_wait_boot(page, timeout: int = 15_000) -> None:
- """Click the ⚡ badge button and wait until it reaches the 'active' state."""
- page.wait_for_function(
- "() => !!document.querySelector('button.awi-activate-btn')",
- timeout=timeout,
- )
- page.click("button.awi-activate-btn")
- page.wait_for_function(
- """() => {
- const btn = document.querySelector('button.awi-activate-btn');
- return btn && btn.dataset.state === 'active';
- }""",
- timeout=timeout,
- )
-
-
-def _wait_for_iframe_model(page, fig_id: str, panel_id: str,
- timeout: int = 10_000) -> None:
- """Block until the iframe's model has a non-empty panel JSON."""
- page.wait_for_function(
- f"""() => {{
- const iframe = document.querySelector('iframe[data-awi-fig="{fig_id}"]');
- if (!iframe || !iframe.contentWindow) return false;
- const mdl = iframe.contentWindow._aplModel;
- if (!mdl) return false;
- const raw = mdl.get('panel_{panel_id}_json');
- return typeof raw === 'string' && raw.length > 10;
- }}""",
- timeout=timeout,
- )
-
-
-# =============================================================================
-# Tier 1 — Traitlet push unit tests (no browser required)
-# =============================================================================
-
-class TestPushHook:
- """Verify _push() / _push_layout() write to sync=True traitlets.
-
- The old tests checked ``_pyodide_push_hook``; now we observe the traitlets
- directly — the same path that the generic anywidget monkey-patch uses in
- Pyodide.
- """
-
- def test_push_does_not_crash(self):
- """Normal mode: _push() succeeds without error."""
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- ax.plot(np.zeros(16)) # must not raise
-
- def test_layout_json_written_on_create(self):
- """layout_json traitlet is set when a figure is created."""
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- import json
- parsed = json.loads(fig.layout_json)
- assert "panel_specs" in parsed, (
- f"layout_json missing 'panel_specs': {list(parsed.keys())}"
- )
-
- def test_panel_json_written_after_plot(self):
- """panel_*_json traitlet is set when a plot is added."""
- import json
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)))
-
- panel_keys = [k for k in fig.trait_names() if k.startswith("panel_") and k.endswith("_json")]
- assert len(panel_keys) >= 1, "Expected at least one panel_*_json trait"
- for k in panel_keys:
- parsed = json.loads(getattr(fig, k))
- assert "kind" in parsed, f"panel JSON missing 'kind': {list(parsed.keys())}"
-
- def test_observe_fires_on_push(self):
- """traitlets.observe() fires when _push() writes a panel trait."""
- seen: list[str] = []
-
- def _watch(change):
- seen.append(change["name"])
-
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- fig.observe(_watch)
- ax.plot(np.zeros(8))
- fig.unobserve(_watch)
-
- assert any(k.startswith("panel_") for k in seen), (
- f"Expected a panel_* trait change; got: {seen}"
- )
-
- def test_panel_id_deterministic(self):
- """Panel IDs derived from SubplotSpec must be identical across rebuilds."""
- ids: list[str] = []
- for _ in range(3):
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- ax.plot(np.zeros(8))
- ids.append(list(fig._plots_map.keys())[0])
- assert ids[0] == ids[1] == ids[2], (
- f"Panel ID must be deterministic; got {ids}"
- )
-
- def test_panel_ids_unique_in_multiplot(self):
- """Each panel in a multi-panel figure has a unique ID."""
- fig, axes = apl.subplots(1, 3, figsize=(900, 300))
- for ax in axes:
- ax.plot(np.zeros(8))
- ids = list(fig._plots_map.keys())
- assert len(ids) == len(set(ids)), f"Panel IDs not unique: {ids}"
-
- def test_panel_id_matches_grid_position(self):
- """Panel IDs encode the SubplotSpec row/col bounds."""
- fig, axes = apl.subplots(2, 2, figsize=(600, 400))
- for ax in np.asarray(axes).flat:
- ax.plot(np.zeros(4))
- ids = set(fig._plots_map.keys())
- for pid in ids:
- assert pid.startswith("p"), f"Unexpected panel ID format: {pid!r}"
-
- def test_dispatch_event_callable_without_kernel(self):
- """_dispatch_event() can be called directly as the Pyodide bridge does."""
- import json
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- ax.plot(np.zeros(16))
- raw = json.dumps({
- "event_type": "on_zoom",
- "panel_id": list(fig._plots_map.keys())[0],
- "source": "js",
- })
- fig._dispatch_event(raw) # must not raise
-
- def test_capture_fig_state_helper(self):
- """_capture_fig_state returns both layout_json and panel JSON(s)."""
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- ax.plot(np.zeros(32))
- state = _capture_fig_state(fig)
- assert "layout_json" in state, f"Expected layout_json; got {list(state.keys())}"
- panel_keys = [k for k in state if k.startswith("panel_")]
- assert len(panel_keys) >= 1, "Expected at least one panel_ key"
-
- def test_no_pyodide_push_hook_attribute(self):
- """figure module no longer exposes _pyodide_push_hook."""
- assert not hasattr(_af, "_pyodide_push_hook"), (
- "_pyodide_push_hook should not exist on figure module in this branch"
- )
-
-
-# =============================================================================
-# Tier 2 — iframe postMessage tests (browser, no Pyodide, no HTTP server)
-# =============================================================================
-
-class TestIframeMessaging:
- """Test the awi_state postMessage protocol via the standalone iframe.
-
- The ``interact_page`` fixture opens the figure HTML as a top-level page
- (not as an iframe), so ``window.parent === window`` and the outbound
- awi_event forwarding is naturally disabled. These tests focus on the
- *inbound* direction: an ``awi_state`` message updates the model.
- """
-
- def _open_fig(self, interact_page):
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#4fc3f7")
- plot = list(fig._plots_map.values())[0]
- panel_id = list(fig._plots_map.keys())[0]
- page = interact_page(fig)
- return fig, plot, panel_id, page
-
- def test_awi_state_message_updates_model_key(self, interact_page):
- """Posting {type:'awi_state', key, value} into the page updates the model."""
- fig, plot, panel_id, page = self._open_fig(interact_page)
-
- # Read the current panel JSON and add a sentinel key
- raw = page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
- assert raw is not None, "Model should have an initial panel JSON"
- curr = json.loads(raw)
- curr["__apl_e2e_sentinel__"] = "hello_from_postMessage"
- new_json = json.dumps(curr)
-
- page.evaluate(f"""() => {{
- window.postMessage({{
- type: 'awi_state',
- key: 'panel_{panel_id}_json',
- value: {json.dumps(new_json)}
- }}, '*');
- }}""")
- _rafter(page)
-
- updated = json.loads(
- page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
- )
- assert updated.get("__apl_e2e_sentinel__") == "hello_from_postMessage", (
- "awi_state postMessage did not update the model key"
- )
-
- def test_awi_state_message_sets_from_parent_flag(self, interact_page):
- """_fromParent is True while the awi_state handler runs.
-
- We can't read the flag mid-handler, but we can verify that a
- save_changes() triggered by awi_state does NOT set _eventJsonDirty
- (since event_json was not written in that transaction). A by-product
- check: calling model.set on a non-event_json key never marks the
- dirty flag.
- """
- fig, plot, panel_id, page = self._open_fig(interact_page)
-
- raw = json.loads(
- page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
- )
- raw["__flag_test__"] = 42
- new_json = json.dumps(raw)
-
- # Expose _eventJsonDirty so we can read it after the handler runs.
- # We monkey-patch model.save_changes to record whether _eventJsonDirty
- # was True at the time of the call triggered by the awi_state message.
- page.evaluate("""() => {
- window._dirtyAtSaveChanges = null;
- // We can't access module-scoped _eventJsonDirty from outside, but
- // we can observe whether an awi_event postMessage is fired: it only
- // fires when (!_fromParent && FIG_ID && parent!==window && dirty).
- // Since FIG_ID is null (standalone page), no awi_event fires in any
- // case. So we check absence of awi_event messages instead.
- window._aplEventsSeen = 0;
- window.addEventListener('message', (e) => {
- if (e.data && e.data.type === 'awi_event') window._aplEventsSeen++;
- });
- }""")
-
- page.evaluate(f"""() => {{
- window.postMessage({{
- type: 'awi_state',
- key: 'panel_{panel_id}_json',
- value: {json.dumps(new_json)}
- }}, '*');
- }}""")
- _rafter(page)
-
- # In standalone mode FIG_ID is null → no awi_event is ever forwarded
- events_seen = page.evaluate("() => window._aplEventsSeen")
- assert events_seen == 0, (
- "_fromParent guard or FIG_ID=null should prevent awi_event echo; "
- f"got {events_seen} awi_event(s)"
- )
-
- def test_awi_state_fires_change_listeners(self, interact_page):
- """Posting awi_state triggers on('change:…') listeners in the model."""
- fig, plot, panel_id, page = self._open_fig(interact_page)
-
- page.evaluate(f"""() => {{
- window._aplChangeCount = 0;
- window._aplModel.on('change:panel_{panel_id}_json', () => {{
- window._aplChangeCount++;
- }});
- }}""")
-
- raw = json.loads(
- page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')")
- )
- raw["__change_test__"] = 1
- new_json = json.dumps(raw)
-
- page.evaluate(f"""() => {{
- window.postMessage({{
- type: 'awi_state',
- key: 'panel_{panel_id}_json',
- value: {json.dumps(new_json)}
- }}, '*');
- }}""")
- _rafter(page)
-
- count = page.evaluate("() => window._aplChangeCount")
- assert count >= 1, (
- "awi_state postMessage should fire change listeners; "
- f"got {count} invocations"
- )
-
- def test_layout_json_push_updates_model(self, interact_page):
- """layout_json can be updated via awi_state, not only panel_*_json."""
- fig, plot, panel_id, page = self._open_fig(interact_page)
-
- layout = json.loads(
- page.evaluate("() => window._aplModel.get('layout_json') || '{}'")
- )
- layout["__layout_sentinel__"] = "bridge_test"
- new_json = json.dumps(layout)
-
- page.evaluate(f"""() => {{
- window.postMessage({{
- type: 'awi_state',
- key: 'layout_json',
- value: {json.dumps(new_json)}
- }}, '*');
- }}""")
- _rafter(page)
-
- updated = json.loads(
- page.evaluate("() => window._aplModel.get('layout_json') || '{}'")
- )
- assert updated.get("__layout_sentinel__") == "bridge_test", (
- "layout_json postMessage did not update the model"
- )
-
-
-# =============================================================================
-# Tier 3 — Full bridge mock-boot tests (HTTP server + mock Pyodide)
-# =============================================================================
-
-class TestFullBridgeBoot:
- """Boot anywidget_bridge.js end-to-end via a mock loadPyodide.
-
- Each test builds a parent HTML page using ``_build_parent_page`` and
- serves it from the shared ``http_server`` fixture. All Pyodide network
- I/O is replaced by the JS mock so tests run in milliseconds.
- """
-
- # ------------------------------------------------------------------
- # helpers
-
- def _open(self, browser, base_url: str, parent_path: pathlib.Path,
- timeout: int = 15_000):
- url = f"{base_url}/{parent_path.name}"
- page = browser.new_page()
- page.goto(url, wait_until="domcontentloaded", timeout=timeout)
- return page
-
- def _basic_fig(self) -> tuple:
- fig, ax = apl.subplots(1, 1, figsize=(400, 300))
- ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#50fa7b")
- panel_id = list(fig._plots_map.keys())[0]
- return fig, panel_id
-
- # ------------------------------------------------------------------
- # tests
-
- def test_button_appears_when_iframe_present(
- self, http_server, _pw_browser
- ):
- """The ⚡ button is injected on any page that has a data-awi-fig iframe."""
- base_url, base_dir = http_server
- fig, panel_id = self._basic_fig()
- parent = _build_parent_page(fig, "btn_test_001", base_dir=base_dir)
- page = self._open(_pw_browser, base_url, parent)
-
- page.wait_for_function(
- "() => !!document.querySelector('button.awi-activate-btn')",
- timeout=5_000,
- )
- tooltip = page.evaluate(
- "() => document.querySelector('button.awi-activate-btn').title"
- )
- assert "interactive" in tooltip.lower(), (
- f"Button tooltip should mention 'interactive'; got {tooltip!r}"
- )
- page.close()
-
- def test_boot_completes_all_mock_steps(
- self, http_server, _pw_browser
- ):
- """Clicking ⚡ runs through all expected mock Pyodide boot steps."""
- base_url, base_dir = http_server
- fig, panel_id = self._basic_fig()
- parent = _build_parent_page(fig, "boot_test_001", base_dir=base_dir)
- page = self._open(_pw_browser, base_url, parent)
-
- _click_and_wait_boot(page)
-
- steps = page.evaluate("() => window._APL_BOOT_STEPS")
-
- assert "loadPyodide" in steps, (
- f"loadPyodide() was never called; steps={steps}"
- )
- assert "micropip_install" in steps, (
- f"micropip install step missing; steps={steps}"
- )
- assert "stub_anywidget" in steps, (
- f"anywidget stub step missing; steps={steps}"
- )
- assert "install_monkey_patch" in steps, (
- f"monkey-patch install step missing; steps={steps!r}\n"
- "This means anywidget_bridge.js never called runPythonAsync with "
- "the _patched_init monkey-patch source — the JS↔Python bridge is broken."
- )
- assert "run_example" in steps, (
- f"Example-run step missing; steps={steps!r}\n"
- "This means anywidget_bridge.js never called runPythonAsync with "
- "the _fig_ids / _push_layout block that seeds the iframes."
- )
- page.close()
-
- def test_anywidgetPush_is_function_after_boot(
- self, http_server, _pw_browser
- ):
- """window._anywidgetPush must be a function after the push-hook step runs."""
- base_url, base_dir = http_server
- fig, panel_id = self._basic_fig()
- parent = _build_parent_page(fig, "apush_test_001", base_dir=base_dir)
- page = self._open(_pw_browser, base_url, parent)
-
- _click_and_wait_boot(page)
-
- is_fn = page.evaluate("() => typeof window._anywidgetPush === 'function'")
- assert is_fn, (
- "window._anywidgetPush should be a function after the push-hook step; "
- "if it is missing the hook was never installed by anywidget_bridge.js"
- )
- page.close()
-
- def test_state_pushed_into_iframe_model(
- self, http_server, _pw_browser
- ):
- """After boot the iframe's model contains the figure's panel JSON.
-
- This is the core Pyodide bridge assertion: Python figure state must
- reach the iframe model via _anywidgetPush → postMessage → awi_state listener
- → model.set(key, value).
- """
- base_url, base_dir = http_server
- fig, panel_id = self._basic_fig()
- expected = fig._plots_map[panel_id].to_state_dict()
-
- parent = _build_parent_page(fig, "state_push_001", base_dir=base_dir)
- page = self._open(_pw_browser, base_url, parent)
-
- _click_and_wait_boot(page)
- _wait_for_iframe_model(page, "state_push_001", panel_id)
-
- raw = page.evaluate(f"""() => {{
- const iframe = document.querySelector('iframe[data-awi-fig="state_push_001"]');
- return iframe && iframe.contentWindow
- ? iframe.contentWindow._aplModel.get('panel_{panel_id}_json')
- : null;
- }}""")
-
- assert raw is not None, (
- "panel JSON was never delivered to the iframe model after boot.\n"
- "Check: (a) _anywidgetPush was installed, (b) postMessage reached the "
- "iframe's awi_state listener, (c) model.set() was called."
- )
- state = json.loads(raw)
- assert state.get("kind") == expected.get("kind"), (
- f"kind mismatch: iframe has {state.get('kind')!r}, "
- f"Python produced {expected.get('kind')!r}"
- )
- page.close()
-
- def test_layout_json_pushed_into_iframe(
- self, http_server, _pw_browser
- ):
- """layout_json (panel geometry) is delivered to the iframe model."""
- base_url, base_dir = http_server
- fig, panel_id = self._basic_fig()
- parent = _build_parent_page(fig, "layout_push_001", base_dir=base_dir)
- page = self._open(_pw_browser, base_url, parent)
-
- _click_and_wait_boot(page)
-
- # Wait for layout_json to propagate
- page.wait_for_function(
- """() => {
- const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]');
- if (!iframe || !iframe.contentWindow) return false;
- const mdl = iframe.contentWindow._aplModel;
- if (!mdl) return false;
- const raw = mdl.get('layout_json');
- return typeof raw === 'string' && raw.length > 10;
- }""",
- timeout=8_000,
- )
-
- raw = page.evaluate("""() => {
- const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]');
- return iframe.contentWindow._aplModel.get('layout_json');
- }""")
- assert raw is not None, "layout_json was not delivered to the iframe"
- layout = json.loads(raw)
- assert "panel_specs" in layout, (
- f"layout_json is missing 'panel_specs'; got keys: {list(layout.keys())}"
- )
- page.close()
-
- def test_event_message_forwarded_to_parent(
- self, http_server, _pw_browser
- ):
- """awi_event messages sent from the iframe arrive at the parent window.
-
- This tests the reverse direction of the bridge: user interaction in
- the iframe → awi_event postMessage → parent window.message listener
- → _fig._dispatch_event(). Here we only test the JS forwarding step;
- the Python dispatch is covered by TestPushHook.test_dispatch_event_*.
- """
- base_url, base_dir = http_server
- fig, panel_id = self._basic_fig()
- parent = _build_parent_page(fig, "event_fwd_001", base_dir=base_dir)
- page = self._open(_pw_browser, base_url, parent)
-
- _click_and_wait_boot(page)
-
- # Install a parent-side listener that records received awi_events
- page.evaluate("""() => {
- window._aplReceivedEvents = [];
- window.addEventListener('message', (e) => {
- if (e.data && e.data.type === 'awi_event') {
- window._aplReceivedEvents.push(e.data);
- }
- });
- }""")
-
- # Synthesise an awi_event from the iframe (mirrors what the iframe
- # does when a widget drag ends: window.parent.postMessage({...}, '*'))
- fake_event = json.dumps({
- "event_type": "on_release",
- "panel_id": panel_id,
- "widget_id": "w_e2e_fake",
- "x": 42.0,
- })
- page.evaluate(f"""() => {{
- // Simulate the iframe posting the event to its parent.
- // In the actual docs the iframe does:
- // window.parent.postMessage({{type:'awi_event', figId, data}}, '*')
- // Here the iframe IS the top-level page so we post to window itself.
- window.postMessage({{
- type: 'awi_event',
- figId: 'event_fwd_001',
- data: {json.dumps(fake_event)}
- }}, '*');
- }}""")
- _rafter(page)
-
- events = page.evaluate("() => window._aplReceivedEvents")
- assert len(events) >= 1, (
- "No awi_event reached the parent message bus.\n"
- "The parent window.message listener in anywidget_bridge.js "
- "may not be installed, or the figId routing is broken."
- )
- assert events[0]["figId"] == "event_fwd_001", (
- f"figId mismatch: {events[0]['figId']!r} vs 'event_fwd_001'"
- )
- page.close()
-
- def test_multiple_panels_all_receive_state(
- self, http_server, _pw_browser
- ):
- """All panels in a multi-panel figure have their state pushed."""
- base_url, base_dir = http_server
-
- fig, axes = apl.subplots(1, 2, figsize=(700, 300))
- axes[0].plot(np.zeros(32))
- axes[1].plot(np.ones(32) * 0.5)
- panel_ids = list(fig._plots_map.keys())
- assert len(panel_ids) == 2, "Expected exactly 2 panels"
-
- parent = _build_parent_page(fig, "multi_panel_001", base_dir=base_dir)
- page = self._open(_pw_browser, base_url, parent)
- _click_and_wait_boot(page)
-
- # Wait for both panels to arrive
- for pid in panel_ids:
- _wait_for_iframe_model(page, "multi_panel_001", pid)
-
- for pid in panel_ids:
- raw = page.evaluate(f"""() => {{
- const iframe = document.querySelector(
- 'iframe[data-awi-fig="multi_panel_001"]');
- return iframe && iframe.contentWindow
- ? iframe.contentWindow._aplModel.get('panel_{pid}_json')
- : null;
- }}""")
- assert raw is not None, (
- f"Panel {pid!r} state was not pushed into the iframe model.\n"
- "If only the first panel arrives, _anywidgetPush may be iterating "
- "panels incorrectly in the mock (or in the real bridge)."
- )
- page.close()
-
- def test_button_shows_error_on_boot_failure(
- self, http_server, _pw_browser
- ):
- """If Pyodide boot fails the button switches to the error state (❌)."""
- base_url, base_dir = http_server
- fig, panel_id = self._basic_fig()
-
- # Build the parent page, then patch the mock to throw on loadPyodide
- parent = _build_parent_page(fig, "error_test_001", base_dir=base_dir)
- html = (base_dir / "error_test_001_parent.html").read_text(encoding="utf-8")
- # Inject a rejection AFTER the mock definition so it overrides it
- html = html.replace(
- "window.loadPyodide = async function({indexURL}) {",
- "window.loadPyodide = async function({indexURL}) { throw new Error('mock boot failure'); //",
- )
- (base_dir / "error_test_001_parent.html").write_text(html, encoding="utf-8")
-
- page = self._open(_pw_browser, base_url, parent)
- page.wait_for_function(
- "() => !!document.querySelector('button.awi-activate-btn')",
- timeout=5_000
- )
- page.click("button.awi-activate-btn")
-
- # Wait for button to enter error state
- page.wait_for_function(
- """() => {
- const btn = document.querySelector('button.awi-activate-btn');
- return btn && btn.dataset.state === 'error';
- }""",
- timeout=10_000,
- )
- label = page.evaluate(
- "() => document.querySelector('button.awi-activate-btn').title"
- )
- assert "mock boot failure" in label, (
- f"Error button title should contain the exception message; got {label!r}"
- )
- page.close()
-
-
-
diff --git a/tests/test_scraper.py b/tests/test_scraper.py
deleted file mode 100644
index a296743..0000000
--- a/tests/test_scraper.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""
-tests/test_scraper.py
-=====================
-
-Pytest tests for the Playwright-based scraper thumbnail functionality.
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import numpy as np
-import pytest
-
-import anyplotlib as apl
-from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png
-from tests._png_utils import decode_png
-
-
-# ── fixtures ──────────────────────────────────────────────────────────────────
-
-@pytest.fixture
-def line_fig():
- fig, ax = apl.subplots(1, 1, figsize=(400, 250))
- ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128)), color="#4fc3f7")
- return fig
-
-
-@pytest.fixture
-def imshow_fig():
- fig, ax = apl.subplots(1, 1, figsize=(320, 320))
- data = np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64)
- ax.imshow(data)
- return fig
-
-
-@pytest.fixture
-def multi_panel_fig():
- fig, axes = apl.subplots(1, 2, figsize=(640, 300))
- axes[0].plot(np.cos(np.linspace(0, 2 * np.pi, 64)))
- axes[1].imshow(
- np.random.default_rng(0).uniform(0, 1, (32, 32)).astype(np.float32)
- )
- return fig
-
-
-# ── thumbnail PNG validation ──────────────────────────────────────────────────
-
-def _assert_thumbnail_is_png(widget, label: str):
- png = _make_thumbnail_png(widget)
- assert png[:4] == b"\x89PNG", f"[{label}] result is not a PNG"
- arr = decode_png(png)
- assert arr.ndim == 3, f"[{label}] expected H×W×C array, got shape {arr.shape}"
- assert arr.shape[2] in (3, 4), f"[{label}] expected RGB/RGBA, got {arr.shape[2]} channels"
-
-
-def test_thumbnail_1d_line(line_fig):
- _assert_thumbnail_is_png(line_fig, "1D line")
-
-
-def test_thumbnail_2d_imshow(imshow_fig):
- _assert_thumbnail_is_png(imshow_fig, "2D imshow")
-
-
-def test_thumbnail_multi_panel(multi_panel_fig):
- _assert_thumbnail_is_png(multi_panel_fig, "multi-panel")
diff --git a/upcoming_changes/11.maintenance.rst b/upcoming_changes/11.maintenance.rst
new file mode 100644
index 0000000..642100c
--- /dev/null
+++ b/upcoming_changes/11.maintenance.rst
@@ -0,0 +1,2 @@
+Refactored the testssuite. Moved to a new directory, combined liked
+t1ests into single files, added a couple new tests and removed some redundant tests.
\ No newline at end of file