Skip to content

Commit 96508d9

Browse files
bedaHovorkaclaude
andcommitted
fix(SP0.9): PR #737 review follow-up — seam test + exhaustive when
Addresses the two Important issues from the PR #737 code review: * Add ShuntingLoopControlStepListenerTest (:core jvmTest, integration-tagged): - proves controlStepListener.onControlStep() is invoked exactly once per iteration, before the admission Dispatcher.decide() call (the load-bearing ordering invariant the SP0.9 design rests on) — endTime=0L gives one iteration; a custom Dispatcher records that the listener already ran. - proves approveQueuedTrain delegates to applyApproveTrain and moves a queued train into the approved set — a NoAction-only dispatcher makes the callback the only approval path; the listener consumes the captured id once (getAndSet(null)) so re-approval cannot throw in applyApproveTrain's requireNotNull and crash the kDisco process mid-iteration. Closes the conservative-sim test-coverage gap (the iteration() seam had no direct test; DispatchDecisionApplierTest only exercised onControlStep on the test thread). * Make the two `when` constructs in DispatchDecisionApplier compile-enforced exhaustive over the sealed DispatchDecision / RouteRequestResult types (expression-body `when`), matching DefaultNetworkActuatorPort.requestRoute. A future subtype (e.g. HoldTrain in SP2b, #556) is now a compile error instead of a silently-dropped decision. Also records the Minor #4 coupling observation (ApproveTrain-via-callback) as a code comment in DispatchDecisionApplier's onApproveTrain KDoc and as a comment on GitHub issue #556, recommending a TrainAdmissionPort extraction for SP2b train-lifecycle commands. Verified: :core:integrationTest 2/2, :dispatcher-agent:test + :core:jvmTest 2177/2177, detekt + ktlintCheck clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 814ac6a commit 96508d9

2 files changed

