This commit is contained in:
20 files changed
+2361
-1841
No files matched your search
+162
@@ -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))
|
||||
}
|
||||
+165
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user