feat(chat): stabilize multi-image attachment picker (#986)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-27 10:50:55 +03:00
1 parent c7d7abf4a1
commit 0ae5ed4db2
11 files changed
+432 -55

No files matched your search

@@ -0,0 +1,101 @@
package com.m57.hermescontrol
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ExternalActivityLifecycleGuardTest {
@Before
fun setUp() {
ExternalActivityLifecycleGuard.resetForTest()
}
@After
fun tearDown() {
ExternalActivityLifecycleGuard.resetForTest()
}
@Test
fun externalActivityLaunch_acquiresConnectionLeaseBeforePreparingServiceAndLaunching() {
val calls = mutableListOf<String>()
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = { calls += "acquire" },
releaseConnectionLease = { calls += "release" },
prepareForBackground = { calls += "prepare" },
cleanupAfterLaunchFailure = { calls += "cleanup" },
scheduleTimeout = { _, _ -> },
launch = { calls += "launch" },
)
assertEquals(listOf("acquire", "prepare", "launch"), calls)
}
@Test
fun transientResumeDuringPicker_doesNotReleaseLeaseUntilResultReturns() {
var released = false
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = {},
releaseConnectionLease = { released = true },
prepareForBackground = {},
cleanupAfterLaunchFailure = {},
scheduleTimeout = { _, _ -> },
launch = {},
)
ExternalActivityLifecycleGuard.onHostPaused()
ExternalActivityLifecycleGuard.onHostResumed()
assertFalse(released)
ExternalActivityLifecycleGuard.onHostPaused()
ExternalActivityLifecycleGuard.externalActivityReturned()
assertFalse(released)
ExternalActivityLifecycleGuard.onHostResumed()
assertTrue(released)
}
@Test
fun abandonedPicker_timeoutReleasesConnectionLease() {
var timeoutAction: (() -> Unit)? = null
var released = false
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = {},
releaseConnectionLease = { released = true },
prepareForBackground = {},
cleanupAfterLaunchFailure = {},
scheduleTimeout = { _, action -> timeoutAction = action },
launch = {},
)
ExternalActivityLifecycleGuard.onHostPaused()
timeoutAction?.invoke()
assertTrue(released)
}
@Test
fun failedExternalActivityLaunch_cleansUpServiceAndConnectionLease() {
val calls = mutableListOf<String>()
runCatching {
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = { calls += "acquire" },
releaseConnectionLease = { calls += "release" },
prepareForBackground = { calls += "prepare" },
cleanupAfterLaunchFailure = { calls += "cleanup" },
scheduleTimeout = { _, _ -> },
launch = { error("picker unavailable") },
)
}
assertEquals(listOf("acquire", "prepare", "cleanup", "release"), calls)
}
}
@@ -89,6 +89,7 @@ class HermesWsClientTest {
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.setAppForeground(true)
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
acceptQueuedMessagesField.isAccessible = true
@@ -97,6 +98,7 @@ class HermesWsClientTest {
@After
fun tearDown() {
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
@@ -837,6 +839,23 @@ class HermesWsClientTest {
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
}
@Test
fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
HermesWsClient.acquireExternalActivityConnectionLease()
HermesWsClient.setAppForeground(false)
assertTrue(HermesWsClient.isConnected)
HermesWsClient.releaseExternalActivityConnectionLease()
assertFalse(HermesWsClient.isConnected)
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
}
@Test
fun testBackgroundWithPendingReplyStaysConnected() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
@@ -1305,6 +1305,24 @@ class ChatViewModelTest {
assertEquals("dummy.txt", pending[0].name)
}
@Test
fun addAttachments_keepsEverySelectedImageInOrder() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
val selected =
listOf(
Attachment("content://image/1", "one.jpg", "image/jpeg", 100L),
Attachment("content://image/2", "two.png", "image/png", 200L),
Attachment("content://image/3", "three.webp", "image/webp", 300L),
)
viewModel.addAttachments(selected)
advanceUntilIdle()
assertEquals(selected, viewModel.uiState.value.pendingAttachments)
}
@Test
fun testRemoveAttachment_validIndex() =
runTest {