From bc44d87011425cc4aaff52f7ec687b07d7e9ff1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 03:27:03 +0000 Subject: [PATCH] test: wait for CalendarsViewModel scopes before resetMain The calendar ViewModel tests cleared their store and then reset Main, but clearing only requests cancellation. The model's flowOn producers on Dispatchers.Default could still be finishing, and handing their completion back to the Main collector after resetMain threw "The main looper is not available" onto a background thread. runTest then failed whichever test started next with UncaughtExceptionsBeforeTest (seen on CI as Nip46ConsentInfoBuilderTest). Both tests now join the models' viewModelScope jobs before resetMain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../calendar/CalendarsViewModelFlowTest.kt | 10 ++++++++ .../calendar/CalendarsViewModelTest.kt | 23 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt index 1a7920efdc..e504817753 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.calendar import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -33,13 +34,16 @@ import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert.assertEquals @@ -137,14 +141,20 @@ class CalendarsViewModelFlowTest { ) = runBlocking { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val store = ViewModelStore() + var modelScope: Job? = null try { val feed = FeedContentState(CalendarAppointmentsFeedFilter(seeEverythingAccount()), scope, LocalCache) val model = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java] + modelScope = model.viewModelScope.coroutineContext.job model.init(pubKey, feed) block(model) } finally { store.clear() scope.cancel() + // Clearing only requests cancellation. The flowOn producers on Dispatchers.Default can + // still be finishing and hand their completion back to the Main collectors; wait for + // them here, while setMain is still in force, not after tearDown's resetMain. + withTimeout(AWAIT_MS) { modelScope?.join() } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt index 770e0add94..1358a9f67d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt @@ -22,13 +22,19 @@ package com.vitorpamplona.amethyst.calendar import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewMode import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.startOfWeek import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -58,13 +64,24 @@ class CalendarsViewModelTest { store = ViewModelStore() } + private val scopes = mutableListOf() + @After fun tearDown() { store.clear() + // Clearing only requests cancellation: a producer the model runs on Dispatchers.Default + // (its flowOn) can still be finishing, and it hands its completion back to the Main + // collector. Waiting for the scopes keeps that inside setMain, instead of throwing onto a + // background thread after resetMain and failing whichever runTest starts next. + runBlocking { withTimeout(SCOPE_CLOSE_MS) { scopes.joinAll() } } + scopes.clear() Dispatchers.resetMain() } - private fun newModel(): CalendarsViewModel = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java] + private fun newModel(): CalendarsViewModel = + ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java].also { + scopes += it.viewModelScope.coroutineContext.job + } @Test fun aFreshScreenOpensOnToday() { @@ -148,4 +165,8 @@ class CalendarsViewModelTest { assertEquals("the lenses must not share one scroll offset", 4, states.distinct().size) } + + companion object { + private const val SCOPE_CLOSE_MS = 5_000L + } }