test: make viewmodel and notification receiver tests deterministic (#1207)

This commit is contained in:
m57 authored and GitHub committed 2026-09-21 16:53:10 +03:00
1 parent 29fdb52978
commit fa2b388b24
9 files changed
+52 -66

No files matched your search

@@ -20,6 +20,7 @@ import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -99,6 +100,7 @@ class CronJobsViewModel :
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow() val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null private var loadJob: Job? = null
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val jobsCache = SwrCache<String, List<CronJob>>() private val jobsCache = SwrCache<String, List<CronJob>>()
fun clearScopeOwnedState() { fun clearScopeOwnedState() {
@@ -166,7 +168,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.pauseCronJob(id) } safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -197,7 +199,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.resumeCronJob(id) } safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -218,7 +220,7 @@ class CronJobsViewModel :
fun triggerCronJob(id: String) { fun triggerCronJob(id: String) {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.triggerCronJob(id) } safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
} }
when (result) { when (result) {
@@ -241,7 +243,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.deleteCronJob(id) } safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -292,7 +294,7 @@ class CronJobsViewModel :
fun loadEditorBlueprints() { fun loadEditorBlueprints() {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getCronBlueprints() } safeApiCall { ApiClient.hermesApi.getCronBlueprints() }
} }
when (result) { when (result) {
@@ -319,7 +321,7 @@ class CronJobsViewModel :
fun loadDeliveryTargets() { fun loadDeliveryTargets() {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() } safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() }
} }
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
@@ -345,7 +347,7 @@ class CronJobsViewModel :
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getCronJob(id) } safeApiCall { ApiClient.hermesApi.getCronJob(id) }
} }
when (result) { when (result) {
@@ -462,7 +464,7 @@ class CronJobsViewModel :
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
if (editor.isNew && editor.selectedBlueprintKey != null) { if (editor.isNew && editor.selectedBlueprintKey != null) {
safeApiCall { safeApiCall {
ApiClient.hermesApi.instantiateBlueprint( ApiClient.hermesApi.instantiateBlueprint(
@@ -33,6 +33,7 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -166,6 +167,7 @@ class KanbanViewModel(
private var eventsBoard: String? = null private var eventsBoard: String? = null
private var reloadJob: Job? = null private var reloadJob: Job? = null
private var currentLoadGen: Int = 0 private var currentLoadGen: Int = 0
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
fun loadBoards() { fun loadBoards() {
val endpoint = endpointProvider() val endpoint = endpointProvider()
@@ -184,7 +186,7 @@ class KanbanViewModel(
coroutineScope { coroutineScope {
val prefetchedBoardDeferred = val prefetchedBoardDeferred =
if (previouslySelectedId != null) { if (previouslySelectedId != null) {
async(Dispatchers.IO) { async(ioDispatcher) {
repository.getBoard( repository.getBoard(
board = previouslySelectedId, board = previouslySelectedId,
includeArchived = _uiState.value.includeArchived, includeArchived = _uiState.value.includeArchived,
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,6 +41,7 @@ class PairingViewModel :
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow() val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
private var launchJob: Job? = null private var launchJob: Job? = null
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val pairingCache = SwrCache<String, PairingResponse>() private val pairingCache = SwrCache<String, PairingResponse>()
fun clearScopeOwnedState() { fun clearScopeOwnedState() {
@@ -140,7 +142,7 @@ class PairingViewModel :
if (_uiState.value.actionKey != null) return if (_uiState.value.actionKey != null) return
_uiState.update { it.copy(actionKey = actionKey) } _uiState.update { it.copy(actionKey = actionKey) }
viewModelScope.launch { viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() } val result = withContext(ioDispatcher) { apiCall() }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { onSuccess(it.copy(actionKey = null)) } _uiState.update { onSuccess(it.copy(actionKey = null)) }
@@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -98,6 +99,7 @@ class SkillsViewModel(
private var loadJob: Job? = null private var loadJob: Job? = null
private var searchJob: Job? = null private var searchJob: Job? = null
private val skillsCache = SwrCache<String, List<Skill>>() private val skillsCache = SwrCache<String, List<Skill>>()
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
fun clearScopeOwnedState() { fun clearScopeOwnedState() {
_uiState.update { _uiState.update {
@@ -252,7 +254,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) } safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) }
} }
when (result) { when (result) {
@@ -307,7 +309,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.previewHubSkill(identifier = identifier) } safeApiCall { ApiClient.hermesApi.previewHubSkill(identifier = identifier) }
} }
when (result) { when (result) {
@@ -343,7 +345,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) } safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) }
} }
when (result) { when (result) {
@@ -381,7 +383,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) } safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) }
} }
when (result) { when (result) {
@@ -438,7 +440,7 @@ class SkillsViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) } safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
} }
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
@@ -489,7 +491,7 @@ class SkillsViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) } safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) }
} }
when (result) { when (result) {
@@ -522,7 +524,7 @@ class SkillsViewModel(
_uiState.update { it.copy(isSavingContent = true, saveContentSuccess = false) } _uiState.update { it.copy(isSavingContent = true, saveContentSuccess = false) }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { safeApiCall {
ApiClient.hermesApi.saveSkillContent( ApiClient.hermesApi.saveSkillContent(
SaveSkillContentRequest( SaveSkillContentRequest(
@@ -560,7 +562,7 @@ class SkillsViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(toastMessage = "Updating skills from hub…") } _uiState.update { it.copy(toastMessage = "Updating skills from hub…") }
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() }
} }
when (result) { when (result) {
@@ -70,19 +70,12 @@ class CronJobsViewModelTest {
private fun createViewModel(): CronJobsViewModel { private fun createViewModel(): CronJobsViewModel {
val vm = CronJobsViewModel() val vm = CronJobsViewModel()
vm.ioDispatcher = testDispatcher
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
return vm return vm
} }
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -8,6 +8,8 @@ import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.KanbanApiService import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.repository.KanbanDispatcherNudger
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEvent import com.m57.hermescontrol.data.ws.KanbanEvent
import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
@@ -42,20 +44,24 @@ class KanbanViewModelTest {
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true) private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanViewModel { private fun createViewModel(): KanbanViewModel {
val vm = KanbanViewModel(eventsClientProvider = { mockEventsClient }) val mockNudger = mockk<KanbanDispatcherNudger>(relaxed = true)
val repo =
KanbanRepositoryImpl(
apiProvider = { mockApi },
ioDispatcher = testDispatcher,
nudger = mockNudger,
)
val vm =
KanbanViewModel(
repository = repo,
eventsClientProvider = { mockEventsClient },
)
vm.ioDispatcher = testDispatcher
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
return vm return vm
} }
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -26,15 +26,7 @@ class LogsViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true) private val mockApi = mockk<HermesApiService>(relaxed = true)
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -36,19 +36,12 @@ class PairingViewModelTest {
private fun createViewModel(): PairingViewModel { private fun createViewModel(): PairingViewModel {
val vm = PairingViewModel() val vm = PairingViewModel()
vm.ioDispatcher = testDispatcher
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
return vm return vm
} }
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -35,15 +35,9 @@ class SkillsViewModelTest {
private val mockApi = mockk<HermesApiService>(relaxed = true) private val mockApi = mockk<HermesApiService>(relaxed = true)
private val app = mockk<Application>(relaxed = true) private val app = mockk<Application>(relaxed = true)
/** private fun createViewModel(): SkillsViewModel = SkillsViewModel(app).also { it.ioDispatcher = testDispatcher }
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() { private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
} }
@@ -105,7 +99,7 @@ class SkillsViewModelTest {
@Test @Test
fun `loadHubSources stores sources and featured skills`() { fun `loadHubSources stores sources and featured skills`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.loadHubSources() vm.loadHubSources()
settle() settle()
@@ -122,7 +116,7 @@ class SkillsViewModelTest {
404, 404,
"""{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()), """{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()),
) )
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.loadHubSources() vm.loadHubSources()
settle() settle()
@@ -137,7 +131,7 @@ class SkillsViewModelTest {
@Test @Test
fun `setViewMode HUB triggers sources load when landing is empty`() { fun `setViewMode HUB triggers sources load when landing is empty`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.setViewMode(SkillsViewMode.HUB) vm.setViewMode(SkillsViewMode.HUB)
settle() settle()
@@ -149,7 +143,7 @@ class SkillsViewModelTest {
fun `scanHubSkill stores the scan result for the identifier`() { fun `scanHubSkill stores the scan result for the identifier`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse) Response.success(scanResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill("hermes-index:web-research") vm.scanHubSkill("hermes-index:web-research")
settle() settle()
@@ -166,7 +160,7 @@ class SkillsViewModelTest {
404, 404,
"""{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()), """{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()),
) )
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill("broken:skill") vm.scanHubSkill("broken:skill")
settle() settle()
@@ -178,7 +172,7 @@ class SkillsViewModelTest {
@Test @Test
fun `scanHubSkill ignores blank identifiers`() { fun `scanHubSkill ignores blank identifiers`() {
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill(" ") vm.scanHubSkill(" ")
settle() settle()
@@ -190,7 +184,7 @@ class SkillsViewModelTest {
fun `clearHubScan resets scan state`() { fun `clearHubScan resets scan state`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse) Response.success(scanResponse)
val vm = SkillsViewModel(app) val vm = createViewModel()
vm.scanHubSkill("hermes-index:web-research") vm.scanHubSkill("hermes-index:web-research")
settle() settle()
vm.clearHubScan() vm.clearHubScan()