This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit 0a2fde06526eea639180b60a891ca01c038c4f11 Merge: c13530cdad 65cb2c7145 Author: Martin Desruisseaux <[email protected]> AuthorDate: Sun Jun 21 19:00:54 2026 +0200 Merge branch 'fix/geotiff-flaky-test' into geoapi-4.0 https://github.com/apache/sis/pull/44 Changes compared to the original pull request: * `Number` comparison initially added into `Utilities.deepEquals(…)` moved to `Range.equals(…)`. * Rely on `Utilities.deepEquals(…)` for comparing the content of `CategoryList`. * Allow (0 … 256) range to be considered as equivalent to [1 … 255]. * `GeoTiffStoreTest` writes an uncompressed image instead of using the deflate algorithm. * Javadoc. .../main/org/apache/sis/coverage/Category.java | 87 +++++++++++--- .../main/org/apache/sis/coverage/CategoryList.java | 2 +- .../org/apache/sis/coverage/SampleDimension.java | 55 +++++++-- .../main/org/apache/sis/coverage/package-info.java | 3 +- .../org/apache/sis/coverage/CategoryListTest.java | 117 ++++++++++++++++++ .../test/org/apache/sis/coverage/CategoryTest.java | 107 +++++++++++++++++ .../apache/sis/coverage/SampleDimensionTest.java | 132 +++++++++++++++++++++ .../org/apache/sis/image/ImageProcessorTest.java | 45 +++++++ .../storage/geotiff/writer/ReformattedImage.java | 4 +- .../sis/storage/geotiff/GeoTiffStoreTest.java | 128 +++++++++++++++++--- .../org/apache/sis/storage/geotiff/ReaderTest.java | 49 ++++++-- .../storage/geotiff/tiled_without_compression.tiff | Bin 0 -> 2324 bytes .../geotiff/untiled_without_compression.tiff | Bin 0 -> 1240 bytes .../main/org/apache/sis/math/NumberType.java | 47 +++++--- .../org/apache/sis/measure/MeasurementRange.java | 26 +++- .../main/org/apache/sis/measure/Range.java | 123 +++++++++++++++++-- .../main/org/apache/sis/util/ComparisonMode.java | 21 ++-- .../main/org/apache/sis/util/Utilities.java | 15 ++- .../apache/sis/util/internal/shared/Numerics.java | 11 +- .../apache/sis/measure/MeasurementRangeTest.java | 1 + .../test/org/apache/sis/measure/RangeTest.java | 24 ++++ gradle/wrapper/gradle-wrapper.properties | 2 +- 22 files changed, 894 insertions(+), 105 deletions(-) diff --cc endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/Category.java index f88ed0593d,04706fc062..1757c17de8 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/Category.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/Category.java @@@ -33,6 -36,6 +33,9 @@@ import org.apache.sis.referencing.opera import org.apache.sis.feature.internal.Resources; import org.apache.sis.util.ArgumentChecks; import org.apache.sis.util.ArraysExt; ++import org.apache.sis.util.ComparisonMode; ++import org.apache.sis.util.LenientComparable; ++import org.apache.sis.util.Utilities; import org.apache.sis.util.iso.Types; @@@ -68,10 -71,10 +71,11 @@@ * <p>All {@code Category} objects are immutable and thread-safe.</p> * * @author Martin Desruisseaux (IRD, Geomatys) -- * @version 1.1 ++ * @author Alexis Manin (Geomatys) ++ * @version 1.7 * @since 1.0 */ - public class Category implements Serializable { + public class Category implements LenientComparable, Serializable { /** * Serial number for inter-operability with different versions. */ @@@ -493,12 -496,12 +497,13 @@@ } /** -- * Returns a hash value for this category. This value needs not remain consistent between -- * different implementations of the same class. ++ * Returns a hash value for this category. ++ * ++ * @return a hash value for this category. */ @Override public int hashCode() { -- return name.hashCode(); ++ return name.hashCode() + range.hashCode(); } /** @@@ -507,31 -510,43 +512,75 @@@ * @param object the object to compare with. * @return {@code true} if the given object is equal to this category. */ + @Override - public boolean equals(final Object object) { + public final boolean equals(final Object object) { + return equals(object, ComparisonMode.STRICT); + } + + /** + * Compares this category with the given object for equality at the given level of strictness. + * + * <ul> + * <li>{@link ComparisonMode#STRICT}: same implementation class, same name, exact range and transfer function.</li> + * <li>{@link ComparisonMode#BY_CONTRACT}: same name, exact range and transfer function; class may differ.</li> + * <li>{@link ComparisonMode#IGNORE_METADATA} and more lenient: range and transfer function only (name is ignored).</li> + * <li>{@link ComparisonMode#APPROXIMATE}: range and transfer function approximately equal.</li> + * </ul> + * + * @param object the object to compare with. + * @param mode the comparison strictness level. + * @return {@code true} if both objects are equal according the given comparison mode. ++ * ++ * @since 1.7 + */ + @Override + public boolean equals(final Object object, final ComparisonMode mode) { - if (object == this) return true; - if (!(object instanceof Category)) return false; - final Category that = (Category) object; - - switch (mode) { - case STRICT: { - if (!getClass().equals(that.getClass())) return false; - } - case BY_CONTRACT: { - if (!name.equals(that.name)) return false; + if (object == this) { + // Slight optimization + return true; + } - if (object != null && getClass().equals(object.getClass())) { ++ if (object instanceof Category) { + final Category that = (Category) object; - if (name.equals(that.name)) { - final NumberRange<?> other = that.range; - /* - * The NumberRange.equals(Object) comparison is not sufficient because it considers all NaN values as equal. - * For the purpose of Category, we need to distinguish the different NaN values. - */ - if (range == other || (range.equals(other) - && doubleToRawLongBits(range.getMinDouble()) == doubleToRawLongBits(other.getMinDouble()) - && doubleToRawLongBits(range.getMaxDouble()) == doubleToRawLongBits(other.getMaxDouble()))) - { - return toConverse.equals(that.toConverse); - } ++ if (mode == ComparisonMode.STRICT) { ++ return object.getClass() == getClass() ++ && name.equals(that.name) ++ && equals(range, that.range, mode) ++ && toConverse.equals(that.toConverse); + } - default: - return Utilities.deepEquals(range, that.range, mode) ++ if (mode.isIgnoringMetadata() || getName().equals(that.getName())) { ++ return equals(getSampleRange(), that.getSampleRange(), mode) + && Utilities.deepEquals(toConverse, that.toConverse, mode); + } } + return false; + } + ++ /** ++ * Compares the given ranges with a distinction between the different NaN values. ++ * The {@link NumberRange#equals(Object, ComparisonMode)} method is not sufficient ++ * because it considers all NaN values as equal. For the purpose of {@link Category}, ++ * we need to distinguish the different NaN values. ++ */ ++ private static boolean equals(final NumberRange<?> range, final NumberRange<?> other, final ComparisonMode mode) { ++ if (range == other) { ++ return true; ++ } ++ return range.equals(other, mode) ++ && sameNaN(range.getMinDouble(), other.getMinDouble()) ++ && sameNaN(range.getMaxDouble(), other.getMaxDouble()); ++ } ++ ++ /** ++ * If the given values are NaN, returns whether they have the same bit patterns. ++ * If the given values are non NaN, returns {@code true} if none of them are NaN. ++ * This method does not verify whether non-NaN values are equal because it depends ++ * on {@link ComparisonMode}. ++ */ ++ private static boolean sameNaN(final double value, final double other) { ++ return doubleToRawLongBits(value) == doubleToRawLongBits(other) ++ || !(Double.isNaN(value) || Double.isNaN(other)); + } + /** * Returns a string representation of this category for debugging purpose. * This string representation may change in any future SIS version. diff --cc endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/CategoryList.java index bba233abf1,75cda8ad26..2156b94847 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/CategoryList.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/CategoryList.java @@@ -408,7 -410,7 +408,7 @@@ final class CategoryList extends Abstra */ final MathTransform1D getTransferFunction() { MathTransform1D tr = categories[0].toConverse; // See condition in javadoc. -- for (int i=categories.length; --i >= 1;) { ++ for (int i = categories.length; --i >= 1;) { if (!tr.equals(categories[i].toConverse)) { tr = this; break; diff --cc endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/SampleDimension.java index 4826bb42de,90c462ca0d..88a1575010 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/SampleDimension.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/SampleDimension.java @@@ -38,8 -41,8 +38,11 @@@ import org.apache.sis.measure.NumberRan import org.apache.sis.math.MathFunctions; import org.apache.sis.math.NumberType; import org.apache.sis.util.ArgumentChecks; ++import org.apache.sis.util.ComparisonMode; ++import org.apache.sis.util.LenientComparable; import org.apache.sis.util.Numbers; import org.apache.sis.util.Debug; ++import org.apache.sis.util.Utilities; import org.apache.sis.util.collection.Containers; import org.apache.sis.util.resources.Vocabulary; import org.apache.sis.util.resources.Errors; @@@ -278,8 -281,8 +281,8 @@@ public class SampleDimension implement public Set<Number> getNoDataValues() { if (converse != null) { // Null if SampleDimension does not contain at least one quantitative category. final boolean isConverted = transferFunction.isIdentity(); -- final NumberRange<?>[] ranges = new NumberRange<?>[categories.size()]; -- final Class<?>[] rangeTypes = new Class<?>[ranges.length]; ++ final var ranges = new NumberRange<?>[categories.size()]; ++ final var rangeTypes = new Class<?>[ranges.length]; int count = 0; for (final Category category : categories) { final Category converted = category.converted(); @@@ -294,7 -297,7 +297,7 @@@ } if (count != 0) { final NumberType widestType = NumberType.forClasses(rangeTypes).filter(NumberType::isConvertible).orElse(null); -- final Set<Number> noDataValues = new TreeSet<>(SampleDimension::compare); ++ final var noDataValues = new TreeSet<Number>(SampleDimension::compare); for (int i=0; i<count; i++) { final NumberRange<?> range = ranges[i]; final Number minimum = range.getMinValue(); @@@ -304,8 -307,8 +307,9 @@@ if (range.isMaxIncluded()) noDataValues.add(widestType.cast(maximum)); } if (NumberType.isInteger(range.getElementType())) { -- long value = minimum.longValue() + 1; // If value was inclusive, then it has already been added to the set. -- long stop = maximum.longValue() - 1; ++ // If value was inclusive, then it has already been added to the set. ++ long value = Math.incrementExact(minimum.longValue()); ++ long stop = Math.decrementExact(maximum.longValue()); while (value <= stop) { noDataValues.add(widestType.wrapExact(value)); } @@@ -474,6 -477,6 +478,8 @@@ /** * Returns a hash value for this sample dimension. ++ * ++ * @return a hash value for this sample dimension. */ @Override public int hashCode() { @@@ -487,15 -490,29 +493,46 @@@ * @return {@code true} if the given object is equal to this sample dimension. */ @Override - public boolean equals(final Object object) { + public final boolean equals(final Object object) { + return equals(object, ComparisonMode.STRICT); + } + ++ /** ++ * Compares this sample dimension with the given object for equality at the given level of strictness. ++ * The {@linkplain #getName() name} is considered as metadata. ++ * ++ * @param object the object to compare with. ++ * @param mode the comparison strictness level. ++ * @return {@code true} if both objects are equal according the given strictness level. ++ * ++ * @see Category#equals(Object, ComparisonMode) ++ * @since 1.7 ++ */ + @Override - public boolean equals(Object other, ComparisonMode mode) { - if (other == this) return true; - if (!(other instanceof SampleDimension)) return false; - - switch (mode) { - case STRICT: { - if (other.getClass() == getClass()) { - final SampleDimension that = (SampleDimension) other; - return name.equals(that.name) && Objects.equals(background, that.background) && categories.equals(that.categories); - } ++ public boolean equals(final Object object, final ComparisonMode mode) { + if (object == this) { + return true; + } + if (object instanceof SampleDimension) { + final SampleDimension that = (SampleDimension) object; - return name.equals(that.name) && Objects.equals(background, that.background) && categories.equals(that.categories); ++ /* ++ * `transferFunction` does not need to be compared because it is derived from the categories. ++ * It is also difficult to keep `transferFunction` comparison consistent with `categories` ++ * comparison if mode is `APPROXIMATE`, because tolerance threshold may not be the same or ++ * may not be applied on the same coefficients. ++ */ ++ if (mode == ComparisonMode.STRICT) { ++ return object.getClass() == getClass() ++ && name.equals(that.name) ++ && categories.equals(that.categories) ++ && Objects.equals(background, that.background); + } - default: { - final var otherDim = (SampleDimension) other; - return Utilities.deepEquals(this.transferFunction, otherDim.transferFunction, mode) - && Utilities.deepEquals(this.categories, otherDim.categories, mode) - && Utilities.deepEquals(this.background, otherDim.background, mode); ++ if (mode.isIgnoringMetadata() || getName().equals(that.getName())) { ++ return Utilities.deepEquals(getCategories(), that.getCategories(), mode) && ++ Utilities.deepEquals(getBackground(), that.getBackground(), mode); + } } + return false; } /** diff --cc endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/package-info.java index cf2ce3869a,cf2ce3869a..fee606b1b8 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/package-info.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/package-info.java @@@ -23,7 -23,7 +23,8 @@@ * {@link org.apache.sis.coverage.grid}. * * @author Martin Desruisseaux (Geomatys) -- * @version 1.5 ++ * @author Alexis Manin (Geomatys) ++ * @version 1.7 * @since 1.0 */ package org.apache.sis.coverage; diff --cc endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/CategoryListTest.java index ea32bb2589,4e2ecfeb64..70c9b865bb --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/CategoryListTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/CategoryListTest.java @@@ -24,6 -24,7 +24,8 @@@ import org.opengis.referencing.operatio import org.apache.sis.referencing.operation.transform.MathTransforms; import org.apache.sis.math.MathFunctions; import org.apache.sis.measure.NumberRange; + import org.apache.sis.util.ComparisonMode; ++import org.apache.sis.util.Utilities; // Test dependencies import org.junit.jupiter.api.Test; @@@ -31,13 -32,13 +33,16 @@@ import static org.junit.jupiter.api.Ass import static org.apache.sis.test.Assertions.assertMessageContains; import org.apache.sis.test.TestUtilities; import org.apache.sis.test.TestCase; ++import org.apache.sis.util.internal.shared.Numerics; /** * Tests {@link CategoryList}. * * @author Martin Desruisseaux (IRD, Geomatys) ++ * @author Alexis Manin (Geomatys) */ ++@SuppressWarnings("exports") public final class CategoryListTest extends TestCase { /** * Creates a new test case. @@@ -414,4 -415,116 +419,116 @@@ assertTrue(category.toConverse.isIdentity()); } } + + /** + * Tests {@link CategoryList#equals(Object, ComparisonMode)} for all comparison modes. + * Covers null/incompatible-type rejection, empty list, element-wise comparison, + * size mismatch, NaN-ordinal differences, name-only differences, and approximate + * transfer-function tolerance. + */ + @Test + public void testLenientEquality() { + final CategoryList list1 = CategoryList.create(categories(), null); + + // ------------------------------------------------------------------ + // Null and incompatible type. + // ------------------------------------------------------------------ - assertFalse(list1.equals(null, ComparisonMode.STRICT), "null/STRICT"); - assertFalse(list1.equals("not a list", ComparisonMode.APPROXIMATE), "String/APPROXIMATE"); ++ assertFalse(Utilities.deepEquals(list1, null, ComparisonMode.STRICT), "null/STRICT"); ++ assertFalse(Utilities.deepEquals(list1, "not a list", ComparisonMode.APPROXIMATE), "String/APPROXIMATE"); + + // ------------------------------------------------------------------ + // Empty list: same reference → true. + // ------------------------------------------------------------------ - assertTrue(CategoryList.EMPTY.equals(CategoryList.EMPTY, ComparisonMode.STRICT), "empty/STRICT"); ++ assertTrue(Utilities.deepEquals(CategoryList.EMPTY, CategoryList.EMPTY, ComparisonMode.STRICT), "empty/STRICT"); + + // ------------------------------------------------------------------ + // Two lists built from independent category arrays with the same + // configuration. All modes must return true. + // ------------------------------------------------------------------ + final CategoryList list2 = CategoryList.create(categories(), null); - assertTrue(list1.equals(list2, ComparisonMode.STRICT), "same-cfg/STRICT"); - assertTrue(list1.equals(list2, ComparisonMode.BY_CONTRACT), "same-cfg/BY_CONTRACT"); - assertTrue(list1.equals(list2, ComparisonMode.IGNORE_METADATA), "same-cfg/IGNORE_METADATA"); - assertTrue(list1.equals(list2, ComparisonMode.APPROXIMATE), "same-cfg/APPROXIMATE"); ++ assertTrue(Utilities.deepEquals(list1, list2, ComparisonMode.STRICT), "same-cfg/STRICT"); ++ assertTrue(Utilities.deepEquals(list1, list2, ComparisonMode.BY_CONTRACT), "same-cfg/BY_CONTRACT"); ++ assertTrue(Utilities.deepEquals(list1, list2, ComparisonMode.IGNORE_METADATA), "same-cfg/IGNORE_METADATA"); ++ assertTrue(Utilities.deepEquals(list1, list2, ComparisonMode.APPROXIMATE), "same-cfg/APPROXIMATE"); + + // ------------------------------------------------------------------ + // Different size: list of 5 categories vs 3 categories. + // ------------------------------------------------------------------ + final ToNaN toNaN = new ToNaN(); + final Category[] fewer = { + new Category("No data", NumberRange.create( 0, true, 0, true), null, null, toNaN), + new Category("Land", NumberRange.create( 7, true, 7, true), null, null, toNaN), + new Category("Temperature", NumberRange.create( 10, true, 100, false), + (MathTransform1D) MathTransforms.linear(0.1, 5), null, toNaN) + }; + final CategoryList shortList = CategoryList.create(fewer, null); - assertFalse(list1.equals(shortList, ComparisonMode.STRICT), "diff-size/STRICT"); - assertFalse(list1.equals(shortList, ComparisonMode.APPROXIMATE), "diff-size/APPROXIMATE"); ++ assertFalse(Utilities.deepEquals(list1, shortList, ComparisonMode.STRICT), "diff-size/STRICT"); ++ assertFalse(Utilities.deepEquals(list1, shortList, ComparisonMode.APPROXIMATE), "diff-size/APPROXIMATE"); + + // ------------------------------------------------------------------ + // Different NaN ordinal in a qualitative category. + // Replace sample value 0 with a category that forces ordinal 99. + // ------------------------------------------------------------------ + final Category[] diffNaN = { + new Category("No data", NumberRange.create( 0, true, 0, true), null, null, (v) -> 99), + new Category("Land", NumberRange.create( 7, true, 7, true), null, null, toNaN), + new Category("Clouds", NumberRange.create( 3, true, 3, true), null, null, toNaN), + new Category("Temperature", NumberRange.create( 10, true, 100, false), + (MathTransform1D) MathTransforms.linear(0.1, 5), null, toNaN), + new Category("Foo", NumberRange.create(100, true, 120, false), + (MathTransform1D) MathTransforms.linear(-1, 3), null, toNaN) + }; + final CategoryList listDiffNaN = CategoryList.create(diffNaN, null); - assertFalse(list1.equals(listDiffNaN, ComparisonMode.STRICT), "diff-NaN/STRICT"); - assertTrue(list1.equals(listDiffNaN, ComparisonMode.APPROXIMATE), "diff-NaN/APPROXIMATE"); ++ assertFalse(Utilities.deepEquals(list1, listDiffNaN, ComparisonMode.STRICT), "diff-NaN/STRICT"); ++ assertTrue (Utilities.deepEquals(list1, listDiffNaN, ComparisonMode.APPROXIMATE), "diff-NaN/APPROXIMATE"); + + // ------------------------------------------------------------------ + // Different name only in the qualitative "No data" category. + // ------------------------------------------------------------------ + final Category[] diffName = { + new Category("Renamed", NumberRange.create( 0, true, 0, true), null, null, toNaN), + new Category("Land", NumberRange.create( 7, true, 7, true), null, null, toNaN), + new Category("Clouds", NumberRange.create( 3, true, 3, true), null, null, toNaN), + new Category("Temperature", NumberRange.create( 10, true, 100, false), + (MathTransform1D) MathTransforms.linear(0.1, 5), null, toNaN), + new Category("Foo", NumberRange.create(100, true, 120, false), + (MathTransform1D) MathTransforms.linear(-1, 3), null, toNaN) + }; + final CategoryList listDiffName = CategoryList.create(diffName, null); - assertFalse(list1.equals(listDiffName, ComparisonMode.STRICT), "diff-name/STRICT"); - assertTrue (list1.equals(listDiffName, ComparisonMode.IGNORE_METADATA), "diff-name/IGNORE_METADATA"); ++ assertFalse(Utilities.deepEquals(list1, listDiffName, ComparisonMode.STRICT), "diff-name/STRICT"); ++ assertTrue (Utilities.deepEquals(list1, listDiffName, ComparisonMode.IGNORE_METADATA), "diff-name/IGNORE_METADATA"); + + // ------------------------------------------------------------------ + // Significantly different transfer function (scale 0.1 → 0.2). + // ------------------------------------------------------------------ + final Category[] bigDiff = { + new Category("No data", NumberRange.create( 0, true, 0, true), null, null, toNaN), + new Category("Land", NumberRange.create( 7, true, 7, true), null, null, toNaN), + new Category("Clouds", NumberRange.create( 3, true, 3, true), null, null, toNaN), + new Category("Temperature", NumberRange.create( 10, true, 100, false), + (MathTransform1D) MathTransforms.linear(0.2, 5), null, toNaN), + new Category("Foo", NumberRange.create(100, true, 120, false), + (MathTransform1D) MathTransforms.linear(-1, 3), null, toNaN) + }; + final CategoryList listBigDiff = CategoryList.create(bigDiff, null); - assertFalse(list1.equals(listBigDiff, ComparisonMode.STRICT), "big-diff/STRICT"); - assertFalse(list1.equals(listBigDiff, ComparisonMode.APPROXIMATE), "big-diff/APPROXIMATE"); ++ assertFalse(Utilities.deepEquals(list1, listBigDiff, ComparisonMode.STRICT), "big-diff/STRICT"); ++ assertFalse(Utilities.deepEquals(list1, listBigDiff, ComparisonMode.APPROXIMATE), "big-diff/APPROXIMATE"); + + // ------------------------------------------------------------------ + // Tiny transfer function difference: offset 5.0 vs 5.0 - 1e-13. + // Relative threshold for offset ≈ 5 is 5E-13 > 1E-13, so within tolerance. + // ------------------------------------------------------------------ + final Category[] tinyDiff = { + new Category("No data", NumberRange.create( 0, true, 0, true), null, null, toNaN), + new Category("Land", NumberRange.create( 7, true, 7, true), null, null, toNaN), + new Category("Clouds", NumberRange.create( 3, true, 3, true), null, null, toNaN), + new Category("Temperature", NumberRange.create( 10, true, 100, false), - (MathTransform1D) MathTransforms.linear(0.1, 5.0 - 1e-13), null, toNaN), ++ (MathTransform1D) MathTransforms.linear(0.1, 5.0 - Numerics.COMPARISON_THRESHOLD), null, toNaN), + new Category("Foo", NumberRange.create(100, true, 120, false), + (MathTransform1D) MathTransforms.linear(-1, 3), null, toNaN) + }; + final CategoryList listTinyDiff = CategoryList.create(tinyDiff, null); - assertFalse(list1.equals(listTinyDiff, ComparisonMode.STRICT), "tiny-diff/STRICT"); - assertTrue (list1.equals(listTinyDiff, ComparisonMode.APPROXIMATE), "tiny-diff/APPROXIMATE"); ++ assertFalse(Utilities.deepEquals(list1, listTinyDiff, ComparisonMode.STRICT), "tiny-diff/STRICT"); ++ assertTrue (Utilities.deepEquals(list1, listTinyDiff, ComparisonMode.APPROXIMATE), "tiny-diff/APPROXIMATE"); + } } diff --cc endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/CategoryTest.java index 51f7bf25e8,4313bc79f3..6556d6e9e7 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/CategoryTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/CategoryTest.java @@@ -34,7 -37,7 +37,9 @@@ import org.apache.sis.test.TestCase * Tests {@link Category}. * * @author Martin Desruisseaux (IRD, Geomatys) ++ * @author Alexis Manin (Geomatys) */ ++@SuppressWarnings("exports") public final class CategoryTest extends TestCase { /** * Small tolerance value for comparisons. @@@ -240,4 -243,106 +245,106 @@@ assertTrue (category.toConverse.isIdentity()); assertFalse (category.isQuantitative()); } + + /** + * Tests {@link Category#equals(Object, ComparisonMode)} for all comparison modes. + * Covers qualitative and quantitative categories, name differences, NaN ordinal + * differences, approximate transform comparison, and category-vs-converse inequality. + */ + @Test + public void testLenientEquality() { + final Category qualA = new Category("Water", NumberRange.create(1, true, 1, true), null, null, new ToNaN()); + final Category qualB = new Category("Water", NumberRange.create(1, true, 1, true), null, null, new ToNaN()); + + // Null / incompatible type — all modes must return false. - assertFalse(qualA.equals(null, ComparisonMode.STRICT), "null/STRICT"); ++ assertFalse(qualA.equals(null, ComparisonMode.STRICT), "null/STRICT"); + assertFalse(qualA.equals("not a category", ComparisonMode.APPROXIMATE), "String/APPROXIMATE"); + + // Self-equality — all modes must return true. - assertTrue(qualA.equals(qualA, ComparisonMode.STRICT), "self/STRICT"); - assertTrue(qualA.equals(qualA, ComparisonMode.BY_CONTRACT), "self/BY_CONTRACT"); ++ assertTrue(qualA.equals(qualA, ComparisonMode.STRICT), "self/STRICT"); ++ assertTrue(qualA.equals(qualA, ComparisonMode.BY_CONTRACT), "self/BY_CONTRACT"); + assertTrue(qualA.equals(qualA, ComparisonMode.IGNORE_METADATA), "self/IGNORE_METADATA"); - assertTrue(qualA.equals(qualA, ComparisonMode.APPROXIMATE), "self/APPROXIMATE"); ++ assertTrue(qualA.equals(qualA, ComparisonMode.APPROXIMATE), "self/APPROXIMATE"); + + // Qualitative: same configuration (fresh ToNaN, same sample → same NaN ordinal). + assertTrue(qualA.equals(qualB, ComparisonMode.STRICT), "qual-same/STRICT"); + assertTrue(qualA.equals(qualB, ComparisonMode.BY_CONTRACT), "qual-same/BY_CONTRACT"); + assertTrue(qualA.equals(qualB, ComparisonMode.IGNORE_METADATA), "qual-same/IGNORE_METADATA"); + assertTrue(qualA.equals(qualB, ComparisonMode.APPROXIMATE), "qual-same/APPROXIMATE"); + + // Qualitative: same sample range but different NaN ordinal. + // Force ordinal 99 for qualC; standard ToNaN for sample value 1 yields ordinal 1. + final Category qualC = new Category("Water", NumberRange.create(1, true, 1, true), null, null, (v) -> 99); + assertFalse(qualA.equals(qualC, ComparisonMode.STRICT), "qual-diffNaN/STRICT"); - assertTrue(qualA.equals(qualC, ComparisonMode.APPROXIMATE), "qual-diffNaN/APPROXIMATE"); ++ assertTrue (qualA.equals(qualC, ComparisonMode.APPROXIMATE), "qual-diffNaN/APPROXIMATE"); + + // Qualitative: same NaN ordinal but different name. + final Category qualD = new Category("Land", NumberRange.create(1, true, 1, true), null, null, new ToNaN()); + assertFalse(qualA.equals(qualD, ComparisonMode.STRICT), "qual-diffName/STRICT"); + assertFalse(qualA.equals(qualD, ComparisonMode.BY_CONTRACT), "qual-diffName/BY_CONTRACT"); + assertTrue (qualA.equals(qualD, ComparisonMode.IGNORE_METADATA), "qual-diffName/IGNORE_METADATA"); + assertTrue (qualA.equals(qualD, ComparisonMode.APPROXIMATE), "qual-diffName/APPROXIMATE"); + + // Category vs its converse (ConvertedCategory): must be false in all modes. + final Category qualConverse = qualA.converse; + assertNotSame(qualA, qualConverse); + assertFalse(qualA.equals(qualConverse, ComparisonMode.STRICT), "vs-converse/STRICT"); + assertFalse(qualA.equals(qualConverse, ComparisonMode.BY_CONTRACT), "vs-converse/BY_CONTRACT"); + assertFalse(qualA.equals(qualConverse, ComparisonMode.IGNORE_METADATA), "vs-converse/IGNORE_METADATA"); + assertFalse(qualA.equals(qualConverse, ComparisonMode.APPROXIMATE), "vs-converse/APPROXIMATE"); + + // ------------------------------------------------------------------ + // Quantitative categories with linear transfer function. + // ------------------------------------------------------------------ + final MathTransform1D tf1 = (MathTransform1D) MathTransforms.linear(0.1, 5.0); + final Category quantA = new Category("Temperature", NumberRange.create(10, true, 100, false), tf1, null, null); + final Category quantB = new Category("Temperature", NumberRange.create(10, true, 100, false), tf1, null, null); + + // Same configuration — all modes must return true. + assertTrue(quantA.equals(quantB, ComparisonMode.STRICT), "quant-same/STRICT"); + assertTrue(quantA.equals(quantB, ComparisonMode.BY_CONTRACT), "quant-same/BY_CONTRACT"); + assertTrue(quantA.equals(quantB, ComparisonMode.IGNORE_METADATA), "quant-same/IGNORE_METADATA"); + assertTrue(quantA.equals(quantB, ComparisonMode.APPROXIMATE), "quant-same/APPROXIMATE"); + + // Different name only. + final Category quantC = new Category("Renamed", NumberRange.create(10, true, 100, false), tf1, null, null); + assertFalse(quantA.equals(quantC, ComparisonMode.STRICT), "quant-diffName/STRICT"); + assertFalse(quantA.equals(quantC, ComparisonMode.BY_CONTRACT), "quant-diffName/BY_CONTRACT"); + assertTrue (quantA.equals(quantC, ComparisonMode.IGNORE_METADATA), "quant-diffName/IGNORE_METADATA"); + assertTrue (quantA.equals(quantC, ComparisonMode.APPROXIMATE), "quant-diffName/APPROXIMATE"); + + // Significantly different transfer function: scale 0.1 vs 0.2. + final MathTransform1D tf2 = (MathTransform1D) MathTransforms.linear(0.2, 5.0); + final Category quantD = new Category("Temperature", NumberRange.create(10, true, 100, false), tf2, null, null); + assertFalse(quantA.equals(quantD, ComparisonMode.STRICT), "quant-bigDiff/STRICT"); + assertFalse(quantA.equals(quantD, ComparisonMode.APPROXIMATE), "quant-bigDiff/APPROXIMATE"); + + // Tiny transfer function difference: offset 5.0 vs 5.0 - 1e-13. + // Relative threshold for offset ≈ 5.0 is 5.0 * 1E-13 = 5E-13 > 1E-13, so within tolerance. + final MathTransform1D tf3 = (MathTransform1D) MathTransforms.linear(0.1, 5.0 - 1e-13); + final Category quantE = new Category("Temperature", NumberRange.create(10, true, 100, false), tf3, null, null); + assertFalse(quantA.equals(quantE, ComparisonMode.STRICT), "quant-tinyDiff/STRICT"); + assertTrue (quantA.equals(quantE, ComparisonMode.APPROXIMATE), "quant-tinyDiff/APPROXIMATE"); + + // ------------------------------------------------------------------ + // Category with MeasurementRange (identity transform, unit-bearing range). + // ------------------------------------------------------------------ + final MathTransform1D identity = (MathTransform1D) MathTransforms.identity(1); + final Category measA = new Category("Celsius", + MeasurementRange.create(10f, true, 30f, true, Units.CELSIUS), + identity, Units.CELSIUS, null); + final Category measB = new Category("Celsius", + MeasurementRange.create(10f, true, 30f, true, Units.CELSIUS), + identity, Units.CELSIUS, null); + + // Same unit — equal in STRICT. + assertTrue(measA.equals(measB, ComparisonMode.STRICT), "meas-sameUnit/STRICT"); + + // Different units: CELSIUS vs KELVIN — ranges differ, must be false. + final Category measK = new Category("Kelvin", + MeasurementRange.create(10f, true, 30f, true, Units.KELVIN), + identity, Units.KELVIN, null); + assertFalse(measA.equals(measK, ComparisonMode.STRICT), "meas-diffUnit/STRICT"); + assertFalse(measA.equals(measK, ComparisonMode.APPROXIMATE), "meas-diffUnit/APPROXIMATE"); + } } diff --cc endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/SampleDimensionTest.java index a2d38f0628,5587a1cae5..1ee960f556 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/SampleDimensionTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/SampleDimensionTest.java @@@ -25,6 -25,7 +25,9 @@@ import org.apache.sis.referencing.opera import org.apache.sis.math.MathFunctions; import org.apache.sis.measure.NumberRange; import org.apache.sis.measure.Units; + import org.apache.sis.util.ComparisonMode; ++import org.apache.sis.util.LenientComparable; // For javadoc ++import org.apache.sis.util.internal.shared.Numerics; // Test dependencies import org.junit.jupiter.api.Test; @@@ -36,7 -37,7 +39,9 @@@ import org.apache.sis.test.TestCase * Tests {@link SampleDimension}. * * @author Martin Desruisseaux (IRD, Geomatys) ++ * @author Alexis Manin (Geomatys) */ ++@SuppressWarnings("exports") public final class SampleDimensionTest extends TestCase { /** * Creates a new test case. @@@ -223,4 -224,125 +228,131 @@@ assertEquals("Clouds", categories.remove(1).getName().toString()); assertEquals(2, categories.size()); } + ++ /** ++ * Tests {@link SampleDimension#equals(Object, ComparisonMode)} with null or incompatible instances. ++ */ + @Test ++ @SuppressWarnings({"ObjectEqualsNull", "IncompatibleEquals"}) + public void testNullAndIncompatibleTypesAreNeverEqual() { + final var base = new SampleDimension.Builder().setName("base").build(); + assertFalse(base.equals(null, ComparisonMode.STRICT)); + assertFalse(base.equals("other", ComparisonMode.STRICT)); + assertFalse(base.equals(null)); + assertFalse(base.equals("other")); + assertFalse(base.equals(null, ComparisonMode.APPROXIMATE)); + assertFalse(base.equals("other", ComparisonMode.APPROXIMATE)); + } + + /** - * Verify strict equality behavior, and ensure standard {@link Object#equals(Object)} is consistent with strict equality. ++ * Tests {@link SampleDimension#equals(Object, ComparisonMode)} with strict equality behavior. ++ * Also ensures that the standard {@link Object#equals(Object)} is consistent with strict equality. + * - * @see org.apache.sis.util.LenientComparable#equals(Object, ComparisonMode) ++ * @see LenientComparable#equals(Object, ComparisonMode) + * @see ComparisonMode#STRICT + */ + @Test + public void testStrictEquality() { + final var base = new SampleDimension.Builder() + .addQualitative("Clouds", 1) + .addQualitative("Lands", 2) + .setName("base") + .build(); + final var strictlyEqToBase = new SampleDimension.Builder() + .addQualitative("Clouds", 1) + .addQualitative("Lands", 2) + .setName("base") + .build(); + + // Same categories and background as dim1, but different dimension name. + final var renamed = new SampleDimension.Builder() + .addQualitative("Clouds", 1) + .addQualitative("Lands", 2) + .setName("Different name") + .build(); + + // Ensure a sample dimension is strictly equal to itself + assertTrue (base.equals(base, ComparisonMode.STRICT), "A sample dimension must be strictly equal to itself"); + assertTrue (base.equals(base), "A sample dimension must be equal to itself"); + + // Ensure strict comparison work as expected + assertTrue (base.equals(strictlyEqToBase, ComparisonMode.STRICT), "identical dimensions must be equal"); + assertTrue (strictlyEqToBase.equals(base, ComparisonMode.STRICT), "equality must be symmetric"); + assertEquals(base.hashCode(), strictlyEqToBase.hashCode(), "hashCode must be consistent with equals"); + + // Dimensions that differ only in name must NOT be equal under STRICT. + assertFalse(base.equals(renamed, ComparisonMode.STRICT), "different names must produce inequality"); + assertFalse(renamed.equals(base, ComparisonMode.STRICT), "inequality must be symmetric"); + } + + /** - * Verifies the {@link org.apache.sis.util.LenientComparable#equals(Object, ComparisonMode)} contract - * for {@link ComparisonMode#APPROXIMATE}. ++ * Tests {@link SampleDimension#equals(Object, ComparisonMode)} with approximate comparison mode. ++ * The intend is to verify the compliance of {@link ComparisonMode#APPROXIMATE} with the contract ++ * documented in {@link LenientComparable#equals(Object, ComparisonMode)}. + * In approximate mode the dimension name is not compared, so two dimensions that differ + * only by name must be considered equal, while a different transfer function must not. + */ + @Test - public void testEqualsApproximate() { ++ public void testApproximateEquality() { + final var base = new SampleDimension.Builder() + .setBackground(null, 0) + .addQualitative("Clouds", 1) + .addQuantitative("Temperature", 10, 200, 0.1, 5.0, Units.CELSIUS) + .setName("Base") + .build(); + final var strictlyEqualToBase = new SampleDimension.Builder() + .setBackground(null, 0) + .addQualitative("Clouds", 1) + .addQuantitative("Temperature", 10, 200, 0.1, 5.0, Units.CELSIUS) + .setName("Base") + .build(); + // Same structure as base but with an explicit different dimension name. + final SampleDimension renamed = new SampleDimension.Builder() + .setBackground(null, 0) + .addQualitative("Clouds", 1) + .addQuantitative("Temperature", 10, 200, 0.1, 5.0, Units.CELSIUS) + .setName("Renamed") + .build(); + + // Different scale in the transfer function — must not be equal even approximately. + final SampleDimension differentScale = new SampleDimension.Builder() + .setBackground(null, 0) + .addQualitative("Clouds", 1) + .addQuantitative("Temperature", 10, 200, 0.2, 5.0, Units.CELSIUS) + .build(); + + // Tiny difference in offset (differs by less than the APPROXIMATE threshold): should be equal. + final SampleDimension tinyOffsetDiff = new SampleDimension.Builder() + .setBackground(null, 0) + .addQualitative("Clouds", 1) - .addQuantitative("Temperature", 10, 200, 0.1, 5.0 - 1e-13, Units.CELSIUS) ++ .addQuantitative("Temperature", 10, 200, 0.1, 5.0 - Numerics.COMPARISON_THRESHOLD, Units.CELSIUS) + .build(); + + assertTrue(base.equals(base, ComparisonMode.APPROXIMATE), + "A sample dimension should be approximately equal to itself"); + // Different dimension name is ignored under APPROXIMATE — must be equal. + assertTrue (base.equals(strictlyEqualToBase, ComparisonMode.APPROXIMATE), + "two strictly equal sample dimensions should also be approximately equal"); + assertTrue (strictlyEqualToBase.equals(base, ComparisonMode.APPROXIMATE), + "two strictly equal sample dimensions should also be approximately equal"); + + // Different dimension name is ignored under APPROXIMATE — must be equal. + assertTrue (base.equals(renamed, ComparisonMode.APPROXIMATE), + "name difference should be ignored on Sample dimension approximate equality"); + assertTrue (renamed.equals(base, ComparisonMode.APPROXIMATE), + "name difference should be ignored on Sample dimension approximate equality"); + + // The same pair must NOT be equal under STRICT because names differ. + assertFalse(base.equals(renamed, ComparisonMode.STRICT), "STRICT must detect name difference"); + + // Different scale in the transfer function → not equal even approximately. + assertFalse(base.equals(differentScale, ComparisonMode.APPROXIMATE), "different scale must produce inequality"); + assertFalse(differentScale.equals(base, ComparisonMode.APPROXIMATE), "different scale must produce inequality"); + + // A very little difference in transfer function offset should still mark both dimensions as approximately equal + assertTrue (base.equals(tinyOffsetDiff, ComparisonMode.APPROXIMATE), + "A tiny offset difference should not fail approximate equality"); + assertTrue (tinyOffsetDiff.equals(base, ComparisonMode.APPROXIMATE), + "A tiny offset difference should not fail approximate equality"); + } } diff --cc endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageProcessorTest.java index bf2fa4c1fe,f817f65f40..7ce57aa8b5 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageProcessorTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageProcessorTest.java @@@ -21,6 -23,6 +21,7 @@@ import java.util.stream.IntStream import java.awt.Shape; import java.awt.Rectangle; import java.awt.image.Raster; ++import java.awt.image.SampleModel; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; import org.opengis.referencing.operation.MathTransform; @@@ -31,12 -35,12 +32,14 @@@ import static org.junit.jupiter.api.Ass import org.apache.sis.image.processing.isoline.IsolinesTest; import org.apache.sis.test.TestCase; import static org.apache.sis.test.Assertions.assertSingleton; ++import static org.apache.sis.feature.Assertions.assertPixelsEqual; /** * Tests {@link ImageProcessor}. * * @author Martin Desruisseaux (Geomatys) ++ * @author Alexis Manin (Geomatys) */ @SuppressWarnings("exports") public final class ImageProcessorTest extends TestCase { @@@ -110,4 -114,42 +113,46 @@@ IsolinesTest.verifyIsolineFromMultiCells(assertSingleton(r.values())); } while ((parallel = !parallel) == true); } + + /** - * Verify that {@link ImageProcessor#reformat(RenderedImage, SampleModel) reformat} properly adapt tile size - * according to given parameters. ++ * Tests {@link ImageProcessor#reformat(RenderedImage, SampleModel)} with a change of tile size. ++ * The reformat operation shall properly adapt tile size according to given parameters. + */ + @Test - public void changeTileSize() { - changeTileSize(12, 12, 4, 2); ++ public void testReformatWithTileSizeChange() { ++ changeTileSize(12, 12, 4, 2); + changeTileSize(64, 64, 32, 32); - changeTileSize(50, 50, 5, 5); ++ changeTileSize(50, 50, 5, 5); + } + - private void changeTileSize(int sourceImageWidth, int sourceImageHeight, int targetTileWidth, int targetTileHeight) { ++ /** ++ * Implementation of {@link #testReformatWithTileSizeChange()} with the given image and tile sizes. ++ */ ++ private void changeTileSize(final int sourceImageWidth, final int sourceImageHeight, ++ final int targetTileWidth, final int targetTileHeight) ++ { + // Fill source image + final var image = new BufferedImage(sourceImageWidth, sourceImageHeight, BufferedImage.TYPE_BYTE_GRAY); + final var canvas = image.getRaster(); - for (int y = 0 ; y < image.getHeight() ; y++) { - for (int x = 0 ; x < image.getWidth() ; x++) { ++ for (int y = image.getHeight(); --y >= 0;) { ++ for (int x = image.getWidth(); --x >= 0;) { + canvas.setSample(x, y, 0, x*y); + } + } + + // Prepare target image layout + final var tileModel = image.getSampleModel().createCompatibleSampleModel(targetTileWidth, targetTileHeight); - final var preferredTileSize = new Dimension(tileModel.getWidth(), tileModel.getHeight()); - processor.setImageLayout(new ImageLayout(tileModel, preferredTileSize, true, false, true, null)); ++ processor.setImageLayout(ImageLayout.DEFAULT.withSampleModel(tileModel, true)); + + // Execute and verify twice: sequential then parallel ++ final var imageBounds = new Rectangle(image.getWidth(), image.getHeight()); + boolean parallel = false; - final var imageBounds = new Rectangle(0, 0, image.getWidth(), image.getHeight()); + do { + processor.setExecutionMode(parallel ? ImageProcessor.Mode.SEQUENTIAL : ImageProcessor.Mode.PARALLEL); + final RenderedImage reformatted = processor.reformat(image, null); ++ assertEquals(targetTileWidth, reformatted.getTileWidth(), "Reformatted image tile width"); ++ assertEquals(targetTileHeight, reformatted.getTileHeight(), "Reformatted image tile height"); + assertPixelsEqual(image, imageBounds, reformatted, imageBounds); - assertEquals(tileModel.getWidth(), reformatted.getTileWidth(), "Reformatted image tile width"); - assertEquals(tileModel.getHeight(), reformatted.getTileHeight(), "Reformatted image tile height"); + } while ((parallel = !parallel) == true); + } } diff --cc endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java index 4894d16b5b,4894d16b5b..0c05b9a1a6 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java @@@ -44,11 -44,11 +44,11 @@@ import org.apache.sis.io.stream.HyperRe */ public final class ReformattedImage { /** -- * Divisor of tile sizes mandated by the TIFF specification. ++ * Divisor of tile sizes mandated by the <abbr>TIFF</abbr> specification. * All tile sizes must be a multiple of this value. * This constant must be a power of 2. */ -- private static final int TILE_DIVISOR = 16; ++ public static final int TILE_DIVISOR = 16; /** * Number of color bands before the extra bands. This number does not include the alpha channel, diff --cc endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java index 2b5804e8a5,ecad02e598..93be7446d2 --- a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java @@@ -16,23 -16,18 +16,25 @@@ */ package org.apache.sis.storage.geotiff; +import java.util.Random; +import java.util.Iterator; +import java.io.IOException; +import java.io.InputStream; import java.io.ByteArrayOutputStream; + import java.nio.ByteBuffer; import java.nio.file.Path; import java.nio.file.Files; +import java.nio.file.StandardOpenOption; import java.awt.Dimension; import java.awt.Rectangle; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; -import org.apache.sis.storage.StorageConnector; -import org.apache.sis.util.Utilities; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import org.opengis.referencing.cs.AxisDirection; import org.opengis.referencing.operation.TransformException; ++import org.apache.sis.util.Utilities; import org.apache.sis.geometry.Envelopes; import org.apache.sis.geometry.GeneralEnvelope; import org.apache.sis.image.DataType; @@@ -50,15 -43,16 +52,17 @@@ import org.apache.sis.coverage.grid.Gri import org.apache.sis.referencing.CRS; import org.apache.sis.referencing.operation.transform.MathTransforms; import org.apache.sis.referencing.operation.matrix.Matrix4; ++import static org.apache.sis.storage.geotiff.writer.ReformattedImage.TILE_DIVISOR; // Test dependencies -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.*; import static org.apache.sis.test.Assertions.assertSingleton; -import static org.apache.sis.feature.Assertions.assertGridToCornerEquals; + import static org.apache.sis.feature.Assertions.assertPixelsEqual; +import static org.apache.sis.feature.Assertions.assertGridToCornerEquals; +import org.apache.sis.image.OverviewImageTest; import org.apache.sis.test.TestCase; +import org.apache.sis.test.TestUtilities; import org.apache.sis.referencing.crs.HardCodedCRS; import org.apache.sis.referencing.operation.HardCodedConversions; @@@ -71,17 -65,16 +75,34 @@@ import static org.opengis.test.Assertio * This class tests indirectly (via {@link GeoTiffStore}) the {@link Reader} and {@link Writer} classes. * * @author Martin Desruisseaux (Geomatys) + * @author Estelle Idée (Geomatys) ++ * @author Alexis Manin (Geomatys) */ @SuppressWarnings("exports") public final class GeoTiffStoreTest extends TestCase { /** * Name of a test file for an untiled image with a single band in gray-scale. ++ * The image is uncompressed for avoiding <abbr>JVM</abbr>-dependent variations. ++ */ ++ static final String UNTILED_WITHOUT_COMPRESSION = "untiled_without_compression.tiff"; ++ ++ /** ++ * Name of a test file for an image similar to {@link #UNTILED_WITHOUT_COMPRESSION} but tiled. ++ * The image is uncompressed for avoiding <abbr>JVM</abbr>-dependent variations. ++ */ ++ static final String TILED_WITHOUT_COMPRESSION = "tiled_without_compression.tiff"; ++ ++ /** ++ * Name of a test file for an untiled image with a single band in gray-scale. ++ * This image has been encoded with the default compression and predictor. ++ * Note that the stream of bytes encoded with {@link Compression#DEFLATE} ++ * may varies depending on the <abbr>JVM</abbr>. */ static final String UNTILED = "untiled.tiff"; /** * Name of a test file for an image similar to {@link #UNTILED} but tiled. ++ * This image has been encoded with the default compression and predictor. */ static final String TILED = "tiled.tiff"; @@@ -143,43 -136,55 +164,59 @@@ } /** -- * Writes an image and compare with the {@code "untiled.tiff"} file. ++ * Writes an image and compares with the {@code "untiled_without_compression.tiff"} file. ++ * Then, reads back the image and performs some validations. * * @throws Exception if a referencing or I/O error occurred. */ @Test public void testWriteUntiled() throws Exception { - testWrite(UNTILED, new Rectangle(32, 16), null, 1054); - testWrite(new Rectangle(32, 16), null); ++ testWriteAndRead(UNTILED_WITHOUT_COMPRESSION, new Rectangle(32, 16), null, 1240); } /** -- * Writes an image and compare with the {@code "tiled.tiff"} file. ++ * Writes an image and compares with the {@code "tiled_without_compression.tiff"} file. ++ * Then, reads back the image and performs some validations. * * @throws Exception if a referencing or I/O error occurred. */ @Test public void testWriteTiled() throws Exception { final var tileSize = new Dimension(16, 16); // TIFF tile size must be multiple of 16. - testWrite(TILED, new Rectangle(tileSize.width * 3, tileSize.height * 2), tileSize, 2334); - testWrite(new Rectangle(tileSize.width * 3, tileSize.height * 2), tileSize); ++ testWriteAndRead(TILED_WITHOUT_COMPRESSION, new Rectangle(tileSize.width * 3, tileSize.height * 2), tileSize, 2324); + } + + /** - * Writes an image and compare with the {@code "tiled.tiff"} file. - * <p> - * This test differs from {@link #testWriteTiled()} because it requests a tile size not accepted as is by geotiff. - * The aim of this test is to ensure that Geotiff writer will adapt tile size according to the Tiff standard. - * It requests tiles of size 19, and expect the Geotiff writer to adapt request to write tiles of size 16 or 32. - * </p> ++ * Writes an image and validates the result. ++ * This test differs from {@link #testWriteTiled()} because it requests a tile size not accepted as-is by GeoTIFF. ++ * The aim of this test is to ensure that GeoTIFF writer will adapt tile size according to the TIFF standard. ++ * It requests tiles of size 7, and expects the GeoTIFF writer to adapt request for writing tiles of size 16 or 32. ++ * ++ * @throws Exception if a referencing or I/O error occurred. + */ + @Test - public void testWriteTiledAdapted() throws Exception { - final var tileSize = new Dimension(7, 7); - testWrite(new Rectangle(64, 64), tileSize); ++ public void testWriteResizedTiles() throws Exception { ++ testWriteAndRead(null, new Rectangle(64, 64), new Dimension(7, 7), 4964); } /** * Implementation of {@link #testWriteUntiled()} and {@link #testWriteTiled()}. ++ * The image is written with no compression for avoiding variations in compression algorithms. * - * @param filename name of the file which contain the expected image. ++ * @param filename name of the file which contain the expected image, or {@code null} if none. * @param bounds bounds of the image to create. * @param tileSize size of the tiles, or {@code null} for the image size. + * @param length expected length in bytes. */ - private static void testWrite(final String filename, final Rectangle bounds, final Dimension tileSize, final int length) - private static void testWrite(final Rectangle bounds, final Dimension tileSize) - throws TransformException, DataStoreException ++ private static void testWriteAndRead(final String filename, final Rectangle bounds, final Dimension tileSize, final int length) + throws TransformException, DataStoreException, IOException { /* * We need a CRS which has no EPSG code for ensuring that the test write the same GeoTIFF keys * with or without the presence of an EPSG database on machine which is building this project. */ -- var crs = HardCodedConversions.mercator(HardCodedCRS.JUPITER); -- var geographicArea = new GeneralEnvelope(HardCodedCRS.JUPITER); ++ final var crs = HardCodedConversions.mercator(HardCodedCRS.JUPITER); ++ final var geographicArea = new GeneralEnvelope(HardCodedCRS.JUPITER); geographicArea.setRange(0, 132, 145); // Range of longitude values. geographicArea.setRange(1, 30, 42); // Range of latitude values. final GridCoverage coverage = new GridCoverageBuilder() @@@ -188,18 -193,71 +225,79 @@@ .flipGridAxis(1) .build(); - final var buffer = new ByteArrayOutputStream(); - try (DataStore ds = DataStores.openWritable(buffer, "geotiff")) { + final var buffer = new ByteArrayOutputStream(length); - try (DataStore ds = DataStores.openWritable(buffer, "geotiff")) { ++ final var source = new StorageConnector(buffer); ++ source.setOption(Compression.OPTION_KEY, Compression.NONE); ++ try (DataStore ds = DataStores.openWritable(source, "geotiff")) { assertInstanceOf(GeoTiffStore.class, ds).append(coverage, null); } - final byte[] actual = buffer.toByteArray(); - final byte[] expected; - try (InputStream in = GeoTiffStoreTest.class.getResourceAsStream(filename)) { - assertNotNull(in, filename); - expected = in.readAllBytes(); - } - assertArrayEquals(expected, actual); - try (var store = new GeoTiffStore(new GeoTiffStoreProvider(), new StorageConnector(ByteBuffer.wrap(actual)))) { - var coverageToValidate = store.components().get(0).read(null); - final var expectedGridGeom = coverage.getGridGeometry(); - final var actualGridGeom = coverageToValidate.getGridGeometry(); - assertTrue( - Utilities.equalsApproximately(expectedGridGeom, actualGridGeom), - () -> String.format( - "Written grid geometry differs from original one.%nOriginal:%n%s%nWritten:%n%s%n", - expectedGridGeom, actualGridGeom - ) - ); - - assertTrue( - Utilities.equalsApproximately(expectedGridGeom, actualGridGeom), - () -> String.format( - "Written grid geometry differs from original one.%nOriginal:%n%s%nWritten:%n%s%n", - expectedGridGeom, actualGridGeom - ) - ); + assertEquals(length, actual.length); ++ if (filename != null) { ++ final byte[] expected; ++ try (InputStream in = GeoTiffStoreTest.class.getResourceAsStream(filename)) { ++ assertNotNull(in, filename); ++ expected = in.readAllBytes(); ++ } ++ assertArrayEquals(expected, actual); ++ } ++ /* ++ * At this point, the test of the writer is considered as completed since we compared the writer output ++ * against the expected stream of bytes in a file. The remaining of this method is test of the reader, ++ * unless we had no file to compare with. ++ */ ++ try (var store = new GeoTiffStore(null, new StorageConnector(ByteBuffer.wrap(actual)))) { ++ final var coverageToValidate = assertSingleton(store.components()).read(null); ++ assertEqualsApproximately( ++ coverage.getGridGeometry(), ++ coverageToValidate.getGridGeometry(), ++ "Written grid geometry differs from original one."); + - final var expectedSampleDims = coverage.getSampleDimensions(); - final var actualSampleDims = coverageToValidate.getSampleDimensions(); - assertTrue( - Utilities.equalsApproximately(expectedSampleDims, actualSampleDims), - () -> String.format( - "Written Sample dimensions differ from original one.%nOriginal:%n%s%nWritten:%n%s%n", - expectedSampleDims, actualSampleDims - ) - ); ++ assertEqualsApproximately( ++ coverage.getSampleDimensions(), ++ coverageToValidate.getSampleDimensions(), ++ "Written Sample dimensions differ from original one."); + + final var actualRendering = coverageToValidate.render(null); + assertPixelsEqual(coverage.render(null), null, actualRendering, null); - // If user requested a tiled dataset, we must ensure the written Geotiff file has been tiled - if (tileSize != null && (tileSize.getWidth() < bounds.getWidth() || tileSize.getHeight() < bounds.getHeight())) { - assertTiling(actualRendering, tileSize, 16); ++ if (tileSize != null) { ++ // If user requested a tiled dataset, we must ensure the written GeoTIFF file has been tiled. ++ validateTileSize("width", tileSize.width, actualRendering.getTileWidth()); ++ validateTileSize("height", tileSize.height, actualRendering.getTileHeight()); + } + } + } + + /** - * Represent the side of the tile being evaluated. Either width (X) or height (Y). ++ * Asserts that the given object are approximately equal. ++ * ++ * @param expected the expected object. ++ * @param actual the actual object. ++ * @param message message to show if the objects are not approximately equal. + */ - private enum TileAxis { width, height } ++ private static void assertEqualsApproximately(final Object expected, final Object actual, final String message) { ++ assertTrue(Utilities.equalsApproximately(expected, actual), ++ () -> String.format("%s%nOriginal:%n%s%nWritten:%n%s%n", message, expected, actual)); ++ } + + /** - * Verify that given image tiling respects user tiling request, modulo a given restriction. - * The restriction maps Tiff standard requirement for tile size to be multiple of a given factor. - * </br> - * It means that if user requests a tile size of 3, but the restriction factor is 2, - * then we expect the image to use a tile size of either 2 or 4, - * which are the nearest enclosing multiples of 2 for request 3. ++ * Verifies that given tile width or height is compliant with <abbr>TIFF</abbr> standard. ++ * The {@code sourceSize} and {@code writtenSize} arguments shall be the rendered image's ++ * {@linkplain RenderedImage#getTileWidth() tile width} or {@linkplain RenderedImage#getTileHeight() tile height}. ++ * ++ * <p>If the source image had a tile size which was already compliant with <abbr>TIFF</abbr> requirement, ++ * then the image should have been written. Otherwise, there is no guarantee about the size of the tiles. ++ * It will not necessarily be the multiple of 16 immediately before or after the original tile size.</p> + * - * @param actualRendering The image to control tiling on. - * @param tileSize The tile size requested by user. - * @param tileSizeMultiple A factor to use to adapted requested tile size. ++ * @param axis which side of the tiling is being tested. Used for assertion error message formatting. ++ * @param sourceSize the tile size along tested side in the image to write. ++ * @param writtenSize the tile size along tested side in the image which has been written. + */ - private static void assertTiling(RenderedImage actualRendering, Dimension tileSize, int tileSizeMultiple) { - assertTileSize(TileAxis.width, actualRendering.getWidth(), actualRendering.getTileWidth(), tileSize.width, tileSizeMultiple); - assertTileSize(TileAxis.height, actualRendering.getHeight(), actualRendering.getTileHeight(), tileSize.height, tileSizeMultiple); ++ private static void validateTileSize(final String axis, final int sourceSize, final int writtenSize) { ++ assertEquals(0, writtenSize % TILE_DIVISOR, () -> axis + " tile size is not a multiple of " + TILE_DIVISOR); ++ if ((sourceSize % TILE_DIVISOR) == 0) { ++ assertEquals(sourceSize, writtenSize, () -> axis + " should have keep the original tile size"); ++ } } /** diff --cc endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/ReaderTest.java index 3534cb5a70,3534cb5a70..1a6b96cf4e --- a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/ReaderTest.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/ReaderTest.java @@@ -76,6 -76,6 +76,16 @@@ public class ReaderTest extends TestCas return image.getTile(image.getMinTileX(), image.getMinTileY()); } ++ /** ++ * Tests the tile matrix set or an untiled image. ++ * ++ * @throws DataStoreException if an error occurred while creating the data store. ++ */ ++ @Test ++ public void testUntiledWithoutCompression() throws DataStoreException { ++ readAndVerify(false, GeoTiffStoreTest.UNTILED_WITHOUT_COMPRESSION); ++ } ++ /** * Tests the tile matrix set or an untiled image. * @@@ -83,11 -83,11 +93,17 @@@ */ @Test public void testUntiled() throws DataStoreException { -- try (GeoTiffStore ds = createStore(GeoTiffStoreTest.UNTILED)) { -- final GridCoverageResource resource = assertInstanceOf(GridCoverageResource.class, assertSingleton(ds.components())); -- assertInstanceOf(ProjectedCRS.class, resource.getGridGeometry().getCoordinateReferenceSystem()); -- assertTrue(assertInstanceOf(TiledResource.class, resource).getTileMatrixSets().isEmpty()); -- } ++ readAndVerify(false, GeoTiffStoreTest.UNTILED); ++ } ++ ++ /** ++ * Tests the tile matrix set. ++ * ++ * @throws DataStoreException if an error occurred while creating the data store. ++ */ ++ @Test ++ public void testTileMatrixSetWithoutCompression() throws DataStoreException { ++ readAndVerify(true, GeoTiffStoreTest.TILED_WITHOUT_COMPRESSION); } /** @@@ -97,13 -97,13 +113,28 @@@ */ @Test public void testTileMatrixSet() throws DataStoreException { -- try (GeoTiffStore ds = createStore(GeoTiffStoreTest.TILED)) { ++ readAndVerify(true, GeoTiffStoreTest.TILED); ++ } ++ ++ /** ++ * Implementation of {@link #testUntiled()} and {@link #testTiled()}. ++ * ++ * @param tiled whether the file to read is tiled. ++ * @param filename the file to read. ++ * @throws DataStoreException if an error occurred while creating the data store. ++ */ ++ private void readAndVerify(final boolean tiled, final String filename) throws DataStoreException { ++ try (GeoTiffStore ds = createStore(filename)) { final GridCoverageResource resource = assertInstanceOf(GridCoverageResource.class, assertSingleton(ds.components())); assertInstanceOf(ProjectedCRS.class, resource.getGridGeometry().getCoordinateReferenceSystem()); -- ++ if (!tiled) { ++ assertTrue(assertInstanceOf(TiledResource.class, resource).getTileMatrixSets().isEmpty()); ++ return; ++ } ++ final String name = filename.substring(0, filename.lastIndexOf('.')); final TileMatrixSet pyramid = assertSingleton(assertInstanceOf(TiledResource.class, resource).getTileMatrixSets()); assertSame(resource.getGridGeometry().getCoordinateReferenceSystem(), pyramid.getCoordinateReferenceSystem()); -- assertEquals("tiled:1:TMS", pyramid.getIdentifier().toString()); ++ assertEquals(name + ":1:TMS", pyramid.getIdentifier().toString()); assertFalse(pyramid.getEnvelope().isEmpty()); final SortedMap<GenericName, ? extends TileMatrix> matrices = pyramid.getTileMatrices(); @@@ -113,7 -113,7 +144,7 @@@ assertTrue(matrices.subMap(matrices.firstKey(), matrices.lastKey()).isEmpty()); final TileMatrix matrix = assertSingleton(matrices.values()); -- assertEquals("tiled:1:TMS:L0", matrix.getIdentifier().toFullyQualifiedName().toString()); ++ assertEquals(name + ":1:TMS:L0", matrix.getIdentifier().toFullyQualifiedName().toString()); assertEquals("TMS:L0", matrix.getIdentifier().toString()); assertEquals(assertSingleton(matrices.keySet()), matrix.getIdentifier()); assertArrayEquals(resource.getGridGeometry().getResolution(false), matrix.getResolution()); diff --cc endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/tiled_without_compression.tiff index 0000000000,0000000000..25636e35a6 new file mode 100644 Binary files differ diff --cc endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/untiled_without_compression.tiff index 0000000000,0000000000..c9908b20e5 new file mode 100644 Binary files differ diff --cc endorsed/src/org.apache.sis.util/main/org/apache/sis/math/NumberType.java index 2f7dddfe17,2f7dddfe17..c6e2850669 --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/NumberType.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/NumberType.java @@@ -47,7 -47,7 +47,7 @@@ import org.apache.sis.util.internal.sha * </ul> * * @author Martin Desruisseaux (IRD, Geomatys) -- * @version 1.6 ++ * @version 1.7 * * @see org.apache.sis.util.Numbers * @see org.apache.sis.image.DataType @@@ -402,13 -402,13 +402,17 @@@ public enum NumberType * @param types the primitive types or {@link Number} classes for which to get a common enumeration value. * @return the enumeration value for the given types, or {@code null}. */ -- private static NumberType forClasses(final boolean optional, final Class<?>... types) { -- NumberType widest = VOID; ++ private static NumberType forClasses(final boolean optional, final Class<?>[] types) { ++ NumberType widest = VOID; // Value of size 0 bit (not the same as null value). boolean found = false; if (types != null) { for (final Class<?> type : types) { -- final NumberType other = forClass(type).orElse(null); -- if (other == null) { ++ final NumberType other = valueOrNull(type); ++ if (other == null) { // Means unknown type (not null type). ++ if (Number.class.isAssignableFrom(type)) { ++ widest = NUMBER; ++ continue; ++ } if (optional) return null; throw new IllegalArgumentException(Errors.format(Errors.Keys.NotANumericalType_1, type)); } @@@ -549,9 -549,9 +553,6 @@@ * @return whether this type is considered narrower than the specified type. */ public boolean isNarrowerThan(final NumberType other) { -- if (other == CHARACTER) { -- return other.isWiderThan(this); -- } return other.ordinal() < NUMBER.ordinal() && ordinal() < other.ordinal() && category <= other.category; // Integers considered narrower than floating-point types. @@@ -572,9 -572,9 +573,6 @@@ * @return whether this type is considered wider than the specified type. */ public boolean isWiderThan(final NumberType other) { -- if (other == CHARACTER) { -- return other.isNarrowerThan(this); -- } return ordinal() < NUMBER.ordinal() && ordinal() > other.ordinal() && category >= other.category; // Floating-point types considered wider than integers. @@@ -685,6 -685,6 +683,25 @@@ throw new ArithmeticException(Errors.format(Errors.Keys.CanNotConvertValue_2, expected, wrapper)); } ++ /** ++ * Returns whether the two given numbers are equal when compared as instances of this type. ++ * The comparison may be inexact if this type is not wide enough for the widest argument value. ++ * ++ * <p><b>Example:</b> ++ * {@link #INTEGER} compares the values returned by the {@link Number#intValue()} method. ++ * It means that if two {@link Long} instances are compared using {@link #INTEGER}, ++ * then only the lowest {@value Integer#SIZE} bits are compared.</p> ++ * ++ * @param v1 first value to compare, or {@code null}. ++ * @param v2 second value to compare, or {@code null}. ++ * @return whether the two values are equal when compared as instances of this type. ++ * ++ * @since 1.7 ++ */ ++ public boolean equals(final Number v1, final Number v2) { ++ return Objects.equals(cast(v1), cast(v2)); ++ } ++ /** * Casts a value to the specified type without checking whether is would cause precision lost. * This method makes the following choice: @@@ -701,7 -701,7 +718,7 @@@ * If this type is not wide enough, then the behavior depends on the implementation of the corresponding * {@code Number.fooValue()} method - typically, the value is just rounded or truncated. * -- * @param number the number to cast, or {@code null}. ++ * @param value the number to cast, or {@code null}. * @return the number cast to this type, or {@code null} if the given number was null. * @throws UnsupportedOperationException if this type cannot cast numbers. * @throws NullPointerException if {@code number} is null. @@@ -710,9 -710,9 +727,9 @@@ * * @see org.apache.sis.util.Numbers#cast(Number, Class) */ -- public Number cast(Number number) { -- if (number == null) { -- return number; ++ public Number cast(Number value) { ++ if (value == null || wrapper.isInstance(value)) { ++ return value; } throw new UnsupportedOperationException(Errors.format(Errors.Keys.UnknownType_1, this)); } diff --cc endorsed/src/org.apache.sis.util/main/org/apache/sis/measure/MeasurementRange.java index 4c36100b25,8b0638edbf..18a051811b --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/measure/MeasurementRange.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/measure/MeasurementRange.java @@@ -55,7 -57,7 +57,8 @@@ import org.apache.sis.util.resources.Er * encouraged to make sure that subclasses remain immutable for more predictable behavior. * * @author Martin Desruisseaux (IRD) -- * @version 0.6 ++ * @author Alexis Manin (Geomatys) ++ * @version 1.7 * * @param <E> the type of range elements as a subclass of {@link Number}. * @@@ -64,6 -66,6 +67,7 @@@ * * @since 0.3 */ ++@SuppressWarnings("EqualsAndHashcode") public class MeasurementRange<E extends Number & Comparable<? super E>> extends NumberRange<E> { /** * Serial number for inter-operability with different versions. @@@ -471,16 -473,17 +475,26 @@@ } /** -- * Compares this measurement range with the specified object for equality. Two {@code MeasurementRange} instances -- * are considered equal if they met all conditions {@linkplain Range#equals(Object) documented in the parent class} -- * and their {@link #unit()} are equal in the sense of {@link Objects#equals(Object, Object)}. ++ * Compares this measurement range with the specified object for equality according the specified criterion. ++ * Two {@code MeasurementRange} instances are considered equal if they met all conditions documented in the ++ * {@linkplain Range#equals(Object, ComparisonMode) parent class} and their {@linkplain #unit() units} are equal. * Note that this comparison does not distinguish the various {@link Float#NaN} or {@link Double#NaN} bit patterns. * * @return {@inheritDoc} ++ * ++ * @since 1.7 */ @Override - public boolean equals(final Object object) { - return super.equals(object) && Objects.equals(unit, ((MeasurementRange<?>) object).unit); - public boolean equals(final Object object, ComparisonMode mode) { - return super.equals(object, mode) - && Utilities.deepEquals(unit, ((MeasurementRange<?>) object).unit, mode); ++ public boolean equals(final Object object, final ComparisonMode mode) { ++ if (super.equals(object, mode) && object instanceof MeasurementRange<?>) { ++ final var other = (MeasurementRange<?>) object; ++ if (mode == ComparisonMode.STRICT) { ++ return Objects.equals(unit, other.unit); ++ } else { ++ return Utilities.deepEquals(unit(), other.unit(), mode); ++ } ++ } ++ return false; } /** diff --cc endorsed/src/org.apache.sis.util/main/org/apache/sis/measure/Range.java index 016ff2169c,ab67f954a7..3b00e5ae9c --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/measure/Range.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/measure/Range.java @@@ -25,8 -25,11 +25,11 @@@ import javax.measure.Unit import org.apache.sis.math.NumberType; import org.apache.sis.util.ArgumentChecks; import org.apache.sis.util.ArgumentCheckByAssertion; + import org.apache.sis.util.ComparisonMode; import org.apache.sis.util.Emptiable; + import org.apache.sis.util.LenientComparable; -import org.apache.sis.util.Utilities; import org.apache.sis.util.internal.shared.Strings; ++import org.apache.sis.util.internal.shared.Numerics; import org.apache.sis.util.collection.CheckedContainer; @@@ -81,7 -84,7 +84,8 @@@ * @author Joe White * @author Martin Desruisseaux (Geomatys) * @author Jody Garnett (for parameterized type inspiration) -- * @version 1.6 ++ * @author Alexis Manin (Geomatys) ++ * @version 1.7 * * @param <E> the type of range elements, typically a {@link Number} subclass or {@link java.util.Date}. * @@@ -639,27 -642,12 +643,124 @@@ public class Range<E extends Comparable * @return {@code true} if the given object is equal to this range. */ @Override - public boolean equals(final Object object) { + public final boolean equals(final Object object) { + return equals(object, ComparisonMode.STRICT); + } + ++ /** ++ * Compares this range with the given object for equality with a tolerance specified by the given mode. ++ * This method performs the comparison described in {@link #equals(Object)} except for the following: ++ * ++ * <ul> ++ * <li>{@link ComparisonMode#IGNORE_METADATA} or more tolerant: ++ * ignore the {@linkplain #getElementType() element types}. ++ * Numbers are converted to the same type before comparison.</li> ++ * <li>{@link ComparisonMode#APPROXIMATE} or more tolerant: compare the minimum and maximum ++ * values as double-precision floating point numbers with an arbitrary tolerance threshold. ++ * The threshold may change in any future version of Apache <abbr>SIS</abbr>.</li> ++ * <li>{@link ComparisonMode#ALLOW_VARIANT} or more tolerant: allow mixing different boundary types. ++ * For example, {@code (0 … 256)} is equivalent to {@code [1 … 255]} in the case of integer types ++ * (but not for floating point types).</li> ++ * </ul> ++ * ++ * @param object the object to compare with this range for equality. ++ * @param mode the strictness level of the comparison. ++ * @return {@code true} if the given object is equal to this range at the given strictness level. ++ * ++ * @since 1.7 ++ */ ++ @Override ++ public boolean equals(final Object object, final ComparisonMode mode) { + if (object == this) { + return true; + } - if (object != null && object.getClass() == getClass()) { - final Range<?> other = (Range<?>) object; - if (Objects.equals(elementType, other.elementType)) { ++ if (mode == ComparisonMode.STRICT) { ++ if (object != null && object.getClass() == getClass()) { ++ final var other = (Range<?>) object; ++ if (elementType == other.elementType) { ++ if (isEmpty()) { ++ return other.isEmpty(); ++ } ++ return isMinIncluded == other.isMinIncluded && ++ isMaxIncluded == other.isMaxIncluded && ++ Objects.equals(minValue, other.minValue) && ++ Objects.equals(maxValue, other.maxValue); ++ } ++ } ++ } else if (object instanceof Range<?>) { ++ final var other = (Range<?>) object; ++ if (mode.isIgnoringMetadata() || getElementType() == getElementType()) { + if (isEmpty()) { + return other.isEmpty(); + } - return Objects.equals(minValue, other.minValue) && - Objects.equals(maxValue, other.maxValue) && - isMinIncluded == other.isMinIncluded && - isMaxIncluded == other.isMaxIncluded; ++ return epsilonEqual(getMinValue(), isMinIncluded(), other.getMinValue(), other.isMinIncluded(), +1, mode) && ++ epsilonEqual(getMaxValue(), isMaxIncluded(), other.getMaxValue(), other.isMaxIncluded(), -1, mode); ++ } ++ } ++ return false; ++ } ++ ++ /** ++ * Returns {@code true} if the given values are equal according the specified strictness level. ++ * In {@code APPROXIMATE} or {@code DEBUG} mode, this method will derive a relative comparison ++ * threshold from the {@link Numerics#COMPARISON_THRESHOLD} constant. ++ * ++ * @param value1 the first value to compare. ++ * @param value2 the second value to compare. ++ * @param isInc1 whether {@code value1} is inclusive. ++ * @param isInc2 whether {@code value2} is inclusive. ++ * @param toIncl value to add to an integer for making it inclusive. ++ * @param mode the strictness level to use for comparing the numbers. ++ * @return {@code true} if both values are considered equal for the given comparison mode. ++ */ ++ private static boolean epsilonEqual(final Object value1, final boolean isInc1, ++ final Object value2, final boolean isInc2, ++ final int toIncl, final ComparisonMode mode) ++ { ++ if (Objects.equals(value1, value2)) { ++ return isInc1 == isInc2; ++ } ++ if (isInc1 == isInc2 || mode.ordinal() >= ComparisonMode.ALLOW_VARIANT.ordinal()) { ++ if (mode.isIgnoringMetadata() && value1 instanceof Number && value2 instanceof Number) { ++ final var n1 = (Number) value1; ++ final var n2 = (Number) value2; ++ NumberType t1 = NumberType.forNumberClass(n1.getClass()); ++ NumberType t2 = NumberType.forNumberClass(n2.getClass()); ++ if (mode.isApproximate()) { ++ double v1 = n1.doubleValue(); ++ double v2 = n2.doubleValue(); ++ if (isInc1 != isInc2) { ++ /* ++ * The comparison mode allows variants. If at least one value is an integer, ++ * increment or decrement that value for having the same inclusiveness status ++ * as the other value. ++ */ if (t1.isInteger()) v1 += (isInc1 ? -toIncl : toIncl); ++ else if (t2.isInteger()) v2 += (isInc2 ? -toIncl : toIncl); ++ } ++ /* ++ * Compare as floating point numbers regardless the value types (even integers). ++ * This is for avoiding the difficulty of keeping the result consistent with all ++ * combinations of "is included" flags and comparison modes. The comparison stay ++ * nevertheless strict for type `int` or smaller, and become approximate only for ++ * type `long` or larger (`BigInteger`). ++ */ ++ return Numerics.epsilonEqual(v1, v2, mode); ++ } else { ++ if (t2.isWiderThan(t1)) { ++ // Conservatively use `double` for avoiding accuracy lost if converting from `int`. ++ t1 = (t2 == NumberType.FLOAT) ? NumberType.DOUBLE : t2; ++ } ++ return t1.equals(n1, n2); ++ } + } + } + return false; + } + /** * Returns a hash code value for this range. ++ * ++ * @return a hash code value for this range. */ @Override public int hashCode() { @@@ -706,6 -729,6 +807,8 @@@ * If this range is a {@link MeasurementRange}, then the {@linkplain Unit unit of measurement} * is appended to the above string representation except for empty ranges. * ++ * @return an unlocalized string representation of this range. ++ * * @see RangeFormat * @see <a href="https://en.wikipedia.org/wiki/ISO_31-11">Wikipedia: ISO 31-11</a> */ diff --cc endorsed/src/org.apache.sis.util/main/org/apache/sis/util/ComparisonMode.java index 793a65c9a8,793a65c9a8..221f0ba4ad --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/ComparisonMode.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/ComparisonMode.java @@@ -54,15 -54,15 +54,15 @@@ import org.opengis.referencing.operatio */ public enum ComparisonMode { /** -- * All attributes of the compared objects shall be strictly equal. This comparison mode ++ * All attributes of the compared objects shall be strictly equal. This criterion * is equivalent to the {@link Object#equals(Object)} method, and must be compliant with -- * the contract documented in that method. In particular, this comparison mode shall be -- * consistent with {@link Object#hashCode()} and be symmetric ({@code A.equals(B)} implies -- * {@code B.equals(A)}). ++ * the contract documented in that method. In particular, the comparison algorithm shall ++ * be consistent with {@link Object#hashCode()} and be symmetric ({@code A.equals(B)} ++ * implies {@code B.equals(A)}). * * <h4>Implementation note</h4> -- * In the <abbr>SIS</abbr> implementations, this comparison mode usually have the following -- * characteristics (not always, this is only typical): ++ * In the <abbr>SIS</abbr> implementations, the comparison algorithm for this criterion ++ * usually has the following characteristics: * * <ul> * <li>The objects being compared need to be the same implementation class.</li> @@@ -96,9 -96,9 +96,9 @@@ BY_CONTRACT, /** -- * Only the attributes relevant to the object functionality are compared. Attributes that -- * are only informative can be ignored. This comparison mode is typically less strict than -- * {@link #BY_CONTRACT}. ++ * Only the attributes relevant to the object functionality are compared. ++ * Attributes that are only informative can be ignored. ++ * This criterion is typically less strict than {@link #BY_CONTRACT}. * * <h4>Application to coordinate reference systems</h4> * If the objects being compared are {@link CoordinateReferenceSystem} instances, @@@ -189,6 -189,6 +189,9 @@@ * <b>Example:</b> two geographic <abbr>CRS</abbr>s with the same attributes but with * (<var>latitude</var>, <var>longitude</var>) axes in one case and * (<var>longitude</var>, <var>latitude</var>) axes in the other case.</li> ++ * <li>Two ranges of integer values containing the same set of numbers, ++ * but specified with different bound inclusiveness. ++ * <b>Example:</b> {@code (0 … 256)} and {@code [1 … 255]}.</li> * </ul> * * @since 0.7 diff --cc endorsed/src/org.apache.sis.util/main/org/apache/sis/util/Utilities.java index 4587b71572,4cdf227ea3..b236c50b22 --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/Utilities.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/Utilities.java @@@ -113,13 -114,13 +113,16 @@@ public final class Utilities } /** -- * Convenience method for testing two objects for equality using the given level of strictness. -- * If at least one of the given objects implement the {@link LenientComparable} interface, then -- * the comparison is performed using the {@link LenientComparable#equals(Object, ComparisonMode)} -- * method. Otherwise this method performs the same work as the -- * {@link Objects#deepEquals(Object, Object)} convenience method. ++ * Tests two objects for equality using the given level of strictness. ++ * This method applies the following rules, in order: * -- * <p>If both arguments are arrays or collections, then the elements are compared recursively.</p> ++ * <ul> ++ * <li>If at least one of the given objects implement the {@link LenientComparable} interface, then the ++ * comparison is delegated to the {@link LenientComparable#equals(Object, ComparisonMode)} method.</li> ++ * <li>Otherwise, if both arguments are {@code Map} entries, {@link Map}s, {@link Collection}s or arrays, ++ * then this method invoke {@code deepEquals(…)} for the elements of above-cited containers.</li> ++ * <li>Otherwise, this method delegates to {@link Objects#deepEquals(Object, Object)}.</li> ++ * </ul> * * @param object1 the first object to compare, or {@code null}. * @param object2 the second object to compare, or {@code null}. diff --cc endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/shared/Numerics.java index a9de7d6262,bd7b23c21b..b015ade95b --- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/shared/Numerics.java +++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/shared/Numerics.java @@@ -65,7 -69,7 +65,7 @@@ public final class Numerics * matrices as equal. This value has been determined empirically in order to allow * {@code org.apache.sis.referencing.operation.transform.ConcatenatedTransform} to * detect the cases where two {@link org.apache.sis.referencing.operation.transform.LinearTransform} -- * are equal for practical purpose. This threshold can be used as below:</p> ++ * are equal for practical purposes. This threshold can be used as below:</p> * * {@snippet lang="java" : * Matrix m1 = ...; @@@ -77,7 -81,7 +77,7 @@@ * * By extension, the same threshold value is used for comparing other floating point values. * -- * <p>The current value is set to the smallest power of 10 which allow the ++ * <p>The current value is set to the smallest power of 10 which allows the * {@code org.apache.sis.test.integration.ConsistencyTest} to pass.</p> * * @see org.apache.sis.referencing.internal.shared.Formulas#LINEAR_TOLERANCE @@@ -385,6 -374,6 +385,7 @@@ /** * Returns {@code true} if the given values are approximately equal, up to the given comparison threshold. ++ * NaN values are considered equal to all other NaN values, even if the threshold is also NaN. * * @param v1 the first value to compare. * @param v2 the second value to compare. @@@ -411,7 -400,11 +412,13 @@@ public static boolean epsilonEqual(final double v1, final double v2, final ComparisonMode mode) { if (mode.isApproximate()) { final double mg = max(abs(v1), abs(v2)); + /* - * If one of the numbers to compare is not finite, it is not possible to compare them using an epsilon. - * In such cases, we must fall back to the standard equal to check if their bit representation is the same. ++ * Infinite magnitude gives infinite tolerance threshold, which causes all finite numbers ++ * to be considered equal to the infinite number. For preventing these false positives, ++ * we must avoid the use of `epsilonEqual(…)` with infinite numbers. Note that there is ++ * no need to check for NaN values because `epsilonEqual(…)` is already NaN-safe. + */ - if (Double.isFinite(mg)) { + if (mg != Double.POSITIVE_INFINITY) { return epsilonEqual(v1, v2, COMPARISON_THRESHOLD * mg); } } diff --cc endorsed/src/org.apache.sis.util/test/org/apache/sis/measure/MeasurementRangeTest.java index baf35e864a,baf35e864a..72a720f789 --- a/endorsed/src/org.apache.sis.util/test/org/apache/sis/measure/MeasurementRangeTest.java +++ b/endorsed/src/org.apache.sis.util/test/org/apache/sis/measure/MeasurementRangeTest.java @@@ -30,6 -30,6 +30,7 @@@ import static org.apache.sis.test.Asser * * @author Martin Desruisseaux (IRD) */ ++@SuppressWarnings("exports") public final class MeasurementRangeTest extends TestCase { /** * Creates a new test case. diff --cc endorsed/src/org.apache.sis.util/test/org/apache/sis/measure/RangeTest.java index d5d6a7e9b0,d5d6a7e9b0..dc9e3185a0 --- a/endorsed/src/org.apache.sis.util/test/org/apache/sis/measure/RangeTest.java +++ b/endorsed/src/org.apache.sis.util/test/org/apache/sis/measure/RangeTest.java @@@ -18,6 -18,6 +18,7 @@@ package org.apache.sis.measure import java.util.Locale; import java.time.Instant; ++import org.apache.sis.util.ComparisonMode; // Test dependencies import org.junit.jupiter.api.Test; @@@ -33,6 -33,6 +34,7 @@@ import org.apache.sis.test.TestCase * @author Joe White * @author Martin Desruisseaux (Geomatys) */ ++@SuppressWarnings("exports") public final class RangeTest extends TestCase { /** * Creates a new test case. @@@ -389,6 -389,6 +391,28 @@@ assertTrue(range5.equals(range6)); } ++ /** ++ * Tests the {@link Range#equals(Object, ComparisonMode)} method. ++ */ ++ @Test ++ public void testLenientEquality() { ++ final Range<Integer> inclusive = new Range<>(Integer.class, 10, true, 20, true); ++ final Range<Integer> exclusive = new Range<>(Integer.class, 10, true, 21, false); ++ final Range<Float> asFloats = new Range<>( Float.class, 10f, true, 20f, true); ++ final Range<Integer> different = new Range<>(Integer.class, 10, true, 22, false); ++ assertTrue (inclusive.equals(exclusive, ComparisonMode.ALLOW_VARIANT)); ++ assertTrue (exclusive.equals(inclusive, ComparisonMode.ALLOW_VARIANT)); ++ assertTrue (exclusive.equals(asFloats, ComparisonMode.ALLOW_VARIANT)); ++ assertFalse(inclusive.equals(different, ComparisonMode.ALLOW_VARIANT)); ++ assertFalse(exclusive.equals(different, ComparisonMode.ALLOW_VARIANT)); ++ assertFalse(inclusive.equals(exclusive, ComparisonMode.APPROXIMATE)); ++ assertFalse(exclusive.equals(asFloats, ComparisonMode.APPROXIMATE)); ++ assertTrue (inclusive.equals(asFloats, ComparisonMode.APPROXIMATE)); ++ assertFalse(inclusive.equals(asFloats, ComparisonMode.BY_CONTRACT)); ++ assertTrue (inclusive.equals(asFloats, ComparisonMode.IGNORE_METADATA)); ++ assertFalse(exclusive.equals(asFloats, ComparisonMode.IGNORE_METADATA)); ++ } ++ /** * Tests serialization. */
