diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 91acf926..9eb76b26 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt index eeb3d643..9c751b1e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt @@ -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 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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index a08ab2e0..245a03bf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt index 97b84e75..7f865b07 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayViewModel.kt @@ -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 { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index fbe8c2de..201387fb 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -84,7 +84,6 @@ class ChatViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - val testMainDispatcher = Dispatchers.Main reqCount = 0 mockkStatic(Log::class) @@ -93,13 +92,21 @@ class ChatViewModelTest { every { Log.w(any(), any()) } returns 0 every { Log.e(any(), any(), any()) } returns 0 - mockkStatic(Dispatchers::class) - every { Dispatchers.IO } returns testDispatcher - every { Dispatchers.Main } returns testMainDispatcher - mockkObject(AuthManager) every { AuthManager.getPinnedModels() } returns emptyList() mockkObject(HermesWsClient) + // Catch-all for unstubbed request() calls: the real implementation + // registers a pending call and launches a 120s timeout job on the + // singleton's real-IO wsScope. The timer outlives the test class, + // fires after unmockkAll and crashes an unrelated later test with + // MockK's "can't find stub" (UncaughtExceptionsBeforeTest). Mirror + // the real request()'s send() delegation (tests verify send calls) + // but return a never-completing deferred — same unanswered behavior, + // no leaked timer. + every { HermesWsClient.request(any(), any(), any()) } answers { + HermesWsClient.send(arg(0), arg(1)) {} + CompletableDeferred() + } mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -173,7 +180,9 @@ class ChatViewModelTest { /** Create a ViewModel with the fake repo injected directly. */ private fun createViewModel(startCleanup: Boolean = false): ChatViewModel = - ChatViewModel(app, startCleanup, fakeRepo, testDispatcher) + // Both dispatchers injected — a real ioDispatcher would race the + // test scheduler (repo writes hop it) and shuffle RPC ordering. + ChatViewModel(app, startCleanup, fakeRepo, testDispatcher, testDispatcher) /** * Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 98cc9457..b2f1365c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -102,6 +102,16 @@ class SlashCommandDispatchRpcTest { arg<((String) -> Unit)?>(2)?.invoke(id) id } + // Catch-all for tests that don't stub request() explicitly: the real + // implementation leaks a 120s timeout job on the singleton's real-IO + // wsScope that fires after unmockkAll and crashes a later test with + // MockK's "can't find stub". Mirror the real send() delegation (so + // send-based verifications still see the call) but skip the timer. + // Specific per-test request() stubs registered later take precedence. + every { HermesWsClient.request(any(), any(), any()) } answers { + HermesWsClient.send(arg(0), arg(1)) {} + CompletableDeferred() + } } @After diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index 7c162ccb..a4a77d5d 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -35,11 +35,6 @@ class ConnectViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - val testMainDispatcher = Dispatchers.Main - mockkStatic(Dispatchers::class) - every { Dispatchers.IO } returns testDispatcher - every { Dispatchers.Main } returns testMainDispatcher - mockkObject(AuthManager) mockkObject(ApiClient) mockkStatic(android.util.Base64::class) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt index d1e510e9..df5221d8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt @@ -7,7 +7,6 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,8 +33,6 @@ class GatewayViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - mockkStatic(Dispatchers::class) - every { Dispatchers.IO } returns testDispatcher mockApi = mockk() mockkObject(ApiClient)