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