perf: optimize screen load times with progressive un-gating and SWR caching (#1192)

This commit is contained in:
M57 authored and GitHub committed 2026-09-18 23:25:30 +03:00
1 parent abce60ad7a
commit f5eaecbbef
55 files changed
+2494 -401

No files matched your search

@@ -129,6 +129,20 @@ class E2eIntegrationTest {
@After
fun tearDown() {
// Restore the GLOBAL Dispatchers object BEFORE resetMain.
//
// setUp() stubs `Dispatchers.Main` to return the TestMainDispatcher captured
// at that moment. unmockkAll() alone -- and it ran AFTER resetMain -- left
// that stub live, so Dispatchers.Main kept handing back a TestMainDispatcher
// whose delegate had already been reset, and it THROWS on dispatch. Any
// later class in this JVM that resumes a continuation onto Main then died
// with DispatchException. That is precisely what broke
// ProfileSwitchCoordinatorTest (its _switched.tryEmit resumes collectors on
// Main) with 2x DispatchException + 2x "test body did not run to completion".
//
// Verified by repro: E2eIntegrationTest + ProfileSwitchCoordinatorTest
// together = 4 failures; ProfileSwitchCoordinatorTest alone = 5/5 pass.
unmockkStatic(Dispatchers::class)
Dispatchers.resetMain()
unmockkAll()
NavigationController.backStack = null
@@ -0,0 +1,371 @@
package com.m57.hermescontrol.data.local
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Behavioural proof that PR #1192's caches are partitioned by [DataScope].
*
* These tests exercise the cache invariants end-to-end through [safeLaunchSwrLoad]
* rather than only asserting key derivation, so a regression that keeps keys
* correct but reuses the wrong entry still fails here.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CacheScopeIsolationTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private class TestViewModel : ViewModel()
/**
* Every ViewModel created by a test. `viewModelScope` jobs are never cancelled
* automatically, so a still-pending dispatch would hit `Dispatchers.Main` *after*
* `resetMain()` and surface as an `UncaughtExceptionsBeforeTest` in a later test.
* Cancel them before tearing the main dispatcher down.
*/
private val createdViewModels = mutableListOf<TestViewModel>()
private fun newViewModel(): TestViewModel = TestViewModel().also { createdViewModels += it }
/** Connection A — server 192.168.1.57, Hermes profile "main". */
private val scopeA = DataScope("conn-a", "http://192.168.1.57:9119", "main", 0L)
/** Connection B — a different Hermes *server* entirely. */
private val scopeB = DataScope("conn-b", "http://10.0.0.9:9119", "main", 0L)
/** Same server as [scopeA], different server-side Hermes profile. */
private val scopeAOtherProfile = DataScope("conn-a", "http://192.168.1.57:9119", "work", 0L)
/** Same connection+profile as [scopeA], but a new authenticated session (logout/login). */
private val scopeANewSession = scopeA.copy(inMemoryAuthGeneration = 1L)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
createdViewModels.forEach { it.viewModelScope.cancel() }
createdViewModels.clear()
testDispatcher.scheduler.advanceUntilIdle()
Dispatchers.resetMain()
}
// ── Cross-context isolation ──────────────────────────────────────────────
@Test
fun testCacheWrittenInOneScopeIsInvisibleInAnother() {
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("profiles"), "server-A-profiles")
assertNull("connection B must not see connection A's cache", cache.get(scopeB.inMemoryKey("profiles")))
assertNull(
"another Hermes profile on the same server must not see A's cache",
cache.get(scopeAOtherProfile.inMemoryKey("profiles")),
)
assertEquals("server-A-profiles", cache.get(scopeA.inMemoryKey("profiles")))
}
@Test
fun testScopeSwitchDoesNotRenderPreviousContextData() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "stale-server-A-data")
// Screen is now rendering scope B and loads with scope B's request scope.
var renderedFromCache: String? = null
var renderedFromNetwork: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scopeB,
scopeIsCurrent = { true },
onCacheHit = { renderedFromCache = it },
apiCall = { NetworkResult.Success("server-B-data") },
onStart = {},
onSuccess = { renderedFromNetwork = it },
onError = {},
)
assertNull("scope B must never be served scope A's cached payload", renderedFromCache)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("server-B-data", renderedFromNetwork)
assertEquals("server-B-data", cache.get(scopeB.inMemoryKey("default")))
assertEquals(
"server A's entry is untouched",
"stale-server-A-data",
cache.get(scopeA.inMemoryKey("default")),
)
}
@Test
fun testLogoutReloginCannotReuseAnotherSessionsInMemoryCache() {
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("sessions"), "session-1-confidential-titles")
assertNull(
"a new authenticated session must not reuse the previous session's in-memory data",
cache.get(scopeANewSession.inMemoryKey("sessions")),
)
}
@Test
fun testPersistentKeySurvivesProcessRestartButNotServerChange() {
// Same physical server + profile, fresh process (generation resets to 0).
val afterRestart = scopeA.copy(inMemoryAuthGeneration = 0L)
assertEquals(
"disk cache must survive a normal process restart for cold-start speed",
scopeA.persistentKey("sessions"),
afterRestart.persistentKey("sessions"),
)
assertNotEquals(
"disk cache must never bridge two different servers",
scopeA.persistentKey("sessions"),
scopeB.persistentKey("sessions"),
)
}
// ── Same-context SWR stays instant ───────────────────────────────────────
@Test
fun testSameScopeRevisitRendersCacheImmediately() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "cached-for-A")
var cacheHit: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scopeA,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("revalidated-for-A") },
onStart = {},
onSuccess = {},
onError = {},
)
assertEquals("revisiting the same context must paint from cache", "cached-for-A", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("revalidated-for-A", cache.get(scopeA.inMemoryKey("default")))
}
// ── Manual refresh ───────────────────────────────────────────────────────
@Test
fun testForceRefreshPurgesOnlyItsOwnScope() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "A-data")
cache.put(scopeB.inMemoryKey("default"), "B-data")
var cacheHit: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
requestScope = scopeB,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("B-fresh") },
onStart = {},
onSuccess = {},
onError = {},
)
assertNull("force refresh must not replay the cached payload", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("B-fresh", cache.get(scopeB.inMemoryKey("default")))
assertEquals("other scopes' entries must survive", "A-data", cache.get(scopeA.inMemoryKey("default")))
}
// ── Mutation reconciliation ──────────────────────────────────────────────
@Test
fun testSuccessfulMutationCannotReplayPreMutationCache() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
val key = scopeA.inMemoryKey("webhooks")
// Pre-mutation server state.
cache.put(key, "webhook-disabled")
var cacheHit: String? = null
var success: String? = null
// Post-mutation reload: must bypass the now-stale cache entry.
vm.safeLaunchSwrLoad(
cache = cache,
cacheKey = "webhooks",
forceRefresh = true,
requestScope = scopeA,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("webhook-enabled") },
onStart = {},
onSuccess = { success = it },
onError = {},
)
assertNull("pre-mutation state must not be replayed", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("webhook-enabled", success)
assertEquals("webhook-enabled", cache.get(key))
}
@Test
fun testMutationReloadFailureAfterForcedRefreshSurfacesError() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
cache.put(scopeA.inMemoryKey("default"), "pre-mutation")
var error: String? = null
var cacheHit: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
requestScope = scopeA,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Failure(NetworkError.Http(500, "boom")) },
onStart = {},
onSuccess = {},
onError = { error = it },
)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(cacheHit)
assertNotNull(
"a failed post-mutation reload must not silently show pre-mutation state",
error,
)
}
// ── Stale in-flight results ──────────────────────────────────────────────
@Test
fun testLateResponseFromPreviousScopeCannotOverwriteNewState() =
testScope.runTest {
val vm = newViewModel()
val cache = SwrCache<String, String>()
var cacheHit: String? = null
var success: String? = null
var error: String? = null
// Request dispatched while scope A was active; the scope flips to B before it returns.
var scopeIsCurrent = true
val job =
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scopeA,
scopeIsCurrent = { scopeIsCurrent },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("server-A-data") },
onStart = {},
onSuccess = { success = it },
onError = { error = it },
)
scopeIsCurrent = false
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(job.isCompleted)
assertNull("stale scope A response must not be rendered", success)
assertNull(
"stale scope A response must not enter scope A's cache",
cache.get(scopeA.inMemoryKey("default")),
)
assertNull(cacheHit)
assertNull(error)
}
// ── Config editor isolation ──────────────────────────────────────────────
@Test
fun testConfigSchemaAndDefaultsNeverCrossScopeBoundaries() {
val schemaCache = SwrCache<String, String>(maxCapacity = 10)
val defaultsCache = SwrCache<String, String>(maxCapacity = 10)
val keyA = scopeA.inMemoryKey("schema")
val keyB = scopeB.inMemoryKey("schema")
schemaCache.put(keyA, "schema-A")
defaultsCache.put(keyA, "defaults-A")
assertNull("new context must not inherit another server's schema", schemaCache.get(keyB))
assertNull("new context must not inherit another server's defaults", defaultsCache.get(keyB))
schemaCache.put(keyB, "schema-B")
defaultsCache.put(keyB, "defaults-B")
assertEquals("schema-A", schemaCache.get(keyA))
assertEquals("schema-B", schemaCache.get(keyB))
assertEquals("defaults-A", defaultsCache.get(keyA))
assertEquals("defaults-B", defaultsCache.get(keyB))
}
@Test
fun testConfigForceRefreshDoesNotEvictOtherScopes() {
val schemaCache = SwrCache<String, String>(maxCapacity = 10)
val keyA = scopeA.inMemoryKey("schema")
val keyB = scopeB.inMemoryKey("schema")
schemaCache.put(keyA, "schema-A")
schemaCache.put(keyB, "schema-B")
// Manual refresh while scoped to B.
schemaCache.remove(keyB)
assertNull(schemaCache.get(keyB))
assertEquals(
"editing config on one server must not wipe another server's cache",
"schema-A",
schemaCache.get(keyA),
)
}
// ── History pagination keys ──────────────────────────────────────────────
@Test
fun testHistoryPaginationKeysAreScopedPerSectionAndScope() {
val cache = SwrCache<String, String>()
val sectionKey = "recent:cli:false"
cache.put(scopeA.inMemoryKey(sectionKey), "page-1-for-A")
assertNull(cache.get(scopeB.inMemoryKey(sectionKey)))
assertNull(cache.get(scopeAOtherProfile.inMemoryKey(sectionKey)))
assertEquals("page-1-for-A", cache.get(scopeA.inMemoryKey(sectionKey)))
}
}
@@ -0,0 +1,79 @@
package com.m57.hermescontrol.data.local
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DataScopeTest {
@Test
fun testSameScopeProducesSameKeys() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
assertEquals(scope1, scope2)
assertEquals(scope1.inMemoryKey("sessions"), scope2.inMemoryKey("sessions"))
assertEquals(scope1.persistentKey("sessions"), scope2.persistentKey("sessions"))
}
@Test
fun testDifferentConnectionProducesDifferentKeys() {
val scope1 = DataScope("conn-a", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("conn-b", "http://192.168.1.57:9119", "main", 0L)
assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey())
assertNotEquals(scope1.persistentKey(), scope2.persistentKey())
}
@Test
fun testChangedBaseUrlProducesDifferentKeys() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("default", "http://10.0.0.2:9119", "main", 0L)
assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey())
assertNotEquals(scope1.persistentKey(), scope2.persistentKey())
}
@Test
fun testDifferentActiveProfileProducesDifferentKeys() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "profile-1", 0L)
val scope2 = DataScope("default", "http://192.168.1.57:9119", "profile-2", 0L)
assertNotEquals(scope1.inMemoryKey(), scope2.inMemoryKey())
assertNotEquals(scope1.persistentKey(), scope2.persistentKey())
}
@Test
fun testAuthGenerationInvalidatesInMemoryKeyOnly() {
val scope1 = DataScope("default", "http://192.168.1.57:9119", "main", 0L)
val scope2 = DataScope("default", "http://192.168.1.57:9119", "main", 1L)
assertNotEquals(scope1.inMemoryKey("default"), scope2.inMemoryKey("default"))
assertEquals(scope1.persistentKey("default"), scope2.persistentKey("default"))
}
@Test
fun testLengthPrefixPreventsDelimiterInjection() {
val scopeA = DataScope("c:1", "http://url", "p", 0L)
val scopeB = DataScope("c", "1:http://url", "p", 0L)
assertNotEquals(scopeA.persistentKey("k"), scopeB.persistentKey("k"))
assertNotEquals(scopeA.inMemoryKey("k"), scopeB.inMemoryKey("k"))
}
@Test
fun testNoSecretsInKeys() {
val scope = DataScope("profile-id", "http://host:9119", "hermes-profile", 0L)
val inMemory = scope.inMemoryKey("local")
val persistent = scope.persistentKey("local")
assertTrue(!inMemory.contains("token", ignoreCase = true))
assertTrue(!inMemory.contains("password", ignoreCase = true))
assertTrue(!inMemory.contains("secret", ignoreCase = true))
assertTrue(!persistent.contains("token", ignoreCase = true))
assertTrue(!persistent.contains("password", ignoreCase = true))
assertTrue(!persistent.contains("secret", ignoreCase = true))
}
}
@@ -0,0 +1,130 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class SessionListCacheStoreTest {
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private lateinit var mockContext: Context
private val prefStorage = mutableMapOf<String, String>()
@Before
fun setUp() {
prefStorage.clear()
mockPrefs = mockk(relaxed = true)
mockEditor = mockk(relaxed = true)
mockContext = mockk(relaxed = true)
every { mockContext.applicationContext } returns mockContext
every { mockContext.getSharedPreferences(any(), any()) } returns mockPrefs
every { mockPrefs.edit() } returns mockEditor
every { mockEditor.putString(any(), any()) } answers {
prefStorage[firstArg()] = secondArg()
mockEditor
}
every { mockPrefs.getString(any(), any()) } answers {
prefStorage[firstArg()] ?: secondArg()
}
every { mockEditor.remove(any()) } answers {
prefStorage.remove(firstArg())
mockEditor
}
every { mockEditor.clear() } answers {
prefStorage.clear()
mockEditor
}
every { mockEditor.apply() } returns Unit
SessionListCacheStore.resetForTesting(mockPrefs)
}
@After
fun tearDown() {
SessionListCacheStore.resetForTesting(null)
}
@Test
fun testPutAndGet() {
val sample =
SessionListResponse(
sessions =
listOf(
SessionInfo(
id = "s-123",
title = "Test Session",
),
),
total = 1,
)
SessionListCacheStore.put("active", sample)
val loaded = SessionListCacheStore.get("active")
assertNotNull(loaded)
assertEquals(1, loaded?.total)
assertEquals("s-123", loaded?.sessions?.firstOrNull()?.id)
assertEquals("Test Session", loaded?.sessions?.firstOrNull()?.title)
}
@Test
fun testCacheMiss() {
assertNull(SessionListCacheStore.get("nonexistent"))
}
@Test
fun testCorruptedJsonReturnsNull() {
prefStorage["sessions_page_corrupt"] = "{ not valid json }"
assertNull(SessionListCacheStore.get("corrupt"))
}
@Test
fun testScopePartitioningCoexistence() {
val scopeA = DataScope("conn-a", "http://server-a", "profile-a")
val scopeB = DataScope("conn-b", "http://server-b", "profile-b")
val keyA = scopeA.persistentKey("recent")
val keyB = scopeB.persistentKey("recent")
val responseA = SessionListResponse(sessions = listOf(SessionInfo(id = "a-1", title = "Session A")), total = 1)
val responseB = SessionListResponse(sessions = listOf(SessionInfo(id = "b-1", title = "Session B")), total = 1)
SessionListCacheStore.put(keyA, responseA)
SessionListCacheStore.put(keyB, responseB)
val loadedA = SessionListCacheStore.get(keyA)
val loadedB = SessionListCacheStore.get(keyB)
assertNotNull(loadedA)
assertNotNull(loadedB)
assertEquals("Session A", loadedA?.sessions?.first()?.title)
assertEquals("Session B", loadedB?.sessions?.first()?.title)
// Removing keyA does not remove keyB
SessionListCacheStore.remove(keyA)
assertNull(SessionListCacheStore.get(keyA))
assertNotNull(SessionListCacheStore.get(keyB))
}
@Test
fun testClearRemovesAll() {
val sample = SessionListResponse(sessions = emptyList(), total = 0)
SessionListCacheStore.put("k1", sample)
SessionListCacheStore.put("k2", sample)
SessionListCacheStore.clear()
assertNull(SessionListCacheStore.get("k1"))
assertNull(SessionListCacheStore.get("k2"))
}
}
@@ -0,0 +1,85 @@
package com.m57.hermescontrol.data.local
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SwrCacheTest {
@Test
fun testCacheHitReturnsValue() {
val cache = SwrCache<String, String>()
cache.put("k1", "v1")
assertEquals("v1", cache.get("k1"))
}
@Test
fun testCacheMissReturnsNull() {
val cache = SwrCache<String, String>()
assertNull(cache.get("nonexistent"))
}
@Test
fun testTtlExpiration() {
var currentTime = 1000L
val cache =
SwrCache<String, String>(
ttlMillis = 500L,
timeProvider = { currentTime },
)
cache.put("k1", "v1")
assertEquals("v1", cache.get("k1"))
// Advance past TTL
currentTime = 1600L
assertNull(cache.get("k1"))
assertEquals(0, cache.size)
}
@Test
fun testLruEviction() {
val cache = SwrCache<String, String>(maxCapacity = 2)
cache.put("k1", "v1")
cache.put("k2", "v2")
cache.put("k3", "v3")
// k1 should have been evicted
assertNull(cache.get("k1"))
assertEquals("v2", cache.get("k2"))
assertEquals("v3", cache.get("k3"))
assertEquals(2, cache.size)
}
@Test
fun testClearAndRemove() {
val cache = SwrCache<String, String>()
cache.put("k1", "v1")
cache.put("k2", "v2")
val removed = cache.remove("k1")
assertEquals("v1", removed)
assertNull(cache.get("k1"))
assertEquals(1, cache.size)
cache.clear()
assertEquals(0, cache.size)
assertNull(cache.get("k2"))
}
@Test
fun testThreadSafetyConcurrentAccess() {
val cache = SwrCache<String, Int>(maxCapacity = 100)
val threads =
List(10) { threadIdx ->
Thread {
for (i in 0 until 500) {
cache.put("k-$threadIdx-$i", i)
cache.get("k-$threadIdx-$i")
if (i % 5 == 0) cache.remove("k-$threadIdx-$i")
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
}
}
@@ -14,11 +14,15 @@ import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
@@ -35,16 +39,29 @@ import retrofit2.Response
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileSwitchCoordinatorTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
@Before
fun setUp() {
// NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock
// bleeds into later test classes in the same JVM (it hijacks
// Dispatchers.IO for HermesWsClient's reconnect coroutines), which
// deterministically broke HermesWsClientTest.testAutoReconnect in
// full-suite runs. Real Dispatchers.IO is fine: the network layer is
// mocked, so withContext(Dispatchers.IO) hops are instant.
// setMain is REQUIRED, and it is NOT the thing that used to bleed.
//
// The leak was mockkStatic(Dispatchers::class) -- absent here, keep it that
// way. Avoiding setMain as well was the actual bug: with no setMain,
// Dispatchers.Main is the JVM's "missing" dispatcher, and switchProfile's
// _switched.tryEmit resumes collectors onto Main. Once ANY earlier class in
// the same test JVM has installed and then reset a TestMainDispatcher,
// dispatching to Main throws -- which is exactly why this class passed 5/5
// in isolation but lost 4 tests in full-suite runs (2x DispatchException
// "Dispatchers.Main threw an exception", 2x "test body did not run to
// completion"). Every other test class here already does this.
Dispatchers.setMain(testDispatcher)
// Drive the coordinator's network hops on the test dispatcher instead of a
// live IO thread pool. With real Dispatchers.IO the mocked calls were
// recorded from a different thread and the Ordering.SEQUENCE assertions
// became load-dependent -- green on an idle machine, 2 failures while the
// emulator saturated the CPU.
ProfileSwitchCoordinator.ioDispatcher = testDispatcher
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
@@ -60,6 +77,8 @@ class ProfileSwitchCoordinatorTest {
@After
fun tearDown() {
ProfileSwitchCoordinator.ioDispatcher = Dispatchers.IO
Dispatchers.resetMain()
unmockkAll()
}
@@ -72,7 +91,18 @@ class ProfileSwitchCoordinatorTest {
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.setActiveProfile(SetActiveProfileRequest("meow")) }
verify(ordering = Ordering.SEQUENCE) {
// Ordering.ORDERED, not SEQUENCE: the point of this test is the relative
// ORDER of the switch steps. SEQUENCE also demands that NO other call
// touches the mocked objects in between, which is untestable in a shared
// test JVM -- real-thread tests (HermesWsClientTest) leave background
// ticket-mint retries calling AuthManager.getBaseUrl()/getServerStore()
// for the rest of the process. Under load those 18 foreign calls landed
// inside the verification window and failed the exact-count check even
// though all 5 steps fired in the correct order (proven by the MockK
// call trace: 1) setActiveProfileId 2) rebuild ... 21) sync 22)
// disconnect 23) connect). ORDER keeps the order-pinning and ignores
// foreign traffic.
verify(ordering = Ordering.ORDERED) {
AuthManager.setActiveProfileId("meow")
HermesWsClient.disconnect()
HermesWsClient.connect()
@@ -128,7 +158,12 @@ class ProfileSwitchCoordinatorTest {
// server's cookie → 401 → dead socket), then the socket re-dials
// — so chat's wipe (via the broadcast) lands before the new
// gateway's gateway.ready auto-creates the fresh session.
coVerify(ordering = Ordering.SEQUENCE) {
//
// ORDERED, not SEQUENCE -- see the sibling test above: SEQUENCE's
// exact-count semantics fail under load in the shared test JVM when
// leaked background retries interleave foreign AuthManager/ApiClient
// calls into the window. ORDER still pins this 5-step sequence.
coVerify(ordering = Ordering.ORDERED) {
AuthManager.setSelectedProfileId("prof-2")
ApiClient.rebuild()
AuthManager.syncCookieStoreForProfile("prof-2")
@@ -1,16 +1,22 @@
package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.SwrCache
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -18,6 +24,9 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ViewModelExtTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private class TestViewModel : ViewModel()
@Before
fun setUp() {
@@ -29,46 +38,149 @@ class ViewModelExtTest {
Dispatchers.resetMain()
}
private class TestViewModel : ViewModel()
@Test
fun `safeLaunchAction triggers onStart, onSuccess and onComplete on success`() =
runTest(testDispatcher) {
fun testNormalSwrCacheHitInvokesOnCacheHitImmediately() =
testScope.runTest {
val vm = TestViewModel()
var started = false
var successResult: String? = null
var completed = false
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val scopedKey = scope.scopedKey("default")
cache.put(scopedKey, "cached-val")
vm.safeLaunchAction(
onStart = { started = true },
apiCall = { NetworkResult.Success("ok") },
onSuccess = { successResult = it },
var cacheHit: String? = null
var success: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Success("fresh-val") },
onStart = {},
onSuccess = { success = it },
onError = {},
onComplete = { completed = true },
)
assertTrue(started)
// Cache hit happens synchronously before coroutine dispatches
assertEquals("cached-val", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("ok", successResult)
assertTrue(completed)
assertEquals("fresh-val", success)
assertEquals("fresh-val", cache.get(scopedKey))
}
@Test
fun `safeLaunchAction triggers onError and onComplete on failure`() =
runTest(testDispatcher) {
fun testForceRefreshBypassesCacheHitAndEmitsErrors() =
testScope.runTest {
val vm = TestViewModel()
var errorMessage: String? = null
var completed = false
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val scopedKey = scope.scopedKey("default")
cache.put(scopedKey, "cached-val")
vm.safeLaunchAction(
apiCall = { NetworkResult.Failure(NetworkError.Unknown("network error", Exception("boom"))) },
var cacheHit: String? = null
var errorReceived: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Failure(NetworkError.Http(500, "Server Error")) },
onStart = {},
onSuccess = {},
onError = { errorMessage = it },
onComplete = { completed = true },
onError = { errorReceived = it },
)
// Force refresh does NOT hit cache
assertEquals(null, cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("network error", errorMessage)
assertTrue(completed)
assertEquals("Server Error", errorReceived)
}
@Test
fun testSilentBackgroundFailurePreservesCachedContent() =
testScope.runTest {
val vm = TestViewModel()
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val scopedKey = scope.scopedKey("default")
cache.put(scopedKey, "cached-val")
var cacheHit: String? = null
var errorReceived: String? = null
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = false,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = { cacheHit = it },
apiCall = { NetworkResult.Failure(NetworkError.Http(500, "Server Error")) },
onStart = {},
onSuccess = {},
onError = { errorReceived = it },
)
assertEquals("cached-val", cacheHit)
testDispatcher.scheduler.advanceUntilIdle()
// Error is suppressed on silent revalidation failure
assertNull(errorReceived)
assertEquals("cached-val", cache.get(scopedKey))
}
@Test
fun testForcedRefreshSupersedesActiveJob() =
testScope.runTest {
val vm = TestViewModel()
val cache = SwrCache<String, String>()
val scope = DataScope("default", "http://server", "main", 0L)
val deferred1 = CompletableDeferred<NetworkResult<String>>()
val deferred2 = CompletableDeferred<NetworkResult<String>>()
var req1Success = false
var req2Success = false
val job1 =
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = false,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = {},
apiCall = { deferred1.await() },
onStart = {},
onSuccess = { req1Success = true },
onError = {},
)
val job2 =
vm.safeLaunchSwrLoad(
cache = cache,
forceRefresh = true,
currentJob = job1,
requestScope = scope,
scopeIsCurrent = { true },
onCacheHit = {},
apiCall = { deferred2.await() },
onStart = {},
onSuccess = { req2Success = true },
onError = {},
)
assertTrue(job1.isCancelled)
assertFalse(job2.isCancelled)
deferred2.complete(NetworkResult.Success("resp-2"))
deferred1.complete(NetworkResult.Success("resp-1"))
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(req2Success)
assertFalse(req1Success)
}
}