From fa2b388b24cf3225526a042e1cfdc580d809d89b Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:53:10 +0300 Subject: [PATCH] test: make viewmodel and notification receiver tests deterministic (#1207) --- .../ui/cron/CronJobsViewModel.kt | 18 +++++++------- .../ui/kanban/KanbanViewModel.kt | 4 +++- .../ui/pairing/PairingViewModel.kt | 4 +++- .../ui/skills/SkillsViewModel.kt | 18 +++++++------- .../ui/cron/CronJobsViewModelTest.kt | 9 +------ .../ui/kanban/KanbanViewModelTest.kt | 24 ++++++++++++------- .../ui/logs/LogsViewModelTest.kt | 8 ------- .../ui/pairing/PairingViewModelTest.kt | 9 +------ .../ui/skills/SkillsViewModelTest.kt | 24 +++++++------------ 9 files changed, 52 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index de760d43..27821eac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -20,6 +20,7 @@ import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -99,6 +100,7 @@ class CronJobsViewModel : val uiState: StateFlow = _uiState.asStateFlow() private var loadJob: Job? = null + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO private val jobsCache = SwrCache>() fun clearScopeOwnedState() { @@ -166,7 +168,7 @@ class CronJobsViewModel : } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.pauseCronJob(id) } } val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() @@ -197,7 +199,7 @@ class CronJobsViewModel : } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.resumeCronJob(id) } } val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() @@ -218,7 +220,7 @@ class CronJobsViewModel : fun triggerCronJob(id: String) { viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.triggerCronJob(id) } } when (result) { @@ -241,7 +243,7 @@ class CronJobsViewModel : } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.deleteCronJob(id) } } val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() @@ -292,7 +294,7 @@ class CronJobsViewModel : fun loadEditorBlueprints() { viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getCronBlueprints() } } when (result) { @@ -319,7 +321,7 @@ class CronJobsViewModel : fun loadDeliveryTargets() { viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() } } if (result is NetworkResult.Success) { @@ -345,7 +347,7 @@ class CronJobsViewModel : } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getCronJob(id) } } when (result) { @@ -462,7 +464,7 @@ class CronJobsViewModel : viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (editor.isNew && editor.selectedBlueprintKey != null) { safeApiCall { ApiClient.hermesApi.instantiateBlueprint( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index d11c98fd..a148ab85 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -33,6 +33,7 @@ import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.data.ws.KanbanLiveStatus import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -166,6 +167,7 @@ class KanbanViewModel( private var eventsBoard: String? = null private var reloadJob: Job? = null private var currentLoadGen: Int = 0 + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO fun loadBoards() { val endpoint = endpointProvider() @@ -184,7 +186,7 @@ class KanbanViewModel( coroutineScope { val prefetchedBoardDeferred = if (previouslySelectedId != null) { - async(Dispatchers.IO) { + async(ioDispatcher) { repository.getBoard( board = previouslySelectedId, includeArchived = _uiState.value.includeArchived, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt index f4fb5d3c..9c587305 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt @@ -15,6 +15,7 @@ import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -40,6 +41,7 @@ class PairingViewModel : val uiState: StateFlow = _uiState.asStateFlow() private var launchJob: Job? = null + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO private val pairingCache = SwrCache() fun clearScopeOwnedState() { @@ -140,7 +142,7 @@ class PairingViewModel : if (_uiState.value.actionKey != null) return _uiState.update { it.copy(actionKey = actionKey) } viewModelScope.launch { - val result = withContext(Dispatchers.IO) { apiCall() } + val result = withContext(ioDispatcher) { apiCall() } when (result) { is NetworkResult.Success -> { _uiState.update { onSuccess(it.copy(actionKey = null)) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index b0a7dda7..faa48d9b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -98,6 +99,7 @@ class SkillsViewModel( private var loadJob: Job? = null private var searchJob: Job? = null private val skillsCache = SwrCache>() + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO fun clearScopeOwnedState() { _uiState.update { @@ -252,7 +254,7 @@ class SkillsViewModel( } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) } } when (result) { @@ -307,7 +309,7 @@ class SkillsViewModel( } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.previewHubSkill(identifier = identifier) } } when (result) { @@ -343,7 +345,7 @@ class SkillsViewModel( } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) } } when (result) { @@ -381,7 +383,7 @@ class SkillsViewModel( } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) } } when (result) { @@ -438,7 +440,7 @@ class SkillsViewModel( viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) } } val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() @@ -489,7 +491,7 @@ class SkillsViewModel( } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) } } when (result) { @@ -522,7 +524,7 @@ class SkillsViewModel( _uiState.update { it.copy(isSavingContent = true, saveContentSuccess = false) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.saveSkillContent( SaveSkillContentRequest( @@ -560,7 +562,7 @@ class SkillsViewModel( viewModelScope.launch { _uiState.update { it.copy(toastMessage = "Updating skills from hub…") } val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } } when (result) { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index 73fff0e9..fd1cc2c4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -70,19 +70,12 @@ class CronJobsViewModelTest { private fun createViewModel(): CronJobsViewModel { val vm = CronJobsViewModel() + vm.ioDispatcher = testDispatcher testDispatcher.scheduler.advanceUntilIdle() return vm } - /** - * Pump the test scheduler while letting the real Dispatchers.IO hops - * (safeLaunchLoad / withContext(IO)) land their resumptions. - */ private fun settle() { - repeat(20) { - testDispatcher.scheduler.advanceUntilIdle() - Thread.sleep(10) - } testDispatcher.scheduler.advanceUntilIdle() } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt index d60587ff..e378863e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt @@ -8,6 +8,8 @@ import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.remote.ApiClient 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.KanbanEventsClient import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope @@ -42,20 +44,24 @@ class KanbanViewModelTest { private val mockEventsClient = mockk(relaxed = true) private fun createViewModel(): KanbanViewModel { - val vm = KanbanViewModel(eventsClientProvider = { mockEventsClient }) + val mockNudger = mockk(relaxed = true) + val repo = + KanbanRepositoryImpl( + apiProvider = { mockApi }, + ioDispatcher = testDispatcher, + nudger = mockNudger, + ) + val vm = + KanbanViewModel( + repository = repo, + eventsClientProvider = { mockEventsClient }, + ) + vm.ioDispatcher = testDispatcher testDispatcher.scheduler.advanceUntilIdle() return vm } - /** - * Pump the test scheduler while letting the real Dispatchers.IO hops - * (safeLaunchLoad / withContext(IO)) land their resumptions. - */ private fun settle() { - repeat(20) { - testDispatcher.scheduler.advanceUntilIdle() - Thread.sleep(10) - } testDispatcher.scheduler.advanceUntilIdle() } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/logs/LogsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/logs/LogsViewModelTest.kt index fcf60d5c..ec5ed96d 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/logs/LogsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/logs/LogsViewModelTest.kt @@ -26,15 +26,7 @@ class LogsViewModelTest { private val testDispatcher = StandardTestDispatcher() private val mockApi = mockk(relaxed = true) - /** - * Pump the test scheduler while letting the real Dispatchers.IO hops - * (safeLaunchLoad / withContext(IO)) land their resumptions. - */ private fun settle() { - repeat(20) { - testDispatcher.scheduler.advanceUntilIdle() - Thread.sleep(10) - } testDispatcher.scheduler.advanceUntilIdle() } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt index f612977c..4c55ea7a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt @@ -36,19 +36,12 @@ class PairingViewModelTest { private fun createViewModel(): PairingViewModel { val vm = PairingViewModel() + vm.ioDispatcher = testDispatcher testDispatcher.scheduler.advanceUntilIdle() return vm } - /** - * Pump the test scheduler while letting the real Dispatchers.IO hops - * (safeLaunchLoad / withContext(IO)) land their resumptions. - */ private fun settle() { - repeat(20) { - testDispatcher.scheduler.advanceUntilIdle() - Thread.sleep(10) - } testDispatcher.scheduler.advanceUntilIdle() } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt index 9a29bdc0..e8392cb3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt @@ -35,15 +35,9 @@ class SkillsViewModelTest { private val mockApi = mockk(relaxed = true) private val app = mockk(relaxed = true) - /** - * Pump the test scheduler while letting the real Dispatchers.IO hops - * (safeLaunchLoad / withContext(IO)) land their resumptions. - */ + private fun createViewModel(): SkillsViewModel = SkillsViewModel(app).also { it.ioDispatcher = testDispatcher } + private fun settle() { - repeat(20) { - testDispatcher.scheduler.advanceUntilIdle() - Thread.sleep(10) - } testDispatcher.scheduler.advanceUntilIdle() } @@ -105,7 +99,7 @@ class SkillsViewModelTest { @Test fun `loadHubSources stores sources and featured skills`() { coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) - val vm = SkillsViewModel(app) + val vm = createViewModel() vm.loadHubSources() settle() @@ -122,7 +116,7 @@ class SkillsViewModelTest { 404, """{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()), ) - val vm = SkillsViewModel(app) + val vm = createViewModel() vm.loadHubSources() settle() @@ -137,7 +131,7 @@ class SkillsViewModelTest { @Test fun `setViewMode HUB triggers sources load when landing is empty`() { coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) - val vm = SkillsViewModel(app) + val vm = createViewModel() vm.setViewMode(SkillsViewMode.HUB) settle() @@ -149,7 +143,7 @@ class SkillsViewModelTest { fun `scanHubSkill stores the scan result for the identifier`() { coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns Response.success(scanResponse) - val vm = SkillsViewModel(app) + val vm = createViewModel() vm.scanHubSkill("hermes-index:web-research") settle() @@ -166,7 +160,7 @@ class SkillsViewModelTest { 404, """{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()), ) - val vm = SkillsViewModel(app) + val vm = createViewModel() vm.scanHubSkill("broken:skill") settle() @@ -178,7 +172,7 @@ class SkillsViewModelTest { @Test fun `scanHubSkill ignores blank identifiers`() { - val vm = SkillsViewModel(app) + val vm = createViewModel() vm.scanHubSkill(" ") settle() @@ -190,7 +184,7 @@ class SkillsViewModelTest { fun `clearHubScan resets scan state`() { coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns Response.success(scanResponse) - val vm = SkillsViewModel(app) + val vm = createViewModel() vm.scanHubSkill("hermes-index:web-research") settle() vm.clearHubScan()