fix(#1427): keep accepted prompts visible during live turns (#1430)

This commit is contained in:
m57 authored and GitHub committed 2026-10-01 20:17:05 +03:00
1 parent 63105b7843
commit 1cef9e7ebb
13 files changed
+332 -14

No files matched your search

@@ -1,27 +1,42 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.UserBubble
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.Locale
@RunWith(AndroidJUnit4::class)
class PendingSendRecoveryTest {
@get:Rule
val compose = createComposeRule()
val compose = createAndroidComposeRule<ComponentActivity>()
private val uncertain =
PendingSend(
@@ -33,6 +48,16 @@ class PendingSendRecoveryTest {
state = PendingSendState.UNKNOWN,
)
private fun imageAttachment(): Attachment {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val file = File(context.cacheDir, "photo.png")
val bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.rgb(20, 100, 160))
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
return Attachment(file.toURI().toString(), file.name, "image/png", file.length())
}
@Test
fun uncertainRetryRequiresExplicitConfirmation() {
var retried: String? = null
@@ -82,6 +107,99 @@ class PendingSendRecoveryTest {
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
}
@Test
fun acceptedPromptShowsTextImageAndStatusWithoutRecoveryControl() {
val image = imageAttachment()
val accepted = uncertain.copy(state = PendingSendState.ACCEPTED, attachments = listOf(image))
compose.setContent {
MaterialTheme {
androidx.compose.foundation.layout.Column {
UserBubble(
ChatMessage(accepted.id, MessageRole.USER, accepted.text, attachments = accepted.attachments),
pendingSendState = accepted.state,
)
PendingSendRecovery(listOf(accepted), true, true, {}, {})
}
}
}
compose.onNodeWithText(accepted.text).assertIsDisplayed()
compose.waitUntil(5_000) {
compose
.onNodeWithContentDescription("photo.png")
.fetchSemanticsNode()
.boundsInRoot.height > 0
}
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed()
compose.onNodeWithText("Accepted by server").assertIsDisplayed()
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
}
@Test
fun recoveryIdentifiesImagesAndFilesAndCanOpenAnImage() {
val image = imageAttachment()
val file = Attachment("file:///private/note", "note.txt", "text/plain", 4)
var openedImage: String? = null
var openedFile: String? = null
compose.setContent {
MaterialTheme {
PendingSendRecovery(
listOf(uncertain.copy(attachments = listOf(image, file))),
false,
false,
{},
{},
onOpenAttachment = { openedFile = it.name },
onImageClick = { openedImage = it.name },
)
}
}
compose.onNodeWithTag("pending_send_recovery").performClick()
compose.onNodeWithText("photo.png").assertIsDisplayed()
compose.waitUntil(5_000) {
compose
.onNodeWithContentDescription("photo.png")
.fetchSemanticsNode()
.boundsInRoot.height > 0
}
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals("photo.png", openedImage) }
// The filename shares a lazy item with the image; scrollToIndex cannot reveal its lower children.
if (!compose.onNodeWithText("note.txt").isDisplayed()) {
compose.onNode(hasScrollAction()).performTouchInput { swipeUp() }
}
compose.onNodeWithText("note.txt").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals("note.txt", openedFile) }
}
@Test
@Suppress("DEPRECATION")
fun chineseRecoveryCountAndDismissAreLocalized() {
// Native dialog windows resolve the Activity locale, not a synthetic LocalContext.
val resources = compose.activity.resources
val original = Configuration(resources.configuration)
val chinese = Configuration(original).apply { setLocale(Locale.SIMPLIFIED_CHINESE) }
try {
compose.runOnUiThread { resources.updateConfiguration(chinese, resources.displayMetrics) }
compose.setContent {
MaterialTheme {
PendingSendRecovery(listOf(uncertain), false, false, {}, {})
}
}
compose.onNodeWithText("1 条待处理消息").assertIsDisplayed().performClick()
compose
.onNodeWithText("移除")
.performScrollTo()
.assertIsDisplayed()
.performClick()
compose
.onNodeWithText(
"要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。",
).assertIsDisplayed()
} finally {
compose.runOnUiThread { resources.updateConfiguration(original, resources.displayMetrics) }
}
}
@Test
fun longUncertainMessageKeepsConfirmationActionsVisible() {
compose.setContent {