epic-12: after epic audit and init commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user