sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] 02/02: Change EnvelopeOperation.serialVersionUID since the meaning of null values in `attributeToCRS` changed. Since we broke serialization compatibility anyway, opportunistically rename `crs` as `targetCRS` for making clearer that this is not necessarily the CRS of geometry objects stored in properties.
Date Tue, 02 Jun 2020 11:03:21 GMT
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 a9ecd8b21360cd2e004594d5f58f395ca1979432
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Tue Jun 2 12:30:03 2020 +0200

    Change EnvelopeOperation.serialVersionUID since the meaning of null values in `attributeToCRS` changed.
    Since we broke serialization compatibility anyway, opportunistically rename `crs` as `targetCRS`
    for making clearer that this is not necessarily the CRS of geometry objects stored in properties.
    
    Then change the approach about how to get the CRS. The previous approach (search early in attribute characteristics)
    was designed before a CRS can be associated directly to a GeometryWrapper. But we more recent Apache SIS code state,
    there is better chance that `Envelope.getCoordinateReferenceSystem()` returns a non-null value, so we should wait a
    bit more before to invoke `Feature.getProperty(…)` instead of `Feature.getPropertyValue(…)`.
---
 .../org/apache/sis/feature/EnvelopeOperation.java  | 198 +++++++----
 ...ception.java => FeatureOperationException.java} |  45 +--
 .../sis/feature/InvalidFeatureException.java       |   6 +-
 .../org/apache/sis/internal/feature/Resources.java |  36 +-
 .../sis/internal/feature/Resources.properties      |   1 +
 .../sis/internal/feature/Resources_fr.properties   |   1 +
 .../apache/sis/feature/EnvelopeOperationTest.java  | 396 +++++++++------------
 .../apache/sis/feature/FeatureOperationsTest.java  | 177 +++++++++
 .../apache/sis/test/suite/FeatureTestSuite.java    |   1 +
 9 files changed, 535 insertions(+), 326 deletions(-)

diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/EnvelopeOperation.java b/core/sis-feature/src/main/java/org/apache/sis/feature/EnvelopeOperation.java
index ad14ad0..ed7703e 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/EnvelopeOperation.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/EnvelopeOperation.java
@@ -22,7 +22,6 @@ import java.util.Map;
 import java.util.LinkedHashMap;
 import java.util.Objects;
 import java.util.Optional;
-import org.apache.sis.util.Utilities;
 import org.opengis.util.GenericName;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.Envelope;
@@ -37,6 +36,7 @@ import org.apache.sis.internal.feature.Geometries;
 import org.apache.sis.internal.util.CollectionsExt;
 import org.apache.sis.geometry.Envelopes;
 import org.apache.sis.geometry.GeneralEnvelope;
+import org.apache.sis.internal.feature.Resources;
 import org.apache.sis.referencing.CRS;
 import org.apache.sis.util.resources.Errors;
 
@@ -69,7 +69,8 @@ import org.opengis.feature.PropertyType;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.7
+ * @author  Alexis Manin (Geomatys)
+ * @version 1.1
  * @since   0.7
  * @module
  */
