Skip to content

Commit 0beb74d

Browse files
bedaHovorkaclaude
andcommitted
Fix exampleGui speed buttons: bridge SimulationController -> ShuntingLoop
Speed-control buttons in exampleGui mode had no effect because the simulation paced via RealTimeSynch, which read speedMultiplier from a ShuntingLoop constructor val frozen at 1.0 in ExampleRegistry. SimulationController.setSpeed only updated SimulationRunner.speedMultiplier (whose throttle() is currently uncalled per its own KDoc), so the chain button -> controller -> simulation was broken at the last hop. Introduces SpeedControllable in core/commonMain (KMP-clean, generic — any main-process can opt in, not ShuntingLoop-specific). ShuntingLoop now implements it with an @kotlin.concurrent.Volatile var so RealTimeSynch on the simulation thread reads each iteration's current value. SimulationController casts the live main process via getMainProcess() and propagates setSpeed() through both the runner and the controllable, covering the active pacing path today and the throttle path when wired. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a6f73aa commit 0beb74d

9 files changed

Lines changed: 410 additions & 13 deletions

File tree

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,4 +1533,12 @@ open class DefaultSimulationContext(
15331533
fun setMainProcess(process: LoopProcess) {
15341534
mainProcess = process
15351535
}
1536+
1537+
/**
1538+
* Returns the currently registered main process, or `null` if none has been set
1539+
* via [setMainProcess]. Callers may downcast to runtime-control interfaces such
1540+
* as [cz.vutbr.fit.interlockSim.sim.SpeedControllable] to retune the live
1541+
* simulation (e.g. wall-clock pacing) from the EDT.
1542+
*/
1543+
fun getMainProcess(): LoopProcess? = mainProcess
15361544
}

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoop.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,22 @@ class ShuntingLoop(
6767
context: SimulationContext,
6868
private val endTime: Long,
6969
private val enableRealTimeSync: Boolean = false,
70-
private val speedMultiplier: Double = 1.0,
70+
initialSpeedMultiplier: Double = 1.0,
7171
private val pathReservationService: PathReservationService = context.getPathReservationService()
7272
) : Interlocking(context),
73+
SpeedControllable,
7374
KoinComponent {
75+
@kotlin.concurrent.Volatile
76+
override var speedMultiplier: Double = initialSpeedMultiplier
77+
set(value) {
78+
require(value > 0.0) { "Speed multiplier must be positive, got: $value" }
79+
field = value
80+
}
81+
7482
init {
75-
require(speedMultiplier > 0.0) { "Speed multiplier must be positive, got: $speedMultiplier" }
83+
require(initialSpeedMultiplier > 0.0) {
84+
"Speed multiplier must be positive, got: $initialSpeedMultiplier"
85+
}
7686
}
7787

7888
// Inject registry for idempotent path reservation checks
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/* Brno University of Technology
2+
* Faculty of Information Technology
3+
*
4+
* BSc Thesis 2006/2007
5+
*
6+
* Railway Interlocking Simulator
7+
*
8+
* Bedrich Hovorka
9+
*/
10+
package cz.vutbr.fit.interlockSim.sim
11+
12+
/**
13+
* Implemented by simulation processes whose wall-clock pacing can be retuned at runtime.
14+
*
15+
* Implementations MUST treat the property as thread-safe: reads happen on the kDisco
16+
* simulation thread (e.g. inside a real-time-sync loop), writes happen on the EDT
17+
* (or any thread driving GUI controls / keyboard shortcuts). Use `@kotlin.concurrent.Volatile`
18+
* on the backing field.
19+
*
20+
* Range validation is left to the implementation; the canonical bounds used by the
21+
* desktop GUI live on `SimulationRunner.MIN_SPEED`/`MAX_SPEED`.
22+
*/
23+
interface SpeedControllable {
24+
/** Wall-clock speed multiplier. 1.0 = real-time, 2.0 = twice as fast, 0.5 = half-speed. */
25+
var speedMultiplier: Double
26+
}

core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoopTest.kt

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ class ShuntingLoopTest : KoinTestBase() {
310310
validContext,
311311
60L,
312312
enableRealTimeSync = true,
313-
speedMultiplier = 2.0
313+
initialSpeedMultiplier = 2.0
314314
)
315315
assertThat(shuntingLoop).isNotNull()
316316
}
@@ -335,7 +335,7 @@ class ShuntingLoopTest : KoinTestBase() {
335335
validContext,
336336
60L,
337337
enableRealTimeSync = true,
338-
speedMultiplier = 0.5
338+
initialSpeedMultiplier = 0.5
339339
)
340340
assertThat(shuntingLoop).isNotNull()
341341
}
@@ -348,7 +348,7 @@ class ShuntingLoopTest : KoinTestBase() {
348348
validContext,
349349
60L,
350350
enableRealTimeSync = true,
351-
speedMultiplier = 1.0
351+
initialSpeedMultiplier = 1.0
352352
)
353353
assertThat(shuntingLoop).isNotNull()
354354
}
@@ -361,7 +361,7 @@ class ShuntingLoopTest : KoinTestBase() {
361361
validContext,
362362
60L,
363363
enableRealTimeSync = true,
364-
speedMultiplier = 2.0
364+
initialSpeedMultiplier = 2.0
365365
)
366366
assertThat(shuntingLoop).isNotNull()
367367
}
@@ -374,7 +374,7 @@ class ShuntingLoopTest : KoinTestBase() {
374374
validContext,
375375
60L,
376376
enableRealTimeSync = true,
377-
speedMultiplier = 5.0
377+
initialSpeedMultiplier = 5.0
378378
)
379379
assertThat(shuntingLoop).isNotNull()
380380
}
@@ -394,7 +394,7 @@ class ShuntingLoopTest : KoinTestBase() {
394394
validContext,
395395
60L,
396396
enableRealTimeSync = true,
397-
speedMultiplier = 0.1
397+
initialSpeedMultiplier = 0.1
398398
)
399399
assertThat(shuntingLoop).isNotNull()
400400
}
@@ -407,7 +407,7 @@ class ShuntingLoopTest : KoinTestBase() {
407407
validContext,
408408
60L,
409409
enableRealTimeSync = true,
410-
speedMultiplier = 10.0
410+
initialSpeedMultiplier = 10.0
411411
)
412412
assertThat(shuntingLoop).isNotNull()
413413
}
@@ -420,7 +420,7 @@ class ShuntingLoopTest : KoinTestBase() {
420420
validContext,
421421
60L,
422422
enableRealTimeSync = true,
423-
speedMultiplier = 0.0
423+
initialSpeedMultiplier = 0.0
424424
)
425425
}.withMessage("Speed multiplier must be positive")
426426
.isFailure()
@@ -435,12 +435,54 @@ class ShuntingLoopTest : KoinTestBase() {
435435
validContext,
436436
60L,
437437
enableRealTimeSync = true,
438-
speedMultiplier = -1.0
438+
initialSpeedMultiplier = -1.0
439439
)
440440
}.withMessage("Speed multiplier must be positive")
441441
.isFailure()
442442
.isInstanceOf(IllegalArgumentException::class)
443443
}
444+
445+
@Test
446+
@DisplayName("speedMultiplier is mutable post-construction (RealTimeSynch reads live value)")
447+
fun speedMultiplier_isMutableAtRuntime() {
448+
val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true)
449+
450+
// Default initial value
451+
assertThat(shuntingLoop.speedMultiplier).isEqualTo(1.0)
452+
453+
// Mutating from the EDT side (or any thread) must stick — RealTimeSynch.iteration()
454+
// reads from this @Volatile field on the simulation thread, so the value seen by
455+
// the wall-clock pacing must match the latest write.
456+
shuntingLoop.speedMultiplier = 2.5
457+
assertThat(shuntingLoop.speedMultiplier).isEqualTo(2.5)
458+
459+
shuntingLoop.speedMultiplier = 0.1
460+
assertThat(shuntingLoop.speedMultiplier).isEqualTo(0.1)
461+
}
462+
463+
@Test
464+
@DisplayName("speedMultiplier setter rejects zero")
465+
fun speedMultiplier_setter_zero_throwsException() {
466+
val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true)
467+
468+
assertThatBlock {
469+
shuntingLoop.speedMultiplier = 0.0
470+
}.withMessage("Speed multiplier must be positive")
471+
.isFailure()
472+
.isInstanceOf(IllegalArgumentException::class)
473+
}
474+
475+
@Test
476+
@DisplayName("speedMultiplier setter rejects negative")
477+
fun speedMultiplier_setter_negative_throwsException() {
478+
val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true)
479+
480+
assertThatBlock {
481+
shuntingLoop.speedMultiplier = -1.0
482+
}.withMessage("Speed multiplier must be positive")
483+
.isFailure()
484+
.isInstanceOf(IllegalArgumentException::class)
485+
}
444486
}
445487

