feat(sessions): show project and last activity on history cards (#1133)

This commit is contained in:
Alfredo Ortegón Sepúlveda authored and GitHub committed 2026-09-16 00:14:09 +03:00
1 parent 529b424eb4
commit aed8ba4a6d
25 files changed
+1617 -80

No files matched your search

@@ -0,0 +1,67 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Wire-shape tests for the session workspace fields and the `projects.list` RPC result. */
class ProjectModelsTest {
private val json = OkHttpProvider.json
@Test
fun `session list row decodes its workspace`() {
val session =
json.decodeFromString<SessionInfo>(
"""{"id":"s1","cwd":"/srv/app/src","git_repo_root":"/srv/app","git_branch":"main"}""",
)
assertEquals("/srv/app/src", session.cwd)
assertEquals("/srv/app", session.git_repo_root)
}
@Test
fun `session list row without a workspace decodes nulls`() {
val session = json.decodeFromString<SessionInfo>("""{"id":"s1"}""")
assertNull(session.cwd)
assertNull(session.git_repo_root)
}
@Test
fun `projects list payload decodes folders, color and archived`() {
val response =
json.decodeFromString<ProjectsListResponse>(
"""
{"projects":[
{"id":"p_1","slug":"app","name":"App","description":null,"icon":"rocket",
"color":"hsl(120 68% 58%)","board_slug":null,"primary_path":"/srv/app",
"archived":false,"created_at":1787590315,
"folders":[{"path":"/srv/app","label":null,"is_primary":true,"added_at":1787590315}]},
{"id":"p_2","slug":"old","name":"Old","archived":1,"folders":[]}
],"active_id":"p_1"}
""".trimIndent(),
)
val app = response.projects[0]
assertEquals("App", app.name)
assertEquals("hsl(120 68% 58%)", app.color)
assertEquals("/srv/app", app.primaryPath)
assertEquals(listOf("/srv/app"), app.folders.map { it.path })
assertTrue(app.folders[0].isPrimary == true)
assertFalse(app.isArchived)
assertTrue(response.projects[1].isArchived)
assertEquals("p_1", response.activeId)
}
@Test
fun `project with only an id and name decodes defaults`() {
val project = json.decodeFromString<ProjectInfo>("""{"id":"p_3","name":"Bare"}""")
assertNull(project.color)
assertTrue(project.folders.isEmpty())
assertFalse(project.isArchived)
}
}
@@ -0,0 +1,85 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProjectsSourceTest {
@Test
fun `decodes the untyped rpc map`() =
runTest {
var calledMethod: String? = null
val source =
HermesProjectsSource(isConnected = { true }) { method, _ ->
calledMethod = method
mapOf(
"projects" to
listOf(
mapOf(
"id" to "p_1",
"name" to "App",
"color" to "hsl(1 2% 3%)",
"archived" to false,
"folders" to listOf(mapOf("path" to "/srv/app", "is_primary" to true)),
),
),
"active_id" to "p_1",
)
}
val projects = source.fetchProjects()
assertEquals(WsMethods.PROJECTS_LIST, calledMethod)
assertEquals(listOf("App"), projects?.map { it.name })
assertEquals(
"/srv/app",
projects
?.single()
?.folders
?.single()
?.path,
)
}
@Test
fun `skips the rpc while the socket is not connected`() =
runTest {
var calls = 0
val source =
HermesProjectsSource(
rpcRequest = { _, _ ->
calls++
mapOf("projects" to emptyList<Any>())
},
isConnected = { false },
)
assertNull(source.fetchProjects())
assertEquals("a disconnected fetch must not queue an RPC", 0, calls)
}
@Test
fun `rpc failure or unexpected payload yields null`() =
runTest {
assertNull(
HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(),
)
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects())
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects())
}
@Test
fun `cancellation propagates`() =
runTest {
val thrown =
runCatching {
HermesProjectsSource(
isConnected = { true },
) { _, _ -> throw CancellationException("gone") }.fetchProjects()
}.exceptionOrNull()
assertTrue(thrown is CancellationException)
}
}
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ProjectColorTest {
private fun assertColor(
expected: Color,
actual: Color?,
) {
requireNotNull(actual)
assertEquals(expected.red, actual.red, 0.01f)
assertEquals(expected.green, actual.green, 0.01f)
assertEquals(expected.blue, actual.blue, 0.01f)
}
@Test
fun `space separated hsl parses`() {
assertColor(Color.hsl(210f, 0.68f, 0.58f), parseProjectColor("hsl(210 68% 58%)"))
}
@Test
fun `comma separated hsl parses`() {
assertColor(Color.hsl(0f, 0.68f, 0.58f), parseProjectColor(" hsl(0, 68%, 58%) "))
}
@Test
fun `hex parses`() {
assertColor(parseHexColor("#4DA8FF", Color.Unspecified), parseProjectColor("#4DA8FF"))
}
@Test
fun `invalid or missing colors are null`() {
assertNull(parseProjectColor(null))
assertNull(parseProjectColor(""))
assertNull(parseProjectColor("tomato"))
assertNull(parseProjectColor("hsl(400 68% 58%)"))
assertNull(parseProjectColor("hsl(210 168% 58%)"))
assertNull(parseProjectColor("#12"))
}
}
@@ -0,0 +1,73 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.ZonedDateTime
class SessionAgeTest {
private val zone = ZoneOffset.UTC
private val now = ZonedDateTime.of(2026, 9, 15, 12, 0, 0, 0, zone).toInstant()
private fun ageSecondsAgo(seconds: Long) = sessionAge((now.epochSecond - seconds).toDouble(), now, zone)
@Test
fun `under a minute reads as now`() {
assertEquals(SessionAge.Now, ageSecondsAgo(0))
assertEquals(SessionAge.Now, ageSecondsAgo(59))
}
@Test
fun `minutes bucket`() {
assertEquals(SessionAge.Minutes(1), ageSecondsAgo(60))
assertEquals(SessionAge.Minutes(15), ageSecondsAgo(15 * 60 + 30))
assertEquals(SessionAge.Minutes(59), ageSecondsAgo(3599))
}
@Test
fun `hours bucket`() {
assertEquals(SessionAge.Hours(1), ageSecondsAgo(3600))
assertEquals(SessionAge.Hours(21), ageSecondsAgo(21 * 3600))
assertEquals(SessionAge.Hours(23), ageSecondsAgo(24 * 3600 - 1))
}
@Test
fun `days bucket up to a week`() {
assertEquals(SessionAge.Days(1), ageSecondsAgo(24 * 3600))
assertEquals(SessionAge.Days(6), ageSecondsAgo(7 * 24 * 3600 - 1))
}
@Test
fun `a week or older shows the date`() {
assertEquals(
SessionAge.Date(LocalDate.of(2026, 9, 8), sameYear = true),
ageSecondsAgo(7 * 24 * 3600),
)
assertEquals(
SessionAge.Date(LocalDate.of(2025, 12, 31), sameYear = false),
sessionAge(ZonedDateTime.of(2025, 12, 31, 23, 0, 0, 0, zone).toEpochSecond().toDouble(), now, zone),
)
}
@Test
fun `future timestamps from clock skew read as now`() {
assertEquals(SessionAge.Now, ageSecondsAgo(-300))
}
@Test
fun `missing timestamps have no age`() {
assertNull(sessionAge(null, now, zone))
assertNull(sessionAge(0.0, now, zone))
}
@Test
fun `activity prefers last active over started at`() {
assertEquals(20.0, SessionInfo(id = "s", started_at = 10.0, last_active = 20.0).activityEpochSeconds())
assertEquals(10.0, SessionInfo(id = "s", started_at = 10.0).activityEpochSeconds())
assertEquals(10.0, SessionInfo(id = "s", started_at = 10.0, last_active = 0.0).activityEpochSeconds())
assertNull(SessionInfo(id = "s").activityEpochSeconds())
}
}
@@ -0,0 +1,159 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.ProjectFolder
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.SessionInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionProjectTest {
private fun session(
cwd: String? = null,
repoRoot: String? = null,
) = SessionInfo(id = "s", cwd = cwd, git_repo_root = repoRoot)
private fun project(
name: String,
vararg folders: String,
color: String? = null,
archived: Boolean = false,
) = ProjectInfo(
id = "p_$name",
name = name,
color = color,
archived = archived,
folders = folders.map { ProjectFolder(path = it) },
)
@Test
fun `explicit project owns a cwd under its folder`() {
val projects = listOf(project("App", "/srv/app", color = "hsl(1 2% 3%)"))
assertEquals(
SessionProject(label = "App", color = "hsl(1 2% 3%)"),
resolveSessionProject(session(cwd = "/srv/app/src"), projects),
)
}
@Test
fun `explicit project matches on the repo root when the cwd sits outside`() {
val projects = listOf(project("App", "/srv/app"))
assertEquals(
"App",
resolveSessionProject(session(cwd = "/tmp/worktree", repoRoot = "/srv/app"), projects)?.label,
)
}
@Test
fun `deepest folder wins for nested projects`() {
val projects = listOf(project("Mono", "/srv/mono"), project("Web", "/srv/mono/web"))
assertEquals("Web", resolveSessionProject(session(cwd = "/srv/mono/web/src"), projects)?.label)
}
@Test
fun `archived projects are skipped`() {
val projects = listOf(project("Root", "/root", archived = true))
assertEquals("notes", resolveSessionProject(session(cwd = "/root/notes"), projects)?.label)
}
@Test
fun `sibling directory with a shared prefix is not under the folder`() {
val projects = listOf(project("Foo", "/root/foo"))
assertEquals("foobar", resolveSessionProject(session(cwd = "/root/foobar"), projects)?.label)
}
@Test
fun `trailing slashes do not break folder matching`() {
val projects = listOf(project("App", "/srv/app/"))
assertEquals("App", resolveSessionProject(session(cwd = "/srv/app/"), projects)?.label)
}
@Test
fun `unowned git session names its repo root`() {
assertEquals(
SessionProject(label = "hermes-agent", color = null),
resolveSessionProject(
session(cwd = "/code/hermes-agent/tools", repoRoot = "/code/hermes-agent"),
emptyList(),
),
)
}
@Test
fun `worktree outside its repo still names the repo`() {
assertEquals(
"hermes-agent",
resolveSessionProject(session(cwd = "/code/wt-123", repoRoot = "/code/hermes-agent"), emptyList())?.label,
)
}
@Test
fun `repo root only session names the root`() {
assertEquals("app", resolveSessionProject(session(repoRoot = "/srv/app"), emptyList())?.label)
}
@Test
fun `non git session names its cwd leaf`() {
assertEquals("scratch", resolveSessionProject(session(cwd = "/home/me/scratch"), emptyList())?.label)
}
@Test
fun `windows paths resolve`() {
val projects = listOf(project("Win", "C:\\code\\app"))
assertEquals("Win", resolveSessionProject(session(cwd = "C:\\code\\app\\src"), projects)?.label)
}
@Test
fun `windows paths match regardless of casing and separators`() {
val projects = listOf(project("Win", "C:\\Work\\App"))
assertEquals("Win", resolveSessionProject(session(cwd = "c:/work/app/src"), projects)?.label)
assertEquals("Win", resolveSessionProject(session(cwd = "C:\\WORK\\APP"), projects)?.label)
}
@Test
fun `unc paths match regardless of casing`() {
val projects = listOf(project("Share", "\\\\Server\\Repos\\App"))
assertEquals("Share", resolveSessionProject(session(cwd = "//server/repos/app/lib"), projects)?.label)
}
@Test
fun `posix paths stay case sensitive`() {
val projects = listOf(project("App", "/srv/App"))
assertEquals("app", resolveSessionProject(session(cwd = "/srv/app"), projects)?.label)
}
@Test
fun `labels keep the recorded spelling`() {
assertEquals("MyRepo", resolveSessionProject(session(cwd = "C:\\Code\\MyRepo"), emptyList())?.label)
}
@Test
fun `repeated separators do not break matching`() {
val projects = listOf(project("App", "/srv/app"))
assertEquals("App", resolveSessionProject(session(cwd = "/srv//app///src"), projects)?.label)
}
@Test
fun `session without a workspace has no project`() {
assertNull(resolveSessionProject(session(), listOf(project("App", "/srv/app"))))
assertNull(resolveSessionProject(session(cwd = " ", repoRoot = ""), emptyList()))
}
@Test
fun `blank explicit name falls back to the folder leaf`() {
val projects = listOf(project(" ", "/srv/app"))
assertEquals("app", resolveSessionProject(session(cwd = "/srv/app"), projects)?.label)
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionLiveStatus
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.ws.ChangeEventHub
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.ProjectsSource
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.coEvery
@@ -64,8 +66,22 @@ class SessionsViewModelTest {
override val connectionStatus: StateFlow<ConnectionStatus> = connectionStatusFlow
}
private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel {
val vm = SessionsViewModel(liveStatusSource = source)
private class FakeProjectsSource : ProjectsSource {
var projectsToReturn: List<ProjectInfo>? = emptyList()
var fetchCallCount = 0
var projectsDeferred: CompletableDeferred<List<ProjectInfo>?>? = null
override suspend fun fetchProjects(): List<ProjectInfo>? {
fetchCallCount++
return projectsDeferred?.await() ?: projectsToReturn
}
}
private fun createViewModel(
source: SessionLiveStatusSource = FakeSessionLiveStatusSource(),
projectsSource: ProjectsSource = FakeProjectsSource(),
): SessionsViewModel {
val vm = SessionsViewModel(liveStatusSource = source, projectsSource = projectsSource)
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@@ -83,6 +99,96 @@ class SessionsViewModelTest {
unmockkAll()
}
@Test
fun `tracking loads the project list`() {
val projects = FakeProjectsSource().apply { projectsToReturn = listOf(ProjectInfo(id = "p_1", name = "App")) }
val vm = createViewModel(projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(
listOf("App"),
vm.uiState.value.projects
.map { it.name },
)
vm.stopLiveStatusTracking()
}
@Test
fun `stopping tracking cancels an in-flight project fetch`() {
val pending = CompletableDeferred<List<ProjectInfo>?>()
val projects = FakeProjectsSource().apply { projectsDeferred = pending }
val vm = createViewModel(projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(1, projects.fetchCallCount)
vm.stopLiveStatusTracking()
pending.complete(listOf(ProjectInfo(id = "p_late", name = "Late")))
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.projects
.isEmpty(),
)
}
@Test
fun `loadSessions without tracking does not fetch projects`() {
val projects = FakeProjectsSource()
val vm = createViewModel(projectsSource = projects)
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(0, projects.fetchCallCount)
}
@Test
fun `failed project refresh keeps the last good list`() {
val projects = FakeProjectsSource().apply { projectsToReturn = listOf(ProjectInfo(id = "p_1", name = "App")) }
val vm = createViewModel(projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
projects.projectsToReturn = null
vm.loadSessions()
testDispatcher.scheduler.runCurrent()
assertEquals(2, projects.fetchCallCount)
assertEquals(
listOf("App"),
vm.uiState.value.projects
.map { it.name },
)
vm.stopLiveStatusTracking()
}
@Test
fun `reconnecting refreshes the project list`() {
val live = FakeSessionLiveStatusSource()
val projects = FakeProjectsSource()
val vm = createViewModel(source = live, projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
live.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED
testDispatcher.scheduler.runCurrent()
val before = projects.fetchCallCount
projects.projectsToReturn = listOf(ProjectInfo(id = "p_2", name = "Web"))
live.connectionStatusFlow.value = ConnectionStatus.CONNECTED
testDispatcher.scheduler.runCurrent()
assertTrue(projects.fetchCallCount > before)
assertEquals(
listOf("Web"),
vm.uiState.value.projects
.map { it.name },
)
vm.stopLiveStatusTracking()
}
@Test
fun `blank query resets search mode`() {
val vm = createViewModel()
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.ui.sessions.components
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.resolveColorScheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Contrast gate for the history card across every preset in both modes. Every line on the
* card is body-sized or smaller, so title and secondary text must both clear WCAG AA 4.5:1.
*/
class SessionCardContrastTest {
private fun forEveryPresetMode(block: (String, androidx.compose.material3.ColorScheme) -> Unit) {
ThemePreset.entries.forEach { preset ->
listOf(true, false).forEach { dark ->
block("$preset dark=$dark", resolveColorScheme(preset, darkTheme = dark))
}
}
}
@Test
fun cardTextMeetsContrastInEveryPresetMode() {
forEveryPresetMode { mode, scheme ->
val palette = sessionCardPalette(scheme)
listOf("title" to palette.title, "secondary" to palette.secondary).forEach { (role, color) ->
val ratio = contrastRatio(color, palette.card)
assertTrue(
"$mode $role contrast %.2f must be >= 4.5".format(ratio),
ratio >= SESSION_CARD_MIN_TEXT_CONTRAST,
)
}
}
}
@Test
fun secondaryKeepsTheThemeColorWhenItAlreadyPasses() {
forEveryPresetMode { mode, scheme ->
if (contrastRatio(scheme.onSurfaceVariant, scheme.surfaceContainer) >= SESSION_CARD_MIN_TEXT_CONTRAST) {
assertEquals(mode, scheme.onSurfaceVariant, sessionCardPalette(scheme).secondary)
}
}
}
}
@@ -0,0 +1,42 @@
package com.m57.hermescontrol.ui.sessions.components
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionCardFooterTest {
@Test
fun `everything fits at its natural width`() {
assertTrue(
footerFitsOneLine(maxWidth = 400, leadWidth = 120, countWidth = 90, badgesWidth = 100, leadMinWidth = 80),
)
}
@Test
fun `a long model shrinks instead of pushing the badges down`() {
assertTrue(
footerFitsOneLine(maxWidth = 400, leadWidth = 380, countWidth = 90, badgesWidth = 150, leadMinWidth = 80),
)
}
@Test
fun `badges wrap only when the model would drop below its minimum`() {
assertFalse(
footerFitsOneLine(maxWidth = 300, leadWidth = 380, countWidth = 120, badgesWidth = 150, leadMinWidth = 80),
)
}
@Test
fun `a short model only needs its own width`() {
assertTrue(
footerFitsOneLine(maxWidth = 300, leadWidth = 40, countWidth = 120, badgesWidth = 140, leadMinWidth = 80),
)
}
@Test
fun `no badges always fits one line`() {
assertTrue(
footerFitsOneLine(maxWidth = 100, leadWidth = 380, countWidth = 120, badgesWidth = 0, leadMinWidth = 80),
)
}
}