feat(chat): add configurable message stats (#1134)

This commit is contained in:
M57 authored and GitHub committed 2026-09-16 00:00:09 +03:00
1 parent afffcd95e8
commit 529b424eb4
20 files changed
+606 -44

No files matched your search

@@ -1,12 +1,18 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.ChatMessage
@@ -44,7 +50,17 @@ class FullBleedChatListTest {
content: String = "content-$id",
toolStatus: ToolStatus? = null,
isStreaming: Boolean = false,
) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming)
tokenCount: Int? = null,
tps: Double? = null,
) = ChatMessage(
id = id,
role = role,
content = content,
toolStatus = toolStatus,
isStreaming = isStreaming,
tokenCount = tokenCount,
tps = tps,
)
private fun render(
messages: List<ChatMessage>,
@@ -52,6 +68,10 @@ class FullBleedChatListTest {
streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage),
isAgentTyping: Boolean = streamingMessage?.isStreaming == true,
clarify: Boolean = false,
messageStatsEnabled: Boolean = false,
showUserMessageTokens: Boolean = true,
showAssistantMessageTokens: Boolean = true,
showTokensPerSecond: Boolean = true,
) {
composeTestRule.setContent {
val listState = LazyListState()
@@ -62,6 +82,10 @@ class FullBleedChatListTest {
searchState = ChatSearchState(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
isLoading = false,
isLoadingOlder = false,
listState = listState,
@@ -133,6 +157,146 @@ class FullBleedChatListTest {
composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist()
}
@Test
fun messageStats_masterOff_hidesAllMetadata() {
render(
messages =
listOf(
msg("u1", MessageRole.USER, tokenCount = 123),
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
),
)
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
}
@Test
fun messageStats_userTokensOnly_showsOnlyUserTokens() {
render(
messages =
listOf(
msg("u1", MessageRole.USER, tokenCount = 123),
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
),
messageStatsEnabled = true,
showAssistantMessageTokens = false,
showTokensPerSecond = false,
)
composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed()
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
}
@Test
fun messageStats_assistantTokensOnly_showsOnlyAssistantTokens() {
render(
messages =
listOf(
msg("u1", MessageRole.USER, tokenCount = 123),
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
),
messageStatsEnabled = true,
showUserMessageTokens = false,
showTokensPerSecond = false,
)
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed()
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
}
@Test
fun messageStats_tpsOnly_showsOnlyAssistantTps() {
render(
messages =
listOf(
msg("u1", MessageRole.USER, tokenCount = 123),
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
),
messageStatsEnabled = true,
showUserMessageTokens = false,
showAssistantMessageTokens = false,
)
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed()
}
@Test
fun messageStats_allEnabled_showsUserTokensAssistantTokensAndTps() {
render(
messages =
listOf(
msg("u1", MessageRole.USER, tokenCount = 123),
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
),
messageStatsEnabled = true,
)
composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed()
composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed()
composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed()
composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0)
composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist()
}
@Test
fun messageStats_nullAndZeroValues_areHidden() {
render(
messages =
listOf(
msg("u1", MessageRole.USER, tokenCount = 0),
msg("a1", MessageRole.ASSISTANT, tokenCount = 0, tps = 0.0),
),
messageStatsEnabled = true,
)
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
}
@Test
fun messageStats_streamingAssistant_hidesFinalMetadata() {
render(
messages = listOf(msg("u1", MessageRole.USER)),
streamingMessage =
msg(
"s1",
MessageRole.ASSISTANT,
content = "",
isStreaming = true,
tokenCount = 456,
tps = 42.5,
),
isAgentTyping = true,
messageStatsEnabled = true,
)
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed()
}
@Test
fun assistantStats_wrapInsideConstrainedWidth() {
composeTestRule.setContent {
Box(
modifier = Modifier.width(220.dp).testTag("stats_host"),
) {
FullBleedAgentMessage(
message = msg("a1", MessageRole.ASSISTANT, tokenCount = 123456789, tps = 9876.5),
messageStatsEnabled = true,
)
}
}
composeTestRule.waitForIdle()
val host = composeTestRule.onNodeWithTag("stats_host").getUnclippedBoundsInRoot()
val token = composeTestRule.onNodeWithTag("fullbleed_token_count").getUnclippedBoundsInRoot()
val tps = composeTestRule.onNodeWithTag("fullbleed_tps").getUnclippedBoundsInRoot()
assert(token.left >= host.left)
assert(token.right <= host.right)
assert(tps.left >= host.left)
assert(tps.right <= host.right)
}
@Test
fun streaming_and_clarify_stillRender() {
render(
@@ -0,0 +1,81 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ChatSectionTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() {
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = false,
showUserMessageTokens = true,
showAssistantMessageTokens = true,
showTokensPerSecond = true,
)
}
composeTestRule.onNodeWithTag("settings_message_stats").assertIsOff().assertIsEnabled()
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsOn().assertIsNotEnabled()
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsOn().assertIsNotEnabled()
composeTestRule.onNodeWithTag("settings_tps").assertIsOn().assertIsNotEnabled()
}
@Test
fun messageStatsMasterOn_enablesChildrenAndRoutesCallbacks() {
var masterChanges = 0
var userChanges = 0
var assistantChanges = 0
var tpsChanges = 0
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = true,
onMessageStatsEnabledChange = { masterChanges++ },
showUserMessageTokens = true,
onUserMessageTokensChange = { userChanges++ },
showAssistantMessageTokens = true,
onAssistantMessageTokensChange = { assistantChanges++ },
showTokensPerSecond = true,
onTokensPerSecondChange = { tpsChanges++ },
)
}
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick()
assertEquals(0, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
assertEquals(1, tpsChanges)
composeTestRule.onNodeWithTag("settings_message_stats").performClick()
assertEquals(1, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
assertEquals(1, tpsChanges)
}
}