diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt index a11fbeeb..7fe4d37d 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt @@ -42,7 +42,9 @@ object RootReducer { clock: () -> Long, ): Pair> { log.debug("action={}", action::class.simpleName) - val quitEffects: List = 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 } } diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerQuitTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerQuitTest.kt new file mode 100644 index 00000000..5db03e48 --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerQuitTest.kt @@ -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) + } +} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt index 2371728a..55a2fe69 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt @@ -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,