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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6
This commit is contained in:
Claude
2026-09-24 03:27:03 +00:00
parent 78efe8901f
commit bc44d87011
2 changed files with 32 additions and 1 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.calendar
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -137,14 +141,20 @@ class CalendarsViewModelFlowTest {
) = runBlocking { ) = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val store = ViewModelStore() val store = ViewModelStore()
var modelScope: Job? = null
try { try {
val feed = FeedContentState(CalendarAppointmentsFeedFilter(seeEverythingAccount()), scope, LocalCache) val feed = FeedContentState(CalendarAppointmentsFeedFilter(seeEverythingAccount()), scope, LocalCache)
val model = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java] val model = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java]
modelScope = model.viewModelScope.coroutineContext.job
model.init(pubKey, feed) model.init(pubKey, feed)
block(model) block(model)
} finally { } finally {
store.clear() store.clear()
scope.cancel() 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() }
} }
} }
@@ -22,13 +22,19 @@ package com.vitorpamplona.amethyst.calendar
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore 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.CalendarsViewMode
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.startOfWeek import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.startOfWeek
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi 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.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -58,13 +64,24 @@ class CalendarsViewModelTest {
store = ViewModelStore() store = ViewModelStore()
} }
private val scopes = mutableListOf<Job>()
@After @After
fun tearDown() { fun tearDown() {
store.clear() 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() 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 @Test
fun aFreshScreenOpensOnToday() { fun aFreshScreenOpensOnToday() {
@@ -148,4 +165,8 @@ class CalendarsViewModelTest {
assertEquals("the lenses must not share one scroll offset", 4, states.distinct().size) assertEquals("the lenses must not share one scroll offset", 4, states.distinct().size)
} }
companion object {
private const val SCOPE_CLOSE_MS = 5_000L
}
} }