Lines changed: 182 additions & 3 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package cz.vutbr.fit.interlockSim.sim
2+
3+
import assertk.assertThat
4+
import assertk.assertions.isEqualTo
5+
import assertk.assertions.isGreaterThanOrEqualTo
6+
import assertk.assertions.isTrue
7+
import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext
8+
import cz.vutbr.fit.interlockSim.context.EditingContext
9+
import cz.vutbr.fit.interlockSim.context.JvmEditingContextFactory
10+
import cz.vutbr.fit.interlockSim.context.SimulationContextFactory
11+
import cz.vutbr.fit.interlockSim.testutil.KoinTestBase
12+
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
13+
import io.github.oshai.kotlinlogging.KotlinLogging
14+
import org.junit.jupiter.api.DisplayName
15+
import org.junit.jupiter.api.Tag
16+
import org.junit.jupiter.api.Test
17+
import org.junit.jupiter.api.Timeout
18+
import org.koin.test.inject
19+
import java.util.concurrent.TimeUnit
20+
import java.util.concurrent.atomic.AtomicBoolean
21+
import java.util.concurrent.atomic.AtomicInteger
22+
import java.util.concurrent.atomic.AtomicReference
23+
24+
private val logger = KotlinLogging.logger {}
25+
26+
/**
27+
* Tests the SP0.9 [ControlStepListener] seam in [ShuntingLoop.iteration] — the
28+
* load-bearing placement of `controlStepListener?.onControlStep()` at the top of
29+
* each iteration, **before** admission decisions are built.
30+
*
31+
* The existing `DispatchDecisionApplierTest` (in `:dispatcher-agent`) exercises the
32+
* applier in isolation by calling `onControlStep()` on the test thread; it does **not**
33+
* verify that `ShuntingLoop.iteration()` actually invokes the listener on the sim
34+
* thread, or that it does so before the admission `Dispatcher.decide` call. These
35+
* tests close that gap by driving a real `context.run()` with a custom [Dispatcher]
36+
* and a [ControlStepListener], observing the real iteration lifecycle.
37+
*
38+
* @since Issue #731 (SP0.9 — Goal 10, PR #737 review follow-up)
39+
*/
40+
@DisplayName("ShuntingLoop ControlStepListener seam (SP0.9, #737)")
41+
@Tag("integration-test")
42+
class ShuntingLoopControlStepListenerTest : KoinTestBase() {
43+
private val editingContextFactory: JvmEditingContextFactory by inject()
44+
private val simulationContextFactory: SimulationContextFactory by inject()
45+
46+
private fun loadVyhybnaContext(): DefaultSimulationContext =
47+
TestFixtures.loadShuntingXml().use { xmlStream ->
48+
val editingContext = editingContextFactory.createContext(xmlStream) as EditingContext
49+
simulationContextFactory.createContext(editingContext) as DefaultSimulationContext
50+
}
51+
52+
/**
53+
* The listener is invoked exactly once per iteration, and its invocation at the
54+
* top of `iteration()` precedes the admission `Dispatcher.decide` call (i.e. it
55+
* runs before `buildAdmissionObservation()` is consumed).
56+
*
57+
* With `endTime = 0L` the simulation performs exactly one iteration, so the
58+
* listener must fire exactly once. The custom [Dispatcher] records, inside its
59+
* admission `decide()`, whether the listener has already run this tick — proving
60+
* the ordering invariant the whole SP0.9 design rests on.
61+
*/
62+
@Test
63+
@Timeout(value = 60, unit = TimeUnit.SECONDS)
64+
fun `controlStepListener is invoked once per iteration, before admission`() {
65+
val context = loadVyhybnaContext()
66+
// Initialize dynamic wrapper map before creating ShuntingLoop (matches the
67+
// example/regression test pattern — see ShuntingLoopRegressionTest).
68+
context.getInOuts()
69+
70+
val listenerCalls = AtomicInteger(0)
71+
val listenerRanBeforeAdmission = AtomicBoolean(false)
72+
73+
// decide() is called at the admission phase right after buildAdmissionObservation(),
74+
// which is AFTER controlStepListener?.onControlStep() at the top of iteration().
75+
// So if the listener ran before admission, listenerCalls is already >= 1 here.
76+
val dispatcher =
77+
object : Dispatcher {
78+
override fun decide(observed: DispatchObservation): List<DispatchDecision> {
79+
if (listenerCalls.get() >= 1) {
80+
listenerRanBeforeAdmission.set(true)
81+
}
82+
return listOf(DispatchDecision.NoAction)
83+
}
84+
}
85+
86+
val shuntingLoop = ShuntingLoop(context, endTime = 0L, dispatcher = dispatcher)
87+
shuntingLoop.controlStepListener = ControlStepListener { listenerCalls.incrementAndGet() }
88+
context.setMainProcess(shuntingLoop)
89+
context.run()
90+
91+
logger.info { "listenerCalls=${listenerCalls.get()}, ranBeforeAdmission=${listenerRanBeforeAdmission.get()}" }
92+
assertThat(listenerCalls.get()).isEqualTo(1)
93+
assertThat(listenerRanBeforeAdmission.get()).isTrue()
94+
}
95+
96+
/**
97+
* `approveQueuedTrain(trainId)` delegates to the private `applyApproveTrain`,
98+
* moving a queued train from the unapproved set into the approved set and
99+
* activating it — observable via `getMaxConcurrentTrains()`.
100+
*
101+
* The custom [Dispatcher] never returns an `ApproveTrain` decision (always
102+
* `NoAction`), so the **only** path by which a train can enter the approved set
103+
* is the listener calling `approveQueuedTrain`. The dispatcher captures the first
104+
* unapproved train id from the admission observation; the listener consumes that
105+
* id **once** (`getAndSet(null)`) and approves it on the next tick (the listener
106+
* runs before the `decide()` that captures the id, so there is a one-tick lag).
107+
* Consuming once is essential: re-approving an already-approved id would throw
108+
* inside `applyApproveTrain`'s `requireNotNull` and crash the ShuntingLoop
109+
* process mid-iteration. `endTime = 30L` gives enough ticks for the generator to
110+
* place a train and the listener to approve it.
111+
*/
112+
@Test
113+
@Timeout(value = 120, unit = TimeUnit.SECONDS)
114+
fun `approveQueuedTrain moves a queued train into the approved set`() {
115+
val context = loadVyhybnaContext()
116+
context.getInOuts()
117+
118+
val capturedTrainId = AtomicReference<String?>(null)
119+
val approvedByCallback = AtomicInteger(0)
120+
val shuntingLoopRef = AtomicReference<ShuntingLoop?>(null)
121+
122+
// Always NoAction: no auto-approval, no auto-reservation. The only mutation
123+
// path into the approved set is the listener's approveQueuedTrain call.
124+
// Re-captures the next unapproved id only after the listener has consumed the
125+
// previous one, so each train is approved exactly once.
126+
val dispatcher =
127+
object : Dispatcher {
128+
override fun decide(observed: DispatchObservation): List<DispatchDecision> {
129+
if (capturedTrainId.get() == null) {
130+
observed.unapprovedTrains.firstOrNull()?.let { capturedTrainId.set(it.trainId) }
131+
}
132+
return listOf(DispatchDecision.NoAction)
133+
}
134+
}
135+
136+
val shuntingLoop = ShuntingLoop(context, endTime = 30L, dispatcher = dispatcher)
137+
shuntingLoopRef.set(shuntingLoop)
138+
shuntingLoop.controlStepListener =
139+
ControlStepListener {
140+
// Consume-once: approve the id captured on a previous tick's decide() and
141+
// clear it so it is never re-approved (which would throw in applyApproveTrain).
142+
capturedTrainId.getAndSet(null)?.let { trainId ->
143+
shuntingLoopRef.get()?.approveQueuedTrain(trainId)
144+
approvedByCallback.incrementAndGet()
145+
}
146+
}
147+
context.setMainProcess(shuntingLoop)
148+
context.run()
149+
150+
val entered = shuntingLoop.getTrainsEntered()
151+
val maxConcurrent = shuntingLoop.getMaxConcurrentTrains()
152+
logger.info {
153+
"entered=$entered, maxConcurrent=$maxConcurrent, approvedByCallback=${approvedByCallback.get()}"
154+
}
155+
156+
// A train was queued by the generator...
157+
assertThat(entered).isGreaterThanOrEqualTo(1)
158+
// ...and the listener approved at least one via the callback...
159+
assertThat(approvedByCallback.get()).isGreaterThanOrEqualTo(1)
160+
// ...and it entered the approved set. Since the dispatcher never approves,
161+
// this could only happen via approveQueuedTrain -> applyApproveTrain.
162+
assertThat(maxConcurrent).isGreaterThanOrEqualTo(1)
163+
}
164+
}

