feat(sessions): show project and last activity on history cards (#1133)
This commit is contained in:
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()
|
||||
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user