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,
|
clock: () -> Long,
|
||||||
): Pair<TuiState, List<Effect>> {
|
): Pair<TuiState, List<Effect>> {
|
||||||
log.debug("action={}", action::class.simpleName)
|
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 prevInputMode = state.inputMode
|
||||||
val prevInputBuffer = state.inputBuffer
|
val prevInputBuffer = state.inputBuffer
|
||||||
|
|
||||||
@@ -78,6 +80,8 @@ object RootReducer {
|
|||||||
|
|
||||||
else -> withSubReducers
|
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
|
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.PauseReason
|
||||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
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.input.Action
|
||||||
import com.correx.apps.tui.state.InputMode
|
import com.correx.apps.tui.state.InputMode
|
||||||
import com.correx.apps.tui.state.SessionSummary
|
import com.correx.apps.tui.state.SessionSummary
|
||||||
@@ -171,7 +174,7 @@ class SessionsReducerTest {
|
|||||||
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
|
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
|
||||||
assertEquals(1, effects.size)
|
assertEquals(1, effects.size)
|
||||||
val effect = effects[0] as Effect.SendWs
|
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)
|
assertEquals("s1", msg.sessionId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +296,8 @@ class SessionsReducerTest {
|
|||||||
fun `SessionSnapshot with pendingApprovals populates pendingApproval on summary`() {
|
fun `SessionSnapshot with pendingApprovals populates pendingApproval on summary`() {
|
||||||
val msg = ServerMessage.SessionSnapshot(
|
val msg = ServerMessage.SessionSnapshot(
|
||||||
sessionId = SessionId("s1"),
|
sessionId = SessionId("s1"),
|
||||||
state = com.correx.apps.server.protocol.SessionStateDto("PAUSED", null, null),
|
state = SessionStateDto("PAUSED", null, null),
|
||||||
pendingApprovals = listOf(com.correx.apps.server.protocol.ApprovalDto(requestId = "r9", tier = "T2")),
|
pendingApprovals = listOf(ApprovalDto(requestId = "r9", tier = "T2")),
|
||||||
lastSequence = 1L,
|
lastSequence = 1L,
|
||||||
lastSessionSequence = 1L,
|
lastSessionSequence = 1L,
|
||||||
)
|
)
|
||||||
@@ -307,7 +310,7 @@ class SessionsReducerTest {
|
|||||||
fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() {
|
fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() {
|
||||||
val msg = ServerMessage.SessionSnapshot(
|
val msg = ServerMessage.SessionSnapshot(
|
||||||
sessionId = SessionId("s1"),
|
sessionId = SessionId("s1"),
|
||||||
state = com.correx.apps.server.protocol.SessionStateDto("ACTIVE", null, null),
|
state = SessionStateDto("ACTIVE", null, null),
|
||||||
pendingApprovals = emptyList(),
|
pendingApprovals = emptyList(),
|
||||||
lastSequence = 1L,
|
lastSequence = 1L,
|
||||||
lastSessionSequence = 1L,
|
lastSessionSequence = 1L,
|
||||||
|
|||||||
Reference in New Issue
Block a user