@@ -77,7 +78,7 @@ final class EnvelopeOperation extends AbstractOperation {
     /**
      * For cross-version compatibility.
      */
-    private static final long serialVersionUID = 6250548001562807671L;
+    private static final long serialVersionUID = 8034615858550405350L;
 
     /**
      * The parameter descriptor for the "Envelope" operation, which does not take any parameter.
@@ -92,17 +93,26 @@ final class EnvelopeOperation extends AbstractOperation {
     /**
      * The coordinate reference system of the envelope to compute, or {@code null}
      * for using the CRS of the default geometry or the first non-empty geometry.
+     * Note that this is the CRS desired by user of this {@link EnvelopeOperation};
+     * it may be unrelated to the CRS of stored geometries.
      */
-    final CoordinateReferenceSystem crs;
+    final CoordinateReferenceSystem targetCRS;
 
     /**
      * The coordinate conversions or transformations from the CRS used by the geometries to the CRS requested
      * by the user, or {@code null} if there is no operation to apply.  If non-null, the length of this array
      * shall be equal to the length of the {@link #attributeNames} array and element at index <var>i</var> is
-     * the operation from the {@code attributeNames[i]} geometry CRS to the {@link #crs}.
+     * the operation from the {@code attributeNames[i]} geometry CRS to the {@link #targetCRS}. It may be the
+     * identity operation, and may also be {@code null} if the property at index <var>i</var> does not declare
+     * a default CRS.
      *
-     * <p>This array contains null element when the {@code MathTransform} associated to the coordinate operation
-     * is the identity transform.</p>
+     * <p><b>Performance note:</b>
+     * if this array is {@code null}, then {@link Feature#getProperty(String)} does not need to be invoked at all.
+     * A null array is a signal that invoking only the cheaper {@link Feature#getPropertyValue(String)} method is
+     * sufficient. However this array become non-null as soon as there is at least one CRS characteristic to check.
+     * We do not distinguish which particular property may have a CRS characteristic because as of Apache SIS 1.0,
+     * implementations of {@link DenseFeature} and {@link SparseFeature} have a "all of nothing" behavior anyway.
+     * So there is no performance gain to expect from a fine-grained knowledge of which properties declare a CRS.</p>
      */
     private final CoordinateOperation[] attributeToCRS;
 
@@ -120,10 +130,10 @@ final class EnvelopeOperation extends AbstractOperation {
      * Creates a new operation computing the envelope of features of the given type.
      *
      * @param identification      the name and other information to be given to this operation.
-     * @param crs                 the coordinate reference system of envelopes to computes, or {@code null}.
+     * @param targetCRS           the coordinate reference system of envelopes to computes, or {@code null}.
      * @param geometryAttributes  the operation or attribute type from which to get geometry values.
      */
-    EnvelopeOperation(final Map<String,?> identification, CoordinateReferenceSystem crs,
+    EnvelopeOperation(final Map<String,?> identification, CoordinateReferenceSystem targetCRS,
             final PropertyType[] geometryAttributes) throws FactoryException
     {
         super(identification);
@@ -158,8 +168,8 @@ final class EnvelopeOperation extends AbstractOperation {
                 final AttributeType<?> ct = at.get().characteristics().get(characteristicName);
                 if (ct != null && CoordinateReferenceSystem.class.isAssignableFrom(ct.getValueClass())) {
                     attributeCRS = (CoordinateReferenceSystem) ct.getDefaultValue();              // May still null.
-                    if (crs == null && isDefault) {
-                        crs = attributeCRS;
+                    if (targetCRS == null && isDefault) {
+                        targetCRS = attributeCRS;
                     }
                     characterizedByCRS = true;
                 }
@@ -188,17 +198,22 @@ final class EnvelopeOperation extends AbstractOperation {
             if (characterizedByCRS) {
                 final CoordinateReferenceSystem value = entry.getValue();
                 if (value != null) {
-                    if (crs == null) {
-                        crs = value;                                    // Fallback if default geometry has no CRS.
+                    if (targetCRS == null) {
+                        targetCRS = value;                  // Fallback if default geometry has no CRS.
                     }
-                    // even in case of identity operation, we keep it to be able to fetch characteristic CRS of the attribute.
-                    attributeToCRS[i] = CRS.findOperation(value, crs, null);
+                    /*
+                     * The following operation is often identity. We do not filter identity operations
+                     * because their source CRS is still a useful information (it is the CRS instance
+                     * found in the attribute characteristic, not necessarily identical to `targetCRS`)
+                     * and because we keep the null value for meaning that attribute CRS is unspecified.
+                     */
+                    attributeToCRS[i] = CRS.findOperation(value, targetCRS, null);
                 }
             }
         }
         resultType = FeatureOperations.POOL.unique(new DefaultAttributeType<>(
                 resultIdentification(identification), Envelope.class, 1, 1, null));
-        this.crs = crs;
+        this.targetCRS = targetCRS;
     }
 
     /**
@@ -280,73 +295,112 @@ final class EnvelopeOperation extends AbstractOperation {
          *
          * @return the union of envelopes of all geometries in the attribute specified to the constructor,
          *         or {@code null} if none.
+         * @throws FeatureOperationException if the envelope can not be computed.
          */
         @Override
-        public Envelope getValue() throws IllegalStateException {
+        public Envelope getValue() throws FeatureOperationException {
             final String[] attributeNames = EnvelopeOperation.this.attributeNames;
-            GeneralEnvelope envelope = null;                                        // Union of all envelopes.
-            for (int i=0; i<attributeNames.length; i++) {
-                GeneralEnvelope genv;                                               // Envelope of a single geometry.
-                final String name = attributeNames[i];
-                if (attributeToCRS == null) {
-                    /*
-                     * If there is no CRS characteristic on any of the properties to query, then invoke the
-                     * Feature.getPropertyValue(String) method instead than Feature.getProperty(String) in
-                     * order to avoid forcing DenseFeature and SparseFeature implementations to wrap the
-                     * property values into real property instances. This is an optimization for reducing
-                     * the amount of objects to create.
-                     */
-                    genv = Geometries.getEnvelope(feature.getPropertyValue(name)).orElse(null);
-                    if (genv == null) continue;
-                } else {
-                    /*
-                     * If there is at least one CRS characteristic to query, then we need the full Property instance.
-                     * We do not distinguish which particular property may have a CRS characteristic because SIS 0.7
-                     * implementations of DenseFeature and SparseFeature have a "all of nothing" behavior anyway.
-                     */
-                    final Property property = feature.getProperty(name);
-                    genv = Geometries.getEnvelope(property.getValue()).orElse(null);
-                    if (genv == null) continue;
-                    /*
-                     * Get the CRS characteristic if present. Most of the time, 'at' will be null and we will
-                     * fallback on the 'attributeToCRS' operations computed at construction time. In the rare
-                     * cases where a CRS characteristic is associated to a particular feature, we will let
-                     * Envelopes.transform(…) searches a coordinate operation.
-                     */
-                    final Attribute<?> at = ((Attribute<?>) property).characteristics()
-                                    .get(AttributeConvention.CRS_CHARACTERISTIC.toString());
-                    try {
-                        if (at == null) {
-                            final CoordinateOperation op = attributeToCRS[i];
-                            if (op != null) {
-                                // Ensure attribute envelope has a CRS by forcing CRS found as characteristic
-                                if (op.getMathTransform().isIdentity() && op.getSourceCRS() != null) genv.setCoordinateReferenceSystem(op.getSourceCRS());
-                                else genv = Envelopes.transform(op, genv);
-                            }
-                        } else {                                                        // Should be a rare case.
-                            final Object geomCRS = at.getValue();
+            GeneralEnvelope   envelope = null;                  // Union of all envelopes.
+            GeneralEnvelope[] deferred = null;                  // Envelopes not yet included in union envelope.
+            boolean hasUnknownCRS = false;                      // Whether at least one geometry has no known CRS.
+            for (int i = 0; i < attributeNames.length; i++) {
+                /*
+                 * Call `Feature.getPropertyValue(…)` instead of `Feature.getProperty(…).getValue()`
+                 * in order to avoid forcing DenseFeature and SparseFeature implementations to wrap
+                 * the property values into new `Property` objects.  The potentially costly call to
+                 * `Feature.getProperty(…)` can be avoided in two scenarios:
+                 *
+                 *   - The constructor determined that no attribute should have CRS characteristics.
+                 *     This scenario is identified by (attributeToCRS == null).
+                 *
+                 *   - The geometry already declares its CRS, in which case that CRS has precedence
+                 *     over attribute characteristics, so we don't need to fetch them.
+                 *
+                 * Inconvenient is that in a third scenario (CRS is defined by attribute characteristics),
+                 * we will do two calls to some `Feature.getProperty…` method, which results in two lookups
+                 * in hash table. We presume that the gain from the optimistic assumption is worth the cost.
+                 */
+                GeneralEnvelope genv = Geometries.getEnvelope(feature.getPropertyValue(attributeNames[i])).orElse(null);
+                if (genv == null) {
+                    continue;
+                }
+                /*
+                 * Get the CRS either directly from the geometry or indirectly from property characteristic.
+                 * The CRS associated with the geometry will be kept consistent with `sourceCRS` and will be
+                 * null only if no CRS has been found anywhere.  Note that `sourceCRS` may be different than
+                 * `op.getSourceCRS()`. This difference will be handled by `Envelopes.transform(…)` later.
+                 */
+                CoordinateReferenceSystem sourceCRS = genv.getCoordinateReferenceSystem();
+                CoordinateOperation op = null;
+                if (attributeToCRS != null) {
+                    op = attributeToCRS[i];
+                    if (sourceCRS == null) {
+                        /*
+                         * Try to get CRS from property characteristic. Usually `at` is null and we fallback
+                         * on the coordinate operation computed at construction time. In the rare case where
+                         * a CRS characteristic is associated to a particular feature, setting `op` to null
+                         * will cause a new coordinate operation to be searched.
+                         */
+                        final Attribute<?> at = ((Attribute<?>) feature.getProperty(attributeNames[i]))
+                                .characteristics().get(AttributeConvention.CRS_CHARACTERISTIC.toString());
+                        final Object geomCRS;
+                        if (at != null && (geomCRS = at.getValue()) != null) {
                             if (!(geomCRS instanceof CoordinateReferenceSystem)) {
-                                throw new IllegalStateException(Errors.format(Errors.Keys.UnspecifiedCRS));
+                                throw new FeatureOperationException(Resources.formatInternational(
+                                        Resources.Keys.IllegalCharacteristicsType_3,
+                                        AttributeConvention.CRS_CHARACTERISTIC,
+                                        CoordinateReferenceSystem.class,
+                                        geomCRS.getClass()));
                             }
-                            genv.setCoordinateReferenceSystem((CoordinateReferenceSystem) geomCRS);
-                            genv = GeneralEnvelope.castOrCopy(Envelopes.transform(genv, crs));
+                            sourceCRS = (CoordinateReferenceSystem) geomCRS;
+                        } else if (op != null) {
+                            sourceCRS = op.getSourceCRS();
                         }
-                    } catch (TransformException e) {
-                        throw new IllegalStateException(Errors.format(Errors.Keys.CanNotTransformEnvelope), e);
+                        genv.setCoordinateReferenceSystem(sourceCRS);
                     }
                 }
-
-                /* Add current attribute envelope in result one. For now, we only allow union of envelopes in the same
-                 * crs because we were not able to deduce output space from feature type characteristics. However, in
-                 * the future, we could find a common space to use.
+                /*
+                 * If the geometry CRS is unknown (sourceCRS == null), leave the geometry CRS to null.
+                 * Do not set it to `targetCRS` because that value is the desired CRS, not necessarily
+                 * the actual CRS.
                  */
+                if (sourceCRS != null && targetCRS != null) try {
+                    if (op == null) {
+                        op = CRS.findOperation(sourceCRS, targetCRS, null);
+                    }
+                    if (!op.getMathTransform().isIdentity()) {
+                        genv = Envelopes.transform(op, genv);
+                    }
+                } catch (FactoryException | TransformException e) {
+                    throw new FeatureOperationException(Errors.formatInternational(Errors.Keys.CanNotTransformEnvelope), e);
+                }
+                /*
+                 * If there is only one geometry, we will return that geometry as-is even if its CRS is unknown.
+                 * It will be up to the user to decide what to do with that. Otherwise (two or more geometries)
+                 * we throw an exception if a CRS is unknown, because we don't know how to combine them.
+                 */
+                hasUnknownCRS |= (sourceCRS == null);
                 if (envelope == null) {
                     envelope = genv;
-                } else if (Utilities.equalsIgnoreMetadata(genv.getCoordinateReferenceSystem(), envelope.getCoordinateReferenceSystem())) {
+                } else if (hasUnknownCRS) {
+                    throw new FeatureOperationException(Errors.formatInternational(Errors.Keys.UnspecifiedCRS));
+                } else if (targetCRS != null) {
                     envelope.add(genv);
-                } else throw new IllegalStateException(Errors.format(Errors.Keys.MismatchedCRS));
+                } else {
+                    if (deferred == null) {
+                        deferred = new GeneralEnvelope[attributeNames.length];
+                        deferred[0] = envelope;
+                    }
+                    deferred[i] = genv;
+                }
+            }
+            if (deferred == null) {
+                return envelope;
+            } else try {
+                return Envelopes.union(deferred);
+            } catch (TransformException e) {
+                throw new FeatureOperationException(Errors.formatInternational(Errors.Keys.CanNotTransformEnvelope), e);
             }
-            return envelope;
         }
 
         /**
@@ -372,11 +426,11 @@ final class EnvelopeOperation extends AbstractOperation {
     @Override
     public boolean equals(final Object obj) {
         if (super.equals(obj)) {
-            // 'this.result' is compared (indirectly) by the super class.
+            // `this.result` is compared (indirectly) by the super class.
             final EnvelopeOperation that = (EnvelopeOperation) obj;
             return Arrays.equals(attributeNames, that.attributeNames) &&
                    Arrays.equals(attributeToCRS, that.attributeToCRS) &&
-                   Objects.equals(crs, that.crs);
+                   Objects.equals(targetCRS,     that.targetCRS);
         }
         return false;
     }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/InvalidFeatureException.java b/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperationException.java
similarity index 56%
copy from core/sis-feature/src/main/java/org/apache/sis/feature/InvalidFeatureException.java
copy to core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperationException.java
index 6f2b9e8..fd9fcba 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/InvalidFeatureException.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperationException.java
@@ -16,52 +16,55 @@
  */
 package org.apache.sis.feature;
 
-import org.opengis.util.InternationalString;
 import org.apache.sis.util.LocalizedException;
-
-// Branch-dependent imports
-import org.opengis.feature.Feature;
-import org.opengis.feature.InvalidPropertyValueException;
+import org.opengis.util.InternationalString;
 
 
 /**
- * Thrown when a feature fails at least one conformance test.
- *
- * <div class="note"><b>Note:</b>
- * this exception extends {@link InvalidPropertyValueException} because an Apache SIS feature
- * can be invalid only if a property is invalid.</div>
+ * Thrown when a property value can not be computed.
+ * This exception may occur during a call to {@link AbstractAttribute#getValue()} on an attribute
+ * instance which computes its value dynamically instead than returning a stored value.
+ * It may be for example the attributes produced by {@link FeatureOperations}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
- *
- * @see Features#validate(Feature)
- *
- * @since 0.7
+ * @version 1.1
+ * @since   1.1
  * @module
  */
-final class InvalidFeatureException extends InvalidPropertyValueException implements LocalizedException {
+final class FeatureOperationException extends IllegalStateException implements LocalizedException {
     /**
      * For cross-version compatibility.
      */
-    private static final long serialVersionUID = 7288810679876346027L;
+    private static final long serialVersionUID = -7281160433831489357L;
 
     /**
-     * A description of the negative conformance result.
+     * A description of the computation error.
      */
     private final InternationalString message;
 
     /**
      * Creates a new exception with the given explanation message.
      *
-     * @param message  a description of the negative conformance result.
+     * @param message  a description of the computation error.
      */
-    InvalidFeatureException(final InternationalString message) {
+    FeatureOperationException(final InternationalString message) {
         super(message.toString());
         this.message = message;
     }
 
     /**
-     * Return the message in various locales.
+     * Creates a new exception with the given explanation message and cause.
+     *
+     * @param message  a description of the computation error.
+     * @param cause    the cause of the error.
+     */
+    FeatureOperationException(final InternationalString message, final Exception cause) {
+        super(message.toString(), cause);
+        this.message = message;
+    }
+
+    /**
+     * Returns the message in various locales.
      *
      * @return the exception message.
      */
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/InvalidFeatureException.java b/core/sis-feature/src/main/java/org/apache/sis/feature/InvalidFeatureException.java
index 6f2b9e8..211a3c8 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/InvalidFeatureException.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/InvalidFeatureException.java
@@ -46,14 +46,14 @@ final class InvalidFeatureException extends InvalidPropertyValueException implem
     private static final long serialVersionUID = 7288810679876346027L;
 
     /**
-     * A description of the negative conformance result.
+     * A description of the illegal feature.
      */
     private final InternationalString message;
 
     /**
      * Creates a new exception with the given explanation message.
      *
-     * @param message  a description of the negative conformance result.
+     * @param message  a description of the illegal feature.
      */
     InvalidFeatureException(final InternationalString message) {
         super(message.toString());
@@ -61,7 +61,7 @@ final class InvalidFeatureException extends InvalidPropertyValueException implem
     }
 
     /**
-     * Return the message in various locales.
+     * Returns the message in various locales.
      *
      * @return the exception message.
      */
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java
index 5a0b823..f4822a7 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java
@@ -20,6 +20,8 @@ import java.net.URL;
 import java.util.Map;
 import java.util.Locale;
 import java.util.MissingResourceException;
+import org.opengis.util.InternationalString;
+import org.apache.sis.util.resources.ResourceInternationalString;
 import org.apache.sis.util.resources.KeyConstants;
 import org.apache.sis.util.resources.IndexedResourceBundle;
 
@@ -30,7 +32,7 @@ import org.apache.sis.util.resources.IndexedResourceBundle;
  * all modules in the Apache SIS project, see {@link org.apache.sis.util.resources} package.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 0.8
+ * @version 1.1
  * @since   0.8
  * @module
  */
@@ -180,6 +182,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short IllegalCategoryRange_2 = 31;
 
         /**
+         * Expected an instance of ‘{1}’ for the “{0}” characteristics, but got an instance of ‘{2}’.
+         */
+        public static final short IllegalCharacteristicsType_3 = 75;
+
+        /**
          * Association “{0}” does not accept features of type ‘{2}’. Expected an instance of ‘{1}’ or
          * derived type.
          */
@@ -564,4 +571,31 @@ public final class Resources extends IndexedResourceBundle {
     {
         return forLocale(null).getString(key, arg0, arg1, arg2, arg3);
     }
+
+    /**
+     * The international string to be returned by {@link formatInternational}.
+     */
+    private static final class International extends ResourceInternationalString {
+        private static final long serialVersionUID = -667435900917846518L;
+
+        International(short key)                           {super(key);}
+        International(short key, Object args)              {super(key, args);}
+        @Override protected KeyConstants getKeyConstants() {return Keys.INSTANCE;}
+        @Override protected IndexedResourceBundle getBundle(final Locale locale) {
+            return forLocale(locale);
+        }
+    }
+
+    /**
+     * Gets an international string for the given key. This method does not check for the key
+     * validity. If the key is invalid, then a {@link MissingResourceException} may be thrown
+     * when a {@link InternationalString#toString(Locale)} method is invoked.
+     *
+     * @param  key   the key for the desired string.
+     * @param  args  values to substitute to "{0}", "{1}", <i>etc</i>.
+     * @return an international string for the given key.
+     */
+    public static InternationalString formatInternational(final short key, final Object... args) {
+        return new International(key, args);
+    }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties
index fa83b8a..27de5d3 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties
@@ -43,6 +43,7 @@ GridCoordinateOutsideCoverage_4   = Indices ({3}) are outside grid coverage. The
 GridEnvelopeMustBeNDimensional_1  = The grid envelope must have at least {0} dimensions.
 GridEnvelopeOutsideCoverage_5     = Envelope is outside grid coverage. Indices [{3,number} \u2026 {4,number}] in dimension {0} do not intersect the [{1,number} \u2026 {2,number}] grid extent.
 IllegalCategoryRange_2            = Sample value range {1} for \u201c{0}\u201d category is illegal.
+IllegalCharacteristicsType_3      = Expected an instance of \u2018{1}\u2019 for the \u201c{0}\u201d characteristics, but got an instance of \u2018{2}\u2019.
 IllegalFeatureType_3              = Association \u201c{0}\u201d does not accept features of type \u2018{2}\u2019. Expected an instance of \u2018{1}\u2019 or derived type.
 IllegalGridEnvelope_3             = Illegal grid envelope [{1,number} \u2026 {2,number}] for dimension {0}.
 IllegalGridGeometryComponent_1    = Can not create a grid geometry with the given \u201c{0}\u201d component.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties
index 0e78e35..0d0972e 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties
@@ -48,6 +48,7 @@ GridCoordinateOutsideCoverage_4   = Les indices ({3}) sont en dehors du domaine
 GridEnvelopeMustBeNDimensional_1  = L\u2019enveloppe de la grille doit avoir au moins {0} dimensions.
 GridEnvelopeOutsideCoverage_5     = L\u2019enveloppe est en dehors du domaine de la grille. Les indices [{3,number} \u2026 {4,number}] dans la dimension {0} n\u2019interceptent pas l\u2019\u00e9tendue [{1,number} \u2026 {2,number}] de la grille.
 IllegalCategoryRange_2            = La plage de valeurs {1} pour la cat\u00e9gorie \u00ab\u202f{0}\u202f\u00bb est ill\u00e9gale.
+IllegalCharacteristicsType_3      = Une instance \u2018{1}\u2019 \u00e9tait attendue pour la caract\u00e9ristique \u00ab\u202f{0}\u202f\u00bb, mais la valeur donn\u00e9e est une instance de \u2018{2}\u2019.
 IllegalFeatureType_3              = L\u2019association \u00ab\u202f{0}\u202f\u00bb n\u2019accepte pas les entit\u00e9s de type \u2018{2}\u2019. Une instance de \u2018{1}\u2019 ou d\u2019un type d\u00e9riv\u00e9 \u00e9tait attendue.
 IllegalGridEnvelope_3             = La plage d\u2019index [{1,number} \u2026 {2,number}] de la dimension {0} n\u2019est pas valide.
 IllegalGridGeometryComponent_1    = Ne peut pas construire une g\u00e9om\u00e9trie de grille avec la composante \u00ab\u202f{0}\u202f\u00bb donn\u00e9e.
diff --git a/core/sis-feature/src/test/java/org/apache/sis/feature/EnvelopeOperationTest.java b/core/sis-feature/src/test/java/org/apache/sis/feature/EnvelopeOperationTest.java
index e0b9739..d58e018 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/feature/EnvelopeOperationTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/feature/EnvelopeOperationTest.java
@@ -16,313 +16,251 @@
  */
 package org.apache.sis.feature;
 
-import java.util.Arrays;
-import java.util.Map;
-import java.util.Collections;
 import com.esri.core.geometry.Point;
 import com.esri.core.geometry.Polyline;
-import com.esri.core.geometry.Polygon;
+import com.esri.core.geometry.Geometry;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.internal.feature.AttributeConvention;
+import org.apache.sis.referencing.crs.HardCodedCRS;
 import org.apache.sis.feature.builder.AttributeRole;
 import org.apache.sis.feature.builder.AttributeTypeBuilder;
-import org.apache.sis.feature.builder.CharacteristicTypeBuilder;
 import org.apache.sis.feature.builder.FeatureTypeBuilder;
+import org.apache.sis.geometry.Envelope2D;
 import org.apache.sis.internal.feature.Geometries;
 import org.apache.sis.internal.feature.GeometryWrapper;
-import org.apache.sis.referencing.CRS;
-import org.opengis.feature.Attribute;
-import org.opengis.feature.AttributeType;
-import org.opengis.feature.Feature;
-import org.opengis.feature.FeatureType;
-import org.opengis.geometry.Envelope;
-import org.opengis.metadata.extent.GeographicBoundingBox;
-import org.opengis.util.FactoryException;
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.internal.feature.AttributeConvention;
-import org.apache.sis.referencing.crs.HardCodedCRS;
-import org.apache.sis.geometry.GeneralEnvelope;
 
 // Test dependencies
-import org.apache.sis.test.DependsOnMethod;
-import org.apache.sis.test.DependsOn;
 import org.apache.sis.test.TestCase;
 import org.junit.Test;
 
 import static org.apache.sis.test.ReferencingAssert.*;
 
 // Branch-dependent imports
-import org.opengis.feature.PropertyType;
+import org.opengis.feature.Attribute;
+import org.opengis.feature.Feature;
+import org.opengis.feature.FeatureType;
 
 
 /**
  * Tests {@link EnvelopeOperation}.
+ * This test uses a feature with two geometric properties, named "g1" and "g2",
+ * optionally associated with a default CRS declared in attribute characteristics.
+ * This class tests different ways to declare the CRS and tests the case where the
+ * CRSs are not the same.
  *
  * @author  Johann Sorel (Geomatys)
- * @version 0.7
+ * @author  Alexis Manin (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
  * @since   0.7
  * @module
  */
-@DependsOn(LinkOperationTest.class)
 public final strictfp class EnvelopeOperationTest extends TestCase {
-
-    private static final AttributeType<CoordinateReferenceSystem> CRS_CHARACTERISTIC = new FeatureTypeBuilder()
-            .addAttribute(CoordinateReferenceSystem.class)
-            .setName(AttributeConvention.CRS_CHARACTERISTIC)
-            .setMinimumOccurs(0)
-            .build();
-
     /**
-     * Creates a feature type with a bounds operation.
-     * The feature contains the following properties:
+     * The description of a feature with two geometric properties. The properties are named "g1" and "g2"
+     * and may or may not have default CRS, depending which {@code initialize(…)} method is invoked.
      *
-     * <ul>
-     *   <li>{@code name} as a {@link String}</li>
-     *   <li>{@code classes} as a {@link Polygon}</li>
-     *   <li>{@code climbing wall} as a {@link Point}</li>
-     *   <li>{@code gymnasium} as a {@link Polygon}</li>
-     *   <li>{@code sis:geometry} as a link to the default geometry</li>
-     *   <li>{@code bounds} as the feature envelope attribute.</li>
-     * </ul>
-     *
-     * @param  defaultGeometry  1 for using "classes" as the default geometry, or 3 for "gymnasium".
-     * @return the feature for a school.
+     * @see #initialize()
+     * @see #initialize(CoordinateReferenceSystem, boolean, CoordinateReferenceSystem, boolean)
      */
-    private static DefaultFeatureType school(final int defaultGeometry) throws FactoryException {
-        final DefaultAttributeType<?> standardCRS = new DefaultAttributeType<>(
-                name(AttributeConvention.CRS_CHARACTERISTIC), CoordinateReferenceSystem.class, 1, 1, HardCodedCRS.WGS84_φλ);
-
-        final DefaultAttributeType<?> normalizedCRS = new DefaultAttributeType<>(
-                name(AttributeConvention.CRS_CHARACTERISTIC), CoordinateReferenceSystem.class, 1, 1, HardCodedCRS.WGS84);
-
-        final PropertyType[] attributes = {
-            new DefaultAttributeType<>(name("name"),          String.class,  1, 1, null),
-            new DefaultAttributeType<>(name("classes"),       Polygon.class, 1, 1, null, standardCRS),
-            new DefaultAttributeType<>(name("climbing wall"), Point.class,   1, 1, null, standardCRS),
-            new DefaultAttributeType<>(name("gymnasium"),     Polygon.class, 1, 1, null, normalizedCRS),
-            null,
-            null
-        };
-        attributes[4] = FeatureOperations.link(name(AttributeConvention.GEOMETRY_PROPERTY), attributes[defaultGeometry]);
-        attributes[5] = FeatureOperations.envelope(name("bounds"), null, attributes);
-        return new DefaultFeatureType(name("school"), false, null, attributes);
-    }
+    private FeatureType type;
 
     /**
-     * Creates a map of identification properties containing only an entry for the given name.
+     * The feature created by a test method. Saved for allowing additional checks or operations.
      */
-    private static Map<String,?> name(final Object name) {
-        return Collections.singletonMap(DefaultAttributeType.NAME_KEY, name);
-    }
+    private Feature feature;
 
     /**
-     * Tests the constructor. The set of attributes on which the operation depends shall include
-     * "classes", "climbing wall" and "gymnasium" but not "name" since the later does not contain
-     * a geometry. Furthermore the default CRS shall be {@code HardCodedCRS.WGS84}, not
-     * {@code HardCodedCRS.WGS84_φλ}, because this test uses "gymnasium" as the default geometry.
+     * Creates the feature type with two geometric properties without default CRS.
      *
-     * @throws FactoryException if an error occurred while searching for the coordinate operations.
+     * @see #initialize(CoordinateReferenceSystem, boolean, CoordinateReferenceSystem, boolean)
      */
-    @Test
-    public void testConstruction() throws FactoryException {
-        final PropertyType property = school(3).getProperty("bounds");
-        assertInstanceOf("bounds", EnvelopeOperation.class, property);
-        final EnvelopeOperation op = (EnvelopeOperation) property;
-        assertSame("crs", HardCodedCRS.WGS84, op.crs);
-        assertSetEquals(Arrays.asList("classes", "climbing wall", "gymnasium"), op.getDependencies());
+    private void initialize() {
+        initialize(null, false, null, false);
     }
 
     /**
-     * Implementation of the test methods.
+     * Creates a feature type containing two geometric properties in the specified CRSs, which may be null.
+     * They will be specified as default CRS of each property through property type CRS characteristic only
+     * if the corresponding {@code declareCRS} flag is true. The first geometry will be the default one.
+     *
+     * @param defaultCRS1        default CRS of first property (may be {@code null}).
+     * @param defaultCRS1        default CRS of second property (may be {@code null}).
+     * @param asCharacteristic1  whether to declare CRS 1 as a characteristic of first property.
+     * @param asCharacteristic2  whether to declare CRS 2 as a characteristic of second property.
      */
-    private static void run(final AbstractFeature feature) {
-        assertNull("Before a geometry is set", feature.getPropertyValue("bounds"));
-        GeneralEnvelope expected;
-
-        // Set one geometry
-        Polygon classes = new Polygon();
-        classes.startPath(10, 20);
-        classes.lineTo(10, 30);
-        classes.lineTo(15, 30);
-        classes.lineTo(15, 20);
-        feature.setPropertyValue("classes", classes);
-        expected = new GeneralEnvelope(HardCodedCRS.WGS84_φλ);
-        expected.setRange(0, 10, 15);
-        expected.setRange(1, 20, 30);
-        assertEnvelopeEquals(expected, (Envelope) feature.getPropertyValue("bounds"));
-
-        // Set second geometry
-        Point wall = new Point(18, 40);
-        feature.setPropertyValue("climbing wall", wall);
-        expected = new GeneralEnvelope(HardCodedCRS.WGS84_φλ);
-        expected.setRange(0, 10, 18);
-        expected.setRange(1, 20, 40);
-        assertEnvelopeEquals(expected, (Envelope) feature.getPropertyValue("bounds"));
-
-        // Set third geometry. This geometry has CRS axis order reversed.
-        Polygon gymnasium = new Polygon();
-        gymnasium.startPath(-5, -30);
-        gymnasium.lineTo(-6, -30);
-        gymnasium.lineTo(-6, -31);
-        gymnasium.lineTo(-5, -31);
-        feature.setPropertyValue("gymnasium", gymnasium);
-        expected = new GeneralEnvelope(HardCodedCRS.WGS84_φλ);
-        expected.setRange(0, -31, 18);
-        expected.setRange(1,  -6, 40);
-        assertEnvelopeEquals(expected, (Envelope) feature.getPropertyValue("bounds"));
+    private void initialize(final CoordinateReferenceSystem defaultCRS1, boolean asCharacteristic1,
+                            final CoordinateReferenceSystem defaultCRS2, boolean asCharacteristic2)
+    {
+        final FeatureTypeBuilder builder = new FeatureTypeBuilder().setName("test");
+        final AttributeTypeBuilder<?> g1 = builder.addAttribute(GeometryWrapper.class).setName("g1");
+        final AttributeTypeBuilder<?> g2 = builder.addAttribute(GeometryWrapper.class).setName("g2");
+        if (asCharacteristic1) g1.setCRS(defaultCRS1);
+        if (asCharacteristic2) g2.setCRS(defaultCRS2);
+        g1.addRole(AttributeRole.DEFAULT_GEOMETRY);
+        type = builder.build();
     }
 
     /**
-     * Tests a dense type with operations.
+     * Sets the two properties to arbitrary geometries in given CRS, then computes the envelope.
+     * The CRS are set directly on the geometry objects, not in the attribute characteristics.
+     * The two geometries are:
+     *
+     * <ul>
+     *   <li>A point at (4 7)</li>
+     *   <li>A polyline in envelope from lower corner (12 15) to upper corner (17 15).
+     * </ul>
      *
-     * @throws FactoryException if an error occurred while searching for the coordinate operations.
+     * @param  crs1  CRS to associate to the first geometry  (not on property characteristic, but geometry itself).
+     * @param  crs2  CRS to associate to the second geometry (not on property characteristic, but geometry itself).
+     * @return a non null envelope, result of the envelope operation.
      */
-    @Test
-    @DependsOnMethod("testConstruction")
-    public void testDenseFeature() throws FactoryException {
-        run(new DenseFeature(school(1)));
+    private Envelope compute(final CoordinateReferenceSystem crs1,
+                             final CoordinateReferenceSystem crs2)
+    {
+        return compute(crs1, false, crs2, false);
     }
 
     /**
-     * Tests a sparse feature type with operations.
+     * Sets the two properties to arbitrary geometries in given CRS, then computes the envelope.
+     * The CRS are set either on geometry objects or on the attribute characteristics, depending
+     * on the {@code asCharacteristic} flags.
      *
-     * @throws FactoryException if an error occurred while searching for the coordinate operations.
+     * @param  crs1  CRS to associate to the first geometry  (either directly or indirectly).
+     * @param  crs2  CRS to associate to the second geometry (either directly or indirectly).
+     * @return a non null envelope, result of the envelope operation.
      */
-    @Test
-    @DependsOnMethod("testConstruction")
-    public void testSparseFeature() throws FactoryException {
-        run(new SparseFeature(school(2)));
+    private Envelope compute(final CoordinateReferenceSystem crs1, final boolean asCharacteristic1,
+                             final CoordinateReferenceSystem crs2, final boolean asCharacteristic2)
+    {
+        feature = type.newInstance();
+        set("g1", crs1, asCharacteristic1, new Point(4, 7));
+        set("g2", crs2, asCharacteristic2, new Polyline(new Point(12, 15), new Point(17, 14)));
+        final Object result = feature.getPropertyValue("sis:envelope");
+        assertInstanceOf("sis:envelope", Envelope.class, result);
+        return (Envelope) result;
     }
 
     /**
-     * If no characteristic is defined on properties, but geometries define different ones, we should return an
-     * error, because it is an ambiguous case (Note: In the future, we could try to push them all in a
-     * {@link CRS#suggestCommonTarget(GeographicBoundingBox, CoordinateReferenceSystem...) common space}.
+     * Sets a geometric property value together with its CRS, either directly or indirectly through
+     * attribute characteristic.
+     *
+     * @param  propertyName      name of the property on which to set the CRS.
+     * @param  crs               the CRS to set on the geometry.
+     * @param  asCharacteristic  whether to associate the CRS as a characteristic or directly on the geometry.
+     * @param  geometry          the ESRI geometry value to store in the property.
      */
-    @Test
-    public void no_characteristic_but_different_geometry_crs() {
-        try {
-            final Envelope env = new CRSManagementUtil().test(HardCodedCRS.WGS84, HardCodedCRS.NTF);
-            fail("Ambiguity in CRS should have caused an error,  a value has been returned: "+env);
-        } catch (IllegalStateException e) {
-            // Expected behavior
+    private void set(final String propertyName, final CoordinateReferenceSystem crs,
+                     final boolean asCharacteristic, final Geometry geometry)
+    {
+        final GeometryWrapper<?> wrapper = Geometries.wrap(geometry).orElseThrow(
+                    () -> new IllegalStateException("Cannot load ESRI binding"));
+
+        if (asCharacteristic) {
+            @SuppressWarnings("unchecked")
+            final Attribute<GeometryWrapper<?>> property =
+                    (Attribute<GeometryWrapper<?>>) feature.getProperty(propertyName);
+            final Attribute<CoordinateReferenceSystem> crsCharacteristic = Features.cast(
+                    property.getType().characteristics().get(AttributeConvention.CRS_CHARACTERISTIC.toString()),
+                    CoordinateReferenceSystem.class).newInstance();
+            crsCharacteristic.setValue(crs);
+            property.characteristics().put(AttributeConvention.CRS_CHARACTERISTIC.toString(), crsCharacteristic);
+            property.setValue(wrapper);
+        } else {
+            wrapper.setCoordinateReferenceSystem(crs);
+            feature.setPropertyValue(propertyName, wrapper);
         }
     }
 
     /**
-     * When CRS is not in characteristics, but can be found on geometries, returned envelope should match it.
+     * Verifies that two geometries using the same CRS, without any CRS declared as the default one, can be combined.
+     * The CRS is not declared in characteristics but can be found on geometries, so returned envelope should use it.
+     * The expected envelope is {@code BOX(4 7, 17 15)}
      */
     @Test
     public void same_crs_on_geometries() {
-        final Envelope env = new CRSManagementUtil().test(HardCodedCRS.WGS84, HardCodedCRS.WGS84);
-        assertEquals(HardCodedCRS.WGS84, env.getCoordinateReferenceSystem());
+        initialize();
+        final Envelope result = compute(HardCodedCRS.WGS84, HardCodedCRS.WGS84);
+        final Envelope expected = new Envelope2D(HardCodedCRS.WGS84, 4, 7, 13, 8);
+        assertSame(HardCodedCRS.WGS84, result.getCoordinateReferenceSystem());
+        assertEnvelopeEquals(expected, result, STRICT);
     }
 
     /**
-     * When referencing is defined neither in characteristics nor on geometries, we should assume all geometries are
-     * expressed in the same space. Therefore, an envelope with no CRS should be returned.
+     * Verifies that two geometries using the same CRS, specified as characteristics, can be combined.
+     * This tests ensures that envelope CRS is the default one specified by property type characteristics.
      */
     @Test
-    public void no_crs_defined() {
-        Envelope env = new CRSManagementUtil().test(null, null);
-        assertNull(env.getCoordinateReferenceSystem());
+    public void same_crs_on_characteristic() {
+        initialize(HardCodedCRS.WGS84, true, HardCodedCRS.WGS84, true);
+        final Envelope result = compute(null, null);
+        final Envelope expected = new Envelope2D(HardCodedCRS.WGS84, 4, 7, 13, 8);
+        assertSame(HardCodedCRS.WGS84, result.getCoordinateReferenceSystem());
+        assertEnvelopeEquals(expected, result, STRICT);
     }
 
     /**
-     * Ensure that returned envelope CRS is the default one specified by property type characteristics if no geometry
-     * defines its CRS.
+     * Verifies that two geometries using different CRS, without any CRS declared as the default one,
+     * are combined using a common CRS. The difference between the two CRS is only a change of axis order.
+     * The expected envelope is {@code BOX(4 7, 15 17)} where the upper corner (15 17) was (17 15) in
+     * the original geometry (before the change of CRS has been applied).
      */
     @Test
-    public void feature_type_characteristic_defines_crs() {
-        final Envelope env = new CRSManagementUtil(HardCodedCRS.WGS84, false, HardCodedCRS.WGS84, false)
-                .test(null, null);
-        assertEquals(HardCodedCRS.WGS84, env.getCoordinateReferenceSystem());
+    public void different_crs_on_geometries() {
+        initialize();
+        final Envelope result = compute(HardCodedCRS.WGS84, HardCodedCRS.WGS84_φλ);
+        final Envelope expected = new Envelope2D(HardCodedCRS.WGS84, 4, 7, 11, 10);
+        assertSame(HardCodedCRS.WGS84, result.getCoordinateReferenceSystem());
+        assertEnvelopeEquals(expected, result, STRICT);
     }
 
+    /**
+     * Verifies that two geometries using different CRS, specified as characteristics, can be combined.
+     */
     @Test
-    public void feature_characteristic_define_crs() {
-        final CRSManagementUtil environment = new CRSManagementUtil(null, true, null, true);
-        Envelope env = environment
-                .test(HardCodedCRS.WGS84, true, HardCodedCRS.WGS84, true);
-        assertEquals(HardCodedCRS.WGS84, env.getCoordinateReferenceSystem());
+    public void different_crs_on_characteristic() {
+        initialize(null, true, null, true);
+        final Envelope result = compute(HardCodedCRS.WGS84, true, HardCodedCRS.WGS84, true);
+        final Envelope expected = new Envelope2D(HardCodedCRS.WGS84, 4, 7, 13, 8);
+        assertSame(HardCodedCRS.WGS84, result.getCoordinateReferenceSystem());
+        assertEnvelopeEquals(expected, result, STRICT);
+    }
 
+    /**
+     * Verifies attempts to compute envelope when geometries have unspecified CRS.
+     * If the feature has two or more geometries, the operation should fail because of ambiguity.
+     * If the feature has only one geometry, its envelope should be returned with a null CRS.
+     */
+    @Test
+    public void unspecified_crs() {
+        initialize();
         try {
-            env = environment.test(HardCodedCRS.WGS84, true, HardCodedCRS.NTF, true);
-            fail("Envelope should not be computed due to different CRS in geometries: "+env);
-        } catch (IllegalStateException e) {
-            // expected behavior
+            final Envelope result = compute(null, null);
+            fail("Should not combine envelopes without CRS, but a value has been returned: " + result);
+        } catch (FeatureOperationException e) {
+            // Expected behavior
+            assertNotNull(e.getMessage());
         }
+        feature.setPropertyValue("g2", null);
+        final Envelope result = (Envelope) feature.getPropertyValue("sis:envelope");
+        assertNull(result.getCoordinateReferenceSystem());
+        assertEnvelopeEquals(new Envelope2D(null, 4, 7, 0, 0), result, STRICT);
     }
 
-    private static class CRSManagementUtil {
-        final FeatureType type;
-
-        CRSManagementUtil() {
-            this(null, false, null, false);
-        }
-
-        /**
-         * Create a feature type containing two geometric fields. If given CRS are non null, they will be specified as
-         * default CRS of each field through property type CRS characteristic.
-         * @param defaultCrs1 Default CRS of first property
-         * @param forceCharacteristic1 True if we want a CRS characteristic even with a null CRS. False to omit
-         *                             characteristic i defaultCrs1 is null.
-         * @param defaultCrs2 Default CRS for second property
-         * @param forceCharacteristic2 True if we want a CRS characteristic even with a null CRS. False to omit
-         *                             characteristic i defaultCrs2 is null.
-         */
-        CRSManagementUtil(final CoordinateReferenceSystem defaultCrs1, boolean forceCharacteristic1, final CoordinateReferenceSystem defaultCrs2, boolean forceCharacteristic2) {
-            final FeatureTypeBuilder builder = new FeatureTypeBuilder().setName("test");
-            final AttributeTypeBuilder<GeometryWrapper> g1 = builder.addAttribute(GeometryWrapper.class).setName("g1");
-            if (defaultCrs1 != null || forceCharacteristic1) g1.setCRS(defaultCrs1);
-            g1.addRole(AttributeRole.DEFAULT_GEOMETRY);
-
-            final AttributeTypeBuilder<GeometryWrapper> g2 = builder.addAttribute(GeometryWrapper.class).setName("g2");
-            if (defaultCrs2 != null || forceCharacteristic2) g2.setCRS(defaultCrs2);
-
-            type = builder.build();
-        }
-
-        /**
-         * Compute the envelope of this feature, and ensure that lower/upper coordinates are well-defined.
-         * The result is returned, so user can check the coordinate reference system on it.
-         *
-         * @param c1 CRS to put on the first geometry (not on property characteristic, but geometry itself)
-         * @param c2 CRS to put on the second geometry (not on property characteristic, but geometry itself)
-         * @return A non null envelope, result of the envelope operation.
-         */
-        Envelope test(final CoordinateReferenceSystem c1, final CoordinateReferenceSystem c2) {
-            return test(c1, false, c2, false);
-        }
-
-        Envelope test(final CoordinateReferenceSystem c1, final boolean c1AsCharacteristic, final CoordinateReferenceSystem c2, final boolean c2AsCharacteristic) {
-            final GeometryWrapper g1 = Geometries.wrap(new Point(4, 4))
-                    .orElseThrow(() -> new IllegalStateException("Cannot load ESRI binding"));
-            final GeometryWrapper g2 = Geometries.wrap(new Polyline(new Point(2, 2), new Point(3, 3)))
-                    .orElseThrow(() -> new IllegalStateException("Cannot load ESRI binding"));
-
-            Feature f = type.newInstance();
-            set(f, "g1", g1, c1, c1AsCharacteristic);
-            set(f, "g2", g2, c2, c2AsCharacteristic);
-
-            Object result = f.getPropertyValue("sis:envelope");
-            assertNotNull(result);
-            assertTrue(result instanceof Envelope);
-            Envelope env = (Envelope) result;
-            assertArrayEquals(new double[]{2, 2}, env.getLowerCorner().getCoordinate(), 1e-4);
-            assertArrayEquals(new double[]{4, 4}, env.getUpperCorner().getCoordinate(), 1e-4);
-            return env;
-        }
-
-        private void set(final Feature target, final String propertyName, final GeometryWrapper geometry, final CoordinateReferenceSystem crs, final boolean asCharacteristic) {
-            if (asCharacteristic) {
-                final Attribute g1p = (Attribute) target.getProperty(propertyName);
-                final Attribute<CoordinateReferenceSystem> crsCharacteristic = CRS_CHARACTERISTIC.newInstance();
-                crsCharacteristic.setValue(crs);
-                g1p.characteristics().put(AttributeConvention.CRS_CHARACTERISTIC.toString(), crsCharacteristic);
-                g1p.setValue(geometry);
-            } else {
-                geometry.setCoordinateReferenceSystem(crs);
-                target.setPropertyValue(propertyName, geometry);
-            }
+    /**
+     * Verifies attempts to compute envelope when only one geometry has unspecified CRS.
+     * The operation should fail because of ambiguity.
+     */
+    @Test
+    public void partially_unspecified_crs() {
+        initialize();
+        try {
+            final Envelope result = compute(null, HardCodedCRS.WGS84);
+            fail("Ambiguity in CRS should have caused an error, but a value has been returned: " + result);
+        } catch (FeatureOperationException e) {
+            // Expected behavior
+            assertNotNull(e.getMessage());
         }
     }
 }
diff --git a/core/sis-feature/src/test/java/org/apache/sis/feature/FeatureOperationsTest.java b/core/sis-feature/src/test/java/org/apache/sis/feature/FeatureOperationsTest.java
new file mode 100644
index 0000000..1badbd3
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/feature/FeatureOperationsTest.java
@@ -0,0 +1,177 @@
+/*
+ * 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.Arrays;
+import java.util.Map;
+import java.util.Collections;
+import com.esri.core.geometry.Point;
+import com.esri.core.geometry.Polygon;
+import org.opengis.geometry.Envelope;
+import org.opengis.util.FactoryException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.internal.feature.AttributeConvention;
+import org.apache.sis.referencing.crs.HardCodedCRS;
+import org.apache.sis.geometry.GeneralEnvelope;
+
+// Test dependencies
+import org.apache.sis.test.DependsOnMethod;
+import org.apache.sis.test.DependsOn;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.apache.sis.test.ReferencingAssert.*;
+
+// Branch-dependent imports
+import org.opengis.feature.PropertyType;
+
+
+/**
+ * Tests a feature combining various {@link FeatureOperations} applied of either sparse or dense features.
+ * This is an integration test. For tests specific to a particular operation, see for example
+ * {@link LinkOperation} or {@link EnvelopeOperation}.
+ *
+ * @author  Johann Sorel (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   0.7
+ * @module
+ */
+@DependsOn({LinkOperationTest.class, EnvelopeOperationTest.class})
+public final strictfp class FeatureOperationsTest extends TestCase {
+    /**
+     * Creates a feature type with an envelope operation.
+     * The feature contains the following properties:
+     *
+     * <ul>
+     *   <li>{@code name} as a {@link String}</li>
+     *   <li>{@code classes} as a {@link Polygon}</li>
+     *   <li>{@code climbing wall} as a {@link Point}</li>
+     *   <li>{@code gymnasium} as a {@link Polygon}</li>
+     *   <li>{@code sis:geometry} as a link to the default geometry</li>
+     *   <li>{@code bounds} as the feature envelope attribute.</li>
+     * </ul>
+     *
+     * @param  defaultGeometry  1 for using "classes" as the default geometry, or 3 for "gymnasium".
+     * @return the feature for a school.
+     */
+    private static DefaultFeatureType school(final int defaultGeometry) throws FactoryException {
+        final DefaultAttributeType<?> standardCRS = new DefaultAttributeType<>(
+                name(AttributeConvention.CRS_CHARACTERISTIC), CoordinateReferenceSystem.class, 1, 1, HardCodedCRS.WGS84_φλ);
+
+        final DefaultAttributeType<?> normalizedCRS = new DefaultAttributeType<>(
+                name(AttributeConvention.CRS_CHARACTERISTIC), CoordinateReferenceSystem.class, 1, 1, HardCodedCRS.WGS84);
+
+        final PropertyType[] attributes = {
+            new DefaultAttributeType<>(name("name"),          String.class,  1, 1, null),
+            new DefaultAttributeType<>(name("classes"),       Polygon.class, 1, 1, null, standardCRS),
+            new DefaultAttributeType<>(name("climbing wall"), Point.class,   1, 1, null, standardCRS),
+            new DefaultAttributeType<>(name("gymnasium"),     Polygon.class, 1, 1, null, normalizedCRS),
+            null,
+            null
+        };
+        attributes[4] = FeatureOperations.link(name(AttributeConvention.GEOMETRY_PROPERTY), attributes[defaultGeometry]);
+        attributes[5] = FeatureOperations.envelope(name("bounds"), null, attributes);
+        return new DefaultFeatureType(name("school"), false, null, attributes);
+    }
+
+    /**
+     * Creates a map of identification properties containing only an entry for the given name.
+     */
+    private static Map<String,?> name(final Object name) {
+        return Collections.singletonMap(DefaultAttributeType.NAME_KEY, name);
+    }
+
+    /**
+     * Tests the constructor. The set of attributes on which the operation depends shall include
+     * "classes", "climbing wall" and "gymnasium" but not "name" since the later does not contain
+     * a geometry. Furthermore the default CRS shall be {@code HardCodedCRS.WGS84}, not
+     * {@code HardCodedCRS.WGS84_φλ}, because this test uses "gymnasium" as the default geometry.
+     *
+     * @throws FactoryException if an error occurred while searching for the coordinate operations.
+     */
+    @Test
+    public void testConstruction() throws FactoryException {
+        final PropertyType property = school(3).getProperty("bounds");
+        assertInstanceOf("bounds", EnvelopeOperation.class, property);
+        final EnvelopeOperation op = (EnvelopeOperation) property;
+        assertSame("targetCRS", HardCodedCRS.WGS84, op.targetCRS);
+        assertSetEquals(Arrays.asList("classes", "climbing wall", "gymnasium"), op.getDependencies());
+    }
+
+    /**
+     * Implementation of {@link #testDenseFeature()} and {@link #testSparseFeature()} methods.
+     */
+    private static void run(final AbstractFeature feature) {
+        assertNull("Before a geometry is set", feature.getPropertyValue("bounds"));
+        GeneralEnvelope expected;
+
+        // Set one geometry
+        Polygon classes = new Polygon();
+        classes.startPath(10, 20);
+        classes.lineTo(10, 30);
+        classes.lineTo(15, 30);
+        classes.lineTo(15, 20);
+        feature.setPropertyValue("classes", classes);
+        expected = new GeneralEnvelope(HardCodedCRS.WGS84_φλ);
+        expected.setRange(0, 10, 15);
+        expected.setRange(1, 20, 30);
+        assertEnvelopeEquals(expected, (Envelope) feature.getPropertyValue("bounds"));
+
+        // Set second geometry
+        Point wall = new Point(18, 40);
+        feature.setPropertyValue("climbing wall", wall);
+        expected = new GeneralEnvelope(HardCodedCRS.WGS84_φλ);
+        expected.setRange(0, 10, 18);
+        expected.setRange(1, 20, 40);
+        assertEnvelopeEquals(expected, (Envelope) feature.getPropertyValue("bounds"));
+
+        // Set third geometry. This geometry has CRS axis order reversed.
+        Polygon gymnasium = new Polygon();
+        gymnasium.startPath(-5, -30);
+        gymnasium.lineTo(-6, -30);
+        gymnasium.lineTo(-6, -31);
+        gymnasium.lineTo(-5, -31);
+        feature.setPropertyValue("gymnasium", gymnasium);
+        expected = new GeneralEnvelope(HardCodedCRS.WGS84_φλ);
+        expected.setRange(0, -31, 18);
+        expected.setRange(1,  -6, 40);
+        assertEnvelopeEquals(expected, (Envelope) feature.getPropertyValue("bounds"));
+    }
+
+    /**
+     * Tests a dense feature type with operations.
+     *
+     * @throws FactoryException if an error occurred while searching for the coordinate operations.
+     */
+    @Test
+    @DependsOnMethod("testConstruction")
+    public void testDenseFeature() throws FactoryException {
+        run(new DenseFeature(school(1)));
+    }
+
+    /**
+     * Tests a sparse feature type with operations.
+     *
+     * @throws FactoryException if an error occurred while searching for the coordinate operations.
+     */
+    @Test
+    @DependsOnMethod("testConstruction")
+    public void testSparseFeature() throws FactoryException {
+        run(new SparseFeature(school(2)));
+    }
+}
diff --git a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
index 527405e..6f9ab2c 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
@@ -47,6 +47,7 @@ import org.junit.runners.Suite;
     org.apache.sis.feature.LinkOperationTest.class,
     org.apache.sis.feature.StringJoinOperationTest.class,
     org.apache.sis.feature.EnvelopeOperationTest.class,
+    org.apache.sis.feature.FeatureOperationsTest.class,
     org.apache.sis.feature.FeatureFormatTest.class,
     org.apache.sis.feature.FeaturesTest.class,
     org.apache.sis.filter.CapabilitiesTest.class,


Mime
View raw message