feat(ws): consume gateway change events (issue #784) (#837)

* feat(ws): consume gateway change events (cron/sessions/platforms/pairing) for targeted refresh (issue #784)

* test(chat): complete catch-all request stub — kill frozen-collector capture race (slash-focus flake, 4645a2b CI)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 13:48:17 +03:00
1 parent 7d60057156
commit f34bfa7cf8
15 files changed
+320 -4

No files matched your search

@@ -99,6 +99,36 @@ class EventParserTest {
assertEquals(mapOf("session_id" to "session-1"), gatewayReady.data)
}
@Test
fun testParseChangeEvents_returnsChangeEvent() {
// Issue #784: gateway broadcasts change events (pet.changed excluded —
// mobile has no pet feature). All four must map to ChangeEvent with the
// type preserved and the payload attached.
val cases =
mapOf(
"cron.changed" to mapOf("a" to "1"),
"sessions.changed" to mapOf("b" to "2"),
"platforms.changed" to mapOf("c" to "3"),
"pairing.changed" to mapOf("d" to "4"),
)
cases.forEach { (type, payload) ->
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params = mapOf("type" to type, "payload" to payload),
)
val event = EventParser.parse(response)
assertTrue("expected ChangeEvent for $type, got $event", event is WsEvent.ChangeEvent)
val changeEvent = event as WsEvent.ChangeEvent
assertEquals(type, changeEvent.type)
assertEquals(payload, changeEvent.data)
}
}
@Test
fun testParseMessageToken_returnsMessageTokenEvent() {
val response =
@@ -101,11 +101,13 @@ class ChatViewModelTest {
// fires after unmockkAll and crashes an unrelated later test with
// MockK's "can't find stub" (UncaughtExceptionsBeforeTest). Mirror
// the real request()'s send() delegation (tests verify send calls)
// but return a never-completing deferred — same unanswered behavior,
// no leaked timer.
// but skip the timer. Must COMPLETE (Unit): a never-completing
// deferred freezes the VM's event collector at the SESSION_CREATE
// session.usage await and lets its late completion race per-test
// capture stubs (slash-focus flake, CI 31137581335 on 4645a2b).
every { HermesWsClient.request(any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>()
CompletableDeferred<Any?>(Unit)
}
mockkObject(ApiClient)
mockkObject(HermesDatabase)
@@ -107,10 +107,15 @@ class SlashCommandDispatchRpcTest {
// wsScope that fires after unmockkAll and crashes a later test with
// MockK's "can't find stub". Mirror the real send() delegation (so
// send-based verifications still see the call) but skip the timer.
// Must COMPLETE (Unit): a never-completing deferred freezes the VM's
// event collector at the SESSION_CREATE-triggered session.usage await,
// and that completion then races the per-test capture stubs — the slot
// can end up holding session.usage instead of command.dispatch (CI run
// 31137581335 on 4645a2b: slash-focus flake).
// Specific per-test request() stubs registered later take precedence.
every { HermesWsClient.request(any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>()
CompletableDeferred<Any?>(Unit)
}
}
@@ -0,0 +1,77 @@
package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.ChangeEventHub
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
/**
* Regression guard for issue #784: `refreshOnChange` must react to hub events
* WITHOUT touching the HermesWsClient singleton — constructing a ViewModel
* that wires it must be safe in a plain unit-test JVM (no mockkObject of
* HermesWsClient, no static mocks). This used to poison the whole suite with
* ExceptionInInitializerError at MockKStub.kt:106.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RefreshOnChangeTest {
private val testDispatcher = StandardTestDispatcher()
private class TestViewModel : ViewModel() {
val state = MutableStateFlow("initial")
init {
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { NetworkResult.Success("refreshed") },
onSuccess = { state.value = it },
)
}
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testRefreshOnChange_updatesOnMatchingEvent() =
runTest(testDispatcher) {
val viewModel = TestViewModel()
// Let the init coroutine run so the collector actually subscribes
// before the event fires (replay=0 — events before subscription
// are dropped, matching production where VMs subscribe at
// construction and events arrive afterwards).
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
advanceUntilIdle()
assertEquals("refreshed", viewModel.state.value)
}
@Test
fun testRefreshOnChange_ignoresOtherEventTypes() =
runTest(testDispatcher) {
val viewModel = TestViewModel()
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
advanceUntilIdle()
assertEquals("initial", viewModel.state.value)
}
}