feat: support session-level hidden flag in SessionListResponse and SessionsScreen (#1033)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
aa28d96b5d
commit
908255f5ad
10 files changed
+316
-20
No files matched your search
@@ -83,6 +83,10 @@ data class SessionInfo(
|
|||||||
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
||||||
@Serializable(with = LenientNullableBooleanSerializer::class)
|
@Serializable(with = LenientNullableBooleanSerializer::class)
|
||||||
val pinned: Boolean? = null,
|
val pinned: Boolean? = null,
|
||||||
|
// Session-level hidden flag (issue #1019): background agent sessions or internal routines.
|
||||||
|
// Uses LenientNullableBooleanSerializer to tolerate booleans, raw SQLite ints (0/1), and strings.
|
||||||
|
@Serializable(with = LenientNullableBooleanSerializer::class)
|
||||||
|
val hidden: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -95,10 +99,11 @@ data class SessionStatsResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionRenameRequest(
|
data class SessionRenameRequest(
|
||||||
// Both fields are optional: the backend PATCH accepts any subset
|
// All fields are optional: the backend PATCH accepts any subset
|
||||||
// (SessionRename model: title/archived/pinned, omitted = unchanged).
|
// (SessionRename model: title/archived/pinned/hidden, omitted = unchanged).
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val pinned: Boolean? = null,
|
val pinned: Boolean? = null,
|
||||||
|
val hidden: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -216,6 +216,14 @@ interface HermesApiService {
|
|||||||
@Body body: SessionRenameRequest,
|
@Body body: SessionRenameRequest,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
// Hide/unhide rides the same PATCH /api/sessions/{id} — body carries only
|
||||||
|
// {hidden} (backend SessionRename model, any subset accepted; issue #1019).
|
||||||
|
@PATCH("api/sessions/{id}")
|
||||||
|
suspend fun setSessionHidden(
|
||||||
|
@Path("id", encoded = true) sessionId: String,
|
||||||
|
@Body body: SessionRenameRequest,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
@POST("api/sessions/bulk-delete")
|
@POST("api/sessions/bulk-delete")
|
||||||
suspend fun bulkDeleteSessions(
|
suspend fun bulkDeleteSessions(
|
||||||
@Body body: BulkDeleteRequest,
|
@Body body: BulkDeleteRequest,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import androidx.compose.material.icons.filled.PushPin
|
|||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
import androidx.compose.material.icons.filled.Terminal
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
@@ -59,6 +61,7 @@ import androidx.compose.material3.DropdownMenu
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -290,7 +293,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
flattenSessionTree(state.sessions)
|
flattenSessionTree(state.displaySessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@@ -313,6 +316,7 @@ fun SessionsScreen(
|
|||||||
state.isSearchMode,
|
state.isSearchMode,
|
||||||
state.searchQuery,
|
state.searchQuery,
|
||||||
state.sessions,
|
state.sessions,
|
||||||
|
state.showHidden,
|
||||||
state.searchResults,
|
state.searchResults,
|
||||||
) {
|
) {
|
||||||
displayedSessions(state)
|
displayedSessions(state)
|
||||||
@@ -521,6 +525,29 @@ fun SessionsScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadSessions() },
|
onRefresh = { viewModel.loadSessions() },
|
||||||
|
actions = {
|
||||||
|
if (state.hasHiddenSessions) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.toggleShowHidden() },
|
||||||
|
modifier = Modifier.testTag("sessions_action_toggle_hidden"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (state.showHidden) {
|
||||||
|
Icons.Filled.VisibilityOff
|
||||||
|
} else {
|
||||||
|
Icons.Filled.Visibility
|
||||||
|
},
|
||||||
|
contentDescription =
|
||||||
|
if (state.showHidden) {
|
||||||
|
stringResource(R.string.sessions_hide_hidden)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.sessions_show_hidden)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
// Single scrolling column: search bar pinned on top, list fills below.
|
// Single scrolling column: search bar pinned on top, list fills below.
|
||||||
@@ -685,6 +712,16 @@ fun SessionsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.displaySessions.isEmpty() -> {
|
||||||
|
EmptyState(
|
||||||
|
title = stringResource(R.string.history_empty_title),
|
||||||
|
subtitle = stringResource(R.string.sessions_all_hidden_desc),
|
||||||
|
icon = Icons.Filled.VisibilityOff,
|
||||||
|
actionLabel = stringResource(R.string.sessions_show_hidden),
|
||||||
|
onAction = { viewModel.toggleShowHidden() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// ── Stats row ───────────────────────────────────────
|
// ── Stats row ───────────────────────────────────────
|
||||||
@@ -801,6 +838,7 @@ fun SessionsScreen(
|
|||||||
isSelected = session.id in state.selectedIds,
|
isSelected = session.id in state.selectedIds,
|
||||||
isDeleting = session.id in state.deletingSessionIds,
|
isDeleting = session.id in state.deletingSessionIds,
|
||||||
isPinned = session.pinned == true,
|
isPinned = session.pinned == true,
|
||||||
|
isHidden = session.hidden == true,
|
||||||
highlightBackground = primaryContainer,
|
highlightBackground = primaryContainer,
|
||||||
highlightForeground = onPrimaryContainer,
|
highlightForeground = onPrimaryContainer,
|
||||||
onCardClick = {
|
onCardClick = {
|
||||||
@@ -822,6 +860,7 @@ fun SessionsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onTogglePin = { viewModel.togglePin(session.id) },
|
onTogglePin = { viewModel.togglePin(session.id) },
|
||||||
|
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -969,6 +1008,7 @@ private fun SessionCard(
|
|||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
isPinned: Boolean,
|
isPinned: Boolean,
|
||||||
|
isHidden: Boolean = false,
|
||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
@@ -976,6 +1016,7 @@ private fun SessionCard(
|
|||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onTogglePin: () -> Unit,
|
onTogglePin: () -> Unit,
|
||||||
|
onToggleHide: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
@@ -1095,6 +1136,13 @@ private fun SessionCard(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
if (session.hidden == true) {
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.sessions_hidden_badge),
|
||||||
|
status = StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!session.status.isNullOrBlank()) {
|
if (!session.status.isNullOrBlank()) {
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
@@ -1116,6 +1164,11 @@ private fun SessionCard(
|
|||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onTogglePin()
|
onTogglePin()
|
||||||
},
|
},
|
||||||
|
isHidden = isHidden,
|
||||||
|
onToggleHide = {
|
||||||
|
menuExpanded = false
|
||||||
|
onToggleHide()
|
||||||
|
},
|
||||||
onSelect = {
|
onSelect = {
|
||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onSelect()
|
onSelect()
|
||||||
@@ -1301,6 +1354,8 @@ private fun SessionActionMenu(
|
|||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
onTogglePin: (() -> Unit)? = null,
|
onTogglePin: (() -> Unit)? = null,
|
||||||
|
isHidden: Boolean = false,
|
||||||
|
onToggleHide: (() -> Unit)? = null,
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
@@ -1324,6 +1379,33 @@ private fun SessionActionMenu(
|
|||||||
onClick = onTogglePin,
|
onClick = onTogglePin,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onToggleHide != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isHidden) {
|
||||||
|
R.string.sessions_action_unhide
|
||||||
|
} else {
|
||||||
|
R.string.sessions_action_hide
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (isHidden) {
|
||||||
|
Icons.Filled.Visibility
|
||||||
|
} else {
|
||||||
|
Icons.Filled.VisibilityOff
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onToggleHide,
|
||||||
|
)
|
||||||
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.sessions_action_select)) },
|
text = { Text(stringResource(R.string.sessions_action_select)) },
|
||||||
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
||||||
|
|||||||
@@ -89,8 +89,15 @@ data class SessionsUiState(
|
|||||||
val isSearching: Boolean = false,
|
val isSearching: Boolean = false,
|
||||||
val searchResults: List<SessionSearchResult> = emptyList(),
|
val searchResults: List<SessionSearchResult> = emptyList(),
|
||||||
val searchError: String? = null,
|
val searchError: String? = null,
|
||||||
|
val showHidden: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||||
|
|
||||||
|
val hasHiddenSessions: Boolean
|
||||||
|
get() = sessions.any { it.hidden == true }
|
||||||
|
|
||||||
|
val displaySessions: List<SessionInfo>
|
||||||
|
get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
|
||||||
}
|
}
|
||||||
|
|
||||||
class SessionsViewModel :
|
class SessionsViewModel :
|
||||||
@@ -315,6 +322,10 @@ class SessionsViewModel :
|
|||||||
|
|
||||||
// ── Bulk selection ───────────────────────────────────────────────────
|
// ── Bulk selection ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun toggleShowHidden() {
|
||||||
|
_uiState.update { it.copy(showHidden = !it.showHidden) }
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleSelecting() {
|
fun toggleSelecting() {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -455,6 +466,49 @@ class SessionsViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hide / unhide (issue #1019) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the hidden flag on a session via PATCH /api/sessions/{id} ({hidden}).
|
||||||
|
*/
|
||||||
|
fun toggleHide(sessionId: String) {
|
||||||
|
val session = _uiState.value.sessions.find { it.id == sessionId } ?: return
|
||||||
|
val targetHidden = session.hidden != true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.setSessionHidden(
|
||||||
|
sessionId = sessionId,
|
||||||
|
body = SessionRenameRequest(hidden = targetHidden),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
sessions =
|
||||||
|
it.sessions
|
||||||
|
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }
|
||||||
|
.pinnedFirst(),
|
||||||
|
toastMessage =
|
||||||
|
if (targetHidden) {
|
||||||
|
"Session hidden"
|
||||||
|
} else {
|
||||||
|
"Session unhidden"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(toastMessage = "Hide failed: ${result.error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete (single) ──────────────────────────────────────────────────
|
// ── Delete (single) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fun requestDeleteSession(sessionId: String) {
|
fun requestDeleteSession(sessionId: String) {
|
||||||
|
|||||||
@@ -406,6 +406,12 @@
|
|||||||
<string name="sessions_action_select_all">전체 선택</string>
|
<string name="sessions_action_select_all">전체 선택</string>
|
||||||
<string name="sessions_action_deselect_all">선택 해제</string>
|
<string name="sessions_action_deselect_all">선택 해제</string>
|
||||||
<string name="sessions_action_delete_n">%1$d개 삭제</string>
|
<string name="sessions_action_delete_n">%1$d개 삭제</string>
|
||||||
|
<string name="sessions_action_hide">숨기기</string>
|
||||||
|
<string name="sessions_action_unhide">숨김 해제</string>
|
||||||
|
<string name="sessions_show_hidden">숨겨진 세션 표시</string>
|
||||||
|
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
|
||||||
|
<string name="sessions_hidden_badge">숨김</string>
|
||||||
|
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</string>
|
||||||
|
|
||||||
<!-- Toolsets Screen -->
|
<!-- Toolsets Screen -->
|
||||||
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
||||||
|
|||||||
@@ -523,6 +523,12 @@
|
|||||||
<string name="sessions_action_rename">重命名</string>
|
<string name="sessions_action_rename">重命名</string>
|
||||||
<string name="sessions_action_pin">置顶</string>
|
<string name="sessions_action_pin">置顶</string>
|
||||||
<string name="sessions_action_unpin">取消置顶</string>
|
<string name="sessions_action_unpin">取消置顶</string>
|
||||||
|
<string name="sessions_action_hide">隐藏</string>
|
||||||
|
<string name="sessions_action_unhide">取消隐藏</string>
|
||||||
|
<string name="sessions_show_hidden">显示已隐藏的会话</string>
|
||||||
|
<string name="sessions_hide_hidden">隐藏已隐藏的会话</string>
|
||||||
|
<string name="sessions_hidden_badge">已隐藏</string>
|
||||||
|
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
|
||||||
<string name="sessions_pinned_indicator">已置顶</string>
|
<string name="sessions_pinned_indicator">已置顶</string>
|
||||||
<string name="sessions_fork_indicator">分支会话</string>
|
<string name="sessions_fork_indicator">分支会话</string>
|
||||||
<string name="sessions_rename_title">重命名会话</string>
|
<string name="sessions_rename_title">重命名会话</string>
|
||||||
|
|||||||
@@ -599,6 +599,12 @@
|
|||||||
<string name="sessions_action_rename">Rename</string>
|
<string name="sessions_action_rename">Rename</string>
|
||||||
<string name="sessions_action_pin">Pin</string>
|
<string name="sessions_action_pin">Pin</string>
|
||||||
<string name="sessions_action_unpin">Unpin</string>
|
<string name="sessions_action_unpin">Unpin</string>
|
||||||
|
<string name="sessions_action_hide">Hide</string>
|
||||||
|
<string name="sessions_action_unhide">Unhide</string>
|
||||||
|
<string name="sessions_show_hidden">Show hidden sessions</string>
|
||||||
|
<string name="sessions_hide_hidden">Hide hidden sessions</string>
|
||||||
|
<string name="sessions_hidden_badge">Hidden</string>
|
||||||
|
<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_pinned_indicator">Pinned</string>
|
||||||
<string name="sessions_fork_indicator">Forked session</string>
|
<string name="sessions_fork_indicator">Forked session</string>
|
||||||
<string name="sessions_rename_title">Rename Session</string>
|
<string name="sessions_rename_title">Rename Session</string>
|
||||||
|
|||||||
@@ -312,6 +312,41 @@ class ModelSerializationTest {
|
|||||||
assertEquals(false, s8.pinned)
|
assertEquals(false, s8.pinned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSessionInfoDeserialization_hiddenToleratesIntAndBooleanAndString() {
|
||||||
|
val s1 = json.decodeFromString<SessionInfo>("""{"id": "s1", "hidden": 0}""")
|
||||||
|
assertEquals(false, s1.hidden)
|
||||||
|
|
||||||
|
val s2 = json.decodeFromString<SessionInfo>("""{"id": "s2", "hidden": 1}""")
|
||||||
|
assertEquals(true, s2.hidden)
|
||||||
|
|
||||||
|
val s3 = json.decodeFromString<SessionInfo>("""{"id": "s3", "hidden": false}""")
|
||||||
|
assertEquals(false, s3.hidden)
|
||||||
|
|
||||||
|
val s4 = json.decodeFromString<SessionInfo>("""{"id": "s4", "hidden": true}""")
|
||||||
|
assertEquals(true, s4.hidden)
|
||||||
|
|
||||||
|
val s5 = json.decodeFromString<SessionInfo>("""{"id": "s5", "hidden": null}""")
|
||||||
|
assertNull(s5.hidden)
|
||||||
|
|
||||||
|
val s6 = json.decodeFromString<SessionInfo>("""{"id": "s6"}""")
|
||||||
|
assertNull(s6.hidden)
|
||||||
|
|
||||||
|
val s7 = json.decodeFromString<SessionInfo>("""{"id": "s7", "hidden": "1"}""")
|
||||||
|
assertEquals(true, s7.hidden)
|
||||||
|
|
||||||
|
val s8 = json.decodeFromString<SessionInfo>("""{"id": "s8", "hidden": "false"}""")
|
||||||
|
assertEquals(false, s8.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSessionRenameRequestSerialization_includesHidden() {
|
||||||
|
val req = SessionRenameRequest(title = "new name", hidden = true)
|
||||||
|
val encoded = json.encodeToString(SessionRenameRequest.serializer(), req)
|
||||||
|
assertTrue(encoded.contains("\"hidden\":true"))
|
||||||
|
assertTrue(encoded.contains("\"title\":\"new name\""))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
|
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
|
||||||
val jsonStr = "{}"
|
val jsonStr = "{}"
|
||||||
|
|||||||
@@ -31,13 +31,19 @@ class RefreshOnChangeTest {
|
|||||||
|
|
||||||
private class TestViewModel : ViewModel() {
|
private class TestViewModel : ViewModel() {
|
||||||
val state = MutableStateFlow("initial")
|
val state = MutableStateFlow("initial")
|
||||||
|
var job: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refreshOnChange(
|
job =
|
||||||
eventType = ChangeEvents.CRON,
|
refreshOnChange(
|
||||||
apiCall = { NetworkResult.Success("refreshed") },
|
eventType = ChangeEvents.CRON,
|
||||||
onSuccess = { state.value = it },
|
apiCall = { NetworkResult.Success("refreshed") },
|
||||||
)
|
onSuccess = { state.value = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
job?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,23 +61,31 @@ class RefreshOnChangeTest {
|
|||||||
fun testRefreshOnChange_updatesOnMatchingEvent() =
|
fun testRefreshOnChange_updatesOnMatchingEvent() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
val viewModel = TestViewModel()
|
val viewModel = TestViewModel()
|
||||||
// Let the init coroutine run so the collector actually subscribes
|
try {
|
||||||
// before the event fires (replay=0 — events before subscription
|
// Let the init coroutine run so the collector actually subscribes
|
||||||
// are dropped, matching production where VMs subscribe at
|
// before the event fires (replay=0 — events before subscription
|
||||||
// construction and events arrive afterwards).
|
// are dropped, matching production where VMs subscribe at
|
||||||
advanceUntilIdle()
|
// construction and events arrive afterwards).
|
||||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
|
advanceUntilIdle()
|
||||||
advanceUntilIdle()
|
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
|
||||||
assertEquals("refreshed", viewModel.state.value)
|
advanceUntilIdle()
|
||||||
|
assertEquals("refreshed", viewModel.state.value)
|
||||||
|
} finally {
|
||||||
|
viewModel.cleanup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testRefreshOnChange_ignoresOtherEventTypes() =
|
fun testRefreshOnChange_ignoresOtherEventTypes() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
val viewModel = TestViewModel()
|
val viewModel = TestViewModel()
|
||||||
advanceUntilIdle()
|
try {
|
||||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
|
advanceUntilIdle()
|
||||||
advanceUntilIdle()
|
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
|
||||||
assertEquals("initial", viewModel.state.value)
|
advanceUntilIdle()
|
||||||
|
assertEquals("initial", viewModel.state.value)
|
||||||
|
} finally {
|
||||||
|
viewModel.cleanup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,4 +281,84 @@ class SessionsViewModelTest {
|
|||||||
.contains("Pin failed"),
|
.contains("Pin failed"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||||
|
Response.success(
|
||||||
|
SessionListResponse(
|
||||||
|
sessions =
|
||||||
|
listOf(
|
||||||
|
SessionInfo("s-visible-1", hidden = false),
|
||||||
|
SessionInfo("s-hidden", hidden = true),
|
||||||
|
SessionInfo("s-visible-2", hidden = null),
|
||||||
|
),
|
||||||
|
total = 3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
vm.loadSessions()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(vm.uiState.value.hasHiddenSessions)
|
||||||
|
assertFalse(vm.uiState.value.showHidden)
|
||||||
|
assertEquals(
|
||||||
|
listOf("s-visible-1", "s-visible-2"),
|
||||||
|
vm.uiState.value.displaySessions
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.toggleShowHidden()
|
||||||
|
assertTrue(vm.uiState.value.showHidden)
|
||||||
|
assertEquals(
|
||||||
|
listOf("s-visible-1", "s-hidden", "s-visible-2"),
|
||||||
|
vm.uiState.value.displaySessions
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.toggleShowHidden()
|
||||||
|
assertFalse(vm.uiState.value.showHidden)
|
||||||
|
assertEquals(
|
||||||
|
listOf("s-visible-1", "s-visible-2"),
|
||||||
|
vm.uiState.value.displaySessions
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleHide updates hidden status and surfaces toast`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||||
|
Response.success(
|
||||||
|
SessionListResponse(
|
||||||
|
sessions = listOf(SessionInfo("s-1", hidden = false)),
|
||||||
|
total = 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
vm.loadSessions()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coEvery { mockApi.setSessionHidden(any(), any()) } returns Response.success(Unit)
|
||||||
|
vm.toggleHide("s-1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
vm.uiState.value.sessions
|
||||||
|
.first()
|
||||||
|
.hidden,
|
||||||
|
)
|
||||||
|
assertEquals("Session hidden", vm.uiState.value.toastMessage)
|
||||||
|
|
||||||
|
vm.toggleHide("s-1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
false,
|
||||||
|
vm.uiState.value.sessions
|
||||||
|
.first()
|
||||||
|
.hidden,
|
||||||
|
)
|
||||||
|
assertEquals("Session unhidden", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user