chore: eliminate all Kotlin compiler warnings in main source (#877)

* chore: eliminate all Kotlin compiler warnings in main source

* test(chat): fix cross-class coroutine leaks poisoning the unit suite

- ChatUpdateCommandTest: inject FakeSlashUsageStore — the real DataStore
  store inits on real IO threads with a relaxed-mock app (cacheDir NPE)
  and leaks an uncaught exception into the next runTest class.
- ChatUpdateCommandTest + SlashCommandDispatchRpcTest: mock
  ProfileSwitchCoordinator.switched (ChatViewModelTest pattern) — VMs
  otherwise park collectors on the singleton flow that crash later
  classes (ProfileSwitchCoordinatorTest) on stale-Main resumption.

Full suite: 944 tests, 0 failures (was 2 flaky per run).

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-08-10 22:56:16 +03:00
committed by GitHub
co-authored by M57
parent b05cffa9c4
commit 1e6e248891
14 changed files with 88 additions and 38 deletions
@@ -94,8 +94,8 @@ class MainActivity : ComponentActivity() {
// Issue #832: MainActivity is exported (launcher requirement) — only // Issue #832: MainActivity is exported (launcher requirement) — only
// honor intents stamped with our own notification action. // honor intents stamped with our own notification action.
if (intent?.action != ACTION_OPEN_CHAT_FROM_NOTIFICATION) return if (intent?.action != ACTION_OPEN_CHAT_FROM_NOTIFICATION) return
val sessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) val sessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
intent?.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) intent.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession) sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession)
} }
} }
@@ -86,7 +86,7 @@ open class AppUpdateChecker(
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null if (!response.isSuccessful) return@use null
val body = response.body?.string().orEmpty() val body = response.body.string().orEmpty()
parseUpdateInfo(body) parseUpdateInfo(body)
} }
} }
@@ -106,7 +106,7 @@ open class AppUpdateChecker(
try { try {
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext false if (!response.isSuccessful) return@withContext false
val body = response.body ?: return@withContext false val body = response.body
val total = body.contentLength() val total = body.contentLength()
dest.outputStream().use { out -> dest.outputStream().use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
@@ -303,7 +303,7 @@ object HermesWsClient {
} }
if (socketToCancel != null) { if (socketToCancel != null) {
rejectAllPending() rejectAllPending()
socketToCancel?.cancel() socketToCancel.cancel()
} }
if (shouldOpen) openSocket() if (shouldOpen) openSocket()
} }
@@ -153,7 +153,7 @@ class KanbanEventsClient {
request, request,
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, webSocket: WebSocket,
response: Response, response: Response,
) { ) {
if (closed || gen != generation) return if (closed || gen != generation) return
@@ -162,7 +162,7 @@ class KanbanEventsClient {
} }
override fun onMessage( override fun onMessage(
ws: WebSocket, webSocket: WebSocket,
text: String, text: String,
) { ) {
if (closed || gen != generation) return if (closed || gen != generation) return
@@ -173,20 +173,20 @@ class KanbanEventsClient {
} }
override fun onMessage( override fun onMessage(
ws: WebSocket, webSocket: WebSocket,
bytes: ByteString, bytes: ByteString,
) = Unit ) = Unit
override fun onClosing( override fun onClosing(
ws: WebSocket, webSocket: WebSocket,
code: Int, code: Int,
reason: String, reason: String,
) { ) {
ws.close(code, reason) webSocket.close(code, reason)
} }
override fun onClosed( override fun onClosed(
ws: WebSocket, webSocket: WebSocket,
code: Int, code: Int,
reason: String, reason: String,
) { ) {
@@ -199,7 +199,7 @@ class KanbanEventsClient {
} }
override fun onFailure( override fun onFailure(
ws: WebSocket, webSocket: WebSocket,
t: Throwable, t: Throwable,
response: Response?, response: Response?,
) { ) {
@@ -135,7 +135,7 @@ private fun BillingContent(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) { ) {
Text( Text(
text = state.errorMessage ?: "", text = state.errorMessage,
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
@@ -152,7 +152,7 @@ private fun BillingContent(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) { ) {
Text( Text(
text = state.actionMessage ?: "", text = state.actionMessage,
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@@ -207,7 +207,7 @@ private fun PlanCard(subscription: SubscriptionCurrent) {
} }
if (subscription.pending_downgrade_display != null) { if (subscription.pending_downgrade_display != null) {
Text( Text(
text = subscription.pending_downgrade_display ?: "", text = subscription.pending_downgrade_display,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
@@ -2890,7 +2890,7 @@ class ChatViewModel(
if (lastAssistantIdx >= 0) { if (lastAssistantIdx >= 0) {
val target = mapped[lastAssistantIdx] val target = mapped[lastAssistantIdx]
if (target.reasoningText.isBlank()) { if (target.reasoningText.isBlank()) {
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning!!) mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
} }
} }
} }
@@ -28,9 +28,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -405,7 +405,7 @@ fun AttachmentChip(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} else { } else {
Icon( Icon(
imageVector = Icons.Default.InsertDriveFile, imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = attachment.name, contentDescription = attachment.name,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -90,7 +90,7 @@ object ToolViewBuilder {
raw = firstString(parseMaybeObject(value), listOf("context")) raw = firstString(parseMaybeObject(value), listOf("context"))
} }
return compactPreview(raw ?: "", max) return compactPreview(raw, max)
} }
private fun contextValue(value: JsonElement?): String { private fun contextValue(value: JsonElement?): String {
@@ -1328,8 +1328,8 @@ object ToolViewBuilder {
val elements = result?.get("elements") as? JsonArray val elements = result?.get("elements") as? JsonArray
if (elements != null) { if (elements != null) {
val mode = firstString(result, listOf("mode")) val mode = firstString(result, listOf("mode"))
val width = intValue(result?.get("width")) val width = intValue(result.get("width"))
val height = intValue(result?.get("height")) val height = intValue(result.get("height"))
val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: "" val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""
val sizePart = if (width != null && height != null) " ${width}x$height" else "" val sizePart = if (width != null && height != null) " ${width}x$height" else ""
return "${elements.size} elements$modePart$sizePart" return "${elements.size} elements$modePart$sizePart"
@@ -5,6 +5,7 @@
package com.m57.hermescontrol.ui.keys package com.m57.hermescontrol.ui.keys
import android.content.ClipData
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -29,6 +30,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
@@ -41,7 +43,6 @@ import androidx.compose.material.icons.outlined.Build
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Forum import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.SmartToy import androidx.compose.material.icons.outlined.SmartToy
import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -66,11 +67,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -97,6 +100,7 @@ import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import kotlinx.coroutines.launch
private const val FILTER_ALL = "ALL" private const val FILTER_ALL = "ALL"
@@ -527,8 +531,9 @@ private fun EnvVarCard(
val isRevealed = revealedValue != null val isRevealed = revealedValue != null
var editedValue by remember(config, revealedValue) { mutableStateOf(revealedValue ?: "") } var editedValue by remember(config, revealedValue) { mutableStateOf(revealedValue ?: "") }
val clipboardManager = LocalClipboardManager.current val clipboard = LocalClipboard.current
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
val displayValue = val displayValue =
if (isRevealed) { if (isRevealed) {
@@ -645,7 +650,7 @@ private fun EnvVarCard(
}.padding(vertical = 2.dp), }.padding(vertical = 2.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.OpenInNew, imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = stringResource(R.string.keys_action_open_url), contentDescription = stringResource(R.string.keys_action_open_url),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
@@ -759,7 +764,10 @@ private fun EnvVarCard(
} }
if (isRevealed && !revealedValue.isNullOrEmpty()) { if (isRevealed && !revealedValue.isNullOrEmpty()) {
IconButton(onClick = { IconButton(onClick = {
clipboardManager.setText(AnnotatedString(revealedValue)) val text = AnnotatedString(revealedValue)
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, text)))
}
onShowToast(copiedMessage) onShowToast(copiedMessage)
}) { }) {
Icon( Icon(
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.profiles package com.m57.hermescontrol.ui.profiles
import android.content.ClipData
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -41,11 +42,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -64,6 +67,7 @@ import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.profiles.components.ProfileBuilderView import com.m57.hermescontrol.ui.profiles.components.ProfileBuilderView
import com.m57.hermescontrol.ui.profiles.components.validateProfileName import com.m57.hermescontrol.ui.profiles.components.validateProfileName
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -91,7 +95,8 @@ fun ProfilesScreen(
var setupCmdProfileName by remember { mutableStateOf<String?>(null) } var setupCmdProfileName by remember { mutableStateOf<String?>(null) }
var copiedSetupCmd by remember { mutableStateOf(false) } var copiedSetupCmd by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var isBuildingProfile by remember { mutableStateOf(false) } var isBuildingProfile by remember { mutableStateOf(false) }
@@ -839,7 +844,8 @@ fun ProfilesScreen(
if (!state.isLoadingSetupCommand && state.setupCommand != null) { if (!state.isLoadingSetupCommand && state.setupCommand != null) {
Button( Button(
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(state.setupCommand.orEmpty())) val text = AnnotatedString(state.setupCommand.orEmpty())
scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, text))) }
copiedSetupCmd = true copiedSetupCmd = true
}, },
) { ) {
@@ -288,7 +288,7 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
state.copy( state.copy(
postSetup = postSetup =
state.postSetup?.copy( state.postSetup?.copy(
lines = data.lines ?: state.postSetup?.lines ?: emptyList(), lines = data.lines ?: state.postSetup.lines,
running = data.running == true, running = data.running == true,
exitCode = data.exit_code, exitCode = data.exit_code,
), ),
@@ -31,24 +31,34 @@ object LocaleContextWrapper {
code.contains("-r", ignoreCase = true) -> { code.contains("-r", ignoreCase = true) -> {
val parts = code.split("-r", ignoreCase = true) val parts = code.split("-r", ignoreCase = true)
Locale(parts[0], parts.getOrElse(1) { "" }) localeFor(parts[0], parts.getOrElse(1) { "" })
} }
code.contains("-") -> { code.contains("-") -> {
val parts = code.split("-") val parts = code.split("-")
Locale(parts[0], parts.getOrElse(1) { "" }) localeFor(parts[0], parts.getOrElse(1) { "" })
} }
code.contains("_") -> { code.contains("_") -> {
val parts = code.split("_") val parts = code.split("_")
Locale(parts[0], parts.getOrElse(1) { "" }) localeFor(parts[0], parts.getOrElse(1) { "" })
} }
else -> { else -> {
Locale(code) Locale.forLanguageTag(code)
} }
} }
private fun localeFor(
language: String,
region: String,
): Locale =
if (region.isEmpty()) {
Locale.forLanguageTag(language)
} else {
Locale.forLanguageTag("$language-$region")
}
/** /**
* Wrap [base] so the configuration carries [locale]. * Wrap [base] so the configuration carries [locale].
* *
@@ -6,11 +6,13 @@ import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@@ -95,6 +97,16 @@ class ChatUpdateCommandTest {
} }
every { HermesWsClient.disconnect() } returns Unit every { HermesWsClient.disconnect() } returns Unit
// ChatViewModel's init subscribes to ProfileSwitchCoordinator.switched
// on viewModelScope. Without mocking the singleton, every VM created
// here parks a collector on the REAL flow — when a later test class
// (e.g. ProfileSwitchCoordinatorTest) emits on it, the stale-Main
// resumption crashes with DispatchException (CI flakes). Mirror
// ChatViewModelTest: stub the flow with a never-emitting mock so the
// collectors park harmlessly.
mockkObject(ProfileSwitchCoordinator)
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
every { HermesWsClient.send(any(), any(), any()) } answers { every { HermesWsClient.send(any(), any(), any()) } answers {
reqCount++ reqCount++
val id = "req-id-$reqCount" val id = "req-id-$reqCount"
@@ -122,7 +134,11 @@ class ChatUpdateCommandTest {
} }
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> { private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) // FakeSlashUsageStore: the real store inits a DataStore on real IO
// threads — with a relaxed-mock app that NPEs on cacheDir and leaks
// an uncaught exception into the NEXT runTest class (CI flakes:
// UncaughtExceptionsBeforeTest).
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
advanceUntilIdle() advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -154,7 +170,7 @@ class ChatUpdateCommandTest {
@Test @Test
fun `applyUpdate triggers the REST action and starts the progress popup`() = fun `applyUpdate triggers the REST action and starts the progress popup`() =
runTest { runTest {
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
coEvery { mockApi.updateHermes() } returns coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update")) Response.success(ActionResponse(ok = true, name = "hermes-update"))
// One running poll, then the action exits — runTest's teardown // One running poll, then the action exits — runTest's teardown
@@ -193,7 +209,7 @@ class ChatUpdateCommandTest {
@Test @Test
fun `applyUpdate settles on success when the action exits cleanly`() = fun `applyUpdate settles on success when the action exits cleanly`() =
runTest { runTest {
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
coEvery { mockApi.updateHermes() } returns coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update")) Response.success(ActionResponse(ok = true, name = "hermes-update"))
coEvery { mockApi.getActionStatus("hermes-update") } returns coEvery { mockApi.getActionStatus("hermes-update") } returns
@@ -221,7 +237,7 @@ class ChatUpdateCommandTest {
@Test @Test
fun `applyUpdate surfaces a rejected trigger in the popup`() = fun `applyUpdate surfaces a rejected trigger in the popup`() =
runTest { runTest {
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
coEvery { mockApi.updateHermes() } returns coEvery { mockApi.updateHermes() } returns
Response.error(404, "no update endpoint".toResponseBody()) Response.error(404, "no update endpoint".toResponseBody())
@@ -4,6 +4,7 @@ import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
@@ -75,6 +76,15 @@ class SlashCommandDispatchRpcTest {
mockkObject(ApiClient) mockkObject(ApiClient)
mockkObject(HermesDatabase) mockkObject(HermesDatabase)
// ChatViewModel's init subscribes to ProfileSwitchCoordinator.switched
// on viewModelScope. Without mocking the singleton, every VM created
// here parks a collector on the REAL flow — when a later test class
// emits on it, the stale-Main resumption crashes (DispatchException,
// CI flakes). Stub it with a never-emitting mock flow (ChatViewModelTest
// pattern) so the collectors park harmlessly.
mockkObject(ProfileSwitchCoordinator)
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
app = mockk(relaxed = true) app = mockk(relaxed = true)
fakeRepo = FakeChatPersistenceRepository() fakeRepo = FakeChatPersistenceRepository()