66
77import pytest
88
9+ mpl = pytest .importorskip ("matplotlib" )
10+ mpl .use (
11+ "Agg"
12+ ) # Non-interactive backend; avoids DPR pre-inflation on HiDPI hosts.
13+
914
1015@pytest .mark .requires ("matplotlib" )
1116class TestSyncWebSocket :
@@ -180,6 +185,63 @@ def capture_send(data: Any, buffers: Any = None) -> None:
180185 plt .close (fig )
181186
182187
188+ @pytest .mark .requires ("matplotlib" )
189+ class TestDpiPreservationOnRerun :
190+ """Re-running a cell that wraps the same figure should not compound DPI.
191+
192+ matplotlib's ``FigureCanvasBase.__init__`` unconditionally captures
193+ ``figure._original_dpi = figure.dpi``. After a HiDPI client connects
194+ and scales ``figure.dpi`` up by the device pixel ratio, a subsequent
195+ canvas creation on the same figure would treat that scaled value as
196+ "original" and scale it again — making the resolution compound on
197+ every rerun (see issue #9466).
198+ """
199+
200+ def test_dpi_does_not_compound_across_reruns (self ) -> None :
201+ import matplotlib .pyplot as plt
202+
203+ from marimo ._plugins .ui ._impl .from_mpl_interactive import (
204+ _MplCleanupHandle ,
205+ mpl_interactive ,
206+ )
207+
208+ fig , ax = plt .subplots (figsize = (5 , 5 ), dpi = 100 )
209+ ax .plot ([1 , 2 , 3 ])
210+
211+ for _ in range (3 ):
212+ with patch ("marimo._plugins.ui._impl.comm.broadcast_notification" ):
213+ element = mpl_interactive (fig )
214+
215+ # Simulate the HiDPI handshake from the frontend.
216+ element ._figure_manager .handle_json (
217+ {"type" : "set_device_pixel_ratio" , "device_pixel_ratio" : 2 }
218+ )
219+ element ._figure_manager .handle_json (
220+ {"type" : "resize" , "width" : 500 , "height" : 500 }
221+ )
222+ # While the canvas is live, dpi reflects the device-scaled value.
223+ assert fig .dpi == 200
224+ assert tuple (fig .get_size_inches ()) == (5.0 , 5.0 )
225+
226+ # Simulate cell teardown — the cleanup handle is what marimo
227+ # registers via cell_lifecycle_registry; running it directly
228+ # avoids needing a live runtime context in the test.
229+ cleanup = _MplCleanupHandle (
230+ comm = element ._comm ,
231+ figure_manager = element ._figure_manager ,
232+ sync_ws = element ._sync_ws ,
233+ original_dpi = element ._original_dpi ,
234+ original_size_inches = element ._original_size_inches ,
235+ )
236+ cleanup .dispose (context = MagicMock (), deletion = False )
237+
238+ # After dispose the figure is restored to the user's intent.
239+ assert fig .dpi == 100
240+ assert tuple (fig .get_size_inches ()) == (5.0 , 5.0 )
241+
242+ plt .close (fig )
243+
244+
183245@pytest .mark .requires ("matplotlib" )
184246class TestMplCleanupHandle :
185247 """Test that _MplCleanupHandle properly closes the comm."""
@@ -190,7 +252,9 @@ def test_dispose_closes_comm(self) -> None:
190252 )
191253
192254 mock_comm = MagicMock ()
193- handle = _MplCleanupHandle (mock_comm )
255+ handle = _MplCleanupHandle (
256+ mock_comm , original_dpi = 100 , original_size_inches = (5.0 , 5.0 )
257+ )
194258 result = handle .dispose (context = MagicMock (), deletion = False )
195259
196260 assert result is True
@@ -202,7 +266,9 @@ def test_dispose_on_deletion(self) -> None:
202266 )
203267
204268 mock_comm = MagicMock ()
205- handle = _MplCleanupHandle (mock_comm )
269+ handle = _MplCleanupHandle (
270+ mock_comm , original_dpi = 100 , original_size_inches = (5.0 , 5.0 )
271+ )
206272 result = handle .dispose (context = MagicMock (), deletion = True )
207273
208274 assert result is True
@@ -216,7 +282,13 @@ def test_dispose_cleans_up_figure_manager(self) -> None:
216282 mock_comm = MagicMock ()
217283 mock_manager = MagicMock ()
218284 mock_ws = MagicMock ()
219- handle = _MplCleanupHandle (mock_comm , mock_manager , mock_ws )
285+ handle = _MplCleanupHandle (
286+ mock_comm ,
287+ figure_manager = mock_manager ,
288+ sync_ws = mock_ws ,
289+ original_dpi = 100 ,
290+ original_size_inches = (5.0 , 5.0 ),
291+ )
220292 result = handle .dispose (context = MagicMock (), deletion = False )
221293
222294 assert result is True
@@ -234,7 +306,13 @@ def test_dispose_tolerates_manager_errors(self) -> None:
234306 mock_manager .remove_web_socket .side_effect = RuntimeError ("boom" )
235307 mock_manager .canvas .close .side_effect = RuntimeError ("boom" )
236308 mock_ws = MagicMock ()
237- handle = _MplCleanupHandle (mock_comm , mock_manager , mock_ws )
309+ handle = _MplCleanupHandle (
310+ mock_comm ,
311+ figure_manager = mock_manager ,
312+ sync_ws = mock_ws ,
313+ original_dpi = 100 ,
314+ original_size_inches = (5.0 , 5.0 ),
315+ )
238316 # Should not raise even if manager cleanup fails
239317 result = handle .dispose (context = MagicMock (), deletion = False )
240318
0 commit comments