Snap for 12770256 from 31161792a2727f369781cf663d4caa60defabcac to 25Q1-release

Change-Id: I6966f72db1c68d7b39d44c3393a3c71626e2e323
diff --git a/mechanics/src/com/android/mechanics/GestureContext.kt b/mechanics/src/com/android/mechanics/GestureContext.kt
new file mode 100644
index 0000000..00665f8
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/GestureContext.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed 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 com.android.mechanics
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.android.mechanics.spec.InputDirection
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Gesture-specific context to augment [MotionValue.currentInput].
+ *
+ * This context helps to capture the user's intent, and should be provided to [MotionValue]s that
+ * respond to a user gesture.
+ */
+@Stable
+interface GestureContext {
+
+    /**
+     * The intrinsic direction of the [MotionValue.currentInput].
+     *
+     * This property determines which of the [DirectionalMotionSpec] from the [MotionSpec] is used,
+     * and also prevents flip-flopping of the output value on tiny input-changes around a
+     * breakpoint.
+     *
+     * If the [MotionValue.currentInput] is driven - directly or indirectly - by a user gesture,
+     * this property should only change direction after the gesture travelled a significant distance
+     * in the opposite direction.
+     *
+     * @see DistanceGestureContext for a default implementation.
+     */
+    val direction: InputDirection
+
+    /**
+     * The gesture distance of the current gesture, in pixels.
+     *
+     * Used solely for the [GestureDistance] [Guarantee]. Can be hard-coded to a static value if
+     * this type of [Guarantee] is not used.
+     */
+    val distance: Float
+}
+
+/** [GestureContext] implementation for manually set values. */
+class ProvidedGestureContext(direction: InputDirection, distance: Float) : GestureContext {
+    override var direction by mutableStateOf(direction)
+    override var distance by mutableFloatStateOf(distance)
+}
+
+/**
+ * [GestureContext] driven by a gesture distance.
+ *
+ * The direction is determined from the gesture input, where going further than
+ * [directionChangeSlop] in the opposite direction toggles the direction.
+ *
+ * @param initialDistance The initial [distance] of the [GestureContext]
+ * @param initialDirection The initial [direction] of the [GestureContext]
+ * @param directionChangeSlop the amount [distance] must be moved in the opposite direction for the
+ *   [direction] to flip.
+ */
+class DistanceGestureContext(
+    initialDistance: Float,
+    initialDirection: InputDirection,
+    directionChangeSlop: Float,
+) : GestureContext {
+    init {
+        require(directionChangeSlop > 0) {
+            "directionChangeSlop must be greater than 0, was $directionChangeSlop"
+        }
+    }
+
+    override var direction by mutableStateOf(initialDirection)
+        private set
+
+    private var furthestDistance by mutableFloatStateOf(initialDistance)
+    private var _distance by mutableFloatStateOf(initialDistance)
+
+    override var distance: Float
+        get() = _distance
+        /**
+         * Updates the [distance].
+         *
+         * This flips the [direction], if the [value] is further than [directionChangeSlop] away
+         * from the furthest recorded value regarding to the current [direction].
+         */
+        set(value) {
+            _distance = value
+            this.direction =
+                when (direction) {
+                    InputDirection.Max -> {
+                        if (furthestDistance - value > directionChangeSlop) {
+                            furthestDistance = value
+                            InputDirection.Min
+                        } else {
+                            furthestDistance = max(value, furthestDistance)
+                            InputDirection.Max
+                        }
+                    }
+
+                    InputDirection.Min -> {
+                        if (value - furthestDistance > directionChangeSlop) {
+                            furthestDistance = value
+                            InputDirection.Max
+                        } else {
+                            furthestDistance = min(value, furthestDistance)
+                            InputDirection.Min
+                        }
+                    }
+                }
+        }
+
+    private var _directionChangeSlop by mutableFloatStateOf(directionChangeSlop)
+
+    var directionChangeSlop: Float
+        get() = _directionChangeSlop
+
+        /**
+         * This flips the [direction], if the current [direction] is further than the new
+         * directionChangeSlop [value] away from the furthest recorded value regarding to the
+         * current [direction].
+         */
+        set(value) {
+            require(value > 0) { "directionChangeSlop must be greater than 0, was $value" }
+
+            _directionChangeSlop = value
+
+            when (direction) {
+                InputDirection.Max -> {
+                    if (furthestDistance - distance > directionChangeSlop) {
+                        furthestDistance = distance
+                        direction = InputDirection.Min
+                    }
+                }
+                InputDirection.Min -> {
+                    if (distance - furthestDistance > directionChangeSlop) {
+                        furthestDistance = value
+                        direction = InputDirection.Max
+                    }
+                }
+            }
+        }
+
+    /**
+     * Sets [distance] and [direction] to the specified values.
+     *
+     * This also resets memoized [furthestDistance], which is used to determine the direction
+     * change.
+     */
+    fun reset(distance: Float, direction: InputDirection) {
+        this.distance = distance
+        this.direction = direction
+        this.furthestDistance = distance
+    }
+}
diff --git a/mechanics/src/com/android/mechanics/spec/InputDirection.kt b/mechanics/src/com/android/mechanics/spec/InputDirection.kt
index 5ff77e7..58fa590 100644
--- a/mechanics/src/com/android/mechanics/spec/InputDirection.kt
+++ b/mechanics/src/com/android/mechanics/spec/InputDirection.kt
@@ -25,7 +25,7 @@
  * The directions are labelled [Min] and [Max] to reflect descending and ascending input values
  * respectively, but it does not imply an spatial direction.
  */
