mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
test(android): pin composer attachment encode hop to the test scheduler (#2189)
The ComposerAttachments harness left the constructor default
encodeDispatcher (Dispatchers.Default, a real thread pool) in place.
With two uploads in flight the second-launched one can finish encoding
first, reach FakeUploadApi.uploadFile first, and consume the scripted
results deque out of launch order — the queued failure gets stolen by
the upload meant to succeed, so the robbed chip never settles to its
scripted state and items.first {} hangs until runTest's dispatch
timeout (UncompletedCoroutinesError, intermittent under suite load).
Pin the encode hop to the scope's own dispatcher (the runTest scheduler
in every test), making upload arrival order equal launch order
deterministically; upload still hops dispatchers as in production. Add
a regression-lock test scripting two results over two concurrent adds.
Product code is untouched: upload() keys results by attachment id and
is arrival-order independent — the ordering dependency lives purely in
the test fake's deque.
via [HAPI](https://hapi.run)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
co-authored by
Claude Code
HAPI
parent
ffd950a220
commit
261cb3bb6b
+50
@@ -4,11 +4,14 @@ import app.hapi.data.api.AttachmentUploadApi
|
||||
import app.hapi.protocol.wire.DeleteUploadResponse
|
||||
import app.hapi.protocol.wire.UploadFileResponse
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.ContinuationInterceptor
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -57,12 +60,29 @@ private fun prepared(
|
||||
|
||||
class ComposerAttachmentsTest {
|
||||
|
||||
/**
|
||||
* Test scaffold: production shape except the base64 encode hop, which is
|
||||
* pinned to the scope's own dispatcher instead of the constructor default
|
||||
* `Dispatchers.Default`. That default is a real thread pool: with two
|
||||
* uploads in flight the second-launched one can finish encoding first and
|
||||
* reach `uploadFile` first, consuming the fake's scripted `results` deque
|
||||
* out of launch order (the queued failure stolen by the upload meant to
|
||||
* succeed) — the robbed chip then never settles to its scripted state and
|
||||
* `items.first {}` hangs until runTest's dispatch timeout
|
||||
* (`UncompletedCoroutinesError`, observed intermittently under suite
|
||||
* load). Keeping the hop on the runTest scheduler makes upload arrival
|
||||
* order equal launch order deterministically, while `upload` still hops
|
||||
* dispatchers exactly as in production. The `Dispatchers.Default`
|
||||
* fallback only covers scopes whose interceptor is not a dispatcher.
|
||||
*/
|
||||
private fun harness(scope: kotlinx.coroutines.CoroutineScope, api: FakeUploadApi) =
|
||||
ComposerAttachments(
|
||||
api = api,
|
||||
sessionId = SESSION,
|
||||
scope = scope,
|
||||
detachedCleanupScope = scope,
|
||||
encodeDispatcher = scope.coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher
|
||||
?: Dispatchers.Default,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -119,6 +139,36 @@ class ComposerAttachmentsTest {
|
||||
assertEquals(api.uploads.value[0].contentBase64, api.uploads.value[1].contentBase64)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent uploads take scripted results in launch order`() = runTest {
|
||||
// Regression lock for the scheduler pin in `harness`: the fake's
|
||||
// scripted `results` deque is consumed by uploadFile arrival order,
|
||||
// which must equal launch order. With the production
|
||||
// Dispatchers.Default encode hop the second upload can reach the
|
||||
// fake first, steal the queued success, and flip which chip settles
|
||||
// Ready (surfaced as an UncompletedCoroutinesError flake when the
|
||||
// first chip then never matches the awaited state).
|
||||
val api = FakeUploadApi().apply {
|
||||
results += Result.success(UploadFileResponse(success = true, path = "/uploads/ok.jpg"))
|
||||
results += Result.failure(RuntimeException("boom"))
|
||||
}
|
||||
val tray = harness(backgroundScope, api)
|
||||
tray.add(prepared(id = "first", filename = "ok.jpg"))
|
||||
tray.add(prepared(id = "second", filename = "bad.jpg"))
|
||||
|
||||
val settled = tray.items.first { list ->
|
||||
list.size == 2 && list.all { it.status != ComposerAttachmentStatus.Uploading }
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
ComposerAttachmentStatus.Ready to "first",
|
||||
ComposerAttachmentStatus.Failed to "second",
|
||||
),
|
||||
settled.map { it.status to it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success=false responses settle Failed too`() = runTest {
|
||||
val api = FakeUploadApi()
|
||||
|
||||
Reference in New Issue
Block a user