import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.RetrySalvageDecidedEvent import com.correx.core.events.events.SalvageDecision import com.correx.core.events.events.TransitionExecutedEvent 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.events.orchestration.OrchestrationState import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer import com.correx.testing.fixtures.EventFixtures.stored import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class OrchestrationReducerTest { private val reducer = DefaultOrchestrationReducer() private val sessionId = SessionId("s1") private val stageId = StageId("stage-1") private val workflowId = "workflow-test" private val state = OrchestrationState(workflowId, stageId) @Test fun `WorkflowStartedEvent sets status to RUNNING`() { val state = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) assertEquals(OrchestrationStatus.RUNNING, state.status) assertEquals(stageId, state.currentStageId) } @Test fun `WorkflowFailedEvent sets status to FAILED and failure reason`() { val started = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val failed = reducer.reduce( started, stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)), ) assertFalse(failed.failureReason.isNullOrBlank()) assertEquals(OrchestrationStatus.FAILED, failed.status) } @Test fun `WorkflowCompletedEvent sets status to COMPLETED`() { val started = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val completed = reducer.reduce( started, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)), ) assertTrue(completed.failureReason.isNullOrBlank()) assertEquals(OrchestrationStatus.COMPLETED, completed.status) assertEquals(stageId, completed.currentStageId) } @Test fun `OrchestrationPausedEvent sets status to PAUSED with reason and pending approval`() { val started = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val paused = reducer.reduce( started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Requires user approval")), ) assertFalse(paused.pauseReason.isNullOrBlank()) assertEquals(OrchestrationStatus.PAUSED, paused.status) assertTrue(paused.pendingApproval) } @Test fun `OrchestrationResumedEvent sets status to RUNNING with null reason and no pending approval`() { val started = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val paused = reducer.reduce( started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Requires user approval")), ) val resumed = reducer.reduce( paused, stored(payload = OrchestrationResumedEvent(sessionId, stageId)), ) assertTrue(resumed.pauseReason.isNullOrBlank()) assertEquals(OrchestrationStatus.RUNNING, resumed.status) assertFalse(resumed.pendingApproval) } @Test fun `RetryAttemptedEvent sets status to RUNNING with null reason and no pending approval`() { val started = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val failed = reducer.reduce( started, stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)), ) val retrying1 = reducer.reduce( failed, stored(payload = RetryAttemptedEvent(sessionId, stageId, 1, 3, "Something went wrong")), ) val retrying2 = reducer.reduce( retrying1, stored(payload = RetryAttemptedEvent(sessionId, stageId, 2, 3, "Something went wrong")), ) val retrying3 = reducer.reduce( retrying2, stored(payload = RetryAttemptedEvent(sessionId, stageId, 3, 3, "Something went wrong")), ) assertTrue(retrying1.retryCount < retrying2.retryCount && retrying2.retryCount < retrying3.retryCount) assertEquals(OrchestrationStatus.RUNNING, retrying1.status) assertNull(retrying1.failureReason) } @Test fun `TransitionExecutedEvent resets retryCount so each stage gets its own retry budget`() { val exhausted = reducer.reduce( state, stored(payload = RetryAttemptedEvent(sessionId, stageId, 3, 3, "boom")), ) assertEquals(3, exhausted.retryCount) val nextStage = StageId("stage-2") val advanced = reducer.reduce( exhausted, stored(payload = TransitionExecutedEvent(sessionId, stageId, nextStage, TransitionId("t1"))), ) assertEquals(nextStage, advanced.currentStageId) assertEquals(0, advanced.retryCount) } @Test fun `RetryAttemptedEvent with charged=true bumps the gate's per-gate budget and fingerprint`() { val retried = reducer.reduce( state, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "boom", gate = "contract", fingerprint = "fp1", charged = true, ), ), ) assertEquals(1, retried.gateRetryBudgets["contract"]) assertEquals("fp1", retried.gateFailureFingerprints["contract"]) } @Test fun `RetryAttemptedEvent with charged=false leaves the gate's budget untouched but records the fingerprint`() { val charged = reducer.reduce( state, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "boom", gate = "contract", fingerprint = "fp1", charged = true, ), ), ) val free = reducer.reduce( charged, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "different problem", gate = "contract", fingerprint = "fp2", charged = false, ), ), ) assertEquals(1, free.gateRetryBudgets["contract"]) assertEquals("fp2", free.gateFailureFingerprints["contract"]) } @Test fun `gate budgets are independent per gate id`() { val contractCharged = reducer.reduce( state, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "boom", gate = "contract", fingerprint = "fp1", charged = true, ), ), ) val reviewCharged = reducer.reduce( contractCharged, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "boom2", gate = "review", fingerprint = "fpr1", charged = true, ), ), ) assertEquals(1, reviewCharged.gateRetryBudgets["contract"]) assertEquals(1, reviewCharged.gateRetryBudgets["review"]) } @Test fun `TransitionExecutedEvent resets per-gate budgets, fingerprints, and salvage-used set`() { val charged = reducer.reduce( state, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "boom", gate = "review", fingerprint = "fp1", charged = true, ), ), ) val salvaged = reducer.reduce( charged, stored( payload = RetrySalvageDecidedEvent(sessionId, stageId, "review", SalvageDecision.CONTINUE, "fixable"), ), ) assertTrue(salvaged.gateSalvageUsed.contains("review")) val nextStage = StageId("stage-2") val advanced = reducer.reduce( salvaged, stored(payload = TransitionExecutedEvent(sessionId, stageId, nextStage, TransitionId("t1"))), ) assertTrue(advanced.gateRetryBudgets.isEmpty()) assertTrue(advanced.gateFailureFingerprints.isEmpty()) assertTrue(advanced.gateSalvageUsed.isEmpty()) } @Test fun `RetrySalvageDecidedEvent CONTINUE resets the gate's budget and marks salvage used`() { val charged = reducer.reduce( state, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "boom", gate = "review", fingerprint = "fp1", charged = true, ), ), ) assertEquals(1, charged.gateRetryBudgets["review"]) val salvaged = reducer.reduce( charged, stored( payload = RetrySalvageDecidedEvent(sessionId, stageId, "review", SalvageDecision.CONTINUE, "fixable"), ), ) assertEquals(0, salvaged.gateRetryBudgets["review"]) assertTrue(salvaged.gateSalvageUsed.contains("review")) } @Test fun `RetrySalvageDecidedEvent FAIL does not touch state`() { val charged = reducer.reduce( state, stored( payload = RetryAttemptedEvent( sessionId, stageId, 1, 3, "boom", gate = "review", fingerprint = "fp1", charged = true, ), ), ) val decided = reducer.reduce( charged, stored( payload = RetrySalvageDecidedEvent(sessionId, stageId, "review", SalvageDecision.FAIL, "not fixable"), ), ) assertEquals(charged, decided) } @Test fun `unrelated event does nothing`() { val started = reducer.reduce( state, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)), ) val unrelated = reducer.reduce(started, stored()) assertEquals(started, unrelated) } }