From 785387034fc0d13556d3f8bb6e2bfc444b61e014 Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Wed, 6 May 2026 16:57:22 +0200 Subject: [PATCH 1/9] chore(Build): ugrade Gradle wrapper to 8.14.4 to allow building with JDK 24 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 2843f7b77fc8f8f26befe97c5fbe6ef6dc320f68 Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Wed, 20 May 2026 16:14:45 +0200 Subject: [PATCH 2/9] feat(Feature): Make SampleDimension lenient comparable --- .../apache/sis/coverage/SampleDimension.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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..c491679ab81 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; @@ -85,7 +88,7 @@ * * @since 1.0 */ -public class SampleDimension implements IdentifiedType, Serializable { +public class SampleDimension implements IdentifiedType, LenientComparable, Serializable { /** * Serial number for inter-operability with different versions. */ @@ -498,6 +501,18 @@ public boolean equals(final Object object) { return false; } + @Override + public boolean equals(Object other, ComparisonMode mode) { + if (other == this) return true; + if (mode.isApproximate() && other instanceof SampleDimension) { + 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 equals(other); + } + /** * Returns a string representation of this sample dimension. * This string is for debugging purpose only and may change in future version. From 76febadda64437f612a05073f68fdfbf5688eb60 Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Wed, 20 May 2026 17:15:11 +0200 Subject: [PATCH 3/9] feat(Utilities): add support for numbers in approximate equality --- .../main/org/apache/sis/util/Utilities.java | 11 +++++++++++ .../org/apache/sis/util/internal/shared/Numerics.java | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) 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); } } From 81ff7e156817af0f87cc6e40672e82351645e2b2 Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Wed, 20 May 2026 16:16:54 +0200 Subject: [PATCH 4/9] fix(Storage): fix Geotiff data store flaky test The Geotiff writing test was failing depending on user build environment; The cause is an assertion that expects that a written image has an exact size, despite it being compressed using deflate algorithm (not deterministic). The fix consists in changing the comparison logic. We now read back the written file, and verify that the returned coverage is equivalent to the coverage that has been used as test input. --- .../sis/storage/geotiff/GeoTiffStoreTest.java | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) 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..bf9a28ce476 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 @@ -17,14 +17,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; @@ -45,9 +47,11 @@ // Test dependencies 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,17 @@ 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); } /** * 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 +179,43 @@ 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 + ) + ); + + assertPixelsEqual(coverage.render(null), null, coverageToValidate.render(null), null); } - assertArrayEquals(expected, actual); - assertEquals(length, actual.length); } } From ed5617b6a5554e329fed98637e50d29c49f45220 Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Thu, 21 May 2026 09:53:55 +0200 Subject: [PATCH 5/9] chore(Storage): improve GeoTiff tests to verify tiling of written files --- .../sis/storage/geotiff/GeoTiffStoreTest.java | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) 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 bf9a28ce476..e43a061cc47 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,7 +16,6 @@ */ package org.apache.sis.storage.geotiff; -import java.io.IOException; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.nio.file.Path; @@ -46,6 +45,7 @@ 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.*; @@ -156,6 +156,20 @@ public void testWriteTiled() throws Exception { 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 that is 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()}. * @@ -215,7 +229,63 @@ private static void testWrite(final Rectangle bounds, final Dimension tileSize) ) ); - assertPixelsEqual(coverage.render(null), null, coverageToValidate.render(null), null); + 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 + ) + ); + } } } } From ad33ef9dd7461127bc8253038088b4604c65470d Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Thu, 21 May 2026 10:58:22 +0200 Subject: [PATCH 6/9] chore(Feature): add a test to verify that image reformatting properly retile given image. --- .../apache/sis/image/ImageProcessorTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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); + } } From e77f5d00a1ceace8edfa8a91337e11be89c525fc Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Wed, 3 Jun 2026 17:02:19 +0200 Subject: [PATCH 7/9] chore(Storage): fix javadoc formatting of a Geotiff unit test --- .../org/apache/sis/storage/geotiff/GeoTiffStoreTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 e43a061cc47..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 @@ -158,11 +158,11 @@ public void testWriteTiled() throws Exception { /** * Writes an image and compare with the {@code "tiled.tiff"} file. - * This test differs from {@link #testWriteTiled()} because it requests a tile size that is not accepted as is by - * geotiff. - *
+ *

+ * 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 { From 911f46d11d63863b63e39e22748f287ec7619bcb Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Wed, 3 Jun 2026 17:05:23 +0200 Subject: [PATCH 8/9] refactor(Storage): force future inheritors of SampleDimension class to override equals through LenientComparable interface --- .../apache/sis/coverage/SampleDimension.java | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) 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 c491679ab81..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 @@ -82,7 +82,7 @@ * * @author Martin Desruisseaux (IRD, Geomatys) * @author Alexis Manin (Geomatys) - * @version 1.5 + * @version 1.7 * * @see org.opengis.metadata.content.SampleDimension * @@ -490,27 +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); - } - return false; + 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 (mode.isApproximate() && other instanceof SampleDimension) { - 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 (!(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 equals(other); } /** From 65cb2c71456fc661512a4cdd0d57e83a678ec4a2 Mon Sep 17 00:00:00 2001 From: Alexis Manin Date: Wed, 3 Jun 2026 17:24:36 +0200 Subject: [PATCH 9/9] feat+fix(Feature): fix SampleDimension lenient comparison by making its subcomponents lenient comparable as well --- .../org/apache/sis/coverage/Category.java | 57 +++++--- .../org/apache/sis/coverage/CategoryList.java | 34 ++++- .../apache/sis/coverage/CategoryListTest.java | 113 ++++++++++++++++ .../org/apache/sis/coverage/CategoryTest.java | 105 +++++++++++++++ .../sis/coverage/SampleDimensionTest.java | 122 ++++++++++++++++++ .../apache/sis/measure/MeasurementRange.java | 7 +- .../main/org/apache/sis/measure/Range.java | 59 ++++++--- 7 files changed, 449 insertions(+), 48 deletions(-) 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. + * + *
    + *
  • {@link ComparisonMode#STRICT}: same implementation class, same name, exact range and transfer function.
  • + *
  • {@link ComparisonMode#BY_CONTRACT}: same name, exact range and transfer function; class may differ.
  • + *
  • {@link ComparisonMode#IGNORE_METADATA} and more lenient: range and transfer function only (name is ignored).
  • + *
  • {@link ComparisonMode#APPROXIMATE}: range and transfer function approximately equal.
  • + *
+ * + * @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/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.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