Skip to content

Commit 5a00c67

Browse files
authored
feat(goal-7): common PR (#482)
Adds runtime simulation speed control to the desktop GUI, covering the full stack from the `SpeedControllable` core interface down to keyboard shortcuts and a live status-bar indicator. ## Core / :core - `SpeedControllable` interface (`sim/`) — generic speed-multiplier contract; wired into `ShuntingLoop` and exposed through `DefaultSimulationContext` ## Desktop UI / :desktop-ui **New components** - `SimulationControlPanel` — `0.1x`–`10.0x` slider + preset buttons (`0.1x`, `0.5x`, `1x`, `2x`, `5x`, `10x`, `50x`); embedded in `ControlPanel` (animation mode) and the Simulation menu - `SimulationController` — owns simulation lifecycle (start/stop), persists the selected speed multiplier, reapplies it on the next run; callback-based API decouples GUI widgets from the runner - `SimulationKeyBindings` — installs global frame shortcuts while in simulation mode: `1`–`5` → presets (0.5x/1x/2x/5x/10x), `+`/`-` → ×1.5 / ÷1.5 step, `Space` → pause/resume toggle (Goal 8 groundwork) **Modified components** - `MenuBar` — new Simulation menu with speed items mirroring panel presets - `StatusBar` — `updateSpeedIndicator()` shows `Speed: X.Xx` whenever the multiplier differs from `1.0x` - `ToolBar` — simulation-mode speed shortcut hints - `Frame` — wires `SimulationController` + `SimulationKeyBindings` into the frame lifecycle; slider change routed exclusively through the callback when wired (mirrors `applyPreset()` pattern) - `Main.kt` — updated argument parsing; `exampleGui` now accepts an optional initial speed multiplier ## Tests ~2 100 lines of new test code across: - `SimulationControlPanelTest` (434 lines) — slider, presets, callback routing - `SimulationControllerTest` (768 lines) — lifecycle, speed persistence, callback contract - `SimulationKeyBindingsTest` (419 lines) — all shortcuts, mode guards - `FrameSimulationLifecycleTest` (209 lines) — full frame lifecycle - `SimulationSpeedGoldenTest` (567 lines) — golden-output regression tests - `SimulationSpeedIntegrationTest` (486 lines) — end-to-end speed propagation - `SimulationSpeedPerformanceTest` (273 lines) — wall-clock throttling benchmarks - `SimulationControllerBridgeIntegrationTest` (133 lines) - `SimulationControllerSpeedPropagationTest` (174 lines) - `FrameSimulationLifecycleTest` flakiness fixes (deterministic latches) ## Docs - `docs/SIMULATION_SPEED_CONTROL.md` — user guide with screenshots - `CLAUDE.md` — Goal 7 architecture notes, keyboard shortcut reference - `LONG_TERM_GOALS.md` — Goal 7 marked complete
1 parent 56a45a9 commit 5a00c67

38 files changed

Lines changed: 5653 additions & 137 deletions

.github/workflows/sonarqube.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ jobs:
9898
# Requires SONAR_TOKEN and SONAR_ORGANIZATION secrets configured in GitHub
9999
# If secrets are not configured, this step will skip gracefully
100100
- name: SonarCloud Scan
101+
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
101102
env:
102103
GITHUB_ACTOR: ${{ github.actor }}
103104
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

CLAUDE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,38 @@ This project uses Gradle with Kotlin DSL. Java 21 LTS is required.
5252
./gradlew runEditor # Launch editor GUI
5353
./gradlew runExampleGui # Animated GUI simulation (Issue #268, milestone complete 2026-02-04)
5454

55+
# Goal 7 speed-control shortcuts (simulation mode only)
56+
# 1-5 -> 0.5x, 1x, 2x, 5x, 10x
57+
# +/- -> multiply or divide speed by 1.5
58+
# Space -> pause/resume toggle (Goal 8 integration point)
59+
5560
# Other tasks
5661
./gradlew javadoc # Generate documentation
5762
./gradlew dependencies # Show dependency tree
5863
```
5964

6065
For complete build system documentation including dependency management, GitHub Packages authentication, manual JAR execution, and Gradle configuration files, see **[docs/KOTLIN_STYLE_GUIDE.md](docs/KOTLIN_STYLE_GUIDE.md)** under "Build & Development Environment".
6166

67+
### Running Simulation with Speed Control
68+
69+
Use the animated GUI when you need live speed changes:
70+
71+
```bash
72+
# Built-in animated example with speed controls
73+
./gradlew runExampleGui
74+
75+
# Equivalent manual JAR launch
76+
java -jar build/libs/interlockSim.jar exampleGui shuntingLoop 300
77+
```
78+
79+
For XML files loaded from the desktop UI, use **Simulation → Start...** and then adjust speed with:
80+
81+
- the speed slider (`0.1x` to `10.0x`)
82+
- preset buttons/menu items (`0.1x`, `0.5x`, `1x`, `2x`, `5x`, `10x`, `50x`)
83+
- global keyboard shortcuts (`1`-`5`, `+`, `-`, `Space`)
84+
85+
The status bar shows `Speed: X.Xx` whenever the speed differs from `1.0x`.
86+
6287
### Directory Structure
6388

6489
- `core/` - KMP `:core` subproject (domain model, simulation engine, XML)
@@ -170,6 +195,12 @@ For complete navigation services architecture, Koin DI integration patterns, and
170195
- AnimatedSim: Real-time animated GUI simulation (Issue #268, milestone complete 2026-02-04)
171196
- Physics-accurate rendering with velocity/acceleration visualization
172197
- Visual train movement with smooth interpolation
198+
- Goal 7 speed control is built around `SimulationRunner`, which applies wall-clock throttling without changing event semantics
199+
- `SimulationController` owns lifecycle, persists the selected speed, and reapplies it on the next simulation start
200+
- `SimulationControlPanel` provides a `0.1x`-`10.0x` slider plus preset buttons up to `50x`
201+
- `StatusBar.updateSpeedIndicator()` shows the live multiplier whenever speed is not `1.0x`
202+
- `SimulationKeyBindings` installs global shortcuts (`1`-`5`, `+`, `-`, `Space`) while the frame is in simulation mode
203+
- `Space` currently toggles `SimulationRunner.isPaused` directly as Goal 8 pause-feature groundwork
173204
- See `docs/ANIMATION_ARCHITECTURE.md` for technical details
174205

175206
**XML Configuration:**

LONG_TERM_GOALS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ Users can automatically collect key performance indicators during simulation inc
209209
**Category:** I: System Operations
210210
**Priority:** Critical
211211
**Development Estimate:** 1 month
212+
**Status:** ✅ COMPLETE
212213

213214
**User Value:**
214215
Users can adjust simulation speed from slow motion (for detailed observation) to fast forward (for quick scenario completion). This provides flexibility for different use cases: slow for education, fast for research batch runs.
@@ -222,7 +223,9 @@ Users can adjust simulation speed from slow motion (for detailed observation) to
222223
**Dependencies:** None (quick win)
223224

224225
**Implementation Notes:**
225-
- kDisco already supports speed control; needs UI exposure
226+
- The simulation library interface used by the model (historically jDisco, now kDisco/KMP) runs in pure simulation time and has no native wall-clock speed control or synchronization.
227+
- Speed control for `ShuntingLoop` is implemented via the `RealTimeSynch` inner process inside `sim/ShuntingLoop.kt` (enabled by `enableRealTimeSync`, paced by `speedMultiplier`). This resides in the `sim/` package.
228+
- `SimulationRunner` provides a complementary external throttling API (`throttle()`, `awaitIfPaused()`) callable from the simulation thread; this is the designed extension point for future simulation processes that delegate pacing outside the `sim/` package.
226229
- Quick win - implement early for immediate value
227230

228231
---

core-test/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/testutil/MockSimulationContext.kt

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import cz.vutbr.fit.interlockSim.objects.core.Cell
2323
import cz.vutbr.fit.interlockSim.objects.core.Cell.Segment
2424
import cz.vutbr.fit.interlockSim.objects.core.DynamicPathSeparator
2525
import cz.vutbr.fit.interlockSim.objects.core.OrientedPathSeparator
26+
import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent
27+
import cz.vutbr.fit.interlockSim.objects.core.ContextPropertyChangeListener
2628
import cz.vutbr.fit.interlockSim.objects.core.Track
29+
import cz.vutbr.fit.interlockSim.objects.core.TrackFacility
30+
import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrack
2731
import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrackBlock
2832
import cz.vutbr.fit.interlockSim.sim.InOutWorker
2933
import org.koin.mp.KoinPlatformTools
@@ -42,6 +46,22 @@ class MockSimulationContext(
4246
private val workers: MutableMap<DynamicInOut, InOutWorker> = mutableMapOf()
4347
private val enabledReports: MutableCollection<ReportType> = mutableListOf()
4448
private var stopped: Boolean = false
49+
private var runListeners: List<ContextPropertyChangeListener> = emptyList()
50+
51+
/** Number of times [close] has been called. Used by tests to verify scope cleanup. */
52+
var closeCount: Int = 0
53+
private set
54+
55+
/**
56+
* On-demand cache for [DynamicTrack] wrappers.
57+
*
58+
* [DefaultSimulationContext.toDynamic] requires [initializeDynamicMapping] to have
59+
* been called first (normally inside [DefaultSimulationContext.run]). Tests that call
60+
* [Frame.setContext] without actually running the simulation trigger the animation
61+
* system which calls [toDynamic] before [run]. This cache creates wrappers on demand
62+
* so tests do not need to call [run] first.
63+
*/
64+
private val dynamicTrackCache: MutableMap<TrackFacility, DynamicTrack> = mutableMapOf()
4565

4666
init {
4767
// Enable all standard reports by default
@@ -68,8 +88,29 @@ class MockSimulationContext(
6888
return delegate.getInOuts()
6989
}
7090

91+
override fun addPropertyChangeListener(listener: ContextPropertyChangeListener) {
92+
runListeners = runListeners + listener
93+
delegate.addPropertyChangeListener(listener)
94+
}
95+
96+
override fun removePropertyChangeListener(listener: ContextPropertyChangeListener) {
97+
runListeners = runListeners - listener
98+
delegate.removePropertyChangeListener(listener)
99+
}
100+
71101
override fun run() {
72102
stopped = false
103+
// Fire directly from runListeners so callers waiting on addPropertyChangeListener
104+
// (e.g. FrameSimulationLifecycleTest) are notified when simulation starts.
105+
// Cannot rely on delegate.freeze() because ContextTransformer already freezes the
106+
// delegate at creation time, making freeze() a no-op here.
107+
val event = ContextChangeEvent("frozen", false, true)
108+
runListeners.forEach { it.propertyChange(event) }
109+
}
110+
111+
override fun close() {
112+
closeCount++
113+
delegate.close()
73114
}
74115

75116
override fun stop() {
@@ -139,6 +180,26 @@ class MockSimulationContext(
139180
): Boolean {
140181
return true
141182
}
183+
184+
/**
185+
* Returns a [DynamicTrack] wrapper for [track], creating one on demand if needed.
186+
*
187+
* Delegate's map is only populated after [DefaultSimulationContext.run] calls
188+
* [initializeDynamicMapping]. Tests that call [Frame.setContext] without starting
189+
* the simulation trigger the animation system before [run], so the map is empty.
190+
* This override falls back to an on-demand cache so tests remain independent of
191+
* simulation startup order.
192+
*/
193+
override fun toDynamic(track: TrackFacility): DynamicTrack {
194+
return try {
195+
delegate.toDynamic(track)
196+
} catch (_: IllegalStateException) {
197+
// Expected: delegate map is empty before initializeDynamicMapping() runs (i.e.,
198+
// before DefaultSimulationContext.run()). Create a wrapper on demand for test use.
199+
val staticKey = (track as? DynamicTrackBlock)?.staticRef as? TrackFacility ?: track
200+
dynamicTrackCache.getOrPut(staticKey) { DynamicTrack(staticKey) }
201+
}
202+
}
142203
}
143204

144205
/**

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: 13 additions & 7 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
@@ -139,7 +149,6 @@ class ShuntingLoop(
139149
private val blockTransitionsByTrain: MutableMap<String, Int> = mutableMapOf()
140150

141151
private inner class RealTimeSynch : LoopProcess() {
142-
private var presvihnuto: Double = 0.0
143152
private var beginTime: Long = 0
144153

145154
override suspend fun startAction() {
@@ -155,15 +164,12 @@ class ShuntingLoop(
155164
// Simulation termination is handled by LoopProcess.terminate() setting the flag
156165
// which is checked between iterations — platformSleep interrupt does not cause a tight loop.
157166
platformSleep(sleepTime)
158-
} else if (sleepTime < 0) {
159-
presvihnuto = sleepTime / 1000.0
160167
}
161168
}
162169

163170
override suspend fun interLoopSleep() {
164171
beginTime = currentTimeMillisKMP()
165-
hold(1 + presvihnuto)
166-
presvihnuto = 0.0
172+
hold(1.0)
167173
}
168174
}
169175

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

0 commit comments

Comments
 (0)