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:
1 parent
b05cffa9c4
commit
1e6e248891
14 files changed
+88
-38
No files matched your search
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user