perf: optimize screen load times with progressive un-gating and SWR caching (#1192)
This commit is contained in:
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() }
|
||||
}
|
||||
}
|
||||
+43
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user