epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:validation"))
|
||||
testImplementation(project(":core:approvals"))
|
||||
testImplementation(project(":infrastructure:persistence"))
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.testing.approvals
|
||||
|
||||
object Module
|
||||
@@ -0,0 +1,88 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ApprovalEngineEdgeCasesTest {
|
||||
|
||||
private val engine = DefaultApprovalEngine()
|
||||
private val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
private fun context(mode: ApprovalMode) = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), StageId("st1"), ProjectId("p1")),
|
||||
mode
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `grants from different scopes apply only to matching identity`() {
|
||||
val sessionGrant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(Tier.T3),
|
||||
reason = "",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
val stageGrant = ApprovalGrant(
|
||||
id = GrantId("g2"),
|
||||
scope = GrantScope.STAGE(StageId("st2")),
|
||||
permittedTiers = setOf(Tier.T4),
|
||||
reason = "",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(
|
||||
request("r1", Tier.T4),
|
||||
context(ApprovalMode.DENY),
|
||||
listOf(sessionGrant, stageGrant),
|
||||
now
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalStatus.PENDING, decision.state,
|
||||
"Stage grant for different stage should not apply"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired grant is ignored`() {
|
||||
val past = Instant.parse("2025-12-31T23:59:59Z")
|
||||
val expiredGrant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "",
|
||||
timestamp = past,
|
||||
expiresAt = past // expired before 'now'
|
||||
)
|
||||
val decision = engine.evaluate(
|
||||
request("r1", Tier.T2),
|
||||
context(ApprovalMode.DENY),
|
||||
listOf(expiredGrant),
|
||||
now
|
||||
)
|
||||
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timestamp passed to evaluate is used as resolution timestamp`() {
|
||||
val myNow = Instant.parse("2026-07-01T12:00:00Z")
|
||||
val decision = engine.evaluate(
|
||||
request("r1", Tier.T0),
|
||||
context(ApprovalMode.YOLO),
|
||||
emptyList(),
|
||||
myNow
|
||||
)
|
||||
assertEquals(myNow, decision.resolutionTimestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import com.correx.core.validation.approval.ApprovalTrigger
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ApprovalTriggerTest {
|
||||
|
||||
private val trigger = ApprovalTrigger()
|
||||
|
||||
@Test
|
||||
fun `should trigger approval on error`() {
|
||||
|
||||
val report = ValidationReport(
|
||||
sections = listOf(
|
||||
ValidationSection(
|
||||
name = "graph",
|
||||
issues = listOf(
|
||||
ValidationIssue(
|
||||
code = "GRAPH_DANGLING_TRANSITION",
|
||||
message = "error",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = trigger.evaluate(report)
|
||||
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not trigger approval on clean report`() {
|
||||
|
||||
val report = ValidationReport(
|
||||
sections = listOf(
|
||||
ValidationSection("graph", emptyList()),
|
||||
),
|
||||
)
|
||||
|
||||
val result = trigger.evaluate(report)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GrantSemanticsTest {
|
||||
|
||||
private val engine = DefaultApprovalEngine()
|
||||
|
||||
@Test
|
||||
fun `grant permits exact tier - auto_approved`() {
|
||||
val req = request("r1", Tier.T2)
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.DENY
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val decision = engine.evaluate(req, ctx, listOf(grant), now)
|
||||
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||
assertEquals(ApprovalStatus.COMPLETED, decision.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grant does not permit other tiers`() {
|
||||
val req = request("r1", Tier.T3)
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.DENY
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val decision = engine.evaluate(req, ctx, listOf(grant), now)
|
||||
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||
assertNull(decision.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple grants - any match is enough`() {
|
||||
val req = request("r1", Tier.T4)
|
||||
val grants = listOf(
|
||||
ApprovalGrant(
|
||||
GrantId("g1"),
|
||||
GrantScope.SESSION,
|
||||
setOf(Tier.T3),
|
||||
"",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
),
|
||||
ApprovalGrant(
|
||||
GrantId("g2"),
|
||||
GrantScope.SESSION,
|
||||
setOf(Tier.T4),
|
||||
"",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.DENY
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val decision = engine.evaluate(req, ctx, grants, now)
|
||||
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ModeApprovalTest {
|
||||
|
||||
private val engine = DefaultApprovalEngine()
|
||||
|
||||
private fun contextForMode(mode: ApprovalMode) = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
mode
|
||||
)
|
||||
|
||||
private val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
@Test
|
||||
fun `DENY mode allows only T0`() {
|
||||
val ctx = contextForMode(ApprovalMode.DENY)
|
||||
assertEquals(
|
||||
ApprovalOutcome.AUTO_APPROVED,
|
||||
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalStatus.PENDING,
|
||||
engine.evaluate(request("r2", Tier.T1), ctx, emptyList(), now).state
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalStatus.PENDING,
|
||||
engine.evaluate(request("r3", Tier.T4), ctx, emptyList(), now).state
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PROMPT mode allows T0-T1`() {
|
||||
val ctx = contextForMode(ApprovalMode.PROMPT)
|
||||
assertEquals(
|
||||
ApprovalOutcome.AUTO_APPROVED,
|
||||
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalOutcome.AUTO_APPROVED,
|
||||
engine.evaluate(request("r2", Tier.T1), ctx, emptyList(), now).outcome
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalStatus.PENDING,
|
||||
engine.evaluate(request("r3", Tier.T2), ctx, emptyList(), now).state
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AUTO mode allows T0-T2`() {
|
||||
val ctx = contextForMode(ApprovalMode.AUTO)
|
||||
assertEquals(
|
||||
ApprovalOutcome.AUTO_APPROVED,
|
||||
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalOutcome.AUTO_APPROVED,
|
||||
engine.evaluate(request("r2", Tier.T2), ctx, emptyList(), now).outcome
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalStatus.PENDING,
|
||||
engine.evaluate(request("r3", Tier.T3), ctx, emptyList(), now).state
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `YOLO mode allows all tiers, including T4`() {
|
||||
val ctx = contextForMode(ApprovalMode.YOLO)
|
||||
assertEquals(
|
||||
ApprovalOutcome.AUTO_APPROVED,
|
||||
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
|
||||
)
|
||||
assertEquals(
|
||||
ApprovalOutcome.AUTO_APPROVED,
|
||||
engine.evaluate(request("r2", Tier.T4), ctx, emptyList(), now).outcome
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertDoesNotThrow
|
||||
|
||||
class NoExecutionCouplingTest {
|
||||
|
||||
@Test
|
||||
fun `engine does not mutate grants or context`() {
|
||||
val engine = DefaultApprovalEngine()
|
||||
val grants = mutableListOf(
|
||||
ApprovalGrant(
|
||||
GrantId("g1"),
|
||||
GrantScope.SESSION,
|
||||
setOf(Tier.T2),
|
||||
"",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
)
|
||||
val originalGrants = grants.toList()
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.DENY
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
engine.evaluate(request("r1", Tier.T2), ctx, grants, now)
|
||||
|
||||
assertEquals(originalGrants, grants, "Grants list must not be mutated")
|
||||
assertEquals(ApprovalMode.DENY, ctx.mode, "Context must not be mutated")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engine does not throw for valid input`() {
|
||||
val engine = DefaultApprovalEngine()
|
||||
assertDoesNotThrow {
|
||||
engine.evaluate(
|
||||
request("r1", Tier.T0),
|
||||
ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.YOLO
|
||||
),
|
||||
emptyList(),
|
||||
Instant.parse("2026-01-01T00:00:01Z")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TierImmutabilityTest {
|
||||
|
||||
private val engine = DefaultApprovalEngine()
|
||||
|
||||
@Test
|
||||
fun `engine never modifies tier - always returns original`() {
|
||||
val tiers = listOf(Tier.T0, Tier.T1, Tier.T2, Tier.T3, Tier.T4)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.YOLO
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
tiers.forEach { tier ->
|
||||
val req = request("r1", tier)
|
||||
val decision = engine.evaluate(req, ctx, emptyList(), now)
|
||||
Assertions.assertEquals(tier, decision.tier, "Tier must remain unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grant does not change tier in decision`() {
|
||||
val tier = Tier.T3
|
||||
val req = request("r1", tier)
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(tier),
|
||||
reason = "test",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.DENY
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val decision = engine.evaluate(req, ctx, listOf(grant), now)
|
||||
Assertions.assertEquals(tier, decision.tier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
subprojects {
|
||||
group = "com.correx.testing"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'java-test-fixtures'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:validation"))
|
||||
testImplementation(project(":core:approvals"))
|
||||
testImplementation(project(":core:artifacts"))
|
||||
testImplementation(project(":core:kernel"))
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
testImplementation(project(":core:inference"))
|
||||
testImplementation(project(":core:context"))
|
||||
testFixturesImplementation(project(":core:events"))
|
||||
testFixturesImplementation(project(":core:sessions"))
|
||||
testFixturesImplementation(project(":testing:fixtures"))
|
||||
testFixturesImplementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version"
|
||||
testFixturesImplementation "org.junit.jupiter:junit-jupiter:$junit_version"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.correx.testing.contracts.utils
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
object ConcurrencyRunner {
|
||||
|
||||
fun run(
|
||||
threads: Int,
|
||||
iterations: Int,
|
||||
block: (threadIndex: Int, iteration: Int) -> Unit
|
||||
) {
|
||||
val executor = Executors.newFixedThreadPool(threads)
|
||||
|
||||
val start = CountDownLatch(1)
|
||||
val done = CountDownLatch(threads)
|
||||
|
||||
repeat(threads) { t ->
|
||||
executor.submit {
|
||||
start.await()
|
||||
|
||||
try {
|
||||
repeat(iterations) { i ->
|
||||
block(t, i)
|
||||
}
|
||||
} finally {
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
done.await()
|
||||
executor.shutdown()
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.correx.testing.contracts.utils
|
||||
|
||||
import java.util.concurrent.CyclicBarrier
|
||||
|
||||
class DeterministicBarrier(parties: Int) {
|
||||
|
||||
private val barrier = CyclicBarrier(parties)
|
||||
|
||||
fun await() {
|
||||
barrier.await()
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.correx.testing.contracts.model
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ContextSnapshotTest {
|
||||
|
||||
@Test
|
||||
fun `decision contains snapshot, not live context`() {
|
||||
val engine = DefaultApprovalEngine()
|
||||
val identity = ApprovalScopeIdentity(SessionId("s1"), StageId("st1"), ProjectId("p1"))
|
||||
val mode = ApprovalMode.AUTO
|
||||
val context = ApprovalContext(identity, mode)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now)
|
||||
|
||||
assertEquals(identity, decision.contextSnapshot.identity)
|
||||
assertEquals(mode, decision.contextSnapshot.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot does not change after decision`() {
|
||||
val engine = DefaultApprovalEngine()
|
||||
val identity = ApprovalScopeIdentity(SessionId("s1"), null, null)
|
||||
val context = ApprovalContext(identity, ApprovalMode.PROMPT)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now)
|
||||
val anotherContext = context.copy(mode = ApprovalMode.YOLO)
|
||||
assertNotEquals(anotherContext.mode, decision.contextSnapshot.mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.correx.testing.contracts.model
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class StageIdTest {
|
||||
|
||||
@Test
|
||||
fun `rejects blank value`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
StageId("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stores value correctly`() {
|
||||
val id = StageId("stage-1")
|
||||
|
||||
assertEquals("stage-1", id.value)
|
||||
assertEquals("stage-1", id.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `value class equality works`() {
|
||||
val a = StageId("x")
|
||||
val b = StageId("x")
|
||||
|
||||
assertEquals(a, b)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.correx.testing.contracts.model
|
||||
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class TransitionIdTest {
|
||||
|
||||
@Test
|
||||
fun `rejects blank value`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
TransitionId("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stores value correctly`() {
|
||||
val id = TransitionId("t-1")
|
||||
|
||||
assertEquals("t-1", id.value)
|
||||
assertEquals("t-1", id.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `value class equality works`() {
|
||||
val a = TransitionId("x")
|
||||
val b = TransitionId("x")
|
||||
|
||||
assertEquals(a, b)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.correx.testing.contracts.model
|
||||
|
||||
import com.correx.core.validation.graph.GraphValidator
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.testing.fixtures.WorkflowFixtures
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ValidationReportContractTest {
|
||||
|
||||
@Test
|
||||
fun `report must be deterministic`() {
|
||||
|
||||
val graph = WorkflowFixtures.simpleGraph()
|
||||
|
||||
val pipeline = ValidationPipeline(
|
||||
listOf(GraphValidator())
|
||||
)
|
||||
|
||||
val context = ValidationContext(graph)
|
||||
|
||||
val r1 = pipeline.validate(context)
|
||||
val r2 = pipeline.validate(context)
|
||||
|
||||
assertEquals(r1, r2)
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.correx.testing.contracts.model
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WorkflowGraphTest {
|
||||
|
||||
private val s1 = StageId("s1")
|
||||
private val s2 = StageId("s2")
|
||||
|
||||
@Test
|
||||
fun `graph stores immutable structure`() {
|
||||
val graph = WorkflowGraph(
|
||||
stages = mapOf(s1 to StageConfig(), s2 to StageConfig()),
|
||||
transitions = setOf(
|
||||
TransitionEdge(
|
||||
id = TransitionId("t1"),
|
||||
from = s1,
|
||||
to = s2,
|
||||
condition = { true }
|
||||
)
|
||||
),
|
||||
start = s1
|
||||
)
|
||||
|
||||
assertEquals(2, graph.stages.size)
|
||||
assertEquals(1, graph.transitions.size)
|
||||
assertEquals(s1, graph.start)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map removes duplicate keys deterministically`() {
|
||||
val node = s1
|
||||
|
||||
val graph = WorkflowGraph(
|
||||
stages = mapOf(node to StageConfig()),
|
||||
transitions = emptySet(),
|
||||
start = s1
|
||||
)
|
||||
|
||||
assertEquals(1, graph.stages.size)
|
||||
assertTrue(graph.stages.containsKey(node))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `graph equality is structural`() {
|
||||
val g1 = WorkflowGraph(
|
||||
stages = mapOf(s1 to StageConfig()),
|
||||
transitions = emptySet(),
|
||||
start = s1
|
||||
)
|
||||
|
||||
val g2 = WorkflowGraph(
|
||||
stages = mapOf(s1 to StageConfig()),
|
||||
transitions = emptySet(),
|
||||
start = s1
|
||||
)
|
||||
|
||||
assertEquals(g1, g2)
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.correx.testing.contracts.model.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.DefaultInferenceRouter
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.NoEligibleProviderException
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.ProviderRegistry
|
||||
import com.correx.core.inference.RoutingStrategy
|
||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.TestTimeSource
|
||||
|
||||
class DefaultInferenceRouterCacheTest {
|
||||
|
||||
private val stage = StageId("cache-test")
|
||||
|
||||
private class CountingProvider(
|
||||
delegate: MockInferenceProvider,
|
||||
) : InferenceProvider by delegate {
|
||||
var healthCheckCount: Int = 0
|
||||
private set
|
||||
private var _health: ProviderHealth = ProviderHealth.Healthy
|
||||
|
||||
fun setHealth(h: ProviderHealth) { _health = h }
|
||||
|
||||
override suspend fun healthCheck(): ProviderHealth {
|
||||
healthCheckCount++
|
||||
return _health
|
||||
}
|
||||
}
|
||||
|
||||
private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry =
|
||||
object : ProviderRegistry {
|
||||
override fun register(provider: InferenceProvider) = Unit
|
||||
override fun resolve(capability: ModelCapability) =
|
||||
providers.filter { p -> p.capabilities().any { it.capability == capability } }
|
||||
override fun listAll() = providers.toList()
|
||||
override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy }
|
||||
}
|
||||
|
||||
private fun firstStrategy(): RoutingStrategy =
|
||||
RoutingStrategy { candidates, required ->
|
||||
candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), required)
|
||||
}
|
||||
|
||||
// ── (a) repeated route() within TTL invokes health check once per provider ──
|
||||
|
||||
@Test
|
||||
fun `health check is cached within TTL`(): Unit = runBlocking {
|
||||
val timeSource = TestTimeSource()
|
||||
val mock = MockInferenceProvider(
|
||||
id = ProviderId("p1"),
|
||||
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
|
||||
)
|
||||
val counting = CountingProvider(mock)
|
||||
val router = DefaultInferenceRouter(
|
||||
registry = registryOf(counting),
|
||||
strategy = firstStrategy(),
|
||||
cacheTtl = 5.seconds,
|
||||
timeSource = timeSource,
|
||||
)
|
||||
|
||||
router.route(stage, setOf(ModelCapability.General))
|
||||
router.route(stage, setOf(ModelCapability.General))
|
||||
router.route(stage, setOf(ModelCapability.General))
|
||||
|
||||
// Cache hit for all subsequent calls: health checked once for the filter pass,
|
||||
// plus the post-selection re-check on the first call (uncached path), then cached.
|
||||
// After the first route() the entry is cached; the two subsequent calls use the cache.
|
||||
// Post-selection re-check always goes live, so total = 1 (filter) + 1 (post-select)
|
||||
// on first call, then 0 (filter cached) + 1 (post-select live) * 2 more = 4 total.
|
||||
// But the cache only governs the filter pass; post-select is always live by design.
|
||||
// So: first call = 2 checks (filter + post-select), subsequent calls = 1 each (post-select).
|
||||
assertEquals(4, counting.healthCheckCount)
|
||||
}
|
||||
|
||||
// ── (b) after TTL expires, health is re-checked ──────────────────────────
|
||||
|
||||
@Test
|
||||
fun `health check is refreshed after TTL expires`(): Unit = runBlocking {
|
||||
val timeSource = TestTimeSource()
|
||||
val mock = MockInferenceProvider(
|
||||
id = ProviderId("p1"),
|
||||
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
|
||||
)
|
||||
val counting = CountingProvider(mock)
|
||||
val router = DefaultInferenceRouter(
|
||||
registry = registryOf(counting),
|
||||
strategy = firstStrategy(),
|
||||
cacheTtl = 5.seconds,
|
||||
timeSource = timeSource,
|
||||
)
|
||||
|
||||
router.route(stage, setOf(ModelCapability.General))
|
||||
val countAfterFirstCall = counting.healthCheckCount
|
||||
|
||||
// Advance past TTL
|
||||
timeSource.plusAssign(6.seconds)
|
||||
|
||||
router.route(stage, setOf(ModelCapability.General))
|
||||
|
||||
// After TTL expiry a new filter-pass health check must fire
|
||||
assert(counting.healthCheckCount > countAfterFirstCall + 1) {
|
||||
"Expected more than ${countAfterFirstCall + 1} total checks, got ${counting.healthCheckCount}"
|
||||
}
|
||||
}
|
||||
|
||||
// ── (c) unhealthy cached entry causes provider skip ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `cached unavailable entry causes provider to be skipped`(): Unit = runBlocking {
|
||||
val timeSource = TestTimeSource()
|
||||
val mock1 = MockInferenceProvider(
|
||||
id = ProviderId("p1"),
|
||||
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
|
||||
)
|
||||
val mock2 = MockInferenceProvider(
|
||||
id = ProviderId("p2"),
|
||||
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
|
||||
)
|
||||
val counting1 = CountingProvider(mock1)
|
||||
val counting2 = CountingProvider(mock2)
|
||||
val router = DefaultInferenceRouter(
|
||||
registry = registryOf(counting1, counting2),
|
||||
strategy = firstStrategy(),
|
||||
cacheTtl = 5.seconds,
|
||||
timeSource = timeSource,
|
||||
)
|
||||
|
||||
// Warm the cache with p1 healthy
|
||||
router.route(stage, setOf(ModelCapability.General))
|
||||
|
||||
// Mark p1 unavailable in the counting provider — but cache still says healthy
|
||||
counting1.setHealth(ProviderHealth.Unavailable("went down"))
|
||||
|
||||
// Advance past TTL so cache refreshes
|
||||
timeSource.plusAssign(6.seconds)
|
||||
|
||||
val selected = router.route(stage, setOf(ModelCapability.General))
|
||||
assertSame(counting2, selected)
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.correx.testing.contracts.model.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.DefaultInferenceRouter
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.NoEligibleProviderException
|
||||
import com.correx.core.inference.ProviderRegistry
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.RoutingStrategy
|
||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class DefaultInferenceRouterTest {
|
||||
|
||||
private val stage = StageId("s1")
|
||||
|
||||
private fun provider(id: String, vararg caps: ModelCapability): MockInferenceProvider =
|
||||
MockInferenceProvider(
|
||||
id = ProviderId(id),
|
||||
declaredCapabilities = caps.map { CapabilityScore(it, 1.0) }.toSet(),
|
||||
)
|
||||
|
||||
private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry =
|
||||
object : ProviderRegistry {
|
||||
override fun register(provider: InferenceProvider) = Unit
|
||||
override fun resolve(capability: ModelCapability) =
|
||||
providers.filter { p -> p.capabilities().any { it.capability == capability } }
|
||||
override fun listAll() = providers.toList()
|
||||
override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy }
|
||||
}
|
||||
|
||||
private fun firstStrategy(): RoutingStrategy =
|
||||
object : RoutingStrategy {
|
||||
override fun select(candidates: List<InferenceProvider>, requiredCapabilities: Set<ModelCapability>) =
|
||||
candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), emptySet())
|
||||
}
|
||||
|
||||
private fun throwingStrategy(): RoutingStrategy =
|
||||
object : RoutingStrategy {
|
||||
override fun select(candidates: List<InferenceProvider>, requiredCapabilities: Set<ModelCapability>) =
|
||||
throw NoEligibleProviderException(StageId("?"), requiredCapabilities)
|
||||
}
|
||||
|
||||
// ── capability matching ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `routes to provider that matches required capability`(): Unit = runBlocking {
|
||||
val p = provider("a", ModelCapability.General)
|
||||
val router = DefaultInferenceRouter(registryOf(p), firstStrategy())
|
||||
assertSame(p, router.route(stage, setOf(ModelCapability.General)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplicates providers that satisfy multiple required capabilities`(): Unit = runBlocking {
|
||||
val p = provider("a", ModelCapability.General, ModelCapability.Coding)
|
||||
val selected = mutableListOf<InferenceProvider>()
|
||||
val captureStrategy = object : RoutingStrategy {
|
||||
override fun select(
|
||||
candidates: List<InferenceProvider>,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
): InferenceProvider {
|
||||
selected.addAll(candidates)
|
||||
return candidates.first()
|
||||
}
|
||||
}
|
||||
val router = DefaultInferenceRouter(
|
||||
registryOf(p),
|
||||
captureStrategy,
|
||||
)
|
||||
router.route(stage, setOf(ModelCapability.General, ModelCapability.Coding))
|
||||
assert(selected.count { it.id == p.id } == 1) { "provider appeared ${selected.size} times, expected 1" }
|
||||
}
|
||||
|
||||
// ── empty capabilities falls back to all providers ────────────────────────
|
||||
|
||||
@Test
|
||||
fun `empty required capabilities routes from all registered providers`(): Unit = runBlocking {
|
||||
val p1 = provider("a", ModelCapability.General)
|
||||
val p2 = provider("b", ModelCapability.Coding)
|
||||
val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy())
|
||||
val result = router.route(stage, emptySet())
|
||||
assertSame(p1, result)
|
||||
}
|
||||
|
||||
// ── no candidates throws ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `throws NoEligibleProviderException when no provider satisfies capability`() {
|
||||
val router = DefaultInferenceRouter(registryOf(), throwingStrategy())
|
||||
assertThrows<NoEligibleProviderException> {
|
||||
runBlocking { router.route(stage, setOf(ModelCapability.General)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throws NoEligibleProviderException when registry is empty and capabilities are empty`() {
|
||||
val router = DefaultInferenceRouter(registryOf(), throwingStrategy())
|
||||
assertThrows<NoEligibleProviderException> {
|
||||
runBlocking { router.route(stage, emptySet()) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── unavailable providers are filtered ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `filters out unavailable providers before selection`(): Unit = runBlocking {
|
||||
val p1 = MockInferenceProvider(
|
||||
id = com.correx.core.events.types.ProviderId("a"),
|
||||
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
|
||||
health = ProviderHealth.Unavailable("down"),
|
||||
)
|
||||
val p2 = provider("b", ModelCapability.General)
|
||||
val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy())
|
||||
val result = router.route(stage, setOf(ModelCapability.General))
|
||||
assertSame(p2, result)
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.correx.testing.contracts.model.inference
|
||||
|
||||
import com.correx.core.context.model.CompressionMetadata
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.GenerationConfig
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class InferenceProviderContractTest {
|
||||
|
||||
fun provider(): InferenceProvider = MockInferenceProvider()
|
||||
|
||||
private fun request() = InferenceRequest(
|
||||
requestId = InferenceRequestId("req-1"),
|
||||
sessionId = SessionId("session-1"),
|
||||
stageId = StageId("stage-1"),
|
||||
contextPack = emptyContextPack(), // test helper — returns minimal stub
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = 0.0,
|
||||
topP = 1.0,
|
||||
maxTokens = 128,
|
||||
),
|
||||
)
|
||||
|
||||
// ── Successful inference ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `infer returns response with matching requestId`(): Unit = runBlocking {
|
||||
val p = provider()
|
||||
val req = request()
|
||||
val resp = p.infer(req)
|
||||
assertEquals(req.requestId, resp.requestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `infer returns non-blank text`(): Unit = runBlocking {
|
||||
val resp = provider().infer(request())
|
||||
assertTrue(resp.text.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `infer tracks token usage`(): Unit = runBlocking {
|
||||
val resp = provider().infer(request())
|
||||
assertTrue(resp.tokensUsed.totalTokens > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `infer latency is non-negative`(): Unit = runBlocking {
|
||||
val resp = provider().infer(request())
|
||||
assertTrue(resp.latencyMs >= 0)
|
||||
}
|
||||
|
||||
// ── Health ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `healthCheck returns non-null`(): Unit = runBlocking {
|
||||
assertNotNull(provider().healthCheck())
|
||||
}
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `capabilities is non-empty`() {
|
||||
assertTrue(provider().capabilities().isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all capability scores are in range 0 to 1`() {
|
||||
provider().capabilities().forEach { cs ->
|
||||
assertTrue(cs.score in 0.0..1.0, "score out of range: $cs")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancellation ──────────────────────────────────────────────────────────
|
||||
|
||||
// A slow provider — required so cancellation actually fires mid-flight rather than
|
||||
// racing a 0ms infer that has already completed by the time cancel() is called.
|
||||
private fun slowProvider(): InferenceProvider = MockInferenceProvider(artificialDelayMs = 1_000L)
|
||||
|
||||
@Test
|
||||
fun `infer respects coroutine cancellation`(): Unit = runBlocking {
|
||||
val p = slowProvider()
|
||||
val started = System.currentTimeMillis()
|
||||
val job = launch { p.infer(request()) }
|
||||
// give the coroutine a moment to enter delay()
|
||||
while (job.isActive && System.currentTimeMillis() - started < 50) { /* spin */ }
|
||||
job.cancel()
|
||||
job.join()
|
||||
assertFalse(job.isActive)
|
||||
// Must complete well before the 1000ms artificial delay would have elapsed.
|
||||
assertTrue(System.currentTimeMillis() - started < 500, "cancellation did not interrupt mid-flight")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `infer cancellation completes within 500ms`(): Unit = runBlocking {
|
||||
val p = slowProvider()
|
||||
val started = System.currentTimeMillis()
|
||||
val job = launch { p.infer(request()) }
|
||||
job.cancel()
|
||||
withTimeout(500) { job.join() }
|
||||
assertTrue(System.currentTimeMillis() - started < 500, "join exceeded budget after cancel")
|
||||
}
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `tokenizer countTokens is consistent with tokenize`(): Unit = runBlocking {
|
||||
val t = provider().tokenizer
|
||||
val text = "hello world"
|
||||
assertEquals(t.tokenize(text).size, t.countTokens(text))
|
||||
}
|
||||
|
||||
private fun emptyContextPack(): ContextPack = ContextPack(
|
||||
id = ContextPackId("cp1"),
|
||||
sessionId = SessionId("session-1"),
|
||||
stageId = StageId("stage-1"),
|
||||
layers = emptyMap(),
|
||||
budgetUsed = 10,
|
||||
budgetLimit = 20,
|
||||
compressionMetadata = CompressionMetadata(),
|
||||
)
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.correx.testing.contracts.model.orchestration
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
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.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.serialization.eventJson
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventsTest {
|
||||
@Test
|
||||
fun `WorkflowStartedEvent survives round-trip`() {
|
||||
val event: EventPayload = WorkflowStartedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
startStageId = StageId("stage-a"),
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowCompletedEvent survives round-trip`() {
|
||||
val event: EventPayload = WorkflowCompletedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
terminalStageId = StageId("stage-a"),
|
||||
totalStages = 1,
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowFailedEvent survives round-trip`() {
|
||||
val event: EventPayload = WorkflowFailedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-a"),
|
||||
reason = "Something went wrong",
|
||||
retryExhausted = false,
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowFailedEvent survives round-trip with retry exhausted`() {
|
||||
val event: EventPayload = WorkflowFailedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-a"),
|
||||
reason = "Something went wrong",
|
||||
retryExhausted = true,
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OrchestrationPausedEvent survives round-trip`() {
|
||||
val event: EventPayload = OrchestrationPausedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-a"),
|
||||
reason = "Requires user approval",
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OrchestrationResumedEvent survives round-trip`() {
|
||||
val event: EventPayload = OrchestrationResumedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-a"),
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RetryAttemptedEvent survives round-trip`() {
|
||||
val event: EventPayload = RetryAttemptedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-a"),
|
||||
attemptNumber = 1,
|
||||
maxAttempts = 3,
|
||||
failureReason = "Something went wrong",
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RetryAttemptedEvent survives round-trip attempt = max attempts`() {
|
||||
val event: EventPayload = RetryAttemptedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage-a"),
|
||||
attemptNumber = 3,
|
||||
maxAttempts = 3,
|
||||
failureReason = "Something went wrong",
|
||||
)
|
||||
|
||||
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
|
||||
assertEquals(event, decoded)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.correx.testing.contracts.model.orchestration
|
||||
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.kernel.execution.ReplayStrategy
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class OrchestrationConfigTest {
|
||||
|
||||
@Test
|
||||
fun `default retryPolicy has maxAttempts of 3`() {
|
||||
assertEquals(3, OrchestrationConfig().retryPolicy.maxAttempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default replayStrategy is Full`() {
|
||||
assertEquals(ReplayStrategy.Full, OrchestrationConfig().replayStrategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default stageTimeoutMs is 60 seconds`() {
|
||||
assertEquals(60_000L, OrchestrationConfig().stageTimeoutMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom values are stored`() {
|
||||
val config = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(maxAttempts = 5, backoffMs = 200L),
|
||||
replayStrategy = ReplayStrategy.Full,
|
||||
stageTimeoutMs = 30_000L,
|
||||
)
|
||||
assertEquals(5, config.retryPolicy.maxAttempts)
|
||||
assertEquals(200L, config.retryPolicy.backoffMs)
|
||||
assertEquals(30_000L, config.stageTimeoutMs)
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.correx.testing.contracts.model.orchestration
|
||||
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class RetryPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `maxAttempts of zero throws IllegalArgumentException`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
RetryPolicy(maxAttempts = 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative maxAttempts throws IllegalArgumentException`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
RetryPolicy(maxAttempts = -1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maxAttempts of one is valid`() {
|
||||
val policy = RetryPolicy(maxAttempts = 1)
|
||||
assertEquals(1, policy.maxAttempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backoffMs defaults to zero`() {
|
||||
val policy = RetryPolicy(maxAttempts = 1)
|
||||
assertEquals(0L, policy.backoffMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backoffMs is stored when provided`() {
|
||||
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 500L)
|
||||
assertEquals(500L, policy.backoffMs)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.correx.testing.contracts.model.orchestration
|
||||
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class StageOutcomeTest {
|
||||
|
||||
@Test
|
||||
fun `sealed exhaustiveness - all variants reachable`() {
|
||||
val results: List<StageExecutionResult> = listOf(
|
||||
StageExecutionResult.Success(emptyList()),
|
||||
StageExecutionResult.Failure("something went wrong", false),
|
||||
)
|
||||
|
||||
val labels = results.map { result ->
|
||||
when (result) {
|
||||
is StageExecutionResult.Success -> "success"
|
||||
is StageExecutionResult.Failure -> "failure"
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf("success", "failure"),
|
||||
labels,
|
||||
)
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.correx.testing.contracts.model.orchestration
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
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 WorkflowResultTest {
|
||||
|
||||
@Test
|
||||
fun `Completed carries sessionId and terminalStageId`() {
|
||||
val result = WorkflowResult.Completed(SessionId("s1"), StageId("stage-z"))
|
||||
assertEquals(SessionId("s1"), result.sessionId)
|
||||
assertEquals(StageId("stage-z"), result.terminalStageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Failed carries sessionId reason and retryExhausted`() {
|
||||
val result = WorkflowResult.Failed(SessionId("s2"), "boom", retryExhausted = true)
|
||||
assertEquals(SessionId("s2"), result.sessionId)
|
||||
assertEquals("boom", result.reason)
|
||||
assertTrue(result.retryExhausted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Failed retryExhausted can be false`() {
|
||||
val result = WorkflowResult.Failed(SessionId("s3"), "transient", retryExhausted = false)
|
||||
assertFalse(result.retryExhausted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancelled carries sessionId`() {
|
||||
val result = WorkflowResult.Cancelled(SessionId("s4"))
|
||||
assertEquals(SessionId("s4"), result.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sealed exhaustiveness - all variants reachable`() {
|
||||
val results: List<WorkflowResult> = listOf(
|
||||
WorkflowResult.Completed(SessionId("s1"), StageId("stage-z")),
|
||||
WorkflowResult.Failed(SessionId("s2"), "reason", retryExhausted = false),
|
||||
WorkflowResult.Cancelled(SessionId("s3")),
|
||||
)
|
||||
|
||||
val labels = results.map { result ->
|
||||
when (result) {
|
||||
is WorkflowResult.Completed -> "completed"
|
||||
is WorkflowResult.Failed -> "failed"
|
||||
is WorkflowResult.Cancelled -> "cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(listOf("completed", "failed", "cancelled"), labels)
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.correx.testing.contracts.fixtures.events.store
|
||||
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.testing.contracts.utils.ConcurrencyRunner
|
||||
import com.correx.testing.fixtures.EventFixtures
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
abstract class EventStoreContractTest {
|
||||
protected abstract fun store(): EventStore
|
||||
|
||||
@Test
|
||||
fun `appends preserve ordering`() {
|
||||
val store = store()
|
||||
|
||||
store.append(EventFixtures.newEvent(EventId("e1"), SessionId("s1")))
|
||||
store.append(EventFixtures.newEvent(EventId("e2"), SessionId("s1")))
|
||||
|
||||
val events = store.read(SessionId("s1"))
|
||||
|
||||
Assertions.assertEquals(2, events.size)
|
||||
Assertions.assertEquals("e1", events[0].metadata.eventId.value)
|
||||
Assertions.assertEquals("e2", events[1].metadata.eventId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idempotency prevents duplicates`() {
|
||||
val store = store()
|
||||
val e = EventFixtures.newEvent(EventId("dup"), SessionId("s1"))
|
||||
|
||||
store.append(e)
|
||||
assertThrows<Exception> {
|
||||
store.append(e)
|
||||
}
|
||||
Assertions.assertEquals(1, store.read(SessionId("s1")).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sessions are isolated`() {
|
||||
val store = store()
|
||||
|
||||
store.append(EventFixtures.newEvent(EventId("a"), SessionId("s1")))
|
||||
store.append(EventFixtures.newEvent(EventId("b"), SessionId("s2")))
|
||||
|
||||
Assertions.assertEquals(1, store.read(SessionId("s1")).size)
|
||||
Assertions.assertEquals(1, store.read(SessionId("s2")).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readFrom respects sequence cursor`() {
|
||||
val store = store()
|
||||
|
||||
store.append(EventFixtures.newEvent(EventId("1"), SessionId("s1")))
|
||||
store.append(EventFixtures.newEvent(EventId("2"), SessionId("s1")))
|
||||
store.append(EventFixtures.newEvent(EventId("3"), SessionId("s1")))
|
||||
|
||||
val partial = store.readFrom(SessionId("s1"), 2)
|
||||
|
||||
Assertions.assertEquals(1, partial.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event ids are globally unique under concurrent appends`() {
|
||||
val store = store()
|
||||
|
||||
ConcurrencyRunner.run(20, 100) { t, i ->
|
||||
store.append(
|
||||
EventFixtures.newEvent(EventId("e-$i-$t"), SessionId("s1"))
|
||||
)
|
||||
}
|
||||
|
||||
val all = store.read(SessionId("s1"))
|
||||
|
||||
val ids = all.map { it.metadata.eventId }.toSet()
|
||||
|
||||
Assertions.assertEquals(all.size, ids.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sequences are strictly increasing per session`() {
|
||||
val store = store()
|
||||
|
||||
repeat(100) {
|
||||
store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1")))
|
||||
}
|
||||
|
||||
val seqs = store.read(SessionId("s1")).map { it.sequence }
|
||||
|
||||
seqs.zipWithNext().forEach { (a, b) ->
|
||||
Assertions.assertTrue(a < b)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sequences are isolated per session`() {
|
||||
val store = store()
|
||||
|
||||
repeat(50) {
|
||||
store.append(EventFixtures.newEvent(EventId("a$it"), SessionId("A")))
|
||||
store.append(EventFixtures.newEvent(EventId("b$it"), SessionId("B")))
|
||||
}
|
||||
|
||||
val aSeq = store.read(SessionId("A")).map { it.sequence }
|
||||
val bSeq = store.read(SessionId("B")).map { it.sequence }
|
||||
|
||||
Assertions.assertEquals(aSeq.sorted(), aSeq)
|
||||
Assertions.assertEquals(bSeq.sorted(), bSeq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `appendAll is atomic per session`() {
|
||||
val store = store()
|
||||
|
||||
val batch = (1..50).map {
|
||||
EventFixtures.newEvent(EventId("e$it"), SessionId("s1"))
|
||||
}
|
||||
|
||||
store.appendAll(batch)
|
||||
|
||||
val result = store.read(SessionId("s1"))
|
||||
|
||||
Assertions.assertEquals(50, result.size)
|
||||
|
||||
val seqs = result.map { it.sequence }
|
||||
Assertions.assertEquals(seqs.sorted(), seqs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent appendAll and append remain consistent`() {
|
||||
val store = store()
|
||||
|
||||
ConcurrencyRunner.run(10, 50) { t, i ->
|
||||
if (i % 2 == 0) {
|
||||
store.append(EventFixtures.newEvent(EventId("a-$t-$i"), SessionId("s1")))
|
||||
} else {
|
||||
store.appendAll(
|
||||
listOf(EventFixtures.newEvent(EventId("b-$t-$i"), SessionId("s1")))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val all = store.read(SessionId("s1"))
|
||||
|
||||
val seqs = all.map { it.sequence }
|
||||
|
||||
Assertions.assertEquals(seqs.sorted(), seqs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read returns stable snapshot`() {
|
||||
val store = store()
|
||||
|
||||
repeat(100) {
|
||||
store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1")))
|
||||
}
|
||||
|
||||
val r1 = store.read(SessionId("s1"))
|
||||
val r2 = store.read(SessionId("s1"))
|
||||
|
||||
Assertions.assertEquals(r1, r2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readFrom behaves like streaming cursor`() {
|
||||
val store = store()
|
||||
|
||||
repeat(10) {
|
||||
store.append(EventFixtures.newEvent(EventId("$it"), SessionId("s1")))
|
||||
}
|
||||
|
||||
val first = store.readFrom(SessionId("s1"), 3)
|
||||
val second = store.readFrom(SessionId("s1"), 7)
|
||||
|
||||
Assertions.assertTrue(first.all { it.sequence > 3 })
|
||||
Assertions.assertTrue(second.all { it.sequence > 7 })
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.correx.testing.contracts.fixtures.projections
|
||||
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.DefaultStateBuilder
|
||||
import com.correx.core.sessions.SessionCounterProjection
|
||||
import com.correx.core.sessions.SessionCounterState
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
open class CountingProjectionContractTest : ProjectionContractTest<SessionCounterState>() {
|
||||
|
||||
override fun projection() =
|
||||
SessionCounterProjection("s1")
|
||||
|
||||
@Test
|
||||
fun `projection reflects event count`() {
|
||||
val events = (0 until 42).map {
|
||||
stored(EventId(it.toString()), sessionId = SessionId("s1"), sequence = it.toLong())
|
||||
}
|
||||
|
||||
val state = DefaultStateBuilder(
|
||||
projection()
|
||||
).build(events)
|
||||
|
||||
assertEquals(42, state.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projection is order sensitive but deterministic`() {
|
||||
val events = listOf(
|
||||
stored(EventId("1"), sessionId = SessionId("s1"), sequence = 0),
|
||||
stored(EventId("2"), sessionId = SessionId("s1"), sequence = 1),
|
||||
stored(EventId("3"), sessionId = SessionId("s1"), sequence = 2)
|
||||
)
|
||||
|
||||
val state = DefaultStateBuilder(
|
||||
projection()
|
||||
).build(events)
|
||||
|
||||
assertEquals(3, state.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projection handles empty stream`() {
|
||||
val state = DefaultStateBuilder(
|
||||
projection()
|
||||
).build(emptyList())
|
||||
|
||||
assertEquals(0, state.count)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.correx.testing.contracts.fixtures.projections
|
||||
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.DefaultStateBuilder
|
||||
import com.correx.core.sessions.projections.Projection
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
abstract class ProjectionContractTest<S> {
|
||||
|
||||
abstract fun projection(): Projection<S>
|
||||
|
||||
@Test
|
||||
fun `projection rebuild is deterministic`() {
|
||||
val events = (0 until 100).map {
|
||||
stored(EventId("e$it"), sessionId = SessionId("s1"), sequence = it.toLong())
|
||||
}
|
||||
|
||||
val projection = projection()
|
||||
|
||||
val builder = DefaultStateBuilder(projection)
|
||||
|
||||
val state1 = builder.build(events)
|
||||
val state2 = builder.build(events)
|
||||
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.correx.testing.contracts.fixtures.projections
|
||||
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.testing.fixtures.EventFixtures.newEvent
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
abstract class ReplayContractTest<S> {
|
||||
|
||||
protected abstract fun store(): EventStore
|
||||
|
||||
protected abstract fun replayer(
|
||||
store: EventStore
|
||||
): EventReplayer<S>
|
||||
|
||||
@Test
|
||||
fun `rebuild is deterministic`() {
|
||||
val store = store()
|
||||
|
||||
repeat(50) {
|
||||
store.append(newEvent(EventId("e$it"), SessionId("s1")))
|
||||
}
|
||||
|
||||
val replayer = replayer(store)
|
||||
|
||||
val state1 = replayer.rebuild(SessionId("s1"))
|
||||
val state2 = replayer.rebuild(SessionId("s1"))
|
||||
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.correx.testing.contracts.fixtures.projections
|
||||
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.core.sessions.SessionCounterState
|
||||
import com.correx.testing.fixtures.EventFixtures.newEvent
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
abstract class SessionReplayContractTest {
|
||||
|
||||
protected abstract fun store(): EventStore
|
||||
|
||||
protected abstract fun replayer(
|
||||
store: EventStore
|
||||
): EventReplayer<SessionCounterState>
|
||||
|
||||
@Test
|
||||
fun `rebuild is deterministic`() {
|
||||
val store = store()
|
||||
|
||||
repeat(50) {
|
||||
store.append(newEvent(EventId("e$it"), SessionId("s1")))
|
||||
}
|
||||
|
||||
val replayer = replayer(store)
|
||||
|
||||
val state1 = replayer.rebuild(SessionId("s1"))
|
||||
val state2 = replayer.rebuild(SessionId("s1"))
|
||||
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rebuild reflects complete stream`() {
|
||||
val store = store()
|
||||
|
||||
repeat(42) {
|
||||
store.append(newEvent(EventId("e$it"), SessionId("s1")))
|
||||
}
|
||||
|
||||
val replayer = replayer(store)
|
||||
|
||||
val state = replayer.rebuild(SessionId("s1"))
|
||||
|
||||
assertEquals(42, state.count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:validation"))
|
||||
testImplementation(project(":core:approvals"))
|
||||
testImplementation(project(":core:context"))
|
||||
testImplementation(project(":infrastructure:persistence"))
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
testImplementation(project(":core:inference"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.testing.deterministic
|
||||
|
||||
object Module
|
||||
@@ -0,0 +1,45 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ApprovalEngineDeterminismTest {
|
||||
|
||||
private val engine = DefaultApprovalEngine()
|
||||
|
||||
@Test
|
||||
fun `identical inputs produce identical decisions`() {
|
||||
val request = request("r1", Tier.T2)
|
||||
val identity = ApprovalScopeIdentity(SessionId("s1"), null, null)
|
||||
val context = ApprovalContext(identity, ApprovalMode.AUTO)
|
||||
val grants = emptyList<ApprovalGrant>()
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val d1 = engine.evaluate(request, context, grants, now)
|
||||
val d2 = engine.evaluate(request, context, grants, now)
|
||||
|
||||
assertEquals(d1, d2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different requests produce different decision IDs`() {
|
||||
val request1 = request("r1", Tier.T2)
|
||||
val request2 = request("r2", Tier.T2)
|
||||
val identity = ApprovalScopeIdentity(SessionId("s1"), null, null)
|
||||
val context = ApprovalContext(identity, ApprovalMode.AUTO)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val d1 = engine.evaluate(request1, context, emptyList(), now)
|
||||
val d2 = engine.evaluate(request2, context, emptyList(), now)
|
||||
|
||||
assertNotEquals(d1.id, d2.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import com.correx.core.context.compression.CompressionStrategy
|
||||
import com.correx.core.context.compression.DefaultContextCompressor
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
|
||||
class ContextCompressionDeterminismTest {
|
||||
|
||||
private val compressor = DefaultContextCompressor()
|
||||
|
||||
private fun entry(
|
||||
id: String,
|
||||
layer: ContextLayer,
|
||||
tokens: Int,
|
||||
content: String = "content-$id",
|
||||
sourceId: String = id
|
||||
) = ContextEntry(
|
||||
id = ContextEntryId(id),
|
||||
layer = layer,
|
||||
content = content,
|
||||
sourceType = "test",
|
||||
sourceId = sourceId,
|
||||
tokenEstimate = tokens
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `same input always produces same output`() {
|
||||
val entries = listOf(
|
||||
entry("a", ContextLayer.L2, 100),
|
||||
entry("b", ContextLayer.L1, 50),
|
||||
entry("c", ContextLayer.L0, 30)
|
||||
)
|
||||
val budget = TokenBudget(limit = 200)
|
||||
val result1 = compressor.compress(entries, budget, CompressionStrategy.Conversation())
|
||||
val result2 = compressor.compress(entries, budget, CompressionStrategy.Conversation())
|
||||
assertEquals(result1, result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `L2 entries are dropped before L1 when over budget`() {
|
||||
val entries = listOf(
|
||||
entry("l2-1", ContextLayer.L2, 80),
|
||||
entry("l1-1", ContextLayer.L1, 80),
|
||||
entry("l0-1", ContextLayer.L0, 80)
|
||||
)
|
||||
val budget = TokenBudget(limit = 170)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
|
||||
assertTrue(result.none { it.layer == ContextLayer.L2 }, "L2 must be dropped first")
|
||||
assertTrue(result.any { it.layer == ContextLayer.L1 }, "L1 must be retained")
|
||||
assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must always be retained")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SteeringNote entries are never dropped regardless of budget`() {
|
||||
val entries = listOf(
|
||||
entry("s1", ContextLayer.L1, 500),
|
||||
entry("s2", ContextLayer.L2, 500)
|
||||
)
|
||||
val budget = TokenBudget(limit = 10)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.SteeringNote)
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EventHistory entries are never dropped`() {
|
||||
val entries = listOf(
|
||||
entry("e1", ContextLayer.L2, 500),
|
||||
entry("e2", ContextLayer.L2, 500)
|
||||
)
|
||||
val budget = TokenBudget(limit = 10)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.EventHistory)
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Artifact strategy keeps latest entry per sourceId`() {
|
||||
val entries = listOf(
|
||||
entry("v1", ContextLayer.L1, 50, sourceId = "artifact-a"),
|
||||
entry("v2", ContextLayer.L1, 50, sourceId = "artifact-a"),
|
||||
entry("v3", ContextLayer.L1, 50, sourceId = "artifact-b")
|
||||
)
|
||||
val budget = TokenBudget(limit = 4000)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.Artifact)
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result.any { it.id == ContextEntryId("v2") }, "Latest artifact-a version must be kept")
|
||||
assertTrue(result.any { it.id == ContextEntryId("v3") }, "artifact-b must be kept")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolLog strategy deduplicates identical content`() {
|
||||
val entries = listOf(
|
||||
entry("t1", ContextLayer.L1, 30, content = "same output"),
|
||||
entry("t2", ContextLayer.L1, 30, content = "same output"),
|
||||
entry("t3", ContextLayer.L1, 30, content = "different output")
|
||||
)
|
||||
val budget = TokenBudget(limit = 4000)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.ToolLog)
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entries fitting within budget are not dropped`() {
|
||||
val entries = listOf(
|
||||
entry("a", ContextLayer.L1, 30),
|
||||
entry("b", ContextLayer.L2, 30)
|
||||
)
|
||||
val budget = TokenBudget(limit = 200)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Conversation keepLast drops oldest entries beyond the limit`() {
|
||||
val entries = (1..5).map { entry("e$it", ContextLayer.L1, 10) }
|
||||
val budget = TokenBudget(limit = 4000)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation(keepLast = 3))
|
||||
assertEquals(3, result.size)
|
||||
assertEquals(listOf("e3", "e4", "e5"), result.map { it.id.value })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Conversation L0 is never truncated when L1 overflows budget`() {
|
||||
val entries = listOf(
|
||||
entry("l0-1", ContextLayer.L0, 60),
|
||||
entry("l1-1", ContextLayer.L1, 60),
|
||||
entry("l1-2", ContextLayer.L1, 60)
|
||||
)
|
||||
val budget = TokenBudget(limit = 100)
|
||||
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
|
||||
assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must never be dropped")
|
||||
assertTrue(result.none { it.id == ContextEntryId("l1-1") }, "Oldest L1 must be dropped first")
|
||||
}
|
||||
|
||||
// Parameterized equivalence test: verifies the refactored running-total trimToFit
|
||||
// produces the same output as the reference naive implementation for varied input shapes.
|
||||
@ParameterizedTest
|
||||
@MethodSource("trimToFitEquivalenceCases")
|
||||
fun `trimToFit running-total matches reference naive implementation`(
|
||||
entries: List<ContextEntry>,
|
||||
budget: TokenBudget
|
||||
) {
|
||||
val expected = naiveTrimToFit(entries, budget)
|
||||
val actual = compressor.compress(entries, budget, CompressionStrategy.Conversation())
|
||||
assertEquals(expected, actual, "running-total trimToFit must match naive O(n²) reference")
|
||||
}
|
||||
|
||||
// Reference O(n²) implementation kept as spec baseline — intentionally not optimized.
|
||||
private fun naiveTrimToFit(entries: List<ContextEntry>, budget: TokenBudget): List<ContextEntry> {
|
||||
val dropOrder = listOf(ContextLayer.L2, ContextLayer.L1)
|
||||
if (entries.sumOf { it.tokenEstimate } <= budget.limit) return entries
|
||||
val mutable = entries.toMutableList()
|
||||
for (layer in dropOrder) {
|
||||
if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break
|
||||
val layerEntries = mutable.filter { it.layer == layer }
|
||||
for (e in layerEntries) {
|
||||
if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break
|
||||
mutable.remove(e)
|
||||
}
|
||||
}
|
||||
return mutable
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun trimToFitEquivalenceCases(): List<Array<Any>> {
|
||||
fun e(id: String, layer: ContextLayer, tokens: Int) =
|
||||
ContextEntry(
|
||||
id = ContextEntryId(id),
|
||||
layer = layer,
|
||||
content = "c-$id",
|
||||
sourceType = "test",
|
||||
sourceId = id,
|
||||
tokenEstimate = tokens
|
||||
)
|
||||
|
||||
return listOf(
|
||||
// All fit — nothing dropped
|
||||
arrayOf(
|
||||
listOf(e("a", ContextLayer.L1, 10), e("b", ContextLayer.L2, 10)),
|
||||
TokenBudget(limit = 100)
|
||||
),
|
||||
// L2 fully dropped, L1 retained
|
||||
arrayOf(
|
||||
listOf(e("x1", ContextLayer.L2, 80), e("x2", ContextLayer.L1, 80), e("x3", ContextLayer.L0, 40)),
|
||||
TokenBudget(limit = 130)
|
||||
),
|
||||
// Both L2 and some L1 dropped
|
||||
arrayOf(
|
||||
(1..5).map { e("l2-$it", ContextLayer.L2, 30) } +
|
||||
(1..5).map { e("l1-$it", ContextLayer.L1, 30) } +
|
||||
listOf(e("l0-1", ContextLayer.L0, 20)),
|
||||
TokenBudget(limit = 80)
|
||||
),
|
||||
// Empty list
|
||||
arrayOf(emptyList<ContextEntry>(), TokenBudget(limit = 50)),
|
||||
// Single entry exactly at budget
|
||||
arrayOf(listOf(e("only", ContextLayer.L2, 50)), TokenBudget(limit = 50))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import com.correx.core.context.builder.DefaultContextPackBuilder
|
||||
import com.correx.core.context.builder.DefaultDecisionPointBuilder
|
||||
import com.correx.core.context.compression.DefaultContextCompressor
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultContextPackBuilderTest {
|
||||
|
||||
private val compressor = DefaultContextCompressor()
|
||||
private val builder = DefaultContextPackBuilder(compressor)
|
||||
private val decisionBuilder = DefaultDecisionPointBuilder()
|
||||
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val packId = ContextPackId("pack-1")
|
||||
|
||||
private fun entry(id: String, layer: ContextLayer, tokens: Int) = ContextEntry(
|
||||
id = ContextEntryId(id),
|
||||
layer = layer,
|
||||
content = "content-$id",
|
||||
sourceType = "test",
|
||||
sourceId = id,
|
||||
tokenEstimate = tokens
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `build groups entries by layer`() {
|
||||
val entries = listOf(
|
||||
entry("a", ContextLayer.L0, 50),
|
||||
entry("b", ContextLayer.L1, 60),
|
||||
entry("c", ContextLayer.L2, 40)
|
||||
)
|
||||
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000))
|
||||
assertEquals(1, pack.layers[ContextLayer.L0]?.size)
|
||||
assertEquals(1, pack.layers[ContextLayer.L1]?.size)
|
||||
assertEquals(1, pack.layers[ContextLayer.L2]?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `budgetUsed reflects total token estimate of retained entries`() {
|
||||
val entries = listOf(
|
||||
entry("a", ContextLayer.L0, 100),
|
||||
entry("b", ContextLayer.L1, 200)
|
||||
)
|
||||
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000))
|
||||
assertEquals(300, pack.budgetUsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pack budgetUsed never exceeds budgetLimit`() {
|
||||
val entries = (1..20).map { entry("e$it", ContextLayer.L2, 100) }
|
||||
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 500))
|
||||
assertTrue(pack.budgetUsed <= 500)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DecisionPointBuilder returns only L0 and L1 entries`() {
|
||||
val entries = listOf(
|
||||
entry("a", ContextLayer.L0, 50),
|
||||
entry("b", ContextLayer.L1, 60),
|
||||
entry("c", ContextLayer.L2, 40)
|
||||
)
|
||||
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000))
|
||||
val decisionEntries = decisionBuilder.build(pack)
|
||||
assertTrue(decisionEntries.all { it.layer == ContextLayer.L0 || it.layer == ContextLayer.L1 })
|
||||
assertEquals(2, decisionEntries.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build handles empty entries list`() {
|
||||
val pack = builder.build(packId, sessionId, stageId, emptyList(), TokenBudget(limit = 1000))
|
||||
assertEquals(0, pack.budgetUsed)
|
||||
assertEquals(0, pack.compressionMetadata.entriesDropped)
|
||||
assertTrue(pack.layers.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steeringNote and eventHistory entries are retained even when exceeding budget`() {
|
||||
val entries = listOf(
|
||||
entry("steering", ContextLayer.L0, 500).copy(sourceType = "steeringNote"),
|
||||
entry("history", ContextLayer.L1, 600).copy(sourceType = "eventHistory"),
|
||||
entry("normal", ContextLayer.L2, 200)
|
||||
)
|
||||
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 800))
|
||||
val allRetained = pack.layers.values.flatten()
|
||||
assertTrue(allRetained.any { it.id == ContextEntryId("steering") }, "steeringNote must be retained")
|
||||
assertTrue(allRetained.any { it.id == ContextEntryId("history") }, "eventHistory must be retained")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionOrdering
|
||||
import com.correx.core.validation.graph.GraphValidator
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.transition.TransitionValidator
|
||||
import com.correx.testing.fixtures.CycleFixtures
|
||||
import com.correx.testing.fixtures.WorkflowFixtures
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GraphValidatorTest {
|
||||
|
||||
private val validator = GraphValidator()
|
||||
|
||||
@Test
|
||||
fun `should detect dangling transition`() {
|
||||
|
||||
val graph = WorkflowFixtures.simpleGraph().copy(
|
||||
stages = mapOf(StageId("A") to StageConfig()) // B removed → dangling
|
||||
)
|
||||
|
||||
val result = validator.validate(
|
||||
ValidationContext(
|
||||
graph = graph,
|
||||
detectedCycles = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
val issues = result.issues
|
||||
|
||||
assertTrue(
|
||||
issues.any { it.code == "GRAPH_DANGLING_TRANSITION" }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should expose cycles as informational only`() {
|
||||
|
||||
val graph = WorkflowFixtures.simpleGraph()
|
||||
val cycles = listOf(CycleFixtures.simpleCycle())
|
||||
|
||||
val validator = GraphValidator()
|
||||
|
||||
val result = validator.validate(
|
||||
ValidationContext(
|
||||
graph = graph,
|
||||
detectedCycles = cycles
|
||||
)
|
||||
)
|
||||
|
||||
val cycleIssues = result.issues
|
||||
.filter { it.code == "GRAPH_CYCLE_DETECTED" }
|
||||
|
||||
assertTrue(cycleIssues.isNotEmpty())
|
||||
}
|
||||
|
||||
class TransitionValidatorTest {
|
||||
|
||||
private val validator = TransitionValidator(
|
||||
ordering = TransitionOrdering.comparator
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `should be deterministic`() {
|
||||
|
||||
val graph = WorkflowFixtures.simpleGraph()
|
||||
|
||||
val result1 = validator.validate(
|
||||
ValidationContext(graph, emptyList())
|
||||
)
|
||||
|
||||
val result2 = validator.validate(
|
||||
ValidationContext(graph, emptyList())
|
||||
)
|
||||
|
||||
assertEquals(result1, result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should flag non-deterministic order when two edges share same from and to`() {
|
||||
|
||||
val a = StageId("A")
|
||||
val b = StageId("B")
|
||||
|
||||
val edge1 = TransitionEdge(
|
||||
id = TransitionId("t1"),
|
||||
from = a,
|
||||
to = b,
|
||||
condition = { true }
|
||||
)
|
||||
|
||||
val edge2 = TransitionEdge(
|
||||
id = TransitionId("t2"),
|
||||
from = a,
|
||||
to = b,
|
||||
condition = { true }
|
||||
)
|
||||
|
||||
val graph = WorkflowGraph(
|
||||
stages = mapOf(a to StageConfig(), b to StageConfig()),
|
||||
transitions = setOf(edge1, edge2),
|
||||
start = a
|
||||
)
|
||||
|
||||
val result = validator.validate(
|
||||
ValidationContext(graph, emptyList())
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
result.issues.any { it.code == "TRANSITION_NON_DETERMINISTIC_ORDER" },
|
||||
"Expected TRANSITION_NON_DETERMINISTIC_ORDER but got: ${result.issues.map { it.code }}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SessionPausedEvent
|
||||
import com.correx.core.events.events.SessionResumedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.datetime.Clock
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionReplayDeterminismTest {
|
||||
|
||||
private fun build(store: EventStore) = DefaultEventReplayer(
|
||||
store,
|
||||
SessionProjector(DefaultSessionReducer())
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `same events produce same state`() {
|
||||
val sessionId = SessionId("s1")
|
||||
|
||||
val store1 = InMemoryEventStore()
|
||||
val store2 = InMemoryEventStore()
|
||||
|
||||
val events = mapOf<EventMetadata, EventPayload>(
|
||||
EventMetadata(EventId("start"), sessionId, Clock.System.now(), 1, null, null) to SessionStartedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(EventId("paused"), sessionId, Clock.System.now(), 1, null, null) to SessionPausedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(EventId("resumed"), sessionId, Clock.System.now(), 1, null, null) to SessionResumedEvent(
|
||||
sessionId
|
||||
)
|
||||
)
|
||||
|
||||
events.forEach { (meta, payload) ->
|
||||
store1.append(NewEvent(meta, payload))
|
||||
store2.append(NewEvent(meta, payload))
|
||||
}
|
||||
|
||||
val replayer1 = build(store1)
|
||||
val replayer2 = build(store2)
|
||||
|
||||
val state1 = replayer1.rebuild(sessionId)
|
||||
val state2 = replayer2.rebuild(sessionId)
|
||||
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
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 TokenBudgetEnforcementTest {
|
||||
|
||||
@Test
|
||||
fun `remaining equals limit minus used`() {
|
||||
val budget = TokenBudget(limit = 1000, used = 300)
|
||||
assertEquals(700, budget.remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canFit returns true when tokens fit`() {
|
||||
val budget = TokenBudget(limit = 500, used = 100)
|
||||
assertTrue(budget.canFit(400))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canFit returns false when over budget`() {
|
||||
val budget = TokenBudget(limit = 500, used = 400)
|
||||
assertFalse(budget.canFit(200))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consume returns new instance with updated used`() {
|
||||
val original = TokenBudget(limit = 1000, used = 0)
|
||||
val after = original.consume(250)
|
||||
assertEquals(0, original.used)
|
||||
assertEquals(250, after.used)
|
||||
assertEquals(750, after.remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fully exhausted budget cannot fit any tokens`() {
|
||||
val budget = TokenBudget(limit = 100, used = 100)
|
||||
assertFalse(budget.canFit(1))
|
||||
assertEquals(0, budget.remaining)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.ValidationOutcome
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
import com.correx.testing.fixtures.WorkflowFixtures
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ValidationPipelineShortCircuitTest {
|
||||
|
||||
private val context = ValidationContext(WorkflowFixtures.simpleGraph())
|
||||
|
||||
private fun errorValidator(name: String) = Validator { _ ->
|
||||
ValidationSection(
|
||||
name = name,
|
||||
issues = listOf(ValidationIssue("ERR", "failure", ValidationSeverity.ERROR))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pipeline stops after first validator with errors and report contains only that section`() {
|
||||
var secondCalled = false
|
||||
val trackingValidator = Validator { _ ->
|
||||
secondCalled = true
|
||||
ValidationSection(name = "second")
|
||||
}
|
||||
|
||||
val pipeline = ValidationPipeline(listOf(errorValidator("first"), trackingValidator))
|
||||
val outcome = pipeline.validate(context) as ValidationOutcome.Rejected
|
||||
|
||||
assertFalse(secondCalled, "second validator must not run after a rejection")
|
||||
assertEquals(1, outcome.report.sections.size)
|
||||
assertEquals("first", outcome.report.sections[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `warnings do not short-circuit the pipeline`() {
|
||||
var secondCalled = false
|
||||
|
||||
val warningValidator = Validator { _ ->
|
||||
ValidationSection(
|
||||
name = "first",
|
||||
issues = listOf(ValidationIssue("WARN", "warning", ValidationSeverity.WARNING))
|
||||
)
|
||||
}
|
||||
val trackingValidator = Validator { _ ->
|
||||
secondCalled = true
|
||||
ValidationSection(name = "second")
|
||||
}
|
||||
|
||||
val pipeline = ValidationPipeline(listOf(warningValidator, trackingValidator))
|
||||
pipeline.validate(context)
|
||||
|
||||
assert(secondCalled) { "warnings must not stop pipeline execution" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:transitions"))
|
||||
implementation(project(":core:approvals"))
|
||||
implementation(project(":core:sessions"))
|
||||
implementation(project(":core:tools"))
|
||||
implementation(project(":core:inference"))
|
||||
implementation(project(":core:context"))
|
||||
implementation(project(":core:kernel"))
|
||||
implementation(project(":core:validation"))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.correx.testing.fixtures
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.RiskSummaryId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationSection
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.Validator
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
fun request(
|
||||
id: String,
|
||||
tier: Tier,
|
||||
timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
) = DomainApprovalRequest(
|
||||
id = ApprovalRequestId(id),
|
||||
tier = tier,
|
||||
validationReportId = ValidationReportId("vr"),
|
||||
riskSummaryId = RiskSummaryId("rs"),
|
||||
timestamp = timestamp,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
userSteering = null,
|
||||
)
|
||||
|
||||
fun cyclePolicyMissingValidator(): Validator = Validator {
|
||||
ValidationSection(name = "cycle policy", issues = listOf(
|
||||
ValidationIssue(
|
||||
severity = ValidationSeverity.WARNING,
|
||||
code = "CYCLE_POLICY_MISSING",
|
||||
message = "forced for testing"
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.correx.testing.fixtures
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.analysis.DetectedCycle
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
|
||||
object CycleFixtures {
|
||||
fun simpleCycle(): DetectedCycle {
|
||||
val a = StageId("A")
|
||||
val b = StageId("B")
|
||||
|
||||
return DetectedCycle(
|
||||
nodes = listOf(a, b),
|
||||
edges = listOf(
|
||||
TransitionEdge(
|
||||
id = TransitionId("t1"),
|
||||
from = a,
|
||||
to = b,
|
||||
condition = { true },
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.correx.testing.fixtures
|
||||
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolInvokedEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
object EventFixtures {
|
||||
fun newEvent(
|
||||
eventId: EventId,
|
||||
sessionId: SessionId = SessionId("s1"),
|
||||
payload: EventPayload = ToolInvokedEvent("write"),
|
||||
timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z")
|
||||
): NewEvent {
|
||||
return NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = eventId,
|
||||
sessionId = sessionId,
|
||||
timestamp = timestamp,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null
|
||||
),
|
||||
payload = payload
|
||||
)
|
||||
}
|
||||
|
||||
fun stored(
|
||||
eventId: EventId = EventId("e1"),
|
||||
payload: EventPayload = ToolInvokedEvent("write"),
|
||||
sessionId: SessionId = SessionId("s1"),
|
||||
sequence: Long = 1L,
|
||||
timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z")
|
||||
): StoredEvent {
|
||||
return StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = eventId,
|
||||
sessionId = sessionId,
|
||||
timestamp = timestamp,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null
|
||||
),
|
||||
sequence = sequence,
|
||||
payload = payload
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.correx.testing.fixtures
|
||||
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.GenerationConfig
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
object InferenceFixtures {
|
||||
|
||||
fun fixedProvider(): InferenceProvider {
|
||||
return MockInferenceProvider(
|
||||
fixedResponse = "Fixed test response",
|
||||
artificialDelayMs = 0,
|
||||
)
|
||||
}
|
||||
|
||||
fun fixedRouter(): InferenceRouter {
|
||||
return object : InferenceRouter {
|
||||
override suspend fun route(
|
||||
stageId: StageId,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
): InferenceProvider {
|
||||
return fixedProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun failingRouter(): InferenceRouter {
|
||||
return object : InferenceRouter {
|
||||
override suspend fun route(
|
||||
stageId: StageId,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
): InferenceProvider {
|
||||
return MockInferenceProvider(forcedFailure = "Simulated failure")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun contextPack(): ContextPack {
|
||||
return ContextPack(
|
||||
id = ContextPackId("cp1"),
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("stage1"),
|
||||
layers = emptyMap(),
|
||||
budgetUsed = 0,
|
||||
budgetLimit = 4096,
|
||||
)
|
||||
}
|
||||
|
||||
fun generationConfig(): GenerationConfig {
|
||||
return GenerationConfig(
|
||||
temperature = 0.7,
|
||||
topP = 1.0,
|
||||
maxTokens = 2048,
|
||||
)
|
||||
}
|
||||
|
||||
fun inferenceCompleted(
|
||||
requestId: InferenceRequestId,
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
providerId: ProviderId,
|
||||
tokensUsed: TokenUsage,
|
||||
latencyMs: Long,
|
||||
): InferenceCompletedEvent {
|
||||
return InferenceCompletedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = providerId,
|
||||
tokensUsed = tokensUsed,
|
||||
latencyMs = latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
fun inferenceRequest(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
requestId: InferenceRequestId? = null,
|
||||
): InferenceRequest {
|
||||
return InferenceRequest(
|
||||
requestId = requestId ?: InferenceRequestId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
contextPack = contextPack(),
|
||||
generationConfig = generationConfig(),
|
||||
)
|
||||
}
|
||||
|
||||
fun inferenceResponse(
|
||||
requestId: InferenceRequestId,
|
||||
text: String = "test response",
|
||||
tokensUsed: TokenUsage = TokenUsage(promptTokens = 100, completionTokens = 200),
|
||||
latencyMs: Long = 500,
|
||||
): InferenceResponse {
|
||||
return InferenceResponse(
|
||||
requestId = requestId,
|
||||
text = text,
|
||||
finishReason = FinishReason.Stop,
|
||||
tokensUsed = tokensUsed,
|
||||
latencyMs = latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
fun newInferenceCompletedEvent(
|
||||
requestId: InferenceRequestId,
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
providerId: ProviderId,
|
||||
tokensUsed: TokenUsage,
|
||||
latencyMs: Long,
|
||||
): NewEvent {
|
||||
return com.correx.core.events.events.NewEvent(
|
||||
metadata = com.correx.core.events.events.EventMetadata(
|
||||
eventId = com.correx.core.events.types.EventId("inf-$requestId"),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = InferenceCompletedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = providerId,
|
||||
tokensUsed = tokensUsed,
|
||||
latencyMs = latencyMs,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.correx.testing.fixtures
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
|
||||
object WorkflowFixtures {
|
||||
|
||||
fun simpleGraph(): WorkflowGraph {
|
||||
val a = StageId("A")
|
||||
val b = StageId("B")
|
||||
|
||||
val t1 = TransitionEdge(
|
||||
id = TransitionId("t1"),
|
||||
from = a,
|
||||
to = b,
|
||||
condition = { true }
|
||||
)
|
||||
|
||||
return WorkflowGraph(
|
||||
stages = mapOf(a to StageConfig(), b to StageConfig()),
|
||||
transitions = setOf(t1),
|
||||
start = a
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.correx.testing.fixtures.context
|
||||
|
||||
import com.correx.core.context.builder.DefaultContextPackBuilder
|
||||
import com.correx.core.context.compression.ContextCompressor
|
||||
import com.correx.core.context.compression.CompressionStrategy
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
|
||||
object ContextFixtures {
|
||||
|
||||
fun simpleCompressor(): ContextCompressor {
|
||||
return object : ContextCompressor {
|
||||
override fun compress(
|
||||
entries: List<ContextEntry>,
|
||||
budget: TokenBudget,
|
||||
strategy: CompressionStrategy
|
||||
): List<ContextEntry> {
|
||||
return entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun simpleBuilder(): DefaultContextPackBuilder {
|
||||
return DefaultContextPackBuilder(simpleCompressor())
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.testing.fixtures.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
|
||||
/**
|
||||
* Configurable fake provider for deterministic tests.
|
||||
*
|
||||
* Modes (mutually exclusive, checked in order):
|
||||
* 1. [forcedFailure] — throws [InferenceProviderException] immediately
|
||||
* 2. [artificialDelayMs] > 0 — suspends before responding (tests timeout/cancel)
|
||||
* 3. default — returns [fixedResponse] immediately
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class MockInferenceProvider(
|
||||
override val id: ProviderId = ProviderId("mock"),
|
||||
override val name: String = "Mock Provider",
|
||||
private val fixedResponse: String = "mock response",
|
||||
private val artificialDelayMs: Long = 0L,
|
||||
private val forcedFailure: String? = null,
|
||||
private val declaredCapabilities: Set<CapabilityScore> = setOf(
|
||||
CapabilityScore(ModelCapability.General, 1.0),
|
||||
),
|
||||
private val health: ProviderHealth = ProviderHealth.Healthy,
|
||||
) : InferenceProvider {
|
||||
|
||||
override val tokenizer: Tokenizer = MockTokenizer()
|
||||
|
||||
var inferCallCount: Int = 0
|
||||
private set
|
||||
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||
inferCallCount++
|
||||
|
||||
if (forcedFailure != null) throw InferenceProviderException(forcedFailure)
|
||||
|
||||
if (artificialDelayMs > 0L) {
|
||||
delay(artificialDelayMs)
|
||||
currentCoroutineContext().ensureActive() // cooperative cancellation checkpoint
|
||||
}
|
||||
|
||||
val usage = TokenUsage(
|
||||
promptTokens = tokenizer.countTokens(request.contextPack.toString()),
|
||||
completionTokens = tokenizer.countTokens(fixedResponse),
|
||||
)
|
||||
|
||||
return InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = fixedResponse,
|
||||
finishReason = FinishReason.Stop,
|
||||
tokensUsed = usage,
|
||||
latencyMs = artificialDelayMs,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun healthCheck(): ProviderHealth = health
|
||||
|
||||
override fun capabilities(): Set<CapabilityScore> = declaredCapabilities
|
||||
}
|
||||
|
||||
class InferenceProviderException(message: String) : Exception(message)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.correx.testing.fixtures.inference
|
||||
|
||||
import com.correx.core.inference.Token
|
||||
import com.correx.core.inference.Tokenizer
|
||||
|
||||
/**
|
||||
* Character-based approximation. Deterministic. Not model-accurate.
|
||||
* Sufficient for contract and budget tests.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
class MockTokenizer : Tokenizer {
|
||||
override suspend fun tokenize(text: String): List<Token> =
|
||||
List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars
|
||||
|
||||
override suspend fun countTokens(text: String): Int =
|
||||
(text.length + 3) / 4
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.correx.testing.fixtures.kernel
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.SessionState
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class MockEventReplayer : EventReplayer<SessionState> {
|
||||
private val states = mutableMapOf<SessionId, SessionState>()
|
||||
|
||||
fun setSessionState(sessionId: SessionId, state: SessionState) {
|
||||
states[sessionId] = state
|
||||
}
|
||||
|
||||
override fun rebuild(sessionId: SessionId): SessionState {
|
||||
return states[sessionId] ?: SessionState(
|
||||
status = SessionStatus.ACTIVE,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
invalidTransitions = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.correx.testing.fixtures.kernel
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.Session
|
||||
import com.correx.core.sessions.SessionState
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class MockSessionRepository {
|
||||
private val sessions = mutableMapOf<SessionId, Session>()
|
||||
fun setSession(sessionId: SessionId, session: Session) {
|
||||
sessions[sessionId] = session
|
||||
}
|
||||
|
||||
fun getSession(sessionId: SessionId): Session {
|
||||
return sessions[sessionId] ?: Session(
|
||||
sessionId = sessionId,
|
||||
state = SessionState(
|
||||
status = SessionStatus.ACTIVE,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
invalidTransitions = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun rebuild(sessionId: SessionId): Session {
|
||||
return getSession(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.correx.testing.fixtures.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
|
||||
class EchoTool : Tool {
|
||||
override val name: String = "echo"
|
||||
override val tier: Tier = Tier.T0
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.correx.testing.fixtures.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
|
||||
class FailingTool : Tool {
|
||||
override val name: String = "failing"
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult =
|
||||
ValidationResult.Invalid("simulated validation failure")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.correx.testing.fixtures.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
|
||||
class RejectedTool : Tool {
|
||||
override val name: String = "rejected"
|
||||
override val tier: Tier = Tier.T4
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.correx.testing.fixtures.transitions
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.evaluation.TransitionConditionEvaluator
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionCondition
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
||||
import com.correx.core.transitions.resolution.TransitionResolver
|
||||
import com.correx.testing.fixtures.WorkflowFixtures
|
||||
|
||||
object TransitionFixtures {
|
||||
|
||||
fun alwaysTrueEvaluator(): TransitionConditionEvaluator {
|
||||
return object : TransitionConditionEvaluator {
|
||||
override fun evaluate(condition: TransitionCondition, context: EvaluationContext): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun simpleResolver(): TransitionResolver {
|
||||
return DefaultTransitionResolver(alwaysTrueEvaluator())
|
||||
}
|
||||
|
||||
fun threeStageGraph(): WorkflowGraph {
|
||||
val a = StageId("A")
|
||||
val b = StageId("B")
|
||||
val c = StageId("C")
|
||||
|
||||
return WorkflowGraph(
|
||||
stages = mapOf(a to StageConfig(), b to StageConfig(), c to StageConfig()),
|
||||
transitions = setOf(
|
||||
TransitionEdge(id = TransitionId("t1"), from = a, to = b, condition = { true }),
|
||||
TransitionEdge(id = TransitionId("t2"), from = b, to = c, condition = { true }),
|
||||
),
|
||||
start = a
|
||||
)
|
||||
}
|
||||
|
||||
fun simpleGraph(): WorkflowGraph = WorkflowFixtures.simpleGraph()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.correx.testing.fixtures.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertInstanceOf
|
||||
|
||||
class MockToolsTest {
|
||||
|
||||
private val request = ToolRequest(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
sessionId = SessionId("s-1"),
|
||||
stageId = StageId("st-1"),
|
||||
toolName = "test"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `EchoTool is T0 and validates any request as Valid`() {
|
||||
val tool = EchoTool()
|
||||
assertEquals(Tier.T0, tool.tier)
|
||||
assertInstanceOf<ValidationResult.Valid>(tool.validateRequest(request))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FailingTool is T2 and validates any request as Invalid`() {
|
||||
val tool = FailingTool()
|
||||
assertEquals(Tier.T2, tool.tier)
|
||||
assertInstanceOf<ValidationResult.Invalid>(tool.validateRequest(request))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RejectedTool is T4 and validates any request as Valid`() {
|
||||
val tool = RejectedTool()
|
||||
assertEquals(Tier.T4, tool.tier)
|
||||
assertInstanceOf<ValidationResult.Valid>(tool.validateRequest(request))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:validation"))
|
||||
testImplementation(project(":core:approvals"))
|
||||
testImplementation(project(":core:context"))
|
||||
testImplementation(project(":core:inference"))
|
||||
testImplementation(project(":core:kernel"))
|
||||
testImplementation(project(":core:risk"))
|
||||
testImplementation(project(":infrastructure:persistence"))
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
testImplementation(project(":testing:kernel"))
|
||||
testImplementation(project(":testing:contracts"))
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.testing.integration
|
||||
|
||||
object Module
|
||||
@@ -0,0 +1,300 @@
|
||||
import com.correx.core.approvals.domain.ApprovalEngine
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
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.execution.RetryPolicy
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.InferenceRepository
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.InferenceState
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||
import com.correx.core.risk.DefaultRiskAssessor
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.validation.approval.ApprovalTrigger
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.testing.fixtures.InferenceFixtures
|
||||
import com.correx.testing.fixtures.context.ContextFixtures
|
||||
import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||
import com.correx.testing.fixtures.transitions.TransitionFixtures
|
||||
import com.correx.testing.fixtures.transitions.TransitionFixtures.threeStageGraph
|
||||
import com.correx.testing.kernel.MockSessionEventReplayer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
class SessionOrchestratorIntegrationTest {
|
||||
private val eventStore = InMemoryEventStore()
|
||||
private val graph = TransitionFixtures.simpleGraph()
|
||||
private val transitionResolver = TransitionFixtures.simpleResolver()
|
||||
private val contextPackBuilder = ContextFixtures.simpleBuilder()
|
||||
private val inferenceRouter = InferenceFixtures.fixedRouter()
|
||||
private val validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator()))
|
||||
private val approvalEngine = DefaultApprovalEngine()
|
||||
private val retryCoordinator = DefaultRetryCoordinator(eventStore)
|
||||
private val sessionReplayer = MockSessionEventReplayer()
|
||||
private val sessionRepository = DefaultSessionRepository(sessionReplayer)
|
||||
val orchestrationReplayer = DefaultEventReplayer(
|
||||
eventStore,
|
||||
OrchestrationProjector(DefaultOrchestrationReducer()),
|
||||
)
|
||||
val orchestrationRepository = OrchestrationRepository(orchestrationReplayer)
|
||||
private val inferenceRepository = InferenceRepository(
|
||||
object : EventReplayer<InferenceState> {
|
||||
override fun rebuild(sessionId: com.correx.core.events.types.SessionId) = InferenceState()
|
||||
},
|
||||
)
|
||||
private val riskAssessor = DefaultRiskAssessor()
|
||||
|
||||
private val repositories = OrchestratorRepositories(
|
||||
eventStore = eventStore,
|
||||
inferenceRepository = inferenceRepository,
|
||||
orchestrationRepository = orchestrationRepository,
|
||||
sessionRepository = sessionRepository,
|
||||
)
|
||||
|
||||
private val engines = OrchestratorEngines(
|
||||
transitionResolver = transitionResolver,
|
||||
contextPackBuilder = contextPackBuilder,
|
||||
inferenceRouter = inferenceRouter,
|
||||
validationPipeline = validationPipeline,
|
||||
approvalEngine = approvalEngine,
|
||||
riskAssessor = riskAssessor,
|
||||
)
|
||||
|
||||
private val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines,
|
||||
retryCoordinator = retryCoordinator,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `successful workflow completes all stages`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s1")
|
||||
val config = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
|
||||
)
|
||||
|
||||
orchestrator.run(sessionId, graph, config)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
assertEquals(2, events.size)
|
||||
|
||||
val workflowStarted = events.find { it.payload is WorkflowStartedEvent }
|
||||
assertNotNull(workflowStarted)
|
||||
assertEquals(
|
||||
StageId("A"),
|
||||
(workflowStarted?.payload as? WorkflowStartedEvent)?.startStageId,
|
||||
)
|
||||
|
||||
val workflowCompleted = events.find { it.payload is WorkflowCompletedEvent }
|
||||
assertNotNull(workflowCompleted)
|
||||
assertEquals(
|
||||
StageId("B"),
|
||||
(workflowCompleted?.payload as WorkflowCompletedEvent).terminalStageId,
|
||||
)
|
||||
assertEquals(1, (workflowCompleted.payload as WorkflowCompletedEvent).totalStages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow fails with retry exhaustion`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s2")
|
||||
val config = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(maxAttempts = 2, backoffMs = 0),
|
||||
)
|
||||
val graph = threeStageGraph()
|
||||
|
||||
val failingOrchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines.copy(
|
||||
inferenceRouter = object : InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>) =
|
||||
MockInferenceProvider(forcedFailure = "Simulated failure")
|
||||
},
|
||||
),
|
||||
retryCoordinator = retryCoordinator,
|
||||
)
|
||||
failingOrchestrator.run(sessionId, graph, config)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
val workflowFailed = events.find { it.payload is WorkflowFailedEvent }
|
||||
assertNotNull(workflowFailed)
|
||||
val failed = workflowFailed?.payload as WorkflowFailedEvent
|
||||
assertEquals("Simulated failure", failed.reason)
|
||||
assertTrue(failed.retryExhausted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow pauses for approval when required`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s3")
|
||||
val config = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
|
||||
)
|
||||
val graph = threeStageGraph()
|
||||
val approvingPipeline = ValidationPipeline(
|
||||
validators = listOf(cyclePolicyMissingValidator()),
|
||||
approvalTrigger = ApprovalTrigger(),
|
||||
)
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines.copy(validationPipeline = approvingPipeline),
|
||||
retryCoordinator = retryCoordinator,
|
||||
)
|
||||
|
||||
orchestrator.run(sessionId, graph, config)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
val orPause = events.find { it.payload is OrchestrationPausedEvent }
|
||||
assertNotNull(orPause)
|
||||
val paused = orPause?.let { it.payload as? OrchestrationPausedEvent }
|
||||
assertEquals("APPROVAL_PENDING", paused?.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow resumes after approval`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s4")
|
||||
val config = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
|
||||
)
|
||||
val graph = threeStageGraph()
|
||||
val yoloApprovalEngine = object : ApprovalEngine {
|
||||
val inner = DefaultApprovalEngine()
|
||||
override fun evaluate(
|
||||
request: DomainApprovalRequest,
|
||||
context: ApprovalContext,
|
||||
grants: List<ApprovalGrant>,
|
||||
now: Instant,
|
||||
) =
|
||||
inner.evaluate(request, ApprovalContext(context.identity, ApprovalMode.YOLO), grants, now)
|
||||
}
|
||||
|
||||
val approvingPipeline = ValidationPipeline(
|
||||
validators = listOf(cyclePolicyMissingValidator()),
|
||||
approvalTrigger = ApprovalTrigger(),
|
||||
)
|
||||
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines.copy(
|
||||
validationPipeline = approvingPipeline,
|
||||
approvalEngine = yoloApprovalEngine,
|
||||
),
|
||||
retryCoordinator = retryCoordinator,
|
||||
)
|
||||
|
||||
orchestrator.run(sessionId, graph, config)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
val resumeCount = events.count { it.payload is OrchestrationResumedEvent }
|
||||
assertEquals(1, resumeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow with empty graph fails immediately`(): Unit = runBlocking {
|
||||
val emptyGraph = WorkflowGraph(
|
||||
stages = mapOf(StageId("start") to StageConfig()),
|
||||
transitions = emptySet(),
|
||||
start = StageId("start"),
|
||||
)
|
||||
val config = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
|
||||
)
|
||||
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines,
|
||||
retryCoordinator = retryCoordinator,
|
||||
)
|
||||
|
||||
val sessionId = SessionId("s6")
|
||||
orchestrator.run(sessionId, emptyGraph, config)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
val workflowFailed = events.find { it.payload is WorkflowFailedEvent }
|
||||
assertNotNull(workflowFailed)
|
||||
val failed = workflowFailed?.payload as WorkflowFailedEvent
|
||||
assertNotNull(failed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `run throws IllegalArgumentException when transition targets undeclared stage`(): Unit = runBlocking {
|
||||
val a = StageId("A")
|
||||
val b = StageId("B")
|
||||
val ghost = StageId("ghost")
|
||||
val z = StageId("Z")
|
||||
// ghost is missing from stages map but has an outgoing transition so it is non-terminal
|
||||
val brokenGraph = WorkflowGraph(
|
||||
stages = mapOf(a to StageConfig(), b to StageConfig()),
|
||||
transitions = setOf(
|
||||
com.correx.core.transitions.graph.TransitionEdge(
|
||||
id = com.correx.core.events.types.TransitionId("t1"), from = a, to = b,
|
||||
condition = { true },
|
||||
),
|
||||
com.correx.core.transitions.graph.TransitionEdge(
|
||||
id = com.correx.core.events.types.TransitionId("t2"), from = b, to = ghost,
|
||||
condition = { true },
|
||||
),
|
||||
com.correx.core.transitions.graph.TransitionEdge(
|
||||
id = com.correx.core.events.types.TransitionId("t3"), from = ghost, to = z,
|
||||
condition = { true },
|
||||
),
|
||||
),
|
||||
start = a,
|
||||
)
|
||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||
val sessionId = SessionId("s7")
|
||||
|
||||
val ex = org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException::class.java) {
|
||||
kotlinx.coroutines.runBlocking { orchestrator.run(sessionId, brokenGraph, config) }
|
||||
}
|
||||
assertTrue(ex.message?.contains("ghost") == true, "Expected message to mention 'ghost', got: ${ex.message}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowStartedEvent carries retryPolicy from config`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s8")
|
||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 5, backoffMs = 0))
|
||||
|
||||
orchestrator.run(sessionId, graph, config)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
val started = events.first { it.payload is WorkflowStartedEvent }.payload as WorkflowStartedEvent
|
||||
assertEquals(5, started.retryPolicy?.maxAttempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OrchestrationState retryPolicy reflects config after workflow starts`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s9")
|
||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 7, backoffMs = 0))
|
||||
|
||||
orchestrator.run(sessionId, graph, config)
|
||||
|
||||
val state = orchestrationRepository.getState(sessionId)
|
||||
assertEquals(7, state.retryPolicy?.maxAttempts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionOrdering
|
||||
import com.correx.core.validation.approval.ApprovalTrigger
|
||||
import com.correx.core.validation.graph.GraphValidator
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.pipeline.ValidationOutcome
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.core.validation.semantic.SemanticValidator
|
||||
import com.correx.core.validation.semantic.rules.CyclePolicyBindingRule
|
||||
import com.correx.core.validation.transition.TransitionValidator
|
||||
import com.correx.testing.fixtures.CycleFixtures
|
||||
import com.correx.testing.fixtures.WorkflowFixtures
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ValidationPipelineIntegrationTest {
|
||||
|
||||
private val pipeline = ValidationPipeline(
|
||||
validators = listOf(
|
||||
GraphValidator(),
|
||||
TransitionValidator(TransitionOrdering.comparator),
|
||||
SemanticValidator(
|
||||
rules = listOf(
|
||||
CyclePolicyBindingRule(requirePolicyForCycles = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
approvalTrigger = ApprovalTrigger()
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `full validation pipeline returns NeedsApproval when cycles are unbound`() {
|
||||
|
||||
val graph = WorkflowFixtures.simpleGraph()
|
||||
|
||||
val cycles = listOf(CycleFixtures.simpleCycle())
|
||||
|
||||
val context = ValidationContext(
|
||||
graph = graph,
|
||||
detectedCycles = cycles,
|
||||
cyclePolicies = emptySet(),
|
||||
)
|
||||
|
||||
val outcome = pipeline.validate(context)
|
||||
|
||||
assertInstanceOf(ValidationOutcome.NeedsApproval::class.java, outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected outcome retryable is false — set by validator, not orchestrator`() {
|
||||
// A dangling transition triggers GraphValidator ERROR → Rejected(retryable=false).
|
||||
// Ownership rule: the validator sets retryable; the orchestrator must only read it.
|
||||
val a = StageId("A")
|
||||
val ghost = StageId("GHOST")
|
||||
val graphWithDanglingTransition = WorkflowGraph(
|
||||
stages = mapOf(a to StageConfig()),
|
||||
transitions = setOf(TransitionEdge(TransitionId("t1"), from = a, to = ghost) { true }),
|
||||
start = a,
|
||||
)
|
||||
val pipeline = ValidationPipeline(validators = listOf(GraphValidator()), approvalTrigger = null)
|
||||
val outcome = pipeline.validate(ValidationContext(graph = graphWithDanglingTransition))
|
||||
|
||||
assertInstanceOf(ValidationOutcome.Rejected::class.java, outcome)
|
||||
assertFalse((outcome as ValidationOutcome.Rejected).retryable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:kernel"))
|
||||
implementation(project(":core:sessions"))
|
||||
implementation(project(":core:inference"))
|
||||
implementation project(':testing:fixtures')
|
||||
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test"
|
||||
testImplementation project(':infrastructure:persistence')
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.correx.testing.kernel
|
||||
|
||||
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.sessions.SessionState
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class MockOrchestrationEventReplayer : EventReplayer<OrchestrationState> {
|
||||
private val states = mutableMapOf<SessionId, OrchestrationState>()
|
||||
|
||||
fun setSessionState(sessionId: SessionId, state: OrchestrationState) {
|
||||
states[sessionId] = state
|
||||
}
|
||||
|
||||
override fun rebuild(sessionId: SessionId): OrchestrationState {
|
||||
return states[sessionId] ?: OrchestrationState(
|
||||
status = OrchestrationStatus.RUNNING,
|
||||
currentStageId = StageId("stage-1"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MockSessionEventReplayer : EventReplayer<SessionState> {
|
||||
private val states = mutableMapOf<SessionId, SessionState>()
|
||||
|
||||
fun setSessionState(sessionId: SessionId, state: SessionState) {
|
||||
states[sessionId] = state
|
||||
}
|
||||
|
||||
override fun rebuild(sessionId: SessionId): SessionState {
|
||||
return states[sessionId] ?: SessionState(
|
||||
status = SessionStatus.ACTIVE,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
invalidTransitions = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.currentTime
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.*
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DefaultRetryCoordinatorTest {
|
||||
|
||||
private lateinit var eventStore: EventStore
|
||||
private lateinit var retryCoordinator: DefaultRetryCoordinator
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
eventStore = InMemoryEventStore()
|
||||
retryCoordinator = DefaultRetryCoordinator(eventStore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return true when currentAttempt is 0 and maxAttempts is 3`() = runTest {
|
||||
val sessionId = SessionId(UUID.randomUUID().toString())
|
||||
val stageId = StageId("stage1")
|
||||
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
|
||||
|
||||
val result = retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 0,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
Assertions.assertTrue(result)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
Assertions.assertEquals(1, events.size)
|
||||
val event = events.first().payload as RetryAttemptedEvent
|
||||
Assertions.assertEquals(1, event.attemptNumber)
|
||||
Assertions.assertEquals(stageId, event.stageId)
|
||||
Assertions.assertEquals("test failure", event.failureReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return true when currentAttempt is 2 and maxAttempts is 3`() = runTest {
|
||||
val sessionId = SessionId(UUID.randomUUID().toString())
|
||||
val stageId = StageId("stage1")
|
||||
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
|
||||
|
||||
// First retry
|
||||
retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 0,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
// Second retry (currentAttempt = 2, which is the third attempt)
|
||||
val result = retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 2,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
Assertions.assertTrue(result)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
Assertions.assertEquals(2, events.size)
|
||||
val lastEvent = events.last().payload as RetryAttemptedEvent
|
||||
Assertions.assertEquals(3, lastEvent.attemptNumber)
|
||||
Assertions.assertEquals("test failure", lastEvent.failureReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return false when currentAttempt equals maxAttempts`() = runTest {
|
||||
val sessionId = SessionId(UUID.randomUUID().toString())
|
||||
val stageId = StageId("stage1")
|
||||
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
|
||||
|
||||
// First two retries
|
||||
retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 0,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 1,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
// currentAttempt = 3 equals maxAttempts = 3, should not retry
|
||||
val result = retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 3,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
Assertions.assertFalse(result)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
Assertions.assertEquals(2, events.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return false when currentAttempt is greater than maxAttempts`() = runTest {
|
||||
val sessionId = SessionId(UUID.randomUUID().toString())
|
||||
val stageId = StageId("stage1")
|
||||
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
|
||||
|
||||
// First retry
|
||||
retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 0,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
// currentAttempt = 5 > maxAttempts = 3, should not retry
|
||||
val result = retryCoordinator.shouldRetry(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
currentAttempt = 5,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
Assertions.assertFalse(result)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
Assertions.assertEquals(1, events.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `should delay by backoffMs before retrying`() = runTest {
|
||||
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 1000L)
|
||||
|
||||
retryCoordinator.shouldRetry(
|
||||
sessionId = SessionId(UUID.randomUUID().toString()),
|
||||
stageId = StageId("stage1"),
|
||||
currentAttempt = 0,
|
||||
policy = policy,
|
||||
failureReason = "test failure",
|
||||
)
|
||||
|
||||
// virtual time should have advanced by backoffMs
|
||||
assertEquals(1000L, currentTime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.kernel.replay.ReplayInferenceProvider
|
||||
import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.testing.fixtures.InferenceFixtures
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class ReplayInferenceProviderTest {
|
||||
|
||||
private lateinit var eventStore: InMemoryEventStore
|
||||
private lateinit var replayProvider: ReplayInferenceProvider
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
eventStore = InMemoryEventStore()
|
||||
replayProvider = ReplayInferenceProvider(eventStore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response carries correct tokensUsed and latencyMs when matching stageId`() = runTest {
|
||||
val sessionId = SessionId("s1")
|
||||
val stageId = StageId("stage1")
|
||||
val requestId = InferenceRequestId("req1")
|
||||
val providerId = ProviderId("test-provider")
|
||||
val tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 200)
|
||||
val latencyMs = 500L
|
||||
|
||||
// Record an inference completed event
|
||||
eventStore.append(
|
||||
InferenceFixtures.newInferenceCompletedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = providerId,
|
||||
tokensUsed = tokensUsed,
|
||||
latencyMs = latencyMs,
|
||||
),
|
||||
)
|
||||
|
||||
val request = InferenceFixtures.inferenceRequest(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
requestId = requestId,
|
||||
)
|
||||
|
||||
val response = replayProvider.infer(request)
|
||||
|
||||
Assertions.assertEquals(request.requestId, response.requestId)
|
||||
Assertions.assertEquals(tokensUsed, response.tokensUsed)
|
||||
Assertions.assertEquals(latencyMs, response.latencyMs)
|
||||
Assertions.assertEquals("", response.text)
|
||||
Assertions.assertEquals(FinishReason.Stop, response.finishReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReplayArtifactMissingException thrown when event for different stageId`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s1")
|
||||
val stageId1 = StageId("stage1")
|
||||
val stageId2 = StageId("stage2")
|
||||
val requestId = InferenceRequestId("req1")
|
||||
val providerId = ProviderId("test-provider")
|
||||
|
||||
// Record event for stage1
|
||||
eventStore.append(
|
||||
InferenceFixtures.newInferenceCompletedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId1,
|
||||
providerId = providerId,
|
||||
tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 200),
|
||||
latencyMs = 500L,
|
||||
),
|
||||
)
|
||||
|
||||
val request = InferenceFixtures.inferenceRequest(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId2, // Different stage
|
||||
requestId = requestId,
|
||||
)
|
||||
|
||||
assertThrows<ReplayArtifactMissingException> {
|
||||
replayProvider.infer(request)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReplayArtifactMissingException thrown when event store is empty`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("s1")
|
||||
val stageId = StageId("stage1")
|
||||
val requestId = InferenceRequestId("req1")
|
||||
|
||||
val request = InferenceFixtures.inferenceRequest(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
requestId = requestId,
|
||||
)
|
||||
|
||||
assertThrows<ReplayArtifactMissingException> {
|
||||
replayProvider.infer(request)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text is empty string by design`() = runTest {
|
||||
val sessionId = SessionId("s1")
|
||||
val stageId = StageId("stage1")
|
||||
val requestId = InferenceRequestId("req1")
|
||||
val providerId = ProviderId("test-provider")
|
||||
|
||||
eventStore.append(
|
||||
InferenceFixtures.newInferenceCompletedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = providerId,
|
||||
tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 50),
|
||||
latencyMs = 300L,
|
||||
),
|
||||
)
|
||||
|
||||
val request = InferenceFixtures.inferenceRequest(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
requestId = requestId,
|
||||
)
|
||||
|
||||
val response = replayProvider.infer(request)
|
||||
|
||||
Assertions.assertEquals("", response.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capabilities returns empty set`() {
|
||||
val capabilities = replayProvider.capabilities()
|
||||
|
||||
Assertions.assertTrue(capabilities.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `healthCheck returns Healthy`() = runTest {
|
||||
val health = replayProvider.healthCheck()
|
||||
|
||||
Assertions.assertEquals(ProviderHealth.Healthy, health)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `id is ReplayProvider`() {
|
||||
Assertions.assertEquals(ProviderId("replay-provider"), replayProvider.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:context"))
|
||||
testImplementation(project(":core:inference"))
|
||||
testImplementation(project(":core:kernel"))
|
||||
testImplementation(project(":core:artifacts"))
|
||||
implementation(project(":testing:fixtures"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.testing.projections
|
||||
|
||||
object Module
|
||||
@@ -0,0 +1,128 @@
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||
import com.correx.core.events.events.ArtifactArchivedEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactRejectedEvent
|
||||
import com.correx.core.events.events.ArtifactRelationshipAddedEvent
|
||||
import com.correx.core.events.events.ArtifactSupersededEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ArtifactLifecyclePhase
|
||||
import com.correx.core.events.types.ArtifactRelationshipType
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ArtifactReducerTest {
|
||||
|
||||
private lateinit var reducer: DefaultArtifactReducer
|
||||
|
||||
private val artifactId = ArtifactId("art1")
|
||||
private val sessionId = SessionId("sess1")
|
||||
private val stageId = StageId("stage1")
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
reducer = DefaultArtifactReducer()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CREATED to VALIDATING is valid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED)
|
||||
val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(ArtifactLifecyclePhase.VALIDATING, result.getOrThrow().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VALIDATING to VALIDATED is valid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATING)
|
||||
val event = stored(payload = ArtifactValidatedEvent(artifactId, sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(ArtifactLifecyclePhase.VALIDATED, result.getOrThrow().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VALIDATING to REJECTED is valid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATING)
|
||||
val event = stored(payload = ArtifactRejectedEvent(artifactId, sessionId, stageId, "schema mismatch"))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(ArtifactLifecyclePhase.REJECTED, result.getOrThrow().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VALIDATED to SUPERSEDED is valid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED)
|
||||
val event = stored(payload = ArtifactSupersededEvent(artifactId, ArtifactId("art2"), sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(ArtifactLifecyclePhase.SUPERSEDED, result.getOrThrow().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VALIDATED to ARCHIVED is valid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED)
|
||||
val event = stored(payload = ArtifactArchivedEvent(artifactId, sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(ArtifactLifecyclePhase.ARCHIVED, result.getOrThrow().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `REJECTED to ARCHIVED is valid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.REJECTED)
|
||||
val event = stored(payload = ArtifactArchivedEvent(artifactId, sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(ArtifactLifecyclePhase.ARCHIVED, result.getOrThrow().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CREATED to VALIDATED is invalid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED)
|
||||
val event = stored(payload = ArtifactValidatedEvent(artifactId, sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is IllegalStateException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VALIDATED to VALIDATING is invalid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED)
|
||||
val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is IllegalStateException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ARCHIVED to anything is invalid`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.ARCHIVED)
|
||||
val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId))
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is IllegalStateException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relationship added event appends to lineage`() {
|
||||
val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED)
|
||||
val event = stored(
|
||||
payload = ArtifactRelationshipAddedEvent(
|
||||
artifactId, ArtifactId("art2"), ArtifactRelationshipType.DERIVED_FROM, sessionId
|
||||
)
|
||||
)
|
||||
val result = reducer.reduce(state, event)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(1, result.getOrThrow().lineage.relationships.size)
|
||||
assertEquals(ArtifactRelationshipType.DERIVED_FROM, result.getOrThrow().lineage.relationships.first().type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import com.correx.core.context.ContextProjector
|
||||
import com.correx.core.context.DefaultContextReducer
|
||||
import com.correx.core.events.events.ContextBuildingStartedEvent
|
||||
import com.correx.core.events.events.ContextPackBuiltEvent
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ContextProjectorTest {
|
||||
|
||||
private val projector = ContextProjector(DefaultContextReducer())
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val packId = ContextPackId("pack-1")
|
||||
|
||||
@Test
|
||||
fun `initial state has no packs and is not building`() {
|
||||
val state = projector.initial()
|
||||
assertEquals(0, state.builtPackIds.size)
|
||||
assertEquals(false, state.buildingInProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay is deterministic`() {
|
||||
val events = listOf(
|
||||
stored(payload = ContextBuildingStartedEvent(sessionId, stageId)),
|
||||
stored(payload = ContextPackBuiltEvent(packId, sessionId, stageId, 100, 4000))
|
||||
)
|
||||
val state1 = events.fold(projector.initial()) { s, e -> projector.apply(s, e) }
|
||||
val state2 = events.fold(projector.initial()) { s, e -> projector.apply(s, e) }
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import com.correx.core.context.DefaultContextReducer
|
||||
import com.correx.core.context.state.ContextState
|
||||
import com.correx.core.events.events.ContextBuildingFailedEvent
|
||||
import com.correx.core.events.events.ContextBuildingInterruptedEvent
|
||||
import com.correx.core.events.events.ContextBuildingStartedEvent
|
||||
import com.correx.core.events.events.ContextPackBuiltEvent
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultContextReducerTest {
|
||||
|
||||
private val reducer = DefaultContextReducer()
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val packId = ContextPackId("pack-1")
|
||||
|
||||
@Test
|
||||
fun `ContextBuildingStartedEvent sets buildingInProgress to true`() {
|
||||
val state = reducer.reduce(
|
||||
ContextState(),
|
||||
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
|
||||
)
|
||||
assertTrue(state.buildingInProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContextPackBuiltEvent records pack and clears buildingInProgress`() {
|
||||
val started = reducer.reduce(
|
||||
ContextState(),
|
||||
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
|
||||
)
|
||||
val built = reducer.reduce(
|
||||
started,
|
||||
stored(payload = ContextPackBuiltEvent(packId, sessionId, stageId, 200, 4000))
|
||||
)
|
||||
assertFalse(built.buildingInProgress)
|
||||
assertEquals(1, built.builtPackIds.size)
|
||||
assertTrue(built.builtPackIds.contains(packId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContextBuildingFailedEvent clears buildingInProgress`() {
|
||||
val started = reducer.reduce(
|
||||
ContextState(),
|
||||
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
|
||||
)
|
||||
val failed = reducer.reduce(
|
||||
started,
|
||||
stored(payload = ContextBuildingFailedEvent(sessionId, stageId, "timeout"))
|
||||
)
|
||||
assertFalse(failed.buildingInProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContextBuildingInterruptedEvent clears buildingInProgress and sets interrupted`() {
|
||||
val started = reducer.reduce(
|
||||
ContextState(),
|
||||
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
|
||||
)
|
||||
val interrupted = reducer.reduce(
|
||||
started,
|
||||
stored(payload = ContextBuildingInterruptedEvent(sessionId, stageId))
|
||||
)
|
||||
assertFalse(interrupted.buildingInProgress)
|
||||
assertTrue(interrupted.interrupted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContextBuildingFailedEvent does not set interrupted`() {
|
||||
val started = reducer.reduce(
|
||||
ContextState(),
|
||||
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
|
||||
)
|
||||
val failed = reducer.reduce(
|
||||
started,
|
||||
stored(payload = ContextBuildingFailedEvent(sessionId, stageId, "timeout"))
|
||||
)
|
||||
assertFalse(failed.interrupted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated events leave state unchanged`() {
|
||||
val state = ContextState()
|
||||
val after = reducer.reduce(state, stored())
|
||||
assertEquals(state, after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import com.correx.core.events.events.SessionCompletedEvent
|
||||
import com.correx.core.events.events.SessionFailedEvent
|
||||
import com.correx.core.events.events.SessionPausedEvent
|
||||
import com.correx.core.events.events.SessionResumedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StageStartedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionState
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultSessionReducerTest {
|
||||
|
||||
private val reducer = DefaultSessionReducer()
|
||||
|
||||
private val sessionId = SessionId("session-1")
|
||||
|
||||
@Test
|
||||
fun `SessionStartedEvent sets ACTIVE status`() {
|
||||
val state = initialState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = SessionStartedEvent(sessionId)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.ACTIVE, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionPausedEvent sets PAUSED status`() {
|
||||
val state = activeState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = SessionPausedEvent(sessionId)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.PAUSED, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionResumedEvent sets ACTIVE status`() {
|
||||
val state = pausedState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = SessionResumedEvent(sessionId)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.ACTIVE, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionCompletedEvent sets COMPLETED status`() {
|
||||
val state = activeState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = SessionCompletedEvent(sessionId)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.COMPLETED, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionFailedEvent sets FAILED status`() {
|
||||
val state = activeState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = SessionFailedEvent(sessionId)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.FAILED, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageStartedEvent sets ACTIVE status`() {
|
||||
val state = pausedState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = StageStartedEvent(
|
||||
sessionId,
|
||||
stageId = StageId("stage-a"),
|
||||
transitionId = TransitionId("transition-a")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.ACTIVE, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageCompletedEvent sets ACTIVE status`() {
|
||||
val state = pausedState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = StageCompletedEvent(
|
||||
sessionId,
|
||||
stageId = StageId("stage-a"),
|
||||
transitionId = TransitionId("transition-a")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.ACTIVE, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageFailedEvent sets FAILED status`() {
|
||||
val state = activeState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = StageFailedEvent(
|
||||
sessionId,
|
||||
stageId = StageId("stage-a"),
|
||||
transitionId = TransitionId("transition-a"),
|
||||
reason = "boom"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.FAILED, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TransitionExecutedEvent sets ACTIVE status`() {
|
||||
val state = pausedState()
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
sessionId = sessionId,
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId,
|
||||
from = StageId("stage-a"),
|
||||
to = StageId("stage-b"),
|
||||
transitionId = TransitionId("transition-a"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(SessionStatus.ACTIVE, result.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createdAt is initialized once`() {
|
||||
val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = initialState(),
|
||||
event = stored(
|
||||
payload = SessionStartedEvent(sessionId),
|
||||
timestamp = timestamp
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(timestamp, result.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createdAt is preserved after initialization`() {
|
||||
val createdAt = Instant.parse("2026-01-01T00:00:00Z")
|
||||
val updatedAt = Instant.parse("2026-01-02T00:00:00Z")
|
||||
|
||||
val state = activeState().copy(
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = state,
|
||||
event = stored(
|
||||
payload = SessionPausedEvent(sessionId),
|
||||
timestamp = updatedAt
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(createdAt, result.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updatedAt always reflects latest event timestamp`() {
|
||||
val timestamp = Instant.parse("2026-01-02T00:00:00Z")
|
||||
|
||||
val result = reducer.reduce(
|
||||
state = activeState(),
|
||||
event = stored(
|
||||
payload = SessionPausedEvent(sessionId),
|
||||
timestamp = timestamp
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(timestamp, result.updatedAt)
|
||||
}
|
||||
|
||||
private fun initialState() =
|
||||
SessionState(
|
||||
status = SessionStatus.CREATED
|
||||
)
|
||||
|
||||
private fun activeState() =
|
||||
SessionState(
|
||||
status = SessionStatus.ACTIVE
|
||||
)
|
||||
|
||||
private fun pausedState() =
|
||||
SessionState(
|
||||
status = SessionStatus.PAUSED
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.InferenceProjector
|
||||
import com.correx.core.inference.InferenceRecord
|
||||
import com.correx.core.inference.InferenceState
|
||||
import com.correx.core.inference.InferenceStatus
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class InferenceProjectorTest {
|
||||
|
||||
private lateinit var projector: InferenceProjector
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
projector = InferenceProjector()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should initialize state with empty records`() {
|
||||
val initialState = projector.initial()
|
||||
assertEquals(0, initialState.records.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start a new record on InferenceStartedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val event = stored(
|
||||
payload = InferenceStartedEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
),
|
||||
)
|
||||
val newState = projector.apply(initialState, event)
|
||||
|
||||
assertEquals(1, newState.records.size)
|
||||
val record = newState.records.first()
|
||||
assertEquals(InferenceStatus.STARTED, record.status)
|
||||
assertEquals("req1", record.requestId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should complete a record on InferenceCompletedEvent`() {
|
||||
val startedRecord = InferenceRecord(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
status = InferenceStatus.STARTED,
|
||||
)
|
||||
val initialState = InferenceState(listOf(startedRecord))
|
||||
|
||||
val event = stored(
|
||||
payload = InferenceCompletedEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
tokensUsed = TokenUsage(50, 50),
|
||||
latencyMs = 500L,
|
||||
),
|
||||
)
|
||||
val newState = projector.apply(initialState, event)
|
||||
|
||||
assertEquals(1, newState.records.size)
|
||||
val record = newState.records.first()
|
||||
assertEquals(InferenceStatus.COMPLETED, record.status)
|
||||
assertEquals(100, record.tokensUsed?.totalTokens)
|
||||
assertEquals(500L, record.latencyMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle multiple events correctly`() {
|
||||
// 1. Start
|
||||
val initialState = projector.initial()
|
||||
val startEvent = stored(
|
||||
payload = InferenceStartedEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
),
|
||||
)
|
||||
var currentState = projector.apply(initialState, startEvent)
|
||||
assertEquals(1, currentState.records.size)
|
||||
|
||||
// 2. Complete
|
||||
val completeEvent = stored(
|
||||
payload = InferenceCompletedEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
tokensUsed = TokenUsage(100, 100),
|
||||
latencyMs = 1000L,
|
||||
),
|
||||
)
|
||||
currentState = projector.apply(currentState, completeEvent)
|
||||
assertEquals(1, currentState.records.size)
|
||||
assertEquals(InferenceStatus.COMPLETED, currentState.records.first().status)
|
||||
assertEquals(200, currentState.records.first().tokensUsed?.totalTokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.ModelLoadedEvent
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.DefaultInferenceReducer
|
||||
import com.correx.core.inference.InferenceRecord
|
||||
import com.correx.core.inference.InferenceReducer
|
||||
import com.correx.core.inference.InferenceState
|
||||
import com.correx.core.inference.InferenceStatus
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class InferenceReducerTest {
|
||||
|
||||
private lateinit var reducer: InferenceReducer
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
reducer = DefaultInferenceReducer()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start a new record on InferenceStartedEvent`() {
|
||||
val initialState = InferenceState(emptyList())
|
||||
val event = stored(
|
||||
payload = InferenceStartedEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
),
|
||||
)
|
||||
val newState = reducer.reduce(initialState, event)
|
||||
|
||||
assertEquals(1, newState.records.size)
|
||||
val record = newState.records.first()
|
||||
assertEquals(InferenceStatus.STARTED, record.status)
|
||||
assertEquals("req1", record.requestId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should complete a record on InferenceCompletedEvent`() {
|
||||
val startedRecord = InferenceRecord(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
status = InferenceStatus.STARTED,
|
||||
)
|
||||
val initialState = InferenceState(listOf(startedRecord))
|
||||
|
||||
val event = stored(
|
||||
payload = InferenceCompletedEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
tokensUsed = TokenUsage(50, 50),
|
||||
latencyMs = 500L,
|
||||
),
|
||||
)
|
||||
val newState = reducer.reduce(initialState, event)
|
||||
|
||||
assertEquals(1, newState.records.size)
|
||||
val record = newState.records.first()
|
||||
assertEquals(InferenceStatus.COMPLETED, record.status)
|
||||
assertEquals(100, record.tokensUsed?.totalTokens)
|
||||
assertEquals(500L, record.latencyMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should fail a record on InferenceFailedEvent`() {
|
||||
val startedRecord = InferenceRecord(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
status = InferenceStatus.STARTED,
|
||||
)
|
||||
val initialState = InferenceState(listOf(startedRecord))
|
||||
|
||||
val event = stored(
|
||||
payload = InferenceFailedEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
reason = "Network failure",
|
||||
),
|
||||
)
|
||||
val newState = reducer.reduce(initialState, event)
|
||||
|
||||
assertEquals(1, newState.records.size)
|
||||
val record = newState.records.first()
|
||||
assertEquals(InferenceStatus.FAILED, record.status)
|
||||
assertEquals("Network failure", record.failureReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should timeout a record on InferenceTimeoutEvent`() {
|
||||
val startedRecord = InferenceRecord(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
status = InferenceStatus.STARTED,
|
||||
)
|
||||
val initialState = InferenceState(listOf(startedRecord))
|
||||
|
||||
val event = stored(
|
||||
payload = InferenceTimeoutEvent(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
timeoutMs = 10000L,
|
||||
),
|
||||
)
|
||||
val newState = reducer.reduce(initialState, event)
|
||||
|
||||
assertEquals(1, newState.records.size)
|
||||
val record = newState.records.first()
|
||||
assertEquals(InferenceStatus.TIMED_OUT, record.status)
|
||||
assertEquals(10000L, record.latencyMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should pass state unchanged on unrelated events`() {
|
||||
val startedRecord = InferenceRecord(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
status = InferenceStatus.STARTED,
|
||||
)
|
||||
val initialState = InferenceState(listOf(startedRecord))
|
||||
|
||||
val event = stored(
|
||||
payload = ModelLoadedEvent(
|
||||
sessionId = SessionId("sess1"),
|
||||
providerId = ProviderId("providerX"),
|
||||
modelId = "model",
|
||||
),
|
||||
)
|
||||
val newState = reducer.reduce(initialState, event)
|
||||
|
||||
assertEquals(1, newState.records.size)
|
||||
assertEquals(InferenceStatus.STARTED, newState.records.first().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not modify record if requestId does not match`() {
|
||||
val startedRecord = InferenceRecord(
|
||||
requestId = InferenceRequestId("req1"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
status = InferenceStatus.STARTED,
|
||||
)
|
||||
val initialState = InferenceState(listOf(startedRecord))
|
||||
|
||||
// Event for a different request
|
||||
val event = stored(
|
||||
payload = InferenceCompletedEvent(
|
||||
requestId = InferenceRequestId("req2"),
|
||||
sessionId = SessionId("sess1"),
|
||||
stageId = StageId("stageA"),
|
||||
providerId = ProviderId("providerX"),
|
||||
tokensUsed = TokenUsage(50, 50),
|
||||
latencyMs = 500L,
|
||||
),
|
||||
)
|
||||
val newState = reducer.reduce(initialState, event)
|
||||
|
||||
// The record should remain unchanged
|
||||
assertEquals(1, newState.records.size)
|
||||
assertEquals(InferenceStatus.STARTED, newState.records.first().status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class OrchestrationProjectorTest {
|
||||
|
||||
private val reducer = DefaultOrchestrationReducer()
|
||||
private val projector = OrchestrationProjector(reducer)
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
|
||||
@Test
|
||||
fun `should initialize orchestration with IDLE status`() {
|
||||
val initialState = projector.initial()
|
||||
assertEquals(OrchestrationStatus.IDLE, initialState.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set RUNNING on WorkflowStartedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
assertEquals(OrchestrationStatus.RUNNING, started.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set RUNNING after resume on pause`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
assertEquals(OrchestrationStatus.RUNNING, started.status)
|
||||
val paused =
|
||||
projector.apply(started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Tool approval")))
|
||||
assertEquals(OrchestrationStatus.PAUSED, paused.status)
|
||||
assertTrue(paused.pendingApproval)
|
||||
val resumed =
|
||||
projector.apply(paused, stored(payload = OrchestrationResumedEvent(sessionId, stageId)))
|
||||
assertEquals(OrchestrationStatus.RUNNING, resumed.status)
|
||||
assertFalse(resumed.pendingApproval)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set FAILED on WorkflowFailedEvent`() {
|
||||
val initialState = projector.initial()
|
||||
val started =
|
||||
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
val failed =
|
||||
projector.apply(
|
||||
started,
|
||||
stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)),
|
||||
)
|
||||
assertEquals(OrchestrationStatus.FAILED, failed.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same events produce same result`() {
|
||||
val initialState = projector.initial()
|
||||
val events = listOf(
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)),
|
||||
stored(
|
||||
payload = RetryAttemptedEvent(
|
||||
sessionId,
|
||||
stageId,
|
||||
attemptNumber = 1,
|
||||
maxAttempts = 3,
|
||||
failureReason = "Something went wrong",
|
||||
),
|
||||
),
|
||||
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)),
|
||||
)
|
||||
|
||||
val result1 = events.fold(initialState) { state, event ->
|
||||
projector.apply(state, event)
|
||||
}
|
||||
val result2 = events.fold(initialState) { state, event ->
|
||||
projector.apply(state, event)
|
||||
}
|
||||
|
||||
assertEquals(result1, result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full pause-resume lifecycle`() {
|
||||
val s0 = projector.initial()
|
||||
val s1 = projector.apply(s0, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
|
||||
val s2 =
|
||||
projector.apply(s1, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "approval required")))
|
||||
val s3 = projector.apply(s2, stored(payload = OrchestrationResumedEvent(sessionId, stageId)))
|
||||
val s4 = projector.apply(s3, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)))
|
||||
|
||||
assertEquals(OrchestrationStatus.RUNNING, s1.status)
|
||||
assertEquals(OrchestrationStatus.PAUSED, s2.status)
|
||||
assertTrue(s2.pendingApproval)
|
||||
assertEquals(OrchestrationStatus.RUNNING, s3.status)
|
||||
assertFalse(s3.pendingApproval)
|
||||
assertEquals(OrchestrationStatus.COMPLETED, s4.status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
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 state = OrchestrationState(stageId)
|
||||
|
||||
@Test
|
||||
fun `WorkflowStartedEvent sets status to RUNNING`() {
|
||||
val state = reducer.reduce(
|
||||
state,
|
||||
stored(payload = WorkflowStartedEvent(sessionId, 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, 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, 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, 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, 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, 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 `unrelated event does nothing`() {
|
||||
val started = reducer.reduce(
|
||||
state,
|
||||
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
|
||||
)
|
||||
|
||||
val unrelated = reducer.reduce(started, stored())
|
||||
assertEquals(started, unrelated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import com.correx.core.events.events.SessionCompletedEvent
|
||||
import com.correx.core.events.events.SessionPausedEvent
|
||||
import com.correx.core.events.events.SessionResumedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StageStartedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
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.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.testing.fixtures.EventFixtures.stored
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionProjectorTest {
|
||||
|
||||
private val projector = SessionProjector(DefaultSessionReducer())
|
||||
|
||||
@Test
|
||||
fun `full lifecycle transitions correctly`() {
|
||||
var state = projector.initial()
|
||||
|
||||
val events = listOf(
|
||||
stored(eventId = EventId("e1"), payload = SessionStartedEvent(SessionId("s1"))),
|
||||
stored(eventId = EventId("e2"), payload = SessionPausedEvent(SessionId("s1"))),
|
||||
stored(eventId = EventId("e3"), payload = SessionResumedEvent(SessionId("s1"))),
|
||||
stored(eventId = EventId("e4"), payload = SessionCompletedEvent(SessionId("s1")))
|
||||
)
|
||||
|
||||
events.forEach {
|
||||
state = projector.apply(state, it)
|
||||
}
|
||||
|
||||
assertEquals(SessionStatus.COMPLETED, state.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed stage transitions session to FAILED`() {
|
||||
var state = projector.initial()
|
||||
|
||||
val events = listOf(
|
||||
stored(
|
||||
eventId = EventId("e1"),
|
||||
payload = SessionStartedEvent(SessionId("s1"))
|
||||
),
|
||||
stored(
|
||||
eventId = EventId("e2"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("draft"),
|
||||
transitionId = TransitionId("t1")
|
||||
)
|
||||
),
|
||||
stored(
|
||||
eventId = EventId("e3"),
|
||||
payload = StageFailedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("draft"),
|
||||
transitionId = TransitionId("t1"),
|
||||
reason = "boom"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
events.forEach {
|
||||
state = projector.apply(state, it)
|
||||
}
|
||||
|
||||
assertEquals(SessionStatus.FAILED, state.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transition events keep session ACTIVE`() {
|
||||
var state = projector.initial()
|
||||
|
||||
val events = listOf(
|
||||
stored(
|
||||
eventId = EventId("e1"),
|
||||
payload = SessionStartedEvent(SessionId("s1"))
|
||||
),
|
||||
stored(
|
||||
eventId = EventId("e2"),
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
from = StageId("a"),
|
||||
to = StageId("b"),
|
||||
transitionId = TransitionId("t1")
|
||||
)
|
||||
),
|
||||
stored(
|
||||
eventId = EventId("e3"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("b"),
|
||||
transitionId = TransitionId("t1")
|
||||
)
|
||||
),
|
||||
stored(
|
||||
eventId = EventId("e4"),
|
||||
payload = StageCompletedEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
stageId = StageId("b"),
|
||||
transitionId = TransitionId("t1")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
events.forEach {
|
||||
state = projector.apply(state, it)
|
||||
}
|
||||
|
||||
assertEquals(SessionStatus.ACTIVE, state.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projection replay is deterministic`() {
|
||||
|
||||
val events = listOf(
|
||||
stored(
|
||||
eventId = EventId("e1"),
|
||||
payload = SessionStartedEvent(SessionId("s1"))
|
||||
),
|
||||
stored(
|
||||
eventId = EventId("e2"),
|
||||
payload = SessionCompletedEvent(SessionId("s1"))
|
||||
)
|
||||
)
|
||||
|
||||
val state1 = events.fold(projector.initial()) { state, event ->
|
||||
projector.apply(state, event)
|
||||
}
|
||||
|
||||
val state2 = events.fold(projector.initial()) { state, event ->
|
||||
projector.apply(state, event)
|
||||
}
|
||||
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:validation"))
|
||||
testImplementation(project(":core:approvals"))
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
testImplementation(project(":infrastructure:persistence"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.testing.replay
|
||||
|
||||
object Module
|
||||
@@ -0,0 +1,59 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ApprovalReplayTest {
|
||||
@Test
|
||||
fun `replaying same request with same inputs yields identical decision`() {
|
||||
val engine1 = DefaultApprovalEngine()
|
||||
val engine2 = DefaultApprovalEngine()
|
||||
val req = request("req1", Tier.T2)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.AUTO
|
||||
)
|
||||
val grants = listOf(
|
||||
ApprovalGrant(
|
||||
GrantId("g1"),
|
||||
GrantScope.SESSION,
|
||||
setOf(Tier.T2),
|
||||
"reason",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val d1 = engine1.evaluate(req, ctx, grants, now)
|
||||
val d2 = engine2.evaluate(req, ctx, grants, now)
|
||||
|
||||
assertEquals(d1, d2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decision ID determinism holds over multiple replays`() {
|
||||
val engine = DefaultApprovalEngine()
|
||||
val req = request("fixed", Tier.T3)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s"), null, null),
|
||||
ApprovalMode.PROMPT
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val d1 = engine.evaluate(req, ctx, emptyList(), now)
|
||||
val d2 = engine.evaluate(req, ctx, emptyList(), now)
|
||||
|
||||
assertEquals(d1.id, d2.id)
|
||||
assertEquals(ApprovalDecisionId("decision:fixed"), d1.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionEmptyReplayTest {
|
||||
|
||||
@Test
|
||||
fun `empty session returns initial state`() {
|
||||
val store = InMemoryEventStore()
|
||||
val replayer = DefaultEventReplayer(
|
||||
store,
|
||||
SessionProjector(DefaultSessionReducer())
|
||||
)
|
||||
|
||||
val state = replayer.rebuild(SessionId("missing"))
|
||||
|
||||
assertEquals(SessionStatus.CREATED, state.status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SessionCompletedEvent
|
||||
import com.correx.core.events.events.SessionPausedEvent
|
||||
import com.correx.core.events.events.SessionResumedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.datetime.Clock
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionReplayTest {
|
||||
|
||||
private val store = InMemoryEventStore()
|
||||
private val projector = SessionProjector(DefaultSessionReducer())
|
||||
private val replayer = DefaultEventReplayer(store, projector)
|
||||
|
||||
@Test
|
||||
fun `rebuild session from event stream`() {
|
||||
val sessionId = SessionId("s1")
|
||||
|
||||
val metadataToPayload = mapOf<EventMetadata, EventPayload>(
|
||||
EventMetadata(EventId("start"), sessionId, Clock.System.now(), 1, null, null) to SessionStartedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(EventId("paused"), sessionId, Clock.System.now(), 1, null, null) to SessionPausedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(EventId("resumed"), sessionId, Clock.System.now(), 1, null, null) to SessionResumedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(
|
||||
EventId("completed"),
|
||||
sessionId,
|
||||
Clock.System.now(),
|
||||
1,
|
||||
null,
|
||||
null
|
||||
) to SessionCompletedEvent(sessionId),
|
||||
)
|
||||
|
||||
metadataToPayload.map { (meta, payload) ->
|
||||
store.append(NewEvent(meta, payload))
|
||||
}
|
||||
|
||||
val state = replayer.rebuild(sessionId)
|
||||
|
||||
assertEquals(SessionStatus.COMPLETED, state.status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import com.correx.core.events.events.SessionCompletedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StageStartedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
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.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.testing.fixtures.EventFixtures.newEvent
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TransitionReplayIntegrationTest {
|
||||
|
||||
private val sessionId = SessionId("session-1")
|
||||
|
||||
@Test
|
||||
fun `workflow replay reconstructs COMPLETED session`() {
|
||||
|
||||
val store = InMemoryEventStore()
|
||||
|
||||
store.appendAll(
|
||||
listOf(
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-1"),
|
||||
payload = SessionStartedEvent(sessionId)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-2"),
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId = sessionId,
|
||||
from = StageId("draft"),
|
||||
to = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-3"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-4"),
|
||||
payload = StageCompletedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-5"),
|
||||
payload = SessionCompletedEvent(sessionId)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val replayer = DefaultEventReplayer(
|
||||
store = store,
|
||||
projection = SessionProjector(
|
||||
reducer = DefaultSessionReducer()
|
||||
)
|
||||
)
|
||||
|
||||
val state = replayer.rebuild(sessionId)
|
||||
|
||||
assertEquals(
|
||||
SessionStatus.COMPLETED,
|
||||
state.status
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow replay reconstructs FAILED session`() {
|
||||
|
||||
val store = InMemoryEventStore()
|
||||
|
||||
store.appendAll(
|
||||
listOf(
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-1"),
|
||||
payload = SessionStartedEvent(sessionId)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-2"),
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId = sessionId,
|
||||
from = StageId("draft"),
|
||||
to = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-3"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-4"),
|
||||
payload = StageFailedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1"),
|
||||
reason = "validation failed"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val replayer = DefaultEventReplayer(
|
||||
store = store,
|
||||
projection = SessionProjector(
|
||||
reducer = DefaultSessionReducer()
|
||||
)
|
||||
)
|
||||
|
||||
val state = replayer.rebuild(sessionId)
|
||||
|
||||
assertEquals(
|
||||
SessionStatus.FAILED,
|
||||
state.status
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay is deterministic for identical event stream`() {
|
||||
val events = listOf(
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-1"),
|
||||
payload = SessionStartedEvent(sessionId)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-2"),
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId = sessionId,
|
||||
from = StageId("draft"),
|
||||
to = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-3"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-4"),
|
||||
payload = StageCompletedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val store1 = InMemoryEventStore()
|
||||
val store2 = InMemoryEventStore()
|
||||
|
||||
store1.appendAll(events)
|
||||
store2.appendAll(events)
|
||||
|
||||
val projection = SessionProjector(
|
||||
reducer = DefaultSessionReducer()
|
||||
)
|
||||
|
||||
val replayer1 = DefaultEventReplayer(
|
||||
store = store1,
|
||||
projection = projection
|
||||
)
|
||||
|
||||
val replayer2 = DefaultEventReplayer(
|
||||
store = store2,
|
||||
projection = projection
|
||||
)
|
||||
|
||||
val state1 = replayer1.rebuild(sessionId)
|
||||
val state2 = replayer2.rebuild(sessionId)
|
||||
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import com.correx.core.transitions.resolution.TransitionOrdering
|
||||
import com.correx.core.validation.graph.GraphValidator
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.core.validation.transition.TransitionValidator
|
||||
import com.correx.testing.fixtures.WorkflowFixtures
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ValidationReplayTest {
|
||||
|
||||
@Test
|
||||
fun `validation must be replay deterministic`() {
|
||||
|
||||
val graph = WorkflowFixtures.simpleGraph()
|
||||
|
||||
val context = ValidationContext(graph)
|
||||
|
||||
val pipeline = ValidationPipeline(
|
||||
listOf(
|
||||
GraphValidator(),
|
||||
TransitionValidator(TransitionOrdering.comparator)
|
||||
)
|
||||
)
|
||||
|
||||
val results = (1..10).map {
|
||||
pipeline.validate(context)
|
||||
}
|
||||
|
||||
assertTrue(results.distinct().size == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:inference"))
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.evaluation.TransitionConditionEvaluator
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionCondition
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import com.correx.core.transitions.resolution.TransitionResolver
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertInstanceOf
|
||||
|
||||
class DefaultTransitionResolverTest {
|
||||
|
||||
private val evaluator = object : TransitionConditionEvaluator {
|
||||
override fun evaluate(
|
||||
condition: TransitionCondition,
|
||||
context: EvaluationContext
|
||||
): Boolean {
|
||||
return condition.evaluate(context)
|
||||
}
|
||||
}
|
||||
|
||||
private val resolver: TransitionResolver = DefaultTransitionResolver(evaluator)
|
||||
|
||||
@Test
|
||||
fun `returns NoMatch when no outgoing transitions exist for current stage`() {
|
||||
val graph = graph(
|
||||
start = "stage-a",
|
||||
transitions = setOf(
|
||||
edge(
|
||||
id = "t1",
|
||||
from = "stage-a",
|
||||
to = "stage-b",
|
||||
condition = alwaysTrue()
|
||||
)
|
||||
),
|
||||
stages = setOf("stage-a", "stage-b")
|
||||
)
|
||||
|
||||
val result = resolver.resolve(
|
||||
graph = graph,
|
||||
context = context(currentStage = "stage-b")
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
TransitionDecision.NoMatch,
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns Stay when all conditions evaluate to false`() {
|
||||
val graph = graph(
|
||||
start = "stage-a",
|
||||
transitions = setOf(
|
||||
edge(
|
||||
id = "t1",
|
||||
from = "stage-a",
|
||||
to = "stage-b",
|
||||
condition = alwaysFalse()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = resolver.resolve(
|
||||
graph = graph,
|
||||
context = context(currentStage = "stage-a")
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
TransitionDecision.Stay,
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns matching transition when condition evaluates to true`() {
|
||||
val graph = graph(
|
||||
start = "stage-a",
|
||||
transitions = setOf(
|
||||
edge(
|
||||
id = "t1",
|
||||
from = "stage-a",
|
||||
to = "stage-b",
|
||||
condition = alwaysTrue()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = resolver.resolve(
|
||||
graph = graph,
|
||||
context = context(currentStage = "stage-a")
|
||||
)
|
||||
|
||||
val move = assertInstanceOf<TransitionDecision.Move>(result)
|
||||
|
||||
assertEquals(
|
||||
TransitionId("t1"),
|
||||
move.transitionId
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
StageId("stage-b"),
|
||||
move.to
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first matching transition wins deterministically`() {
|
||||
val graph = graph(
|
||||
start = "stage-a",
|
||||
transitions = setOf(
|
||||
edge(
|
||||
id = "t2",
|
||||
from = "stage-a",
|
||||
to = "stage-c",
|
||||
condition = alwaysTrue()
|
||||
),
|
||||
edge(
|
||||
id = "t1",
|
||||
from = "stage-a",
|
||||
to = "stage-b",
|
||||
condition = alwaysTrue()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = resolver.resolve(
|
||||
graph = graph,
|
||||
context = context(currentStage = "stage-a")
|
||||
)
|
||||
|
||||
val move = assertInstanceOf<TransitionDecision.Move>(result)
|
||||
|
||||
assertEquals(
|
||||
TransitionId("t1"),
|
||||
move.transitionId
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
StageId("stage-b"),
|
||||
move.to
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only transitions from current stage are evaluated`() {
|
||||
val graph = graph(
|
||||
start = "stage-a",
|
||||
transitions = setOf(
|
||||
edge(
|
||||
id = "t1",
|
||||
from = "stage-x",
|
||||
to = "stage-y",
|
||||
condition = alwaysTrue()
|
||||
),
|
||||
edge(
|
||||
id = "t2",
|
||||
from = "stage-a",
|
||||
to = "stage-b",
|
||||
condition = alwaysTrue()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = resolver.resolve(
|
||||
graph = graph,
|
||||
context = context(currentStage = "stage-a")
|
||||
)
|
||||
|
||||
val move = assertInstanceOf<TransitionDecision.Move>(result)
|
||||
|
||||
assertEquals(
|
||||
TransitionId("t2"),
|
||||
move.transitionId
|
||||
)
|
||||
}
|
||||
|
||||
private fun edge(
|
||||
id: String,
|
||||
from: String,
|
||||
to: String,
|
||||
condition: TransitionCondition
|
||||
): TransitionEdge {
|
||||
return TransitionEdge(
|
||||
id = TransitionId(id),
|
||||
from = StageId(from),
|
||||
to = StageId(to),
|
||||
condition = condition
|
||||
)
|
||||
}
|
||||
|
||||
private fun graph(
|
||||
start: String,
|
||||
transitions: Set<TransitionEdge>,
|
||||
stages: Set<String> = transitions.flatMap { listOf(it.from.value, it.to.value) }.toSet()
|
||||
) = WorkflowGraph(
|
||||
start = StageId(start),
|
||||
stages = stages.associate { StageId(it) to StageConfig() },
|
||||
transitions = transitions
|
||||
)
|
||||
|
||||
private fun context(
|
||||
currentSession: String = "s1",
|
||||
currentStage: String,
|
||||
): EvaluationContext {
|
||||
return EvaluationContext(
|
||||
sessionId = SessionId(currentSession),
|
||||
currentStage = StageId(currentStage),
|
||||
variables = emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun alwaysTrue() =
|
||||
TransitionCondition { true }
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
private fun alwaysFalse() =
|
||||
TransitionCondition { false }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StageStartedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.serialization.JsonEventSerializer
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TransitionEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `StageStartedEvent serializes and deserializes`() {
|
||||
val event = StageStartedEvent(
|
||||
sessionId = SessionId("session-1"),
|
||||
stageId = StageId("stage-a"),
|
||||
transitionId = TransitionId("transition-a")
|
||||
)
|
||||
|
||||
assertRoundTrip(event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageCompletedEvent serializes and deserializes`() {
|
||||
val event = StageCompletedEvent(
|
||||
sessionId = SessionId("session-1"),
|
||||
stageId = StageId("stage-a"),
|
||||
transitionId = TransitionId("transition-a")
|
||||
)
|
||||
|
||||
assertRoundTrip(event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageFailedEvent serializes and deserializes`() {
|
||||
val event = StageFailedEvent(
|
||||
sessionId = SessionId("session-1"),
|
||||
stageId = StageId("stage-a"),
|
||||
transitionId = TransitionId("transition-a"),
|
||||
reason = "boom"
|
||||
)
|
||||
|
||||
assertRoundTrip(event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TransitionExecutedEvent serializes and deserializes`() {
|
||||
val event = TransitionExecutedEvent(
|
||||
sessionId = SessionId("session-1"),
|
||||
from = StageId("stage-a"),
|
||||
to = StageId("stage-b"),
|
||||
transitionId = TransitionId("transition-a")
|
||||
)
|
||||
|
||||
assertRoundTrip(event)
|
||||
}
|
||||
|
||||
private inline fun <reified T : EventPayload> assertRoundTrip(
|
||||
event: T
|
||||
) {
|
||||
val json = JsonEventSerializer()
|
||||
val serialized = json.serialize(event)
|
||||
|
||||
val deserialized =
|
||||
json.deserialize(serialized)
|
||||
|
||||
assertEquals(event, deserialized)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user