feat(sessions): branch tree flattening with group recency and collapsible pinned section (#1087)
This commit is contained in:
9 files changed
+482
-93
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionTreeTest {
|
||||
@@ -8,46 +9,79 @@ class SessionTreeTest {
|
||||
fun nestsBranchesAndUsesDesktopFallbackNames() {
|
||||
val sessions =
|
||||
listOf(
|
||||
SessionInfo(id = "unrelated", title = "Recent root"),
|
||||
SessionInfo(id = "branch-b", parent_session_id = "parent"),
|
||||
SessionInfo(id = "parent", title = "Original"),
|
||||
SessionInfo(id = "branch-a", parent_session_id = "parent", preview = "copied prompt"),
|
||||
SessionInfo(id = "nested", parent_session_id = "branch-a", title = "Named nested branch"),
|
||||
SessionInfo(id = "unrelated", title = "Recent root", last_active = 10.0),
|
||||
SessionInfo(id = "parent", title = "Original", last_active = 5.0),
|
||||
SessionInfo(id = "branch-b", parent_session_id = "parent", last_active = 6.0),
|
||||
SessionInfo(
|
||||
id = "branch-a",
|
||||
parent_session_id = "parent",
|
||||
preview = "copied prompt",
|
||||
last_active = 7.0,
|
||||
),
|
||||
SessionInfo(
|
||||
id = "nested",
|
||||
parent_session_id = "branch-a",
|
||||
title = "Named nested branch",
|
||||
last_active = 8.0,
|
||||
),
|
||||
)
|
||||
|
||||
val tree = flattenSessionTree(sessions)
|
||||
val tree = flattenSessionsWithBranches(sessions)
|
||||
|
||||
assertEquals(listOf("unrelated", "parent", "branch-b", "branch-a", "nested"), tree.map { it.session.id })
|
||||
assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.depth })
|
||||
// groupRecency of parent is max(5.0, 6.0, 7.0, 8.0) = 8.0
|
||||
// unrelated is 10.0 -> unrelated first, then parent family
|
||||
assertEquals(listOf("unrelated", "parent", "branch-a", "nested", "branch-b"), tree.map { it.session.id })
|
||||
assertEquals(listOf(0, 0, 1, 2, 1), tree.map { it.depth })
|
||||
assertEquals(
|
||||
listOf("Recent root", "Original", "branch 1", "branch 2", "Named nested branch"),
|
||||
tree.map {
|
||||
it.displayTitle
|
||||
},
|
||||
listOf("Recent root", "Original", "branch 1", "Named nested branch", "branch 2"),
|
||||
tree.map { it.displayTitle },
|
||||
)
|
||||
// Fork flag + nesting depth (1 = child of root, 2 = fork of a fork).
|
||||
// Fork flag + nesting depth
|
||||
assertEquals(listOf(false, false, true, true, true), tree.map { it.isFork })
|
||||
assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.forkDepth })
|
||||
assertEquals(listOf(0, 0, 1, 2, 1), tree.map { it.forkDepth })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deepNestingMarksEveryForkWithDepth() {
|
||||
// root → a (child 1) → a1 (child 1) ; root → b (child 2, last)
|
||||
// a1 is fork-depth 2 (fork of a fork), b is fork-depth 1.
|
||||
fun activeForkLiftsEntireFamilyAboveFresherUnrelatedRoot() {
|
||||
// Parent has old recency (1.0). Fork has fresh recency (100.0). Unrelated has recency 50.0.
|
||||
val sessions =
|
||||
listOf(
|
||||
SessionInfo(id = "root", title = "Root"),
|
||||
SessionInfo(id = "a", parent_session_id = "root"),
|
||||
SessionInfo(id = "b", parent_session_id = "root"),
|
||||
SessionInfo(id = "a1", parent_session_id = "a"),
|
||||
SessionInfo(id = "unrelated", title = "Unrelated", last_active = 50.0),
|
||||
SessionInfo(id = "parent", title = "Parent", last_active = 1.0),
|
||||
SessionInfo(id = "fork", parent_session_id = "parent", title = "Active Fork", last_active = 100.0),
|
||||
)
|
||||
|
||||
val tree = flattenSessionTree(sessions)
|
||||
val tree = flattenSessionsWithBranches(sessions)
|
||||
|
||||
assertEquals(listOf("root", "a", "a1", "b"), tree.map { it.session.id })
|
||||
assertEquals(listOf(0, 1, 2, 1), tree.map { it.depth })
|
||||
assertEquals(listOf(false, true, true, true), tree.map { it.isFork })
|
||||
assertEquals(listOf(0, 1, 2, 1), tree.map { it.forkDepth })
|
||||
assertEquals(listOf("parent", "fork", "unrelated"), tree.map { it.session.id })
|
||||
assertEquals(listOf(0, 1, 0), tree.map { it.depth })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aliasesLineageRootIdForCompressionProjectedTips() {
|
||||
val sessions =
|
||||
listOf(
|
||||
SessionInfo(id = "tip_parent", lineageRootId = "root_parent", title = "Parent Tip", last_active = 5.0),
|
||||
SessionInfo(id = "child", parent_session_id = "root_parent", title = "Child", last_active = 6.0),
|
||||
)
|
||||
|
||||
val tree = flattenSessionsWithBranches(sessions)
|
||||
|
||||
assertEquals(listOf("tip_parent", "child"), tree.map { it.session.id })
|
||||
assertEquals(listOf(0, 1), tree.map { it.depth })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesOrderWhenRequested() {
|
||||
val sessions =
|
||||
listOf(
|
||||
SessionInfo(id = "pin1", title = "Pin 1", last_active = 10.0),
|
||||
SessionInfo(id = "pin2", title = "Pin 2", last_active = 90.0),
|
||||
)
|
||||
|
||||
val tree = flattenSessionsWithBranches(sessions, preserveOrder = true)
|
||||
|
||||
assertEquals(listOf("pin1", "pin2"), tree.map { it.session.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,9 +93,13 @@ class SessionTreeTest {
|
||||
SessionInfo(id = "b", parent_session_id = "a"),
|
||||
)
|
||||
|
||||
val tree = flattenSessionTree(sessions)
|
||||
val tree = flattenSessionsWithBranches(sessions)
|
||||
|
||||
assertEquals(setOf("orphan", "a", "b"), tree.map { it.session.id }.toSet())
|
||||
assertEquals("orphan preview", tree.first { it.session.id == "orphan" }.displayTitle)
|
||||
val orphanItem = tree.first { it.session.id == "orphan" }
|
||||
assertEquals("orphan preview", orphanItem.displayTitle)
|
||||
// Orphan keeps fork identity even without parent in loaded set
|
||||
assertTrue(orphanItem.isFork)
|
||||
assertEquals(0, orphanItem.depth)
|
||||
}
|
||||
}
|
||||
@@ -331,10 +331,8 @@ class SessionsViewModelTest {
|
||||
// ── Pin / unpin ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `loaded list keeps pinned sessions on top`() {
|
||||
fun `loaded list retains server order and identifies pinned sessions`() {
|
||||
val vm = createViewModel()
|
||||
// Backend returns recency order with the pinned flag set; the client
|
||||
// must lift pins above the rest while keeping the rest's order.
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
@@ -349,15 +347,22 @@ class SessionsViewModelTest {
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// Sessions retain server order (family grouping is handled by flattenSessionsWithBranches)
|
||||
assertEquals(
|
||||
listOf("old-pinned", "recent"),
|
||||
listOf("recent", "old-pinned"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
// pinnedSessions derives the pinned items
|
||||
assertEquals(
|
||||
listOf("old-pinned"),
|
||||
vm.uiState.value.pinnedSessions
|
||||
.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `togglePin moves the session to the top and sets the flag`() {
|
||||
fun `togglePin updates the session pinned state without mangling session order`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
@@ -374,16 +379,21 @@ class SessionsViewModelTest {
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
listOf("s-2", "s-1"),
|
||||
listOf("s-1", "s-2"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
vm.uiState.value.sessions
|
||||
.first()
|
||||
.first { it.id == "s-2" }
|
||||
.pinned,
|
||||
)
|
||||
assertEquals(
|
||||
listOf("s-2"),
|
||||
vm.uiState.value.pinnedSessions
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals("Session pinned", vm.uiState.value.toastMessage)
|
||||
}
|
||||
|
||||
@@ -500,4 +510,42 @@ class SessionsViewModelTest {
|
||||
)
|
||||
assertEquals("Session unhidden", vm.uiState.value.toastMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stitchMissingParents fetches missing parent sessions up to cap`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions =
|
||||
listOf(
|
||||
SessionInfo("child", parent_session_id = "parent", title = "Child"),
|
||||
),
|
||||
total = 1,
|
||||
),
|
||||
)
|
||||
coEvery { mockApi.getSessionInfo("parent") } returns
|
||||
Response.success(
|
||||
SessionInfo("parent", title = "Fetched Parent"),
|
||||
)
|
||||
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(2, vm.uiState.value.sessions.size)
|
||||
assertTrue(
|
||||
vm.uiState.value.sessions
|
||||
.any { it.id == "parent" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `togglePinnedExpanded toggles expanded state`() {
|
||||
val vm = createViewModel()
|
||||
assertTrue(vm.uiState.value.pinnedExpanded)
|
||||
vm.togglePinnedExpanded()
|
||||
assertFalse(vm.uiState.value.pinnedExpanded)
|
||||
vm.togglePinnedExpanded()
|
||||
assertTrue(vm.uiState.value.pinnedExpanded)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user