This commit is contained in:
16 files changed
+1485
-12
No files matched your search
@@ -0,0 +1,168 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionLiveStatusSourceTest {
|
||||
@Test
|
||||
fun `decodeSnapshot decodes working and waiting statuses by stored id`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"sessions" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"id" to "runtime-1",
|
||||
"session_key" to "stored-1",
|
||||
"status" to "working",
|
||||
),
|
||||
mapOf(
|
||||
"id" to "runtime-2",
|
||||
"session_key" to "stored-2",
|
||||
"status" to "waiting",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
|
||||
|
||||
assertNotNull(snapshot)
|
||||
assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["stored-1"])
|
||||
assertEquals(SessionLiveStatus.WAITING, snapshot.statusByStoredId["stored-2"])
|
||||
assertEquals("stored-1", snapshot.storedIdByRuntimeId["runtime-1"])
|
||||
assertEquals("stored-2", snapshot.storedIdByRuntimeId["runtime-2"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodeSnapshot ignores idle and starting for status but keeps runtime mapping`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"sessions" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"id" to "runtime-idle",
|
||||
"session_key" to "stored-idle",
|
||||
"status" to "idle",
|
||||
),
|
||||
mapOf(
|
||||
"id" to "runtime-start",
|
||||
"session_key" to "stored-start",
|
||||
"status" to "starting",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
|
||||
|
||||
assertNotNull(snapshot)
|
||||
assertTrue(snapshot!!.statusByStoredId.isEmpty())
|
||||
assertEquals("stored-idle", snapshot.storedIdByRuntimeId["runtime-idle"])
|
||||
assertEquals("stored-start", snapshot.storedIdByRuntimeId["runtime-start"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodeSnapshot skips items with blank id or session_key`() {
|
||||
val payload =
|
||||
mapOf(
|
||||
"sessions" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"id" to "",
|
||||
"session_key" to "stored-1",
|
||||
"status" to "working",
|
||||
),
|
||||
mapOf(
|
||||
"id" to "runtime-2",
|
||||
"session_key" to " ",
|
||||
"status" to "working",
|
||||
),
|
||||
mapOf(
|
||||
"id" to "runtime-3",
|
||||
"session_key" to "stored-3",
|
||||
"status" to "working",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
|
||||
|
||||
assertNotNull(snapshot)
|
||||
assertEquals(1, snapshot!!.statusByStoredId.size)
|
||||
assertEquals(SessionLiveStatus.WORKING, snapshot.statusByStoredId["stored-3"])
|
||||
assertEquals(1, snapshot.storedIdByRuntimeId.size)
|
||||
assertEquals("stored-3", snapshot.storedIdByRuntimeId["runtime-3"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodeSnapshot returns authoritative empty snapshot on empty sessions list`() {
|
||||
val payload = mapOf("sessions" to emptyList<Map<String, Any>>())
|
||||
|
||||
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
|
||||
|
||||
assertNotNull(snapshot)
|
||||
assertTrue(snapshot!!.statusByStoredId.isEmpty())
|
||||
assertTrue(snapshot.storedIdByRuntimeId.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodeSnapshot returns null for malformed or missing sessions field`() {
|
||||
assertNull(SessionLiveStatusDecoder.decodeSnapshot(null))
|
||||
assertNull(SessionLiveStatusDecoder.decodeSnapshot(mapOf("other" to "data")))
|
||||
assertNull(SessionLiveStatusDecoder.decodeSnapshot("invalid-json-string"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() =
|
||||
runBlocking {
|
||||
var capturedMethod: String? = null
|
||||
var capturedParams: Map<String, Any>? = null
|
||||
|
||||
val source =
|
||||
HermesSessionLiveStatusSource(
|
||||
rpcRequest = { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mapOf(
|
||||
"sessions" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"id" to "rt-1",
|
||||
"session_key" to "st-1",
|
||||
"status" to "working",
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
eventsProvider = { MutableSharedFlow() },
|
||||
connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) },
|
||||
)
|
||||
|
||||
val snapshot = source.fetchActiveSessionsSnapshot()
|
||||
|
||||
assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod)
|
||||
assertTrue(capturedParams!!.isEmpty())
|
||||
assertNotNull(snapshot)
|
||||
assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HermesSessionLiveStatusSource returns null when RPC throws`() =
|
||||
runBlocking {
|
||||
val source =
|
||||
HermesSessionLiveStatusSource(
|
||||
rpcRequest = { _, _ -> throw RuntimeException("RPC error") },
|
||||
eventsProvider = { MutableSharedFlow() },
|
||||
connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) },
|
||||
)
|
||||
|
||||
val snapshot = source.fetchActiveSessionsSnapshot()
|
||||
assertNull(snapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionLiveStatusReducerTest {
|
||||
@Test
|
||||
fun `snapshot replacement seeds WORKING and WAITING by stored ID`() {
|
||||
val snapshot =
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId =
|
||||
mapOf(
|
||||
"stored-1" to SessionLiveStatus.WORKING,
|
||||
"stored-2" to SessionLiveStatus.WAITING,
|
||||
),
|
||||
storedIdByRuntimeId =
|
||||
mapOf(
|
||||
"rt-1" to "stored-1",
|
||||
"rt-2" to "stored-2",
|
||||
),
|
||||
)
|
||||
|
||||
val state = SessionLiveStatusReducer.applySnapshot(SessionLiveTrackingState(), snapshot)
|
||||
|
||||
assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"])
|
||||
assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-2"])
|
||||
assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"])
|
||||
assertEquals("stored-2", state.storedIdByRuntimeId["rt-2"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `later successful empty snapshot removes previously live rows`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val state =
|
||||
SessionLiveStatusReducer.applySnapshot(
|
||||
initial,
|
||||
LiveSessionSnapshot(emptyMap(), emptyMap()),
|
||||
)
|
||||
|
||||
assertTrue(state.liveStatuses.isEmpty())
|
||||
assertTrue(state.storedIdByRuntimeId.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime ID reuse moves activity from old stored ID to new stored ID`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = mapOf("stored-old" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-old"),
|
||||
)
|
||||
|
||||
val event =
|
||||
WsEvent.SessionInfo(
|
||||
data =
|
||||
mapOf(
|
||||
"session_id" to "rt-1",
|
||||
"stored_session_id" to "stored-new",
|
||||
"running" to true,
|
||||
),
|
||||
)
|
||||
|
||||
val state = SessionLiveStatusReducer.applyWsEvent(initial, event)
|
||||
|
||||
assertNull(state.liveStatuses["stored-old"])
|
||||
assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-new"])
|
||||
assertEquals("stored-new", state.storedIdByRuntimeId["rt-1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session info with stored_session_id and running=true marks WORKING`() {
|
||||
val initial = SessionLiveTrackingState()
|
||||
|
||||
val event =
|
||||
WsEvent.SessionInfo(
|
||||
data =
|
||||
mapOf(
|
||||
"session_id" to "rt-1",
|
||||
"stored_session_id" to "stored-1",
|
||||
"running" to true,
|
||||
),
|
||||
)
|
||||
|
||||
val state = SessionLiveStatusReducer.applyWsEvent(initial, event)
|
||||
|
||||
assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"])
|
||||
assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session info with running=false clears that stored row`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val event =
|
||||
WsEvent.SessionInfo(
|
||||
data =
|
||||
mapOf(
|
||||
"session_id" to "rt-1",
|
||||
"stored_session_id" to "stored-1",
|
||||
"running" to false,
|
||||
),
|
||||
)
|
||||
|
||||
val state = SessionLiveStatusReducer.applyWsEvent(initial, event)
|
||||
|
||||
assertNull(state.liveStatuses["stored-1"])
|
||||
assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message start marks WORKING only when runtime-to-stored identity is known`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = emptyMap(),
|
||||
storedIdByRuntimeId = mapOf("rt-known" to "stored-known"),
|
||||
)
|
||||
|
||||
// Unknown runtime id -> no mutation
|
||||
val unknownEvent = WsEvent.MessageStart(sessionId = "rt-unknown")
|
||||
val state1 = SessionLiveStatusReducer.applyWsEvent(initial, unknownEvent)
|
||||
assertTrue(state1.liveStatuses.isEmpty())
|
||||
|
||||
// Known runtime id -> marks WORKING
|
||||
val knownEvent = WsEvent.MessageStart(sessionId = "rt-known")
|
||||
val state2 = SessionLiveStatusReducer.applyWsEvent(state1, knownEvent)
|
||||
assertEquals(SessionLiveStatus.WORKING, state2.liveStatuses["stored-known"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message complete and message done clear only matching runtime`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses =
|
||||
mapOf(
|
||||
"stored-1" to SessionLiveStatus.WORKING,
|
||||
"stored-2" to SessionLiveStatus.WORKING,
|
||||
),
|
||||
storedIdByRuntimeId =
|
||||
mapOf(
|
||||
"rt-1" to "stored-1",
|
||||
"rt-2" to "stored-2",
|
||||
),
|
||||
)
|
||||
|
||||
val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "rt-1")
|
||||
val state1 = SessionLiveStatusReducer.applyWsEvent(initial, completeEvent)
|
||||
|
||||
assertNull(state1.liveStatuses["stored-1"])
|
||||
assertEquals(SessionLiveStatus.WORKING, state1.liveStatuses["stored-2"])
|
||||
|
||||
val doneEvent = WsEvent.MessageDone(sessionId = "rt-2")
|
||||
val state2 = SessionLiveStatusReducer.applyWsEvent(state1, doneEvent)
|
||||
|
||||
assertNull(state2.liveStatuses["stored-2"])
|
||||
assertTrue(state2.liveStatuses.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval request and clarify request mark WAITING for known runtime`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val approvalEvent =
|
||||
WsEvent.ApprovalRequest(
|
||||
command = "rm -rf",
|
||||
description = "delete",
|
||||
patternKeys = null,
|
||||
sessionId = "rt-1",
|
||||
requestId = "req-1",
|
||||
choices = listOf("allow", "deny"),
|
||||
allowPermanent = false,
|
||||
smartDenied = false,
|
||||
)
|
||||
|
||||
val state = SessionLiveStatusReducer.applyWsEvent(initial, approvalEvent)
|
||||
assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-1"])
|
||||
|
||||
val clarifyEvent =
|
||||
WsEvent.ClarifyRequest(
|
||||
text = "which option?",
|
||||
options = listOf("a", "b"),
|
||||
sessionId = "rt-1",
|
||||
)
|
||||
|
||||
val state2 = SessionLiveStatusReducer.applyWsEvent(initial, clarifyEvent)
|
||||
assertEquals(SessionLiveStatus.WAITING, state2.liveStatuses["stored-1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown or missing IDs never mutate another session`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val state1 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageStart(sessionId = null))
|
||||
assertEquals(initial, state1)
|
||||
|
||||
val state2 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageDone(sessionId = "other"))
|
||||
assertEquals(initial, state2)
|
||||
|
||||
val state3 =
|
||||
SessionLiveStatusReducer.applyWsEvent(
|
||||
initial,
|
||||
WsEvent.SessionInfo(data = null),
|
||||
)
|
||||
assertEquals(initial, state3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear resets state to empty`() {
|
||||
val initial =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val cleared = SessionLiveStatusReducer.clear()
|
||||
assertTrue(cleared.liveStatuses.isEmpty())
|
||||
assertTrue(cleared.storedIdByRuntimeId.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,31 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResponse
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.ws.ChangeEventHub
|
||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
@@ -24,6 +36,7 @@ import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -34,8 +47,25 @@ class SessionsViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
||||
|
||||
private fun createViewModel(): SessionsViewModel {
|
||||
val vm = SessionsViewModel()
|
||||
private class FakeSessionLiveStatusSource : SessionLiveStatusSource {
|
||||
val eventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 16)
|
||||
val connectionStatusFlow = MutableStateFlow(ConnectionStatus.CONNECTED)
|
||||
var snapshotToReturn: LiveSessionSnapshot? = null
|
||||
var fetchCallCount = 0
|
||||
var snapshotDeferred: CompletableDeferred<LiveSessionSnapshot?>? = null
|
||||
|
||||
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? {
|
||||
fetchCallCount++
|
||||
val deferred = snapshotDeferred
|
||||
return if (deferred != null) deferred.await() else snapshotToReturn
|
||||
}
|
||||
|
||||
override val events: Flow<WsEvent> = eventsFlow
|
||||
override val connectionStatus: StateFlow<ConnectionStatus> = connectionStatusFlow
|
||||
}
|
||||
|
||||
private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel {
|
||||
val vm = SessionsViewModel(liveStatusSource = source)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
return vm
|
||||
}
|
||||
@@ -548,4 +578,219 @@ class SessionsViewModelTest {
|
||||
vm.togglePinnedExpanded()
|
||||
assertTrue(vm.uiState.value.pinnedExpanded)
|
||||
}
|
||||
|
||||
// ── Live Session Status Tracking (issue #1101) ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `initial snapshot hydration updates liveStatuses`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
source.snapshotToReturn =
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
|
||||
assertEquals(1, source.fetchCallCount)
|
||||
vm.stopLiveStatusTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful absence reaping clears statuses not in snapshot`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
source.snapshotToReturn =
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
|
||||
|
||||
// Second snapshot returns empty
|
||||
source.snapshotToReturn = LiveSessionSnapshot(emptyMap(), emptyMap())
|
||||
vm.refreshLiveStatuses()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
assertTrue(
|
||||
vm.uiState.value.liveStatuses
|
||||
.isEmpty(),
|
||||
)
|
||||
assertEquals(2, source.fetchCallCount)
|
||||
vm.stopLiveStatusTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `working to idle event transition via SessionInfo updates liveStatuses`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
source.snapshotToReturn =
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
|
||||
|
||||
// Send running = false
|
||||
source.eventsFlow.tryEmit(
|
||||
WsEvent.SessionInfo(
|
||||
data =
|
||||
mapOf(
|
||||
"session_id" to "rt-1",
|
||||
"stored_session_id" to "stored-1",
|
||||
"running" to false,
|
||||
),
|
||||
),
|
||||
)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
assertNull(vm.uiState.value.liveStatuses["stored-1"])
|
||||
vm.stopLiveStatusTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disconnect clears immediately and reconnect rehydrates`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
source.snapshotToReturn =
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
|
||||
|
||||
// Disconnect
|
||||
source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(
|
||||
vm.uiState.value.liveStatuses
|
||||
.isEmpty(),
|
||||
)
|
||||
|
||||
// Reconnect
|
||||
source.snapshotToReturn =
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WAITING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
source.connectionStatusFlow.value = ConnectionStatus.CONNECTED
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
assertEquals(SessionLiveStatus.WAITING, vm.uiState.value.liveStatuses["stored-1"])
|
||||
vm.stopLiveStatusTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late pre-disconnect response is ignored`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
val deferred = CompletableDeferred<LiveSessionSnapshot?>()
|
||||
source.snapshotDeferred = deferred
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
// Disconnect before deferred completes
|
||||
source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
// Complete the late response
|
||||
deferred.complete(
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
),
|
||||
)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
// Must remain empty because the response was from the previous generation
|
||||
assertTrue(
|
||||
vm.uiState.value.liveStatuses
|
||||
.isEmpty(),
|
||||
)
|
||||
vm.stopLiveStatusTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated start is idempotent`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
source.snapshotToReturn = LiveSessionSnapshot()
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
assertEquals(1, source.fetchCallCount)
|
||||
vm.stopLiveStatusTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop cancels ticker and clears state`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
source.snapshotToReturn =
|
||||
LiveSessionSnapshot(
|
||||
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
|
||||
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
|
||||
)
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals(1, vm.uiState.value.liveStatuses.size)
|
||||
|
||||
vm.stopLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(
|
||||
vm.uiState.value.liveStatuses
|
||||
.isEmpty(),
|
||||
)
|
||||
|
||||
// Advancing time past the 30s poll interval should NOT trigger another fetch
|
||||
testDispatcher.scheduler.advanceTimeBy(60_000)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals(1, source.fetchCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change bursts coalesce into at most one trailing request`() {
|
||||
val source = FakeSessionLiveStatusSource()
|
||||
val deferred1 = CompletableDeferred<LiveSessionSnapshot?>()
|
||||
source.snapshotDeferred = deferred1
|
||||
|
||||
val vm = createViewModel(source)
|
||||
vm.startLiveStatusTracking()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals(1, source.fetchCallCount)
|
||||
|
||||
// Multiple change events while request 1 is in flight
|
||||
source.snapshotDeferred = CompletableDeferred()
|
||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null))
|
||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null))
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
// Call count should still be 1 (in-flight)
|
||||
assertEquals(1, source.fetchCallCount)
|
||||
|
||||
// Complete request 1
|
||||
deferred1.complete(LiveSessionSnapshot())
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
// Request 2 was coalesced and launched as the trailing request
|
||||
assertEquals(2, source.fetchCallCount)
|
||||
vm.stopLiveStatusTracking()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user