446488
@Nested

desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/ExampleRegistry.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ class ExampleRegistry {
135135
// Initialize dynamic wrapper map by calling getInOuts()
136136
context.getInOuts()
137137
// Enable real-time synchronization for GUI mode with 1x speed multiplier
138-
context.setMainProcess(ShuntingLoop(context, time, enableRealTimeSync = true, speedMultiplier = 1.0))
138+
context.setMainProcess(ShuntingLoop(context, time, enableRealTimeSync = true, initialSpeedMultiplier = 1.0))
139139
context
140140
}
141141
}

desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationController.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
package cz.vutbr.fit.interlockSim.gui
1212

13+
import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext
1314
import cz.vutbr.fit.interlockSim.context.SimulationContext
15+
import cz.vutbr.fit.interlockSim.sim.SpeedControllable
1416
import io.github.oshai.kotlinlogging.KotlinLogging
1517
import java.beans.PropertyChangeListener
1618

@@ -62,6 +64,16 @@ internal class SimulationController(
6264
/** Listener registered on the active runner for speed changes; removed on stop. */
6365
private var speedListener: PropertyChangeListener? = null
6466

67+
/**
68+
* Reference to the running main process when it implements [SpeedControllable]
69+
* (e.g. [cz.vutbr.fit.interlockSim.sim.ShuntingLoop]). `setSpeed` propagates
70+
* speed changes here so the simulation thread's wall-clock pacing actually
71+
* tracks the GUI controls — without this link the runner-side `speedMultiplier`
72+
* is dead-state w.r.t. RealTimeSynch.
73+
*/
74+
@Volatile
75+
private var speedControllable: SpeedControllable? = null
76+
6577
/**
6678
* Desired speed multiplier applied to new and currently running simulations.
6779
*
@@ -107,6 +119,10 @@ internal class SimulationController(
107119
val newRunner = SimulationRunner(context)
108120
newRunner.speedMultiplier = desiredSpeed
109121
runner = newRunner
122+
val mainProcess = (context as? DefaultSimulationContext)?.getMainProcess()
123+
val controllable = mainProcess as? SpeedControllable
124+
speedControllable = controllable
125+
controllable?.speedMultiplier = desiredSpeed
110126

111127
// Start synchronously BEFORE enabling the Stop button or launching the monitor
112128
// thread. This ensures stopSimulation() always has a live thread to interrupt.
@@ -155,6 +171,7 @@ internal class SimulationController(
155171
if (runner === newRunner) {
156172
cleanupSpeedListener(newRunner)
157173
runner = null
174+
speedControllable = null
158175
onSpeedChanged(SimulationRunner.DEFAULT_SPEED)
159176
onStateChanged(SimulationStatus.STOPPED)
160177
onCompleted()
@@ -177,6 +194,7 @@ internal class SimulationController(
177194
cleanupSpeedListener(r)
178195
r.stop()
179196
runner = null
197+
speedControllable = null
180198
onSpeedChanged(SimulationRunner.DEFAULT_SPEED)
181199
onStateChanged(SimulationStatus.STOPPED)
182200
}
@@ -205,6 +223,7 @@ internal class SimulationController(
205223
}
206224
desiredSpeed = multiplier
207225
runner?.speedMultiplier = multiplier
226+
speedControllable?.speedMultiplier = multiplier
208227
}
209228

210229
companion object {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
Brno University of Technology
3+
Faculty of Information Technology
4+
5+
BSc Thesis 2006/2007
6+
Railway Interlocking Simulator
7+
8+
Bridge integration test (Goal 7 / Issue #187): with real DefaultSimulationContext
9+
and a real ShuntingLoop, SimulationController.setSpeed must reach the live
10+
ShuntingLoop instance through DefaultSimulationContext.getMainProcess() and the
11+
SpeedControllable cast — closing the loop the user observed broken in
12+
`exampleGui shuntingLoop`.
13+
*/
14+
15+
package cz.vutbr.fit.interlockSim.gui
16+
17+
import assertk.assertThat
18+
import assertk.assertions.isEqualTo
19+
import assertk.assertions.isSameAs
20+
import assertk.assertions.isTrue
21+
import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext
22+
import cz.vutbr.fit.interlockSim.context.SimulationContextFactory
23+
import cz.vutbr.fit.interlockSim.sim.ShuntingLoop
24+
import cz.vutbr.fit.interlockSim.sim.SpeedControllable
25+
import cz.vutbr.fit.interlockSim.testutil.KoinTestBase
26+
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
27+
import cz.vutbr.fit.interlockSim.testutil.integrationTestModule
28+
import org.junit.jupiter.api.DisplayName
29+
import org.junit.jupiter.api.Tag
30+
import org.junit.jupiter.api.Test
31+
import org.junit.jupiter.api.Timeout
32+
import org.koin.core.module.Module
33+
import org.koin.test.get
34+
import java.util.concurrent.TimeUnit
35+
36+
@Tag("integration-test")
37+
@DisplayName("SimulationController -> ShuntingLoop bridge (real types)")
38+
class SimulationControllerBridgeIntegrationTest : KoinTestBase() {
39+
40+
override fun getTestModule(): Module = integrationTestModule
41+
42+
@Test
43+
@DisplayName("Real ShuntingLoop is recognised as SpeedControllable via getMainProcess()")
44+
fun realShuntingLoopIsRecognizedAsSpeedControllable() {
45+
val factory = get<SimulationContextFactory>()
46+
val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext }
47+
48+
ctx.use { context ->
49+
context.getInOuts()
50+
val loop = ShuntingLoop(context, 60L, enableRealTimeSync = true)
51+
context.setMainProcess(loop)
52+
53+
val mainProcess = context.getMainProcess()
54+
assertThat(mainProcess).isSameAs(loop)
55+
assertThat(mainProcess is SpeedControllable).isTrue()
56+
57+
val controllable = mainProcess as SpeedControllable
58+
controllable.speedMultiplier = 2.5
59+
// Same instance — RealTimeSynch on the simulation thread reads through
60+
// the same @Volatile-backed field on its next iteration.
61+
assertThat(loop.speedMultiplier).isEqualTo(2.5)
62+
}
63+
}
64+
65+
@Test
66+
@Timeout(value = 15, unit = TimeUnit.SECONDS)
67+
@DisplayName("controller.setSpeed propagates to live ShuntingLoop while simulation runs")
68+
fun controllerSetSpeedPropagatesToLiveShuntingLoop() {
69+
val factory = get<SimulationContextFactory>()
70+
val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext }
71+
72+
ctx.use { context ->
73+
context.getInOuts()
74+
// Long simulated end time + real-time sync so the simulation thread is
75+
// active when we change speed. We stop() it explicitly in finally.
76+
val loop = ShuntingLoop(
77+
context,
78+
endTime = 600L,
79+
enableRealTimeSync = true,
80+
initialSpeedMultiplier = 1.0
81+
)
82+
context.setMainProcess(loop)
83+
84+
val controller = SimulationController()
85+
try {
86+
controller.start(context)
87+
88+
// SimulationController wires its `speedControllable` reference
89+
// synchronously inside start(), before launching the simulation thread,
90+
// so the bridge is live by the time we get here.
91+
controller.setSpeed(2.0)
92+
assertThat(loop.speedMultiplier).isEqualTo(2.0)
93+
94+
controller.setSpeed(0.5)
95+
assertThat(loop.speedMultiplier).isEqualTo(0.5)
96+
97+
controller.setSpeed(10.0)
98+
assertThat(loop.speedMultiplier).isEqualTo(10.0)
99+
} finally {
100+
controller.stop()
101+
}
102+
}
103+
}
104+
105+
@Test
106+
@Timeout(value = 15, unit = TimeUnit.SECONDS)
107+
@DisplayName("setSpeed before start applies on start() to the real ShuntingLoop")
108+
fun preStartSpeedAppliedToRealShuntingLoopOnStart() {
109+
val factory = get<SimulationContextFactory>()
110+
val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext }
111+
112+
ctx.use { context ->
113+
context.getInOuts()
114+
val loop = ShuntingLoop(
115+
context,
116+
endTime = 600L,
117+
enableRealTimeSync = true,
118+
initialSpeedMultiplier = 1.0
119+
)
120+
context.setMainProcess(loop)
121+
122+
val controller = SimulationController()
123+
try {
124+
controller.setSpeed(3.0)
125+
controller.start(context)
126+
127+
assertThat(loop.speedMultiplier).isEqualTo(3.0)
128+
} finally {
129+
controller.stop()
130+
}
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)