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,
): 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,