diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/Geo3dShapeTest.java b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/Geo3dShapeTest.java new file mode 100644 index 00000000000..68f3670561d --- /dev/null +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/Geo3dShapeTest.java @@ -0,0 +1,87 @@ +package org.apache.lucene.spatial.spatial4j; + +/* + * 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. + */ + +import java.util.Random; + +import com.carrotsearch.randomizedtesting.RandomizedContext; +import com.carrotsearch.randomizedtesting.annotations.Seed; +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.distance.DistanceUtils; +import com.spatial4j.core.shape.Point; +import org.apache.lucene.spatial.spatial4j.geo3d.GeoCircle; +import org.apache.lucene.spatial.spatial4j.geo3d.GeoPoint; +import org.apache.lucene.spatial.spatial4j.geo3d.GeoShape; +import org.junit.Rule; +import org.junit.Test; + +import static com.spatial4j.core.distance.DistanceUtils.DEGREES_TO_RADIANS; + +public class Geo3dShapeTest extends RandomizedShapeTest { + @Rule + public final TestLog testLog = TestLog.instance; + + static Random random() { + return RandomizedContext.current().getRandom(); + } + + { + ctx = SpatialContext.GEO; + } + + @Test + @Seed("FAD1BAB12B6DCCFE") + public void testGeoCircleRect() { + new RectIntersectionTestHelper(ctx) { + + @Override + protected Geo3dShape generateRandomShape(Point nearP) { + // Circles + while (true) { + final int circleRadius = random().nextInt(180); + final Point point = nearP; + try { + final GeoShape shape = new GeoCircle(point.getY() * DEGREES_TO_RADIANS, point.getX() * DEGREES_TO_RADIANS, + circleRadius * DEGREES_TO_RADIANS); + return new Geo3dShape(shape, ctx); + } catch (IllegalArgumentException e) { + // This is what happens when we create a shape that is invalid. Although it is conceivable that there are cases where + // the exception is thrown incorrectly, we aren't going to be able to do that in this random test. + continue; + } + } + } + + @Override + protected Point randomPointInEmptyShape(Geo3dShape shape) { + GeoPoint geoPoint = ((GeoCircle)shape.shape).center; + return geoPointToSpatial4jPoint(geoPoint); + } + + }.testRelateWithRectangle(); + } + + //TODO PORT OTHER TESTS + + + private Point geoPointToSpatial4jPoint(GeoPoint geoPoint) { + return ctx.makePoint(geoPoint.x * DistanceUtils.RADIANS_TO_DEGREES, + geoPoint.y * DistanceUtils.RADIANS_TO_DEGREES); + } + +} diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/RandomizedShapeTest.java b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/RandomizedShapeTest.java new file mode 100644 index 00000000000..493d9585a3f --- /dev/null +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/RandomizedShapeTest.java @@ -0,0 +1,285 @@ +package org.apache.lucene.spatial.spatial4j; + +/* + * 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. + */ + + +import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.distance.DistanceUtils; +import com.spatial4j.core.shape.Circle; +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.SpatialRelation; +import com.spatial4j.core.shape.impl.Range; + +import static com.spatial4j.core.shape.SpatialRelation.CONTAINS; +import static com.spatial4j.core.shape.SpatialRelation.WITHIN; + +/** + * A base test class with utility methods to help test shapes. + * Extends from RandomizedTest. + */ +public abstract class RandomizedShapeTest extends RandomizedTest { + + protected static final double EPS = 10e-9; + + protected SpatialContext ctx;//needs to be set ASAP + + /** Used to reduce the space of numbers to increase the likelihood that + * random numbers become equivalent, and thus trigger different code paths. + * Also makes some random shapes easier to manually examine. + */ + protected final double DIVISIBLE = 2;// even coordinates; (not always used) + + protected RandomizedShapeTest() { + } + + public RandomizedShapeTest(SpatialContext ctx) { + this.ctx = ctx; + } + + public static void checkShapesImplementEquals( Class[] classes ) { + for( Class clazz : classes ) { + try { + clazz.getDeclaredMethod( "equals", Object.class ); + } catch (Exception e) { + fail("Shape needs to define 'equals' : " + clazz.getName()); + } + try { + clazz.getDeclaredMethod( "hashCode" ); + } catch (Exception e) { + fail("Shape needs to define 'hashCode' : " + clazz.getName()); + } + } + } + + //These few norm methods normalize the arguments for creating a shape to + // account for the dateline. Some tests loop past the dateline or have offsets + // that go past it and it's easier to have them coded that way and correct for + // it here. These norm methods should be used when needed, not frivolously. + + protected double normX(double x) { + return ctx.isGeo() ? DistanceUtils.normLonDEG(x) : x; + } + + protected double normY(double y) { + return ctx.isGeo() ? DistanceUtils.normLatDEG(y) : y; + } + + protected Rectangle makeNormRect(double minX, double maxX, double minY, double maxY) { + if (ctx.isGeo()) { + if (Math.abs(maxX - minX) >= 360) { + minX = -180; + maxX = 180; + } else { + minX = DistanceUtils.normLonDEG(minX); + maxX = DistanceUtils.normLonDEG(maxX); + } + + } else { + if (maxX < minX) { + double t = minX; + minX = maxX; + maxX = t; + } + minX = boundX(minX, ctx.getWorldBounds()); + maxX = boundX(maxX, ctx.getWorldBounds()); + } + if (maxY < minY) { + double t = minY; + minY = maxY; + maxY = t; + } + minY = boundY(minY, ctx.getWorldBounds()); + maxY = boundY(maxY, ctx.getWorldBounds()); + return ctx.makeRectangle(minX, maxX, minY, maxY); + } + + public static double divisible(double v, double divisible) { + return (int) (Math.round(v / divisible) * divisible); + } + + protected double divisible(double v) { + return divisible(v, DIVISIBLE); + } + + /** reset()'s p, and confines to world bounds. Might not be divisible if + * the world bound isn't divisible too. + */ + protected Point divisible(Point p) { + Rectangle bounds = ctx.getWorldBounds(); + double newX = boundX( divisible(p.getX()), bounds ); + double newY = boundY( divisible(p.getY()), bounds ); + p.reset(newX, newY); + return p; + } + + static double boundX(double i, Rectangle bounds) { + return bound(i, bounds.getMinX(), bounds.getMaxX()); + } + + static double boundY(double i, Rectangle bounds) { + return bound(i, bounds.getMinY(), bounds.getMaxY()); + } + + static double bound(double i, double min, double max) { + if (i < min) return min; + if (i > max) return max; + return i; + } + + protected void assertRelation(SpatialRelation expected, Shape a, Shape b) { + assertRelation(null, expected, a, b); + } + + protected void assertRelation(String msg, SpatialRelation expected, Shape a, Shape b) { + _assertIntersect(msg, expected, a, b); + //check flipped a & b w/ transpose(), while we're at it + _assertIntersect(msg, expected.transpose(), b, a); + } + + private void _assertIntersect(String msg, SpatialRelation expected, Shape a, Shape b) { + SpatialRelation sect = a.relate(b); + if (sect == expected) + return; + msg = ((msg == null) ? "" : msg+"\r") + a +" intersect "+b; + if (expected == WITHIN || expected == CONTAINS) { + if (a.getClass().equals(b.getClass())) // they are the same shape type + assertEquals(msg,a,b); + else { + //they are effectively points or lines that are the same location + assertTrue(msg,!a.hasArea()); + assertTrue(msg,!b.hasArea()); + + Rectangle aBBox = a.getBoundingBox(); + Rectangle bBBox = b.getBoundingBox(); + if (aBBox.getHeight() == 0 && bBBox.getHeight() == 0 + && (aBBox.getMaxY() == 90 && bBBox.getMaxY() == 90 + || aBBox.getMinY() == -90 && bBBox.getMinY() == -90)) + ;//== a point at the pole + else + assertEquals(msg, aBBox, bBBox); + } + } else { + assertEquals(msg,expected,sect);//always fails + } + } + + protected void assertEqualsRatio(String msg, double expected, double actual) { + double delta = Math.abs(actual - expected); + double base = Math.min(actual, expected); + double deltaRatio = base==0 ? delta : Math.min(delta,delta / base); + assertEquals(msg,0,deltaRatio, EPS); + } + + protected int randomIntBetweenDivisible(int start, int end) { + return randomIntBetweenDivisible(start, end, (int)DIVISIBLE); + } + /** Returns a random integer between [start, end]. Integers between must be divisible by the 3rd argument. */ + protected int randomIntBetweenDivisible(int start, int end, int divisible) { + // DWS: I tested this + int divisStart = (int) Math.ceil( (start+1) / (double)divisible ); + int divisEnd = (int) Math.floor( (end-1) / (double)divisible ); + int divisRange = Math.max(0,divisEnd - divisStart + 1); + int r = randomInt(1 + divisRange);//remember that '0' is counted + if (r == 0) + return start; + if (r == 1) + return end; + return (r-2 + divisStart)*divisible; + } + + protected Rectangle randomRectangle(Point nearP) { + Rectangle bounds = ctx.getWorldBounds(); + if (nearP == null) + nearP = randomPointIn(bounds); + + Range xRange = randomRange(rarely() ? 0 : nearP.getX(), Range.xRange(bounds, ctx)); + Range yRange = randomRange(rarely() ? 0 : nearP.getY(), Range.yRange(bounds, ctx)); + + return makeNormRect( + divisible(xRange.getMin()), + divisible(xRange.getMax()), + divisible(yRange.getMin()), + divisible(yRange.getMax()) ); + } + + private Range randomRange(double near, Range bounds) { + double mid = near + randomGaussian() * bounds.getWidth() / 6; + double width = Math.abs(randomGaussian()) * bounds.getWidth() / 6;//1/3rd + return new Range(mid - width / 2, mid + width / 2); + } + + private double randomGaussianZeroTo(double max) { + if (max == 0) + return max; + assert max > 0; + double r; + do { + r = Math.abs(randomGaussian()) * (max * 0.50); + } while (r > max); + return r; + } + + protected Rectangle randomRectangle(int divisible) { + double rX = randomIntBetweenDivisible(-180, 180, divisible); + double rW = randomIntBetweenDivisible(0, 360, divisible); + double rY1 = randomIntBetweenDivisible(-90, 90, divisible); + double rY2 = randomIntBetweenDivisible(-90, 90, divisible); + double rYmin = Math.min(rY1,rY2); + double rYmax = Math.max(rY1,rY2); + if (rW > 0 && rX == 180) + rX = -180; + return makeNormRect(rX, rX + rW, rYmin, rYmax); + } + + protected Point randomPoint() { + return randomPointIn(ctx.getWorldBounds()); + } + + protected Point randomPointIn(Circle c) { + double d = c.getRadius() * randomDouble(); + double angleDEG = 360 * randomDouble(); + Point p = ctx.getDistCalc().pointOnBearing(c.getCenter(), d, angleDEG, ctx, null); + assertEquals(CONTAINS,c.relate(p)); + return p; + } + + protected Point randomPointIn(Rectangle r) { + double x = r.getMinX() + randomDouble()*r.getWidth(); + double y = r.getMinY() + randomDouble()*r.getHeight(); + x = normX(x); + y = normY(y); + Point p = ctx.makePoint(x,y); + assertEquals(CONTAINS,r.relate(p)); + return p; + } + + protected Point randomPointIn(Shape shape) { + if (!shape.hasArea())// or try the center? + throw new UnsupportedOperationException("Need area to define shape!"); + Rectangle bbox = shape.getBoundingBox(); + Point p; + do { + p = randomPointIn(bbox); + } while (!bbox.relate(p).intersects()); + return p; + } +} + diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/RectIntersectionTestHelper.java b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/RectIntersectionTestHelper.java new file mode 100644 index 00000000000..8ce0dd7d2cb --- /dev/null +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/RectIntersectionTestHelper.java @@ -0,0 +1,180 @@ +package org.apache.lucene.spatial.spatial4j; + +/* + * 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. + */ + +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.SpatialRelation; +import com.spatial4j.core.shape.impl.InfBufLine; +import com.spatial4j.core.shape.impl.PointImpl; + +import static com.spatial4j.core.shape.SpatialRelation.CONTAINS; +import static com.spatial4j.core.shape.SpatialRelation.DISJOINT; + +public abstract class RectIntersectionTestHelper extends RandomizedShapeTest { + + public RectIntersectionTestHelper(SpatialContext ctx) { + super(ctx); + } + + protected abstract S generateRandomShape(Point nearP); + + protected abstract Point randomPointInEmptyShape(S shape); + + @SuppressWarnings("unchecked") + @Override + protected Point randomPointIn(Shape shape) { + if (!shape.hasArea()) + return randomPointInEmptyShape((S) shape); + return super.randomPointIn(shape); + } + + public void testRelateWithRectangle() { + //counters for the different intersection cases + int i_C = 0, i_I = 0, i_W = 0, i_D = 0, i_bboxD = 0; + int laps = 0; + final int MINLAPSPERCASE = scaledRandomIntBetween(20, 200); + while(i_C < MINLAPSPERCASE || i_I < MINLAPSPERCASE || i_W < MINLAPSPERCASE + || i_D < MINLAPSPERCASE || i_bboxD < MINLAPSPERCASE) { + laps++; + + TestLog.clear(); + + Point nearP = randomPointIn(ctx.getWorldBounds()); + + S s = generateRandomShape(nearP); + + Rectangle r = randomRectangle(s.getBoundingBox().getCenter()); + + SpatialRelation ic = s.relate(r); + + TestLog.log("S-R Rel: {}, Shape {}, Rectangle {}", ic, s, r); + + try { + int MAX_TRIES = scaledRandomIntBetween(10, 100); + switch (ic) { + case CONTAINS: + i_C++; + for (int j = 0; j < MAX_TRIES; j++) { + Point p = randomPointIn(r); + assertRelation(null, CONTAINS, s, p); + } + break; + + case WITHIN: + i_W++; + for (int j = 0; j < MAX_TRIES; j++) { + Point p = randomPointIn(s); + assertRelation(null, CONTAINS, r, p); + } + break; + + case DISJOINT: + if (!s.getBoundingBox().relate(r).intersects()) {//bboxes are disjoint + i_bboxD++; + if (i_bboxD > MINLAPSPERCASE) + break; + } else { + i_D++; + } + for (int j = 0; j < MAX_TRIES; j++) { + Point p = randomPointIn(r); + assertRelation(null, DISJOINT, s, p); + } + break; + + case INTERSECTS: + i_I++; + SpatialRelation pointR = null;//set once + Rectangle randomPointSpace = null; + MAX_TRIES = 1000;//give many attempts + for (int j = 0; j < MAX_TRIES; j++) { + Point p; + if (j < 4) { + p = new PointImpl(0, 0, ctx); + InfBufLine.cornerByQuadrant(r, j + 1, p); + } else { + if (randomPointSpace == null) { + if (pointR == DISJOINT) { + randomPointSpace = intersectRects(r,s.getBoundingBox()); + } else {//CONTAINS + randomPointSpace = r; + } + } + p = randomPointIn(randomPointSpace); + } + SpatialRelation pointRNew = s.relate(p); + if (pointR == null) { + pointR = pointRNew; + } else if (pointR != pointRNew) { + break; + } else if (j >= MAX_TRIES) { + //TODO consider logging instead of failing + fail("Tried intersection brute-force too many times without success"); + } + } + + break; + + default: fail(""+ic); + } + } catch (AssertionError e) { + onAssertFail(e, s, r, ic); + } + if (laps > MINLAPSPERCASE * 1000) + fail("Did not find enough intersection cases in a reasonable number" + + " of random attempts. CWIDbD: "+i_C+","+i_W+","+i_I+","+i_D+","+i_bboxD + + " Laps exceeded "+MINLAPSPERCASE * 1000); + } + System.out.println("Laps: "+laps + " CWIDbD: "+i_C+","+i_W+","+i_I+","+i_D+","+i_bboxD); + } + + protected void onAssertFail(AssertionError e, S s, Rectangle r, SpatialRelation ic) { + throw e; + } + + private Rectangle intersectRects(Rectangle r1, Rectangle r2) { + assert r1.relate(r2).intersects(); + final double minX, maxX; + if (r1.relateXRange(r2.getMinX(),r2.getMinX()).intersects()) { + minX = r2.getMinX(); + } else { + minX = r1.getMinX(); + } + if (r1.relateXRange(r2.getMaxX(),r2.getMaxX()).intersects()) { + maxX = r2.getMaxX(); + } else { + maxX = r1.getMaxX(); + } + final double minY, maxY; + if (r1.relateYRange(r2.getMinY(),r2.getMinY()).intersects()) { + minY = r2.getMinY(); + } else { + minY = r1.getMinY(); + } + if (r1.relateYRange(r2.getMaxY(),r2.getMaxY()).intersects()) { + maxY = r2.getMaxY(); + } else { + maxY = r1.getMaxY(); + } + return ctx.makeRectangle(minX, maxX, minY, maxY); + } + +} \ No newline at end of file diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/TestLog.java b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/TestLog.java new file mode 100644 index 00000000000..52c5a0baa58 --- /dev/null +++ b/lucene/spatial/src/test/org/apache/lucene/spatial/spatial4j/TestLog.java @@ -0,0 +1,83 @@ +package org.apache.lucene.spatial.spatial4j; + +/* + * 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. + */ + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import com.carrotsearch.randomizedtesting.rules.TestRuleAdapter; + +/** + * A utility logger for tests in which log statements are logged following + * test failure only. Add this to a JUnit based test class with a {@link org.junit.Rule} + * annotation. + */ +public class TestLog extends TestRuleAdapter { + + //TODO does this need to be threadsafe (such as via thread-local state)? + private static ArrayList logStack = new ArrayList(); + private static final int MAX_LOGS = 1000; + + public static final TestLog instance = new TestLog(); + + private TestLog() {} + + @Override + protected void before() throws Throwable { + logStack.clear(); + } + + @Override + protected void afterAlways(List errors) throws Throwable { + if (!errors.isEmpty()) + logThenClear(); + } + + private void logThenClear() { + for (LogEntry entry : logStack) { + //no SLF4J in Lucene... fallback to this + if (entry.args != null && entry.args.length > 0) { + System.out.println(entry.msg + " " + Arrays.asList(entry.args) + "(no slf4j subst; sorry)"); + } else { + System.out.println(entry.msg); + } + } + logStack.clear(); + } + + public static void clear() { + logStack.clear(); + } + + /** + * Enqueues a log message with substitution arguments ala SLF4J (i.e. {} syntax). + * If the test fails then it'll be logged then, otherwise it'll be forgotten. + */ + public static void log(String msg, Object... args) { + if (logStack.size() > MAX_LOGS) { + throw new RuntimeException("Too many log statements: "+logStack.size() + " > "+MAX_LOGS); + } + LogEntry entry = new LogEntry(); + entry.msg = msg; + entry.args = args; + logStack.add(entry); + } + + private static class LogEntry { String msg; Object[] args; } +}