fix: improve History search and prevent duplicate-key crashes (#1186)

This commit is contained in:
M57 authored and GitHub committed 2026-09-17 23:00:53 +03:00
1 parent 7951acc6eb
commit 9690ce8dc6
9 files changed
+458 -48

No files matched your search

@@ -7,6 +7,33 @@ import org.junit.Assert.assertNull
import org.junit.Test
class SessionSearchFormattingTest {
@Test
fun `wire title stays distinct from excerpt and preserves metadata`() {
val hit =
kotlinx.serialization.json.Json
.decodeFromString<SessionSearchResult>(
"""{"session_id":"s","title":"Launch","snippet":"match","last_active":42,"message_count":7}""",
).toSessionInfo()
assertEquals("Launch", hit.title)
assertEquals("match", hit.preview)
assertEquals(42.0, hit.last_active)
assertEquals(7, hit.message_count)
}
@Test
fun `highlight phrases and words without Unicode offset drift`() {
val text = "İ 🌙 launch notes and deploy"
val result =
highlightSearchText(
text,
"\"launch notes\" deploy",
androidx.compose.ui.graphics.Color.Transparent,
androidx.compose.ui.graphics.Color.Unspecified,
)
assertEquals(text, result.text)
assertEquals(listOf("launch notes", "deploy"), result.spanStyles.map { text.substring(it.start, it.end) })
}
@Test
fun `clean search snippet strips highlight markers from plain text`() {
assertEquals(
@@ -99,6 +99,66 @@ class SessionsViewModelTest {
unmockkAll()
}
@Test
fun `wire paging metadata drives a second request without changing history paging`() {
val first =
kotlinx.serialization.json.Json.decodeFromString<SessionSearchResponse>(
"""{"results":[{"session_id":"a"},{"session_id":"a"}],"has_more":true,"next_offset":20}""",
)
coEvery { mockApi.searchSessions("launch", null, null, "cron", 20, 0) } returns Response.success(first)
coEvery { mockApi.searchSessions("launch", null, null, "cron", 20, 20) } returns
Response.success(
SessionSearchResponse(listOf(SessionSearchResult("b")), has_more = false),
)
val vm = createViewModel()
vm.setSearchQuery("launch")
testDispatcher.scheduler.advanceUntilIdle()
vm.loadMoreSearch()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("a", "b"),
vm.uiState.value.searchResults
.map { it.session_id },
)
assertFalse(vm.uiState.value.searchHasMore)
assertFalse(vm.uiState.value.hasMore)
coVerify(exactly = 1) { mockApi.searchSessions("launch", null, null, "cron", 20, 20) }
}
@Test
fun `bulk delete removes selected search hits and preserves others`() {
mockkObject(com.m57.hermescontrol.data.local.AuthManager)
every {
com.m57.hermescontrol.data.local.AuthManager
.getLastOpenedSessionId()
} returns null
coEvery { mockApi.searchSessions("launch", null, null, "cron", 20, 0) } returns
Response.success(SessionSearchResponse(listOf(SessionSearchResult("a"), SessionSearchResult("b"))))
coEvery { mockApi.bulkDeleteSessions(any()) } returns
Response.success(
com.m57.hermescontrol.data.model
.BulkDeleteResponse(ok = true, deleted = 1),
)
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns
Response.success(SessionListResponse(sessions = emptyList(), total = 0))
val vm = createViewModel()
vm.setSearchQuery("launch")
testDispatcher.scheduler.advanceUntilIdle()
vm.toggleSessionSelection("a")
vm.confirmBulkDelete()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("b"),
vm.uiState.value.searchResults
.map { it.session_id },
)
assertTrue(
vm.uiState.value.selectedIds
.isEmpty(),
)
assertFalse(vm.uiState.value.isDeletingBulk)
}
@Test
fun `tracking loads the project list`() {
val projects = FakeProjectsSource().apply { projectsToReturn = listOf(ProjectInfo(id = "p_1", name = "App")) }
@@ -189,6 +249,67 @@ class SessionsViewModelTest {
vm.stopLiveStatusTracking()
}
@Test
fun `search is pending immediately before debounce`() {
val vm = createViewModel()
vm.setSearchQuery("deploy")
assertTrue(vm.uiState.value.isSearching)
assertNull(vm.uiState.value.searchError)
vm.setSearchQuery("")
assertFalse(vm.uiState.value.isSearching)
}
@Test
fun `search pagination keeps results on failure and retries same offset`() {
coEvery { mockApi.searchSessions("deploy", null, null, "cron", 20, 0) } returns
Response.success(SessionSearchResponse(listOf(SessionSearchResult("one")), true, 1))
coEvery { mockApi.searchSessions("deploy", null, null, "cron", 20, 1) } returns
Response.error(503, "busy".toResponseBody()) andThen
Response.error(503, "busy".toResponseBody()) andThen
Response.error(503, "busy".toResponseBody()) andThen
Response.success(
SessionSearchResponse(listOf(SessionSearchResult("one"), SessionSearchResult("two")), false),
)
val vm = createViewModel()
vm.setSearchQuery("deploy")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.searchHasMore)
vm.loadMoreSearch()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("one"),
vm.uiState.value.searchResults
.map { it.session_id },
)
assertNotNull(vm.uiState.value.searchLoadMoreError)
assertNull(vm.uiState.value.searchError)
vm.loadMoreSearch()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("one", "two"),
vm.uiState.value.searchResults
.map { it.session_id },
)
assertFalse(vm.uiState.value.searchHasMore)
assertFalse(vm.uiState.value.hasMore)
}
@Test
fun `legacy search and non advancing page do not loop`() {
coEvery { mockApi.searchSessions("old", null, null, "cron", 20, 0) } returns
Response.success(SessionSearchResponse(listOf(SessionSearchResult("one"))))
coEvery { mockApi.searchSessions("new", null, null, "cron", 20, 0) } returns
Response.success(SessionSearchResponse(listOf(SessionSearchResult("two")), true, 0))
val vm = createViewModel()
for (query in listOf("old", "new")) {
vm.setSearchQuery(query)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(vm.uiState.value.searchHasMore)
vm.loadMoreSearch()
}
coVerify(exactly = 2) { mockApi.searchSessions(any(), any(), any(), any(), any(), any()) }
}
@Test
fun `blank query resets search mode`() {
val vm = createViewModel()