feat(effects-02): route Action.Quit through sub-reducers; Effect.Quit last
Effect.Quit is appended at the tail of the effect list rather than prepended, so all sub-reducer cleanup effects dispatch before runner teardown. Combined with the sequential dispatch from effects-01, list position is a runtime guarantee. Closes finding #9.
This commit is contained in:
@@ -42,7 +42,9 @@ object RootReducer {
|
||||
clock: () -> Long,
|
||||
): Pair<TuiState, List<Effect>> {
|
||||
log.debug("action={}", action::class.simpleName)
|
||||
val quitEffects: List<Effect> = if (action is Action.Quit) listOf(Effect.Quit) else emptyList()
|
||||
// Effect.Quit appended LAST so all cleanup effects from sub-reducers dispatch first.
|
||||
// See effects-01 (sequential dispatch) for why list position is a runtime guarantee.
|
||||
// I-Q3: sub-reducers must not synthesize Effect.Quit — it is a terminal signal owned by RootReducer.
|
||||
val prevInputMode = state.inputMode
|
||||
val prevInputBuffer = state.inputBuffer
|
||||
|
||||
@@ -78,6 +80,8 @@ object RootReducer {
|
||||
|
||||
else -> withSubReducers
|
||||
}
|
||||
return final to (quitEffects + ie + se + ce + pe)
|
||||
val combined = ie + se + ce + pe
|
||||
val withTerminal = if (action is Action.Quit) combined + Effect.Quit else combined
|
||||
return final to withTerminal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RootReducerQuitTest {
|
||||
|
||||
private val fixedClock: () -> Long = { 1000L }
|
||||
|
||||
@Test
|
||||
fun `Effect Quit is the terminal effect when Action Quit is reduced`() {
|
||||
val (_, effects) = RootReducer.reduce(TuiState(snapshotPhase = false), Action.Quit, fixedClock)
|
||||
assertEquals(1, effects.count { it is Effect.Quit })
|
||||
assertTrue(effects.last() is Effect.Quit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no Effect Quit when action is not Quit`() {
|
||||
val (_, effects) = RootReducer.reduce(TuiState(snapshotPhase = false), Action.Connected, fixedClock)
|
||||
assertFalse(effects.any { it is Effect.Quit })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sub-reducers are invoked for Action Quit`() {
|
||||
val initial = TuiState(snapshotPhase = false)
|
||||
val (state, _) = RootReducer.reduce(initial, Action.Quit, fixedClock)
|
||||
// ConnectionReducer handles Action.Quit by keeping state; verify state went through sub-reducers
|
||||
// (state is the result of all sub-reducers, not an early-exit)
|
||||
assertEquals(initial.inputMode, state.inputMode)
|
||||
assertEquals(initial.sessions, state.sessions)
|
||||
assertEquals(initial.connection, state.connection)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ApprovalDto
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.SessionStateDto
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.SessionSummary
|
||||
@@ -171,7 +174,7 @@ class SessionsReducerTest {
|
||||
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
|
||||
assertEquals(1, effects.size)
|
||||
val effect = effects[0] as Effect.SendWs
|
||||
val msg = effect.message as com.correx.apps.server.protocol.ClientMessage.CancelSession
|
||||
val msg = effect.message as ClientMessage.CancelSession
|
||||
assertEquals("s1", msg.sessionId.value)
|
||||
}
|
||||
|
||||
@@ -293,8 +296,8 @@ class SessionsReducerTest {
|
||||
fun `SessionSnapshot with pendingApprovals populates pendingApproval on summary`() {
|
||||
val msg = ServerMessage.SessionSnapshot(
|
||||
sessionId = SessionId("s1"),
|
||||
state = com.correx.apps.server.protocol.SessionStateDto("PAUSED", null, null),
|
||||
pendingApprovals = listOf(com.correx.apps.server.protocol.ApprovalDto(requestId = "r9", tier = "T2")),
|
||||
state = SessionStateDto("PAUSED", null, null),
|
||||
pendingApprovals = listOf(ApprovalDto(requestId = "r9", tier = "T2")),
|
||||
lastSequence = 1L,
|
||||
lastSessionSequence = 1L,
|
||||
)
|
||||
@@ -307,7 +310,7 @@ class SessionsReducerTest {
|
||||
fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() {
|
||||
val msg = ServerMessage.SessionSnapshot(
|
||||
sessionId = SessionId("s1"),
|
||||
state = com.correx.apps.server.protocol.SessionStateDto("ACTIVE", null, null),
|
||||
state = SessionStateDto("ACTIVE", null, null),
|
||||
pendingApprovals = emptyList(),
|
||||
lastSequence = 1L,
|
||||
lastSessionSequence = 1L,
|
||||
|
||||
Reference in New Issue
Block a user