Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 3c812619 authored by Mike Schneider's avatar Mike Schneider
Browse files

Common `MotionValueToolkit` for compose- and view-based MotionValues

- The [ViewMotionValueToolkit] migrates away from using the ActivityScenarioRule, making tests much faster to exectue
- The output is shifted one frame earlier, matching what the compose version does (required golden updates)

Test: Existing unit test
Flag: EXEMPT TEST_ONLY
Bug: 409930448
Change-Id: I502490258d6e191cb18ec17ffbe53ab85fc54001
parent 86c41ff7
Loading
Loading
Loading
Loading
+191 −0
Original line number Diff line number Diff line
/*
 * 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.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.mechanics.testing

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import com.android.mechanics.DistanceGestureContext
import com.android.mechanics.MotionValue
import com.android.mechanics.debug.FrameData
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.MotionSpec
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import platform.test.motion.MotionTestRule
import platform.test.motion.golden.FrameId
import platform.test.motion.golden.TimeSeries
import platform.test.motion.golden.TimestampFrameId

/** Toolkit to support [MotionValue] motion tests. */
class ComposeMotionValueToolkit(val composeTestRule: ComposeContentTestRule) :
    MotionValueToolkit<MotionValue, DistanceGestureContext>() {

    override fun goldenTest(
        motionTestRule: MotionTestRule<*>,
        spec: MotionSpec,
        createDerived: (underTest: MotionValue) -> List<MotionValue>,
        semantics: List<CapturedSemantics<*>>,
        initialValue: Float,
        initialDirection: InputDirection,
        directionChangeSlop: Float,
        stableThreshold: Float,
        verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult,
        testInput: suspend InputScope<MotionValue, DistanceGestureContext>.() -> Unit,
    ) = runTest {
        with(composeTestRule) {
            val frameEmitter = MutableStateFlow<Long>(0)

            val testHarness =
                ComposeMotionValueTestHarness(
                    initialValue,
                    initialDirection,
                    spec,
                    stableThreshold,
                    directionChangeSlop,
                    frameEmitter.asStateFlow(),
                    createDerived,
                )
            val underTest = testHarness.underTest
            val derived = testHarness.derived

            val inspectors = buildMap {
                put(underTest, underTest.debugInspector())
                derived.forEach { put(it, it.debugInspector()) }
            }

            setContent {
                LaunchedEffect(Unit) {
                    launch { underTest.keepRunning() }
                    derived.forEach { launch { it.keepRunning() } }
                }
            }

            val recordingJob = launch { testInput.invoke(testHarness) }

            waitForIdle()
            mainClock.autoAdvance = false

            val frameIds = mutableListOf<FrameId>()
            val frameData = mutableMapOf<MotionValue, MutableList<FrameData>>()

            fun recordFrame(frameId: TimestampFrameId) {
                frameIds.add(frameId)
                inspectors.forEach { (motionValue, inspector) ->
                    frameData.computeIfAbsent(motionValue) { mutableListOf() }.add(inspector.frame)
                }
            }

            val startFrameTime = mainClock.currentTime
            recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
            while (!recordingJob.isCompleted) {
                frameEmitter.tryEmit(mainClock.currentTime + 16)
                runCurrent()
                mainClock.advanceTimeByFrame()
                recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
            }

            val timeSeries =
                createTimeSeries(
                    frameIds,
                    frameData.entries
                        .map { (motionValue, frameData) ->
                            val prefix =
                                if (motionValue == underTest) "" else "${motionValue.label}-"
                            prefix to frameData
                        }
                        .sortedBy { it.first },
                    semantics,
                )

            inspectors.values.forEach { it.dispose() }
            verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries)
        }
    }
}

private class ComposeMotionValueTestHarness(
    initialInput: Float,
    initialDirection: InputDirection,
    spec: MotionSpec,
    stableThreshold: Float,
    directionChangeSlop: Float,
    val onFrame: StateFlow<Long>,
    createDerived: (underTest: MotionValue) -> List<MotionValue>,
) : InputScope<MotionValue, DistanceGestureContext> {

    override var input by mutableFloatStateOf(initialInput)
    override val gestureContext: DistanceGestureContext =
        DistanceGestureContext(initialInput, initialDirection, directionChangeSlop)

    override val underTest =
        MotionValue(
            { input },
            gestureContext,
            stableThreshold = stableThreshold,
            initialSpec = spec,
        )

    val derived = createDerived(underTest)

    override fun updateInput(value: Float) {
        input = value
        gestureContext.dragOffset = value
    }

    override suspend fun awaitStable() {
        val debugInspectors = buildList {
            add(underTest.debugInspector())
            addAll(derived.map { it.debugInspector() })
        }
        try {

            onFrame
                // Since this is a state-flow, the current frame is counted too.
                .drop(1)
                .takeWhile { debugInspectors.any { !it.frame.isStable } }
                .collect {}
        } finally {
            debugInspectors.forEach { it.dispose() }
        }
    }

    override suspend fun awaitFrames(frames: Int) {
        onFrame
            // Since this is a state-flow, the current frame is counted too.
            .drop(1)
            .take(frames)
            .collect {}
    }

    override fun reset(position: Float, direction: InputDirection) {
        input = position
        gestureContext.reset(position, direction)
    }
}
+0 −21
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.testing

