diff --git a/README.md b/README.md index 3bfffca..0af8165 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ The following operations are specific to `Interval`: | `lowerBound`, `upperBound` | Corresponds to `start` and `end`, but swapped if `isReversed`. | | `isLowerBoundIncluded`, `isUpperBoundIncluded` | Corresponds to `isStartIncluded` and `isEndIncluded`, but swapped if `isReversed`. | | `size` | The absolute difference between `start` and `end`. | +| `getValueAt()` | Get the value at a given percentage inside (0.0–1.0) or outside (< 0.0, > 1.0) the interval. | | `nonReversed()` | `reverse()` the interval in case it `isReversed`. | | `reverse()` | Return an interval which swaps `start` with `end`, as well as boundary inclusions. | | `canonicalize()` | Return the interval in canonical form. E.g., The canonical form of `[5, 1)` is `[2, 5]` for integer types. | diff --git a/kotlinx.interval.datetime/src/commonTest/kotlin/InstantIntervalTest.kt b/kotlinx.interval.datetime/src/commonTest/kotlin/InstantIntervalTest.kt index 9dbde3c..254d047 100644 --- a/kotlinx.interval.datetime/src/commonTest/kotlin/InstantIntervalTest.kt +++ b/kotlinx.interval.datetime/src/commonTest/kotlin/InstantIntervalTest.kt @@ -7,6 +7,6 @@ import kotlin.time.Duration private val a = Instant.fromEpochSeconds( 0, 50 ) private val b = Instant.fromEpochSeconds( 0, 100 ) -private val c = Instant.fromEpochSeconds( 100, 50 ) +private val c = Instant.fromEpochSeconds( 0, 150 ) object InstantIntervalTest : IntervalTest( a, b, c, b - a, InstantInterval.Operations ) diff --git a/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt b/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt index 4edd311..48953e2 100644 --- a/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt +++ b/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt @@ -8,11 +8,11 @@ import kotlin.test.* /** * Tests for [Interval] which creates intervals for testing using [a], which should be smaller than [b], - * 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]. + * which should be smaller than [c]. The distance between [a] and [b], and [b] and [c], should be equal. + * In addition, for evenly-spaced types of [T], this distance should be greater than the spacing between any two + * subsequent values in the set. + * And, for non-evenly-spaced types, this distance 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>( @@ -59,14 +59,14 @@ abstract class IntervalTest, TSize : Comparable>( assertTrue( a < b && b < c ) assertTrue( abSize > sizeOperations.additiveIdentity ) - // For evenly-spaced types, the distance between a-b, and b-c, should be greater than the spacing. + // The distance between a-b and b-c should be equal. + val abSize = valueOperations.unsafeSubtract( b, a ) + val bcSize = valueOperations.unsafeSubtract( c, b ) + assertTrue( abSize == bcSize ) + + // For evenly-spaced types, the distance should be e greater than the spacing. val spacing = valueOperations.spacing - if ( spacing != null ) - { - val abSize = valueOperations.unsafeSubtract( b, a ) - val bcSize = valueOperations.unsafeSubtract( c, b ) - assertTrue( abSize > spacing && bcSize > spacing ) - } + if ( spacing != null ) assertTrue( abSize > spacing ) } @Test @@ -179,6 +179,82 @@ abstract class IntervalTest, TSize : Comparable>( openIntervals.forEach { assertTrue( a !in it && b !in it ) } } + @Test + fun getValueAt_inside_interval() + { + val acIntervals = createAllInclusionTypeIntervals( a, c ) + + for ( ac in acIntervals ) + { + assertEquals( a,ac.getValueAt( 0.0 ) ) + assertEquals( b, ac.getValueAt( 0.5 ) ) + assertEquals( c, ac.getValueAt( 1.0 ) ) + } + } + + @Test + fun getValueAt_outside_interval() + { + val abIntervals = createAllInclusionTypeIntervals( a, b ) + for ( ab in abIntervals ) + { + assertEquals( c, ab.getValueAt( 2.0 ) ) + } + + val bcIntervals = createAllInclusionTypeIntervals( b, c ) + for ( bc in bcIntervals ) + { + assertEquals( a, bc.getValueAt( -1.0 ) ) + } + } + + @Test + fun getValueAt_reverse_intervals() + { + val adIntervals = createAllInclusionTypeIntervals( a, d ) + + for ( ad in adIntervals ) + { + val nonReversed = ad.getValueAt( 0.2 ) + val reversed = ad.reverse().getValueAt( 0.8 ) + assertEquals( + valueOperations.toDouble( nonReversed ), + valueOperations.toDouble( reversed ), + absoluteTolerance = 0.000000001 + ) + } + } + + @Test + fun getValueAt_returned_value_overflows() + { + val maxIntervals = createAllInclusionTypeIntervals( operations.minValue, operations.maxValue ) + + for ( max in maxIntervals ) + { + assertEquals( max.start, max.getValueAt( 0.0 ) ) + // Loss of precision for large values as part of double conversion is expected. + // Therefore, compare double-converted values which have the same loss of precision. + assertEquals( + valueOperations.toDouble(max.end ), + valueOperations.toDouble( max.getValueAt( 1.0 ) ) + ) + assertFailsWith { max.getValueAt( -0.1 ) } + assertFailsWith { max.getValueAt( 1.1 ) } + } + } + + @Test + fun getValueAt_percentage_overflows() + { + val ab = createClosedInterval( a, b ) + + val maxPercentage = sizeOperations.toDouble( sizeOperations.maxValue ) / sizeOperations.toDouble( ab.size ) + val tooBigPercentage = maxPercentage * 2 + + assertFailsWith { ab.getValueAt( tooBigPercentage ) } + } + @Test fun minus_for_interval_lying_within() { diff --git a/kotlinx.interval.testcases/src/commonMain/kotlin/TypeOperationsTest.kt b/kotlinx.interval.testcases/src/commonMain/kotlin/TypeOperationsTest.kt index a600e73..fb5d315 100644 --- a/kotlinx.interval.testcases/src/commonMain/kotlin/TypeOperationsTest.kt +++ b/kotlinx.interval.testcases/src/commonMain/kotlin/TypeOperationsTest.kt @@ -85,6 +85,24 @@ abstract class TypeOperationsTest>( } } + @Test + fun fromDouble_rounds_to_nearest_value() + { + // Rounding only needed for evenly-spaced types. + val spacing = typeOperations.spacing + if ( spacing == null ) return + + val next = typeOperations.unsafeAdd( a, spacing ) + val aDouble = typeOperations.toDouble( a ) + val nextDouble = typeOperations.toDouble( next ) + val oneThird = (nextDouble - aDouble) / 3 + val closerToA = aDouble + oneThird + val closerToNext = nextDouble - oneThird + + assertEquals( a, typeOperations.fromDouble( closerToA ) ) + assertEquals( next, typeOperations.fromDouble( closerToNext ) ) + } + @Test fun fromDouble_overflows_past_max() { diff --git a/kotlinx.interval/src/commonMain/kotlin/BasicTypeOperations.kt b/kotlinx.interval/src/commonMain/kotlin/BasicTypeOperations.kt index b1ca88e..dddfc02 100644 --- a/kotlinx.interval/src/commonMain/kotlin/BasicTypeOperations.kt +++ b/kotlinx.interval/src/commonMain/kotlin/BasicTypeOperations.kt @@ -160,7 +160,11 @@ internal object UIntOperations : TypeOperations override fun unsafeAdd( a: UInt, b: UInt ): UInt = a + b override fun unsafeSubtract( a: UInt, b: UInt ): UInt = a - b - override fun fromDouble( double: Double ): UInt = double.toUInt() + override fun fromDouble( double: Double ): UInt + { + val uInt = double.toUInt() + return if ( double - uInt.toDouble() >= 0.5 ) uInt + 1.toUInt() else uInt + } override fun toDouble( value: UInt ): Double = value.toDouble() } @@ -174,7 +178,11 @@ internal object ULongOperations : TypeOperations override fun unsafeAdd( a: ULong, b: ULong ): ULong = a + b override fun unsafeSubtract( a: ULong, b: ULong ): ULong = a - b - override fun fromDouble( double: Double ): ULong = double.toULong() + override fun fromDouble( double: Double ): ULong + { + val uLong = double.toULong() + return if ( double - uLong.toDouble() >= 0.5 ) uLong + 1.toULong() else uLong + } override fun toDouble( value: ULong ): Double = value.toDouble() } diff --git a/kotlinx.interval/src/commonMain/kotlin/Interval.kt b/kotlinx.interval/src/commonMain/kotlin/Interval.kt index 94b58fd..d7bbb02 100644 --- a/kotlinx.interval/src/commonMain/kotlin/Interval.kt +++ b/kotlinx.interval/src/commonMain/kotlin/Interval.kt @@ -122,6 +122,34 @@ open class Interval, TSize : Comparable>( && ( upperCompare < 0 || (upperCompare == 0 && isUpperBoundIncluded) ) } + /** + * Get the value at a given percentage within (0.0–1.0) or outside (< 0.0, > 1.0) of the interval. + * 0.0 corresponds to [start] and 1.0 to [end]. + * The calculation is performed using [Double] arithmetic and the result is rounded to the to nearest value of [T]. + * + * @throws ArithmeticException if the resulting value falls outside the range which can be represented by [T]. + */ + fun getValueAt( percentage: Double ): T + { + val normalizedPercentage = if ( isReversed ) 1.0 - percentage else percentage + val shiftLeft = normalizedPercentage < 0 + val absPercentage = if ( shiftLeft ) -normalizedPercentage else normalizedPercentage + val addToLowerBoundDouble = sizeOperations.toDouble( size ) * absPercentage + + // Throw when the resulting value can't be represented by T. + if ( shiftLeft || normalizedPercentage > 1 ) + { + val max = if ( shiftLeft ) operations.minValue else operations.maxValue + val toMax = operations.getDistance( lowerBound, max ) + val toMaxDouble = sizeOperations.toDouble( toMax ) + if ( addToLowerBoundDouble > toMaxDouble ) + throw ArithmeticException( "The resulting value is out of bounds for this type." ) + } + + val addToLowerBoundSize = sizeOperations.fromDouble( addToLowerBoundDouble ) + return operations.unsafeShift( lowerBound, addToLowerBoundSize, shiftLeft ) + } + /** * Return an [IntervalUnion] representing all [T] values in this interval, * excluding all [T] values in the specified interval [toSubtract].