Merge pull request #151 from apache/feature/UIMA-6336-Align-select-behaviors-of-uimaFITv2-with-uimaFITv3

[UIMA-6336] Align select behaviors of uimaFITv2 with uimaFITv3
diff --git a/uimafit-core/src/main/java/org/apache/uima/fit/util/CasUtil.java b/uimafit-core/src/main/java/org/apache/uima/fit/util/CasUtil.java
index c8f72b7..c2cbe36 100644
--- a/uimafit-core/src/main/java/org/apache/uima/fit/util/CasUtil.java
+++ b/uimafit-core/src/main/java/org/apache/uima/fit/util/CasUtil.java
@@ -1099,7 +1099,7 @@
       throw new IllegalArgumentException("Type [" + type.getName() + "] is not an annotation type");
     }
 
-    List<AnnotationFS> precedingAnnotations = new LinkedList<AnnotationFS>();
+    List<AnnotationFS> precedingAnnotations = new ArrayList<AnnotationFS>();
 
     // Seek annotation in index
     // withSnapshotIterators() not needed here since we copy the FSes to a list anyway    
@@ -1115,11 +1115,18 @@
     }
     
     int anchorBegin = anchor.getBegin();
-    int anchorEnd = anchor.getEnd();
 
-    // No need to do additional seeks here (as done in selectCovered) because the current method
-    // does not have to worry about type priorities - it never returns annotations that have
-    // the same offset as the reference annotation.
+    // Zero-width annotations are in the index *after* the wider annotations starting at the same
+    // location, but we would consider a zero-width annotation at the beginning of a larger
+    // reference annotation to be preceding the larger one. So we need to seek right
+    // for any relevant zero-with annotations.
+    while (itr.isValid() && itr.get().getBegin() == anchorBegin) {
+      itr.moveToNext();
+      if (!itr.isValid()) {
+        itr.moveToLast();
+        break;
+      }
+    }
     
     // make sure we're past the beginning of the reference annotation
     while (itr.isValid() && itr.get().getEnd() > anchorBegin) {
@@ -1133,11 +1140,11 @@
       int curEnd = cur.getEnd();
 
       if (
-              curEnd <= anchorBegin && 
-              (cur.getBegin() != curEnd || anchorBegin != curEnd) &&
-              (anchorBegin != anchorEnd || curEnd != anchorBegin)
+              (curEnd <= anchorBegin
+              || (cur.getBegin() == curEnd && curEnd == anchorBegin))
+              && cur != anchor
       ) {
-        precedingAnnotations.add(itr.get());
+        precedingAnnotations.add(cur);
         i++;
       }
     }
@@ -1154,14 +1161,14 @@
    *          a CAS.
    * @param type
    *          a UIMA type.
-   * @param annotation
+   * @param anchor
    *          anchor annotation
    * @param count
    *          number of annotations to collect
    * @return List of aType annotations following anchor annotation
    * @see <a href="package-summary.html#SortOrder">Order of selected feature structures</a>
    */
