Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Clean up metamorphic test with helpers
  • Loading branch information
seadowg committed Dec 1, 2025
commit e6474c0718ec190cf0be70a6c47f5e9ac523650d
11 changes: 0 additions & 11 deletions shared/src/main/java/org/odk/collect/shared/geometry/Geometry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,6 @@ fun LineSegment.intersects(other: LineSegment, allowConnection: Boolean = false,
}
}

/**
* Calculate a [Point] on this [LineSegment] based on the `position` using
* [Linear interpolation](https://en.wikipedia.org/wiki/Linear_interpolation). `0` will return
* [LineSegment.start] and `1` will return [LineSegment.end].
*/
fun LineSegment.interpolate(position: Double): Point {
val x = start.x + position * (end.x - start.x)
val y = start.y + position * (end.y - start.y)
return Point(x, y)
}

/**
* Calculate the "orientation" (or "direction") of three points using the cross product of the
* vectors of the pairs of points (see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ package org.odk.collect.shared.geometry
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
import org.odk.collect.shared.geometry.support.GeometryTestUtils.addRandomIntersectingSegment
import org.odk.collect.shared.geometry.support.GeometryTestUtils.getTraceGenerator
import org.odk.collect.shared.geometry.support.GeometryTestUtils.reverse
import org.odk.collect.shared.geometry.support.GeometryTestUtils.scale
import org.odk.collect.shared.quickCheck
import kotlin.random.Random

Expand Down Expand Up @@ -214,18 +218,15 @@ class GeometryTest {
generator = getTraceGenerator()
) { trace, intersects ->
// Check intersects is consistent when trace is reversed
val reversedTrace = Trace(trace.points.reversed())
val reversedTrace = trace.reverse()
assertThat(
"Expected intersects=$intersects:\n$reversedTrace",
reversedTrace.intersects(),
equalTo(intersects)
)

// Check intersects is consistent when trace is scaled
val scaleFactor = Random.nextDouble(0.1, 10.0)
val scaledTrace = Trace(trace.points.map {
Point(it.x * scaleFactor, it.y * scaleFactor)
})
val scaledTrace = trace.scale(Random.nextDouble(0.1, 10.0))
assertThat(
"Expected intersects=$intersects:\n$scaledTrace",
scaledTrace.intersects(),
Expand All @@ -234,13 +235,7 @@ class GeometryTest {

// Check adding an intersection makes intersects true
if (!intersects && !trace.isClosed()) {
val intersectionSegment = trace.segments().dropLast(1).random()
val intersectPosition = Random.nextDouble(0.1, 1.0)
val intersectionPoint = intersectionSegment.interpolate(intersectPosition)
val lineSegment = LineSegment(trace.points.last(), intersectionPoint)
val intersectingSegment =
LineSegment(lineSegment.start, lineSegment.interpolate(1.1))
val intersectingTrace = Trace(trace.points + intersectingSegment.end)
val intersectingTrace = trace.addRandomIntersectingSegment()
assertThat(
"Expected intersects=true:\n$intersectingTrace",
intersectingTrace.intersects(),
Expand Down Expand Up @@ -282,56 +277,4 @@ class GeometryTest {

assertThat(segment1.intersects(segment2, allowConnection = true), equalTo(true))
}

@Test
fun `LineSegment#interpolate returns a point on the segment at a proportional distance`() {
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 0.0))

assertThat(segment.interpolate(0.0), equalTo(Point(0.0, 0.0)))
assertThat(segment.interpolate(0.5), equalTo(Point(0.5, 0.0)))
assertThat(segment.interpolate(1.0), equalTo(Point(1.0, 0.0)))
}

@Test
fun `LineSegment#interpolate returns a collinear point within the line's bounding box`() {
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 1.0))
val interpolatedPoint = segment.interpolate(0.5)

val orientation = orientation(interpolatedPoint, segment.start, segment.end)
assertThat(orientation, equalTo(Orientation.Collinear))
assertThat(interpolatedPoint.within(segment), equalTo(true))
}

@Test
fun `LineSegment#interpolate returns a collinear point within the line's bounding box for higher precision points with a suitable epsilon`() {
val segment = LineSegment(Point(56.6029153, 20.2311124), Point(56.6029192, 20.2310467))
val interpolatedPoint = segment.interpolate(0.5)

val orientation = orientation(interpolatedPoint, segment.start, segment.end, epsilon = 0.000001)
assertThat(orientation, equalTo(Orientation.Collinear))
assertThat(interpolatedPoint.within(segment), equalTo(true))
}

private fun getTraceGenerator(maxLength: Int = 10, maxCoordinate: Double = 100.0): Sequence<Trace> {
return generateSequence {
val length = Random.nextInt(3, maxLength)
val trace = Trace(0.until(length).map {
Point(
Random.nextDouble(maxCoordinate * -1, maxCoordinate),
Random.nextDouble(maxCoordinate * -1, maxCoordinate)
)
})

if (trace.isClosed()) {
trace
} else {
val shouldClose = Random.nextBoolean()
if (shouldClose) {
trace.copy(points = trace.points + trace.points.first())
} else {
trace
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package org.odk.collect.shared.geometry.support

import org.odk.collect.shared.geometry.LineSegment
import org.odk.collect.shared.geometry.Point
import org.odk.collect.shared.geometry.Trace
import org.odk.collect.shared.geometry.segments
import kotlin.random.Random

object GeometryTestUtils {

fun getTraceGenerator(maxLength: Int = 10, maxCoordinate: Double = 100.0): Sequence<Trace> {
return generateSequence {
val length = Random.nextInt(3, maxLength)
val trace = Trace(0.until(length).map {
Point(
Random.nextDouble(maxCoordinate * -1, maxCoordinate),
Random.nextDouble(maxCoordinate * -1, maxCoordinate)
)
})

if (trace.isClosed()) {
trace
} else {
val shouldClose = Random.nextBoolean()
if (shouldClose) {
trace.copy(points = trace.points + trace.points.first())
} else {
trace
}
}
}
}

fun Trace.reverse(): Trace {
return Trace(points.reversed())
}

fun Trace.scale(factor: Double): Trace {
return Trace(points.map {
Point(it.x * factor, it.y * factor)
})
}

fun Trace.addRandomIntersectingSegment(): Trace {
val intersectionSegment = segments().dropLast(1).random()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to ignore the last segment? As I understand it, it avoids the scenario when the additional segment lies on the last segment but why? This still would be treated as an intersection.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! I'd meant to add a comment when moving this to a method and then forgotten:

/**
* Choose random segment, a random (interpolated) point on that segment and then create a new
* trace with an additional point just beyond that to create an intersecting trace.
*
* Never chooses the last segment as a target for intersecting as that can only create a
* collinear intersection which is unlikely to be accurate due to inaccuracies in [interpolate].
*/

