diff --git a/README.rst b/README.rst index 96db5ca6..a81b8322 100644 --- a/README.rst +++ b/README.rst @@ -114,13 +114,24 @@ and the tests will pass if the images are the same. If you omit the runs, without checking the output images. -Generating a Failure Summary -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Generating a Test Summary +^^^^^^^^^^^^^^^^^^^^^^^^^ By specifying the ``--mpl-generate-summary=html`` CLI argument, a HTML summary -page will be generated showing the baseline, diff and result image for each -failing test. If no baseline images are configured, just the result images will -be displayed. (See also, the **Results always** section below.) +page will be generated showing the result, log entry and RMS of each test, +and the hashes if configured. The baseline, diff and result image for each +failing test will be shown. If **Results always** is configured +(see section below), images for passing tests will also be shown. +If no baseline images are configured, just the result images will +be displayed. + ++---------------+---------------+---------------+ +| |html all| | |html filter| | |html result| | ++---------------+---------------+---------------+ + +As well as ``html``, ``basic-html`` can be specified for an alternative HTML +summary which does not rely on JavaScript or external resources. A ``json`` +summary can also be saved. Multiple options can be specified comma-separated. Options ------- @@ -301,6 +312,9 @@ install the latest version of the plugin then do:: The reason for having to install the plugin first is to ensure that the plugin is correctly loaded as part of the test suite. +.. |html all| image:: images/html_all.png +.. |html filter| image:: images/html_filter.png +.. |html result| image:: images/html_result.png .. |expected| image:: images/baseline-coords_overlay_auto_coord_meta.png .. |actual| image:: images/coords_overlay_auto_coord_meta.png .. |diff| image:: images/coords_overlay_auto_coord_meta-failed-diff.png diff --git a/images/html_all.png b/images/html_all.png new file mode 100644 index 00000000..82e3eec4 Binary files /dev/null and b/images/html_all.png differ diff --git a/images/html_filter.png b/images/html_filter.png new file mode 100644 index 00000000..9fc6f998 Binary files /dev/null and b/images/html_filter.png differ diff --git a/images/html_result.png b/images/html_result.png new file mode 100644 index 00000000..d8ef5c44 Binary files /dev/null and b/images/html_result.png differ diff --git a/pytest_mpl/plugin.py b/pytest_mpl/plugin.py index 425b5314..a972d813 100644 --- a/pytest_mpl/plugin.py +++ b/pytest_mpl/plugin.py @@ -43,7 +43,9 @@ import pytest -SUPPORTED_FORMATS = {'html', 'json'} +from pytest_mpl.summary.html import generate_summary_basic_html, generate_summary_html + +SUPPORTED_FORMATS = {'html', 'json', 'basic-html'} SHAPE_MISMATCH_ERROR = """Error: Image dimensions did not match. Expected shape: {expected_shape} @@ -51,37 +53,6 @@ Actual shape: {actual_shape} {actual_path}""" -HTML_INTRO = """ - - - - - - -

Image test comparison

-%summary% - - - - - - - -""" - def _download_file(baseline, filename): # Note that baseline can be a comma-separated list of URLs that we can @@ -162,7 +133,7 @@ def pytest_addoption(parser): group.addoption('--mpl-generate-summary', action='store', help="Generate a summary report of any failed tests" ", in --mpl-results-path. The type of the report should be " - "specified. Supported types are `html` and `json`. " + "specified. Supported types are `html`, `json` and `basic-html`. " "Multiple types can be specified separated by commas.") results_path_help = "directory for test results, relative to location where py.test is run" @@ -712,105 +683,6 @@ def item_function_wrapper(*args, **kwargs): else: item.obj = item_function_wrapper - def generate_stats(self): - """ - Generate a dictionary of summary statistics. - """ - stats = {'passed': 0, 'failed': 0, 'passed_baseline': 0, 'failed_baseline': 0, 'skipped': 0} - for test in self._test_results.values(): - if test['status'] == 'passed': - stats['passed'] += 1 - if test['rms'] is not None: - stats['failed_baseline'] += 1 - elif test['status'] == 'failed': - stats['failed'] += 1 - if test['rms'] is None: - stats['passed_baseline'] += 1 - elif test['status'] == 'skipped': - stats['skipped'] += 1 - else: - raise ValueError(f"Unknown test status '{test['status']}'.") - self._test_stats = stats - - def generate_summary_html(self): - """ - Generate a simple HTML table of the failed test results - """ - html_file = self.results_dir / 'fig_comparison.html' - with open(html_file, 'w') as f: - - passed = f"{self._test_stats['passed']} passed" - if self._test_stats['failed_baseline'] > 0: - passed += (" hash comparison, although " - f"{self._test_stats['failed_baseline']} " - "of those have a different baseline image") - - failed = f"{self._test_stats['failed']} failed" - if self._test_stats['passed_baseline'] > 0: - failed += (" hash comparison, although " - f"{self._test_stats['passed_baseline']} " - "of those have a matching baseline image") - - f.write(HTML_INTRO.replace('%summary%', f'

