You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2021/09/07 16:51:12 UTC

[sis] branch geoapi-4.0 updated (f32bc87 -> 7b885ef)

This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a change to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git.


    from f32bc87  Rename Landsat implementation classes, add localization, group bands in Reflective/Panchromatic/Thermal groups.
     new 854a1d5  Add a comment about future work.
     new 6d1ba31  Throws exception in case of invalid property values in `ImageProcessor.recolor(…)` method instead of silently ignoring.
     new 7b885ef  Move and refactor `FeatureComparator` in a test package.

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../org/apache/sis/gui/coverage/RenderingData.java |   4 +-
 .../java/org/apache/sis/image/ImageProcessor.java  |   3 +-
 .../java/org/apache/sis/image/RecoloredImage.java  |  95 +++--
 .../org/apache/sis/feature/FeatureComparator.java  | 310 --------------
 .../apache/sis/test/feature/FeatureComparator.java | 458 +++++++++++++++++++++
 .../org/apache/sis/test/feature}/package-info.java |   8 +-
 .../storage/earthobservation/LandsatAggregate.java |   4 +
 7 files changed, 528 insertions(+), 354 deletions(-)
 delete mode 100644 core/sis-feature/src/test/java/org/apache/sis/feature/FeatureComparator.java
 create mode 100644 core/sis-feature/src/test/java/org/apache/sis/test/feature/FeatureComparator.java
 copy core/{sis-metadata/src/test/java/org/apache/sis/test/sql => sis-feature/src/test/java/org/apache/sis/test/feature}/package-info.java (88%)

[sis] 02/03: Throws exception in case of invalid property values in `ImageProcessor.recolor(…)` method instead of silently ignoring.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 6d1ba31020621a219a9a0d46d677fec8df2d8825
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue Sep 7 15:28:10 2021 +0200

    Throws exception in case of invalid property values in `ImageProcessor.recolor(…)` method instead of silently ignoring.
---
 .../org/apache/sis/gui/coverage/RenderingData.java |  4 +-
 .../java/org/apache/sis/image/ImageProcessor.java  |  3 +-
 .../java/org/apache/sis/image/RecoloredImage.java  | 95 ++++++++++++++--------
 3 files changed, 62 insertions(+), 40 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
