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