test: de-poison unit suite — drop static Dispatchers mocks, kill leaked WS timers (#833)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 04:18:14 +03:00
1 parent fe79ff42b4
commit 4645a2b6e7
8 files changed
+51 -33

No files matched your search

@@ -105,6 +105,15 @@ object HermesWsClient {
private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Started lazily on the first [connect] instead of in [init]: the
* reconnect-on-network-change subscription only matters once a socket
* has actually been opened. init-time subscription also leaks a live
* real-IO collector into the unit-test JVM (class load), where
* NetworkMonitor emissions crashed it after unmockkAll.
*/
private val networkCollectorStarted = AtomicBoolean(false)
@Volatile
private var reconnectJob: Job? = null
@@ -194,11 +203,6 @@ object HermesWsClient {
}
init {
wsScope.launch {
NetworkMonitor.networkChanges.collect { networkAvailable ->
reconnectForNetworkChange(networkAvailable)
}
}
// Extract credential_warning from gateway.ready / session.info payloads.
wsScope.launch {
events.collect { event ->
@@ -242,6 +246,13 @@ object HermesWsClient {
/** Open a WebSocket connection using settings from [AuthManager]. */
fun connect() {
if (networkCollectorStarted.compareAndSet(false, true)) {
wsScope.launch {
NetworkMonitor.networkChanges.collect { networkAvailable ->
reconnectForNetworkChange(networkAvailable)
}
}
}
var socketToCancel: WebSocket? = null
val shouldOpen =
synchronized(outboundLock) {
@@ -3,10 +3,8 @@ package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
interface ToastHost {
fun clearToast()
@@ -22,7 +20,11 @@ inline fun <T> ViewModel.safeLaunchLoad(
if (currentJob?.isActive == true) return currentJob
onStart()
return viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() }
// No withContext(Dispatchers.IO) hop: Retrofit suspend calls already
// run off the caller thread, and an explicit hop forces every
// consuming test to fake Dispatchers.IO with a static mock — the
// JVM-wide bleed that flakes the suite (see the de-poisoned tests).
val result = apiCall()
when (result) {
is NetworkResult.Success -> onSuccess(result.data)
is NetworkResult.Failure -> onError(result.error.message)
@@ -15,13 +15,11 @@ import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ConnectUiState(
val token: String = "",
@@ -77,9 +75,8 @@ class ConnectViewModel(
if (endpoint == null || state.token.isBlank()) return
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token)
safeApiCall { tempApi.getHealth() }
safeApiCall {
ApiClient.createTempService(endpoint.baseUrl.toString(), state.token).getHealth()
}
if (result is NetworkResult.Success) {
_uiState.update { it.copy(health = result.data) }
@@ -138,9 +135,8 @@ class ConnectViewModel(
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token)
safeApiCall { tempApi.getStatus() }
safeApiCall {
ApiClient.createTempService(endpoint.baseUrl.toString(), state.token).getStatus()
}
when (result) {
is NetworkResult.Success -> {
@@ -8,13 +8,11 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class GatewayUiState(
val isLoading: Boolean = false,
@@ -66,7 +64,7 @@ class GatewayViewModel :
) {
_uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() }
val result = apiCall()
when (result) {
is NetworkResult.Success -> {
_uiState.update {