import android.app.Activity

class EmptyTestActivity : Activity()
+149 −226
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 * Copyright (C) 2025 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.
@@ -14,17 +14,8 @@
 * limitations under the License.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.mechanics.testing

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import com.android.mechanics.DistanceGestureContext
import com.android.mechanics.MotionValue
import com.android.mechanics.debug.FrameData
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.MotionSpec
@@ -32,16 +23,6 @@ import com.android.mechanics.spec.SemanticKey
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.sign
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import platform.test.motion.MotionTestRule
import platform.test.motion.RecordedMotion.Companion.create
import platform.test.motion.golden.DataPoint
@@ -49,44 +30,121 @@ import platform.test.motion.golden.DataPointType
import platform.test.motion.golden.Feature
import platform.test.motion.golden.FrameId
import platform.test.motion.golden.TimeSeries
import platform.test.motion.golden.TimestampFrameId
import platform.test.motion.golden.asDataPoint

/** Toolkit to support [MotionValue] motion tests. */
class MotionValueToolkit(val composeTestRule: ComposeContentTestRule) {
    companion object {
        internal const val TAG = "MotionValueToolkit"
    }
/**
 * Records and verifies a timeseries of the [MotionValue]'s output.
 *
 * Tests provide at a minimum the initial [spec], and a [testInput] function, which defines the
 * [MotionValue] input over time.
 *
 * @param spec The initial [MotionSpec]
 * @param semantics The list of semantic values to capture in the golden
 * @param initialValue The initial value of the [MotionValue]
 * @param initialDirection The initial [InputDirection] of the [MotionValue]
 * @param directionChangeSlop the minimum distance for the input to change in the opposite direction
 *   before the underlying GestureContext changes direction.
 * @param stableThreshold The maximum remaining oscillation amplitude for the springs to be
 *   considered stable.
 * @param verifyTimeSeries Custom verification function to write assertions on the captured time
 *   series. If the function returns `SkipGoldenVerification`, the timeseries won`t be compared to a
 *   golden.
 * @param createDerived (experimental) Creates derived MotionValues
 * @param testInput Controls the MotionValue during the test. The timeseries is being recorded until
 *   the function completes.
 * @see ComposeMotionValueToolkit
 * @see ViewMotionValueToolkit
 */
fun <
    T : MotionValueToolkit<MotionValueType, GestureContextType>,
    MotionValueType,
    GestureContextType,
> MotionTestRule<T>.goldenTest(
    spec: MotionSpec,
    semantics: List<CapturedSemantics<*>> = emptyList(),
    initialValue: Float = 0f,
    initialDirection: InputDirection = InputDirection.Max,
    directionChangeSlop: Float = 5f,
    stableThreshold: Float = 0.01f,
    verifyTimeSeries: VerifyTimeSeriesFn = { VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden },
    createDerived: (underTest: MotionValueType) -> List<MotionValueType> = { emptyList() },
    testInput: suspend (InputScope<MotionValueType, GestureContextType>).() -> Unit,
) {
    toolkit.goldenTest(
        this,
        spec,
        createDerived,
        semantics,
        initialValue,
        initialDirection,
        directionChangeSlop,
        stableThreshold,
        verifyTimeSeries,
        testInput,
    )
}

interface InputScope {
/** Scope to control the MotionValue during a test. */
interface InputScope<MotionValueType, GestureContextType> {
    /** Current input of the `MotionValue` */
    val input: Float
    val gestureContext: DistanceGestureContext
    val underTest: MotionValue
    /** GestureContext created for the `MotionValue` */
    val gestureContext: GestureContextType
    /** MotionValue being tested. */
    val underTest: MotionValueType

    suspend fun awaitStable()
    /** Updates the input value *and* the `gestureContext.dragOffset`. */
    fun updateInput(value: Float)

