This commit is contained in:
16 files changed
+1485
-12
No files matched your search
+180
@@ -0,0 +1,180 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class SessionLiveStatusIndicatorTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun workingStatus_displaysRunningIndicator() {
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = SessionLiveStatus.WORKING,
|
||||
sessionId = "sess-working",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_live_status_sess-working").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Running").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waitingStatus_displaysNeedsInputIndicator() {
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = SessionLiveStatus.WAITING,
|
||||
sessionId = "sess-waiting",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_live_status_sess-waiting").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Needs input").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCard_withLiveStatus_displaysIndicator() {
|
||||
val session =
|
||||
SessionInfo(
|
||||
id = "sess-card-working",
|
||||
title = "Working session",
|
||||
message_count = 5,
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = "Working session",
|
||||
branchStem = null,
|
||||
isFork = false,
|
||||
forkDepth = 0,
|
||||
query = "",
|
||||
isSelecting = false,
|
||||
isSelected = false,
|
||||
isDeleting = false,
|
||||
isPinned = false,
|
||||
isHidden = false,
|
||||
liveStatus = SessionLiveStatus.WORKING,
|
||||
highlightBackground = Color.Transparent,
|
||||
highlightForeground = Color.Transparent,
|
||||
onCardClick = {},
|
||||
onToggleSelection = {},
|
||||
onSelect = {},
|
||||
onRename = {},
|
||||
onTogglePin = {},
|
||||
onToggleHide = {},
|
||||
onDelete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_card_sess-card-working").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithTag(
|
||||
"session_live_status_sess-card-working",
|
||||
useUnmergedTree = true,
|
||||
).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Running", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCard_withoutLiveStatus_hidesIndicator() {
|
||||
val session =
|
||||
SessionInfo(
|
||||
id = "sess-card-idle",
|
||||
title = "Idle session",
|
||||
message_count = 2,
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = "Idle session",
|
||||
branchStem = null,
|
||||
isFork = false,
|
||||
forkDepth = 0,
|
||||
query = "",
|
||||
isSelecting = false,
|
||||
isSelected = false,
|
||||
isDeleting = false,
|
||||
isPinned = false,
|
||||
isHidden = false,
|
||||
liveStatus = null,
|
||||
highlightBackground = Color.Transparent,
|
||||
highlightForeground = Color.Transparent,
|
||||
onCardClick = {},
|
||||
onToggleSelection = {},
|
||||
onSelect = {},
|
||||
onRename = {},
|
||||
onTogglePin = {},
|
||||
onToggleHide = {},
|
||||
onDelete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_card_sess-card-idle").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_live_status_sess-card-idle").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchResultCard_withLiveStatus_displaysIndicator() {
|
||||
val session =
|
||||
SessionInfo(
|
||||
id = "sess-search-waiting",
|
||||
preview = "Searching something",
|
||||
source = "web",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SearchResultCard(
|
||||
session = session,
|
||||
query = "Searching",
|
||||
isSelecting = false,
|
||||
isSelected = false,
|
||||
isDeleting = false,
|
||||
liveStatus = SessionLiveStatus.WAITING,
|
||||
highlightBackground = Color.Transparent,
|
||||
highlightForeground = Color.Transparent,
|
||||
onCardClick = {},
|
||||
onToggleSelection = {},
|
||||
onSelect = {},
|
||||
onRename = {},
|
||||
onDelete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_card_sess-search-waiting").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithTag(
|
||||
"session_live_status_sess-search-waiting",
|
||||
useUnmergedTree = true,
|
||||
).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Needs input", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* High-level live status for a session in History.
|
||||
*/
|
||||
enum class SessionLiveStatus {
|
||||
WORKING,
|
||||
WAITING,
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC response from `session.active_list`.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActiveSessionsResponse(
|
||||
val sessions: List<ActiveSessionItem> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Individual item in `session.active_list`.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActiveSessionItem(
|
||||
val id: String? = null,
|
||||
@SerialName("session_key")
|
||||
val sessionKey: String? = null,
|
||||
val status: String? = null,
|
||||
@SerialName("last_active")
|
||||
val lastActive: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed authoritative snapshot of live sessions.
|
||||
*/
|
||||
data class LiveSessionSnapshot(
|
||||
val statusByStoredId: Map<String, SessionLiveStatus> = emptyMap(),
|
||||
val storedIdByRuntimeId: Map<String, String> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
|
||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.serializer
|
||||
|
||||
/**
|
||||
* Interface for polling and observing live session statuses.
|
||||
*/
|
||||
interface SessionLiveStatusSource {
|
||||
suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot?
|
||||
|
||||
val events: Flow<WsEvent>
|
||||
val connectionStatus: StateFlow<ConnectionStatus>
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes untyped RPC responses from `session.active_list` into a [LiveSessionSnapshot].
|
||||
*/
|
||||
object SessionLiveStatusDecoder {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
fun decodeSnapshot(rawResult: Any?): LiveSessionSnapshot? {
|
||||
if (rawResult == null) return null
|
||||
val response =
|
||||
try {
|
||||
decodeResponse(rawResult)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
} ?: return null
|
||||
|
||||
val statusByStoredId = mutableMapOf<String, SessionLiveStatus>()
|
||||
val storedIdByRuntimeId = mutableMapOf<String, String>()
|
||||
|
||||
for (item in response.sessions) {
|
||||
val runtimeId = item.id?.trim()
|
||||
val storedId = item.sessionKey?.trim()
|
||||
if (runtimeId.isNullOrEmpty() || storedId.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
storedIdByRuntimeId[runtimeId] = storedId
|
||||
when (item.status?.trim()?.lowercase()) {
|
||||
"working" -> {
|
||||
statusByStoredId[storedId] = SessionLiveStatus.WORKING
|
||||
}
|
||||
|
||||
"waiting" -> {
|
||||
statusByStoredId[storedId] = SessionLiveStatus.WAITING
|
||||
}
|
||||
|
||||
else -> {
|
||||
// idle, starting, unknown -> no live indicator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LiveSessionSnapshot(
|
||||
statusByStoredId = statusByStoredId,
|
||||
storedIdByRuntimeId = storedIdByRuntimeId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeResponse(result: Any): ActiveSessionsResponse? {
|
||||
val element: JsonElement =
|
||||
when (result) {
|
||||
is JsonElement -> {
|
||||
result
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
anyToJsonElement(result)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(result.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
else -> {
|
||||
val str = result.toString()
|
||||
try {
|
||||
json.parseToJsonElement(str)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (element !is JsonObject || !element.containsKey("sessions")) {
|
||||
return null
|
||||
}
|
||||
return json.decodeFromJsonElement(serializer<ActiveSessionsResponse>(), element)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> {
|
||||
JsonNull
|
||||
}
|
||||
|
||||
is JsonElement -> {
|
||||
value
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
JsonObject(
|
||||
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
|
||||
)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(value.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
is String -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Boolean -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Number -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
else -> {
|
||||
JsonPrimitive(value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
|
||||
*/
|
||||
class HermesSessionLiveStatusSource(
|
||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
||||
HermesWsClient.request(method, params).await()
|
||||
},
|
||||
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
|
||||
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
|
||||
) : SessionLiveStatusSource {
|
||||
override val events: Flow<WsEvent> by lazy { eventsProvider() }
|
||||
override val connectionStatus: StateFlow<ConnectionStatus> by lazy { connectionStatusProvider() }
|
||||
|
||||
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
|
||||
try {
|
||||
val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap())
|
||||
SessionLiveStatusDecoder.decodeSnapshot(raw)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Transient state for tracking live active sessions in History.
|
||||
*/
|
||||
data class SessionLiveTrackingState(
|
||||
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
||||
val storedIdByRuntimeId: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure reducer managing live active session transitions.
|
||||
*/
|
||||
object SessionLiveStatusReducer {
|
||||
fun applySnapshot(
|
||||
state: SessionLiveTrackingState,
|
||||
snapshot: LiveSessionSnapshot,
|
||||
): SessionLiveTrackingState =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = snapshot.statusByStoredId,
|
||||
storedIdByRuntimeId = snapshot.storedIdByRuntimeId,
|
||||
)
|
||||
|
||||
fun applyWsEvent(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent,
|
||||
): SessionLiveTrackingState =
|
||||
when (event) {
|
||||
is WsEvent.SessionInfo -> reduceSessionInfo(state, event)
|
||||
is WsEvent.MessageStart -> reduceMessageStart(state, event)
|
||||
is WsEvent.MessageComplete -> reduceMessageComplete(state, event)
|
||||
is WsEvent.MessageDone -> reduceMessageDone(state, event)
|
||||
is WsEvent.ApprovalRequest -> reduceApprovalRequest(state, event)
|
||||
is WsEvent.ClarifyRequest -> reduceClarifyRequest(state, event)
|
||||
else -> state
|
||||
}
|
||||
|
||||
fun clear(): SessionLiveTrackingState = SessionLiveTrackingState()
|
||||
|
||||
private fun reduceSessionInfo(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.SessionInfo,
|
||||
): SessionLiveTrackingState {
|
||||
val data = event.data ?: return state
|
||||
val storedId = (data["stored_session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val runtimeId = (data["session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val running = data["running"] as? Boolean
|
||||
|
||||
val targetStoredId = storedId ?: runtimeId?.let { state.storedIdByRuntimeId[it] }
|
||||
if (targetStoredId == null && runtimeId == null) {
|
||||
return state
|
||||
}
|
||||
|
||||
val oldStoredId = runtimeId?.let { state.storedIdByRuntimeId[it] }
|
||||
val nextMapping =
|
||||
if (runtimeId != null && targetStoredId != null) {
|
||||
state.storedIdByRuntimeId + (runtimeId to targetStoredId)
|
||||
} else {
|
||||
state.storedIdByRuntimeId
|
||||
}
|
||||
|
||||
if (targetStoredId == null) {
|
||||
return state.copy(storedIdByRuntimeId = nextMapping)
|
||||
}
|
||||
|
||||
val nextStatuses = state.liveStatuses.toMutableMap()
|
||||
if (oldStoredId != null && oldStoredId != targetStoredId) {
|
||||
nextStatuses.remove(oldStoredId)
|
||||
}
|
||||
|
||||
when (running) {
|
||||
true -> {
|
||||
nextStatuses[targetStoredId] = SessionLiveStatus.WORKING
|
||||
}
|
||||
|
||||
false -> {
|
||||
nextStatuses.remove(targetStoredId)
|
||||
}
|
||||
|
||||
null -> {
|
||||
// If running is not specified, leave status untouched
|
||||
}
|
||||
}
|
||||
|
||||
return state.copy(
|
||||
liveStatuses = nextStatuses,
|
||||
storedIdByRuntimeId = nextMapping,
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceMessageStart(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.MessageStart,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
if (state.liveStatuses[storedId] == SessionLiveStatus.WORKING) return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WORKING),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceMessageComplete(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.MessageComplete,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val storedId =
|
||||
event.storedSessionId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: runtimeId?.let { state.storedIdByRuntimeId[it] }
|
||||
?: return state
|
||||
|
||||
if (!state.liveStatuses.containsKey(storedId)) return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses - storedId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceMessageDone(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.MessageDone,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
if (!state.liveStatuses.containsKey(storedId)) return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses - storedId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceApprovalRequest(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.ApprovalRequest,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceClarifyRequest(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.ClarifyRequest,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -274,6 +275,13 @@ fun SessionsScreen(
|
||||
viewModel.loadSessions()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startLiveStatusTracking()
|
||||
onDispose {
|
||||
viewModel.stopLiveStatusTracking()
|
||||
}
|
||||
}
|
||||
|
||||
// Toast effect
|
||||
ToastEffect(
|
||||
toastMessage = state.toastMessage,
|
||||
@@ -612,6 +620,7 @@ fun SessionsScreen(
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
liveStatus = state.liveStatuses[session.id],
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
@@ -760,6 +769,7 @@ fun SessionsScreen(
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
liveStatus = state.liveStatuses[session.id],
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
|
||||
@@ -5,12 +5,18 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.PruneRequest
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
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.HermesSessionLiveStatusSource
|
||||
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
@@ -19,6 +25,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
@@ -90,6 +97,7 @@ data class SessionsUiState(
|
||||
val searchError: String? = null,
|
||||
val showHidden: Boolean = false,
|
||||
val pinnedExpanded: Boolean = true,
|
||||
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
||||
) {
|
||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||
|
||||
@@ -103,8 +111,9 @@ data class SessionsUiState(
|
||||
get() = displaySessions.filter { it.pinned == true }
|
||||
}
|
||||
|
||||
class SessionsViewModel :
|
||||
ViewModel(),
|
||||
class SessionsViewModel(
|
||||
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||
@@ -112,6 +121,11 @@ class SessionsViewModel :
|
||||
private var loadJob: Job? = null
|
||||
private var pageJob: Job? = null
|
||||
private var statsJob: Job? = null
|
||||
private var trackingJob: Job? = null
|
||||
private var trackingGeneration: Long = 0
|
||||
private var liveTrackingState = SessionLiveTrackingState()
|
||||
private var liveStatusRefreshInFlight = false
|
||||
private var liveStatusRefreshPending = false
|
||||
private var generation: Long = 0
|
||||
private var rawPaginationOffset: Int = 0
|
||||
|
||||
@@ -172,6 +186,7 @@ class SessionsViewModel :
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
const val MAX_STITCH_PER_PAGE = 8
|
||||
const val MAX_STITCH_ROUNDS = 3
|
||||
const val LIVE_STATUS_POLL_INTERVAL_MS = 30_000L
|
||||
}
|
||||
|
||||
private val resolvedParentCache = mutableMapOf<String, SessionInfo>()
|
||||
@@ -259,6 +274,9 @@ class SessionsViewModel :
|
||||
val section = _uiState.value.section
|
||||
pageJob?.cancel()
|
||||
loadEmptyCount()
|
||||
if (trackingJob?.isActive == true) {
|
||||
refreshLiveStatuses()
|
||||
}
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
currentJob = loadJob,
|
||||
@@ -863,4 +881,106 @@ class SessionsViewModel :
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
// ── Live Session Status Tracking (issue #1101) ───────────────────────
|
||||
|
||||
fun startLiveStatusTracking() {
|
||||
if (trackingJob?.isActive == true) return
|
||||
trackingJob =
|
||||
viewModelScope.launch {
|
||||
// Connection status observer: non-connected clears immediately, CONNECTED rehydrates.
|
||||
launch {
|
||||
liveStatusSource.connectionStatus.collect { status ->
|
||||
if (status != ConnectionStatus.CONNECTED) {
|
||||
trackingGeneration++
|
||||
liveTrackingState = SessionLiveStatusReducer.clear()
|
||||
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
||||
} else {
|
||||
requestLiveStatusSnapshot(++trackingGeneration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live WebSocket events observer.
|
||||
launch {
|
||||
liveStatusSource.events.collect { event ->
|
||||
liveTrackingState = SessionLiveStatusReducer.applyWsEvent(liveTrackingState, event)
|
||||
_uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) }
|
||||
}
|
||||
}
|
||||
|
||||
// Change events observer: sessions.changed triggers a snapshot refresh.
|
||||
launch {
|
||||
ChangeEventHub.events
|
||||
.filter { it.type == ChangeEvents.SESSIONS }
|
||||
.collect {
|
||||
requestLiveStatusSnapshot(trackingGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
// Visible-only 30s poll backstop
|
||||
launch {
|
||||
while (true) {
|
||||
delay(LIVE_STATUS_POLL_INTERVAL_MS)
|
||||
requestLiveStatusSnapshot(trackingGeneration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopLiveStatusTracking() {
|
||||
trackingGeneration++
|
||||
trackingJob?.cancel()
|
||||
trackingJob = null
|
||||
liveStatusRefreshInFlight = false
|
||||
liveStatusRefreshPending = false
|
||||
liveTrackingState = SessionLiveStatusReducer.clear()
|
||||
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
||||
}
|
||||
|
||||
fun refreshLiveStatuses() {
|
||||
requestLiveStatusSnapshot(trackingGeneration)
|
||||
}
|
||||
|
||||
private fun requestLiveStatusSnapshot(gen: Long) {
|
||||
if (trackingJob?.isActive != true ||
|
||||
gen != trackingGeneration ||
|
||||
liveStatusSource.connectionStatus.value != ConnectionStatus.CONNECTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (liveStatusRefreshInFlight) {
|
||||
liveStatusRefreshPending = true
|
||||
return
|
||||
}
|
||||
liveStatusRefreshInFlight = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val snapshot = liveStatusSource.fetchActiveSessionsSnapshot()
|
||||
if (trackingJob?.isActive == true &&
|
||||
gen == trackingGeneration &&
|
||||
liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED &&
|
||||
snapshot != null
|
||||
) {
|
||||
liveTrackingState = SessionLiveStatusReducer.applySnapshot(liveTrackingState, snapshot)
|
||||
_uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) }
|
||||
}
|
||||
} finally {
|
||||
liveStatusRefreshInFlight = false
|
||||
if (liveStatusRefreshPending &&
|
||||
trackingJob?.isActive == true &&
|
||||
gen == trackingGeneration &&
|
||||
liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||
) {
|
||||
liveStatusRefreshPending = false
|
||||
requestLiveStatusSnapshot(gen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopLiveStatusTracking()
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
@@ -56,6 +58,7 @@ fun SearchResultCard(
|
||||
isSelecting: Boolean,
|
||||
isSelected: Boolean,
|
||||
isDeleting: Boolean,
|
||||
liveStatus: SessionLiveStatus? = null,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
onCardClick: () -> Unit,
|
||||
@@ -65,6 +68,7 @@ fun SearchResultCard(
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
||||
val cleanSnippet = cleanSearchSnippet(snippet)
|
||||
val playedAt = formatPlayedAt(session.started_at)
|
||||
@@ -86,6 +90,10 @@ fun SearchResultCard(
|
||||
border =
|
||||
if (isSelected) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) {
|
||||
BorderStroke(2.dp, statusColors.success)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) {
|
||||
BorderStroke(2.dp, statusColors.warning)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -153,6 +161,12 @@ fun SearchResultCard(
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
if (liveStatus != null) {
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = liveStatus,
|
||||
sessionId = session.id,
|
||||
)
|
||||
}
|
||||
session.source?.let { src ->
|
||||
StatusBadge(
|
||||
text = sourceLabel(src),
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.model.SessionTreeItem
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
@@ -146,6 +147,7 @@ fun SessionCard(
|
||||
isDeleting: Boolean,
|
||||
isPinned: Boolean,
|
||||
isHidden: Boolean = false,
|
||||
liveStatus: SessionLiveStatus? = null,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
onCardClick: () -> Unit,
|
||||
@@ -158,7 +160,6 @@ fun SessionCard(
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
||||
val srcIcon = sourceIcon(session.source)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -176,10 +177,12 @@ fun SessionCard(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border =
|
||||
if (isActive && !isSelecting) {
|
||||
BorderStroke(2.dp, statusColors.success)
|
||||
} else if (isSelected) {
|
||||
if (isSelected) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) {
|
||||
BorderStroke(2.dp, statusColors.success)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) {
|
||||
BorderStroke(2.dp, statusColors.warning)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -270,11 +273,11 @@ fun SessionCard(
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
if (!session.status.isNullOrBlank()) {
|
||||
if (liveStatus != null) {
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
StatusBadge(
|
||||
text = session.status,
|
||||
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = liveStatus,
|
||||
sessionId = session.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.m57.hermescontrol.ui.sessions.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
|
||||
@Composable
|
||||
fun SessionLiveStatusIndicator(
|
||||
liveStatus: SessionLiveStatus,
|
||||
sessionId: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val (bgColor, fgColor, textRes, descRes) =
|
||||
when (liveStatus) {
|
||||
SessionLiveStatus.WORKING -> {
|
||||
Tuple4(
|
||||
statusColors.successContainer,
|
||||
statusColors.success,
|
||||
R.string.sessions_live_status_running,
|
||||
R.string.sessions_live_status_running_desc,
|
||||
)
|
||||
}
|
||||
|
||||
SessionLiveStatus.WAITING -> {
|
||||
Tuple4(
|
||||
statusColors.warningContainer,
|
||||
statusColors.warning,
|
||||
R.string.sessions_live_status_waiting,
|
||||
R.string.sessions_live_status_waiting_desc,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val text = stringResource(textRes)
|
||||
val description = stringResource(descRes)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.testTag("session_live_status_$sessionId")
|
||||
.semantics { contentDescription = description },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = bgColor,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
horizontal = spacing.sm,
|
||||
vertical = 2.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
when (liveStatus) {
|
||||
SessionLiveStatus.WORKING -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(10.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = fgColor,
|
||||
)
|
||||
}
|
||||
|
||||
SessionLiveStatus.WAITING -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = fgColor,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = fgColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Tuple4<A, B, C, D>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D,
|
||||
)
|
||||
@@ -543,6 +543,10 @@
|
||||
<string name="sessions_all_hidden_desc">جميع الجلسات مخفية حاليًا. اضغط أدناه لإظهار الجلسات المخفية.</string>
|
||||
<string name="sessions_pinned_indicator">مثبتة</string>
|
||||
<string name="sessions_fork_indicator">جلسة مفرعة</string>
|
||||
<string name="sessions_live_status_running">قيد التشغيل</string>
|
||||
<string name="sessions_live_status_waiting">بانتظار إدخال</string>
|
||||
<string name="sessions_live_status_running_desc">الجلسة قيد التشغيل</string>
|
||||
<string name="sessions_live_status_waiting_desc">الجلسة بانتظار إدخال من المستخدم</string>
|
||||
<string name="sessions_rename_title">إعادة تسمية الجلسة</string>
|
||||
<string name="sessions_rename_hint">عنوان الجلسة</string>
|
||||
<string name="toolsets_empty_title">لم يتم العثور على مجموعات أدوات</string>
|
||||
|
||||
@@ -423,6 +423,10 @@
|
||||
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
|
||||
<string name="sessions_hidden_badge">숨김</string>
|
||||
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</string>
|
||||
<string name="sessions_live_status_running">실행 중</string>
|
||||
<string name="sessions_live_status_waiting">입력 대기 중</string>
|
||||
<string name="sessions_live_status_running_desc">세션이 실행 중입니다</string>
|
||||
<string name="sessions_live_status_waiting_desc">세션이 사용자 입력을 대기하고 있습니다</string>
|
||||
|
||||
<!-- Toolsets Screen -->
|
||||
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
||||
|
||||
@@ -534,6 +534,10 @@
|
||||
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
|
||||
<string name="sessions_pinned_indicator">已置顶</string>
|
||||
<string name="sessions_fork_indicator">分支会话</string>
|
||||
<string name="sessions_live_status_running">运行中</string>
|
||||
<string name="sessions_live_status_waiting">等待输入</string>
|
||||
<string name="sessions_live_status_running_desc">会话正在运行中</string>
|
||||
<string name="sessions_live_status_waiting_desc">会话等待用户输入</string>
|
||||
<string name="sessions_rename_title">重命名会话</string>
|
||||
<string name="sessions_rename_hint">会话标题</string>
|
||||
<string name="toolsets_empty_title">没有工具集数据</string>
|
||||
|
||||
@@ -619,6 +619,10 @@
|
||||
<string name="sessions_all_hidden_desc">All sessions are currently hidden. Tap below to show hidden sessions.</string>
|
||||
<string name="sessions_pinned_indicator">Pinned</string>
|
||||
<string name="sessions_fork_indicator">Forked session</string>
|
||||
<string name="sessions_live_status_running">Running</string>
|
||||
<string name="sessions_live_status_waiting">Needs input</string>
|
||||
<string name="sessions_live_status_running_desc">Session is running</string>
|
||||
<string name="sessions_live_status_waiting_desc">Session needs user input</string>
|
||||
<string name="sessions_rename_title">Rename Session</string>
|
||||
<string name="sessions_rename_hint">Session title</string>
|
||||
|
||||
|
||||
@@ -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