{passed}.

{failed}.

')) - - for test_name in sorted(self._test_results.keys()): - summary = self._test_results[test_name] - - if not self.results_always and summary['result_image'] is None: - continue # Don't show test if no result image - - if summary['rms'] is None and summary['tolerance'] is not None: - rms = (f'
\n' - f' RMS: ' - f' < {summary["tolerance"]}\n' - f'
') - elif summary['rms'] is not None: - rms = (f'
\n' - f' RMS: ' - f' {summary["rms"]}\n' - f'
') - else: - rms = '' - - hashes = '' - if summary['baseline_hash'] is not None: - hashes += (f'
Baseline: ' - f'{summary["baseline_hash"]}
\n') - if summary['result_hash'] is not None: - hashes += (f'
Result: ' - f'{summary["result_hash"]}
\n') - if len(hashes) > 0: - if summary["baseline_hash"] == summary["result_hash"]: - hash_result = 'passed' - else: - hash_result = 'failed' - hashes = f'
\n{hashes}
' - - images = {} - for image_type in ['baseline_image', 'diff_image', 'result_image']: - if summary[image_type] is not None: - images[image_type] = f'' - else: - images[image_type] = '' - - f.write(f'\n' - ' \n' - f' \n' - f' \n' - f' \n' - '\n\n') - - f.write('
Test NameBaseline imageDiffNew image
\n' - '
\n' - f'
{test_name}
\n' - f'
{summary["status"]}
\n' - f' {rms}{hashes}\n' - '
{images["baseline_image"]}{images["diff_image"]}{images["result_image"]}
\n') - f.write('\n') - f.write('') - - return html_file - def generate_summary_json(self): json_file = self.results_dir / 'results.json' with open(json_file, 'w') as f: @@ -843,13 +715,14 @@ def pytest_unconfigure(self, config): if self._test_results[test_name][image_type] == '%EXISTS%': self._test_results[test_name][image_type] = str(directory / filename) - self.generate_stats() - if 'json' in self.generate_summary: summary = self.generate_summary_json() print(f"A JSON report can be found at: {summary}") if 'html' in self.generate_summary: - summary = self.generate_summary_html() + summary = generate_summary_html(self._test_results, self.results_dir) + print(f"A summary of the failed tests can be found at: {summary}") + if 'basic-html' in self.generate_summary: + summary = generate_summary_basic_html(self._test_results, self.results_dir) print(f"A summary of the failed tests can be found at: {summary}") diff --git a/pytest_mpl/summary/__init__.py b/pytest_mpl/summary/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest_mpl/summary/html.py b/pytest_mpl/summary/html.py new file mode 100644 index 00000000..858cfe21 --- /dev/null +++ b/pytest_mpl/summary/html.py @@ -0,0 +1,288 @@ +import os +import sys +import shutil + +if sys.version_info >= (3, 8): + from functools import cached_property +else: + cached_property = property + +from jinja2 import Environment, PackageLoader, select_autoescape + +__all__ = ['generate_summary_html', 'generate_summary_basic_html'] + + +class Results: + """ + Data for the whole test run, used for providing data to the template. + + Parameters + ---------- + results : dict + The `pytest_mpl.plugin.ImageComparison._test_results` object. + title : str + Value for HTML Codestin Search App + + +{% include 'navbar.html' %} +
+
+
+ +
+
+
+{% include 'filter.html' %} + + + + + diff --git a/pytest_mpl/summary/templates/basic.html b/pytest_mpl/summary/templates/basic.html new file mode 100644 index 00000000..3feb1095 --- /dev/null +++ b/pytest_mpl/summary/templates/basic.html @@ -0,0 +1,81 @@ + + + + + + + Codestin Search App + + +

Image test comparison

+

+ {{ results.statistics['passed'] }} passed + {% if results.statistics['failed_baseline'] > 0 -%} + hash comparison, although {{ results.statistics['failed_baseline'] }} + of those have a different baseline image + {%- endif %} +

+

+ {{ results.statistics['failed'] }} failed + {% if results.statistics['passed_baseline'] > 0 -%} + hash comparison, although {{ results.statistics['passed_baseline'] }} + of those have a matching baseline image + {%- endif %} +

+ + + + + + + + {% for result in results.cards -%} + {% if result.result_image -%} + + + {% macro image(file) -%} + + {%- endmacro -%} + {{ image(result.baseline_image) }} + {{ image(result.diff_image) }} + {{ image(result.result_image) }} + + {%- endif %} + {%- endfor %} +
BaselineDiffResult
+
{{ result.module }}
+
{{ result.name }}
+
{{ result.status | upper }}
+
RMS: {{ result.rms_str }} ({{ result.image_status }})
+ {% if result.result_hash -%} +
Result hash: {{ result.result_hash }} ({{ result.hash_status}})
+ {%- endif %} +
{% if file %}{% endif %}
+ + diff --git a/pytest_mpl/summary/templates/extra.js b/pytest_mpl/summary/templates/extra.js new file mode 100644 index 00000000..cb80eefe --- /dev/null +++ b/pytest_mpl/summary/templates/extra.js @@ -0,0 +1,216 @@ +// Remove all elements of class mpl-hash if hash test not run +if (document.body.classList[0] == 'no-hash-test') { + document.querySelectorAll('.mpl-hash').forEach(function (elem) { + elem.parentElement.removeChild(elem); + }); +} + +// Enable tooltips +var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) +var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl) +}) + +// Search, sort and filter +var options = { + valueNames: ['collected-sort', 'test-name', 'status-sort', 'rms-sort', 'filter-classes', + 'rms-value', 'baseline-hash-value', 'result-hash-value'] +}; +var resultsList = new List('results', options); + +var filterClasses = []; +var filterElements = document.getElementById('filterForm').getElementsByClassName('filter'); +for (var i = 0, elem; elem = filterElements[i++];) { + filterClasses.push(elem.id); +} +countClasses(); + +// Get and apply initial search parameters from URL +var searchParams = new URLSearchParams(window.location.search); +if (window.location.search.length > 0) { + applyURL(); +} else { // If no parameters, apply default but don't update the URL + resultsList.sort('status-sort', {order: "desc"}); +} +// Show page after initial filtering to prevent flashing +document.getElementById('resultslist').style.display = null; + +// Show a message if no tests match current filters +var alertPlaceholder = document.getElementById('noResultsAlert'); +warnIfNone(); // Initialize +resultsList.on('updated', function () { + warnIfNone(); +}) + +// Record URL parameters after new sort (but do not update URL yet) +resultsList.on('sortComplete', function updateSortURL() { + var sortElements = document.getElementsByClassName('sort'); + for (var i = 0, elem; elem = sortElements[i++];) { + if (elem.checked) { + searchParams.set('sort', elem.dataset['sort']); + searchParams.set('order', getSortOrder(elem)); + break; + } + } +}) + +// Update URL when filter sidebar is hidden +var filterOffcanvas = document.getElementById('offcanvasFilter'); +filterOffcanvas.addEventListener('hide.bs.offcanvas', function () { + updateURL(); +}) + +// Update URL when search bar is clicked away from +function searchComplete() { + var q = document.getElementsByClassName('search')[0].value; + if (q.length > 0) { // Include query in URL if active query + searchParams.set('q', q); + } else { + searchParams.delete('q'); + } + updateURL(); +} + +// Search, sort and filter by the current URL parameters +function applyURL() { + // Get and apply sort + var sort = searchParams.get('sort'); + if (sort) { + document.getElementsByName('sort').forEach( + function selectSort(elem) { + if (elem.dataset['sort'] == sort) { + elem.checked = true; + } + } + ) + resultsList.sort(sort, {order: searchParams.get('order')}); + } + // Get and apply filters + var filters = searchParams.getAll('f'); + if (filters.length > 0) { + var cond = searchParams.get('c'); + if (cond === 'and') { + document.getElementById('conditionand').checked = true; + } else if (cond === 'or') { + document.getElementById('conditionor').checked = true; + } + for (var i = 0, f; f = filters[i++];) { + document.getElementById(f).checked = true; + } + applyFilters(); + } + // Get and apply search + var query = searchParams.get('q'); + if (query) { + document.getElementsByClassName('search')[0].value = query; + resultsList.search(query); + } +} + +// Update the URL with the current search parameters +function updateURL() { + var query = searchParams.toString(); + if (query.length > 0) { // Don't end the URL with '?' + query = '?' + query; + } + if (window.location.search != query) { // Update URL if changed + history.replaceState(null, '', window.location.pathname + query); + } +} + +// Get the current sorting order from an active sort radio button +function getSortOrder(elem) { + var fixedOrder = elem.dataset['order']; + if (fixedOrder == 'asc' || fixedOrder == 'desc') { + return fixedOrder; + } else if (elem.classList.contains('desc')) { + return 'desc'; + } else if (elem.classList.contains('asc')) { + return 'asc'; + } else { + return 'asc'; + } +} + +function applyFilters() { + searchParams.delete('f'); + searchParams.delete('c'); + var cond_and = document.getElementById('filterForm').elements['conditionand'].checked; + var filters = []; + var filterElements = document.getElementById('filterForm').getElementsByClassName('filter'); + for (var i = 0, elem; elem = filterElements[i++];) { + if (elem.checked) { + filters.push(elem.id); + searchParams.append('f', elem.id); + } + } + if (filters.length == 0) { + resultsList.filter(); // Show all if nothing selected + return countClasses(); + } + searchParams.set('c', (cond_and) ? 'and' : 'or'); + resultsList.filter(function (item) { + var inc = false; + for (var i = 0, filt; filt = filters[i++];) { + if (item.values()['filter-classes'].includes(filt)) { + if (!cond_and) { + return true; + } + inc = true; + } else { + if (cond_and) { + return false; + } + } + } + return inc; + }); + countClasses(); +} + +function resetFilters() { + resultsList.filter(); + document.getElementById("filterForm").reset(); + countClasses(); + searchParams.delete('f'); + searchParams.delete('c'); +} + +function countClasses() { + for (var i = 0, filt; filt = filterElements[i++];) { + var count = 0; + if (document.getElementById('filterForm').elements['conditionand'].checked) { + var itms = resultsList.visibleItems; + } else { + var itms = resultsList.items; + } + for (var j = 0, itm; itm = itms[j++];) { + if (itm.values()['filter-classes'].includes(filt.id)) { + count++; + } + } + var badge = filt.parentElement.getElementsByClassName('badge')[0]; + badge.innerHTML = count.toString(); + } +} + +function warnIfNone() { + if (resultsList.visibleItems.length === 0) { // Show info box + alertPlaceholder.innerHTML = ''; + } else { // Remove info box + alertPlaceholder.innerHTML = ''; + } +} + +// Clear active search and filters +function clearAll() { + document.getElementsByClassName('search')[0].value = ''; + resultsList.search(''); + searchParams.delete('q'); + resetFilters(); + updateURL(); +} diff --git a/pytest_mpl/summary/templates/filter.html b/pytest_mpl/summary/templates/filter.html new file mode 100644 index 00000000..2cf18da6 --- /dev/null +++ b/pytest_mpl/summary/templates/filter.html @@ -0,0 +1,60 @@ +
+
+
Sort and filter results
+ +
+
+
Sort tests by...
+
+ {% macro sort_option(id, name, order='', default=false) -%} + + + {%- endmacro -%} + {{ sort_option('status-sort', 'status', 'desc', default=true) }} + {{ sort_option('collected-sort', 'collected', 'asc') }} + {{ sort_option('test-name', 'name') }} + {{ sort_option('rms-sort', 'RMS', 'desc') }} +
+
+
Show tests which have...
+
+ {% macro filter_option(id, name) -%} + + {%- endmacro -%} + {{ filter_option('overall-passed', 'passed') }} + {{ filter_option('overall-failed', 'failed') }} + {{ filter_option('overall-skipped', 'skipped') }} +
+
+ {{ filter_option('image-match', 'matching images') }} + {{ filter_option('image-diff', 'differing images') }} + {{ filter_option('image-missing', 'no baseline image') }} +
+
+ {{ filter_option('hash-match', 'matching hashes') }} + {{ filter_option('hash-diff', 'differing hashes') }} + {{ filter_option('hash-missing', 'no baseline hash') }} +
+
+ + +
+
+ + + + +
+
+
+
+
diff --git a/pytest_mpl/summary/templates/hash.svg b/pytest_mpl/summary/templates/hash.svg new file mode 100644 index 00000000..a16f5535 --- /dev/null +++ b/pytest_mpl/summary/templates/hash.svg @@ -0,0 +1,3 @@ + + + diff --git a/pytest_mpl/summary/templates/image.svg b/pytest_mpl/summary/templates/image.svg new file mode 100644 index 00000000..556e0dbf --- /dev/null +++ b/pytest_mpl/summary/templates/image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/pytest_mpl/summary/templates/navbar.html b/pytest_mpl/summary/templates/navbar.html new file mode 100644 index 00000000..3ba762ac --- /dev/null +++ b/pytest_mpl/summary/templates/navbar.html @@ -0,0 +1,21 @@ + diff --git a/pytest_mpl/summary/templates/result.html b/pytest_mpl/summary/templates/result.html new file mode 100644 index 00000000..5cdfac55 --- /dev/null +++ b/pytest_mpl/summary/templates/result.html @@ -0,0 +1,51 @@ +{% for r in results.cards -%} +
+ + + + + +
+ + {% if r.diff_image -%} +
+
+ diff image +
+
+ result image +
+
+ {%- else -%} + result image + {%- endif %} +
+ {% filter indent(width=8) -%} + {% include 'result_images.html' %} + {%- endfilter %} +
+
{{ r.module }}
+
{{ r.name }}
+
+
+ + {% for badge in r.badges -%} + + {% endfor %} +
+
+
+
+
+{%- endfor %} diff --git a/pytest_mpl/summary/templates/result_images.html b/pytest_mpl/summary/templates/result_images.html new file mode 100644 index 00000000..2affdd65 --- /dev/null +++ b/pytest_mpl/summary/templates/result_images.html @@ -0,0 +1,57 @@ +
+
+
{{ r.module }}
+ +
+
+
{{ r.name }}
+
+ {% macro image_card(file, name) -%} +
+
+
{{ name }}
+ {% if file -%} + + {%- endif %} +
+
+ {%- endmacro -%} + {{ image_card(r.baseline_image, 'Baseline') }} + {{ image_card(r.diff_image, 'Diff') }} + {{ image_card(r.result_image, 'Result') }} +
+
+
+
+
{{ r.status | upper }}
+
+
{{ r.status_msg }}
+
+
+
+
+
+
{{ r.image_status | image_status_msg }}
+
+ {% macro pre_data(id, value, name) -%} +
+ +
{{ value }}
+
+ {%- endmacro -%} + {{ pre_data('rms', r.rms_str, 'RMS') }} + {{ pre_data('tolerance', r.tolerance, 'Tolerance') }} +
+
+
+
{{ r.hash_status | hash_status_msg }}
+
+ {{ pre_data('baseline_hash', r.baseline_hash, 'Baseline') }} + {{ pre_data('result_hash', r.result_hash, 'Result') }} +
+
+
+
+
+
diff --git a/pytest_mpl/summary/templates/styles.css b/pytest_mpl/summary/templates/styles.css new file mode 100644 index 00000000..7a07169e --- /dev/null +++ b/pytest_mpl/summary/templates/styles.css @@ -0,0 +1,58 @@ +body.no-hash-test .mpl-hash { + display: none; +} +.navbar-brand span.repo { + font-weight: bold; +} +.nav-filtertools { + width: 100%; +} +.nav-filtertools div.spacer { + flex: 1; + display: none; +} +.nav-filtertools .nav-searchbar { + flex: 10; +} +#filterForm .spacer { + flex: 1; +} +#filterForm span.badge { + float: right; + margin-top: 2px; +} +@media (min-width: 576px) { + .nav-filtertools div.spacer { + display: block; + } + .nav-filtertools .nav-searchbar { + max-width: 400px; + } +} +.image-missing .mpl-image .card-body { + display: none; +} +pre { + white-space: pre-wrap; +} +div.result div.status-badge button img { + vertical-align: sub; +} +div.result div.status-badge button.btn-warning img { + filter: brightness(0); +} +div.mpl-images { + height: auto; +} +div.hover-image { + position: relative; + border: 3px solid #dc3545; + border-radius: .2rem; +} +div.hover-image div.result-image { + position: absolute; + top: 0; +} +div.hover-image div.result-image img { + filter: opacity(0.3); +} diff --git a/setup.cfg b/setup.cfg index 3e213b81..cd193ab4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,12 +23,14 @@ long_description_content_type = text/x-rst [options] zip_safe = True packages = find: +include_package_data = True python_requires = >=3.6 install_requires = pytest matplotlib importlib_resources;python_version<'3.8' packaging + Jinja2 [options.entry_points] pytest11 = diff --git a/tests/test_pytest_mpl.py b/tests/test_pytest_mpl.py index e2a77c36..62fe0091 100644 --- a/tests/test_pytest_mpl.py +++ b/tests/test_pytest_mpl.py @@ -441,11 +441,16 @@ def test_results_always(tmpdir): code = call_pytest(['--mpl', test_file, '--mpl-results-always', rf'--mpl-hash-library={hash_library}', rf'--mpl-baseline-path={baseline_dir_abs}', - '--mpl-generate-summary=html,json', + '--mpl-generate-summary=html,json,basic-html', rf'--mpl-results-path={results_path.strpath}']) assert code == 0 # hashes correct, so all should pass - comparison_file = results_path.join('fig_comparison.html') + # assert files for interactive HTML exist + assert results_path.join('fig_comparison.html').exists() + assert results_path.join('styles.css').exists() + assert results_path.join('extra.js').exists() + + comparison_file = results_path.join('fig_comparison_basic.html') with open(comparison_file, 'r') as f: html = f.read() @@ -462,7 +467,7 @@ def test_results_always(tmpdir): test_name = f'test.{test}' - summary = f'
{test_name}
' + summary = f'
{test_name.split(".")[-1]}
' assert summary in html assert test_name in json_results.keys()