diff --git a/tests/base/test_transforms3d_plot.py b/tests/base/test_transforms3d_plot.py index 1f05806e..d18d9300 100755 --- a/tests/base/test_transforms3d_plot.py +++ b/tests/base/test_transforms3d_plot.py @@ -74,9 +74,12 @@ def test_plot(self): plt.close("all") @pytest.mark.skipif( - os.environ.get("CI") == "true" + plt.get_backend().lower() == "agg" + or os.environ.get("CI") == "true" or (sys.platform.startswith("darwin") and sys.version_info < (3, 11)), - reason="no display in CI / tkinter bug on mac", + reason="animation wait=True busy-loop never terminates under the " + "non-interactive Agg backend (no event loop to deregister the " + "timer callback); needs a real display", ) def test_animate(self): tranimate(transl(1, 2, 3), repeat=False, wait=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..5ee09ecc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import os + +# Force a non-interactive Matplotlib backend for the whole test session, +# before any test module or package code gets a chance to import +# matplotlib.pyplot. Several modules (geom2d, geom3d, spline, animate) +# import pyplot at module load time, so this has to happen here, in +# conftest.py, which pytest guarantees to load before collecting tests. +# +# CI already sets MPLBACKEND=Agg via the workflow env, so this mainly +# fixes local runs, which otherwise use the platform's interactive +# backend and pop up real windows / can hang on plt.pause(). setdefault +# (not a hard override) leaves an escape hatch: run with +# MPLBACKEND=MacOSX pytest ... to actually see a plot when you want to. +os.environ.setdefault("MPLBACKEND", "Agg") diff --git a/tests/test_spline.py b/tests/test_spline.py index 1a8ae0e8..e1fb56c9 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -28,7 +28,11 @@ def test_evaluation(self): nt.assert_almost_equal(spline(0).A, self.control_poses[0].A) nt.assert_almost_equal(spline(1).A, self.control_poses[-1].A) - @pytest.mark.skipif(os.environ.get("CI") == "true", reason="no display in CI") + @pytest.mark.skipif( + plt.get_backend().lower() == "agg" or os.environ.get("CI") == "true", + reason="animate=True busy-waits on a timer callback that never " + "fires under the non-interactive Agg backend", + ) def test_visualize(self): spline = BSplineSE3(self.control_poses) spline.visualize( @@ -69,7 +73,11 @@ def test_small_delta_t(self): np.linspace(0, InterpSplineSE3._e, len(self.waypoints)), self.waypoints ) - @pytest.mark.skipif(os.environ.get("CI") == "true", reason="no display in CI") + @pytest.mark.skipif( + plt.get_backend().lower() == "agg" or os.environ.get("CI") == "true", + reason="animate=True busy-waits on a timer callback that never " + "fires under the non-interactive Agg backend", + ) def test_visualize(self): spline = InterpSplineSE3(self.times, self.waypoints) spline.visualize( @@ -110,7 +118,9 @@ def test_spline_fit(self): assert fit.max_angular_error() < np.deg2rad(5.0) assert fit.max_angular_error() < 0.1 - if os.environ.get("CI") != "true": + # animate=True busy-waits on a timer callback that never fires + # under the non-interactive Agg backend + if plt.get_backend().lower() != "agg" and os.environ.get("CI") != "true": spline.visualize( sample_times=np.linspace(0, self.time_horizon, 100), animate=True,