From 9f99e514837f006ca8c082efcd154412a251aa14 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 12 Nov 2026 13:03:12 +0100 Subject: [PATCH] Give MoleculeViewModel a frame clock on the JVM RecompositionMode.ContextClock needs a MonotonicFrameClock in the coroutine context. Dispatchers.Main on the JVM carries none, so a MoleculeViewModel's presenter failed inside the sharing coroutine and uiState never moved past its initial value. The JVM actual now adds a clock that hands out a frame every 16 ms through delay, which runs on the Swing timer in a desktop app and on virtual time under a test dispatcher. --- app/molecule/molecule-public/build.gradle.kts | 6 ++ .../molecule/public/MoleculeViewModel.jvm.kt | 22 ++++++- .../molecule/public/MoleculeViewModelTest.kt | 63 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/molecule/molecule-public/src/jvmTest/kotlin/com/hedvig/android/molecule/public/MoleculeViewModelTest.kt diff --git a/app/molecule/molecule-public/build.gradle.kts b/app/molecule/molecule-public/build.gradle.kts index 3665e82866..ce72039628 100644 --- a/app/molecule/molecule-public/build.gradle.kts +++ b/app/molecule/molecule-public/build.gradle.kts @@ -17,5 +17,11 @@ kotlin { implementation(libs.jetbrains.compose.runtime) implementation(libs.coroutines.core) } + jvmTest.dependencies { + implementation(libs.assertK) + implementation(libs.coroutines.test) + implementation(libs.junit) + implementation(libs.turbine) + } } } diff --git a/app/molecule/molecule-public/src/jvmMain/kotlin/com/hedvig/android/molecule/public/MoleculeViewModel.jvm.kt b/app/molecule/molecule-public/src/jvmMain/kotlin/com/hedvig/android/molecule/public/MoleculeViewModel.jvm.kt index 510aafe072..175a4b7cff 100644 --- a/app/molecule/molecule-public/src/jvmMain/kotlin/com/hedvig/android/molecule/public/MoleculeViewModel.jvm.kt +++ b/app/molecule/molecule-public/src/jvmMain/kotlin/com/hedvig/android/molecule/public/MoleculeViewModel.jvm.kt @@ -1,6 +1,26 @@ package com.hedvig.android.molecule.public +import androidx.compose.runtime.MonotonicFrameClock import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay -internal actual val mainDispatcher: CoroutineContext = Dispatchers.Main +/** + * [Dispatchers.Main] on the JVM carries no [MonotonicFrameClock], which `RecompositionMode.ContextClock` requires, so + * one is added here. + */ +internal actual val mainDispatcher: CoroutineContext = Dispatchers.Main + FixedRateFrameClock + +/** + * Hands out a frame every 16 ms, close to the 60 Hz cadence the Android and iOS display clocks give. The wait is a + * [delay], so it runs on the frame's dispatcher (the Swing timer in a desktop app, virtual time under a test + * dispatcher) and needs no UI window to exist. + */ +private object FixedRateFrameClock : MonotonicFrameClock { + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + delay(FRAME_DURATION_MILLIS) + return onFrame(System.nanoTime()) + } + + private const val FRAME_DURATION_MILLIS = 16L +} diff --git a/app/molecule/molecule-public/src/jvmTest/kotlin/com/hedvig/android/molecule/public/MoleculeViewModelTest.kt b/app/molecule/molecule-public/src/jvmTest/kotlin/com/hedvig/android/molecule/public/MoleculeViewModelTest.kt new file mode 100644 index 0000000000..0d0a8c2283 --- /dev/null +++ b/app/molecule/molecule-public/src/jvmTest/kotlin/com/hedvig/android/molecule/public/MoleculeViewModelTest.kt @@ -0,0 +1,63 @@ +package com.hedvig.android.molecule.public + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +class MoleculeViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the presenter emits a new state after an event`() = runTest { + val viewModel = CounterViewModel() + + viewModel.uiState.test { + assertThat(awaitItem()).isEqualTo(0) + viewModel.emit(CounterEvent.Increment) + assertThat(awaitItem()).isEqualTo(1) + } + } +} + +private sealed interface CounterEvent { + data object Increment : CounterEvent +} + +private class CounterViewModel : MoleculeViewModel( + initialState = 0, + presenter = CounterPresenter(), +) + +private class CounterPresenter : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: Int): Int { + var count by remember { mutableIntStateOf(lastState) } + CollectEvents { event -> + when (event) { + CounterEvent.Increment -> count++ + } + } + return count + } +}