feat(kanban): add KanbanSyncCoordinator and harden WebSocket event decoding
This commit is contained in:
1 parent
6fd697f2b0
commit
da03500b30
4 files changed
+273
No files matched your search
@@ -0,0 +1,104 @@
|
||||
package com.m57.hermescontrol.data.repository
|
||||
|
||||
import com.m57.hermescontrol.data.ws.KanbanEvent
|
||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
|
||||
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class KanbanSyncCoordinatorTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||
|
||||
private lateinit var coordinator: KanbanSyncCoordinator
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
coordinator =
|
||||
KanbanSyncCoordinator(
|
||||
repository = mockRepository,
|
||||
eventsClientProvider = { mockEventsClient },
|
||||
reloadDebounceMs = 250L,
|
||||
pollIntervalMs = 60_000L,
|
||||
nudgeDebounceMs = 400L,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLiveStatusTransitions() =
|
||||
runTest(testDispatcher) {
|
||||
val statusSlot = slot<(KanbanLiveStatus) -> Unit>()
|
||||
verify(exactly = 0) { mockEventsClient.connect(any(), any(), any(), any(), any()) }
|
||||
|
||||
coordinator.connect(testScope, "dev", onReload = {})
|
||||
verify { mockEventsClient.connect(any(), eq("dev"), any(), any(), capture(statusSlot)) }
|
||||
|
||||
statusSlot.captured(KanbanLiveStatus.CONNECTED)
|
||||
assertEquals(KanbanSyncStatus.Live, coordinator.syncStatus.value)
|
||||
|
||||
statusSlot.captured(KanbanLiveStatus.DISCONNECTED)
|
||||
assertTrue(coordinator.syncStatus.value is KanbanSyncStatus.Polling)
|
||||
|
||||
statusSlot.captured(KanbanLiveStatus.AUTH_FAILED)
|
||||
assertEquals(KanbanSyncStatus.AuthFailed, coordinator.syncStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDebouncedEventReload() =
|
||||
runTest(testDispatcher) {
|
||||
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
|
||||
var reloadCount = 0
|
||||
|
||||
coordinator.connect(testScope, "dev", onReload = { reloadCount++ })
|
||||
verify { mockEventsClient.connect(any(), eq("dev"), any(), capture(eventsSlot), any()) }
|
||||
|
||||
eventsSlot.captured(KanbanEventsEnvelope(events = listOf(KanbanEvent(id = 1, kind = "status"))))
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(100)
|
||||
assertEquals(0, reloadCount)
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(151)
|
||||
assertEquals(1, reloadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDebouncedNudge() =
|
||||
runTest(testDispatcher) {
|
||||
coordinator.scheduleNudge(testScope, "dev")
|
||||
testDispatcher.scheduler.advanceTimeBy(200)
|
||||
|
||||
// Still within debounce window
|
||||
coVerify(exactly = 0) { mockRepository.nudgeDispatcher(any()) }
|
||||
|
||||
testDispatcher.scheduler.advanceTimeBy(201)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { mockRepository.nudgeDispatcher("dev") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDisconnectCancelsEverything() =
|
||||
runTest(testDispatcher) {
|
||||
coordinator.connect(testScope, "dev", onReload = {})
|
||||
coordinator.disconnect()
|
||||
|
||||
verify { mockEventsClient.disconnect() }
|
||||
assertEquals(KanbanSyncStatus.Disconnected, coordinator.syncStatus.value)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,16 @@ class KanbanEventsClientTest {
|
||||
assertEquals(1710000000L, event.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses event with numeric run_id from sqlite`() {
|
||||
val frame =
|
||||
"""{"events":[{"id":8,"task_id":"t-2","run_id":42,"kind":"status","payload":null,"created_at":1710000001}],"cursor":8}"""
|
||||
val envelope = parseKanbanEventsFrame(frame)
|
||||
assertNotNull(envelope)
|
||||
assertEquals(1, envelope!!.events.size)
|
||||
assertEquals("42", envelope.events.first().runId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses empty events batch`() {
|
||||
val envelope = parseKanbanEventsFrame("""{"events":[],"cursor":3}""")
|
||||
|
||||
Reference in new issue
Block a user