index 4b65e39..7fbc9f3 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
@@ -284,9 +284,7 @@ final class RenderingData implements Cloneable {
             if (selectedDerivative == Stretching.AUTOMATIC) {
                 modifiers.put("multStdDev", 3);
             }
-            if (dataRanges != null) {
-                modifiers.put("sampleDimensions", dataRanges.toArray(SampleDimension[]::new));
-            }
+            modifiers.put("sampleDimensions", dataRanges);
             image = processor.stretchColorRamp(image, modifiers);
         }
         return image;
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
index ebea38f..edcf916 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
@@ -691,7 +691,7 @@ public class ImageProcessor implements Cloneable {
      *   </tr><tr>
      *     <td>{@code "sampleDimensions"}</td>
      *     <td>Meaning of pixel values.</td>
-     *     <td><code>{@linkplain SampleDimension}[]</code></td>
+     *     <td>{@link SampleDimension}</td>
      *   </tr>
      * </table>
      *
@@ -710,6 +710,7 @@ public class ImageProcessor implements Cloneable {
      * @param  modifiers  modifiers for narrowing the range of values, or {@code null} if none.
      * @return the image with color ramp stretched between the specified or calculated bounds,
      *         or {@code image} unchanged if the operation can not be applied on the given image.
+     * @throws IllegalArgumentException if the value associated to one of about keys is not of expected type.
      */
     public RenderedImage stretchColorRamp(final RenderedImage source, final Map<String,?> modifiers) {
         ArgumentChecks.ensureNonNull("source", source);
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
index cf34f00..d97aa1b 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
@@ -17,6 +17,7 @@
 package org.apache.sis.image;
 
 import java.util.Map;
+import java.util.List;
 import java.util.Arrays;
 import java.awt.Shape;
 import java.awt.image.ColorModel;
@@ -27,6 +28,7 @@ import org.apache.sis.coverage.Category;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.util.collection.Containers;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.math.Statistics;
@@ -119,39 +121,53 @@ final class RecoloredImage extends ImageAdapter {
         double        minimum       = Double.NaN;
         double        maximum       = Double.NaN;
         double        deviations    = Double.POSITIVE_INFINITY;
-        SampleDimension[] ranges    = null;
+        SampleDimension range       = null;
         /*
          * Extract and validate parameter values.
          * No calculation started at this stage.
          */
         if (modifiers != null) {
-            final Object minValue = modifiers.get("minimum");
-            if (minValue instanceof Number) {
-                minimum = ((Number) minValue).doubleValue();
-            }
-            final Object maxValue = modifiers.get("maximum");
-            if (maxValue instanceof Number) {
-                maximum = ((Number) maxValue).doubleValue();
-            }
+            final Number minValue = Containers.property(modifiers, "minimum", Number.class);
+            final Number maxValue = Containers.property(modifiers, "maximum", Number.class);
+            if (minValue != null) minimum = minValue.doubleValue();
+            if (maxValue != null) maximum = maxValue.doubleValue();
             if (minimum >= maximum) {
                 throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalRange_2, minValue, maxValue));
             }
-            Object value = modifiers.get("multStdDev");
-            if (value instanceof Number) {
-                deviations = ((Number) value).doubleValue();
-                ArgumentChecks.ensureStrictlyPositive("multStdDev", deviations);
+            {   // For keeping `value` in local scope.
+                final Number value = Containers.property(modifiers, "multStdDev", Number.class);
+                if (value != null) {
+                    deviations = value.doubleValue();
+                    ArgumentChecks.ensureStrictlyPositive("multStdDev", deviations);
+                }
             }
-            value = modifiers.get("statistics");
-            if (value instanceof RenderedImage) {
-                statsSource = (RenderedImage) value;
-            } else if (value instanceof Statistics) {
-                statistics = (Statistics) value;
-            } else if (value instanceof Statistics[]) {
-                statsAllBands = (Statistics[]) value;
+            Object value = modifiers.get("statistics");
+            if (value != null) {
+                if (value instanceof RenderedImage) {
+                    statsSource = (RenderedImage) value;
+                } else if (value instanceof Statistics) {
+                    statistics = (Statistics) value;
+                } else if (value instanceof Statistics[]) {
+                    statsAllBands = (Statistics[]) value;
+                } else {
+                    throw illegalPropertyType(modifiers, "statistics", value);
+                }
             }
             value = modifiers.get("sampleDimensions");
-            if (value instanceof SampleDimension[]) {
-                ranges = (SampleDimension[]) value;
+            if (value != null) {
+                if (value instanceof List<?>) {
+                    final List<?> ranges = (List<?>) value;
+                    if (visibleBand < ranges.size()) {
+                        value = ranges.get(visibleBand);
+                    }
+                }
+                if (value != null) {
+                    if (value instanceof SampleDimension) {
+                        range = (SampleDimension) value;
+                    } else {
+                        throw illegalPropertyType(modifiers, "sampleDimensions", value);
+                    }
+                }
             }
         }
         /*
@@ -196,20 +212,17 @@ final class RecoloredImage extends ImageAdapter {
             int validMin = 0;
             int validMax = size - 1;        // Inclusive.
             double span = 0;
-            if (ranges != null && visibleBand < ranges.length) {
-                final SampleDimension range = ranges[visibleBand];
-                if (range != null) {
-                    for (final Category category : range.getCategories()) {
-                        if (category.isQuantitative()) {
-                            final NumberRange<?> r = category.getSampleRange();
-                            final double min = Math.max(r.getMinDouble(true), 0);
-                            final double max = Math.min(r.getMaxDouble(true), size - 1);
-                            final double s   = Math.min(max, maximum) - Math.max(min, minimum);    // Intersection.
-                            if (s > span) {
-                                validMin = (int) min;
-                                validMax = (int) max;
-                                span = s;
-                            }
+            if (range != null) {
+                for (final Category category : range.getCategories()) {
+                    if (category.isQuantitative()) {
+                        final NumberRange<?> r = category.getSampleRange();
+                        final double min = Math.max(r.getMinDouble(true), 0);
+                        final double max = Math.min(r.getMaxDouble(true), size - 1);
+                        final double s   = Math.min(max, maximum) - Math.max(min, minimum);    // Intersection.
+                        if (s > span) {
+                            validMin = (int) min;
+                            validMax = (int) max;
+                            span = s;
                         }
                     }
                 }
@@ -243,6 +256,16 @@ final class RecoloredImage extends ImageAdapter {
     }
 
     /**
+     * Returns the exception to be thrown when a property is of illegal type.
+     */
+    private static IllegalArgumentException illegalPropertyType(
+            final Map<String,?> properties, final String key, final Object value)
+    {
+        return new IllegalArgumentException(Errors.getResources(properties)
+                .getString(Errors.Keys.IllegalPropertyValueClass_2, key, value.getClass()));
+    }
+
+    /**
      * Returns the color model of this image.
      */
     @Override

[sis] 01/03: Add a comment about future work.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 854a1d58319b027aeaeaf9491be8ce28ec225ed9
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue Sep 7 15:27:57 2021 +0200

    Add a comment about future work.
---
 .../org/apache/sis/storage/earthobservation/LandsatAggregate.java     | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/storage/sis-earth-observation/src/main/java/org/apache/sis/storage/earthobservation/LandsatAggregate.java b/storage/sis-earth-observation/src/main/java/org/apache/sis/storage/earthobservation/LandsatAggregate.java
index d94c0b7..9517e1d 100644
--- a/storage/sis-earth-observation/src/main/java/org/apache/sis/storage/earthobservation/LandsatAggregate.java
+++ b/storage/sis-earth-observation/src/main/java/org/apache/sis/storage/earthobservation/LandsatAggregate.java
@@ -36,6 +36,10 @@ import org.apache.sis.util.ArraysExt;
  * An aggregate of {@link LandsatResource}.
  * Each aggregate is for one {@link LandsatBandGroup}.
  *
+ * @todo Future implementation should implement {@code GridCoverageResource}
+ *       and provides an aggregated coverage view where each Landsat band is
+ *       a sample dimension.
+ *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.1
  * @since   1.1

[sis] 03/03: Move and refactor `FeatureComparator` in a test package.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 7b885ef7b4a9f518ced6aec722ba4f1aa3fe996c
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue Sep 7 18:41:35 2021 +0200

    Move and refactor `FeatureComparator` in a test package.
---
 .../org/apache/sis/feature/FeatureComparator.java  | 310 --------------
 .../apache/sis/test/feature/FeatureComparator.java | 458 +++++++++++++++++++++
 .../org/apache/sis/test/feature/package-info.java  |  30 ++
 3 files changed, 488 insertions(+), 310 deletions(-)

diff --git a/core/sis-feature/src/test/java/org/apache/sis/feature/FeatureComparator.java b/core/sis-feature/src/test/java/org/apache/sis/feature/FeatureComparator.java
deleted file mode 100644
index 5176a7f..0000000
--- a/core/sis-feature/src/test/java/org/apache/sis/feature/FeatureComparator.java
+++ /dev/null
@@ -1,310 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.sis.feature;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.StringJoiner;
-import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.Deprecable;
-import org.junit.Assert;
-import org.opengis.feature.AttributeType;
-import org.opengis.feature.Feature;
-import org.opengis.feature.FeatureAssociationRole;
-import org.opengis.feature.FeatureType;
-import org.opengis.feature.IdentifiedType;
-import org.opengis.feature.Operation;
-import org.opengis.feature.PropertyType;
-import org.opengis.util.GenericName;
-
-/**
- * Tool to compare feature and feature types.
- *
- * @author Johann Sorel (Geomatys)
- * @version 2.0
- * @since   2.0
- * @module
- */
-public class FeatureComparator {
-
-    private final Object obj1;
-    private final Object obj2;
-
-    /**
-     * The fully-qualified name of properties to ignore in comparisons. This
-     * collection is initially empty. Users can add or remove elements in this
-     * collection as they wish.
-     *
-     * <p>
-     * The elements shall be names in the form {@code "namespace:name"}, or only
-     * {@code "name"} if there is no namespace.</p>
-     */
-    public final Set<String> ignoredProperties = new HashSet<>();
-    /**
-     * The fully-qualified name of characteristics to ignore in comparisons.
-     * This collection is initially empty. Users can add or remove elements in
-     * this collection as they wish.
-     *
-     * <p>
-     * The elements shall be names in the form {@code "namespace:name"}, or only
-     * {@code "name"} if there is no namespace.</p>
-     */
-    public final Set<String> ignoredCharacteristics = new HashSet<>();
-
-    public boolean ignoreDefinition = false;
-    public boolean ignoreDesignation = false;
-    public boolean ignoreDescription = false;
-
-    public FeatureComparator(Feature expected, Feature result) {
-        ArgumentChecks.ensureNonNull("expected", expected);
-        ArgumentChecks.ensureNonNull("result", result);
-        this.obj1 = expected;
-        this.obj2 = result;
-    }
-
-    public FeatureComparator(FeatureType expected, FeatureType result) {
-        ArgumentChecks.ensureNonNull("expected", expected);
-        ArgumentChecks.ensureNonNull("result", result);
-        this.obj1 = expected;
-        this.obj2 = result;
-    }
-
-    /**
-     * Compare the features or feature types specified at construction time.
-     */
-    public void compare() {
-        final Path path = new Path();
-        if (obj1 instanceof FeatureType) {
-            compareFeatureType(path, (FeatureType) obj1, (FeatureType) obj2);
-        } else {
-            compareFeature(path, (Feature) obj1, (Feature) obj2);
-        }
-    }
-
-    private void compareType(Path path, IdentifiedType expected, IdentifiedType result) {
-        if (expected instanceof FeatureType) {
-            compareFeatureType(path, (FeatureType) expected, (FeatureType) result);
-        } else if (expected instanceof PropertyType) {
-            comparePropertyType(path, (PropertyType) expected, (PropertyType) result);
-        } else {
-            Assert.fail(msg(path, "Unexpected type " + expected));
-        }
-    }
-
-    private void compareFeatureType(Path path, FeatureType expected, FeatureType result) {
-        compareIdentifiedType(path, expected, result);
-
-        Assert.assertEquals(msg(path, "Abstract state differ"), expected.isAbstract(), result.isAbstract());
-        Assert.assertEquals(msg(path, "Super types differ"), expected.getSuperTypes(), result.getSuperTypes());
-
-        List<? extends PropertyType> expectedProperties = new ArrayList<>(expected.getProperties(false));
-        List<? extends PropertyType> resultProperties = new ArrayList<>(result.getProperties(false));
-
-        while (!expectedProperties.isEmpty()) {
-            final PropertyType pte = expectedProperties.remove(0);
-            if (ignoredProperties.contains(pte.getName().toString())) {
-                continue;
-            }
-            Path sub = path.sub(pte.getName().toString());
-            PropertyType ptr = find(sub, resultProperties, pte.getName());
-            resultProperties.remove(ptr);
-            comparePropertyType(sub, pte, ptr);
-        }
-        while (!resultProperties.isEmpty()) {
-            final PropertyType pte = resultProperties.remove(0);
-            if (ignoredProperties.contains(pte.getName().toString())) {
-                continue;
-            }
-            Path sub = path.sub(pte.getName().toString());
-            Assert.fail(msg(sub, "Result type contains a property not declared in expected type : " + pte.getName()));
-        }
-
-    }
-
-    private void compareFeature(Path path, Feature expected, Feature result) {
-        compareFeatureType(path, expected.getType(), result.getType());
-
-        Collection<? extends PropertyType> properties = expected.getType().getProperties(true);
-        for (PropertyType pte : properties) {
-            if (ignoredProperties.contains(pte.getName().toString())) {
-                continue;
-            }
-            Path sub = path.sub(pte.getName().toString());
-
-            Object expectedValue = expected.getPropertyValue(pte.getName().toString());
-            Object resultValue = result.getPropertyValue(pte.getName().toString());
-            if (!(expectedValue instanceof Collection)) {
-                expectedValue = (expectedValue == null) ? Collections.EMPTY_LIST : Arrays.asList(expectedValue);
-            }
-            if (!(resultValue instanceof Collection)) {
-                resultValue = (resultValue == null) ? Collections.EMPTY_LIST : Arrays.asList(resultValue);
-            }
-
-            Collection<?> expectedCol = (Collection) expectedValue;
-            Collection<?> resultCol = (Collection) resultValue;
-
-            if (expectedCol.size() != resultCol.size()) {
-                Assert.fail(msg(sub, "Number of values differ, expected " + expectedCol.size() + " but was " + resultCol.size()));
-            }
-
-            Iterator<?> expectedIte = expectedCol.iterator();
-            Iterator<?> resultIte = resultCol.iterator();
-
-            while (expectedIte.hasNext()) {
-                Object subExpVal = expectedIte.next();
-                Object subResVal = resultIte.next();
-
-                if (subExpVal instanceof Feature) {
-                    compareFeature(path, (Feature) subExpVal, (Feature) subResVal);
-                } else {
-                    Assert.assertEquals(subExpVal, subResVal);
-                }
-            }
-        }
-    }
-
-    private void comparePropertyType(Path path, PropertyType expected, PropertyType result) {
-
-        if (expected instanceof AttributeType) {
-            if (!(result instanceof AttributeType)) {
-                Assert.fail(msg(path, "Expected an AttributeType for name " + ((AttributeType) expected).getName() + " but found a " + result));
-            }
-            compareAttribute(path, (AttributeType) expected, (AttributeType) result);
-
-        } else if (expected instanceof FeatureAssociationRole) {
-            if (!(result instanceof FeatureAssociationRole)) {
-                Assert.fail(msg(path, "Expected a FeatureAssociationRole for name " + ((AttributeType) expected).getName() + " but found a " + result));
-            }
-            compareFeatureAssociationRole(path, (FeatureAssociationRole) expected, (FeatureAssociationRole) result);
-
-        } else if (expected instanceof Operation) {
-            if (!(result instanceof Operation)) {
-                Assert.fail(msg(path, "Expected an Operation for name " + ((AttributeType) expected).getName() + " but found a " + result));
-            }
-            compareOperation(path, (Operation) expected, (Operation) result);
-        }
-    }
-
-    private void compareAttribute(Path path, AttributeType expected, AttributeType result) {
-        compareIdentifiedType(path, expected, result);
-        Assert.assertEquals(msg(path, "Value classe differ"), expected.getValueClass(), expected.getValueClass());
-        Assert.assertEquals(msg(path, "Default value differ"), expected.getDefaultValue(), expected.getDefaultValue());
-
-        Map<String, AttributeType<?>> expCharacteristics = expected.characteristics();
-        Map<String, AttributeType<?>> resCharacteristics = result.characteristics();
-
-        final List<String> expKeys = new ArrayList<>(expCharacteristics.keySet());
-        final List<String> resKeys = new ArrayList<>(resCharacteristics.keySet());
-
-        while (!expKeys.isEmpty()) {
-            final String pte = expKeys.remove(0);
-            if (ignoredCharacteristics.contains(pte)) {
-                continue;
-            }
-            AttributeType<?> exp = expCharacteristics.get(pte);
-            AttributeType<?> res = resCharacteristics.get(pte);
-            resKeys.remove(pte);
-            comparePropertyType(path.sub("characteristic(" + pte + ")"), exp, res);
-        }
-        while (!resKeys.isEmpty()) {
-            final String pte = resKeys.remove(0);
-            if (ignoredCharacteristics.contains(pte)) {
-                continue;
-            }
-            Assert.fail(msg(path, "Result type contains a characteristic not declared in expected type : " + pte));
-        }
-
-    }
-
-    private void compareFeatureAssociationRole(Path path, FeatureAssociationRole expected, FeatureAssociationRole result) {
-        compareIdentifiedType(path, expected, result);
-
-        Assert.assertEquals(msg(path, "Minimum occurences differ"), expected.getMinimumOccurs(), result.getMinimumOccurs());
-        Assert.assertEquals(msg(path, "Maximum occurences differ"), expected.getMaximumOccurs(), result.getMaximumOccurs());
-        compareFeatureType(path.sub("association-valuetype"), expected.getValueType(), result.getValueType());
-    }
-
-    private void compareOperation(Path path, Operation expected, Operation result) {
-        compareIdentifiedType(path, expected, result);
-        Assert.assertEquals(expected.getParameters(), result.getParameters());
-        compareType(path.sub("operation-result(" + expected.getResult().getName().toString() + ")"), expected.getResult(), result.getResult());
-    }
-
-    private void compareIdentifiedType(Path path, IdentifiedType expected, IdentifiedType result) {
-        Assert.assertEquals(msg(path, "Name differ"), expected.getName(), result.getName());
-        if (!ignoreDefinition) {
-            Assert.assertEquals(msg(path, "Definition differ"), expected.getDefinition(), result.getDefinition());
-        }
-        if (!ignoreDesignation) {
-            Assert.assertEquals(msg(path, "Designation differ"), expected.getDesignation(), result.getDesignation());
-        }
-        if (!ignoreDescription) {
-            Assert.assertEquals(msg(path, "Description differ"), expected.getDescription(), result.getDescription());
-        }
-
-        //check deprecable
-        if (expected instanceof Deprecable) {
-            if (result instanceof Deprecable) {
-                boolean dep1 = ((Deprecable) expected).isDeprecated();
-                boolean dep2 = ((Deprecable) result).isDeprecated();
-                if (dep1 != dep2) {
-                    Assert.fail(msg(path, "Deprecated state differ, " + dep1 + " in expected " + dep2 + " in result"));
-                }
-            }
-        }
-    }
-
-    private static PropertyType find(Path path, Collection<? extends PropertyType> properties, GenericName name) {
-        for (PropertyType pt : properties) {
-            if (pt.getName().equals(name)) {
-                return pt;
-            }
-        }
-        Assert.fail(msg(path, "Property not found for name " + name));
-        return null;
-    }
-
-    private static String msg(Path path, String errorMessage) {
-        return path.toString() + " " + errorMessage;
-    }
-
-    private static class Path {
-
-        private final List<String> segments = new ArrayList<>();
-
-        public Path sub(String segment) {
-            Path p = new Path();
-            p.segments.addAll(segments);
-            p.segments.add(segment);
-            return p;
-        }
-
-        @Override
-        public String toString() {
-            final StringJoiner sj = new StringJoiner(" > ");
-            segments.stream().forEach(sj::add);
-            return "[" + sj.toString() + "]";
-        }
-    }
-}
diff --git a/core/sis-feature/src/test/java/org/apache/sis/test/feature/FeatureComparator.java b/core/sis-feature/src/test/java/org/apache/sis/test/feature/FeatureComparator.java
new file mode 100644
index 0000000..316f6ad
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/test/feature/FeatureComparator.java
@@ -0,0 +1,458 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.test.feature;
+
+import java.util.Set;
+import java.util.Map;
+import java.util.List;
+import java.util.Deque;
+import java.util.ArrayList;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.stream.Collectors;
+import org.opengis.util.GenericName;
+import org.apache.sis.internal.util.CollectionsExt;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Deprecable;
+
+import static org.opengis.test.Assert.*;
+
+// Branch-dependent imports
+import org.opengis.feature.Feature;
+import org.opengis.feature.FeatureType;
+import org.opengis.feature.FeatureAssociationRole;
+import org.opengis.feature.PropertyType;
+import org.opengis.feature.AttributeType;
+import org.opengis.feature.IdentifiedType;
+import org.opengis.feature.Operation;
+
+
+/**
+ * Comparator of feature instances and feature types.
+ * Can be used in test suite for comparing an actual feature against its expected value.
+ *
+ * @author  Johann Sorel (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public strictfp class FeatureComparator {
+    /**
+     * The expected feature, or {@code null} if comparing only feature type.
+     */
+    private final Feature expectedInstance;
+
+    /**
+     * The expected feature type.
+     */
+    private final FeatureType expectedType;
+
+    /**
+     * The actual feature, or {@code null} if comparing only feature type.
+     */
+    private final Feature actualInstance;
+
+    /**
+     * The actual feature type.
+     */
+    private final FeatureType actualType;
+
+    /**
+     * The fully-qualified name of properties to ignore in comparisons.
+     * This collection is initially empty.
+     * Users can add or remove elements in this collection as they wish.
+     *
+     * <p>The elements shall be names in the form {@code "namespace:name"},
+     * or only {@code "name"} if there is no namespace.</p>
+     *
+     * @see #compare()
+     */
+    public final Set<String> ignoredProperties = new HashSet<>();
+
+    /**
+     * The fully-qualified name of characteristics to ignore in comparisons.
+     * This collection is initially empty.
+     * Users can add or remove elements in this collection as they wish.
+     *
+     * <p>The elements shall be names in the form {@code "namespace:name"},
+     * or only {@code "name"} if there is no namespace.</p>
+     *
+     * @see #compare()
+     */
+    public final Set<String> ignoredCharacteristics = new HashSet<>();
+
+    /**
+     * Configuration option for ignoring the <em>definition</em> metadata associated
+     * to feature types and property types. The default value is {@code false}.
+     *
+     * @see #compare()
+     * @see IdentifiedType#getDefinition()
+     */
+    public boolean ignoreDefinition;
+
+    /**
+     * Configuration option for ignoring the <em>designation</em> metadata associated
+     * to feature types and property types. The default value is {@code false}.
+     *
+     * @see #compare()
+     * @see IdentifiedType#getDesignation()
+     */
+    public boolean ignoreDesignation;
+
+    /**
+     * Configuration option for ignoring the <em>description</em> metadata associated
+     * to feature types and property types. The default value is {@code false}.
+     *
+     * @see #compare()
+     * @see IdentifiedType#getDescription()
+     */
+    public boolean ignoreDescription;
+
+    /**
+     * Path to the property being compared. Used in case of test failure.
+     */
+    private final Deque<String> path = new ArrayDeque<>();
+
+    /**
+     * Creates a new comparator for the given feature instances.
+     *
+     * @param  expected  the expected feature instance.
+     * @param  actual    the actual feature instance.
+     */
+    public FeatureComparator(final Feature expected, final Feature actual) {
+        ArgumentChecks.ensureNonNull("expected", expected);
+        ArgumentChecks.ensureNonNull("actual", actual);
+        expectedInstance = expected;
+        expectedType     = expected.getType();
+        actualInstance   = actual;
+        actualType       = actual.getType();
+    }
+
+    /**
+     * Creates a new comparator for the given feature types.
+     *
+     * @param  expected  the expected feature type.
+     * @param  actual    the actual feature type.
+     */
+    public FeatureComparator(final FeatureType expected, final FeatureType actual) {
+        ArgumentChecks.ensureNonNull("expected", expected);
+        ArgumentChecks.ensureNonNull("actual",   actual);
+        expectedInstance = null;
+        expectedType     = expected;
+        actualInstance   = null;
+        actualType       = actual;
+    }
+
+    /**
+     * Compares the feature instances or feature types specified at construction time.
+     * If there is any aspect to ignore during comparisons, then the {@link #ignoredProperties},
+     * {@link #ignoredCharacteristics}, {@link #ignoreDefinition}, {@link #ignoreDesignation} or
+     * {@link #ignoreDescription} flags should be set before to invoke this method.
+     *
+     * @throws AssertionError if the test fails.
+     */
+    public void compare() {
+        if (expectedInstance != null) {
+            compareFeature(expectedInstance, actualInstance);
+        } else {
+            compareFeatureType(expectedType, actualType);
+        }
+    }
+
+    /**
+     * Compares two feature types or two property types.
+     *
+     * @param  expected  the expected type.
+     * @param  actual    the actual type.
+     * @throws AssertionError if the actual type is not equal to the expected type.
+     */
+    private void compareType(final IdentifiedType expected, final IdentifiedType actual) {
+        boolean recognized = false;
+        if (expected instanceof FeatureType) {
+            assertInstanceOf(path(), FeatureType.class, actual);
+            compareFeatureType((FeatureType) expected, (FeatureType) actual);
+            recognized = true;
+        }
+        if (expected instanceof PropertyType) {
+            assertInstanceOf(path(), PropertyType.class, actual);
+            comparePropertyType((PropertyType) expected, (PropertyType) actual);
+            recognized = true;
+        }
+        if (!recognized) {
+            fail(path() + "Unexpected type " + expected);
+        }
+    }
+
+    /**
+     * Compares two feature types. This comparison implies the comparisons of all non-ignored properties.
+     *
+     * @param  expected  the expected type.
+     * @param  actual    the actual type.
+     * @throws AssertionError if the actual type is not equal to the expected type.
+     */
+    private void compareFeatureType(final FeatureType expected, final FeatureType actual) {
+        compareIdentifiedType(expected, actual);
+
+        // TODO: put messages in lambda functionw with JUnit 5.
+        assertEquals(path() + "Abstract state differ", expected.isAbstract(), actual.isAbstract());
+        assertEquals(path() + "Super types differ", expected.getSuperTypes(), actual.getSuperTypes());
+        /*
+         * Compare all properties that are not ignored.
+         * Properties are removed from the `actualProperties` list as we found them.
+         */
+        final List<PropertyType> actualProperties = new ArrayList<>(actual.getProperties(false));
+        actualProperties.removeIf(this::isIgnored);
+        for (final PropertyType pte : expected.getProperties(false)) {
+            if (!isIgnored(pte)) {
+                final String tip = push(pte.getName().toString());
+                PropertyType pta = findAndRemove(actualProperties, pte.getName());
+                comparePropertyType(pte, pta);
+                pull(tip);
+            }
+        }
+        /*
+         * Any remaining property in the `actualProperties` list is unexpected.
+         */
+        if (!actualProperties.isEmpty()) {
+            final StringBuilder b = new StringBuilder(path())
+                    .append("Actual type contains a property not declared in expected type:")
+                    .append(System.lineSeparator());
+            for (final PropertyType pta : actualProperties) {
+                b.append("  ").append(pta.getName()).append(System.lineSeparator());
+            }
+            fail(b.toString());
+        }
+    }
+
+    /**
+     * Compares two feature instances. This comparison implies the comparison of feature types
+     * and the comparisons of all non-ignored properties.
+     *
+     * @param  expected  the expected instance.
+     * @param  actual    the actual instance.
+     * @throws AssertionError if the actual instance is not equal to the expected instance.
+     */
+    private void compareFeature(final Feature expected, final Feature actual) {
+        compareFeatureType(expected.getType(), actual.getType());
+        for (final PropertyType p : expected.getType().getProperties(true)) {
+            if (isIgnored(p)) {
+                continue;
+            }
+            final String tip = push(p.getName().toString());
+            final Collection<?> expectedValues = asCollection(expected.getPropertyValue(tip));
+            final Collection<?> actualValues   = asCollection(actual.getPropertyValue(tip));
+            assertEquals(path() + "Number of values differ", expectedValues.size(), actualValues.size());
+            final Iterator<?> expectedIter = expectedValues.iterator();
+            final Iterator<?> actualIter = actualValues.iterator();
+            while (expectedIter.hasNext()) {
+                final Object expectedElement = expectedIter.next();
+                final Object actualElement = actualIter.next();
+                if (expectedElement instanceof Feature) {
+                    compareFeature((Feature) expectedElement, (Feature) actualElement);
+                } else {
+                    assertEquals(expectedElement, actualElement);
+                }
+            }
+            pull(tip);
+        }
+    }
+
+    /**
+     * Compares two property types. This method dispatches the comparison to a more specialized method.
+     *
+     * @param  expected  the expected property.
+     * @param  actual    the actual property.
+     * @throws AssertionError if the actual property is not equal to the expected property.
+     */
+    private void comparePropertyType(final PropertyType expected, final PropertyType actual) {
+        if (expected instanceof AttributeType) {
+            assertInstanceOf(path(), AttributeType.class, actual);
+            compareAttribute((AttributeType) expected, (AttributeType) actual);
+        }
+        if (expected instanceof FeatureAssociationRole) {
+            assertInstanceOf(path(), FeatureAssociationRole.class, actual);
+            compareFeatureAssociationRole((FeatureAssociationRole) expected, (FeatureAssociationRole) actual);
+        }
+        if (expected instanceof Operation) {
+            assertInstanceOf(path(), Operation.class, actual);
+            compareOperation((Operation) expected, (Operation) actual);
+        }
+    }
+
+    /**
+     * Compares two attribute types.
+     *
+     * @param  expected  the expected property.
+     * @param  actual    the actual property.
+     * @throws AssertionError if the actual property is not equal to the expected property.
+     */
+    private void compareAttribute(final AttributeType<?> expected, final AttributeType<?> actual) {
+        compareIdentifiedType(expected, actual);
+        assertEquals(path() + "Value classe differ",  expected.getValueClass(),   expected.getValueClass());
+        assertEquals(path() + "Default value differ", expected.getDefaultValue(), expected.getDefaultValue());
+
+        final Map<String, AttributeType<?>> expectedChrs = expected.characteristics();
+        final Map<String, AttributeType<?>> actualChrs = actual.characteristics();
+        final List<String> actualChrNames = new ArrayList<>(actualChrs.keySet());
+        actualChrNames.removeIf((p) -> ignoredCharacteristics.contains(p));
+
+        for (final Map.Entry<String, AttributeType<?>> entry : expectedChrs.entrySet()) {
+            final String p = entry.getKey();
+            if (!ignoredCharacteristics.contains(p)) {
+                final AttributeType<?> expectedChr = entry.getValue();
+                final AttributeType<?> actualChr = actualChrs.get(p);
+                final String tip = push("characteristic(" + p + ')');
+                assertNotNull(path(), actualChr);
+                assertTrue(actualChrNames.remove(p));
+                comparePropertyType(expectedChr, actualChr);
+                pull(tip);
+            }
+        }
+        /*
+         * Any remaining characteristics in the `actualChrNames` list is unexpected.
+         */
+        if (!actualChrNames.isEmpty()) {
+            final StringBuilder b = new StringBuilder(path())
+                    .append("Result type contains a characteristic not declared in expected type:")
+                    .append(System.lineSeparator());
+            for (final String c : actualChrNames) {
+                b.append("  ").append(c).append(System.lineSeparator());
+            }
+            fail(b.toString());
+        }
+    }
+
+    /**
+     * Compares two associations.
+     *
+     * @param  expected  the expected property.
+     * @param  actual    the actual property.
+     * @throws AssertionError if the actual property is not equal to the expected property.
+     */
+    private void compareFeatureAssociationRole(final FeatureAssociationRole expected, final FeatureAssociationRole actual) {
+        compareIdentifiedType(expected, actual);
+        assertEquals(path() + "Minimum occurences differ", expected.getMinimumOccurs(), actual.getMinimumOccurs());
+        assertEquals(path() + "Maximum occurences differ", expected.getMaximumOccurs(), actual.getMaximumOccurs());
+        final String tip = push("association-valuetype");
+        compareFeatureType(expected.getValueType(), actual.getValueType());
+        pull(tip);
+    }
+
+    /**
+     * Compares two operations.
+     *
+     * @param  expected  the expected property.
+     * @param  actual    the actual property.
+     * @throws AssertionError if the actual property is not equal to the expected property.
+     */
+    private void compareOperation(final Operation expected, final Operation actual) {
+        compareIdentifiedType(expected, actual);
+        assertEquals(expected.getParameters(), actual.getParameters());
+        final String tip = push("operation-actual(" + expected.getResult().getName() + ')');
+        compareType(expected.getResult(), actual.getResult());
+        pull(tip);
+    }
+
+    /**
+     * Comparisons common to feature type, property type, association roles and operations.
+     *
+     * @param  expected  the expected type.
+     * @param  actual    the actual type.
+     * @throws AssertionError if the actual type is not equal to the expected type.
+     */
+    private void compareIdentifiedType(final IdentifiedType expected, final IdentifiedType actual) {
+        assertEquals(path() + "Name differ", expected.getName(), actual.getName());
+        if (!ignoreDefinition) {
+            assertEquals(path() + "Definition differ", expected.getDefinition(), actual.getDefinition());
+        }
+        if (!ignoreDesignation) {
+            assertEquals(path() + "Designation differ", expected.getDesignation(), actual.getDesignation());
+        }
+        if (!ignoreDescription) {
+            assertEquals(path() + "Description differ", expected.getDescription(), actual.getDescription());
+        }
+        if (expected instanceof Deprecable && actual instanceof Deprecable) {
+            assertEquals(path() + "Deprecated state differ",
+                    ((Deprecable) expected).isDeprecated(),
+                    ((Deprecable) actual).isDeprecated());
+        }
+    }
+
+    /**
+     * Adds the given name to the path to the property being compared.
+     * This is used for formatting error messages in case of error.
+     */
+    private String push(final String label) {
+        path.addLast(label);
+        return label;
+    }
+
+    /**
+     * Removes the given name from the path to the property being compared.
+     * This is the converse of {@link #push(String)} and shall be invoked
+     * when the comparison of a property finished.
+     */
+    private void pull(final String tip) {
+        assertSame(tip, path.removeLast());
+    }
+
+    /**
+     * Returns a string representation of current {@link #path} value.
+     */
+    private String path() {
+        return path.stream().collect(Collectors.joining(" > ", "[", "]: "));
+    }
+
+    /**
+     * Returns {@code true} if the given property should be ignored in feature comparisons.
+     */
+    private boolean isIgnored(final PropertyType property) {
+        return ignoredProperties.contains(property.getName().toString());
+    }
+
+    /**
+     * Searches for a property of the given name in the given list and remove the property from that list.
+     * If the property is not found, then this method fails.
+     */
+    private PropertyType findAndRemove(final Collection<PropertyType> properties, final GenericName name) {
+        final Iterator<PropertyType> it = properties.iterator();
+        while (it.hasNext()) {
+            final PropertyType pt = it.next();
+            if (pt.getName().equals(name)) {
+                it.remove();
+                return pt;
+            }
+        }
+        fail(path() + "Property not found for name " + name);
+        return null;
+    }
+
+    /**
+     * Returns the given value as a collection. If the value is null, an empty collection is returned.
+     * If the value is already a collection, then it is returned as-is. Otherwise the value is wrapped
+     * in a singleton collection.
+     */
+    private static Collection<?> asCollection(final Object value) {
+        if (value instanceof Collection<?>) {
+            return (Collection<?>) value;
+        } else {
+            return CollectionsExt.singletonOrEmpty(value);
+        }
+    }
+}
diff --git a/core/sis-feature/src/test/java/org/apache/sis/test/feature/package-info.java b/core/sis-feature/src/test/java/org/apache/sis/test/feature/package-info.java
new file mode 100644
index 0000000..01c55ca
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/test/feature/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Utility methods for testing features.
+ *
+ * <p>Objects defined in this package are only for SIS testing purpose any many change
+ * in any future version without notice.</p>
+ *
+ * @author  Johann Sorel (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+package org.apache.sis.test.feature;