refactor(chat): split transcript component files (#1310, #1311, #1312) (#1349)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 07:31:04 +03:00
1 parent 65d6f98262
commit f2675e0b1a
20 files changed
+2361 -1841

No files matched your search

@@ -0,0 +1,162 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.chat.ClarifyQuestionUi
import com.m57.hermescontrol.ui.chat.ClarifyUi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ClarifyBubbleTest {
@get:Rule
val compose = createComposeRule()
@Test
fun clickingProvidedChoice_invokesOnRespondSingleWithExactChoice() {
var respondedSingle: String? = null
var respondedBatch: Map<String, String>? = null
var dismissed = false
val clarifyRequest =
ClarifyUi(
text = "Which environment should be targeted?",
options = listOf("Staging", "Production"),
clarifyId = "req-1",
)
compose.setContent {
HermesControlTheme {
ClarifyBubble(
clarifyRequest = clarifyRequest,
onRespondSingle = { respondedSingle = it },
onRespondBatch = { respondedBatch = it },
onDismiss = { dismissed = true },
)
}
}
compose.onNodeWithText("Staging").assertIsDisplayed().performClick()
compose.runOnIdle {
assertEquals("Staging", respondedSingle)
assertNull(respondedBatch)
assertEquals(false, dismissed)
}
}
@Test
fun clickingDismiss_invokesOnDismissWithoutResponse() {
var respondedSingle: String? = null
var respondedBatch: Map<String, String>? = null
var dismissed = false
val dismissLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_dismiss)
val clarifyRequest =
ClarifyUi(
text = "Do you want to run database migrations?",
options = listOf("Yes", "No"),
clarifyId = "req-2",
)
compose.setContent {
HermesControlTheme {
ClarifyBubble(
clarifyRequest = clarifyRequest,
onRespondSingle = { respondedSingle = it },
onRespondBatch = { respondedBatch = it },
onDismiss = { dismissed = true },
)
}
}
compose.onNodeWithText(dismissLabel).assertIsDisplayed().performClick()
compose.runOnIdle {
assertEquals(true, dismissed)
assertNull(respondedSingle)
assertNull(respondedBatch)
}
}
@Test
fun replacingRequestIdentity_resetsEnteredTextAndSelection() {
val sharedQuestion =
ClarifyQuestionUi(
qid = "q-common",
question = "Which build targets and extras should be enabled?",
choices = listOf("APK", "AAB", "Native Symbols"),
multiSelect = true,
)
var currentRequest by mutableStateOf(
ClarifyUi(
text = sharedQuestion.question,
clarifyId = "req-1",
questions = listOf(sharedQuestion),
),
)
compose.setContent {
HermesControlTheme {
ClarifyBubble(
clarifyRequest = currentRequest,
onRespondSingle = {},
onRespondBatch = {},
onDismiss = {},
)
}
}
compose
.onNodeWithText("APK")
.assertIsDisplayed()
.assertIsNotSelected()
.performClick()
compose.onNodeWithText("APK").assertIsSelected()
val textInputNode = compose.onNode(hasSetTextAction()).assertIsDisplayed()
textInputNode.performTextInput("--dry-run")
textInputNode.assert(hasEditableText("--dry-run"))
// Replace request identity while retaining identical qid, question, and options
compose.runOnIdle {
currentRequest =
ClarifyUi(
text = sharedQuestion.question,
clarifyId = "req-2",
questions = listOf(sharedQuestion),
)
}
// Selected choice and typed text must reset under new clarifyId
compose.onNodeWithText("APK").assertIsDisplayed().assertIsNotSelected()
compose.onNode(hasSetTextAction()).assertIsDisplayed().assert(hasEditableText(""))
}
// The field merges its placeholder label into Text, so assertTextEquals can
// never match; compare only the typed value.
private fun hasEditableText(value: String) =
SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(value))
}
@@ -0,0 +1,165 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class CopyFeedbackTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Composable
private fun CopyFeedbackHarness(
tagPrefix: String,
stateReceiver: (MutableState<Boolean>) -> Unit = {},
) {
val feedbackState = rememberCopyFeedback()
stateReceiver(feedbackState)
Column {
Text(
text = if (feedbackState.value) "Copied" else "Idle",
modifier = Modifier.testTag("${tagPrefix}_status"),
)
Text(
text = "Trigger",
modifier =
Modifier
.testTag("${tagPrefix}_trigger")
.clickable { feedbackState.value = true },
)
}
}
@Test
fun initialStateIsFalse() {
composeTestRule.setContent {
CopyFeedbackHarness(tagPrefix = "feedback")
}
composeTestRule
.onNodeWithTag("feedback_status")
.assertIsDisplayed()
.assertTextEquals("Idle")
}
@Test
fun settingTrueShowsFeedbackAndResetsAfter1500Ms() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
CopyFeedbackHarness(tagPrefix = "feedback")
}
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Advance 1400ms - should still be true (100ms before 1500ms timeout)
composeTestRule.mainClock.advanceTimeBy(1400L)
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Advance another 100ms to hit exactly 1500ms
composeTestRule.mainClock.advanceTimeBy(100L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
}
@Test
fun settingTrueAgainWhileAlreadyTrueDoesNotRestartActiveTimer() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
CopyFeedbackHarness(tagPrefix = "feedback")
}
// Trigger at t = 0ms
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Advance to t = 1000ms
composeTestRule.mainClock.advanceTimeBy(1000L)
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Re-trigger setting true at t = 1000ms while already true
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// If the active timer is NOT restarted, it resets at t = 1500ms (500ms after the second click)
composeTestRule.mainClock.advanceTimeBy(500L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
}
@Test
fun separateComposedHookInstancesAreIndependent() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
Column {
CopyFeedbackHarness(tagPrefix = "instance_a")
CopyFeedbackHarness(tagPrefix = "instance_b")
}
}
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
// Trigger only instance A
composeTestRule.onNodeWithTag("instance_a_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
// Instance A should be active, instance B should remain idle
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
// Advance 1000ms
composeTestRule.mainClock.advanceTimeBy(1000L)
// Trigger instance B at t = 1000ms
composeTestRule.onNodeWithTag("instance_b_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
// Both should be Copied now
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
// Advance 500ms to t = 1500ms: Instance A should expire, Instance B should still have 1000ms remaining
composeTestRule.mainClock.advanceTimeBy(500L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
// Advance remaining 1000ms for Instance B
composeTestRule.mainClock.advanceTimeBy(1000L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
}
}