Skip to content

Commit 9b864b3

Browse files
committed
feat: add unit tests for panel alignment and inset functionality
1 parent 38c7188 commit 9b864b3

4 files changed

Lines changed: 274 additions & 342 deletions

File tree

‎anyplotlib/tests/test_layouts/test_gridspec.py‎

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22
tests/test_gridspec.py
33
======================
44
5-
Tests for GridSpec / SubplotSpec indexing AND the figure sizing pipeline
6-
(_compute_cell_sizes) that converts grid specs + figsize into per-panel
7-
canvas pixel dimensions.
5+
Tests for GridSpec / SubplotSpec indexing, the figure sizing pipeline
6+
(_compute_cell_sizes), and per-panel plot-area alignment.
87
9-
The sizing contract (all measured at the *canvas* level, before PAD margins):
8+
Sizing contract (all measured at the *canvas* level, before PAD margins):
109
- All panels in the same grid column have the same canvas width (pw).
1110
- All panels in the same grid row have the same canvas height (ph).
1211
- Grid tracks are pure ratio math — no aspect-locking.
@@ -19,6 +18,13 @@
1918
sum(row tracks) <= fh.
2019
- Images are rendered "contain" (letterboxed) in JS — the Python layout
2120
engine never modifies tracks because of image content.
21+
22+
Alignment contract (inner plot-area coordinates, shared PAD constants):
23+
- PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42
24+
- The inner plot/image area for any panel kind is:
25+
x=PAD_L, y=PAD_T, w=pw-PAD_L-PAD_R, h=ph-PAD_T-PAD_B
26+
- All panels in the same column share pw → same left/right edges.
27+
- All panels in the same row share ph → same top/bottom edges.
2228
"""
2329

2430
from __future__ import annotations
@@ -31,6 +37,9 @@
3137
from anyplotlib.figure import Figure
3238
from anyplotlib.figure_plots import GridSpec, SubplotSpec, Axes # noqa: F401
3339

40+
# PAD constants must match figure_esm.js (used in panel-alignment tests)
41+
PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42
42+
3443

3544
# ─────────────────────────────────────────────────────────────────────────────
3645
# Helpers
@@ -705,6 +714,129 @@ def test_figsize_in_layout_json(self):
705714
assert layout["fig_height"] == 555
706715

707716

717+
# ─────────────────────────────────────────────────────────────────────────────
718+
# Part 8 – Panel alignment
719+
# ─────────────────────────────────────────────────────────────────────────────
720+
721+
def _plot_area(pw: int, ph: int) -> tuple[int, int, int, int]:
722+
"""Return (x, y, w, h) of the inner plot/image area for any panel kind.
723+
724+
Both 1-D and 2-D panels use the same PAD constants in figure_esm.js,
725+
so as long as Python assigns the same (pw, ph) to sibling panels they
726+
are guaranteed to be pixel-aligned inside the shared canvas grid cell.
727+
"""
728+
return PAD_L, PAD_T, pw - PAD_L - PAD_R, ph - PAD_T - PAD_B
729+
730+
731+
class TestPanelAlignment:
732+
"""Same-row / same-column panels must share canvas dimensions and
733+
therefore produce identical inner plot-area coordinates."""
734+
735+
# ── two-row, one-column ───────────────────────────────────────────────
736+
737+
def test_2row_1col_same_width(self):
738+
fig, axs = vw.subplots(2, 1, figsize=(600, 600))
739+
v2d = axs[0].imshow(np.random.rand(128, 128))
740+
v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
741+
s = _sizes(fig)
742+
pw2d = s[v2d._id][0]
743+
pw1d = s[v1d._id][0]
744+
assert pw2d == pw1d, (
745+
f"Panels in same column must have equal width: 2D={pw2d}, 1D={pw1d}"
746+
)
747+
748+
def test_2row_1col_left_edge_aligned(self):
749+
"""Left edge of the 2D image area and 1D plot area must both be PAD_L."""
750+
fig, axs = vw.subplots(2, 1, figsize=(600, 600))
751+
v2d = axs[0].imshow(np.random.rand(128, 128))
752+
v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
753+
s = _sizes(fig)
754+
x2d = _plot_area(*s[v2d._id])[0]
755+
x1d = _plot_area(*s[v1d._id])[0]
756+
assert x2d == x1d == PAD_L, (
757+
f"Left edge must be PAD_L={PAD_L}: 2D={x2d}, 1D={x1d}"
758+
)
759+
760+
def test_2row_1col_plot_area_widths_equal(self):
761+
"""Plot-area widths must match when panels share a column."""
762+
fig, axs = vw.subplots(2, 1, figsize=(600, 600))
763+
v2d = axs[0].imshow(np.random.rand(128, 128))
764+
v1d = axs[1].plot(np.sin(np.linspace(0, 6, 256)))
765+
s = _sizes(fig)
766+
w2d = _plot_area(*s[v2d._id])[2]
767+
w1d = _plot_area(*s[v1d._id])[2]
768+
assert w2d == w1d, f"Plot area widths: 2D={w2d}, 1D={w1d}"
769+
770+
# ── one-row, two-column ───────────────────────────────────────────────
771+
772+
def test_1row_2col_same_height(self):
773+
fig, axs = vw.subplots(1, 2, figsize=(800, 400))
774+
v2d = axs[0].imshow(np.random.rand(64, 64))
775+
v1d = axs[1].plot(np.cos(np.linspace(0, 6, 256)))
776+
s = _sizes(fig)
777+
ph2d = s[v2d._id][1]
778+
ph1d = s[v1d._id][1]
779+
assert ph2d == ph1d, (
780+
f"Panels in same row must have equal height: 2D={ph2d}, 1D={ph1d}"
781+
)
782+
783+
def test_1row_2col_top_bottom_aligned(self):
784+
"""Top and bottom y-coordinates of plot areas must match across the row."""
785+
fig, axs = vw.subplots(1, 2, figsize=(800, 400))
786+
v2d = axs[0].imshow(np.random.rand(64, 64))
787+
v1d = axs[1].plot(np.cos(np.linspace(0, 6, 256)))
788+
s = _sizes(fig)
789+
y2d, h2d = _plot_area(*s[v2d._id])[1], _plot_area(*s[v2d._id])[3]
790+
y1d, h1d = _plot_area(*s[v1d._id])[1], _plot_area(*s[v1d._id])[3]
791+
assert y2d == y1d == PAD_T, f"Top y: 2D={y2d}, 1D={y1d}"
792+
assert h2d == h1d, f"Plot area heights: 2D={h2d}, 1D={h1d}"
793+
794+
# ── 2D panel canvas equals its grid cell ─────────────────────────────
795+
796+
def test_square_image_gets_square_canvas(self):
797+
"""A 128×128 image in a 500×500 figsize → canvas is 500×500 (pw == ph).
798+
Images are letterboxed in JS; the Python layout never changes the cell."""
799+
fig, axs = vw.subplots(1, 1, figsize=(500, 500))
800+
v2d = axs.imshow(np.random.rand(128, 128))
801+
pw, ph = _sizes(fig)[v2d._id]
802+
assert pw == ph, f"Square figsize must give pw==ph: pw={pw}, ph={ph}"
803+
804+
def test_wide_image_canvas_equals_cell(self):
805+
"""A 2:1 image in a square cell gets a square canvas — no aspect-lock."""
806+
fig, axs = vw.subplots(1, 1, figsize=(512, 512))
807+
v2d = axs.imshow(np.random.rand(128, 256)) # w=256, h=128
808+
pw, ph = _sizes(fig)[v2d._id]
809+
assert pw == 512 and ph == 512, (
810+
f"Canvas should equal full figsize 512×512, got {pw}×{ph}"
811+
)
812+
813+
# ── non-square 2D panel plus 1D panel — column width consistent ───────
814+
815+
def test_nonsquare_2d_and_1d_same_column(self):
816+
"""A tall non-square image in a 2-row, 1-col layout must not affect the
817+
1D panel's canvas width — both must equal the column track width."""
818+
fig, axs = vw.subplots(2, 1, figsize=(600, 800))
819+
v2d = axs[0].imshow(np.random.rand(256, 128)) # tall image
820+
v1d = axs[1].plot(np.random.rand(256))
821+
s = _sizes(fig)
822+
pw2d = s[v2d._id][0]
823+
pw1d = s[v1d._id][0]
824+
assert pw2d == pw1d, (
825+
f"Same-column panels must have equal width: 2D={pw2d}, 1D={pw1d}"
826+
)
827+
828+
# ── plot-area dimensions are positive ─────────────────────────────────
829+
830+
def test_plot_areas_positive(self):
831+
fig, axs = vw.subplots(2, 1, figsize=(400, 400))
832+
v2d = axs[0].imshow(np.random.rand(64, 64))
833+
v1d = axs[1].plot(np.random.rand(128))
834+
for pid, (pw, ph) in _sizes(fig).items():
835+
x, y, w, h = _plot_area(pw, ph)
836+
assert w > 0, f"Panel {pid}: plot area width must be positive, got {w}"
837+
assert h > 0, f"Panel {pid}: plot area height must be positive, got {h}"
838+
839+
708840

