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:
2026-05-25 16:58:33 +04:00
parent 5effe287d4
commit cdc03b8809
3 changed files with 50 additions and 6 deletions
@@ -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,