    suspend fun awaitFrames(frames: Int = 1)
    /** Resets the input value *and* the `gestureContext.dragOffset`, inclusive of direction. */
    fun reset(position: Float, direction: InputDirection)

    var directionChangeSlop: Float
    /** Waits for `underTest` and derived `MotionValues` to become stable. */
    suspend fun awaitStable()

    fun updateValue(position: Float)
    /** Waits for the next "frame" (16ms). */
    suspend fun awaitFrames(frames: Int = 1)
}

    suspend fun animateValueTo(
/** Animates the input linearly from the current [input] to the [targetValue]. */
suspend fun InputScope<*, *>.animateValueTo(
    targetValue: Float,
    changePerFrame: Float = abs(input - targetValue) / 5f,
    )
) {
    require(changePerFrame > 0f)
    var currentValue = input
    val delta = targetValue - currentValue
    val step = changePerFrame * delta.sign

    suspend fun animatedInputSequence(vararg values: Float)
    val stepCount = floor((abs(delta) / changePerFrame) - 1).toInt()
    repeat(stepCount) {
        currentValue += step
        updateInput(currentValue)
        awaitFrames()
    }

    fun reset(position: Float, direction: InputDirection)
    updateInput(targetValue)
    awaitFrames()
}

/** Sets the input to the [values], one value per frame. */
suspend fun InputScope<*, *>.animatedInputSequence(vararg values: Float) {
    values.forEach {
        updateInput(it)
        awaitFrames()
    }
}

/** Custom functions to write assertions on the recorded [TimeSeries] */
typealias VerifyTimeSeriesFn = TimeSeries.() -> VerifyTimeSeriesResult

/** [VerifyTimeSeriesFn] indicating whether the timeseries should be verified the golden file. */
enum class VerifyTimeSeriesResult {
    SkipGoldenVerification,
    AssertTimeSeriesMatchesGolden,
}

/** A semantic value to capture in the golden. */
class CapturedSemantics<T>(
    val key: SemanticKey<T>,
    val dataPointType: DataPointType<T>,
@@ -97,78 +155,29 @@ class CapturedSemantics<T>(
    }
}

fun MotionTestRule<MotionValueToolkit>.goldenTest(
sealed class MotionValueToolkit<MotionValueType, GestureContextType> {
    internal abstract fun goldenTest(
        motionTestRule: MotionTestRule<*>,
        spec: MotionSpec,
    createDerived: (underTest: MotionValue) -> List<MotionValue> = { emptyList() },
    semantics: List<CapturedSemantics<*>> = emptyList(),
    initialValue: Float = 0f,
    initialDirection: InputDirection = InputDirection.Max,
    directionChangeSlop: Float = 5f,
    stableThreshold: Float = 0.01f,
    verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult = {
        VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden
    },
    testInput: suspend InputScope.() -> Unit,
) = runTest {
    with(toolkit.composeTestRule) {
        val frameEmitter = MutableStateFlow<Long>(0)

        val testHarness =
            MotionValueTestHarness(
                initialValue,
                initialDirection,
                spec,
                stableThreshold,
                directionChangeSlop,
                frameEmitter.asStateFlow(),
                createDerived,
        createDerived: (underTest: MotionValueType) -> List<MotionValueType>,
        semantics: List<CapturedSemantics<*>>,
        initialValue: Float,
        initialDirection: InputDirection,
        directionChangeSlop: Float,
        stableThreshold: Float,
        verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult,
        testInput: suspend (InputScope<MotionValueType, GestureContextType>).() -> Unit,
    )
        val underTest = testHarness.underTest
        val derived = testHarness.derived

        val inspectors = buildMap {
            put(underTest, underTest.debugInspector())
            derived.forEach { put(it, it.debugInspector()) }
        }

        setContent {
            LaunchedEffect(Unit) {
                launch { underTest.keepRunning() }
                derived.forEach { launch { it.keepRunning() } }
            }
        }

        val recordingJob = launch { testInput.invoke(testHarness) }

        waitForIdle()
        mainClock.autoAdvance = false

        val frameIds = mutableListOf<FrameId>()
        val frameData = mutableMapOf<MotionValue, MutableList<FrameData>>()

        fun recordFrame(frameId: TimestampFrameId) {
            frameIds.add(frameId)
            inspectors.forEach { (motionValue, inspector) ->
                frameData.computeIfAbsent(motionValue) { mutableListOf() }.add(inspector.frame)
            }
        }

        val startFrameTime = mainClock.currentTime
        recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
        while (!recordingJob.isCompleted) {
            frameEmitter.tryEmit(mainClock.currentTime + 16)
            runCurrent()
            mainClock.advanceTimeByFrame()
            recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
        }

        val timeSeries =
            TimeSeries(
    protected fun createTimeSeries(
        frameIds: List<FrameId>,
        frameData: List<Pair<String, List<FrameData>>>,
        semantics: List<CapturedSemantics<*>>,
    ): TimeSeries {
        return TimeSeries(
            frameIds.toList(),
            buildList {
                    frameData.forEach { (motionValue, frames) ->
                        val prefix = if (motionValue == underTest) "" else "${motionValue.label}-"

                frameData.forEach { (prefix, frames) ->
                    add(Feature("${prefix}input", frames.map { it.input.asDataPoint() }))
                    add(
                        Feature(
@@ -195,103 +204,17 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(
                }
            },
        )

        inspectors.values.forEach { it.dispose() }

        val recordedMotion = create(timeSeries, screenshots = null)
        val skipGoldenVerification = verifyTimeSeries.invoke(recordedMotion.timeSeries)
        if (skipGoldenVerification == VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden) {
            assertThat(recordedMotion).timeSeriesMatchesGolden()
        }
    }
}

private class MotionValueTestHarness(
    initialInput: Float,
    initialDirection: InputDirection,
    spec: MotionSpec,
    stableThreshold: Float,
    directionChangeSlop: Float,
    val onFrame: StateFlow<Long>,
    createDerived: (underTest: MotionValue) -> List<MotionValue>,
) : InputScope {

    override var input by mutableFloatStateOf(initialInput)
    override val gestureContext: DistanceGestureContext =
        DistanceGestureContext(initialInput, initialDirection, directionChangeSlop)

    override val underTest =
        MotionValue(
            { input },
            gestureContext,
            stableThreshold = stableThreshold,
            initialSpec = spec,
        )

    val derived = createDerived(underTest)

    override fun updateValue(position: Float) {
        input = position
        gestureContext.dragOffset = position
    }

    override var directionChangeSlop: Float
        get() = gestureContext.directionChangeSlop
        set(value) {
            gestureContext.directionChangeSlop = value
        }

    override suspend fun awaitStable() {
        val debugInspectors = buildList {
            add(underTest.debugInspector())
            addAll(derived.map { it.debugInspector() })
        }
        try {

            onFrame
                // Since this is a state-flow, the current frame is counted too.
                .drop(1)
                .takeWhile { debugInspectors.any { !it.frame.isStable } }
                .collect {}
        } finally {
            debugInspectors.forEach { it.dispose() }
        }
    }

    override suspend fun awaitFrames(frames: Int) {
        onFrame
            // Since this is a state-flow, the current frame is counted too.
            .drop(1)
            .take(frames)
            .collect {}
    }

    override suspend fun animateValueTo(targetValue: Float, changePerFrame: Float) {
        require(changePerFrame > 0f)
        var currentValue = input
        val delta = targetValue - currentValue
        val step = changePerFrame * delta.sign

        val stepCount = floor((abs(delta) / changePerFrame) - 1).toInt()
        repeat(stepCount) {
            currentValue += step
            updateValue(currentValue)
            awaitFrames()
        }

        updateValue(targetValue)
        awaitFrames()
    }

    override suspend fun animatedInputSequence(vararg values: Float) {
        values.forEach {
            updateValue(it)
            awaitFrames()
        }
    protected fun verifyTimeSeries(
        motionTestRule: MotionTestRule<*>,
        timeSeries: TimeSeries,
        verificationFn: TimeSeries.() -> VerifyTimeSeriesResult,
    ) {
        val recordedMotion = motionTestRule.create(timeSeries, screenshots = null)
        val skipGoldenVerification = verificationFn.invoke(recordedMotion.timeSeries)
        if (skipGoldenVerification == VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden) {
            motionTestRule.assertThat(recordedMotion).timeSeriesMatchesGolden()
        }

    override fun reset(position: Float, direction: InputDirection) {
        input = position
        gestureContext.reset(position, direction)
    }
}
+172 −0

File added.

Preview size limit exceeded, changes collapsed.

+0 −8
Original line number Diff line number Diff line
@@ -18,14 +18,6 @@
    package="com.android.mechanics.tests">

    <application android:debuggable="true">
        <activity
            android:name="com.android.mechanics.testing.EmptyTestActivity"
            android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
        <uses-library android:name="android.test.runner" />
    </application>

Loading