diff --git 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 index f88ed0593d9..04706fc0622 100644 --- 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 @@ -23,6 +23,9 @@ import java.io.Serializable; import static java.lang.Double.doubleToRawLongBits; import javax.measure.Unit; +import org.apache.sis.util.ComparisonMode; +import org.apache.sis.util.LenientComparable; +import org.apache.sis.util.Utilities; import org.opengis.util.InternationalString; import org.opengis.referencing.operation.MathTransform1D; import org.opengis.referencing.operation.TransformException; @@ -71,7 +74,7 @@ * @version 1.1 * @since 1.0 */ -public class Category implements Serializable { +public class Category implements LenientComparable, Serializable { /** * Serial number for inter-operability with different versions. */ @@ -507,29 +510,41 @@ public int hashCode() { * @param object the object to compare with. * @return {@code true} if the given object is equal to this category. */ + 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. + * + * + * + * @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. + */ @Override - public boolean equals(final Object object) { - if (object == this) { - // Slight optimization - return true; - } - if (object != null && getClass().equals(object.getClass())) { - 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); - } + 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; } + default: + return Utilities.deepEquals(range, that.range, mode) + && Utilities.deepEquals(toConverse, that.toConverse, mode); } - return false; } /** diff --git 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 index bba233abf13..75cda8ad26a 100644 --- 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 @@ -32,6 +32,8 @@ 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.measure.NumberRange; import org.apache.sis.math.MathFunctions; @@ -69,7 +71,7 @@ * * @author Martin Desruisseaux (IRD, Geomatys) */ -final class CategoryList extends AbstractList implements MathTransform1D, Serializable { +final class CategoryList extends AbstractList implements MathTransform1D, LenientComparable, Serializable { /** * Serial number for inter-operability with different versions. */ @@ -825,16 +827,34 @@ public final Category get(final int i) { * Compares the specified object with this category list for equality. */ @Override - public boolean equals(final Object object) { + public final boolean equals(final Object object) { if (object instanceof CategoryList) { - final CategoryList that = (CategoryList) object; - if (Arrays.equals(categories, that.categories)) { - assert Arrays.equals(minimums, that.minimums); - } else { + return equals(object, ComparisonMode.STRICT); + } + return super.equals(object); + } + + /** + * Compares this category list with the given object for equality at the given level of strictness. + * The comparison is performed element-wise using {@link Category#equals(Object, ComparisonMode)}. + * + * @param object the object to compare with. + * @param mode the comparison strictness level. + * @return {@code true} if both lists are equal according the given comparison mode. + */ + @Override + public boolean equals(final Object object, final ComparisonMode mode) { + if (object == this) return true; + if (!(object instanceof CategoryList)) return false; + final CategoryList that = (CategoryList) object; + final int count = categories.length; + if (that.categories.length != count) return false; + for (int i = 0; i < count; i++) { + if (!categories[i].equals(that.categories[i], mode)) { return false; } } - return super.equals(object); + return true; } /** diff --git 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 index 4826bb42dea..90c462ca0d2 100644 --- 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 @@ -28,6 +28,9 @@ import java.util.Optional; import java.io.Serializable; import javax.measure.Unit; +import org.apache.sis.util.ComparisonMode; +import org.apache.sis.util.LenientComparable; +import org.apache.sis.util.Utilities; import org.opengis.util.GenericName; import org.opengis.util.InternationalString; import org.opengis.referencing.operation.MathTransform1D; @@ -79,13 +82,13 @@ * * @author Martin Desruisseaux (IRD, Geomatys) * @author Alexis Manin (Geomatys) - * @version 1.5 + * @version 1.7 * * @see org.opengis.metadata.content.SampleDimension * * @since 1.0 */ -public class SampleDimension implements IdentifiedType, Serializable { +public class SampleDimension implements IdentifiedType, LenientComparable, Serializable { /** * Serial number for inter-operability with different versions. */ @@ -487,15 +490,29 @@ public int hashCode() { * @return {@code true} if the given object is equal to this sample dimension. */ @Override - public boolean equals(final Object object) { - 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); + public final boolean equals(final Object object) { + return equals(object, ComparisonMode.STRICT); + } + + @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); + } + } + 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); + } } - return false; } /** diff --git 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 index ea32bb2589f..4e2ecfeb640 100644 --- 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 @@ 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; // Test dependencies import org.junit.jupiter.api.Test; @@ -414,4 +415,116 @@ public void testFromConvertedCategories() { 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"); + + // ------------------------------------------------------------------ + // Empty list: same reference → true. + // ------------------------------------------------------------------ + assertTrue(CategoryList.EMPTY.equals(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"); + + // ------------------------------------------------------------------ + // 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"); + + // ------------------------------------------------------------------ + // 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"); + + // ------------------------------------------------------------------ + // 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"); + + // ------------------------------------------------------------------ + // 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"); + + // ------------------------------------------------------------------ + // 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), + 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"); + } } diff --git 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 index 51f7bf25e89..4313bc79f35 100644 --- 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 @@ -20,8 +20,11 @@ import org.opengis.referencing.operation.MathTransform1D; import org.opengis.referencing.operation.TransformException; import org.apache.sis.referencing.operation.transform.MathTransforms; +import org.apache.sis.measure.MeasurementRange; import org.apache.sis.measure.NumberRange; +import org.apache.sis.measure.Units; import org.apache.sis.math.MathFunctions; +import org.apache.sis.util.ComparisonMode; // Test dependencies import org.junit.jupiter.api.Test; @@ -240,4 +243,106 @@ public void testCategoryNaN() { 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("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.IGNORE_METADATA), "self/IGNORE_METADATA"); + 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"); + + // 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 --git 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 index a2d38f06285..5587a1cae5f 100644 --- 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 @@ import org.apache.sis.math.MathFunctions; import org.apache.sis.measure.NumberRange; import org.apache.sis.measure.Units; +import org.apache.sis.util.ComparisonMode; // Test dependencies import org.junit.jupiter.api.Test; @@ -223,4 +224,125 @@ public void testBuilder() { assertEquals("Clouds", categories.remove(1).getName().toString()); assertEquals(2, categories.size()); } + + @Test + 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. + * + * @see org.apache.sis.util.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}. + * 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() { + 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) + .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 --git 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 index bf2fa4c1fea..f817f65f400 100644 --- 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 @@ -16,6 +16,8 @@ */ package org.apache.sis.image; +import java.awt.Dimension; +import java.awt.image.SampleModel; import java.util.Map; import java.util.stream.IntStream; import java.awt.Shape; @@ -27,6 +29,8 @@ // Test dependencies import org.junit.jupiter.api.Test; + +import static org.apache.sis.feature.Assertions.assertPixelsEqual; import static org.junit.jupiter.api.Assertions.*; import org.apache.sis.image.processing.isoline.IsolinesTest; import org.apache.sis.test.TestCase; @@ -110,4 +114,42 @@ public void testIsolines() { 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. + */ + @Test + public void changeTileSize() { + changeTileSize(12, 12, 4, 2); + changeTileSize(64, 64, 32, 32); + changeTileSize(50, 50, 5, 5); + } + + private void changeTileSize(int sourceImageWidth, int sourceImageHeight, int targetTileWidth, 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++) { + 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)); + + // Execute and verify twice: sequential then parallel + 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); + 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 --git 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 index 5d495e9544b..ecad02e598c 100644 --- 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,15 +16,16 @@ */ package org.apache.sis.storage.geotiff; -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.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 org.opengis.referencing.cs.AxisDirection; import org.opengis.referencing.operation.TransformException; import org.apache.sis.geometry.Envelopes; @@ -44,10 +45,13 @@ import org.apache.sis.referencing.operation.matrix.Matrix4; // 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 org.apache.sis.test.TestCase; import org.apache.sis.referencing.crs.HardCodedCRS; import org.apache.sis.referencing.operation.HardCodedConversions; @@ -138,7 +142,7 @@ public void testNonLinearVerticalTransform() throws Exception { */ @Test public void testWriteUntiled() throws Exception { - testWrite(UNTILED, new Rectangle(32, 16), null, 1054); + testWrite(new Rectangle(32, 16), null); } /** @@ -149,19 +153,31 @@ public void testWriteUntiled() throws Exception { @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); + } + + /** + * Writes an image and compare with the {@code "tiled.tiff"} file. + *

+ * 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. + *

+ */ + @Test + public void testWriteTiledAdapted() throws Exception { + final var tileSize = new Dimension(7, 7); + testWrite(new Rectangle(64, 64), tileSize); } /** * Implementation of {@link #testWriteUntiled()} and {@link #testWriteTiled()}. * - * @param filename name of the file which contain the expected image. * @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) - throws TransformException, DataStoreException, IOException + private static void testWrite(final Rectangle bounds, final Dimension tileSize) + throws TransformException, DataStoreException { /* * We need a CRS which has no EPSG code for ensuring that the test write the same GeoTIFF keys @@ -177,17 +193,99 @@ private static void testWrite(final String filename, final Rectangle bounds, fin .flipGridAxis(1) .build(); - final var buffer = new ByteArrayOutputStream(length); + final var buffer = new ByteArrayOutputStream(); try (DataStore ds = DataStores.openWritable(buffer, "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(); + 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 + ) + ); + + 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 + ) + ); + + 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); + } + } + } + + /** + * Represent the side of the tile being evaluated. Either width (X) or height (Y). + */ + private enum TileAxis { width, height } + + /** + * 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. + *
+ * 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. + * + * @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. + */ + 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); + } + + /** + * Test a specific tile side according to requirements expressed by {@link #assertTiling(RenderedImage, Dimension, int)}. + * + * @param axis Which side of the tiling is being tested. Used for assertion error message formatting. + * @param imgSize The image actual size along tested side (its {@link RenderedImage#getWidth() width} or {@link RenderedImage#getHeight() height}). + * @param imgActualTileSize The image actual tile size along tested side (its {@link RenderedImage#getTileWidth() tile width} or {@link RenderedImage#getTileHeight() tile height}). + * @param requestedTileSize User request tile size along the side to test. + * @param tileSizeMultiple The restriction factor: actual tile size must be a multiple of this value, independently of the user request. + */ + private static void assertTileSize(TileAxis axis, int imgSize, int imgActualTileSize, int requestedTileSize, int tileSizeMultiple) { + if (imgSize > requestedTileSize) { + final int modulo = requestedTileSize % tileSizeMultiple; + if (modulo == 0) { + assertEquals(requestedTileSize, imgActualTileSize, () -> "Tile " + axis); + } else if (requestedTileSize < tileSizeMultiple) { + assertEquals(tileSizeMultiple, imgActualTileSize, () -> "Tile " + axis); + } else { + final var minTileSize = requestedTileSize - modulo; + final var maxTileSize = requestedTileSize + (tileSizeMultiple - modulo); + assertTrue(imgActualTileSize == minTileSize || imgActualTileSize == maxTileSize, + () -> String.format( + "Tile %s should be either %d or %d (because it must be a multiple of %d), but it is %d", + axis, minTileSize, maxTileSize, tileSizeMultiple, imgActualTileSize + ) + ); + } } - assertArrayEquals(expected, actual); - assertEquals(length, actual.length); } } diff --git 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 index 4c36100b25a..8b0638edbf6 100644 --- 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 @@ -21,7 +21,9 @@ import javax.measure.UnitConverter; import javax.measure.IncommensurableException; import org.apache.sis.math.NumberType; +import org.apache.sis.util.ComparisonMode; import org.apache.sis.util.Numbers; +import org.apache.sis.util.Utilities; import org.apache.sis.util.resources.Errors; @@ -479,8 +481,9 @@ public Range[] subtract(final Range range) throws IllegalArgumentException * @return {@inheritDoc} */ @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); } /** diff --git 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 index 016ff2169c1..ab67f954a7a 100644 --- 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,7 +25,10 @@ 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.collection.CheckedContainer; @@ -90,7 +93,7 @@ * * @since 0.3 */ -public class Range> implements CheckedContainer, Formattable, Emptiable, Serializable { +public class Range> implements CheckedContainer, Formattable, Emptiable, LenientComparable, Serializable { /** * For cross-version compatibility. */ @@ -639,23 +642,8 @@ private int compareMaxTo(final E value, int position) { * @return {@code true} if the given object is equal to this range. */ @Override - public boolean equals(final Object object) { - if (object == this) { - return true; - } - if (object != null && object.getClass() == getClass()) { - final Range other = (Range) object; - if (Objects.equals(elementType, other.elementType)) { - if (isEmpty()) { - return other.isEmpty(); - } - return Objects.equals(minValue, other.minValue) && - Objects.equals(maxValue, other.maxValue) && - isMinIncluded == other.isMinIncluded && - isMaxIncluded == other.isMaxIncluded; - } - } - return false; + public final boolean equals(final Object object) { + return equals(object, ComparisonMode.STRICT); } /** @@ -673,6 +661,41 @@ public int hashCode() { return hash ^ (int) serialVersionUID; } + + @Override + public boolean equals(Object object, ComparisonMode mode) { + if (object == this) { + return true; + } + if (!(object instanceof Range)) return false; + final Range other = (Range) object; + switch (mode) { + case STRICT: + case BY_CONTRACT: + case IGNORE_METADATA: { + if (other.getClass() != getClass()) return false; + if (!Objects.equals(elementType, other.elementType)) return false; + if (isEmpty()) return other.isEmpty(); + return Objects.equals(minValue, other.minValue) + && Objects.equals(maxValue, other.maxValue) + && isMinIncluded == other.isMinIncluded + && isMaxIncluded == other.isMaxIncluded; + } + default: { + return Utilities.deepEquals(minValue, other.minValue, mode) + && Utilities.deepEquals(maxValue, other.maxValue, mode) + /* TODO: we might want to improve this in the future to allow mixing different boundary types. + * For example, (0..256) is equivalent to [1..255] in the case of a discrete integer space, + * but not in continuous real number space. + * As it is difficult for now to properly manage such corner cases, we start strict, + * so we can improve this step by step in the future. + */ + && isMinIncluded == other.isMinIncluded + && isMaxIncluded == other.isMaxIncluded; + } + } + } + /** * Returns {@code true} if the given number is formatted with only one character. * We will use less space if the minimum and maximum values are formatted using diff --git 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 index 4587b715721..4cdf227ea3a 100644 --- 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 @@ -25,6 +25,7 @@ import java.util.Objects; import java.util.Optional; import org.apache.sis.util.collection.CheckedContainer; +import org.apache.sis.util.internal.shared.Numerics; /** @@ -204,6 +205,16 @@ assert isNotDebug(mode) : ((object1 != null) ? object1.getClass() } return true; } + + if (object1 instanceof Number && object2 instanceof Number) { + final Number n1 = (Number) object1; + final Number n2 = (Number) object2; + return (n1 == n2 || ( + (n1 instanceof Double || n1 instanceof Float || n2 instanceof Double || n2 instanceof Float) + && Numerics.epsilonEqual(n1.doubleValue(), n2.doubleValue(), mode) + )); + } + return Objects.deepEquals(object1, object2); } diff --git 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 index 3a03b0fe2f3..bd7b23c21ba 100644 --- 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 @@ -400,7 +400,11 @@ public static boolean epsilonEqual(final double v1, final double v2, final doubl 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 (mg != Double.POSITIVE_INFINITY) { + /* + * 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. + */ + if (Double.isFinite(mg)) { return epsilonEqual(v1, v2, COMPARISON_THRESHOLD * mg); } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138c96..aaaabb3cb9f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME