* 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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user