mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
test(android): synchronize reader image decoding
The four decoded-image reader cases raced Compose's one-second waitUntil against Base64ImageState work on Dispatchers.Default. Compose idleness and its animation clock do not own that background decode, so scheduled run 36816025306 could time out before image semantics appeared. Provide a composition-scoped decode dispatcher, retaining Dispatchers.Default in production. The shared reader fixture owns a separate TestCoroutineScheduler, starts composition, drains the real decoder, then waits for Compose publication and layout. Assert zero images before draining and exactly one afterward; all existing scroll and resize assertions remain intact. A temporary CountDownLatch held decoding until showReader returned, then a 1500 ms sleep inside withContext forced the original losing interleaving. The original coupledViewportAndImageResizeKeepsTheSelectedImageVisible case failed with the exact 1000 ms ComposeTimeoutException. The same probe passed all 26 reader cases after the fix. Both probe and sleep are absent from the final change. Probe-free :app:testPlayDebugUnitTest passed all 26 reader and 15 codec cases. Validation: check-changed, Android ktlint, git diff --check, and isolated Codex autoreview passed with no accepted/actionable findings. Production delta: +5 lines for the scoped dispatcher seam; test delta: +13. Separate follow-up: ChatImagePreviewTest still warms its managed-image decoder before a wall-clock wait; its independent fixture was not changed here. Test cost: node scripts/run-vitest.mjs apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollOwnershipLayoutTest.kt --maxWorkers=1 took 13.45 s and exited with no Kotlin tests found; Gradle reader+codec proof took 40.01 s wall, with 12.96 s in the 26-test reader class.
This commit is contained in:
parent
abb97239bc
commit
d3fbeaac8f
2 changed files with 22 additions and 4 deletions
|
|
@ -6,11 +6,15 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal val LocalBase64ImageDecodeDispatcher = staticCompositionLocalOf<CoroutineDispatcher> { Dispatchers.Default }
|
||||
|
||||
/** Compose state for async base64 image decoding. */
|
||||
internal data class Base64ImageState(
|
||||
val image: ImageBitmap?,
|
||||
|
|
@ -23,13 +27,14 @@ internal fun rememberBase64ImageState(
|
|||
base64: String,
|
||||
source: Base64ImageSource = Base64ImageSource.Inline,
|
||||
): Base64ImageState {
|
||||
val decodeDispatcher = LocalBase64ImageDecodeDispatcher.current
|
||||
var image by remember(base64, source) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var failed by remember(base64, source) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64, source) {
|
||||
LaunchedEffect(base64, source, decodeDispatcher) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(decodeDispatcher) {
|
||||
try {
|
||||
val bitmap = decodeBase64Bitmap(base64, source = source) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
|
|
@ -59,6 +60,8 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestCoroutineScheduler
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
|
|
@ -76,6 +79,7 @@ import java.io.ByteArrayOutputStream
|
|||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-420dpi", application = Application::class)
|
||||
class ChatReaderScrollOwnershipLayoutTest {
|
||||
private val imageDecodeDispatcher = StandardTestDispatcher(TestCoroutineScheduler())
|
||||
private val animationScale =
|
||||
object : MotionDurationScale {
|
||||
override val scaleFactor = 1f
|
||||
|
|
@ -482,7 +486,13 @@ class ChatReaderScrollOwnershipLayoutTest {
|
|||
var viewportHeight by mutableStateOf(480.dp)
|
||||
var viewportWidth by mutableStateOf(360.dp)
|
||||
showReader(initialStreamingLines = 2, inlineImage = base64, viewportWidth = { viewportWidth }, viewportHeight = { viewportHeight })
|
||||
composeRule.waitUntil { composeRule.onAllNodesWithContentDescription("image/png").fetchSemanticsNodes().size == 1 }
|
||||
// Compose idleness does not include the decoder's background work. Drain it
|
||||
// separately so image readiness never depends on wall time or advances reader animations.
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onAllNodesWithContentDescription("image/png").assertCountEquals(0)
|
||||
imageDecodeDispatcher.scheduler.advanceUntilIdle()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onAllNodesWithContentDescription("image/png").assertCountEquals(1)
|
||||
val initialImage = composeRule.onNodeWithContentDescription("image/png").fetchSemanticsNode()
|
||||
println("READER_IMAGE_ARMING initial=${initialImage.positionInRoot} size=${initialImage.size}")
|
||||
val transcript = composeRule.onNode(hasScrollToIndexAction()).assertIsDisplayed()
|
||||
|
|
@ -1024,7 +1034,10 @@ class ChatReaderScrollOwnershipLayoutTest {
|
|||
val current = rememberChatReaderScrollController("animation-owner", timeline, historyLoading = historyLoading)
|
||||
SideEffect { reader = current }
|
||||
LaunchedEffect(Unit) { observedScale = currentCoroutineContext()[MotionDurationScale]?.scaleFactor }
|
||||
CompositionLocalProvider(LocalChatReaderNavigation provides current.navigation) {
|
||||
CompositionLocalProvider(
|
||||
LocalChatReaderNavigation provides current.navigation,
|
||||
LocalBase64ImageDecodeDispatcher provides imageDecodeDispatcher,
|
||||
) {
|
||||
val navigation = checkNotNull(LocalChatReaderNavigation.current)
|
||||
Column(Modifier.size(360.dp, 700.dp).clipToBounds()) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue