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
@@ -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