001/*
002 *    GeoAPI - Java interfaces for OGC/ISO standards
003 *    Copyright © 2008-2024 Open Geospatial Consortium, Inc.
004 *    http://www.geoapi.org
005 *
006 *    Licensed under the Apache License, Version 2.0 (the "License");
007 *    you may not use this file except in compliance with the License.
008 *    You may obtain a copy of the License at
009 *
010 *        http://www.apache.org/licenses/LICENSE-2.0
011 *
012 *    Unless required by applicable law or agreed to in writing, software
013 *    distributed under the License is distributed on an "AS IS" BASIS,
014 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 *    See the License for the specific language governing permissions and
016 *    limitations under the License.
017 */
018package org.opengis.test;
019
020import java.util.Collection;
021import java.util.function.Supplier;
022import java.awt.Shape;
023import java.awt.geom.Rectangle2D;
024import java.awt.geom.PathIterator;
025import java.awt.geom.AffineTransform;
026import java.awt.image.RenderedImage;
027
028import org.opengis.util.InternationalString;
029import org.opengis.metadata.Identifier;
030import org.opengis.metadata.citation.Citation;
031import org.opengis.referencing.operation.Matrix;
032import org.opengis.referencing.cs.AxisDirection;
033import org.opengis.referencing.cs.CoordinateSystem;
034import org.opengis.test.coverage.image.PixelIterator;
035
036import static org.junit.jupiter.api.Assertions.*;
037
038
039/**
040 * Extension to JUnit assertion methods.
041 *
042 * @author  Martin Desruisseaux (Geomatys)
043 * @version 3.1
044 * @since   3.1
045 */
046@SuppressWarnings("strictfp")   // Because we still target Java 11.
047public final strictfp class Assertions {
048    /**
049     * The keyword for unrestricted value in {@link String} arguments.
050     */
051    private static final String UNRESTRICTED = "##unrestricted";
052
053    /**
054     * Do not allow instantiation of this class.
055     */
056    private Assertions() {
057    }
058
059    /**
060     * Returns the given message, or an empty string if the message is null.
061     *
062     * @param  message  the message, possibly null.
063     * @return the given message or an empty string, never null.
064     */
065    private static String nonNull(final String message) {
066        return (message != null) ? message.trim().concat(" ") : "";
067    }
068
069    /**
070     * Returns the concatenation of the given message with the given extension.
071     * This method returns the given extension if the message is null or empty.
072     *
073     * <p>Invoking this method is equivalent to invoking {@code nonNull(message) + ext},
074     * but avoid the creation of temporary objects in the common case where the message
075     * is null.</p>
076     *
077     * @param  message  the message, or {@code null}.
078     * @param  ext      the extension to append after the message.
079     * @return the concatenated string.
080     */
081    private static String concat(String message, final String ext) {
082        if (message == null || (message = message.trim()).isEmpty()) {
083            return ext;
084        }
085        return message + ' ' + ext;
086    }
087
088    /**
089     * Verifies if we expected a null value, then returns {@code true} if the value is null as expected.
090     *
091     * @param  expected  the expected value (only its existence is checked).
092     * @param  actual    the actual value (only its existence is checked).
093     * @param  message   the message to show in case of test failure, or {@code null}.
094     * @return whether the actual value is null.
095     */
096    private static boolean isNull(final Object expected, final Object actual, final String message) {
097        final boolean isNull = (actual == null);
098        if (isNull != (expected == null)) {
099            fail(concat(message, isNull ? "Value is null." : "Expected null."));
100        }
101        return isNull;
102    }
103
104    /**
105     * Asserts that the given integer value is positive, including zero.
106     *
107     * @param value    the value to test.
108     * @param message  header of the exception message in case of failure, or {@code null} if none.
109     */
110    public static void assertPositive(final int value, final String message) {
111        if (value < 0) {
112            fail(nonNull(message) + "Value is " + value + '.');
113        }
114    }
115
116    /**
117     * Asserts that the given integer value is strictly positive, excluding zero.
118     *
119     * @param value    the value to test.
120     * @param message  header of the exception message in case of failure, or {@code null} if none.
121     */
122    public static void assertStrictlyPositive(final int value, final String message) {
123        if (value <= 0) {
124            fail(nonNull(message) + "Value is " + value + '.');
125        }
126    }
127
128    /**
129     * Asserts that the given minimum and maximum values make a valid range. More specifically
130     * asserts that if both values are non-null, then the minimum value is not greater than the
131     * maximum value.
132     *
133     * @param <T>      the type of values being compared.
134     * @param minimum  the lower bound of the range to test, or {@code null} if unbounded.
135     * @param maximum  the upper bound of the range to test, or {@code null} if unbounded.
136     * @param message  header of the exception message in case of failure, or {@code null} if none.
137     */
138    @SuppressWarnings("unchecked")
139    public static <T> void assertValidRange(final Comparable<T> minimum, final Comparable<T> maximum, final String message) {
140        if (minimum != null && maximum != null) {
141            if (minimum.compareTo((T) maximum) > 0) {
142                fail(nonNull(message) + "Range found is [" + minimum + " ... " + maximum + "].");
143            }
144        }
145    }
146
147    /**
148     * Asserts that the given minimum is smaller or equals to the given maximum.
149     *
150     * @param minimum  the lower bound of the range to test.
151     * @param maximum  the upper bound of the range to test.
152     * @param message  header of the exception message in case of failure, or {@code null} if none.
153     */
154    public static void assertValidRange(final int minimum, final int maximum, final String message) {
155        if (minimum > maximum) {
156            fail(nonNull(message) + "Range found is [" + minimum + " ... " + maximum + "].");
157        }
158    }
159
160    /**
161     * Asserts that the given minimum is smaller or equals to the given maximum.
162     * If one bound is or both bounds are {@linkplain Double#NaN NaN}, then the test fails.
163     *
164     * @param minimum  the lower bound of the range to test.
165     * @param maximum  the upper bound of the range to test.
166     * @param message  header of the exception message in case of failure, or {@code null} if none.
167     */
168    public static void assertValidRange(final double minimum, final double maximum, final String message) {
169        if (!(minimum <= maximum)) {            // Use `!` for catching NaN.
170            fail(nonNull(message) + "Range found is [" + minimum + " ... " + maximum + "].");
171        }
172    }
173
174    /**
175     * Asserts that the given value is inside the given range. This method does <strong>not</strong>
176     * test the validity of the given [{@code minimum} … {@code maximum}] range.
177     *
178     * @param <T>      the type of values being compared.
179     * @param minimum  the lower bound of the range (inclusive), or {@code null} if unbounded.
180     * @param maximum  the upper bound of the range (inclusive), or {@code null} if unbounded.
181     * @param value    the value to test, or {@code null} (which is a failure).
182     * @param message  header of the exception message in case of failure, or {@code null} if none.
183     */
184    public static <T> void assertBetween(final Comparable<T> minimum, final Comparable<T> maximum, T value, final String message) {
185        if (minimum != null) {
186            if (minimum.compareTo(value) > 0) {
187                fail(nonNull(message) + "Value " + value + " is less than " + minimum + '.');
188            }
189        }
190        if (maximum != null) {
191            if (maximum.compareTo(value) < 0) {
192                fail(nonNull(message) + "Value " + value + " is greater than " + maximum + '.');
193            }
194        }
195    }
196
197    /**
198     * Asserts that the given value is inside the given range. This method does <strong>not</strong>
199     * test the validity of the given [{@code minimum} … {@code maximum}] range.
200     *
201     * @param minimum  the lower bound of the range, inclusive.
202     * @param maximum  the upper bound of the range, inclusive.
203     * @param value    the value to test.
204     * @param message  header of the exception message in case of failure, or {@code null} if none.
205     */
206    public static void assertBetween(final int minimum, final int maximum, final int value, final String message) {
207        if (value < minimum) {
208            fail(nonNull(message) + "Value " + value + " is less than " + minimum + '.');
209        }
210        if (value > maximum) {
211            fail(nonNull(message) + "Value " + value + " is greater than " + maximum + '.');
212        }
213    }
214
215    /**
216     * Asserts that the given value is inside the given range. If the given {@code value} is
217     * {@linkplain Double#NaN NaN}, then this test passes silently. This method does <strong>not</strong>
218     * test the validity of the given [{@code minimum} … {@code maximum}] range.
219     *
220     * @param minimum  the lower bound of the range, inclusive.
221     * @param maximum  the upper bound of the range, inclusive.
222     * @param value    the value to test.
223     * @param message  header of the exception message in case of failure, or {@code null} if none.
224     */
225    public static void assertBetween(final double minimum, final double maximum, final double value, final String message) {
226        if (value < minimum) {
227            fail(nonNull(message) + "Value " + value + " is less than " + minimum + '.');
228        }
229        if (value > maximum) {
230            fail(nonNull(message) + "Value " + value + " is greater than " + maximum + '.');
231        }
232    }
233
234    /**
235     * Asserts that the given value is contained in the given collection. If the given collection
236     * is null, then this test passes silently (a null collection is considered as "unknown", not
237     * empty). If the given value is null, then the test passes only if the given collection
238     * contains the null element.
239     *
240     * @param collection  the collection where to look for inclusion, or {@code null} if unrestricted.
241     * @param value       the value to test for inclusion.
242     * @param message     header of the exception message in case of failure, or {@code null} if none.
243     */
244    public static void assertContains(final Collection<?> collection, final Object value, final String message) {
245        if (collection != null) {
246            if (!collection.contains(value)) {
247                fail(nonNull(message) + "Looked for value \"" + value + "\" in a collection of " +
248                        collection.size() + "elements.");
249            }
250        }
251    }
252
253    /**
254     * Asserts that the title or an alternate title of the given citation is equal to the given string.
255     * This method is typically used for testing if a citation stands for the OGC, OGP or EPSG authority
256     * for instance. Such abbreviations are often declared as {@linkplain Citation#getAlternateTitles()
257     * alternate titles} rather than the main {@linkplain Citation#getTitle() title}, but this method
258     * tests both for safety.
259     *
260     * @param expected  the expected title or alternate title.
261     * @param actual    the citation to test.
262     * @param message   header of the exception message in case of failure, or {@code null} if none.
263     */
264    public static void assertAnyTitleEquals(final String expected, final Citation actual, final String message) {
265        if (isNull(expected, actual, message)) {
266            return;
267        }
268        InternationalString title = actual.getTitle();
269        if (title != null && expected.equals(title.toString())) {
270            return;
271        }
272        for (final InternationalString t : actual.getAlternateTitles()) {
273            if (expected.equals(t.toString())) {
274                return;
275            }
276        }
277        fail(concat(message, '"' + expected + "\" not found in title or alternate titles."));
278    }
279
280    /**
281     * Asserts that the given identifier is equal to the given authority, code space, version and code.
282     * If any of the above-cited properties is {@code ""##unrestricted"}, then it will not be verified.
283     * This flexibility is useful in the common case where a test accepts any {@code version} value.
284     *
285     * @param authority  the expected authority title or alternate title (may be {@code null}), or {@code "##unrestricted"}.
286     * @param codeSpace  the expected code space (may be {@code null}), or {@code "##unrestricted"}.
287     * @param version    the expected version    (may be {@code null}), or {@code "##unrestricted"}.
288     * @param code       the expected code value (may be {@code null}), or {@code "##unrestricted"}.
289     * @param actual     the identifier to test.
290     * @param message    header of the exception message in case of failure, or {@code null} if none.
291     */
292    public static void assertIdentifierEquals(final String authority, final String codeSpace, final String version,
293            final String code, final Identifier actual, final String message)
294    {
295        if (actual == null) {
296            fail(concat(message, "Identifier is null"));
297        } else {
298            if (!UNRESTRICTED.equals(authority)) assertAnyTitleEquals(authority, actual.getAuthority(), message);
299            if (!UNRESTRICTED.equals(codeSpace)) assertEquals(codeSpace, actual.getCodeSpace(), () -> concat(message, "Wrong code space"));
300            if (!UNRESTRICTED.equals(version))   assertEquals(version,   actual.getVersion(),   () -> concat(message, "Wrong version"));
301            if (!UNRESTRICTED.equals(code))      assertEquals(code,      actual.getCode(),      () -> concat(message, "Wrong code"));
302        }
303    }
304
305    /**
306     * Asserts that the character sequences are equal, ignoring any characters that are not valid for Unicode identifiers.
307     * First, this method locates the {@linkplain Character#isUnicodeIdentifierStart(int) Unicode identifier start}
308     * in each sequences, ignoring any other characters before them. Then, starting from the identifier starts, this
309     * method compares only the {@linkplain Character#isUnicodeIdentifierPart(int) Unicode identifier parts} until
310     * the end of character sequences.
311     *
312     * <p><b>Examples:</b> {@code "WGS 84"} and {@code "WGS84"} as equal according this method.</p>
313     *
314     * @param expected    the expected character sequence (may be {@code null}), or {@code "##unrestricted"}.
315     * @param actual      the character sequence to compare, or {@code null}.
316     * @param ignoreCase  {@code true} for ignoring case.
317     * @param message     header of the exception message in case of failure, or {@code null} if none.
318     */
319    public static void assertUnicodeIdentifierEquals(final CharSequence expected, final CharSequence actual,
320            final boolean ignoreCase, final String message)
321    {
322        if (UNRESTRICTED.equals(expected) || isNull(expected, actual, message)) {
323            return;
324        }
325        final int expLength = expected.length();
326        final int valLength = actual.length();
327        int       expOffset = 0;
328        int       valOffset = 0;
329        boolean   expPart   = false;
330        boolean   valPart   = false;
331        while (expOffset < expLength) {
332            int expCode = Character.codePointAt(expected, expOffset);
333            if (isUnicodeIdentifier(expCode, expPart)) {
334                expPart = true;
335                int valCode;
336                do {
337                    if (valOffset >= valLength) {
338                        fail(nonNull(message) + "Expected \"" + expected + "\" but got \"" + actual + "\". "
339                                + "Missing part: \"" + expected.subSequence(expOffset, expLength) + "\".");
340                        return;
341                    }
342                    valCode    = Character.codePointAt(actual, valOffset);
343                    valOffset += Character.charCount(valCode);
344                } while (!isUnicodeIdentifier(valCode, valPart));
345                valPart = true;
346                if (ignoreCase) {
347                    expCode = Character.toLowerCase(expCode);
348                    valCode = Character.toLowerCase(valCode);
349                }
350                if (valCode != expCode) {
351                    fail(nonNull(message) + "Expected \"" + expected + "\" but got \"" + actual + "\".");
352                    return;
353                }
354            }
355            expOffset += Character.charCount(expCode);
356        }
357        while (valOffset < valLength) {
358            final int valCode = Character.codePointAt(actual, valOffset);
359            if (isUnicodeIdentifier(valCode, valPart)) {
360                fail(nonNull(message) + "Expected \"" + expected + "\", but found it with a unexpected "
361                        + "trailing string: \"" + actual.subSequence(valOffset, valLength) + "\".");
362            }
363            valOffset += Character.charCount(valCode);
364        }
365    }
366
367    /**
368     * Returns {@code true} if the given codepoint is an unicode identifier start or part.
369     *
370     * @param  codepoint  the code point to test.
371     * @param  part       {@code false} for identifier start, or {@code true} for identifier part.
372     * @return whether the given code point is a Unicode identifier start or part.
373     */
374    private static boolean isUnicodeIdentifier(final int codepoint, final boolean part) {
375        return part ? Character.isUnicodeIdentifierPart (codepoint)
376                    : Character.isUnicodeIdentifierStart(codepoint);
377    }
378
379    /**
380     * Asserts that all axes in the given coordinate system are pointing toward the given directions,
381     * in the same order.
382     *
383     * @param cs        the coordinate system to test.
384     * @param expected  the expected axis directions.
385     */
386    public static void assertAxisDirectionsEqual(final CoordinateSystem cs, final AxisDirection... expected) {
387        assertAxisDirectionsEqual(cs, expected, null);
388    }
389
390    /**
391     * Asserts that all axes in the given coordinate system are pointing toward the given directions,
392     * in the same order.
393     *
394     * @param cs        the coordinate system to test.
395     * @param expected  the expected axis directions.
396     * @param message   header of the exception message in case of failure, or {@code null} if none.
397     */
398    public static void assertAxisDirectionsEqual(final CoordinateSystem cs, final AxisDirection[] expected, final String message) {
399        assertEquals(expected.length, cs.getDimension(), () -> concat(message, "Wrong coordinate system dimension."));
400        for (int i=0; i<expected.length; i++) {
401            final int ci = i;   // Because lambda expressions require final values.
402            assertEquals(expected[i], cs.getAxis(i).getDirection(),
403                    () -> concat(message, "Wrong axis direction at index" + ci + '.'));
404        }
405    }
406
407    /**
408     * Asserts that the given matrix is equal to the expected one, up to the given tolerance value.
409     *
410     * @param expected   the expected matrix, which may be {@code null}.
411     * @param actual     the matrix to compare, or {@code null}.
412     * @param tolerance  the tolerance threshold.
413     * @param message    header of the exception message in case of failure, or {@code null} if none.
414     *
415     * @see org.opengis.test.referencing.TransformTestCase#assertMatrixEquals(Matrix, Matrix, Matrix, String)
416     */
417    public static void assertMatrixEquals(final Matrix expected, final Matrix actual, final double tolerance, final String message) {
418        if (isNull(expected, actual, message)) {
419            return;
420        }
421        final int numRow = actual.getNumRow();
422        final int numCol = actual.getNumCol();
423        assertEquals(expected.getNumRow(), numRow, "numRow");
424        assertEquals(expected.getNumCol(), numCol, "numCol");
425        for (int j=0; j<numRow; j++) {
426            for (int i=0; i<numCol; i++) {
427                final double e = expected.getElement(j,i);
428                final double a = actual.getElement(j,i);
429                if (!(StrictMath.abs(e - a) <= tolerance) && Double.doubleToLongBits(a) != Double.doubleToLongBits(e)) {
430                    fail(nonNull(message) + "Matrix.getElement(" + j + ", " + i + "): expected " + e + " but got " + a);
431                }
432            }
433        }
434    }
435
436    /**
437     * Asserts that all control points of two shapes are equal.
438     * This method performs the following checks:
439     *
440     * <ol>
441     *   <li>Ensures that the {@linkplain Shape#getBounds2D() shape bounds} are equal,
442     *       up to the given tolerance thresholds.</li>
443     *   <li>{@linkplain Shape#getPathIterator(AffineTransform) Gets the path iterator} of each shape.</li>
444     *   <li>Ensures that the {@linkplain PathIterator#getWindingRule() winding rules} are equal.</li>
445     *   <li>Iterates over all path segments until the iteration {@linkplain PathIterator#isDone() is done}.
446     *       For each iteration step:<ol>
447     *       <li>Invokes {@link PathIterator#currentSegment(double[])}.</li>
448     *       <li>Ensures that the segment type (one of the {@code SEG_*} constants) is the same.</li>
449     *       <li>Ensures that the coordinate values are equal, up to the given tolerance thresholds.</li>
450     *   </ol></li>
451     * </ol>
452     *
453     * @param expected    the expected shape, which may be {@code null}.
454     * @param actual      the actual shape, or {@code null}.
455     * @param toleranceX  the tolerance threshold for <var>x</var> coordinate values.
456     * @param toleranceY  the tolerance threshold for <var>y</var> coordinate values.
457     * @param message     header of the exception message in case of failure, or {@code null} if none.
458     */
459    public static void assertShapeEquals(final Shape expected, final Shape actual,
460            final double toleranceX, final double toleranceY, final String message)
461    {
462        if (isNull(expected, actual, message)) {
463            return;
464        }
465        final Rectangle2D b0 = expected.getBounds2D();
466        final Rectangle2D b1 = actual  .getBounds2D();
467        final Supplier<String> mismatch = () -> concat(message, "Mismatched bounds.");
468        assertEquals(b0.getMinX(), b1.getMinX(), toleranceX, mismatch);
469        assertEquals(b0.getMaxX(), b1.getMaxX(), toleranceX, mismatch);
470        assertEquals(b0.getMinY(), b1.getMinY(), toleranceY, mismatch);
471        assertEquals(b0.getMaxY(), b1.getMaxY(), toleranceY, mismatch);
472        assertPathEquals(expected.getPathIterator(null), actual.getPathIterator(null), toleranceX, toleranceY, message);
473    }
474
475    /**
476     * Asserts that all control points in two geometric paths are equal.
477     * This method performs the following checks:
478     *
479     * <ol>
480     *   <li>Ensures that the {@linkplain PathIterator#getWindingRule() winding rules} are equal.</li>
481     *   <li>Iterates over all path segments until the iteration {@linkplain PathIterator#isDone() is done}.
482     *       For each iteration step:<ol>
483     *       <li>Invokes {@link PathIterator#currentSegment(double[])}.</li>
484     *       <li>Ensures that the segment type (one of the {@code SEG_*} constants) is the same.</li>
485     *       <li>Ensures that the coordinate values are equal, up to the given tolerance thresholds.</li>
486     *   </ol></li>
487     * </ol>
488     *
489     * This method can be used instead of {@link #assertShapeEquals(Shape, Shape, double, double, String)}
490     * when the tester needs to compare the shapes with a non-null affine transform or a flatness factor.
491     * in such case, the tester needs to invoke the {@link Shape#getPathIterator(AffineTransform, double)}
492     * method himself.
493     *
494     * @param expected    the expected path, which may be {@code null}.
495     * @param actual      the actual path, or {@code null}.
496     * @param toleranceX  the tolerance threshold for <var>x</var> coordinate values.
497     * @param toleranceY  the tolerance threshold for <var>y</var> coordinate values.
498     * @param message     header of the exception message in case of failure, or {@code null} if none.
499     */
500    public static void assertPathEquals(final PathIterator expected, final PathIterator actual,
501            final double toleranceX, final double toleranceY, final String message)
502    {
503        if (isNull(expected, actual, message)) {
504            return;
505        }
506        assertEquals(expected.getWindingRule(), actual.getWindingRule(),
507                     () -> concat(message, "Mismatched winding rule."));
508        final Supplier<String> mismatchedType = () -> concat(message, "Mismatched path segment type.");
509        final Supplier<String> mismatchedX    = () -> concat(message, "Mismatched X coordinate value.");
510        final Supplier<String> mismatchedY    = () -> concat(message, "Mismatched Y coordinate value.");
511        final Supplier<String> endOfPath      = () -> concat(message, "Premature end of path.");
512        final double[] expectedCoords = new double[6];
513        final double[] actualCoords   = new double[6];
514        while (!expected.isDone()) {
515            assertFalse(actual.isDone(), endOfPath);
516            final int type = expected.currentSegment(expectedCoords);
517            assertEquals(type, actual.currentSegment(actualCoords), mismatchedType);
518            final int length;
519            switch (type) {
520                case PathIterator.SEG_CLOSE:   length = 0; break;
521                case PathIterator.SEG_MOVETO:  // Fallthrough
522                case PathIterator.SEG_LINETO:  length = 2; break;
523                case PathIterator.SEG_QUADTO:  length = 4; break;
524                case PathIterator.SEG_CUBICTO: length = 6; break;
525                default: throw new AssertionError(nonNull(message) + "Unknown segment type: " + type);
526            }
527            for (int i=0; i<length;) {
528                assertEquals(expectedCoords[i], actualCoords[i++], toleranceX, mismatchedX);
529                assertEquals(expectedCoords[i], actualCoords[i++], toleranceY, mismatchedY);
530            }
531            actual.next();
532            expected.next();
533        }
534        assertTrue(actual.isDone(), () -> concat(message, "Expected end of path."));
535    }
536
537    /**
538     * Asserts that all sample values in the given images are equal. This method requires the images
539     * {@linkplain RenderedImage#getWidth() width}, {@linkplain RenderedImage#getHeight() height}
540     * and the {@linkplain java.awt.image.SampleModel#getNumBands() number of bands} to be equal,
541     * but does <em>not</em> require the
542     * {@linkplain RenderedImage#getMinX() minimal <var>x</var>} value,
543     * {@linkplain RenderedImage#getMinY() minimal <var>y</var>} value,
544     * {@linkplain RenderedImage#getTile(int, int) tiling},
545     * {@linkplain java.awt.image.ColorModel color model} or
546     * {@linkplain java.awt.image.SampleModel#getDataType() datatype} to be equal.
547     *
548     * @param expected   an image containing the expected values, which may be {@code null}.
549     * @param actual     the actual image containing the sample values to compare, or {@code null}.
550     * @param tolerance  tolerance threshold for floating point comparisons.
551     *                   This threshold is ignored if both images use integer datatype.
552     * @param message    header of the exception message in case of failure, or {@code null} if none.
553     *
554     * @see PixelIterator#assertSampleValuesEqual(PixelIterator, double)
555     */
556    public static void assertSampleValuesEqual(final RenderedImage expected, final RenderedImage actual,
557            final double tolerance, final String message)
558    {
559        if (isNull(expected, actual, message)) {
560            return;
561        }
562        assertEquals(expected.getWidth(),  actual.getWidth(),  () -> concat(message, "Mismatched image width."));
563        assertEquals(expected.getHeight(), actual.getHeight(), () -> concat(message, "Mismatched image height."));
564        assertEquals(expected.getSampleModel().getNumBands(), actual.getSampleModel().getNumBands(),
565                     () -> concat(message, "Mismatched number of bands."));
566        final var iterator = new PixelIterator(expected);
567        iterator.assertSampleValuesEqual(new PixelIterator(actual), tolerance);
568    }
569}