Skip to content

Commit 9cd3fbe

Browse files
committed
Figure equality-based tests.
Implement a `check_figures_equal` decorator, which allows tests where both the reference and the test image are generated. The idea is to allow tests of the form "this feature should be equivalent to that (usually more complex) way of achieving the same thing" without further bloating the test image directory. The saved images are properly created in the `result_images` folder, but cannot be "accepted" or "rejected" using the triage_tests UI (as there is indeed no reference image to be saved in the repo). Includes an example use case.
1 parent af7a74b commit 9cd3fbe

File tree

3 files changed

+70
-25
lines changed

3 files changed

+70
-25
lines changed

lib/matplotlib/testing/decorators.py

+40
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,46 @@ def image_comparison(baseline_images, extensions=None, tol=0,
407407
savefig_kwargs=savefig_kwarg, style=style)
408408

409409

410+
def check_figures_equal(*, extensions=("png", "pdf", "svg"), tol=0):
411+
"""
412+
Decorator for test cases that generate and compare two figures.
413+
414+
The decorated function must take two arguments, *fig_test* and *fig_ref*,
415+
and draw the test and reference images on them. After the function
416+
returns, the figures are saved and compared.
417+
418+
Arguments
419+
---------
420+
extensions : list, default: ["png", "pdf", "svg"]
421+
The extensions to test.
422+
tol : float
423+
The RMS threshold above which the test is considered failed.
424+
"""
425+
426+
def decorator(func):
427+
import pytest
428+
429+
_, result_dir = map(Path, _image_directories(func))
430+
431+
@pytest.mark.parametrize("ext", extensions)
432+
def wrapper(ext):
433+
fig_test = plt.figure("test")
434+
fig_ref = plt.figure("reference")
435+
func(fig_test, fig_ref)
436+
test_image_path = str(
437+
result_dir / (func.__name__ + "." + ext))
438+
ref_image_path = str(
439+
result_dir / (func.__name__ + "-expected." + ext))
440+
fig_test.savefig(test_image_path)
441+
fig_ref.savefig(ref_image_path)
442+
_raise_on_image_difference(
443+
ref_image_path, test_image_path, tol=tol)
444+
445+
return wrapper
446+
447+
return decorator
448+
449+
410450
def _image_directories(func):
411451
"""
412452
Compute the baseline and result image directories for testing *func*.

lib/matplotlib/tests/test_axes.py

+6-13
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import warnings
1515

1616
import matplotlib
17-
from matplotlib.testing.decorators import image_comparison
17+
from matplotlib.testing.decorators import image_comparison, check_figures_equal
1818
import matplotlib.pyplot as plt
1919
import matplotlib.markers as mmarkers
2020
import matplotlib.patches as mpatches
@@ -5702,18 +5702,11 @@ def test_plot_columns_cycle_deprecation():
57025702
plt.plot(np.zeros((2, 2)), np.zeros((2, 3)))
57035703

57045704

5705-
def test_markerfacecolor_none_alpha():
5706-
fig1, ax1 = plt.subplots()
5707-
ax1.plot(0, "o", mfc="none", alpha=.5)
5708-
buf1 = io.BytesIO()
5709-
fig1.savefig(buf1)
5710-
5711-
fig2, ax2 = plt.subplots()
5712-
ax2.plot(0, "o", mfc="w", alpha=.5)
5713-
buf2 = io.BytesIO()
5714-
fig2.savefig(buf2)
5715-
5716-
assert buf1.getvalue() == buf2.getvalue()
5705+
# pdf and svg tests fail using travis' old versions of gs and inkscape.
5706+
@check_figures_equal(extensions=["png"])
5707+
def test_markerfacecolor_none_alpha(fig_test, fig_ref):
5708+
fig_test.subplots().plot(0, "o", mfc="none", alpha=.5)
5709+
fig_ref.subplots().plot(0, "o", mfc="w", alpha=.5)
57175710

57185711

57195712
def test_tick_padding_tightbbox():

tools/triage_tests.py

+24-12
Original file line numberDiff line numberDiff line change
@@ -192,14 +192,22 @@ def set_large_image(self, index):
192192
self.thumbnails[self.current_thumbnail].setFrameShape(1)
193193

194194
def accept_test(self):
195-
self.entries[self.current_entry].accept()
195+
entry = self.entries[self.current_entry]
196+
if entry.status == 'autogen':
197+
print('Cannot accept autogenerated test cases.')
198+
return
199+
entry.accept()
196200
self.filelist.currentItem().setText(
197201
self.entries[self.current_entry].display)
198202
# Auto-move to the next entry
199203
self.set_entry(min((self.current_entry + 1), len(self.entries) - 1))
200204

201205
def reject_test(self):
202-
self.entries[self.current_entry].reject()
206+
entry = self.entries[self.current_entry]
207+
if entry.status == 'autogen':
208+
print('Cannot reject autogenerated test cases.')
209+
return
210+
entry.reject()
203211
self.filelist.currentItem().setText(
204212
self.entries[self.current_entry].display)
205213
# Auto-move to the next entry
@@ -261,11 +269,14 @@ def __init__(self, path, root, source):
261269
]
262270
self.thumbnails = [os.path.join(self.dir, x) for x in self.thumbnails]
263271

264-
self.status = 'unknown'
265-
266-
if self.same(os.path.join(self.dir, self.generated),
272+
if not Path(self.destdir, self.generated).exists():
273+
# This case arises from a check_figures_equal test.
274+
self.status = 'autogen'
275+
elif self.same(os.path.join(self.dir, self.generated),
267276
os.path.join(self.destdir, self.generated)):
268277
self.status = 'accept'
278+
else:
279+
self.status = 'unknown'
269280

270281
def same(self, a, b):
271282
"""
@@ -297,16 +308,18 @@ def display(self):
297308
Get the display string for this entry. This is the text that
298309
appears in the list widget.
299310
"""
300-
status_map = {'unknown': '\N{BALLOT BOX}',
301-
'accept': '\N{BALLOT BOX WITH CHECK}',
302-
'reject': '\N{BALLOT BOX WITH X}'}
311+
status_map = {
312+
'unknown': '\N{BALLOT BOX}',
313+
'accept': '\N{BALLOT BOX WITH CHECK}',
314+
'reject': '\N{BALLOT BOX WITH X}',
315+
'autogen': '\N{WHITE SQUARE CONTAINING BLACK SMALL SQUARE}',
316+
}
303317
box = status_map[self.status]
304318
return '{} {} [{}]'.format(box, self.name, self.extension)
305319

306320
def accept(self):
307321
"""
308-
Accept this test by copying the generated result to the
309-
source tree.
322+
Accept this test by copying the generated result to the source tree.
310323
"""
311324
a = os.path.join(self.dir, self.generated)
312325
b = os.path.join(self.destdir, self.generated)
@@ -315,8 +328,7 @@ def accept(self):
315328

316329
def reject(self):
317330
"""
318-
Reject this test by copying the expected result to the
319-
source tree.
331+
Reject this test by copying the expected result to the source tree.
320332
"""
321333
a = os.path.join(self.dir, self.expected)
322334
b = os.path.join(self.destdir, self.generated)

0 commit comments

Comments
 (0)