diff --git a/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt b/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt index 3fc4150..4edd311 100644 --- a/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt +++ b/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt @@ -11,6 +11,8 @@ import kotlin.test.* * which should be smaller than [c]. * For evenly-spaced types of [T], the distance between [a] and [b], and [b] and [c], should be greater than the spacing * between any two subsequent values in the set. + * For non-evenly-spaced types, the distance between [a] and [b] should be small enough so that there isn't sufficient + * precision to represent them as individual values when shifted close to the max possible values represented by [T]. */ @Suppress( "FunctionName" ) abstract class IntervalTest, TSize : Comparable>( @@ -498,6 +500,20 @@ abstract class IntervalTest, TSize : Comparable>( assertEquals( expectedShift, shifted.offsetAmount ) } + @Test + fun shift_tiny_intervals_long_distances_for_value_types_with_lossy_precision() + { + if ( valueOperations.spacing != null ) return // Evenly-spaced types don't lose precision. + + val tinyInterval = createOpenInterval( a, b ) + val shifted = tinyInterval.shift( sizeOperations.maxValue ) + + // Due to loss of precision, there is no more distinction between `a` and `b` after shifting, resulting in an + // interval with `size` 0. This causes the interval to "collapse" into a single value, represented as a closed + // interval. + assertTrue( shifted.shiftedInterval.isClosedInterval ) + } + @Test fun intersects_for_fully_contained_intervals() { diff --git a/kotlinx.interval/src/commonMain/kotlin/Interval.kt b/kotlinx.interval/src/commonMain/kotlin/Interval.kt index 5b47f75..94b58fd 100644 --- a/kotlinx.interval/src/commonMain/kotlin/Interval.kt +++ b/kotlinx.interval/src/commonMain/kotlin/Interval.kt @@ -80,6 +80,32 @@ open class Interval, TSize : Comparable>( val size: TSize get() = operations.getDistance( start, end ) + /** + * Safe initialization of a calculated interval in case loss of precision can cause invalid initialization. + * I.e., coerce values to the minimum and maximum allowed values, and collapse the interval to a single value in + * case its size is zero. + */ + private fun safeInterval( + start: T, + isStartIncluded: Boolean, + end: T, + isEndIncluded: Boolean + ): Interval + { + val coercedStart = coerceMinMax( start ) + val coercedEnd = coerceMinMax( end ) + + val interval = + if ( coercedStart == coercedEnd ) Interval( coercedStart, true, coercedEnd, true, operations ) + else Interval( coercedStart, isStartIncluded, coercedEnd, isEndIncluded, operations ) + return interval + } + + private fun coerceMinMax( value: T ) = + if ( value > operations.maxValue ) operations.maxValue + else if ( value < operations.minValue ) operations.minValue + else value + override fun iterator(): Iterator> = listOf( this ).iterator() override fun getBounds(): Interval = this.canonicalize() @@ -190,13 +216,9 @@ open class Interval, TSize : Comparable>( val toShift = if ( overflows ) maxShift else amount // Return shifted interval. - val shifted = Interval( - operations.unsafeShift( start, toShift, invertDirection ), - isStartIncluded, - operations.unsafeShift( end, toShift, invertDirection ), - isEndIncluded, - operations - ) + val shiftedStart = operations.unsafeShift( start, toShift, invertDirection ) + val shiftedEnd = operations.unsafeShift( end, toShift, invertDirection ) + val shifted = safeInterval( shiftedStart, isStartIncluded, shiftedEnd, isEndIncluded ) return ShiftResult( shifted, toShift ) }