* feat(#530): add SessionSearch models + searchSessions endpoint + contract tests * feat(#530): wire server-backed search into Sessions screen (debounce + render) * style(#530): ktlint import ordering on HermesApiService * fix(#530): ktlint-clean overlap wrap + map search snippet to title * fix(#530): map search snippet to title so results show + highlight * fix(#530): render search hits as honest result cards instead of faking title Backend SessionSearchResult carries no session name (only snippet + metadata), so mapping snippet->title made the card show raw excerpt as the title. Instead, render a dedicated SearchResultCard: 'Match' label + session id, highlighted snippet as body, and source/model/played-at chips. Added SessionInfo.model and a friendly relative-time formatter. ktlint clean, compileDebugKotlin + unit tests pass. * fix(#530): strip JSON from search snippet + tighten result card padding * fix(#530): strip FTS highlight markers from search snippet on client agy's backend change re-wraps matched terms in >>>term<<< markers inside the clean snippet, but the mobile highlights by raw query term and never consumed those markers — so literal '>>>'/'<<<' would render in result text. Strip the markers before passing the snippet to highlightText so the query highlight still works on clean text. * fix(#530): integrate tree-flattening hierarchy with search modes in SessionsScreen * fix(#530): fix test assertion and mock ApiClient in SessionsViewModelTest * fix(#530): resolve compose PaddingValues compile error and fix slash-preservation test path * fix(#530): clean search snippet from JSON payload and add unit test --------- Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
71c1bdded9
commit
c543eb6470
8 files changed
+914
-252
No files matched your search
+81
@@ -307,6 +307,87 @@ class HermesApiServiceMockWebServerTest {
|
||||
assertNull(body.messages[0].timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchSessions_parsesResponse() =
|
||||
runBlocking {
|
||||
mockServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"session_id": "sess-abc",
|
||||
"snippet": "…matched text…",
|
||||
"role": "user",
|
||||
"source": "telegram",
|
||||
"model": "gpt-4o",
|
||||
"session_started": 1718000000
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val response = api.searchSessions(q = "matched", profile = null)
|
||||
assertTrue(response.isSuccessful)
|
||||
|
||||
val body = response.body()
|
||||
assertNotNull(body)
|
||||
assertEquals(1, body!!.results.size)
|
||||
|
||||
val first = body.results[0]
|
||||
assertEquals("sess-abc", first.session_id)
|
||||
assertEquals("…matched text…", first.snippet)
|
||||
assertEquals("telegram", first.source)
|
||||
assertEquals("gpt-4o", first.model)
|
||||
assertEquals(1718000000.0, first.session_started!!, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchSessions_encodesQueryAndProfileParams() =
|
||||
runBlocking {
|
||||
mockServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("""{ "results": [] }"""),
|
||||
)
|
||||
|
||||
api.searchSessions(q = "hello world", profile = "work")
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
assertTrue(
|
||||
"q must be URL-encoded",
|
||||
request.path!!.contains("q=hello%20world"),
|
||||
)
|
||||
assertTrue(
|
||||
"profile param must be sent",
|
||||
request.path!!.contains("profile=work"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSessionMessages_preservesSessionIdSlashes() =
|
||||
runBlocking {
|
||||
val sessionId = "session/with/slashes"
|
||||
mockServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("""{ "messages": [] }"""),
|
||||
)
|
||||
|
||||
api.getSessionMessages(sessionId)
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
assertEquals(
|
||||
"path should contain the session ID with slashes preserved",
|
||||
"/api/sessions/session/with/slashes/messages?offset=0",
|
||||
request.path,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getOAuthProviders_parsesList() =
|
||||
runBlocking {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionsViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
||||
|
||||
private fun createViewModel(): SessionsViewModel {
|
||||
val vm = SessionsViewModel()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
return vm
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockkObject(ApiClient)
|
||||
every { ApiClient.hermesApi } returns mockApi
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query resets search mode`() {
|
||||
val vm = createViewModel()
|
||||
vm.setSearchQuery("something")
|
||||
vm.setSearchQuery("")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals("", vm.uiState.value.searchQuery)
|
||||
assertFalse(vm.uiState.value.isSearchMode)
|
||||
assertEquals(0, vm.uiState.value.searchResults.size)
|
||||
assertFalse(vm.uiState.value.isSearching)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-blank query enters search mode and resolves`() {
|
||||
val vm = createViewModel()
|
||||
vm.setSearchQuery("hello")
|
||||
// state is set synchronously
|
||||
assertEquals("hello", vm.uiState.value.searchQuery)
|
||||
assertTrue(vm.uiState.value.isSearchMode)
|
||||
// advance past debounce + (failing, offline) network call
|
||||
testDispatcher.scheduler.advanceTimeBy(500)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
// Either way the spinner must stop and the query persists.
|
||||
assertFalse(vm.uiState.value.isSearching)
|
||||
assertEquals("hello", vm.uiState.value.searchQuery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `select all uses the IDs shown in the current view`() {
|
||||
val vm = createViewModel()
|
||||
|
||||
vm.selectAll(setOf("search-session-1", "search-session-2"))
|
||||
|
||||
assertEquals(
|
||||
setOf("search-session-1", "search-session-2"),
|
||||
vm.uiState.value.selectedIds,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clean search snippet extracts text from JSON payload`() {
|
||||
assertEquals(
|
||||
"Find the deployment logs",
|
||||
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user