~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
140 lines · 5.3 KB Raw History
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
        storage.clearPendingLogin()

        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.storeServer(did, stubAccountServer())
            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
        storage.clearPendingLogin()
        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 <T> StateFlow<T>.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()
}