Files
correx/testing/deterministic/src/test/kotlin/RouterReducerTest.kt
T
kami 2c459da009 feat(router): implement Epic 14 — core:router module
Implements the full conversational router facade: RouterState, RouterReducer,
RouterProjector, RouterRepository, RouterContextBuilder, RouterFacade, protocol
types, WebSocket wiring, infrastructure factory, and deterministic test suite.

Also fixes spec divergences found in post-implementation review:
- Add SteeringNote domain object to core:context (epic prerequisite)
- Rename RouterFacade.handleChat → onUserInput per spec interface contract
- Add in-memory ConcurrentHashMap conversation history to DefaultRouterFacade
- Make RouterRepository.getRouterState suspend
- Rename RouterConfig.keepLast → conversationKeepLast, fix defaults (6, 4096)
- Refactor InfrastructureModule.createRouterFacade to self-assemble internally
- Fix FileReadTool: allowedPaths was dead constructor param (@SuppressUnusedParameter);
  now stored as private val and enforced in validateRequest
- Disable koverVerify on modules tested via testing/ submodules or with
  hardware/integration dependencies (24 modules); build gate now passes clean
2026-05-21 15:06:20 +04:00

252 lines
10 KiB
Kotlin

import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.ToolInvokedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.router.DefaultRouterReducer
import com.correx.core.router.model.StageOutcomeKind
import com.correx.core.router.model.WorkflowStatus
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.testing.fixtures.EventFixtures.stored
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class RouterReducerTest {
private val reducer = DefaultRouterReducer()
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
@Test
fun `initial state has IDLE status and empty collections`() {
val state = reducer.initial
assertEquals(WorkflowStatus.IDLE, state.workflowStatus)
assertNull(state.sessionId)
assertNull(state.currentStageId)
assertTrue(state.l2Memory.isEmpty())
assertTrue(state.conversationHistory.isEmpty())
}
@Test
fun `WorkflowStartedEvent sets sessionId, RUNNING status and startStageId`() {
val state = reducer.initial
val updated = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
assertEquals(sessionId, updated.sessionId)
assertEquals(WorkflowStatus.RUNNING, updated.workflowStatus)
assertEquals(stageId, updated.currentStageId)
}
@Test
fun `WorkflowCompletedEvent sets COMPLETED and clears currentStageId`() {
val initial = reducer.initial
val started = reducer.reduce(
initial,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val completed = reducer.reduce(
started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3)),
)
assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus)
assertNull(completed.currentStageId)
assertEquals(sessionId, completed.sessionId)
}
@Test
fun `WorkflowFailedEvent sets FAILED and clears currentStageId`() {
val initial = reducer.initial
val started = reducer.reduce(
initial,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val failed = reducer.reduce(
started,
stored(payload = WorkflowFailedEvent(sessionId, stageId, "timeout", false)),
)
assertEquals(WorkflowStatus.FAILED, failed.workflowStatus)
assertNull(failed.currentStageId)
}
@Test
fun `OrchestrationPausedEvent sets PAUSED status`() {
val initial = reducer.initial
val started = reducer.reduce(
initial,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val paused = reducer.reduce(
started,
stored(payload = OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")),
)
assertEquals(WorkflowStatus.PAUSED, paused.workflowStatus)
assertEquals(sessionId, paused.sessionId)
}
@Test
fun `OrchestrationResumedEvent sets RUNNING status from PAUSED`() {
val initial = reducer.initial
val started = reducer.reduce(
initial,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val paused = reducer.reduce(
started,
stored(payload = OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")),
)
val resumed = reducer.reduce(
paused,
stored(payload = OrchestrationResumedEvent(sessionId, stageId)),
)
assertEquals(WorkflowStatus.RUNNING, resumed.workflowStatus)
}
@Test
fun `StageCompletedEvent appends RouterL2Entry with SUCCESS to l2Memory`() {
val state = reducer.initial
val storedEvent = stored(payload = StageCompletedEvent(sessionId, stageId, StageId("t1")))
val updated = reducer.reduce(state, storedEvent)
assertEquals(1, updated.l2Memory.size)
val entry = updated.l2Memory[0]
assertEquals(stageId, entry.stageId)
assertEquals(StageOutcomeKind.SUCCESS, entry.outcome)
assertTrue(entry.summary.contains("completed"))
assertNotNull(entry.timestamp)
}
@Test
fun `StageCompletedEvent preserves other state fields`() {
val initial = reducer.initial
val started = reducer.reduce(
initial,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val updated = reducer.reduce(
started,
stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))),
)
assertEquals(sessionId, updated.sessionId)
assertEquals(WorkflowStatus.RUNNING, updated.workflowStatus)
assertEquals(stageId, updated.currentStageId)
}
@Test
fun `StageFailedEvent appends RouterL2Entry with FAILURE and clears currentStageId`() {
val initial = reducer.initial
val started = reducer.reduce(
initial,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val storedEvent = stored(payload = StageFailedEvent(sessionId, stageId, StageId("t1"), "timeout"))
val updated = reducer.reduce(started, storedEvent)
assertEquals(1, updated.l2Memory.size)
val entry = updated.l2Memory[0]
assertEquals(stageId, entry.stageId)
assertEquals(StageOutcomeKind.FAILURE, entry.outcome)
assertTrue(entry.summary.contains("timeout"))
assertNull(updated.currentStageId)
}
@Test
fun `multiple StageCompletedEvents append to l2Memory sequentially`() {
val state = reducer.initial
val s1 = reducer.reduce(state, stored(payload = StageCompletedEvent(sessionId, StageId("a"), StageId("t1"))))
val s2 = reducer.reduce(s1, stored(payload = StageCompletedEvent(sessionId, StageId("b"), StageId("t2"))))
val s3 = reducer.reduce(s2, stored(payload = StageCompletedEvent(sessionId, StageId("c"), StageId("t3"))))
assertEquals(3, s3.l2Memory.size)
assertEquals(StageId("a"), s3.l2Memory[0].stageId)
assertEquals(StageId("b"), s3.l2Memory[1].stageId)
assertEquals(StageId("c"), s3.l2Memory[2].stageId)
s3.l2Memory.forEach {
assertEquals(StageOutcomeKind.SUCCESS, it.outcome)
}
}
@Test
fun `SteeringNoteAddedEvent does not change state`() {
val state = reducer.initial
val updated = reducer.reduce(
state,
stored(payload = SteeringNoteAddedEvent(sessionId, "do something different")),
)
assertEquals(state, updated)
assertTrue(updated.conversationHistory.isEmpty())
}
@Test
fun `SteeringNoteAddedEvent on non-initial state does not change state`() {
val initial = reducer.initial
val started = reducer.reduce(
initial,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val withNote = reducer.reduce(
started,
stored(payload = SteeringNoteAddedEvent(sessionId, "shift focus")),
)
assertEquals(started, withNote)
assertEquals(WorkflowStatus.RUNNING, withNote.workflowStatus)
assertEquals(stageId, withNote.currentStageId)
}
@Test
fun `unknown event type returns unchanged state`() {
val state = reducer.initial
val updated = reducer.reduce(state, stored(payload = ToolInvokedEvent("test")))
assertEquals(state, updated)
}
@Test
fun `l2Memory entry preserves timestamp from event metadata`() {
val storedEvent = stored(
payload = StageCompletedEvent(sessionId, stageId, StageId("t1")),
timestamp = Instant.parse("2026-06-15T12:00:00Z"),
)
val updated = reducer.reduce(reducer.initial, storedEvent)
assertEquals(1, updated.l2Memory.size)
assertNotNull(updated.l2Memory[0].timestamp)
}
@Test
fun `full lifecycle start complete pause resume start another stage fail`() {
val s0 = reducer.initial
assertEquals(WorkflowStatus.IDLE, s0.workflowStatus)
val s1 = reducer.reduce(s0, stored(payload = WorkflowStartedEvent(sessionId, StageId("stage-A"))))
assertEquals(WorkflowStatus.RUNNING, s1.workflowStatus)
assertEquals(StageId("stage-A"), s1.currentStageId)
val s2 = reducer.reduce(s1, stored(payload = StageCompletedEvent(sessionId, StageId("stage-A"), StageId("t1"))))
assertEquals(1, s2.l2Memory.size)
assertEquals(StageOutcomeKind.SUCCESS, s2.l2Memory[0].outcome)
val s3 = reducer.reduce(s2, stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1)))
assertEquals(WorkflowStatus.COMPLETED, s3.workflowStatus)
assertNull(s3.currentStageId)
val s4 = reducer.reduce(s3, stored(payload = OrchestrationPausedEvent(sessionId, StageId("stage-A"), "manual pause")))
assertEquals(WorkflowStatus.PAUSED, s4.workflowStatus)
val s5 = reducer.reduce(s4, stored(payload = OrchestrationResumedEvent(sessionId, StageId("stage-A"))))
assertEquals(WorkflowStatus.RUNNING, s5.workflowStatus)
val s6 = reducer.reduce(
s5,
stored(payload = StageFailedEvent(sessionId, StageId("stage-B"), StageId("t2"), "llm error")),
)
assertEquals(StageOutcomeKind.FAILURE, s6.l2Memory[s6.l2Memory.size - 1].outcome)
assertNull(s6.currentStageId)
}
}