-enum class InputDirection {
-    Min,
-    Max,
+enum class InputDirection(val sign: Int) {
+    Min(sign = -1),
+    Max(sign = +1),
 }
diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt
index 7471398..4bd4240 100644
--- a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt
+++ b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt
@@ -56,6 +56,63 @@
     val resetSpring: SpringParameters = DefaultResetSpring,
     val segmentHandlers: Map<SegmentKey, OnChangeSegmentHandler> = emptyMap(),
 ) {
+
+    /** The [DirectionalMotionSpec] for the specified [direction]. */
+    operator fun get(direction: InputDirection): DirectionalMotionSpec {
+        return when (direction) {
+            InputDirection.Min -> minDirection
+            InputDirection.Max -> maxDirection
+        }
+    }
+
+    /** Whether this spec contains a segment with the specified [segmentKey]. */
+    fun containsSegment(segmentKey: SegmentKey): Boolean {
+        return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1
+    }
+
+    /**
+     * The [SegmentData] for an input with the specified [position] and [direction].
+     *
+     * The returned [SegmentData] will be cached while [SegmentData.isValidForInput] returns `true`.
+     */
+    fun segmentAtInput(position: Float, direction: InputDirection): SegmentData {
+        require(position.isFinite())
+
+        return with(get(direction)) {
+            var idx = findBreakpointIndex(position)
+            if (direction == InputDirection.Min && breakpoints[idx].position == position) {
+                // The segment starts at `position`. Since the breakpoints are sorted ascending, no
+                // matter the spec's direction, need to return the previous segment in the min
+                // direction.
+                idx--
+            }
+
+            SegmentData(
+                this@MotionSpec,
+                breakpoints[idx],
+                breakpoints[idx + 1],
+                direction,
+                mappings[idx],
+            )
+        }
+    }
+
+    /**
+     * Looks up the new [SegmentData] once the [currentSegment] is not valid for an input with
+     * [newPosition] and [newDirection].
+     *
+     * This will delegate to the [segmentHandlers], if registered for the [currentSegment]'s key.
+     */
+    internal fun onChangeSegment(
+        currentSegment: SegmentData,
+        newPosition: Float,
+        newDirection: InputDirection,
+    ): SegmentData {
+        val segmentChangeHandler = segmentHandlers[currentSegment.key]
+        return segmentChangeHandler?.invoke(this, currentSegment, newPosition, newDirection)
+            ?: segmentAtInput(newPosition, newDirection)
+    }
+
     companion object {
         /**
          * Default spring parameters for the reset spring. Matches the Fast Spatial spring of the
@@ -97,10 +154,11 @@
     /**
      * Returns the index of the closest breakpoint where `Breakpoint.position <= position`.
      *
-     * Guaranteed to be a valid index into [breakpoints], and guaranteed not to be the last element.
+     * Guaranteed to be a valid index into [breakpoints], and guaranteed to be neither the first nor
+     * the last element.
      *
      * @param position the position in the input domain.
-     * @return Index into [breakpoints], guaranteed to be in range `0..breakpoints.size - 2`
+     * @return Index into [breakpoints], guaranteed to be in range `1..breakpoints.size - 2`
      */
     fun findBreakpointIndex(position: Float): Int {
         require(position.isFinite())
@@ -119,6 +177,22 @@
         return result
     }
 
+    /**
+     * The index of the breakpoint with the specified [breakpointKey], or `-1` if no such breakpoint
+     * exists.
+     */
+    fun findBreakpointIndex(breakpointKey: BreakpointKey): Int {
+        return breakpoints.indexOfFirst { it.key == breakpointKey }
+    }
+
+    /** Index into [mappings] for the specified [segmentKey], or `-1` if no such segment exists. */
+    fun findSegmentIndex(segmentKey: SegmentKey): Int {
+        val result = breakpoints.indexOfFirst { it.key == segmentKey.minBreakpoint }
+        if (result < 0 || breakpoints[result + 1].key != segmentKey.maxBreakpoint) return -1
+
+        return result
+    }
+
     companion object {
         /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */
         val Empty =
diff --git a/mechanics/src/com/android/mechanics/spec/Segment.kt b/mechanics/src/com/android/mechanics/spec/Segment.kt
index 29ead4d..14b1f40 100644
--- a/mechanics/src/com/android/mechanics/spec/Segment.kt
+++ b/mechanics/src/com/android/mechanics/spec/Segment.kt
@@ -40,6 +40,7 @@
  * respective breakpoint.
  */
 data class SegmentData(
+    val spec: MotionSpec,
     val minBreakpoint: Breakpoint,
     val maxBreakpoint: Breakpoint,
     val direction: InputDirection,
@@ -62,6 +63,18 @@
             InputDirection.Min -> inputPosition > minBreakpoint.position
         }
     }
+
+    /**
+     * The breakpoint at the side of the segment's start.
+     *
+     * The [entryBreakpoint]'s [Guarantee] is the relevant guarantee for this segment.
+     */
+    val entryBreakpoint: Breakpoint
+        get() =
+            when (direction) {
+                InputDirection.Max -> minBreakpoint
+                InputDirection.Min -> maxBreakpoint
+            }
 }
 
 /**
diff --git a/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt b/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt
new file mode 100644
index 0000000..4784f9e
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed 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 com.android.mechanics
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.mechanics.spec.InputDirection
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.nextDown
+import kotlin.math.nextUp
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DistanceGestureContextTest {
+
+    @Test
+    fun setDistance_maxDirection_increasingInput_keepsDirection() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 0f,
+                initialDirection = InputDirection.Max,
+                directionChangeSlop = 5f,
+            )
+
+        for (value in 0..6) {
+            underTest.distance = value.toFloat()
+            assertThat(underTest.direction).isEqualTo(InputDirection.Max)
+        }
+    }
+
+    @Test
+    fun setDistance_minDirection_decreasingInput_keepsDirection() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 0f,
+                initialDirection = InputDirection.Min,
+                directionChangeSlop = 5f,
+            )
+
+        for (value in 0 downTo -6) {
+            underTest.distance = value.toFloat()
+            assertThat(underTest.direction).isEqualTo(InputDirection.Min)
+        }
+    }
+
+    @Test
+    fun setDistance_maxDirection_decreasingInput_keepsDirection_belowDirectionChangeSlop() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 0f,
+                initialDirection = InputDirection.Max,
+                directionChangeSlop = 5f,
+            )
+
+        underTest.distance = -5f
+        assertThat(underTest.direction).isEqualTo(InputDirection.Max)
+    }
+
+    @Test
+    fun setDistance_maxDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 0f,
+                initialDirection = InputDirection.Max,
+                directionChangeSlop = 5f,
+            )
+
+        underTest.distance = (-5f).nextDown()
+        assertThat(underTest.direction).isEqualTo(InputDirection.Min)
+    }
+
+    @Test
+    fun setDistance_minDirection_increasingInput_keepsDirection_belowDirectionChangeSlop() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 0f,
+                initialDirection = InputDirection.Min,
+                directionChangeSlop = 5f,
+            )
+
+        underTest.distance = 5f
+        assertThat(underTest.direction).isEqualTo(InputDirection.Min)
+    }
+
+    @Test
+    fun setDistance_minDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 0f,
+                initialDirection = InputDirection.Min,
+                directionChangeSlop = 5f,
+            )
+
+        underTest.distance = 5f.nextUp()
+        assertThat(underTest.direction).isEqualTo(InputDirection.Max)
+    }
+
+    @Test
+    fun reset_resetsFurthestValue() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 10f,
+                initialDirection = InputDirection.Max,
+                directionChangeSlop = 1f,
+            )
+
+        underTest.reset(5f, direction = InputDirection.Max)
+        assertThat(underTest.direction).isEqualTo(InputDirection.Max)
+        assertThat(underTest.distance).isEqualTo(5f)
+
+        underTest.distance -= 1f
+        assertThat(underTest.direction).isEqualTo(InputDirection.Max)
+        assertThat(underTest.distance).isEqualTo(4f)
+
+        underTest.distance = underTest.distance.nextDown()
+        assertThat(underTest.direction).isEqualTo(InputDirection.Min)
+        assertThat(underTest.distance).isWithin(0.0001f).of(4f)
+    }
+
+    @Test
+    fun setDirectionChangeSlop_smallerThanCurrentDelta_switchesDirection() {
+        val underTest =
+            DistanceGestureContext(
+                initialDistance = 10f,
+                initialDirection = InputDirection.Max,
+                directionChangeSlop = 5f,
+            )
+
+        underTest.distance -= 2f
+        assertThat(underTest.direction).isEqualTo(InputDirection.Max)
+        assertThat(underTest.distance).isEqualTo(8f)
+
+        underTest.directionChangeSlop = 1f
+        assertThat(underTest.direction).isEqualTo(InputDirection.Min)
+        assertThat(underTest.distance).isEqualTo(8f)
+    }
+}
diff --git a/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt
new file mode 100644
index 0000000..d73f39b
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed 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 com.android.mechanics.spec
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.mechanics.spring.SpringParameters
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.nextDown
+import kotlin.math.nextUp
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DirectionalMotionSpecTest {
+
+    @Test
+    fun noBreakpoints_throws() {
+        assertFailsWith<IllegalArgumentException> {
+            DirectionalMotionSpec(emptyList(), emptyList())
+        }
+    }
+
+    @Test
+    fun wrongSentinelBreakpoints_throws() {
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+
+        assertFailsWith<IllegalArgumentException> {
+            DirectionalMotionSpec(listOf(breakpoint1, breakpoint2), listOf(Mapping.Identity))
+        }
+    }
+
+    @Test
+    fun tooFewMappings_throws() {
+        assertFailsWith<IllegalArgumentException> {
+            DirectionalMotionSpec(listOf(Breakpoint.minLimit, Breakpoint.maxLimit), emptyList())
+        }
+    }
+
+    @Test
+    fun tooManyMappings_throws() {
+        assertFailsWith<IllegalArgumentException> {
+            DirectionalMotionSpec(
+                listOf(Breakpoint.minLimit, Breakpoint.maxLimit),
+                listOf(Mapping.One, Mapping.Two),
+            )
+        }
+    }
+
+    @Test
+    fun breakpointsOutOfOrder_throws() {
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+        assertFailsWith<IllegalArgumentException> {
+            DirectionalMotionSpec(
+                listOf(Breakpoint.minLimit, breakpoint2, breakpoint1, Breakpoint.maxLimit),
+                listOf(Mapping.Zero, Mapping.One, Mapping.Two),
+            )
+        }
+    }
+
+    @Test
+    fun findBreakpointIndex_returnsMinForEmptySpec() {
+        val underTest = DirectionalMotionSpec.builder(Spring).complete()
+
+        assertThat(underTest.findBreakpointIndex(0f)).isEqualTo(0)
+        assertThat(underTest.findBreakpointIndex(Float.MAX_VALUE)).isEqualTo(0)
+        assertThat(underTest.findBreakpointIndex(-Float.MAX_VALUE)).isEqualTo(0)
+    }
+
+    @Test
+    fun findBreakpointIndex_throwsForNonFiniteInput() {
+        val underTest = DirectionalMotionSpec.builder(Spring).complete()
+
+        assertFailsWith<IllegalArgumentException> { underTest.findBreakpointIndex(Float.NaN) }
+        assertFailsWith<IllegalArgumentException> {
+            underTest.findBreakpointIndex(Float.NEGATIVE_INFINITY)
+        }
+        assertFailsWith<IllegalArgumentException> {
+            underTest.findBreakpointIndex(Float.POSITIVE_INFINITY)
+        }
+    }
+
+    @Test
+    fun findBreakpointIndex_atBreakpoint_returnsIndex() {
+        val underTest =
+            DirectionalMotionSpec.builder(Spring).toBreakpoint(10f).completeWith(Mapping.Identity)
+
+        assertThat(underTest.findBreakpointIndex(10f)).isEqualTo(1)
+    }
+
+    @Test
+    fun findBreakpointIndex_afterBreakpoint_returnsPreviousIndex() {
+        val underTest =
+            DirectionalMotionSpec.builder(Spring).toBreakpoint(10f).completeWith(Mapping.Identity)
+
+        assertThat(underTest.findBreakpointIndex(10f.nextUp())).isEqualTo(1)
+    }
+
+    @Test
+    fun findBreakpointIndex_beforeBreakpoint_returnsIndex() {
+        val underTest =
+            DirectionalMotionSpec.builder(Spring).toBreakpoint(10f).completeWith(Mapping.Identity)
+
+        assertThat(underTest.findBreakpointIndex(10f.nextDown())).isEqualTo(0)
+    }
+
+    @Test
+    fun findBreakpointIndexByKey_returnsIndex() {
+        val underTest =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .completeWith(Mapping.Identity)
+
+        assertThat(underTest.findBreakpointIndex(B1)).isEqualTo(1)
+    }
+
+    @Test
+    fun findBreakpointIndexByKey_unknown_returnsMinusOne() {
+        val underTest =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .completeWith(Mapping.Identity)
+
+        assertThat(underTest.findBreakpointIndex(B2)).isEqualTo(-1)
+    }
+
+    @Test
+    fun findSegmentIndex_returnsIndexForSegment_ignoringDirection() {
+        val underTest =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+
+        assertThat(underTest.findSegmentIndex(SegmentKey(B1, B2, InputDirection.Max))).isEqualTo(1)
+        assertThat(underTest.findSegmentIndex(SegmentKey(B1, B2, InputDirection.Min))).isEqualTo(1)
+    }
+
+    @Test
+    fun findSegmentIndex_forInvalidKeys_returnsMinusOne() {
+        val underTest =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .continueWith(Mapping.One)
+                .toBreakpoint(30f, key = B3)
+                .completeWith(Mapping.Identity)
+
+        assertThat(underTest.findSegmentIndex(SegmentKey(B2, B1, InputDirection.Max))).isEqualTo(-1)
+        assertThat(underTest.findSegmentIndex(SegmentKey(B1, B3, InputDirection.Max))).isEqualTo(-1)
+    }
+
+    companion object {
+        val B1 = BreakpointKey("one")
+        val B2 = BreakpointKey("two")
+        val B3 = BreakpointKey("three")
+        val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
+    }
+}
diff --git a/mechanics/tests/src/com/android/mechanics/spec/FluentSpecBuilderTest.kt b/mechanics/tests/src/com/android/mechanics/spec/FluentSpecBuilderTest.kt
index 697b6e1..e950bc7 100644
--- a/mechanics/tests/src/com/android/mechanics/spec/FluentSpecBuilderTest.kt
+++ b/mechanics/tests/src/com/android/mechanics/spec/FluentSpecBuilderTest.kt
@@ -27,7 +27,7 @@
 
     @Test
     fun directionalSpec_buildEmptySpec() {
-        val result = DirectionalMotionSpec.builder(spring).complete()
+        val result = DirectionalMotionSpec.builder(Spring).complete()
 
         assertThat(result).breakpoints().isEmpty()
         assertThat(result).mappings().containsExactly(Mapping.Identity)
@@ -35,7 +35,7 @@
 
     @Test
     fun directionalSpec_buildEmptySpec_inReverse() {
-        val result = DirectionalMotionSpec.reverseBuilder(spring).complete()
+        val result = DirectionalMotionSpec.reverseBuilder(Spring).complete()
 
         assertThat(result).breakpoints().isEmpty()
         assertThat(result).mappings().containsExactly(Mapping.Identity)
@@ -44,15 +44,15 @@
     @Test
     fun motionSpec_sameSpecInBothDirections() {
         val result =
-            MotionSpec.builder(spring, Mapping.Zero)
-                .toBreakpoint(0f, b1)
+            MotionSpec.builder(Spring, Mapping.Zero)
+                .toBreakpoint(0f, B1)
                 .continueWith(Mapping.One)
-                .toBreakpoint(10f, b2)
+                .toBreakpoint(10f, B2)
                 .completeWith(Mapping.Two)
 
         assertThat(result.maxDirection).isSameInstanceAs(result.minDirection)
 
-        assertThat(result.minDirection).breakpoints().keys().containsExactly(b1, b2).inOrder()
+        assertThat(result.minDirection).breakpoints().keys().containsExactly(B1, B2).inOrder()
         assertThat(result.minDirection)
             .mappings()
             .containsExactly(Mapping.Zero, Mapping.One, Mapping.Two)
@@ -62,15 +62,15 @@
     @Test
     fun directionalSpec_addBreakpointsAndMappings() {
         val result =
-            DirectionalMotionSpec.builder(spring, Mapping.Zero)
-                .toBreakpoint(0f, b1)
+            DirectionalMotionSpec.builder(Spring, Mapping.Zero)
+                .toBreakpoint(0f, B1)
                 .continueWith(Mapping.One)
-                .toBreakpoint(10f, b2)
+                .toBreakpoint(10f, B2)
                 .completeWith(Mapping.Two)
 
-        assertThat(result).breakpoints().keys().containsExactly(b1, b2).inOrder()
-        assertThat(result).breakpoints().withKey(b1).isAt(0f)
-        assertThat(result).breakpoints().withKey(b2).isAt(10f)
+        assertThat(result).breakpoints().keys().containsExactly(B1, B2).inOrder()
+        assertThat(result).breakpoints().withKey(B1).isAt(0f)
+        assertThat(result).breakpoints().withKey(B2).isAt(10f)
         assertThat(result)
             .mappings()
             .containsExactly(Mapping.Zero, Mapping.One, Mapping.Two)
@@ -80,15 +80,15 @@
     @Test
     fun directionalSpec_addBreakpointsAndMappings_inReverse() {
         val result =
-            DirectionalMotionSpec.reverseBuilder(spring, Mapping.Two)
-                .toBreakpoint(10f, b2)
+            DirectionalMotionSpec.reverseBuilder(Spring, Mapping.Two)
+                .toBreakpoint(10f, B2)
                 .continueWith(Mapping.One)
-                .toBreakpoint(0f, b1)
+                .toBreakpoint(0f, B1)
                 .completeWith(Mapping.Zero)
 
-        assertThat(result).breakpoints().keys().containsExactly(b1, b2).inOrder()
-        assertThat(result).breakpoints().withKey(b1).isAt(0f)
-        assertThat(result).breakpoints().withKey(b2).isAt(10f)
+        assertThat(result).breakpoints().keys().containsExactly(B1, B2).inOrder()
+        assertThat(result).breakpoints().withKey(B1).isAt(0f)
+        assertThat(result).breakpoints().withKey(B2).isAt(10f)
         assertThat(result)
             .mappings()
             .containsExactly(Mapping.Zero, Mapping.One, Mapping.Two)
@@ -98,20 +98,20 @@
     @Test
     fun directionalSpec_mappingBuilder_setsDefaultSpring() {
         val result =
-            DirectionalMotionSpec.builder(spring)
+            DirectionalMotionSpec.builder(Spring)
                 .toBreakpoint(10f)
                 .jumpTo(20f)
                 .continueWithConstantValue()
                 .complete()
 
-        assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(spring)
+        assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(Spring)
     }
 
     @Test
     fun directionalSpec_mappingBuilder_canOverrideDefaultSpring() {
         val otherSpring = SpringParameters(stiffness = 10f, dampingRatio = 0.1f)
         val result =
-            DirectionalMotionSpec.builder(spring)
+            DirectionalMotionSpec.builder(Spring)
                 .toBreakpoint(10f)
                 .jumpTo(20f, spring = otherSpring)
                 .continueWithConstantValue()
@@ -123,7 +123,7 @@
     @Test
     fun directionalSpec_mappingBuilder_defaultsToNoGuarantee() {
         val result =
-            DirectionalMotionSpec.builder(spring)
+            DirectionalMotionSpec.builder(Spring)
                 .toBreakpoint(10f)
                 .jumpTo(20f)
                 .continueWithConstantValue()
@@ -136,7 +136,7 @@
     fun directionalSpec_mappingBuilder_canSetGuarantee() {
         val guarantee = Guarantee.InputDelta(10f)
         val result =
-            DirectionalMotionSpec.builder(spring)
+            DirectionalMotionSpec.builder(Spring)
                 .toBreakpoint(10f)
                 .jumpTo(20f, guarantee = guarantee)
                 .continueWithConstantValue()
@@ -148,7 +148,7 @@
     @Test
     fun directionalSpec_mappingBuilder_jumpTo_setsAbsoluteValue() {
         val result =
-            DirectionalMotionSpec.builder(spring, Mapping.Fixed(99f))
+            DirectionalMotionSpec.builder(Spring, Mapping.Fixed(99f))
                 .toBreakpoint(10f)
                 .jumpTo(20f)
                 .continueWithConstantValue()
@@ -161,7 +161,7 @@
     @Test
     fun directionalSpec_mappingBuilder_jumpBy_setsRelativeValue() {
         val result =
-            DirectionalMotionSpec.builder(spring, Mapping.Linear(factor = 0.5f))
+            DirectionalMotionSpec.builder(Spring, Mapping.Linear(factor = 0.5f))
                 .toBreakpoint(10f)
                 .jumpBy(30f)
                 .continueWithConstantValue()
@@ -174,7 +174,7 @@
     @Test
     fun directionalSpec_mappingBuilder_continueWithConstantValue_usesSourceValue() {
         val result =
-            DirectionalMotionSpec.builder(spring, Mapping.Linear(factor = 0.5f))
+            DirectionalMotionSpec.builder(Spring, Mapping.Linear(factor = 0.5f))
                 .toBreakpoint(5f)
                 .jumpBy(0f)
                 .continueWithConstantValue()
@@ -186,7 +186,7 @@
     @Test
     fun directionalSpec_mappingBuilder_continueWithFractionalInput_matchesLinearMapping() {
         val result =
-            DirectionalMotionSpec.builder(spring)
+            DirectionalMotionSpec.builder(Spring)
                 .toBreakpoint(5f)
                 .jumpTo(1f)
                 .continueWithFractionalInput(fraction = .1f)
@@ -201,7 +201,7 @@
     @Test
     fun directionalSpec_mappingBuilder_reverse_continueWithFractionalInput_matchesLinearMapping() {
         val result =
-            DirectionalMotionSpec.reverseBuilder(spring)
+            DirectionalMotionSpec.reverseBuilder(Spring)
                 .toBreakpoint(15f)
                 .jumpTo(2f)
                 .continueWithFractionalInput(fraction = .1f)
@@ -216,7 +216,7 @@
     @Test
     fun directionalSpec_mappingBuilder_continueWithTargetValue_matchesLinearMapping() {
         val result =
-            DirectionalMotionSpec.builder(spring)
+            DirectionalMotionSpec.builder(Spring)
                 .toBreakpoint(5f)
                 .jumpTo(1f)
                 .continueWithTargetValue(target = 20f)
@@ -232,7 +232,7 @@
     @Test
     fun directionalSpec_mappingBuilder_reverse_continueWithTargetValue_matchesLinearMapping() {
         val result =
-            DirectionalMotionSpec.reverseBuilder(spring)
+            DirectionalMotionSpec.reverseBuilder(Spring)
                 .toBreakpoint(30f)
                 .jumpTo(20f)
                 .continueWithTargetValue(target = 1f)
@@ -246,9 +246,8 @@
     }
 
     companion object {
-        val spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
-        val b1 = BreakpointKey("One")
-        val b2 = BreakpointKey("Two")
-        val b3 = BreakpointKey("Three")
+        val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
+        val B1 = BreakpointKey("One")
+        val B2 = BreakpointKey("Two")
     }
 }
diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt
index e14d007..3254695 100644
--- a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt
+++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt
@@ -18,10 +18,8 @@
 
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.testing.BreakpointSubject.Companion.assertThat
 import com.google.common.truth.Truth.assertThat
-import kotlin.math.nextDown
-import kotlin.math.nextUp
-import kotlin.test.assertFailsWith
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -29,100 +27,225 @@
 class MotionSpecTest {
 
     @Test
-    fun directionalMotionSpec_noBreakpoints_throws() {
-        assertFailsWith<IllegalArgumentException> {
-            DirectionalMotionSpec(emptyList(), emptyList())
-        }
+    fun containsSegment_unknownSegment_returnsFalse() {
+        val underTest = MotionSpec.builder(Spring).complete()
+        assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse()
     }
 
     @Test
-    fun directionalMotionSpec_wrongSentinelBreakpoints_throws() {
-        val breakpoint1 = Breakpoint(b1, position = 10f, spring, Guarantee.None)
-        val breakpoint2 = Breakpoint(b2, position = 20f, spring, Guarantee.None)
-
-        assertFailsWith<IllegalArgumentException> {
-            DirectionalMotionSpec(listOf(breakpoint1, breakpoint2), listOf(Mapping.Identity))
-        }
-    }
-
-    @Test
-    fun directionalMotionSpec_tooFewMappings_throws() {
-        assertFailsWith<IllegalArgumentException> {
-            DirectionalMotionSpec(listOf(Breakpoint.minLimit, Breakpoint.maxLimit), emptyList())
-        }
-    }
-
-    @Test
-    fun directionalMotionSpec_tooManyMappings_throws() {
-        assertFailsWith<IllegalArgumentException> {
-            DirectionalMotionSpec(
-                listOf(Breakpoint.minLimit, Breakpoint.maxLimit),
-                listOf(Mapping.One, Mapping.Two),
-            )
-        }
-    }
-
-    @Test
-    fun directionalMotionSpec_breakpointsOutOfOrder_throws() {
-        val breakpoint1 = Breakpoint(b1, position = 10f, spring, Guarantee.None)
-        val breakpoint2 = Breakpoint(b2, position = 20f, spring, Guarantee.None)
-        assertFailsWith<IllegalArgumentException> {
-            DirectionalMotionSpec(
-                listOf(Breakpoint.minLimit, breakpoint2, breakpoint1, Breakpoint.maxLimit),
-                listOf(Mapping.Zero, Mapping.One, Mapping.Two),
-            )
-        }
-    }
-
-    @Test
-    fun directionalMotionSpec_findBreakpointIndex_returnsMinForEmptySpec() {
-        val underTest = DirectionalMotionSpec.builder(spring).complete()
-
-        assertThat(underTest.findBreakpointIndex(0f)).isEqualTo(0)
-        assertThat(underTest.findBreakpointIndex(Float.MAX_VALUE)).isEqualTo(0)
-        assertThat(underTest.findBreakpointIndex(-Float.MAX_VALUE)).isEqualTo(0)
-    }
-
-    @Test
-    fun directionalMotionSpec_findBreakpointIndex_throwsForNonFiniteInput() {
-        val underTest = DirectionalMotionSpec.builder(spring).complete()
-
-        assertFailsWith<IllegalArgumentException> { underTest.findBreakpointIndex(Float.NaN) }
-        assertFailsWith<IllegalArgumentException> {
-            underTest.findBreakpointIndex(Float.NEGATIVE_INFINITY)
-        }
-        assertFailsWith<IllegalArgumentException> {
-            underTest.findBreakpointIndex(Float.POSITIVE_INFINITY)
-        }
-    }
-
-    @Test
-    fun directionalMotionSpec_findBreakpointIndex_atBreakpoint_returnsIndex() {
+    fun containsSegment_symmetricSpec_knownSegment_returnsTrue() {
         val underTest =
-            DirectionalMotionSpec.builder(spring).toBreakpoint(10f).completeWith(Mapping.Identity)
+            MotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
 
-        assertThat(underTest.findBreakpointIndex(10f)).isEqualTo(1)
+        assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue()
+        assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isTrue()
     }
 
     @Test
-    fun directionalMotionSpec_findBreakpointIndex_afterBreakpoint_returnsPreviousIndex() {
-        val underTest =
-            DirectionalMotionSpec.builder(spring).toBreakpoint(10f).completeWith(Mapping.Identity)
+    fun containsSegment_asymmetricSpec_knownMaxDirectionSegment_trueOnlyInMaxDirection() {
+        val forward =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+        val reverse = DirectionalMotionSpec.builder(Spring).complete()
 
-        assertThat(underTest.findBreakpointIndex(10f.nextUp())).isEqualTo(1)
+        val underTest = MotionSpec(forward, reverse)
+
+        assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue()
+        assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isFalse()
     }
 
     @Test
-    fun directionalMotionSpec_findBreakpointIndex_beforeBreakpoint_returnsIndex() {
-        val underTest =
-            DirectionalMotionSpec.builder(spring).toBreakpoint(10f).completeWith(Mapping.Identity)
+    fun containsSegment_asymmetricSpec_knownMinDirectionSegment_trueOnlyInMinDirection() {
+        val forward = DirectionalMotionSpec.builder(Spring).complete()
+        val reverse =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
 
-        assertThat(underTest.findBreakpointIndex(10f.nextDown())).isEqualTo(0)
+        val underTest = MotionSpec(forward, reverse)
+
+        assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse()
+        assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isTrue()
+    }
+
+    @Test
+    fun segmentAtInput_emptySpec_maxDirection_segmentDataIsCorrect() {
+        val underTest = MotionSpec.builder(Spring).complete()
+
+        val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Max)
+
+        assertThat(segmentAtInput.spec).isSameInstanceAs(underTest)
+        assertThat(segmentAtInput.minBreakpoint).isSameInstanceAs(Breakpoint.minLimit)
+        assertThat(segmentAtInput.maxBreakpoint).isSameInstanceAs(Breakpoint.maxLimit)
+        assertThat(segmentAtInput.direction).isEqualTo(InputDirection.Max)
+        assertThat(segmentAtInput.mapping).isEqualTo(Mapping.Identity)
+    }
+
+    @Test
+    fun segmentAtInput_emptySpec_minDirection_segmentDataIsCorrect() {
+        val underTest = MotionSpec.builder(Spring).complete()
+
+        val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Min)
+
+        assertThat(segmentAtInput.spec).isSameInstanceAs(underTest)
+        assertThat(segmentAtInput.minBreakpoint).isSameInstanceAs(Breakpoint.minLimit)
+        assertThat(segmentAtInput.maxBreakpoint).isSameInstanceAs(Breakpoint.maxLimit)
+        assertThat(segmentAtInput.direction).isEqualTo(InputDirection.Min)
+        assertThat(segmentAtInput.mapping).isEqualTo(Mapping.Identity)
+    }
+
+    @Test
+    fun segmentAtInput_atBreakpointPosition() {
+        val underTest =
+            MotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+
+        val segmentAtInput = underTest.segmentAtInput(10f, InputDirection.Max)
+
+        assertThat(segmentAtInput.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Max))
+        assertThat(segmentAtInput.minBreakpoint).isAt(10f)
+        assertThat(segmentAtInput.maxBreakpoint).isAt(20f)
+        assertThat(segmentAtInput.mapping).isEqualTo(Mapping.One)
+    }
+
+    @Test
+    fun segmentAtInput_reverse_atBreakpointPosition() {
+        val underTest =
+            MotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+
+        val segmentAtInput = underTest.segmentAtInput(20f, InputDirection.Min)
+
+        assertThat(segmentAtInput.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min))
+        assertThat(segmentAtInput.minBreakpoint).isAt(10f)
+        assertThat(segmentAtInput.maxBreakpoint).isAt(20f)
+        assertThat(segmentAtInput.mapping).isEqualTo(Mapping.One)
+    }
+
+    @Test
+    fun containsSegment_asymmetricSpec_readsFromIndicatedDirection() {
+        val forward =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+        val reverse =
+            DirectionalMotionSpec.builder(Spring)
+                .toBreakpoint(5f, key = B1)
+                .continueWith(Mapping.Two)
+                .toBreakpoint(25f, key = B2)
+                .completeWith(Mapping.Identity)
+
+        val underTest = MotionSpec(forward, reverse)
+
+        val segmentAtInputMax = underTest.segmentAtInput(15f, InputDirection.Max)
+        assertThat(segmentAtInputMax.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Max))
+        assertThat(segmentAtInputMax.minBreakpoint).isAt(10f)
+        assertThat(segmentAtInputMax.maxBreakpoint).isAt(20f)
+        assertThat(segmentAtInputMax.mapping).isEqualTo(Mapping.One)
+
+        val segmentAtInputMin = underTest.segmentAtInput(15f, InputDirection.Min)
+        assertThat(segmentAtInputMin.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min))
+        assertThat(segmentAtInputMin.minBreakpoint).isAt(5f)
+        assertThat(segmentAtInputMin.maxBreakpoint).isAt(25f)
+        assertThat(segmentAtInputMin.mapping).isEqualTo(Mapping.Two)
+    }
+
+    @Test
+    fun onSegmentChanged_noHandler_returnsEqualSegmentForSameInput() {
+        val underTest =
+            MotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+
+        val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max)
+        val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Max)
+        assertThat(segmentAtInput).isEqualTo(onChangedResult)
+    }
+
+    @Test
+    fun onSegmentChanged_noHandler_returnsNewSegmentForNewInput() {
+        val underTest =
+            MotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+
+        val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max)
+        val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min)
+        assertThat(segmentAtInput).isNotEqualTo(onChangedResult)
+
+        assertThat(onChangedResult.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min))
+    }
+
+    @Test
+    fun onSegmentChanged_withHandlerReturningNull_returnsSegmentAtInput() {
+        val underTest =
+            MotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+                .copy(
+                    segmentHandlers =
+                        mapOf(SegmentKey(B1, B2, InputDirection.Max) to { _, _, _ -> null })
+                )
+
+        val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max)
+        val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min)
+
+        assertThat(segmentAtInput).isNotEqualTo(onChangedResult)
+        assertThat(onChangedResult.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min))
+    }
+
+    @Test
+    fun onSegmentChanged_withHandlerReturningSegment_returnsHandlerResult() {
+        val underTest =
+            MotionSpec.builder(Spring)
+                .toBreakpoint(10f, key = B1)
+                .continueWith(Mapping.One)
+                .toBreakpoint(20f, key = B2)
+                .completeWith(Mapping.Identity)
+                .copy(
+                    segmentHandlers =
+                        mapOf(
+                            SegmentKey(B1, B2, InputDirection.Max) to
+                                { _, _, _ ->
+                                    segmentAtInput(0f, InputDirection.Min)
+                                }
+                        )
+                )
+
+        val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max)
+        val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min)
+
+        assertThat(segmentAtInput).isNotEqualTo(onChangedResult)
+        assertThat(onChangedResult.key)
+            .isEqualTo(SegmentKey(Breakpoint.minLimit.key, B1, InputDirection.Min))
     }
 
     companion object {
-        val b1 = BreakpointKey("one")
-        val b2 = BreakpointKey("two")
-        val spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
+        val B1 = BreakpointKey("one")
+        val B2 = BreakpointKey("two")
+        val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
     }
 }
diff --git a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt
index 748ac1d..f66991c 100644
--- a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt
+++ b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt
@@ -26,29 +26,34 @@
 @RunWith(AndroidJUnit4::class)
 class SegmentTest {
 
+    private val fakeSpec = MotionSpec.Empty
+
     @Test
     fun segmentData_isValidForInput_betweenBreakpointsSameDirection_isTrue() {
-        val breakpoint1 = Breakpoint(b1, position = 10f, spring, Guarantee.None)
-        val breakpoint2 = Breakpoint(b2, position = 20f, spring, Guarantee.None)
-        val underTest = SegmentData(breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity)
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+        val underTest =
+            SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity)
 
         assertThat(underTest.isValidForInput(15f, InputDirection.Max)).isTrue()
     }
 
     @Test
     fun segmentData_isValidForInput_betweenBreakpointsOppositeDirection_isFalse() {
-        val breakpoint1 = Breakpoint(b1, position = 10f, spring, Guarantee.None)
-        val breakpoint2 = Breakpoint(b2, position = 20f, spring, Guarantee.None)
-        val underTest = SegmentData(breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity)
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+        val underTest =
+            SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity)
 
         assertThat(underTest.isValidForInput(15f, InputDirection.Min)).isFalse()
     }
 
     @Test
     fun segmentData_isValidForInput_inMaxDirection_sampledAtVariousPositions_matchesExpectation() {
-        val breakpoint1 = Breakpoint(b1, position = 10f, spring, Guarantee.None)
-        val breakpoint2 = Breakpoint(b2, position = 20f, spring, Guarantee.None)
-        val underTest = SegmentData(breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity)
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+        val underTest =
+            SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity)
 
         for ((samplePosition, expectedResult) in
             listOf(5f to true, 10f to true, 15f to true, 20f to false, 25f to false)) {
@@ -60,9 +65,10 @@
 
     @Test
     fun segmentData_isValidForInput_inMinDirection_sampledAtVariousPositions_matchesExpectation() {
-        val breakpoint1 = Breakpoint(b1, position = 10f, spring, Guarantee.None)
-        val breakpoint2 = Breakpoint(b2, position = 20f, spring, Guarantee.None)
-        val underTest = SegmentData(breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity)
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+        val underTest =
+            SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity)
 
         for ((samplePosition, expectedResult) in
             listOf(5f to false, 10f to false, 15f to true, 20f to true, 25f to true)) {
@@ -72,9 +78,29 @@
         }
     }
 
+    @Test
+    fun segmentData_entryBreakpoint_maxDirection_returnsMinBreakpoint() {
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+        val underTest =
+            SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity)
+
+        assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint1)
+    }
+
+    @Test
+    fun segmentData_entryBreakpoint_minDirection_returnsMaxBreakpoint() {
+        val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None)
+        val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None)
+        val underTest =
+            SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity)
+
+        assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint2)
+    }
+
     companion object {
-        val b1 = BreakpointKey("one")
-        val b2 = BreakpointKey("two")
-        val spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
+        val B1 = BreakpointKey("one")
+        val B2 = BreakpointKey("two")
+        val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
     }
 }
diff --git a/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt b/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt
index b0d0833..d06012d 100644
--- a/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt
+++ b/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt
@@ -84,7 +84,7 @@
         initialVelocity: Float,
         parameters: SpringParameters,
     ) = buildList {
-        Animatable(displacement, displacementThreshold).animateTo(
+        Animatable(displacement, DisplacementThreshold).animateTo(
             0f,
             parameters.asSpringSpec(),
             initialVelocity,
@@ -99,9 +99,9 @@
         parameters: SpringParameters,
     ) = buildList {
         var state = SpringState(displacement, initialVelocity)
-        while (!state.isStable(parameters, displacementThreshold)) {
+        while (!state.isStable(parameters, DisplacementThreshold)) {
             add(state)
-            state = state.calculateUpdatedState(frameDelayNanos, parameters)
+            state = state.calculateUpdatedState(FrameDelayNanos, parameters)
         }
     }
 
@@ -131,7 +131,7 @@
             val mechanics = byMechanics.elementAtOrNull(i) ?: SpringState.AtRest
             val compose = byCompose.elementAtOrNull(i) ?: SpringState.AtRest
             assertThat(mechanics.displacement)
-                .isWithin(displacementThreshold)
+                .isWithin(DisplacementThreshold)
                 .of(compose.displacement)
         }
     }
@@ -142,11 +142,11 @@
 
     private fun runTestWithFrameClock(testBody: suspend () -> Unit) = runTest {
         val testScope: TestScope = this
-        withContext(TestMonotonicFrameClock(testScope, frameDelayNanos)) { testBody() }
+        withContext(TestMonotonicFrameClock(testScope, FrameDelayNanos)) { testBody() }
     }
 
     companion object {
-        private val frameDelayNanos: Long = 16_000_000L
-        private val displacementThreshold: Float = 0.01f
+        private val FrameDelayNanos: Long = 16_000_000L
+        private val DisplacementThreshold: Float = 0.01f
     }
 }
diff --git a/mechanics/tests/src/com/android/mechanics/testing/MotionSpecSubject.kt b/mechanics/tests/src/com/android/mechanics/testing/MotionSpecSubject.kt
index a3f7a6b..1a83e06 100644
--- a/mechanics/tests/src/com/android/mechanics/testing/MotionSpecSubject.kt
+++ b/mechanics/tests/src/com/android/mechanics/testing/MotionSpecSubject.kt
@@ -39,14 +39,14 @@
     fun breakpoints(): BreakpointsSubject {
         isNotNull()
 
-        return check("breakpoints").about(BreakpointsSubject.subjectFactory).that(actual)
+        return check("breakpoints").about(BreakpointsSubject.SubjectFactory).that(actual)
     }
 
     /** Assert on the mappings. */
     fun mappings(): MappingsSubject {
         isNotNull()
 
-        return check("mappings").about(MappingsSubject.subjectFactory).that(actual)
+        return check("mappings").about(MappingsSubject.SubjectFactory).that(actual)
     }
 
     companion object {
@@ -76,20 +76,20 @@
 
     fun atPosition(position: Float): BreakpointSubject {
         return check("breakpoint @ $position")
-            .about(BreakpointSubject.subjectFactory)
+            .about(BreakpointSubject.SubjectFactory)
             .that(actual?.breakpoints?.find { it.position == position })
     }
 
     fun withKey(key: BreakpointKey): BreakpointSubject {
         return check("breakpoint with $key]")
-            .about(BreakpointSubject.subjectFactory)
+            .about(BreakpointSubject.SubjectFactory)
             .that(actual?.breakpoints?.find { it.key == key })
     }
 
     companion object {
 
         /** Returns a factory to be used with [Truth.assertAbout]. */
-        val subjectFactory =
+        val SubjectFactory =
             Factory<BreakpointsSubject, DirectionalMotionSpec> { failureMetadata, subject ->
                 BreakpointsSubject(failureMetadata, subject)
             }
@@ -132,10 +132,14 @@
             Correspondence.transforming<Breakpoint, Float>({ it?.position }, "position")
 
         /** Returns a factory to be used with [Truth.assertAbout]. */
-        val subjectFactory =
+        val SubjectFactory =
             Factory<BreakpointSubject, Breakpoint> { failureMetadata, subject ->
                 BreakpointSubject(failureMetadata, subject)
             }
+
+        /** Shortcut for `Truth.assertAbout(subjectFactory).that(breakpoint)`. */
+        fun assertThat(breakpoint: Breakpoint): BreakpointSubject =
+            Truth.assertAbout(SubjectFactory).that(breakpoint)
     }
 }
 
@@ -148,13 +152,13 @@
     /** Assert on the mapping at or after the specified position. */
     fun atOrAfter(position: Float): MappingSubject {
         return check("mapping @ $position")
-            .about(MappingSubject.subjectFactory)
+            .about(MappingSubject.SubjectFactory)
             .that(actual?.run { mappings[findBreakpointIndex(position)] })
     }
 
     companion object {
         /** Returns a factory to be used with [Truth.assertAbout]. */
-        val subjectFactory =
+        val SubjectFactory =
             Factory<MappingsSubject, DirectionalMotionSpec> { failureMetadata, subject ->
                 MappingsSubject(failureMetadata, subject)
             }
@@ -187,9 +191,13 @@
 
     companion object {
         /** Returns a factory to be used with [Truth.assertAbout]. */
-        val subjectFactory =
+        val SubjectFactory =
             Factory<MappingSubject, Mapping> { failureMetadata, subject ->
                 MappingSubject(failureMetadata, subject)
             }
+
+        /** Shortcut for `Truth.assertAbout(subjectFactory).that(mapping)`. */
+        fun assertThat(mapping: Mapping): MappingSubject =
+            Truth.assertAbout(SubjectFactory).that(mapping)
     }
 }