-  public static List<AnnotationFS> selectFollowing(CAS cas, Type type, AnnotationFS annotation,
+  public static List<AnnotationFS> selectFollowing(CAS cas, Type type, AnnotationFS anchor,
           int count) {
     if (!cas.getTypeSystem().subsumes(cas.getAnnotationType(), type)) {
       throw new IllegalArgumentException("Type [" + type.getName() + "] is not an annotation type");
@@ -1170,7 +1177,10 @@
     // Seek annotation in index
     // withSnapshotIterators() not needed here since we copy the FSes to a list anyway    
     FSIterator<AnnotationFS> itr = cas.getAnnotationIndex(type).iterator();
-    itr.moveTo(annotation);
+    itr.moveTo(anchor);
+    
+    int anchorBegin = anchor.getBegin();
+    int anchorEnd = anchor.getEnd();
 
     // When seeking forward, there is no need to check if the insertion point is beyond the
     // index. If it was, there would be nothing beyond it that could be found and returned.
@@ -1182,24 +1192,41 @@
     // does not have to worry about type priorities - it never returns annotations that have
     // the same offset as the reference annotation.
 
+    if (anchorBegin == anchorEnd) {
+      // zero-width annotations appear *after* larger annotations with the same start position in
+      // the index but the larger annotations are considered to be *following* the zero-width, so we
+      // have to look to the left for larger annotations...
+      if (itr.isValid()) {
+        itr.moveToPrevious();
+        while (itr.isValid() && itr.get().getBegin() == anchorBegin) {
+          itr.moveToPrevious();
+        }
+        
+        if (!itr.isValid()) {
+          itr.moveToFirst();
+        }
+        else {
+          itr.moveToNext();
+        }
+      }
+      else {
+        itr.moveToFirst();
+      }
+    }
+    
     // make sure we're past the end of the reference annotation
-    while (itr.isValid() && itr.get().getBegin() < annotation.getEnd()) {
+    while (itr.isValid() && itr.get().getBegin() < anchorEnd) {
       itr.moveToNext();
     }
 
     // add annotations from the iterator into the result list
-    int refEnd = annotation.getEnd();
-    List<AnnotationFS> followingAnnotations = new LinkedList<AnnotationFS>();
-    for (int i = 0; i < count && itr.isValid(); i++, itr.moveToNext()) {
-      AnnotationFS fs = itr.get();
-      int begin = fs.getBegin();
-      int end = fs.getEnd();
-      if (begin == end && refEnd == begin) {
-        // Skip zero-width annotation at the end of the reference annotation. These are considered
-        // to be "coveredBy" instead of following
-        continue;
+    List<AnnotationFS> followingAnnotations = new ArrayList<AnnotationFS>();
+    for (int i = 0; i < count && itr.isValid(); itr.moveToNext()) {
+      AnnotationFS cur = itr.get();
+      if (cur != anchor && cur.getBegin() >= anchorEnd) {
+        followingAnnotations.add(cur);
+        i ++;
       }
-      followingAnnotations.add(itr.get());
     }
     
     return followingAnnotations;
diff --git a/uimafit-core/src/test/java/org/apache/uima/fit/util/AnnotationPredicateTestData.java b/uimafit-core/src/test/java/org/apache/uima/fit/util/AnnotationPredicateTestData.java
new file mode 100644
index 0000000..12dd85e
--- /dev/null
+++ b/uimafit-core/src/test/java/org/apache/uima/fit/util/AnnotationPredicateTestData.java
@@ -0,0 +1,137 @@
+/*
+ * 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.uima.fit.util;
+
+import static java.lang.Integer.MAX_VALUE;
+import static java.util.Arrays.asList;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.BEGINNING_WITH;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.COLOCATED;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.COVERED_BY;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.COVERING;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.ENDING_WITH;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.FOLLOWING;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.OVERLAPPING;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.OVERLAPPING_AT_BEGIN;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.OVERLAPPING_AT_END;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.PRECEDING;
+
+import java.util.List;
+
+import org.apache.uima.fit.util.SelectionAssert.TestCase;
+
+public class AnnotationPredicateTestData {
+  public static enum RelativePosition {
+    COLOCATED,
+    OVERLAPPING,
+    OVERLAPPING_AT_BEGIN,
+    OVERLAPPING_AT_END,
+    COVERING,
+    COVERED_BY,
+    PRECEDING,
+    FOLLOWING,
+    BEGINNING_WITH,
+    ENDING_WITH
+  }
+  
+  // Used as fixed references for the annotation relation cases.
+  private static final int BEGIN = 10;
+  private static final int END = 20;
+  private static final int Z_POS = 10;
+
+  public static final TestCase T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13;
+  
+  public static final List<TestCase> NON_ZERO_WIDTH_TEST_CASES = asList(
+      T1 = new TestCase("1) Y begins and ends after X (### [---])", 
+          p -> p.apply(BEGIN, END, END + 1, MAX_VALUE),
+          asList(PRECEDING)),
+      T2 = new TestCase("2) Y begins at X's end and ends after X (###[---])", 
+          p -> p.apply(BEGIN, END, END, MAX_VALUE),
+          asList(PRECEDING)),
+      T3 = new TestCase("3) Y begins within and ends after X (##[#--])", 
+          p -> p.apply(BEGIN, END, END - 1 , MAX_VALUE),
+          asList(OVERLAPPING, OVERLAPPING_AT_BEGIN)),
+      T4 = new TestCase("4) Y begins and ends at X's boundries ([###])", 
+          p -> p.apply(BEGIN, END, BEGIN, END),
+          asList(OVERLAPPING, COLOCATED, COVERED_BY, COVERING, BEGINNING_WITH, ENDING_WITH)),
+      T5 = new TestCase("5) Y begins and ends within X (#[#]#)", 
+          p -> p.apply(BEGIN, END, BEGIN + 1, END - 1),
+          asList(OVERLAPPING, COVERING)),
+      T6 = new TestCase("6) Y begins at and ends before X's boundries ([##]#)", 
+          p -> p.apply(BEGIN, END, BEGIN, END - 1),
+          asList(OVERLAPPING, COVERING, BEGINNING_WITH, OVERLAPPING_AT_END)),
+      T7 = new TestCase("7) Y begins after and ends at X's boundries (#[##])", 
+          p -> p.apply(BEGIN, END, BEGIN + 1, END),
+          asList(OVERLAPPING, COVERING, ENDING_WITH, OVERLAPPING_AT_BEGIN)),
+      T8 = new TestCase("8) Y begins before and ends after X's boundries ([-###-])", 
+          p -> p.apply(BEGIN, END, BEGIN - 1, END + 1),
+          asList(OVERLAPPING, COVERED_BY)),
+      T9 = new TestCase("9) X starts where Y begins and ends within Y ([##-])", 
+          p -> p.apply(BEGIN, END, BEGIN, END + 1),
+          asList(OVERLAPPING, COVERED_BY, BEGINNING_WITH)),
+      T10 = new TestCase("10) X starts within Y and ends where Y ends ([-##])", 
+          p -> p.apply(BEGIN, END, BEGIN - 1, END),
+          asList(OVERLAPPING, COVERED_BY, ENDING_WITH)),
+      T11 = new TestCase("11) Y begins before and ends within X ([--#]##)", 
+          p -> p.apply(BEGIN, END, 0, BEGIN + 1),
+          asList(OVERLAPPING, OVERLAPPING_AT_END)),
+      T12 = new TestCase("12) Y begins before and ends where X begins ([---]###)", 
+          p -> p.apply(BEGIN, END, 0, BEGIN),
+          asList(FOLLOWING)),
+      T13 = new TestCase("13) Y begins and ends before X begins ([---] ###)", 
+          p -> p.apply(BEGIN, END, 0, BEGIN - 1),
+          asList(FOLLOWING)));
+
+  public static final TestCase TZ1, TZ2, TZ3, TZ4, TZ5, TZ6, TZ7, TZ8, TZ9, TZ10, TZ11;
+
+  public static final List<TestCase> ZERO_WIDTH_TEST_CASES = asList(
+      TZ1 = new TestCase("Z1) Zero-width X before Y start (# [---])", 
+          p -> p.apply(Z_POS, Z_POS, Z_POS + 10, Z_POS + 20),
+          asList(PRECEDING)),
+      TZ2 = new TestCase("Z2) Zero-width Y after X's end (### |)", 
+          p -> p.apply(BEGIN, END, END + 1, END + 1),
+          asList(PRECEDING)),
+      TZ3 = new TestCase("Z3) Zero-width X at Y's start (#---])", 
+          p -> p.apply(Z_POS, Z_POS, Z_POS, Z_POS + 10),
+          asList(PRECEDING, OVERLAPPING, COVERED_BY, BEGINNING_WITH)),
+      TZ4 = new TestCase("Z4) Zero-width X at Y's end ([---#)", 
+          p -> p.apply(Z_POS, Z_POS, Z_POS-10, Z_POS),
+          asList(FOLLOWING, OVERLAPPING, COVERED_BY, ENDING_WITH)),
+      TZ5 = new TestCase("Z5) Zero-width Y where X begins (|###)", 
+          p -> p.apply(BEGIN, END, BEGIN, BEGIN),
+          asList(FOLLOWING, OVERLAPPING, COVERING, BEGINNING_WITH)),
+      TZ6 = new TestCase("Z6) Zero-width Y within X (#|#)", 
+          p -> p.apply(BEGIN, END, BEGIN + 1, BEGIN + 1),
+          asList(OVERLAPPING, COVERING)),
+      TZ7 = new TestCase("Z7) Zero-width Y at X's end (###|)", 
+          p -> p.apply(BEGIN, END, END, END),
+          asList(PRECEDING, OVERLAPPING, COVERING, ENDING_WITH)),
+      TZ8 = new TestCase("Z8) Zero-width X with Y (-|-)", 
+          p -> p.apply(Z_POS, Z_POS, Z_POS - 5, Z_POS + 5),
+          asList(OVERLAPPING, COVERED_BY)),
+      TZ9 = new TestCase("Z9) Zero-width X after Y's end ([---] #)", 
+          p -> p.apply(Z_POS, Z_POS, Z_POS - 10, Z_POS - 5),
+          asList(FOLLOWING)),
+      TZ10 = new TestCase("Z10) Zero-width Y before X begins (| ###)", 
+          p -> p.apply(BEGIN, END, BEGIN - 1, BEGIN - 1),
+          asList(FOLLOWING)),
+      TZ11 = new TestCase("Z11) Zero-width X matches zero-width Y start/end (#)", 
+          p -> p.apply(Z_POS, Z_POS, Z_POS, Z_POS),
+          asList(FOLLOWING, PRECEDING, OVERLAPPING, COVERED_BY, COVERING, COLOCATED, BEGINNING_WITH, 
+              ENDING_WITH)));
+}
diff --git a/uimafit-core/src/test/java/org/apache/uima/fit/util/AnnotationPredicates.java b/uimafit-core/src/test/java/org/apache/uima/fit/util/AnnotationPredicates.java
new file mode 100644
index 0000000..d503799
--- /dev/null
+++ b/uimafit-core/src/test/java/org/apache/uima/fit/util/AnnotationPredicates.java
@@ -0,0 +1,234 @@
+/*
+ * 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.uima.fit.util;
+
+import org.apache.uima.cas.text.AnnotationFS;
+
+/**
+ * This class is back-ported for testing only from UIMAv3. It is used to check that the behavior
+ * of uimaFIT v2 and uimaFITv2 with respect to the select utilities is aligned.
+ */
+final class AnnotationPredicates {
+  public static boolean coveredBy(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aYBegin <= aXBegin && aXEnd <= aYEnd;
+  }
+
+  public static boolean coveredBy(AnnotationFS aX, int aYBegin, int aYEnd) {
+    return aYBegin <= aX.getBegin() && aX.getEnd() <= aYEnd;
+  }
+
+  /**
+   * Y is starting before or at the same position as A and ends after or at the
+   * same position as X.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X is covered by Y.
+   */
+  public static boolean coveredBy(AnnotationFS aX, AnnotationFS aY) {
+    return aY.getBegin() <= aX.getBegin() && aX.getEnd() <= aY.getEnd();
+  }
+
+  public static boolean covering(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aXBegin <= aYBegin && aYEnd <= aXEnd;
+  }
+
+  public static boolean covering(AnnotationFS aX, int aYBegin, int aYEnd) {
+    return aX.getBegin() <= aYBegin && aYEnd <= aX.getEnd();
+  }
+
+  /**
+   * X is starting before or at the same position as Y and ends after or at the
+   * same position as Y.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X is covering Y.
+   */
+  public static boolean covering(AnnotationFS aX, AnnotationFS aY) {
+    return aX.getBegin() <= aY.getBegin() && aY.getEnd() <= aX.getEnd();
+  }
+
+  public static boolean colocated(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aXBegin == aYBegin && aXEnd == aYEnd;
+  }
+
+  public static boolean colocated(AnnotationFS aX, int aYBegin, int aYEnd) {
+    return aX.getBegin() == aYBegin && aX.getEnd() == aYEnd;
+  }
+
+  /**
+   * X starts and ends at the same position as Y.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X is at the same location as Y.
+   */
+  public static boolean colocated(AnnotationFS aX, AnnotationFS aY) {
+    return aX.getBegin() == aY.getBegin() && aX.getEnd() == aY.getEnd();
+  }
+
+  public static boolean overlapping(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aYBegin == aXBegin || aYEnd == aXEnd || (aXBegin < aYEnd && aYBegin < aXEnd);
+  }
+
+  public static boolean overlapping(AnnotationFS aX, int aYBegin, int aYEnd) {
+    int xBegin = aX.getBegin();
+    int xEnd = aX.getEnd();
+    return aYBegin == xBegin || aYEnd == xEnd || (xBegin < aYEnd && aYBegin < xEnd);
+  }
+
+  /**
+   * The intersection of the spans X and Y is non-empty. If either X or Y have a
+   * zero-width, then the intersection is considered to be non-empty if the begin
+   * of X is either within Y or the same as the begin of Y - and vice versa.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X overlaps with Y in any way.
+   */
+  public static boolean overlapping(AnnotationFS aX, AnnotationFS aY) {
+    int xBegin = aX.getBegin();
+    int xEnd = aX.getEnd();
+    int yBegin = aY.getBegin();
+    int yEnd = aY.getEnd();
+    return yBegin == xBegin || yEnd == xEnd || (xBegin < yEnd && yBegin < xEnd);
+  }
+
+  public static boolean overlappingAtBegin(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aXBegin < aYBegin && aYBegin < aXEnd && aXEnd <= aYEnd;
+  }
+
+  public static boolean overlappingAtBegin(AnnotationFS aX, int aYBegin, int aYEnd) {
+    int xEnd = aX.getEnd();
+    return aYBegin < xEnd && xEnd <= aYEnd && aX.getBegin() < aYBegin;
+  }
+
+  /**
+   * X is starting before or at the same position as Y and ends before Y ends.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X overlaps Y on the left.
+   */
+  public static boolean overlappingAtBegin(AnnotationFS aX, AnnotationFS aY) {
+    int xEnd = aX.getEnd();
+    int yBegin = aY.getBegin();
+    return yBegin < xEnd && xEnd <= aY.getEnd() && aX.getBegin() < yBegin;
+  }
+
+  public static boolean overlappingAtEnd(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aYBegin <= aXBegin && aXBegin < aYEnd && aYEnd < aXEnd;
+  }
+
+  public static boolean overlappingAtEnd(AnnotationFS aX, int aYBegin, int aYEnd) {
+    int xBegin = aX.getBegin();
+    return aYBegin <= xBegin && xBegin < aYEnd && aYEnd < aX.getEnd();
+  }
+
+  /**
+   * X is starting after Y starts and ends after or at the same position as Y.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X overlaps Y on the right.
+   */
+  public static boolean overlappingAtEnd(AnnotationFS aX, AnnotationFS aY) {
+    int xBegin = aX.getBegin();
+    int yEnd = aY.getEnd();
+    return xBegin < yEnd && aY.getBegin() <= xBegin && yEnd < aX.getEnd();
+  }
+
+  public static boolean following(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aXBegin >= aYEnd;
+  }
+
+  public static boolean following(AnnotationFS aX, int aYBegin, int aYEnd) {
+    return aX.getBegin() >= aYEnd;
+  }
+
+  /**
+   * X starts at or after the position that Y ends.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X is right of Y.
+   */
+  public static boolean following(AnnotationFS aX, AnnotationFS aY) {
+    return aX.getBegin() >= aY.getEnd();
+  }
+
+  public static boolean preceding(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aYBegin >= aXEnd;
+  }
+
+  public static boolean preceding(AnnotationFS aX, int aYBegin, int aYEnd) {
+    return aYBegin >= aX.getEnd();
+  }
+
+  /**
+   * X ends before or at the position that Y starts.
+   * 
+   * @param aX
+   *          X
+   * @param aY
+   *          Y
+   * @return whether X left of Y.
+   */
+  public static boolean preceding(AnnotationFS aX, AnnotationFS aY) {
+    return aY.getBegin() >= aX.getEnd();
+  }
+  
+  public static boolean beginningWith(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aXBegin == aYBegin;
+  }
+
+  public static boolean beginningWith(AnnotationFS aX, int aYBegin, int aYEnd) {
+    return aX.getBegin() == aYBegin;
+  }
+
+  public static boolean beginningWith(AnnotationFS aX, AnnotationFS aY) {
+    return aX.getBegin() == aY.getBegin();
+  }
+
+  public static boolean endingWith(int aXBegin, int aXEnd, int aYBegin, int aYEnd) {
+    return aXEnd == aYEnd;
+  }
+  public static boolean endingWith(AnnotationFS aX, int aYBegin, int aYEnd) {
+    return aX.getEnd() == aYEnd;
+  }
+
+  public static boolean endingWith(AnnotationFS aX, AnnotationFS aY) {
+    return aX.getEnd() == aY.getEnd();
+  }
+}
diff --git a/uimafit-core/src/test/java/org/apache/uima/fit/util/CasUtilTest.java b/uimafit-core/src/test/java/org/apache/uima/fit/util/CasUtilTest.java
index 769502e..8589108 100644
--- a/uimafit-core/src/test/java/org/apache/uima/fit/util/CasUtilTest.java
+++ b/uimafit-core/src/test/java/org/apache/uima/fit/util/CasUtilTest.java
@@ -18,24 +18,48 @@
  */
 package org.apache.uima.fit.util;
 
+import static java.lang.Integer.MAX_VALUE;
 import static java.util.Arrays.asList;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.StreamSupport.stream;
 import static org.apache.uima.fit.factory.TypeSystemDescriptionFactory.createTypeSystemDescription;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.NON_ZERO_WIDTH_TEST_CASES;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.ZERO_WIDTH_TEST_CASES;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.COLOCATED;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.COVERED_BY;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.COVERING;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.FOLLOWING;
+import static org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition.PRECEDING;
+import static org.apache.uima.fit.util.AnnotationPredicates.colocated;
+import static org.apache.uima.fit.util.AnnotationPredicates.coveredBy;
+import static org.apache.uima.fit.util.AnnotationPredicates.covering;
+import static org.apache.uima.fit.util.AnnotationPredicates.following;
+import static org.apache.uima.fit.util.AnnotationPredicates.preceding;
+import static org.apache.uima.fit.util.CasUtil.exists;
 import static org.apache.uima.fit.util.CasUtil.getAnnotationType;
 import static org.apache.uima.fit.util.CasUtil.getType;
 import static org.apache.uima.fit.util.CasUtil.iterator;
 import static org.apache.uima.fit.util.CasUtil.iteratorFS;
 import static org.apache.uima.fit.util.CasUtil.select;
+import static org.apache.uima.fit.util.CasUtil.selectAt;
 import static org.apache.uima.fit.util.CasUtil.selectByIndex;
+import static org.apache.uima.fit.util.CasUtil.selectCovered;
+import static org.apache.uima.fit.util.CasUtil.selectCovering;
 import static org.apache.uima.fit.util.CasUtil.selectFS;
+import static org.apache.uima.fit.util.CasUtil.selectFollowing;
+import static org.apache.uima.fit.util.CasUtil.selectPreceding;
 import static org.apache.uima.fit.util.CasUtil.toText;
-import static org.apache.uima.fit.util.CasUtil.exists;
+import static org.apache.uima.fit.util.SelectionAssert.assertSelection;
+import static org.apache.uima.fit.util.SelectionAssert.assertSelectionIsEqualOnRandomData;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
+import java.util.List;
 
 import org.apache.uima.UIMAException;
 import org.apache.uima.cas.ArrayFS;
@@ -45,6 +69,7 @@
 import org.apache.uima.cas.text.AnnotationFS;
 import org.apache.uima.fit.ComponentTestBase;
 import org.apache.uima.fit.type.Token;
+import org.apache.uima.fit.util.SelectionAssert.TestCase;
 import org.apache.uima.jcas.cas.TOP;
 import org.apache.uima.jcas.tcas.Annotation;
 import org.apache.uima.util.CasCreationUtils;
@@ -55,6 +80,9 @@
  * 
  */
 public class CasUtilTest extends ComponentTestBase {
+  private List<TestCase> defaultPredicatesTestCases = union(NON_ZERO_WIDTH_TEST_CASES,
+          ZERO_WIDTH_TEST_CASES);
+  
   @Test
   public void testGetType() {
     String text = "Rot wood cheeses dew?";
@@ -205,4 +233,111 @@
 
     assertTrue(exists(cas, tokenType));
   }
+  
+  @Test
+  public void thatSelectFollowingBehaviorAlignsWithPrecedingPredicate() throws Exception {
+    // In order to find annotations that X is preceding, we select the following annotations
+    assertSelection(
+        PRECEDING,
+        (cas, type, x, y) -> selectFollowing(cas, type, x, MAX_VALUE).contains(y),
+        defaultPredicatesTestCases);
+  }
+  
+  @Test
+  public void thatSelectPrecedingBehaviorAlignsWithPrecedingPredicateOnRandomData() throws Exception
+  {
+    assertSelectionIsEqualOnRandomData(
+        (cas, type, context) -> stream(cas.getAnnotationIndex(type).spliterator(), false)
+            .filter(candidate -> preceding(candidate, context))
+            .collect(toList()),
+        (cas, type, context) -> selectPreceding(cas, type, context, MAX_VALUE));
+  }
+
+  @Test
+  public void thatSelectPrecedingBehaviorAlignsWithFollowingPredicate() throws Exception {
+    // In order to find annotations that X is following, we select the preceding annotations
+    assertSelection(
+        FOLLOWING,
+        (cas, type, x, y) -> selectPreceding(cas, type, x, MAX_VALUE).contains(y),
+        defaultPredicatesTestCases);
+  }
+  
+  @Test
+  public void thatSelectFollowingBehaviorAlignsWithFollowingPredicateOnRandomData() throws Exception
+  {
+    assertSelectionIsEqualOnRandomData(
+        (cas, type, context) -> stream(cas.getAnnotationIndex(type).spliterator(), false)
+            .filter(candidate -> following(candidate, context))
+            .collect(toList()),
+        (cas, type, context) -> selectFollowing(cas, type, context, MAX_VALUE));
+  }
+
+  @Test
+  public void thatSelectCoveringBehaviorAlignsWithCoveredByPredicate() throws Exception {
+    // X covered by Y means that Y is covering X, so we need to select the covering annotations
+    // below.
+    assertSelection(
+        COVERED_BY,
+        (cas, type, x, y) -> selectCovering(cas, type, x).contains(y),
+        defaultPredicatesTestCases);
+  }
+  
+  @Test
+  public void thatSelectCoveredBehaviorAlignsWithCoveredByPredicateOnRandomData() throws Exception
+  {
+    assertSelectionIsEqualOnRandomData(
+        (cas, type, context) -> stream(cas.getAnnotationIndex(type).spliterator(), false)
+            .filter(candidate -> coveredBy(candidate, context))
+            .collect(toList()),
+        (cas, type, context) -> selectCovered(cas, type, context));
+  }
+
+  @Test
+  public void thatSelectCoveredBehaviorAlignsWithCoveringPredicate() throws Exception {
+    // X covering Y means that Y is covered by Y, so we need to select the covered by annotations
+    // below.
+    assertSelection(
+        COVERING,
+        (cas, type, x, y) -> selectCovered(cas, type, x).contains(y),
+        defaultPredicatesTestCases);
+  }
+
+  @Test
+  public void thatSelectFsBehaviorAlignsWithCoveringPredicateOnRandomData() throws Exception
+  {
+    assertSelectionIsEqualOnRandomData(
+        (cas, type, context) -> stream(cas.getAnnotationIndex(type).spliterator(), false)
+            .filter(candidate -> covering(candidate, context))
+            .collect(toList()),
+        (cas, type, context) -> selectCovering(cas, type, context));
+  }
+  
+  @Test
+  public void thatSelectAtBehaviorAlignsWithColocatedPredicate() throws Exception {
+    // X covering Y means that Y is covered by Y, so we need to select the covered by annotations
+    // below.
+    assertSelection(
+        COLOCATED,
+        (cas, type, x, y) -> selectAt(cas, type, x.getBegin(), x.getEnd()).contains(y),
+        defaultPredicatesTestCases);
+  }  
+
+  @Test
+  public void thatSelectAtBehaviorAlignsWithColocatedPredicateOnRandomData() throws Exception
+  {
+    assertSelectionIsEqualOnRandomData(
+        (cas, type, context) -> stream(cas.getAnnotationIndex(type).spliterator(), false)
+            .filter(candidate -> colocated(candidate, context))
+            .collect(toList()),
+        (cas, type, context) -> selectAt(cas, type, context.getBegin(), context.getEnd()));
+  }
+  
+  @SafeVarargs
+  public static <T> List<T> union(List<T>... aLists) {
+      List<T> all = new ArrayList<>();
+      for (List<T> list : aLists) {
+        all.addAll(list);
+      }
+      return all;
+  }
 }
diff --git a/uimafit-core/src/test/java/org/apache/uima/fit/util/JCasUtilTest.java b/uimafit-core/src/test/java/org/apache/uima/fit/util/JCasUtilTest.java
index 7094af4..091a6e1 100644
--- a/uimafit-core/src/test/java/org/apache/uima/fit/util/JCasUtilTest.java
+++ b/uimafit-core/src/test/java/org/apache/uima/fit/util/JCasUtilTest.java
@@ -318,7 +318,9 @@
     // print(a1);
     // System.out.println("--- Optimized");
     // print(a2);
-    assertEquals("Container: [" + t.getBegin() + ".." + t.getEnd() + "]", a1, a2);
+    assertThat(a2)
+        .as("Container: [" + t.getBegin() + ".." + t.getEnd() + "]")
+        .containsExactlyElementsOf((Iterable) a1);
   }
 
   @Test
@@ -668,7 +670,7 @@
   }
 
   @Test
-  public void thatSelectFollowingDoesNotFindZeroWidthAnnotationAtEnd()
+  public void thatSelectFollowingDoesFindZeroWidthAnnotationAtEnd()
   {
     Annotation a1 = new Annotation(jCas, 10, 20);
     Annotation a2 = new Annotation(jCas, 20, 20);
@@ -678,11 +680,11 @@
     List<Annotation> selection = selectFollowing(Annotation.class, a1, MAX_VALUE);
     
     assertThat(selection)
-            .isEmpty();
+            .containsExactly(a2);
   }
 
   @Test
-  public void thatSelectPrecedingDoesNotFindZeroWidthAnnotationAtStart()
+  public void thatSelectPrecedingDoesFindZeroWidthAnnotationAtStart()
   {
     Annotation a1 = new Annotation(jCas, 10, 20);
     Annotation a2 = new Annotation(jCas, 10, 10);
@@ -692,11 +694,11 @@
     List<Annotation> selection = selectPreceding(Annotation.class, a1, MAX_VALUE);
     
     assertThat(selection)
-            .isEmpty();
+            .containsExactly(a2);
   }
 
   @Test
-  public void thatSelectPrecedingOnZeroWidthDoesNotFindAnnotationEndingAtSameLocation()
+  public void thatSelectPrecedingOnZeroWidthDoesFindAnnotationEndingAtSameLocation()
   {
     Annotation a1 = new Annotation(jCas, 10, 20);
     Annotation a2 = new Annotation(jCas, 20, 20);
@@ -706,7 +708,7 @@
     List<Annotation> selection = selectPreceding(Annotation.class, a2, MAX_VALUE);
     
     assertThat(selection)
-            .isEmpty();
+            .containsExactly(a1);
   }
 
   @Test
diff --git a/uimafit-core/src/test/java/org/apache/uima/fit/util/SelectionAssert.java b/uimafit-core/src/test/java/org/apache/uima/fit/util/SelectionAssert.java
new file mode 100644
index 0000000..22f21cd
--- /dev/null
+++ b/uimafit-core/src/test/java/org/apache/uima/fit/util/SelectionAssert.java
@@ -0,0 +1,187 @@
+/*
+ * 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.uima.fit.util;
+
+import static java.util.Arrays.asList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.function.Function;
+
+import org.apache.uima.UIMAFramework;
+import org.apache.uima.cas.CAS;
+import org.apache.uima.cas.Type;
+import org.apache.uima.cas.text.AnnotationFS;
+import org.apache.uima.fit.util.AnnotationPredicateTestData.RelativePosition;
+import org.apache.uima.resource.metadata.TypeSystemDescription;
+import org.apache.uima.util.CasCreationUtils;
+import org.assertj.core.api.AutoCloseableSoftAssertions;
+
+public class SelectionAssert {
+  public static void assertSelection(RelativePosition aCondition, RelativeAnnotationPredicate aPredicate, 
+      List<TestCase> aTestCases)
+      throws Exception {
+    CAS cas = CasCreationUtils.createCas((TypeSystemDescription) null, null, null);
+    Type type = cas.getAnnotationType();
+
+    try (AutoCloseableSoftAssertions softly = new AutoCloseableSoftAssertions()) {
+      for (TestCase testCase : aTestCases) {
+        cas.reset();
+
+        // Create annotations
+        AnnotationFS x = cas.createAnnotation(type, 0, 0);
+        AnnotationFS y = cas.createAnnotation(type, 0, 0);
+
+        // Position the annotations according to the test data
+        testCase.getTest().apply((beginA, endA, beginB, endB) -> {
+          FSUtil.setFeature(x, CAS.FEATURE_BASE_NAME_BEGIN, beginA);
+          FSUtil.setFeature(x, CAS.FEATURE_BASE_NAME_END, endA);
+          FSUtil.setFeature(y, CAS.FEATURE_BASE_NAME_BEGIN, beginB);
+          FSUtil.setFeature(y, CAS.FEATURE_BASE_NAME_END, endB);
+          cas.addFsToIndexes(x);
+          cas.addFsToIndexes(y);
+          return true;
+        });
+
+        softly.assertThat(aPredicate.apply(cas, type, x, y)).as(testCase.getDescription())
+            .isEqualTo(testCase.getValidPositions().contains(aCondition));
+      }
+    }
+  }
+
+  public static void assertSelectionIsEqualOnRandomData(TypeByContextSelector aExpected, TypeByContextSelector aActual)
+      throws Exception {
+    final int ITERATIONS = 30;
+    final int TYPES = 5;
+
+    TypeSystemDescription tsd = UIMAFramework.getResourceSpecifierFactory().createTypeSystemDescription();
+    
+    Map<String, Type> types = new LinkedHashMap<>();
+    for (int i = 0; i < TYPES; i++) {
+      String typeName = "test.Type" + (i + 1);
+      tsd.addType(typeName, "", CAS.TYPE_NAME_ANNOTATION);
+      types.put(typeName, null);
+    }
+    
+    CAS randomCas = CasCreationUtils.createCas(tsd, null, null, null);
+
+    for (String typeName : types.keySet()) {
+      types.put(typeName, randomCas.getTypeSystem().getType(typeName));
+    }
+    
+    System.out.print("Iteration: ");
+    try {
+      Iterator<Type> ti = types.values().iterator();
+      Type type1 = ti.next();
+      Type type2 = ti.next();
+      
+      for (int i = 0; i < ITERATIONS; i++) {
+        if (i % 10 == 0) {
+          System.out.print(i);
+        }
+        else {
+          System.out.print(".");
+        }
+  
+        initRandomCas(randomCas, 3 * i, 0, types.values().toArray(new Type[types.size()]));
+  
+        for (AnnotationFS context : randomCas.getAnnotationIndex(type1)) {
+          List<AnnotationFS> expected = aExpected.select(randomCas, type2, context);
+          List<AnnotationFS> actual = aActual.select(randomCas, type2, context);
+  
+          assertThat(actual)
+              .as("Selected [%s] with context [%s]@[%d..%d]", type2.getShortName(), 
+                  type1.getShortName(), context.getBegin(), context.getEnd())
+              .containsExactlyElementsOf(expected);
+        }
+      }
+      System.out.print(ITERATIONS);
+    }
+    finally {
+      System.out.println();
+    }
+  }
+
+  private static void initRandomCas(CAS aCas, int aSize, int aMinimumWidth, Type... aTypes) {
+    Random rnd = new Random();
+
+    List<Type> types = new ArrayList<>(asList(aTypes));
+
+    // Shuffle the types
+    for (int n = 0; n < 10; n++) {
+      Type t = types.remove(rnd.nextInt(types.size()));
+      types.add(t);
+    }
+
+    // Randomly generate annotations
+    for (int n = 0; n < aSize; n++) {
+      for (Type t : types) {
+        int begin = rnd.nextInt(100);
+        int end = begin + rnd.nextInt(30) + aMinimumWidth;
+        aCas.addFsToIndexes(aCas.createAnnotation(t, begin, end));
+      }
+    }
+  }
+
+  @FunctionalInterface
+  public static interface RelativeAnnotationPredicate {
+    boolean apply(CAS cas, Type type, AnnotationFS x, AnnotationFS y);
+  }
+
+  @FunctionalInterface
+  public static interface TypeByContextSelector {
+    List<AnnotationFS> select(CAS aCas, Type aType, AnnotationFS aContext);
+  }
+  
+  @FunctionalInterface
+  public static interface RelativePositionPredicate {
+    boolean apply(int beginA, int endA, int beginB, int endB);
+  }
+
+  public static class TestCase {
+    private final String description;
+
+    private final Function<RelativePositionPredicate, Boolean> predicate;
+    
+    private final List<RelativePosition> validPositions;
+
+    public TestCase(String aDescription, Function<RelativePositionPredicate, Boolean> aPredicate, List<RelativePosition> aValidPositions) {
+      description = aDescription;
+      predicate = aPredicate;
+      validPositions = aValidPositions;
+    }
+
+    public String getDescription() {
+      return description;
+    }
+
+    public Function<RelativePositionPredicate, Boolean> getTest() {
+      return predicate;
+    }
+    
+    public List<RelativePosition> getValidPositions() {
+      return validPositions;
+    }
+  }
+}