* 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
@@ -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