dispatcher-agent/src/main/kotlin/cz/vutbr/fit/interlockSim/dispatcher/DispatchDecisionApplier.kt

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ import io.github.oshai.kotlinlogging.KotlinLogging
5252
* @param onApproveTrain Callback invoked (on the sim thread) to admit a queued train.
5353
* Typically [ShuntingLoop.approveQueuedTrain][cz.vutbr.fit.interlockSim.sim.ShuntingLoop.approveQueuedTrain].
5454
*
55+
* **Design note (SP0.9 review, Minor #4):** train admission (unapproved → approved
56+
* + kDisco `activate`) is a `ShuntingLoop`-specific lifecycle step, not a generic
57+
* train-actuator command — [TrainActuatorPort][cz.vutbr.fit.interlockSim.ports.TrainActuatorPort]
58+
* only exposes `setTargetSpeed`. Routing `ApproveTrain` through a `(String) -> Unit`
59+
* callback is therefore an intentional, minimal coupling for SP0.9. If SP2b (#556)
60+
* adds more train-lifecycle commands (e.g. `HoldTrain`), extract a dedicated
61+
* `TrainAdmissionPort` and route all train commands through it — symmetric with
62+
* [NetworkActuatorPort] — rather than growing this constructor-arg list.
63+
*
5564
* @since Issue #731 (SP0.9 — Goal 10)
5665
*/
5766
class DispatchDecisionApplier(
@@ -80,7 +89,10 @@ class DispatchDecisionApplier(
8089
}
8190
}
8291

83-
private fun applyDecision(decision: DispatchDecision) {
92+
// Expression-body `when` so the compiler enforces exhaustiveness over the sealed
93+
// DispatchDecision type — adding a future subtype (e.g. HoldTrain in SP2b, #556)
94+
// becomes a compile error here rather than a silently-dropped decision.
95+
private fun applyDecision(decision: DispatchDecision) =
8496
when (decision) {
8597
is DispatchDecision.ApproveTrain -> {
8698
logger.debug { "Applying ApproveTrain: trainId=${decision.trainId}" }
@@ -89,7 +101,6 @@ class DispatchDecisionApplier(
89101
is DispatchDecision.ReservePath -> applyReservePath(decision)
90102
DispatchDecision.NoAction -> Unit
91103
}
92-
}
93104

94105
private fun applyReservePath(decision: DispatchDecision.ReservePath) {
95106
logger.debug {
@@ -102,7 +113,11 @@ class DispatchDecisionApplier(
102113
decision.fromSemaphoreName,
103114
decision.toSeparatorName
104115
)
105-
when (result) {
116+
// Exhaustive `when` *expression* over the sealed RouteRequestResult type —
117+
// returning the `when` forces the compiler to enforce coverage, so a future
118+
// subtype addition fails to compile here instead of being silently ignored
119+
// (matches the pattern in DefaultNetworkActuatorPort.requestRoute).
120+
return when (result) {
106121
is RouteRequestResult.Reserved ->
107122
logger.debug {
108123
"ReservePath: reserved ${result.blocksCount} block(s) for ${decision.trainId}"

0 commit comments

Comments
 (0)