This is basically the "two consecutive segments intersect" special case, and we test this it specifically (Trace#intersects returns true when two segments and they intersect).

val intersectPosition = Random.nextDouble(0.1, 1.0)
val intersectionPoint = intersectionSegment.interpolate(intersectPosition)
val lineSegment = LineSegment(points.last(), intersectionPoint)
val intersectingSegment =
LineSegment(lineSegment.start, lineSegment.interpolate(1.1))
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a line that intersects through another (rather than just touching) avoids needing to use an epsilon to account for the inaccurate interpolate and is far more realistic.

return Trace(points + intersectingSegment.end)
}

/**
* Calculate a [Point] on this [LineSegment] based on the `position` using
* [Linear interpolation](https://en.wikipedia.org/wiki/Linear_interpolation). `0` will return
* [LineSegment.start] and `1` will return [LineSegment.end].
*/
fun LineSegment.interpolate(position: Double): Point {
val x = start.x + position * (end.x - start.x)
val y = start.y + position * (end.y - start.y)
return Point(x, y)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.odk.collect.shared.geometry.support

import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
import org.odk.collect.shared.geometry.LineSegment
import org.odk.collect.shared.geometry.Orientation
import org.odk.collect.shared.geometry.Point
import org.odk.collect.shared.geometry.orientation
import org.odk.collect.shared.geometry.support.GeometryTestUtils.interpolate
import org.odk.collect.shared.geometry.within

class GeometryTestUtilsTest {

@Test
fun `LineSegment#interpolate returns a point on the segment at a proportional distance`() {
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 0.0))

assertThat(segment.interpolate(0.0), equalTo(Point(0.0, 0.0)))
assertThat(segment.interpolate(0.5), equalTo(Point(0.5, 0.0)))
assertThat(segment.interpolate(1.0), equalTo(Point(1.0, 0.0)))
}

@Test
fun `LineSegment#interpolate returns a collinear point within the line's bounding box`() {
val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 1.0))
val interpolatedPoint = segment.interpolate(0.5)

val orientation = orientation(interpolatedPoint, segment.start, segment.end)
assertThat(orientation, equalTo(Orientation.Collinear))
assertThat(interpolatedPoint.within(segment), equalTo(true))
}

@Test
fun `LineSegment#interpolate returns a collinear point within the line's bounding box for higher precision points with a suitable epsilon`() {
val segment = LineSegment(Point(56.6029153, 20.2311124), Point(56.6029192, 20.2310467))
val interpolatedPoint = segment.interpolate(0.5)

val orientation = orientation(interpolatedPoint, segment.start, segment.end, epsilon = 0.000001)
assertThat(orientation, equalTo(Orientation.Collinear))
assertThat(interpolatedPoint.within(segment), equalTo(true))
}
}