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,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),
)
}
}