package social.grain.support import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import android.content.Context import android.os.Looper import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import social.grain.data.api.DPoP import social.grain.data.auth.AuthManager import social.grain.data.auth.SecureStore import social.grain.data.auth.StoredAccount import social.grain.data.auth.TokenStorage val appContext: Context get() = ApplicationProvider.getApplicationContext() /** * Everything a view model under test needs, wired to a [StubServer]. * * The auth manager is the real one against real Keystore-backed storage rather * than a stand-in, because a view model's most interesting branch is usually * "am I signed in" — and a fake that always says yes hides the signed-out path * that every screen has to handle. */ class Harness( val server: StubServer = StubServer(), signedIn: Boolean = true, val did: String = "did:plc:me", val handle: String = "me.grain.social", avatar: String? = "https://cdn.grain.test/avatar/me.jpg", ) { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val storage = TokenStorage(SecureStore(appContext)) init { FakeAndroidKeyStore.install() // Whatever a previous test left behind is not this test's session. storage.accounts.forEach { storage.removeAccount(it.did) } storage.activeDid = null if (signedIn) { storage.storeDpopKey(did, DPoP.createEphemeral().encodedKeyPair()) storage.storeTokens( did = did, accessToken = "test-access", refreshToken = "test-refresh", handle = handle, expiresAtMillis = System.currentTimeMillis() + 3_600_000, scope = null, ) storage.setAvatar(did, avatar) storage.activeDid = did storage.upsertAccount(StoredAccount(did, handle, avatar)) } } val auth = AuthManager(storage, server.client(), testJson, scope) val client = server.xrpc() val feedCache = social.grain.data.local.FeedCache(appContext, testJson) val viewedStories = social.grain.data.local.ViewedStoryStore(appContext, testJson, scope) val storyStatus = social.grain.data.local.StoryStatusCache() val recentSearches = social.grain.data.local.RecentSearchStore(appContext, testJson) val labelDefinitions = social.grain.data.local.LabelDefinitionsCache(client, auth, scope) /** Undo everything this harness put on the device. */ fun tearDown() { storage.accounts.forEach { storage.removeAccount(it.did) } storage.activeDid = null scope.coroutineContext[kotlinx.coroutines.Job]?.cancel() server.shutdown() } } /** * Block until the flow's value satisfies [predicate]. * * Real time rather than a virtual scheduler: the client under test hands its * requests to OkHttp's own dispatcher, which no test scheduler controls, so * skipping delay would just mean giving up before the reply arrives. */ fun StateFlow.awaitValue( what: String = "state", timeoutMs: Long = 10_000, predicate: (T) -> Boolean, ): T = runBlocking { withTimeoutOrNull(timeoutMs) { first(predicate) } ?: throw AssertionError("Timed out waiting for $what. Last value: ${this@awaitValue.value}") } /** Block until [nsid] has been called at least [count] times. */ fun StubServer.awaitCall(nsid: String, count: Int = 1, timeoutMs: Long = 10_000): StubCall { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { if (countOf(nsid) >= count) return lastCall(nsid) Thread.sleep(10) } throw AssertionError( "Timed out waiting for $count call(s) to $nsid; saw ${countOf(nsid)}", ) } /** * Let anything already queued on the main thread run to completion. * * Used where a test has to prove a *negative* — that no request went out — * which has no state change to wait on. The sleep is what gives work already * handed to a background dispatcher a chance to arrive. */ fun settle() { org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle() Thread.sleep(120) org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle() } /** * Publish pending snapshot writes so `snapshotFlow` collectors see them. * * Compose normally does this from the recomposer's frame loop. A view model * exercised without a composition has no frame loop, so a `TextFieldState` * edited from a test would otherwise never reach the flow watching it. */ fun applySnapshot() { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } /** Type [text] into a field a view model owns, as the text field would. */ fun TextFieldState.type(text: String) { setTextAndPlaceCursorAtEnd(text) applySnapshot() }