feat(history): fluid infinite scroll + bigger pages for the sessions list (#885)
- Page size 20 -> 50 (matches the desktop sidebar; backend caps at 100) - Auto-load the next page when scrolling within 6 items of the end, driven by real scroll position (layoutInfo-derived, not frozen state) - Guard against offset-pagination churn: dedupe appended ids so a new session landing on top between page loads can't crash LazyColumn on a duplicate key - Tests: append+dedupe, in-flight loadMore no-op guard Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
ff951eaf14
commit
95178d3438
3 files changed
+110
-3
No files matched your search
@@ -1,7 +1,11 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
@@ -19,6 +23,7 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionsViewModelTest {
|
||||
@@ -90,4 +95,66 @@ class SessionsViewModelTest {
|
||||
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMore appends the next page and dedupes overlapping ids`() {
|
||||
val vm = createViewModel()
|
||||
|
||||
// Page 1: 2 sessions of 3 total — hasMore stays true.
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
||||
total = 3,
|
||||
),
|
||||
)
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id })
|
||||
assertTrue(vm.uiState.value.hasMore)
|
||||
assertFalse(vm.uiState.value.isLoadingMore)
|
||||
|
||||
// Page 2 overlaps page 1 (offset churn: a new session landed on top
|
||||
// between loads) — the duplicate id must not double-append.
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-3"), SessionInfo("s-2")),
|
||||
total = 3,
|
||||
),
|
||||
)
|
||||
vm.loadMore()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("s-1", "s-2", "s-3"), vm.uiState.value.sessions.map { it.id })
|
||||
assertEquals(3, vm.uiState.value.total)
|
||||
assertFalse(vm.uiState.value.hasMore)
|
||||
assertFalse(vm.uiState.value.isLoadingMore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMore is a no-op while a load is already running`() {
|
||||
val vm = createViewModel()
|
||||
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-1")),
|
||||
total = 2,
|
||||
),
|
||||
)
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// Fire two loadMore calls back-to-back before the dispatcher runs: the
|
||||
// first sets isLoadingMore=true synchronously, the second must be dropped.
|
||||
vm.loadMore()
|
||||
vm.loadMore()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// loadSessions (1) + exactly one loadMore (1) = 2 API hits total.
|
||||
coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) }
|
||||
assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id })
|
||||
assertFalse(vm.uiState.value.isLoadingMore)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user