709841

710842

anyplotlib/tests/test_plot3d/test_inset.py renamed to anyplotlib/tests/test_layouts/test_inset.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""
22
Tests for InsetAxes — floating overlay inset panels.
33
4+
Unit tests
5+
----------
46
Covers:
57
- Creation via fig.add_inset()
68
- layout_json inset_specs content
@@ -13,15 +15,33 @@
1315
- Invalid corner raises ValueError
1416
- Figure resize keeps inset fracs correct
1517
- plot._id registered in _plots_map
18+
19+
Visual regression tests
20+
-----------------------
21+
Pixel-accurate rendering checks for inset panels in a headless Chromium
22+
browser. Each test renders a deterministic Figure and compares it against
23+
a golden PNG in ``tests/baselines/``.
24+
25+
Generate / refresh baselines::
26+
27+
uv run pytest tests/test_layouts/test_inset.py --update-baselines -v
28+
29+
Normal CI run (fails on regression)::
30+
31+
uv run pytest tests/test_layouts/test_inset.py -v
1632
"""
33+
from __future__ import annotations
34+
1735
import json
36+
import pathlib
37+
1838
import numpy as np
1939
import pytest
2040
import anyplotlib as apl
2141
from anyplotlib.figure_plots import InsetAxes
2242

2343

24-
# ── helpers ──────────────────────────────────────────────────────────────────
44+
# ── helpers (unit tests) ──────────────────────────────────────────────────────
2545

2646
def _make_fig():
2747
fig, ax = apl.subplots(1, 1, figsize=(640, 480))
@@ -247,3 +267,120 @@ def test_repr():
247267
assert "top-right" in r
248268
assert "normal" in r
249269

270+
271+
# ─────────────────────────────────────────────────────────────────────────────
272+
# Visual regression tests
273+
# ─────────────────────────────────────────────────────────────────────────────
274+
275+
BASELINES = pathlib.Path(__file__).parent / "baselines"
276+
277+
278+
def _check(name: str, arr: np.ndarray, update: bool) -> None:
279+
from anyplotlib.tests._png_utils import decode_png, encode_png, compare_arrays
280+
281+
path = BASELINES / f"{name}.png"
282+
283+
if update:
284+
BASELINES.mkdir(exist_ok=True)
285+
path.write_bytes(encode_png(arr))
286+
pytest.skip(f"Baseline updated: {path.name}")
287+
288+
if not path.exists():
289+
pytest.skip(
290+
f"No baseline for {name!r} — run with --update-baselines to create it"
291+
)
292+
293+
expected = decode_png(path.read_bytes())
294+
ok, msg = compare_arrays(arr, expected)
295+
assert ok, f"Visual regression [{name}]: {msg}"
296+
297+
298+
def _main_fig():
299+
"""640×480 figure with a grayscale 64×64 imshow — the inset host."""
300+
rng = np.random.default_rng(0)
301+
fig, ax = apl.subplots(1, 1, figsize=(640, 480))
302+
ax.imshow(rng.uniform(0.0, 1.0, (64, 64)).astype(np.float32))
303+
return fig
304+
305+
306+
class TestInsetVisual:
307+
"""Pixel-level visual regression tests for the floating inset panel system."""
308+
309+
# ── single inset, normal state ─────────────────────────────────────────
310+
311+
def test_inset_normal_2d(self, take_screenshot, update_baselines):
312+
"""2-D inset in top-right corner, normal state."""
313+
rng = np.random.default_rng(1)
314+
fig = _main_fig()
315+
inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Zoom")
316+
inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
317+
cmap="viridis")
318+
arr = take_screenshot(fig)
319+
_check("inset_normal_2d", arr, update_baselines)
320+
321+
def test_inset_minimized(self, take_screenshot, update_baselines):
322+
"""Inset collapsed to title bar only after minimize()."""
323+
rng = np.random.default_rng(2)
324+
fig = _main_fig()
325+
inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Phase")
326+
inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
327+
inset.minimize()
328+
arr = take_screenshot(fig)
329+
_check("inset_minimized", arr, update_baselines)
330+
331+
def test_inset_maximized(self, take_screenshot, update_baselines):
332+
"""Inset expanded to ~72 % of figure after maximize()."""
333+
rng = np.random.default_rng(3)
334+
fig = _main_fig()
335+
inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Detail")
336+
inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
337+
cmap="inferno")
338+
inset.maximize()
339+
arr = take_screenshot(fig)
340+
_check("inset_maximized", arr, update_baselines)
341+
342+
# ── two insets stacked in the same corner ──────────────────────────────
343+
344+
def test_inset_stacked(self, take_screenshot, update_baselines):
345+
"""Two insets sharing top-right corner stack with constant gap."""
346+
rng = np.random.default_rng(4)
347+
fig = _main_fig()
348+
i1 = fig.add_inset(0.28, 0.25, corner="top-right", title="A")
349+
i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
350+
i2 = fig.add_inset(0.28, 0.25, corner="top-right", title="B")
351+
i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
352+
cmap="hot")
353+
arr = take_screenshot(fig)
354+
_check("inset_stacked", arr, update_baselines)
355+
356+
# ── 1-D line inset ─────────────────────────────────────────────────────
357+
358+
def test_inset_1d(self, take_screenshot, update_baselines):
359+
"""1-D line plot inset in bottom-right corner."""
360+
rng = np.random.default_rng(5)
361+
fig = _main_fig()
362+
inset = fig.add_inset(0.32, 0.22, corner="bottom-right",
363+
title="Profile")
364+
t = np.linspace(0.0, 2 * np.pi, 128)
365+
inset.plot(np.sin(t) + rng.normal(0, 0.05, 128),
366+
color="#4fc3f7", linewidth=1.5)
367+
arr = take_screenshot(fig)
368+
_check("inset_1d", arr, update_baselines)
369+
370+
# ── stacked with one minimized (restack test) ──────────────────────────
371+
372+
def test_inset_stacked_one_minimized(self, take_screenshot, update_baselines):
373+
"""Two insets in same corner; first minimized — second shifts up."""
374+
rng = np.random.default_rng(6)
375+
fig = _main_fig()
376+
i1 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Min")
377+
i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32))
378+
i2 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Normal")
379+
i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32),
380+
cmap="viridis")
381+
i1.minimize()
382+
arr = take_screenshot(fig)
383+
_check("inset_stacked_one_minimized", arr, update_baselines)
384+
385+
386+

0 commit comments

Comments
 (0)